diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 758f383..96612ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,14 +36,22 @@ jobs: run: python -m pip install -r requirements.txt - name: Audit run: python scripts/test/audit.py - - name: Build wheel - run: | - python -m pip install --upgrade setuptools wheel - python -m pip wheel . --no-deps --no-build-isolation -w dist - - name: Audit wheel contents - run: python scripts/test/p14_wheel_audit.py dist/*.whl + + windows-test: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + architecture: 'x64' + - name: Install application dependencies + run: python -m pip install -r requirements.txt + - name: Full regression audit + run: python scripts/test/audit.py windows-build: + if: startsWith(github.ref, 'refs/tags/v') runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -265,7 +273,7 @@ jobs: release: if: startsWith(github.ref, 'refs/tags/v') - needs: [test, windows-build] + needs: [test, windows-test, windows-build] runs-on: ubuntu-latest permissions: contents: write diff --git a/ooudit.py b/ooudit.py deleted file mode 100644 index 3bac49d..0000000 --- a/ooudit.py +++ /dev/null @@ -1,327 +0,0 @@ - - SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS - - Commands marked with * may be preceded by a number, _N. - Notes in parentheses indicate the behavior if _N is given. - A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K. - - h H Display this help. - q :q Q :Q ZZ Exit. - --------------------------------------------------------------------------- - - MMOOVVIINNGG - - e ^E j ^N CR * Forward one line (or _N lines). - y ^Y k ^K ^P * Backward one line (or _N lines). - ESC-j * Forward one file line (or _N file lines). - ESC-k * Backward one file line (or _N file lines). - f ^F ^V SPACE * Forward one window (or _N lines). - b ^B ESC-v * Backward one window (or _N lines). - z * Forward one window (and set window to _N). - w * Backward one window (and set window to _N). - ESC-SPACE * Forward one window, but don't stop at end-of-file. - ESC-b * Backward one window, but don't stop at beginning-of-file. - d ^D * Forward one half-window (and set half-window to _N). - u ^U * Backward one half-window (and set half-window to _N). - ESC-) RightArrow * Right one half screen width (or _N positions). - ESC-( LeftArrow * Left one half screen width (or _N positions). - ESC-} ^RightArrow Right to last column displayed. - ESC-{ ^LeftArrow Left to first column. - F Forward forever; like "tail -f". - ESC-F Like F but stop when search pattern is found. - ESC-f Like F but ring the bell when search pattern is found. - r ^R ^L Repaint screen. - R Repaint screen, discarding buffered input. - --------------------------------------------------- - Default "window" is the screen height. - Default "half-window" is half of the screen height. - --------------------------------------------------------------------------- - - SSEEAARRCCHHIINNGG - - /_p_a_t_t_e_r_n * Search forward for (_N-th) matching line. - ?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line. - n * Repeat previous search (for _N-th occurrence). - N * Repeat previous search in reverse direction. - ESC-n * Repeat previous search, spanning files. - ESC-N * Repeat previous search, reverse dir. & spanning files. - ^O^N ^On * Search forward for (_N-th) OSC8 hyperlink. - ^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink. - ^O^L ^Ol Jump to the currently selected OSC8 hyperlink. - ESC-u Undo (toggle) search highlighting. - ESC-U Clear search highlighting. - &_p_a_t_t_e_r_n * Display only matching lines. - --------------------------------------------------- - Search is case-sensitive unless changed with -i or -I. - A search pattern may begin with one or more of: - ^N or ! Search for NON-matching lines. - ^E or * Search multiple files (pass thru END OF FILE). - ^F or @ Start search at FIRST file (for /) or last file (for ?). - ^K Highlight matches, but don't move (KEEP position). - ^R Don't use REGULAR EXPRESSIONS. - ^S _n Search for match in _n-th parenthesized subpattern. - ^W WRAP search if no match found. - ^L Enter next character literally into pattern. - --------------------------------------------------------------------------- - - JJUUMMPPIINNGG - - g < ESC-< HOME * Go to first line in file (or line _N). - G > ESC-> END * Go to last line in file (or line _N). - p % * Go to beginning of file (or _N percent into file). - t * Go to the (_N-th) next tag. - T * Go to the (_N-th) previous tag. - { ( [ * Find close bracket } ) ]. - } ) ] * Find open bracket { ( [. - ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>. - ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>. - --------------------------------------------------- - Each "find close bracket" command goes forward to the close bracket - matching the (_N-th) open bracket in the top line. - Each "find open bracket" command goes backward to the open bracket - matching the (_N-th) close bracket in the bottom line. - - m_<_l_e_t_t_e_r_> Mark the current top line with . - M_<_l_e_t_t_e_r_> Mark the current bottom line with . - '_<_l_e_t_t_e_r_> Go to a previously marked position. - '' Go to the previous position. - ^X^X Same as '. - ESC-m_<_l_e_t_t_e_r_> Clear a mark. - --------------------------------------------------- - A mark is any upper-case or lower-case letter. - Certain marks are predefined: - ^ means beginning of the file - $ means end of the file - --------------------------------------------------------------------------- - - CCHHAANNGGIINNGG FFIILLEESS - - :e [_f_i_l_e] Examine a new file. - ^X^V Same as :e. - :n * Examine the (_N-th) next file from the command line. - :p * Examine the (_N-th) previous file from the command line. - :x * Examine the first (or _N-th) file from the command line. - ^O^O Open the currently selected OSC8 hyperlink. - :d Delete the current file from the command line list. - = ^G :f Print current file name. - --------------------------------------------------------------------------- - - MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS - - -_<_f_l_a_g_> Toggle a command line option [see OPTIONS below]. - --_<_n_a_m_e_> Toggle a command line option, by name. - __<_f_l_a_g_> Display the setting of a command line option. - ___<_n_a_m_e_> Display the setting of an option, by name. - +_c_m_d Execute the less cmd each time a new file is examined. - - !_c_o_m_m_a_n_d Execute the shell command with $SHELL. - #_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt. - |XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command. - s _f_i_l_e Save input to a file. - v Edit the current file with $VISUAL or $EDITOR. - V Print version number of "less". - --------------------------------------------------------------------------- - - OOPPTTIIOONNSS - - Most options may be changed either on the command line, - or from within less by using the - or -- command. - Options may be given in one of two forms: either a single - character preceded by a -, or a name preceded by --. - - -? ........ --help - Display help (from command line). - -a ........ --search-skip-screen - Search skips current screen. - -A ........ --SEARCH-SKIP-SCREEN - Search starts just after target line. - -b [_N] .... --buffers=[_N] - Number of buffers. - -B ........ --auto-buffers - Don't automatically allocate buffers for pipes. - -c ........ --clear-screen - Repaint by clearing rather than scrolling. - -d ........ --dumb - Dumb terminal. - -D xx_c_o_l_o_r . --color=xx_c_o_l_o_r - Set screen colors. - -e -E .... --quit-at-eof --QUIT-AT-EOF - Quit at end of file. - -f ........ --force - Force open non-regular files. - -F ........ --quit-if-one-screen - Quit if entire file fits on first screen. - -g ........ --hilite-search - Highlight only last match for searches. - -G ........ --HILITE-SEARCH - Don't highlight any matches for searches. - -h [_N] .... --max-back-scroll=[_N] - Backward scroll limit. - -i ........ --ignore-case - Ignore case in searches that do not contain uppercase. - -I ........ --IGNORE-CASE - Ignore case in all searches. - -j [_N] .... --jump-target=[_N] - Screen position of target lines. - -J ........ --status-column - Display a status column at left edge of screen. - -k _f_i_l_e ... --lesskey-file=_f_i_l_e - Use a compiled lesskey file. - -K ........ --quit-on-intr - Exit less in response to ctrl-C. - -L ........ --no-lessopen - Ignore the LESSOPEN environment variable. - -m -M .... --long-prompt --LONG-PROMPT - Set prompt style. - -n ......... --line-numbers - Suppress line numbers in prompts and messages. - -N ......... --LINE-NUMBERS - Display line number at start of each line. - -o [_f_i_l_e] .. --log-file=[_f_i_l_e] - Copy to log file (standard input only). - -O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e] - Copy to log file (unconditionally overwrite). - -p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n] - Start at pattern (from command line). - -P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t] - Define new prompt. - -q -Q .... --quiet --QUIET --silent --SILENT - Quiet the terminal bell. - -r -R .... --raw-control-chars --RAW-CONTROL-CHARS - Output "raw" control characters. - -s ........ --squeeze-blank-lines - Squeeze multiple blank lines. - -S ........ --chop-long-lines - Chop (truncate) long lines rather than wrapping. - -t _t_a_g .... --tag=[_t_a_g] - Find a tag. - -T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e] - Use an alternate tags file. - -u -U .... --underline-special --UNDERLINE-SPECIAL - Change handling of backspaces, tabs and carriage returns. - -V ........ --version - Display the version number of "less". - -w ........ --hilite-unread - Highlight first new line after forward-screen. - -W ........ --HILITE-UNREAD - Highlight first new line after any forward movement. - -x [_N[,...]] --tabs=[_N[,...]] - Set tab stops. - -X ........ --no-init - Don't use termcap init/deinit strings. - -y [_N] .... --max-forw-scroll=[_N] - Forward scroll limit. - -z [_N] .... --window=[_N] - Set size of window. - -" [_c[_c]] . --quotes=[_c[_c]] - Set shell quote characters. - -~ ........ --tilde - Don't display tildes after end of file. - -# [_N] .... --shift=[_N] - Set horizontal scroll amount (0 = one half screen width). - - --autosave=[_m_/_!_*] - Actions which cause the history file to be saved. - --exit-follow-on-close - Exit F command on a pipe when writer closes pipe. - --file-size - Automatically determine the size of the input file. - --follow-name - The F command changes files if the input file is renamed. - --form-feed - Stop scrolling when a form feed character is reached. - --header=[_L[,_C[,_N]]] - Use _L lines (starting at line _N) and _C columns as headers. - --incsearch - Search file as each pattern character is typed in. - --intr=[_C] - Use _C instead of ^X to interrupt a read. - --lesskey-context=_t_e_x_t - Use lesskey source file contents. - --lesskey-src=_f_i_l_e - Use a lesskey source file. - --line-num-width=[_N] - Set the width of the -N line number field to _N characters. - --match-shift=[_N] - Show at least _N characters to the left of a search match. - --modelines=[_N] - Read _N lines from the input file and look for vim modelines. - --mouse - Enable mouse input. - --no-edit-warn - Don't warn when using v command on a file opened via LESSOPEN. - --no-keypad - Don't send termcap keypad init/deinit strings. - --no-histdups - Remove duplicates from command history. - --no-number-headers - Don't give line numbers to header lines. - --no-paste - Ignore pasted input. - --no-search-header-lines - Searches do not include header lines. - --no-search-header-columns - Searches do not include header columns. - --no-search-headers - Searches do not include header lines or columns. - --no-vbell - Disable the terminal's visual bell. - --redraw-on-quit - Redraw final screen when quitting. - --rscroll=[_C] - Set the character used to mark truncated lines. - --save-marks - Retain marks across invocations of less. - --search-options=[EFKNRW-] - Set default options for every search. - --show-preproc-errors - Display a message if preprocessor exits with an error status. - --proc-backspace - Process backspaces for bold/underline. - --PROC-BACKSPACE - Treat backspaces as control characters. - --proc-return - Delete carriage returns before newline. - --PROC-RETURN - Treat carriage returns as control characters. - --proc-tab - Expand tabs to spaces. - --PROC-TAB - Treat tabs as control characters. - --status-col-width=[_N] - Set the width of the -J status column to _N characters. - --status-line - Highlight or color the entire line containing a mark. - --use-backslash - Subsequent options use backslash as escape char. - --use-color - Enables colored text. - --wheel-lines=[_N] - Each click of the mouse wheel moves _N lines. - --wordwrap - Wrap lines at spaces. - - - --------------------------------------------------------------------------- - - LLIINNEE EEDDIITTIINNGG - - These keys can be used to edit text being entered - on the "command line" at the bottom of the screen. - - RightArrow ..................... ESC-l ... Move cursor right one character. - LeftArrow ...................... ESC-h ... Move cursor left one character. - ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word. - ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word. - HOME ........................... ESC-0 ... Move cursor to start of line. - END ............................ ESC-$ ... Move cursor to end of line. - BACKSPACE ................................ Delete char to left of cursor. - DELETE ......................... ESC-x ... Delete char under cursor. - ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor. - ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor. - ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line. - UpArrow ........................ ESC-k ... Retrieve previous command line. - DownArrow ...................... ESC-j ... Retrieve next command line. - TAB ...................................... Complete filename & cycle. - SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle. - ctrl-L ................................... Complete filename, list all. diff --git a/scripts/test/audit.py b/scripts/test/audit.py index a0f8be2..85ea624 100644 --- a/scripts/test/audit.py +++ b/scripts/test/audit.py @@ -1,18 +1,51 @@ from __future__ import annotations -import compileall +import shutil import subprocess import sys from pathlib import Path + ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -print("== Invio syntax audit ==") -if not compileall.compile_dir(ROOT / "src", quiet=1) or not compileall.compile_file(ROOT / "main.py", quiet=1): - raise SystemExit("Syntax audit failed") -print("PASS") + +def _purge_repository_bytecode() -> int: + """Remove ignored Python bytecode that can shadow current source on timestamp collisions.""" + removed = 0 + roots = [ROOT / "src", ROOT / "tests", ROOT / "scripts", ROOT / "providers"] + root_cache = ROOT / "__pycache__" + if root_cache.is_dir(): + shutil.rmtree(root_cache) + removed += 1 + for tree in roots: + if not tree.exists(): + continue + for cache_dir in sorted(tree.rglob("__pycache__"), reverse=True): + if cache_dir.is_dir(): + shutil.rmtree(cache_dir) + removed += 1 + return removed + + +# Purge before importing any Invio/repository module. Workspace ZIPs can carry +# ignored timestamp-based .pyc files whose metadata still matches extracted +# source files on Windows, causing stale bytecode to override source truth. +_purged_bytecode_dirs = _purge_repository_bytecode() + +from scripts.test.repository_syntax import RepositorySyntaxError, assert_repository_python_syntax + + +print("== Invio bytecode hygiene ==") +print(f"PASS ({_purged_bytecode_dirs} generated __pycache__ directorie(s) purged)") + +print("\n== Invio syntax audit ==") +try: + checked = assert_repository_python_syntax(ROOT) +except RepositorySyntaxError as exc: + raise SystemExit(str(exc)) from exc +print(f"PASS ({checked} repository Python files)") print("\n== Invio unit tests ==") subprocess.run([sys.executable, "-m", "unittest", "discover", "-s", "tests", "-v"], cwd=ROOT, check=True) diff --git a/scripts/test/repository_syntax.py b/scripts/test/repository_syntax.py new file mode 100644 index 0000000..d143b12 --- /dev/null +++ b/scripts/test/repository_syntax.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import subprocess +import tokenize +from pathlib import Path + + +class RepositorySyntaxError(RuntimeError): + """Raised when one or more repository Python sources are not syntactically valid.""" + + +def repository_python_files(root: Path) -> tuple[Path, ...]: + """Return auditable Python sources in deterministic repository order. + + The candidate set includes Git-tracked files plus untracked files that are not + ignored by the repository. This keeps pre-commit verification honest: newly + added source/test files are audited before ``git add``, while ignored private, + generated, and bytecode artifacts stay outside the source gate. + """ + root = Path(root).resolve() + try: + completed = subprocess.run( + [ + "git", + "-C", + str(root), + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + "--", + "*.py", + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except (OSError, subprocess.CalledProcessError) as exc: + detail = "" + if isinstance(exc, subprocess.CalledProcessError) and exc.stderr: + detail = exc.stderr.decode("utf-8", errors="replace").strip() + suffix = f": {detail}" if detail else "" + raise RepositorySyntaxError(f"Unable to enumerate repository Python files{suffix}") from exc + + paths: list[Path] = [] + for raw_path in completed.stdout.split(b"\0"): + if not raw_path: + continue + relative = Path(raw_path.decode("utf-8", errors="strict")) + path = root / relative + # Pending deletions are intentionally absent from the candidate worktree. + if path.is_file(): + paths.append(path) + return tuple(sorted(paths, key=lambda path: path.relative_to(root).as_posix())) + + +def syntax_failures(root: Path) -> tuple[str, ...]: + """Compile repository Python source text without importing or executing modules.""" + root = Path(root).resolve() + failures: list[str] = [] + for path in repository_python_files(root): + relative = path.relative_to(root).as_posix() + try: + with tokenize.open(path) as handle: + source = handle.read() + compile(source, relative, "exec", dont_inherit=True) + except (SyntaxError, UnicodeError) as exc: + if isinstance(exc, SyntaxError): + location = f"line {exc.lineno}" if exc.lineno else "unknown line" + message = exc.msg or exc.__class__.__name__ + else: + location = "source decoding" + message = str(exc) + failures.append(f"{relative}: {message} ({location})") + return tuple(failures) + + +def assert_repository_python_syntax(root: Path) -> int: + """Fail closed on invalid repository Python and return the number checked.""" + files = repository_python_files(root) + failures = syntax_failures(root) + if failures: + formatted = "\n".join(f"- {failure}" for failure in failures) + raise RepositorySyntaxError(f"Repository Python syntax audit failed:\n{formatted}") + return len(files) + + +# Compatibility aliases retained for the already-issued Phase-01 delta helper/tests. +def tracked_python_files(root: Path) -> tuple[Path, ...]: + return repository_python_files(root) + + +def assert_tracked_python_syntax(root: Path) -> int: + return assert_repository_python_syntax(root) diff --git a/tests/test_p14_distribution_pipeline.py b/tests/test_p14_distribution_pipeline.py index 6a323be..ac51f77 100644 --- a/tests/test_p14_distribution_pipeline.py +++ b/tests/test_p14_distribution_pipeline.py @@ -196,6 +196,34 @@ def test_github_workflow_builds_wheel_nuitka_onedir_wix_msi_and_tag_release(self self.assertIn("-pdbtype none", workflow) + def test_update_fix_ci_is_source_only_and_release_build_is_tag_gated(self): + workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + linux_test = workflow.split("\n test:\n", 1)[1].split("\n windows-test:\n", 1)[0] + windows_test = workflow.split("\n windows-test:\n", 1)[1].split("\n windows-build:\n", 1)[0] + windows_build = workflow.split("\n windows-build:\n", 1)[1].split("\n release:\n", 1)[0] + release = workflow.split("\n release:\n", 1)[1] + + forbidden_update_fix_fragments = ( + "pip wheel", + "Nuitka", + "wix build", + "msiexec", + "prepare_windows_distribution.py", + "finalize_release_checksums.py", + "actions/upload-artifact", + "gh release", + ) + for job_name, block in (("test", linux_test), ("windows-test", windows_test)): + with self.subTest(job=job_name): + self.assertIn("python scripts/test/audit.py", block) + for fragment in forbidden_update_fix_fragments: + self.assertNotIn(fragment, block) + + self.assertIn("if: startsWith(github.ref, 'refs/tags/v')", windows_build) + self.assertIn("if: startsWith(github.ref, 'refs/tags/v')", release) + self.assertIn("needs: [test, windows-test, windows-build]", release) + + def test_compiled_keyring_contract_covers_code_metadata_and_real_credential_round_trip(self): workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") app_source = (ROOT / "src" / "app.py").read_text(encoding="utf-8") diff --git a/tests/test_repository_syntax.py b/tests/test_repository_syntax.py new file mode 100644 index 0000000..6d9b129 --- /dev/null +++ b/tests/test_repository_syntax.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import os +import py_compile +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from scripts.test.repository_syntax import ( + RepositorySyntaxError, + assert_repository_python_syntax, + repository_python_files, +) + + +ROOT = Path(__file__).resolve().parents[1] + + +def _git(root: Path, *args: str) -> None: + subprocess.run( + ["git", "-C", str(root), *args], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +class RepositorySyntaxAuditTests(unittest.TestCase): + def _repo(self) -> tempfile.TemporaryDirectory[str]: + temp = tempfile.TemporaryDirectory() + _git(Path(temp.name), "init", "-q") + return temp + + def test_accidental_ooudit_artifact_is_absent(self): + self.assertFalse((ROOT / "ooudit.py").exists()) + audited = {path.relative_to(ROOT).as_posix() for path in repository_python_files(ROOT)} + self.assertNotIn("ooudit.py", audited) + + def test_valid_tracked_root_and_nested_python_compile_without_execution(self): + with self._repo() as temp: + root = Path(temp) + (root / "root_file.py").write_text("raise RuntimeError('must not execute')\n", encoding="utf-8") + nested = root / "nested" / "module.py" + nested.parent.mkdir() + nested.write_text("VALUE = 1\n", encoding="utf-8") + _git(root, "add", "root_file.py", "nested/module.py") + + checked = assert_repository_python_syntax(root) + + self.assertEqual(checked, 2) + + def test_malformed_tracked_python_fails_closed(self): + with self._repo() as temp: + root = Path(temp) + bad = root / "bad.py" + bad.write_text("def broken(:\n pass\n", encoding="utf-8") + _git(root, "add", "bad.py") + + with self.assertRaisesRegex(RepositorySyntaxError, r"bad\.py"): + assert_repository_python_syntax(root) + + def test_malformed_untracked_unignored_python_fails_before_git_add(self): + with self._repo() as temp: + root = Path(temp) + bad = root / "candidate_bad.py" + bad.write_text("def broken(:\n", encoding="utf-8") + + with self.assertRaisesRegex(RepositorySyntaxError, r"candidate_bad\.py"): + assert_repository_python_syntax(root) + + def test_malformed_ignored_python_is_outside_repository_source_gate(self): + with self._repo() as temp: + root = Path(temp) + (root / ".gitignore").write_text("generated/\n", encoding="utf-8") + good = root / "good.py" + good.write_text("VALUE = 1\n", encoding="utf-8") + ignored = root / "generated" / "generated_bad.py" + ignored.parent.mkdir() + ignored.write_text("def broken(:\n", encoding="utf-8") + _git(root, "add", ".gitignore", "good.py") + + checked = assert_repository_python_syntax(root) + + self.assertEqual(checked, 1) + + def test_purging_timestamp_valid_stale_local_bytecode_restores_source_truth(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + module = root / "cache_probe.py" + module.write_text("VALUE = 'old'\n", encoding="utf-8") + fixed_time = 1_700_000_000 + os.utime(module, (fixed_time, fixed_time)) + py_compile.compile(str(module), doraise=True) + + # Same byte length + same timestamp keeps the local .pyc apparently valid. + module.write_text("VALUE = 'new'\n", encoding="utf-8") + os.utime(module, (fixed_time, fixed_time)) + + command = [ + sys.executable, + "-c", + "import cache_probe; print(cache_probe.VALUE)", + ] + stale = subprocess.run( + command, + cwd=root, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.assertEqual(stale.stdout.strip(), "old") + + cache_dir = root / "__pycache__" + self.assertTrue(cache_dir.is_dir()) + import shutil + shutil.rmtree(cache_dir) + + fresh = subprocess.run( + command, + cwd=root, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.assertEqual(fresh.stdout.strip(), "new") + + def test_audit_entrypoint_purges_repo_bytecode_before_repository_import(self): + source = (ROOT / "scripts" / "test" / "audit.py").read_text(encoding="utf-8") + purge_index = source.index("_purged_bytecode_dirs = _purge_repository_bytecode()") + import_index = source.index("from scripts.test.repository_syntax import") + self.assertLess(purge_index, import_index) + self.assertIn('rglob("__pycache__")', source)