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,510 @@
|
||||
"""
|
||||
Settings and import-graph tests.
|
||||
|
||||
python tests\\test_settings.py
|
||||
|
||||
Three things it checks, each of which has actually broken:
|
||||
|
||||
* **`ROOT` resolves to the project root.** `settings.py` moved two levels
|
||||
deeper during the restructure, so the `.parent` count had to change. Get it
|
||||
wrong and `.env` silently does not load — every credential reads empty and
|
||||
the failure surfaces as "ECW_URL is not set", pointing at the wrong file.
|
||||
* **No selector reaches for `cfg.`** Selectors moved out of settings into
|
||||
`selectors\\`, so a leftover `cfg.SEL_…` is an AttributeError that only
|
||||
fires on the line that uses it — possibly minutes into a run, after a login
|
||||
has been spent.
|
||||
* **The import graph is acyclic**, layer by layer. The layering is the whole
|
||||
point of the package; a cycle would make it decorative.
|
||||
|
||||
And, since 2026-10-05 (review item P1), how `.env` values are read — because
|
||||
the reader once turned saving to live eCW ON without a word:
|
||||
|
||||
* **Inline comments are stripped.** `SAVE_CORRECTIONS=0 # note` used to
|
||||
read as "0 # note", which is not a yes/no word, so the setting fell back
|
||||
to its default — which was ON.
|
||||
* **An unreadable yes/no or number is a config problem**, not a silent
|
||||
fallback, so the run stops before login.
|
||||
* **Writing is off unless asked for**: SAVE_CORRECTIONS defaults to False,
|
||||
APPLY_CORRECTIONS (which only fills the form in) to True.
|
||||
|
||||
These never read the real `.env`: the loader is fed a temp file, the parsers
|
||||
read test-only keys, and the defaults are checked on a copy of settings.py
|
||||
loaded with no `.env` beside it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import pathlib
|
||||
import tempfile
|
||||
import importlib.util
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from ecw_bot import settings as cfg # noqa: E402
|
||||
|
||||
# Layer → what it is allowed to import from. A module may always import its own
|
||||
# layer and anything below it.
|
||||
ALLOWED = {
|
||||
"settings": {"selectors"},
|
||||
"selectors": set(),
|
||||
"core": {"settings"},
|
||||
# Reads the exported report off disk. Sits beside settings rather than in
|
||||
# pages\ because it touches no DOM, and above core\ because it knows what a
|
||||
# claim report is — which core deliberately does not.
|
||||
"claim_file": {"settings", "core"},
|
||||
"auth": {"settings", "selectors", "core"},
|
||||
"pages": {"settings", "selectors", "core"},
|
||||
# Detect/apply against a claim, via what pages\ already exposes — never
|
||||
# its own DOM knowledge, and never reaches into flows\ (which is what
|
||||
# calls it).
|
||||
"conditions": {"settings", "selectors", "core", "pages"},
|
||||
"flows": {"settings", "selectors", "core", "claim_file",
|
||||
"auth", "pages", "conditions"},
|
||||
}
|
||||
|
||||
|
||||
def check_root() -> list:
|
||||
bad = []
|
||||
if cfg.ROOT != ROOT:
|
||||
bad.append(f"settings.ROOT is {cfg.ROOT}, expected {ROOT}")
|
||||
if not cfg.ENV_PATH.exists():
|
||||
bad.append(f"settings.ENV_PATH does not exist: {cfg.ENV_PATH}")
|
||||
if not cfg.ECW_URL:
|
||||
bad.append("ECW_URL is empty — .env was not picked up from ROOT")
|
||||
for name in ("LOGS", "INPUTS", "SCREENSHOTS", "DOWNLOADS_TMP"):
|
||||
p = getattr(cfg, name)
|
||||
if ROOT not in p.parents and p != ROOT:
|
||||
bad.append(f"settings.{name} ({p}) is outside the project root")
|
||||
return bad
|
||||
|
||||
|
||||
def check_no_selectors_in_settings() -> list:
|
||||
leaked = [n for n in dir(cfg) if n.startswith(("SEL_", "TXT_"))]
|
||||
return [f"selectors still exported by settings: {leaked}"] if leaked else []
|
||||
|
||||
|
||||
def check_no_alias_shadowing() -> list:
|
||||
"""
|
||||
No function may bind a name that is also a module-level import alias.
|
||||
|
||||
This exists because of a real bug, and because the check that was supposed
|
||||
to catch it did not. `claim_filters.clear_balance` inherited
|
||||
`for sel in (cfg.SEL_BAL_OP1, ...)` from the pre-package code, where `sel`
|
||||
was just a loop variable. After the split, `sel` is also the selectors
|
||||
module — and binding it anywhere in a function makes it local for the
|
||||
**whole** function, so a `sel.SEL_BAL_OP1` earlier in the same function
|
||||
raises UnboundLocalError:
|
||||
|
||||
[CLAIMS] Failed: cannot access local variable 'sel'
|
||||
where it is not associated with a value
|
||||
|
||||
An AST pass for *undefined* names cannot see this — the name is bound, just
|
||||
later than it is read. Only shadowing itself is detectable, so that is what
|
||||
is checked. It is also simply a bad idea: reading `sel` in one function
|
||||
should not mean something different from reading it in the next.
|
||||
"""
|
||||
import ast
|
||||
bad = []
|
||||
pkg = ROOT / "src" / "ecw_bot"
|
||||
for path in sorted(pkg.rglob("*.py")):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
aliases = set()
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
for a in node.names:
|
||||
aliases.add((a.asname or a.name).split(".")[0])
|
||||
if not aliases:
|
||||
continue
|
||||
for fn in [n for n in ast.walk(tree)
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]:
|
||||
bound = {a.arg for a in fn.args.args} | \
|
||||
{a.arg for a in fn.args.kwonlyargs}
|
||||
for n in ast.walk(fn):
|
||||
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store):
|
||||
bound.add(n.id)
|
||||
elif isinstance(n, ast.comprehension):
|
||||
for t in ast.walk(n.target):
|
||||
if isinstance(t, ast.Name):
|
||||
bound.add(t.id)
|
||||
elif isinstance(n, ast.ExceptHandler) and n.name:
|
||||
bound.add(n.name)
|
||||
for name in sorted(bound & aliases):
|
||||
bad.append(f"{path.relative_to(pkg)}: {fn.name}() binds "
|
||||
f"{name!r}, which is an imported module alias")
|
||||
return bad
|
||||
|
||||
|
||||
def check_layers() -> list:
|
||||
import re
|
||||
bad = []
|
||||
pkg = ROOT / "src" / "ecw_bot"
|
||||
for path in sorted(pkg.rglob("*.py")):
|
||||
rel = path.relative_to(pkg)
|
||||
layer = rel.parts[0] if len(rel.parts) > 1 else rel.stem
|
||||
if layer not in ALLOWED:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
# Relative imports only — that is the convention inside the package.
|
||||
for m in re.finditer(r"^from \.\.?(\w+)", text, re.M):
|
||||
target = m.group(1)
|
||||
if target == layer or target in ALLOWED[layer]:
|
||||
continue
|
||||
if target in ALLOWED:
|
||||
bad.append(f"{rel} ({layer}) imports {target}, which is above it")
|
||||
return bad
|
||||
|
||||
|
||||
# ── .env reading (P1) ─────────────────────────────────────────────────────────
|
||||
# Every key below is test-only and unset in a real run, so the loader's
|
||||
# "real environment wins" setdefault always takes the file's value — and none
|
||||
# of this can collide with, or leak, a real setting.
|
||||
_PREFIX = "ECWBOT_TEST_"
|
||||
|
||||
|
||||
def _clear_test_keys() -> None:
|
||||
for k in [k for k in os.environ if k.startswith(_PREFIX)]:
|
||||
del os.environ[k]
|
||||
|
||||
|
||||
def _load_lines(lines: list) -> None:
|
||||
"""Run the real loader over a temp .env holding `lines`."""
|
||||
_clear_test_keys()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = pathlib.Path(tmp) / ".env"
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
cfg._load_env_file(path)
|
||||
|
||||
|
||||
def _expect(cases: dict) -> list:
|
||||
"""cases: {suffix: expected value}. Problems for every mismatch."""
|
||||
bad = []
|
||||
for suffix, want in cases.items():
|
||||
got = os.environ.get(_PREFIX + suffix)
|
||||
if got != want:
|
||||
bad.append(f"{_PREFIX}{suffix}: got {got!r}, expected {want!r}")
|
||||
return bad
|
||||
|
||||
|
||||
def check_env_inline_comments_unquoted() -> list:
|
||||
_load_lines([
|
||||
# The two lines review item P1 found.
|
||||
f"{_PREFIX}SAVE=0 # verification only",
|
||||
f"{_PREFIX}LIMIT=5 # demo",
|
||||
# A tab is whitespace too.
|
||||
f"{_PREFIX}TAB=1\t# tab before the hash",
|
||||
# '#' with no whitespace before it is part of the value: the landing
|
||||
# URL's fragment and a mailbox password both look like this.
|
||||
f"{_PREFIX}URL=https://host.example/index.jsp#/schedule",
|
||||
f"{_PREFIX}PASS=abc#123",
|
||||
# ...and the cut is at the FIRST ` #`, keeping a '#' inside the value.
|
||||
f"{_PREFIX}BOTH=abc#123 # the comment",
|
||||
# Blank value with only a comment after it is blank, not "# ...".
|
||||
f"{_PREFIX}EMPTY= # nothing yet",
|
||||
# Plain values, spaces inside, are untouched.
|
||||
f"{_PREFIX}PLAIN=Claim Dt(s)",
|
||||
])
|
||||
try:
|
||||
return _expect({
|
||||
"SAVE": "0", "LIMIT": "5", "TAB": "1",
|
||||
"URL": "https://host.example/index.jsp#/schedule",
|
||||
"PASS": "abc#123", "BOTH": "abc#123", "EMPTY": "",
|
||||
"PLAIN": "Claim Dt(s)",
|
||||
})
|
||||
finally:
|
||||
_clear_test_keys()
|
||||
|
||||
|
||||
def check_env_inline_comments_quoted() -> list:
|
||||
_load_lines([
|
||||
# Quoting is how a value keeps a ` #`.
|
||||
f'{_PREFIX}HASH="a # b"',
|
||||
f"{_PREFIX}SINGLE='a # b'",
|
||||
# Anything after the closing quote is dropped.
|
||||
f'{_PREFIX}TRAIL="abc" # note',
|
||||
f"{_PREFIX}STRAIL='abc' # note",
|
||||
# A quote inside the comment must not drag the comment in.
|
||||
f'{_PREFIX}QCOMMENT="abc" # it is "quoted"',
|
||||
# Fully quoted, nothing after: exactly as before, inner quote kept.
|
||||
f'{_PREFIX}FULL="abc"',
|
||||
f'{_PREFIX}INNER="pa"ss"',
|
||||
# ...including an inner `"#`: like an unquoted value, a '#' only
|
||||
# starts a comment after whitespace, so a password quoted end to end
|
||||
# that contains `"#` is not cut at it (review of unit A, fix round 1).
|
||||
f'{_PREFIX}INNERHASH="a"#b"',
|
||||
f'{_PREFIX}INNERHASH2="x"# y"',
|
||||
# Same-kind inner quote, then whitespace and '#': indistinguishable
|
||||
# from a trailing comment, so it IS one (documented in settings.py);
|
||||
# the other kind of quotes is how such a value is written.
|
||||
f'{_PREFIX}SAMEQ="a" #b"',
|
||||
f"""{_PREFIX}OTHERQ='a" #b'""",
|
||||
# Not quoted end to end and no whitespace before the '#': the first
|
||||
# closing quote still ends the value.
|
||||
f'{_PREFIX}NOSPACE="abc"#note',
|
||||
f'{_PREFIX}BLANKQ=""',
|
||||
# No closing quote: not a quoted value; the quote stays, as before.
|
||||
f'{_PREFIX}OPEN="abc',
|
||||
])
|
||||
try:
|
||||
return _expect({
|
||||
"HASH": "a # b", "SINGLE": "a # b", "TRAIL": "abc",
|
||||
"STRAIL": "abc", "QCOMMENT": "abc", "FULL": "abc",
|
||||
"INNER": 'pa"ss', "INNERHASH": 'a"#b', "INNERHASH2": 'x"# y',
|
||||
"SAMEQ": "a", "OTHERQ": 'a" #b',
|
||||
"NOSPACE": "abc", "BLANKQ": "", "OPEN": '"abc',
|
||||
})
|
||||
finally:
|
||||
_clear_test_keys()
|
||||
|
||||
|
||||
def check_commented_save_line_reads_as_off() -> list:
|
||||
"""The P1 incident end to end: loader, then the yes/no parser."""
|
||||
saved_bad = list(cfg._BAD_VALUES)
|
||||
_load_lines([f"{_PREFIX}SAVE=0 # verification only"])
|
||||
try:
|
||||
bad = []
|
||||
# Default True on purpose — the old SAVE_CORRECTIONS default — so a
|
||||
# comment that leaked into the value would show up as True here.
|
||||
if cfg._env_bool(_PREFIX + "SAVE", True) is not False:
|
||||
bad.append("SAVE=0 # comment did not read as False")
|
||||
if cfg._BAD_VALUES != saved_bad:
|
||||
bad.append(f"a clean commented line was recorded as bad: "
|
||||
f"{cfg._BAD_VALUES[len(saved_bad):]}")
|
||||
return bad
|
||||
finally:
|
||||
cfg._BAD_VALUES[:] = saved_bad
|
||||
_clear_test_keys()
|
||||
|
||||
|
||||
def check_bad_values_are_reported_by_validate() -> list:
|
||||
saved_bad = list(cfg._BAD_VALUES)
|
||||
_clear_test_keys()
|
||||
os.environ[_PREFIX + "FLAG"] = "O" # the letter O, not zero
|
||||
os.environ[_PREFIX + "COUNT"] = "5x"
|
||||
os.environ[_PREFIX + "NEG"] = "-1"
|
||||
os.environ[_PREFIX + "BLANK"] = ""
|
||||
os.environ[_PREFIX + "PASSWORD_FLAG"] = "hunter2"
|
||||
os.environ[_PREFIX + "OK"] = "Yes"
|
||||
try:
|
||||
bad = []
|
||||
# Unreadable: the default comes back, but the value is recorded.
|
||||
if cfg._env_bool(_PREFIX + "FLAG", True) is not True:
|
||||
bad.append("bad bool did not return the default")
|
||||
if cfg._env_int(_PREFIX + "COUNT", 1) != 1:
|
||||
bad.append("bad int did not return the default")
|
||||
cfg._env_int(_PREFIX + "NEG", 1)
|
||||
cfg._env_bool(_PREFIX + "PASSWORD_FLAG", False)
|
||||
# Blank and unset mean "use the default" — never a problem.
|
||||
cfg._env_bool(_PREFIX + "BLANK", False)
|
||||
cfg._env_int(_PREFIX + "BLANK", 7)
|
||||
cfg._env_bool(_PREFIX + "UNSET", False)
|
||||
cfg._env_int(_PREFIX + "UNSET", 7)
|
||||
# Recognised words are case-insensitive, as before.
|
||||
if cfg._env_bool(_PREFIX + "OK", False) is not True:
|
||||
bad.append("'Yes' did not read as True")
|
||||
|
||||
problems = cfg.validate()
|
||||
for want in (f"{_PREFIX}FLAG='O' is not a valid yes/no value",
|
||||
f"{_PREFIX}COUNT='5x' is not a whole number",
|
||||
f"{_PREFIX}NEG='-1' is not a whole number"):
|
||||
if not any(want in p for p in problems):
|
||||
bad.append(f"validate() did not report: {want}")
|
||||
secret = [p for p in problems if _PREFIX + "PASSWORD_FLAG" in p]
|
||||
if len(secret) != 1:
|
||||
bad.append(f"PASSWORD key reported {len(secret)} time(s), want 1")
|
||||
if any("hunter2" in p for p in problems):
|
||||
bad.append("validate() printed the value of a PASSWORD key")
|
||||
for quiet in ("BLANK", "UNSET", "OK"):
|
||||
if any(f"{_PREFIX}{quiet}" in p for p in problems):
|
||||
bad.append(f"{_PREFIX}{quiet} was reported but is fine")
|
||||
# Recorded once, however often the setting is read.
|
||||
cfg._env_bool(_PREFIX + "FLAG", True)
|
||||
if sum(1 for k, _, _ in cfg._BAD_VALUES if k == _PREFIX + "FLAG") != 1:
|
||||
bad.append("a bad value read twice was recorded twice")
|
||||
return bad
|
||||
finally:
|
||||
cfg._BAD_VALUES[:] = saved_bad
|
||||
_clear_test_keys()
|
||||
|
||||
|
||||
def check_quoted_value_with_trailing_text_is_not_guessed() -> list:
|
||||
"""
|
||||
Final review, 2026-10-05: `SAVE_CORRECTIONS="1"0` read as 1 (LIVE-WRITE)
|
||||
with nothing recorded, while the same text unquoted (`10`) stopped the
|
||||
run. A yes/no or number key with text after its closing quote is now a
|
||||
bad value; a text key keeps reading as the quoted part, as before.
|
||||
"""
|
||||
saved_bad = list(cfg._BAD_VALUES)
|
||||
saved_q = dict(cfg._QUOTE_TRAILING)
|
||||
_load_lines([
|
||||
f'{_PREFIX}QSAVE="1"0',
|
||||
f"{_PREFIX}QINT='5'x",
|
||||
f'{_PREFIX}QTEXT="abc"xyz',
|
||||
f'{_PREFIX}QCLEAN="1" # a real comment',
|
||||
])
|
||||
try:
|
||||
bad = []
|
||||
if cfg._env_bool(_PREFIX + "QSAVE", False) is not False:
|
||||
bad.append('"1"0 did not fall back to the default')
|
||||
if cfg._env_int(_PREFIX + "QINT", 1) != 1:
|
||||
bad.append("'5'x did not fall back to the default")
|
||||
if cfg._env(_PREFIX + "QTEXT") != "abc":
|
||||
bad.append("a text key no longer reads as its quoted part")
|
||||
if cfg._env_bool(_PREFIX + "QCLEAN", False) is not True:
|
||||
bad.append('"1" # comment did not read as True')
|
||||
problems = cfg.validate()
|
||||
for want in (f"""{_PREFIX}QSAVE='"1"0' is not a valid yes/no value""",
|
||||
f"""{_PREFIX}QINT="'5'x" is not a whole number"""):
|
||||
if not any(want in p for p in problems):
|
||||
bad.append(f"validate() did not report: {want}")
|
||||
if any(f"{_PREFIX}QCLEAN" in p for p in problems):
|
||||
bad.append("a quoted value with a real comment was reported")
|
||||
return bad
|
||||
finally:
|
||||
cfg._BAD_VALUES[:] = saved_bad
|
||||
cfg._QUOTE_TRAILING.clear()
|
||||
cfg._QUOTE_TRAILING.update(saved_q)
|
||||
_clear_test_keys()
|
||||
|
||||
|
||||
def _settings_without_env(overrides: dict):
|
||||
"""
|
||||
A fresh copy of settings.py, run with NO .env beside it and the given
|
||||
environment overrides (None = unset). So what comes back is the code
|
||||
defaults, not whatever the real .env on this machine says.
|
||||
|
||||
The copy is pointed at an empty temp folder through settings.py's own
|
||||
frozen-exe branch (ROOT = the executable's folder), so its loader finds
|
||||
no .env. Nothing is registered in sys.modules; the real `cfg` is untouched.
|
||||
"""
|
||||
saved_env = {k: os.environ.get(k) for k in overrides}
|
||||
had_frozen = hasattr(sys, "frozen")
|
||||
saved_frozen = getattr(sys, "frozen", None)
|
||||
saved_exe = sys.executable
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
try:
|
||||
for k, v in overrides.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
sys.frozen = True
|
||||
sys.executable = str(pathlib.Path(tmp) / "bot.exe")
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"ecw_bot._settings_probe", cfg.__file__)
|
||||
probe = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(probe)
|
||||
return probe
|
||||
finally:
|
||||
sys.executable = saved_exe
|
||||
if had_frozen:
|
||||
sys.frozen = saved_frozen
|
||||
else:
|
||||
del sys.frozen
|
||||
for k, v in saved_env.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def check_write_defaults() -> list:
|
||||
bad = []
|
||||
for label, value in (("unset", None), ("blank", "")):
|
||||
probe = _settings_without_env({"SAVE_CORRECTIONS": value,
|
||||
"APPLY_CORRECTIONS": value})
|
||||
# Proves the probe really ran away from the project's .env.
|
||||
if probe.ROOT == cfg.ROOT:
|
||||
bad.append(f"{label}: the probe used the real project root")
|
||||
# A missing or blank line must never enable writes.
|
||||
if probe.SAVE_CORRECTIONS is not False:
|
||||
bad.append(f"{label}: SAVE_CORRECTIONS defaults to "
|
||||
f"{probe.SAVE_CORRECTIONS!r}, expected False")
|
||||
# Applying only fills the form in (closed with Cancel while saving is
|
||||
# off); defaulting it off would make a run that omits it check nothing.
|
||||
if probe.APPLY_CORRECTIONS is not True:
|
||||
bad.append(f"{label}: APPLY_CORRECTIONS defaults to "
|
||||
f"{probe.APPLY_CORRECTIONS!r}, expected True")
|
||||
if any(k in ("SAVE_CORRECTIONS", "APPLY_CORRECTIONS")
|
||||
for k, _, _ in getattr(probe, "_BAD_VALUES", [])):
|
||||
bad.append(f"{label}: a {label} value was recorded as bad")
|
||||
return bad
|
||||
|
||||
|
||||
def check_env_file_keys_are_recorded() -> list:
|
||||
"""The keys the loader took from .env — not those the real environment
|
||||
already had — are recorded, so a child process can be started without
|
||||
them and read .env afresh (web/bot_runner.py, review 2026-10-14)."""
|
||||
_clear_test_keys()
|
||||
os.environ[_PREFIX + "REAL"] = "from-the-real-environment"
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = pathlib.Path(tmp) / ".env"
|
||||
path.write_text(f"{_PREFIX}REAL=file\n{_PREFIX}FILE=file\n"
|
||||
f"Ecwbot_Test_Mixed=file\n", encoding="utf-8")
|
||||
cfg._load_env_file(path)
|
||||
keys = getattr(cfg, "ENV_FILE_KEYS", set())
|
||||
bad = []
|
||||
if _PREFIX + "FILE" not in keys:
|
||||
bad.append("a key loaded from .env is not in ENV_FILE_KEYS")
|
||||
if _PREFIX + "REAL" in keys:
|
||||
bad.append("a key the real environment had is in ENV_FILE_KEYS")
|
||||
# Recorded as os.environ lists it — on Windows upper-cased — or the
|
||||
# child-env filter, which walks os.environ, misses it.
|
||||
listed = [k for k in os.environ if k.upper() == _PREFIX + "MIXED"]
|
||||
if not listed or listed[0] not in keys:
|
||||
bad.append("a mixed-case .env key is not recorded the way "
|
||||
"os.environ lists it")
|
||||
return bad
|
||||
finally:
|
||||
keys = getattr(cfg, "ENV_FILE_KEYS", set())
|
||||
for k in (_PREFIX + "FILE", _PREFIX + "REAL", _PREFIX + "MIXED",
|
||||
"Ecwbot_Test_Mixed"):
|
||||
keys.discard(k)
|
||||
_clear_test_keys()
|
||||
|
||||
|
||||
def _run(check) -> list:
|
||||
"""A check that raises is a failure of that check, not of the file."""
|
||||
try:
|
||||
return check()
|
||||
except Exception as exc:
|
||||
return [f"raised {type(exc).__name__}: {exc}"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
groups = [
|
||||
("ROOT and .env resolution", _run(check_root)),
|
||||
("no selectors in settings", _run(check_no_selectors_in_settings)),
|
||||
("no shadowed import alias", _run(check_no_alias_shadowing)),
|
||||
("import graph is layered", _run(check_layers)),
|
||||
(".env: unquoted inline comment stripped",
|
||||
_run(check_env_inline_comments_unquoted)),
|
||||
(".env: quoted value and trailing comment",
|
||||
_run(check_env_inline_comments_quoted)),
|
||||
("SAVE=0 with a comment reads as off",
|
||||
_run(check_commented_save_line_reads_as_off)),
|
||||
("bad yes/no and number reported by validate",
|
||||
_run(check_bad_values_are_reported_by_validate)),
|
||||
("quoted yes/no with trailing text is not guessed",
|
||||
_run(check_quoted_value_with_trailing_text_is_not_guessed)),
|
||||
("SAVE off / APPLY on by default", _run(check_write_defaults)),
|
||||
(".env keys recorded for a child process",
|
||||
_run(check_env_file_keys_are_recorded)),
|
||||
]
|
||||
failures = 0
|
||||
for name, problems in groups:
|
||||
if problems:
|
||||
failures += 1
|
||||
print(f" FAIL {name}")
|
||||
for p in problems:
|
||||
print(f" {p}")
|
||||
else:
|
||||
print(f" ok {name}")
|
||||
|
||||
print(f"\n{len(groups) - failures}/{len(groups)} passed")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user