""" 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())