''' Operator backfill-audit helper regressions. ''' from hashlib import sha256 import json from pathlib import Path import runpy import subprocess import numpy as np import polars as pl import pytest from piker.storage._audit import audit_ohlcv_parquet def load_helpers() -> dict: ''' Load the sourceable xonsh file as its pure-Python namespace. ''' path = ( Path(__file__).parents[1] /'snippets' /'nativedb_backfill_audit.xsh' ) return runpy.run_path(path) def mk_frame( times: tuple[int, ...], closes: tuple[float, ...]|None = None, ) -> pl.DataFrame: ''' Build canonical history for evidence comparisons. ''' size: int = len(times) closes = closes or tuple(float(value) for value in times) return pl.DataFrame({ 'index': pl.Series(np.arange(size), dtype=pl.Int64), 'time': pl.Series(times, dtype=pl.Int64), 'open': pl.Series(np.arange(size), dtype=pl.Float64), 'high': pl.Series(np.arange(size) + 1, dtype=pl.Float64), 'low': pl.Series(np.arange(size), dtype=pl.Float64), 'close': pl.Series(closes, dtype=pl.Float64), 'volume': pl.Series(np.arange(size), dtype=pl.Float64), }) def init_case( helpers: dict, run_root: Path, ) -> None: ''' Initialize one fixed synthetic qualification case. ''' helpers['bfq_init'](run_root, validate_piker=False) helpers['bfq_record_case']( run_root, fqme='x.test', period_s=60, provider='fake', symptom='synthetic lifecycle regression', ) def write_phase_evidence( run_root: Path, phase: str, path: Path, fqme: str = 'x.test', ) -> None: ''' Write checksum-bound report and manual metadata for a snapshot. ''' report: dict = audit_ohlcv_parquet( path, fqme=fqme, period_s=60, ) evidence: Path = run_root / 'evidence' report_path: Path = evidence / f'{phase}.json' report_path.write_text( json.dumps(report, indent=2, sort_keys=True) + '\n' ) manual: dict = { 'phase': phase, 'run_root': str(run_root.resolve()), 'snapshot_path': path.name, 'report_sha256': sha256( report_path.read_bytes() ).hexdigest(), } (evidence / f'{phase}.manual.json').write_text( json.dumps(manual, indent=2, sort_keys=True) + '\n' ) def test_disposable_seed_is_archived_before_fresh( tmp_path: Path, ) -> None: ''' Fresh qualification must never remove the operator's source file. The workflow starts from known-bad production evidence but needs an empty disposable NativeDB for a full provider backfill. Initialize a marked root, seed by copy, then clear for fresh and prove both the original and evidence copy survive while only the disposable active path is moved into the evidence directory. ''' helpers: dict = load_helpers() run_root: Path = tmp_path / 'run' source: Path = tmp_path / 'known-bad.parquet' mk_frame((60, 120)).write_parquet(source) helpers['bfq_init'](run_root, validate_piker=False) active: Path = helpers['bfq_seed']( run_root, 'x.test', 60, source, ) archived: Path = helpers['bfq_clear_for_fresh']( run_root, 'x.test', 60, ) assert source.is_file() assert (run_root / 'evidence' / 'seed.parquet').is_file() assert archived.is_file() assert not active.exists() def test_seed_active_copy_comes_from_captured_evidence( tmp_path: Path, ) -> None: ''' Source replacement must not split baseline and replay bytes. Production writers replace Parquet atomically, so copying the source twice can capture one generation as evidence and replay another. Replace the source after the first copy, then prove the the active disposable file still derives from the captured seed rather than reopening the changed source. ''' helpers: dict = load_helpers() run_root: Path = tmp_path / 'run' source: Path = tmp_path / 'source.parquet' original: bytes mk_frame((60, 120)).write_parquet(source) original = source.read_bytes() helpers['bfq_init'](run_root, validate_piker=False) real_copy = helpers['_bfq_copy_exclusive'] copied: int = 0 def replace_after_capture( src: Path, dst: Path, mode: int|None = None, ) -> None: nonlocal copied real_copy(src, dst, mode=mode) copied += 1 if copied == 1: mk_frame((60, 180)).write_parquet(source) helpers['bfq_seed'].__globals__[ '_bfq_copy_exclusive' ] = replace_after_capture active: Path = helpers['bfq_seed']( run_root, 'x.test', 60, source, ) assert source.read_bytes() != original assert active.read_bytes() == original assert ( run_root / 'evidence' / 'seed.parquet' ).read_bytes() == original def test_seed_copy_failure_removes_partial_active_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: ''' Failed replay copy must not leave an active partial Parquet. Seed evidence can complete before the disposable active copy runs out of space. A partial active path blocks retry and lets a chart mistake truncated bytes for history. Fail the second copy after a short write and prove active storage is removed while the completed seed remains exact. ''' helpers: dict = load_helpers() run_root: Path = tmp_path / 'run' source: Path = tmp_path / 'source.parquet' mk_frame((60, 120)).write_parquet(source) before: bytes = source.read_bytes() helpers['bfq_init'](run_root, validate_piker=False) real_copy = helpers['shutil'].copyfileobj copies: int = 0 def fail_second(source_file, destination_file) -> None: nonlocal copies copies += 1 if copies == 2: destination_file.write(source_file.read(10)) raise OSError('simulated full filesystem') real_copy(source_file, destination_file) monkeypatch.setattr( helpers['shutil'], 'copyfileobj', fail_second, ) with pytest.raises(OSError, match='full filesystem'): helpers['bfq_seed']( run_root, 'x.test', 60, source, ) active: Path = ( run_root /'xdg/piker/nativedb/x.test.ohlcv60s.parquet' ) assert not active.exists() assert ( run_root / 'evidence' / 'seed.parquet' ).read_bytes() == before def test_helpers_reject_paths_outside_marked_root( tmp_path: Path, ) -> None: ''' A marker must not authorize traversal or symlinked paths. FQME text becomes part of the active Parquet filename and fresh reset moves that path. Prove parent traversal is rejected, then replace the evidence directory with a symlink and prove path construction refuses to cross the disposable-root boundary. ''' helpers: dict = load_helpers() run_root: Path = tmp_path / 'run' helpers['bfq_init'](run_root, validate_piker=False) with pytest.raises(ValueError, match='Unsafe FQME'): helpers['_bfq_paths']( run_root, '../../outside', 60, ) evidence: Path = run_root / 'evidence' outside: Path = tmp_path / 'outside' outside.mkdir() evidence.rmdir() evidence.symlink_to(outside, target_is_directory=True) with pytest.raises(RuntimeError, match='symlinked path'): helpers['bfq_init']( run_root, validate_piker=False, ) with pytest.raises(RuntimeError, match='escaped run root'): helpers['_bfq_paths'](run_root, 'x.test', 60) with pytest.raises(RuntimeError, match='Unsafe evidence'): helpers['bfq_compare'](run_root, 'before', 'after') def test_helpers_reject_dangling_evidence_symlinks( tmp_path: Path, ) -> None: ''' Final evidence names must not follow dangling external symlinks. ``Path.exists()`` is false for a dangling symlink, which can make a check-then-write path create its external target. Link the case record and seed outside a marked root. Prove both writers reject the final component before creating external bytes. ''' helpers: dict = load_helpers() run_root: Path = tmp_path / 'run' helpers['bfq_init'](run_root, validate_piker=False) outside_case: Path = tmp_path / 'outside-case.json' case_path: Path = run_root / 'case.json' case_path.symlink_to(outside_case) with pytest.raises(RuntimeError, match='symlinked case'): helpers['bfq_record_case']( run_root, fqme='x.test', period_s=60, provider='fake', symptom='unsafe path test', ) assert not outside_case.exists() case_path.unlink() source: Path = tmp_path / 'source.parquet' mk_frame((60, 120)).write_parquet(source) outside_seed: Path = tmp_path / 'outside-seed.parquet' seed: Path = run_root / 'evidence' / 'seed.parquet' seed.symlink_to(outside_seed) with pytest.raises(RuntimeError, match='symlinked output'): helpers['bfq_seed']( run_root, 'x.test', 60, source, ) assert not outside_seed.exists() def test_bfq_use_updates_xonsh_subprocess_environment( tmp_path: Path, ) -> None: ''' Disposable XDG selection must reach commands launched by xonsh. Updating only ``os.environ`` does not mutate xonsh's environment mapping, so manual chart commands previously retained normal user storage. Source the real snippet in xonsh, initialize a run root, and assert a subprocess sees the disposable XDG path. ''' repo: Path = Path(__file__).parents[1] snippet: Path = repo / 'snippets' / 'nativedb_backfill_audit.xsh' run_root: Path = tmp_path / 'run' code: str = ( f'source {snippet}; ' f'bfq_init(p\'{run_root}\', validate_piker=False); ' 'env' ) result = subprocess.run( ['xonsh', '-c', code], check=False, capture_output=True, cwd=repo, text=True, ) assert result.returncode == 0, result.stderr expected: str = f'XDG_CONFIG_HOME={run_root / "xdg"}' assert result.stdout.count(expected) == 2 def test_init_validates_console_script_against_worktree( tmp_path: Path, ) -> None: ''' An installed console script must import the reviewed worktree. A shared virtualenv's ``piker`` entry point can resolve its editable root checkout, where a new audit command is absent, even while the operator is in this linked worktree. Run initialization and prove its PYTHONPATH-bound preflight sees the local command before it records the executable and repository identity. ''' helpers: dict = load_helpers() run_root: Path = tmp_path / 'run' helpers['bfq_init'](run_root) marker: dict = json.loads( (run_root / '.piker-backfill-qualification').read_text() ) assert Path(marker['repo_root']) == Path(__file__).parents[1] def test_phase_compare_detects_preservation_and_seam_changes( tmp_path: Path, ) -> None: ''' Lifecycle comparison must preserve data without hiding seams. Restart and append may replace a matching provider seam while old timestamps remain. Save two read-only snapshots where the second appends a row and updates a close. Prove checks pass while the the changed timestamp stays explicit operator evidence. ''' helpers: dict = load_helpers() run_root: Path = tmp_path / 'run' init_case(helpers, run_root) evidence: Path = run_root / 'evidence' before_path: Path = evidence / 'fresh.parquet' after_path: Path = evidence / 'restart.parquet' mk_frame((60, 120), (1, 2)).write_parquet(before_path) mk_frame((60, 120, 180), (1, 20, 3)).write_parquet(after_path) for phase, path in ( ('fresh', before_path), ('restart', after_path), ): write_phase_evidence(run_root, phase, path) comparison: dict = helpers['bfq_compare']( run_root, 'fresh', 'restart', require_newer=True, ) assert comparison['passed'] is True assert comparison['missing_timestamp_count'] == 0 assert comparison['changed_common_row_count'] == 1 assert comparison['changed_common_timestamps'] == [120] assert comparison['checks']['maximum_advanced'] is True with pytest.raises(ValueError, match='phases must differ'): helpers['bfq_compare'](run_root, 'fresh', 'fresh') def test_repair_compare_allows_invalid_and_duplicate_removal( tmp_path: Path, ) -> None: ''' Repair may remove invalid rows but must preserve valid times. Known-bad history may contain zero-epoch and duplicate rows. Both should disappear during canonical rewrite. Compare that baseline to clean extended history and prove preservation ignores invalid epoch and duplicate multiplicity while recording their before and after counts. ''' helpers: dict = load_helpers() run_root: Path = tmp_path / 'run' init_case(helpers, run_root) evidence: Path = run_root / 'evidence' before_path: Path = evidence / 'baseline.parquet' after_path: Path = evidence / 'repair.parquet' mk_frame((0, 60, 60, 120)).write_parquet(before_path) mk_frame((60, 120, 180)).write_parquet(after_path) for phase, path in ( ('baseline', before_path), ('repair', after_path), ): write_phase_evidence(run_root, phase, path) comparison: dict = helpers['bfq_compare']( run_root, 'baseline', 'repair', require_newer=True, ) assert comparison['passed'] is True assert comparison['missing_timestamp_count'] == 0 assert comparison['before_duplicate_excess'] == 1 assert comparison['after_duplicate_excess'] == 0 def test_malformed_baseline_produces_failed_comparison_evidence( tmp_path: Path, ) -> None: ''' Comparison must report unauditable baselines instead of crashing. Raw audit accepts malformed Parquet so humans can save evidence before repair. Write a baseline with textual timestamps and no close column, compare it with canonical history, and prove the helper writes limitations plus failed checks before raising its qualification assertion. ''' helpers: dict = load_helpers() run_root: Path = tmp_path / 'run' init_case(helpers, run_root) evidence: Path = run_root / 'evidence' before_path: Path = evidence / 'broken.parquet' after_path: Path = evidence / 'repair.parquet' mk_frame((60, 120)).drop('close').with_columns( pl.Series('time', ['60', '120']) ).write_parquet(before_path) mk_frame((60, 120, 180)).write_parquet(after_path) for phase, path in ( ('broken', before_path), ('repair', after_path), ): write_phase_evidence(run_root, phase, path) with pytest.raises(AssertionError): helpers['bfq_compare']( run_root, 'broken', 'repair', ) comparison: dict = json.loads( ( evidence /'compare-broken-to-repair.json' ).read_text() ) assert comparison['passed'] is False assert comparison['checks']['no_timestamps_lost'] is False assert 'before_time_nonnumeric' in comparison['limitations'] assert ( 'common_value_comparison_unavailable' in comparison['limitations'] ) def test_compare_rejects_modified_or_cross_case_evidence( tmp_path: Path, ) -> None: ''' Lifecycle checks must not combine unrelated or altered artifacts. JSON can remain plausible when a phase snapshot is replaced or a report was generated for another market. Bind each report to its checksum and fixed case metadata. Prove both changes abort before abort before a comparison result can be accepted. ''' helpers: dict = load_helpers() run_root: Path = tmp_path / 'run' init_case(helpers, run_root) evidence: Path = run_root / 'evidence' before: Path = evidence / 'before.parquet' after: Path = evidence / 'after.parquet' mk_frame((60, 120)).write_parquet(before) mk_frame((60, 120, 180)).write_parquet(after) write_phase_evidence(run_root, 'before', before) write_phase_evidence(run_root, 'after', after) after_report: Path = evidence / 'after.json' report_bytes: bytes = after_report.read_bytes() after_report.write_bytes(report_bytes + b'\n') with pytest.raises(RuntimeError, match='report checksum'): helpers['bfq_compare'](run_root, 'before', 'after') after_report.write_bytes(report_bytes) mk_frame((60, 180)).write_parquet(before) with pytest.raises(RuntimeError, match='snapshot checksum'): helpers['bfq_compare'](run_root, 'before', 'after') other: Path = evidence / 'other.parquet' mk_frame((60, 120)).write_parquet(other) write_phase_evidence( run_root, 'other', other, fqme='other.test', ) with pytest.raises(RuntimeError, match='does not match'): helpers['bfq_compare'](run_root, 'other', 'after')