piker/tests/test_backfill_audit_snippet.py

565 lines
17 KiB
Python

'''
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')