diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79b6b60..0bc3406 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,3 +98,74 @@ jobs: run: | set -euo pipefail python -m build + + normal-wheel: + name: Normal wheel (${{ matrix.profile }}, Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - python-version: "3.11" + profile: normal + constraints: "" + - python-version: "3.12" + profile: normal + constraints: "" + - python-version: "3.11" + profile: binance-numeric-constraints + constraints: tests/fixtures/normal-wheel-binance-constraints.txt + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Build the normal wheel + run: | + set -euo pipefail + python -m pip install build + python -m build --wheel --outdir dist + sha256sum dist/*.whl + + - name: Install only the wheel and its declared dependencies + env: + CONSTRAINTS_FILE: ${{ matrix.constraints }} + run: | + set -euo pipefail + python -m venv "${RUNNER_TEMP}/crypto-normal-wheel" + wheels=(dist/*.whl) + test "${#wheels[@]}" -eq 1 + test -f "${wheels[0]}" + constraints=() + if [ -n "${CONSTRAINTS_FILE}" ]; then + constraints=(--constraint "${GITHUB_WORKSPACE}/${CONSTRAINTS_FILE}") + fi + "${RUNNER_TEMP}/crypto-normal-wheel/bin/python" -I -m pip install \ + --no-cache-dir --report "${RUNNER_TEMP}/normal-wheel-install.json" \ + "${constraints[@]}" "${wheels[0]}" + "${RUNNER_TEMP}/crypto-normal-wheel/bin/python" -I -m pip check + + - name: Verify the installed public entrypoint and dependency origins + run: | + set -euo pipefail + qpk_commit="$(python - <<'PY' + import re + import tomllib + from pathlib import Path + + dependencies = tomllib.loads(Path("pyproject.toml").read_text())["project"]["dependencies"] + prefix = "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@" + matches = [item.removeprefix(prefix) for item in dependencies if item.startswith(prefix)] + assert len(matches) == 1 and re.fullmatch(r"[0-9a-f]{40}", matches[0]), matches + print(matches[0]) + PY + )" + cd "${RUNNER_TEMP}" + "${RUNNER_TEMP}/crypto-normal-wheel/bin/python" -I \ + "${GITHUB_WORKSPACE}/scripts/check_normal_wheel_install.py" \ + --expected-qpk-commit "${qpk_commit}" diff --git a/docs/adr/0002-declare-runtime-monitoring-dependencies.md b/docs/adr/0002-declare-runtime-monitoring-dependencies.md index fbf06e6..f4e4e54 100644 --- a/docs/adr/0002-declare-runtime-monitoring-dependencies.md +++ b/docs/adr/0002-declare-runtime-monitoring-dependencies.md @@ -1,6 +1,6 @@ # ADR 0002: Declare runtime monitoring dependencies -Status: proposed +Status: proposed; dependency adoption requires owner approval ## Context @@ -9,124 +9,155 @@ supported runtime entrypoints. Research extras and the developer test environment serve different purposes and should not supply an undeclared requirement of that import path. -The inspected dependency declarations and sources expose a gap: - | Boundary | Evidence | | --- | --- | -| Consumer metadata | [Crypto dependency declarations][crypto-deps] list QPK as the only normal dependency. NumPy and Pandas appear in the optional `research` extra. | +| Consumer metadata | [The baseline Crypto declaration][crypto-deps] lists QPK as the only normal dependency. NumPy and Pandas appear in the optional `research` extra. | | Consumer import | [The common runtime entrypoint][crypto-common] imports QPK's `PerformanceMonitor` at module scope. | | Consumed package metadata | [The pinned QPK declaration][qpk-deps] has an empty normal dependency list. NumPy and Pandas appear only in its `dev` extra. | | Consumed import path | [The monitor][qpk-monitor] imports Pandas and its metrics module. [The metrics module][qpk-metrics] imports both NumPy and Pandas at module scope. | | Existing verification | [CI installation][ci-install] explicitly installs NumPy and Pandas. [The catalog/entrypoint check][entrypoint-check] skips when the import reports missing Pandas. | -The QPK links identify the revision consumed by the referenced Crypto manifest, -rather than QPK's current default branch. This is static dependency and import -evidence. A failure in a clean wheel installation has not been reproduced by -this decision record, and no production failure is inferred. This path also -does not establish the complete dependency closure of every runtime module. - -Passing tests in an environment that separately installs these libraries do -not answer whether the normal package declaration is sufficient. The existing -tests remain useful for the behavior they exercise. +The QPK links identify the consumed revision, +`c7646a7168b3dafa763ef7751a182d23e8de7790`. Passing tests in an environment that +separately installs these libraries does not establish that normal package +metadata is sufficient. No production failure is inferred from this finding. + +## Clean normal-wheel reproduction + +[Diagnostic draft #299][baseline-pr] measured the unchanged package metadata and +runtime source from Crypto commit +`ec0b330081213f56c3ff98164ec00ba26c10dfc9`. Its test-only head was +`7025582e5ba99781197379415abbc9a8001a78a0`. + +Each job built `crypto_strategies-0.4.11-py3-none-any.whl`, created a new virtual +environment, and ran normal `pip install` on that wheel. Neither job requested +extras, manually installed NumPy or Pandas, used an editable installation, or +added a sibling source checkout. The import probe ran with `python -I` outside +the checkout and checked installed distribution and module origins. A network +audit guard covered the import; no strategy was evaluated. + +| Environment | Normal install | `pip check` | Public entrypoint import | +| --- | --- | --- | --- | +| [Python 3.11][baseline-311] | Succeeded | Passed | `ModuleNotFoundError`, missing `pandas` | +| [Python 3.12][baseline-312] | Succeeded | Passed | `ModuleNotFoundError`, missing `pandas` | + +Both reports recorded NumPy and Pandas distributions and modules as absent, +Crypto and QPK under the fresh environment's `site-packages`, the exact QPK pin, +and zero socket or DNS attempts. Both wheel builds had SHA-256 +`f990c3b458beb8e1f8ea07e9ad2acae58da85ba3efb956d9ad0f4618f976afc9`. +The baseline measurement reported `runtime_import_succeeded: false`; its green +diagnostic job means that the specified gap was reproduced. It does not mean the +runtime import worked. The dependency proposal removes that diagnostic +acceptance mode and requires a successful import. + +A preceding executor installation stopped while fetching QPK because its +configured proxy was unreachable. That was an environment failure, not evidence +of the missing dependency. The successful CI installations above establish the +normal-install result independently. ## Proposed decision -First reproduce the normal-install import boundary in an isolated environment. -If the gap is confirmed, prefer the smallest packaging correction: explicitly -declare the libraries required by the retained runtime import path in Crypto's -normal dependencies, while preserving the QPK source pin and runtime code. -The observed path requires consideration of both Pandas and NumPy. - -The supported version constraints must be established against the existing -consumer environments before that correction. Do not copy the research-extra -minimums into normal dependencies without compatibility evidence, broaden -constraints to make resolution succeed, or upgrade a platform implicitly. -Keep the existing `research` installation interface compatible when deciding -where its requirements belong. - -This proposal selects no new dependency version and makes no change to package -metadata, locks, runtime imports, workflows or deployment configuration. A -dependency-adoption change remains separate from a behavior-preserving code -extraction. +Declare `numpy` and `pandas` as normal Crypto runtime requirements, preserving +the QPK source pin, package version, runtime imports and strategy implementation. +This assigns the immediate consumer responsibility for the import path it +retains. It does not change monitoring, execution or accounting behavior. + +Use requirements without new numeric version bounds. The evidence establishes +dependency ownership, not a new supported minimum or a reason to upgrade a +consumer. In particular, copying the research extra's `pandas>=3.1.0` constraint +would exclude the existing [Binance Python 3.11 lock][binance-lock], which uses +NumPy 2.4.6 and Pandas 3.0.3. A separate normal-wheel CI case supplies those two +existing constraints to pip. Constraints restrict resolution; they do not +install otherwise undeclared packages. This case verifies installation and +entrypoint-import compatibility, not the complete consumer runtime. + +Keep the `research` extra unchanged. Add normal dependency edges to Crypto's +root `uv.lock` record using the already locked NumPy and Pandas nodes, and add +the corresponding normal metadata entries. No resolved package version, +artifact, source revision, resolution marker or research edge changes. This is +a five-line root metadata update, not a lock regeneration. + +### Existing research lock inconsistency + +The baseline project requests `pandas>=3.1.0` for `research`, while the lock's +research metadata requests `>=3.0.5` and its Pandas node is 3.0.5. The mismatch +predates this proposal. Resolving it can change versions or the research +contract and is outside the authorized packaging declaration. + +The QPK pin-consistency check validates shared Git references; it does not prove +general lock consistency or research-extra reproducibility. This proposal makes +no claim that the existing research lock is coherent. Reconciliation requires a +separate reviewed change and owner approval for any dependency-version change. + +## Validation + +The added normal-wheel CI jobs cover Python 3.11, Python 3.12, and Python 3.11 +with the existing Binance numeric constraints. Each job: + +1. Builds the normal wheel and installs it with its declared dependencies into a + fresh virtual environment through the normal resolver. +2. Runs `pip check`, then imports `crypto_strategies.entrypoints` using the + environment's isolated interpreter from outside the checkout. +3. Requires installed distribution metadata and module origins for Crypto, QPK, + NumPy and Pandas. Crypto must come from a wheel, QPK must match the unchanged + Git pin, and loaded modules must stay under that environment's site packages. +4. Fails on a missing import, incomplete metadata, a source or pin mismatch, an + editable or contaminated environment, or any observed network attempt. + +Offline tests cover the verifier's rejection paths. The previous CI jobs remain +intact, including strategy tests, Ruff, package build and shared-pin checks. No +existing skip or assertion is weakened. Runtime source is byte-for-byte +unchanged; normal installation and full repository CI must pass on the reviewed +proposal before it is considered ready for adoption review. + +These checks cover the public entrypoint import and existing test suite. They do +not establish the dependency closure of every optional runtime module, exercise +real monitoring storage, or validate a deployed consumer. ## Alternatives ### Add a QPK monitoring extra An explicit QPK extra would give the monitoring implementation ownership of its -requirements. Crypto would then depend on that extra. The currently consumed -QPK revision has no such extra, so this approach needs a shared-package change -and a separately reviewed consumer adoption. It has a larger release scope than -correcting the immediate consumer declaration. +requirements. The consumed QPK revision has no such extra, so this needs a +shared-package change and separately reviewed consumer adoption. It has a larger +release scope than correcting the immediate consumer declaration. ### Separate monitoring from strategy imports first -An explicit observation sink at application composition can keep evaluation -imports independent of monitoring infrastructure. That requires -characterization of monitor reuse, record payloads, persistence, warning paths -and call order. It is a broader architecture change and should not be combined -with the packaging correction. +An explicit observation sink at application composition could remove the +monitoring dependency from evaluation imports. That requires characterization +of monitor reuse, persistence, record payloads, warning paths and call order. +It is a broader architecture change and should not be combined with this +packaging correction. ### Delay or suppress the import error Moving the import into a best-effort recording function changes when a missing -dependency becomes visible. If the existing broad exception handler then -continues, an import failure could become a returned strategy decision without -the expected observation. This does not establish a complete installation and -must not be presented as a behavior-preserving fix. - -## Validation required before implementation - -1. Build and install the baseline wheel in a fresh environment using only its - declared normal dependencies. Keep research and development extras absent; - do not add NumPy or Pandas through test setup. -2. Import the supported runtime entrypoint without a sibling source checkout. - Record the actual imported module paths and installed distribution metadata. - Separate an import failure from a resolver, network or build failure. -3. Test the proposed declaration against the same boundary and the supported - consumer constraints. Keep the QPK source revision fixed. Run dependency - consistency checks without silently changing the consumer's lock file. -4. Run the affected strategy and monitoring tests with synthetic inputs and - controlled storage substitutes. Compare decisions, exceptions, observation - payloads, call order and warning behavior. No broker or real storage access - is needed for this packaging validation. -5. Retain the existing checks. A normal-install smoke check must fail on a - missing required import; a skip is not evidence that the installation works. - Verify that the research extra remains installable and compatible. - -The current metadata, import path and CI setup can be inspected independently -of this proposed validation. A successful documentation check does not complete -the clean-install experiment. - -## Consequences and rollback - -A declaration correction preserves the current runtime code and makes its -installation requirements explicit. It can enlarge the normal installation and -affect dependency resolution, so it still requires consumer compatibility -review. It does not remove monitoring's storage-dependent side effects. - -The alternative import-boundary change may reduce ordinary runtime -dependencies, but it must preserve observable monitoring behavior or be treated -as a separate behavior change. - -This proposed record requires no migration. If a later packaging change is -adopted, revert that consumer adoption separately to the previously supported -package and lock state. Retain the previous implementation and its compatibility -evidence; do not rewrite published history or hide the unresolved import gap. - -## Open decision - -Choose whether to proceed with a normal dependency declaration, develop a QPK -monitoring extra for separate adoption, or first isolate monitoring from the -strategy import path. The proposed first choice is the normal declaration, -conditional on the clean-install result and an agreed compatible constraint -set. Neither dependency adoption nor the alternative architecture change is -implemented by this record. - -[crypto-deps]: https://github.com/QuantStrategyLab/CryptoStrategies/blob/b537248094f3cd123b1400c421a4bb89c71b1981/pyproject.toml#L11-L19 -[crypto-common]: https://github.com/QuantStrategyLab/CryptoStrategies/blob/b537248094f3cd123b1400c421a4bb89c71b1981/src/crypto_strategies/entrypoints/_common.py#L9-L14 +dependency becomes visible. An existing broad exception handler could then +return a strategy decision without the expected observation. This does not +establish a complete installation or preserve the current failure boundary. + +## Consequences, adoption and rollback + +The declaration enlarges the normal installation and participates in consumer +dependency resolution. Green CI is packaging evidence; it does not authorize a +production dependency update. The proposal remains a draft until the owner +approves adoption. No production pin or deployment changes with this proposal. + +If adopted and later reverted, restore the previous consumer package and lock +state through a reviewed revert. Keep the reproduction record: reverting the +declaration restores the known normal-install gap. Resolve the separate +research lock inconsistency without silently changing its contract. + +[crypto-deps]: https://github.com/QuantStrategyLab/CryptoStrategies/blob/ec0b330081213f56c3ff98164ec00ba26c10dfc9/pyproject.toml#L11-L19 +[crypto-common]: https://github.com/QuantStrategyLab/CryptoStrategies/blob/ec0b330081213f56c3ff98164ec00ba26c10dfc9/src/crypto_strategies/entrypoints/_common.py#L9-L14 [qpk-deps]: https://github.com/QuantStrategyLab/QuantPlatformKit/blob/c7646a7168b3dafa763ef7751a182d23e8de7790/pyproject.toml#L15-L25 [qpk-monitor]: https://github.com/QuantStrategyLab/QuantPlatformKit/blob/c7646a7168b3dafa763ef7751a182d23e8de7790/src/quant_platform_kit/strategy_lifecycle/performance_monitor.py#L17-L35 [qpk-metrics]: https://github.com/QuantStrategyLab/QuantPlatformKit/blob/c7646a7168b3dafa763ef7751a182d23e8de7790/src/quant_platform_kit/strategy_lifecycle/performance_metrics.py#L12-L13 -[ci-install]: https://github.com/QuantStrategyLab/CryptoStrategies/blob/b537248094f3cd123b1400c421a4bb89c71b1981/.github/workflows/ci.yml#L69-L78 -[entrypoint-check]: https://github.com/QuantStrategyLab/CryptoStrategies/blob/b537248094f3cd123b1400c421a4bb89c71b1981/tests/test_contract_governance.py#L96-L103 +[ci-install]: https://github.com/QuantStrategyLab/CryptoStrategies/blob/ec0b330081213f56c3ff98164ec00ba26c10dfc9/.github/workflows/ci.yml#L69-L78 +[entrypoint-check]: https://github.com/QuantStrategyLab/CryptoStrategies/blob/ec0b330081213f56c3ff98164ec00ba26c10dfc9/tests/test_contract_governance.py#L96-L103 +[baseline-pr]: https://github.com/QuantStrategyLab/CryptoStrategies/pull/299 +[baseline-311]: https://github.com/QuantStrategyLab/CryptoStrategies/actions/runs/41202442005/job/124540746081 +[baseline-312]: https://github.com/QuantStrategyLab/CryptoStrategies/actions/runs/41202442005/job/124540746036 +[binance-lock]: https://github.com/QuantStrategyLab/BinancePlatform/blob/20209604a9288482bed9e25506d3c9b6942ea2f1/uv.lock diff --git a/pyproject.toml b/pyproject.toml index c2b2708..4b3a943 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,8 @@ description = "Shared crypto strategy catalog and implementations" readme = "README.md" requires-python = ">=3.11" dependencies = [ + "numpy", + "pandas", "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@c7646a7168b3dafa763ef7751a182d23e8de7790", ] diff --git a/scripts/check_normal_wheel_install.py b/scripts/check_normal_wheel_install.py new file mode 100644 index 0000000..114155f --- /dev/null +++ b/scripts/check_normal_wheel_install.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Verify a normal Crypto wheel installation without evaluating a strategy.""" + +from __future__ import annotations + +import argparse +import importlib +import importlib.metadata +import importlib.util +import json +from pathlib import Path +import re +import site +import sys +import sysconfig +from urllib.parse import urlsplit + +_NETWORK_EVENTS = frozenset({"socket.connect", "socket.bind", "socket.getaddrinfo"}) +_NUMERIC_MODULES = ("numpy", "pandas") +_REQUIRED_MODULES = ("crypto_strategies", "quant_platform_kit") +_QPK_REPOSITORY = ("github.com", "/QuantStrategyLab/QuantPlatformKit.git") + + +class VerificationError(RuntimeError): + """An installation or provenance requirement was not met.""" + + +def _within(path: Path, roots: tuple[Path, ...]) -> bool: + return any(path.is_relative_to(root) for root in roots) + + +def _library_roots() -> tuple[Path, ...]: + prefix = Path(sys.prefix).resolve() + if sys.prefix == sys.base_prefix or not sys.flags.isolated or site.ENABLE_USER_SITE: + raise VerificationError("an isolated virtualenv interpreter is required; invoke python -I") + config = prefix / "pyvenv.cfg" + if not config.is_file(): + raise VerificationError("virtualenv configuration is absent") + settings = {} + for line in config.read_text().splitlines(): + if "=" in line: + key, value = line.split("=", 1) + settings[key.strip().lower()] = value.strip().lower() + if settings.get("include-system-site-packages") != "false": + raise VerificationError("system site packages must be disabled") + paths = sysconfig.get_paths() + roots = tuple(dict.fromkeys(Path(paths[key]).resolve() for key in ("purelib", "platlib"))) + if not all(root.is_relative_to(prefix) for root in roots): + raise VerificationError("site packages must belong to the active virtualenv") + return roots + + +def _distribution(name: str, roots: tuple[Path, ...], expected_qpk_pin: str) -> dict | None: + try: + distribution = importlib.metadata.distribution(name) + except importlib.metadata.PackageNotFoundError: + return None + location = Path(distribution.locate_file("")).resolve() + if not _within(location, roots): + raise VerificationError(f"{name} distribution is outside virtualenv site packages") + raw = distribution.read_text("direct_url.json") + direct = json.loads(raw) if raw is not None else {} + if direct.get("dir_info", {}).get("editable"): + raise VerificationError(f"{name} is editable") + info = {"version": distribution.version, "location": str(location)} + if name == "crypto-strategies": + source = urlsplit(direct.get("url", "")) + if not source.path.lower().endswith(".whl") or "archive_info" not in direct: + raise VerificationError("Crypto must be installed from the built wheel") + info["wheel_filename"] = Path(source.path).name + if name == "quant-platform-kit": + source = urlsplit(direct.get("url", "")) + vcs = direct.get("vcs_info", {}) + if (source.hostname, source.path) != _QPK_REPOSITORY: + raise VerificationError("QPK distribution has an unexpected repository origin") + if vcs.get("vcs") != "git" or vcs.get("commit_id") != expected_qpk_pin: + raise VerificationError("QPK distribution does not match the expected Git pin") + info["vcs_commit"] = vcs["commit_id"] + return info + + +def _module_origin(name: str, roots: tuple[Path, ...], *, loaded: bool = False) -> str | None: + if loaded: + module = sys.modules.get(name) + origin = None if module is None else getattr(module, "__file__", None) + else: + specification = importlib.util.find_spec(name) + origin = None if specification is None else specification.origin + if origin is None: + return None + path = Path(origin).resolve() + if not _within(path, roots): + raise VerificationError(f"{name} module is outside virtualenv site packages") + return str(path) + + +def classify_import( + error: Exception | None, + numeric_presence: dict, + network_attempts: list[str], +) -> tuple[dict, int]: + """Require a successful import with both numeric dependencies installed.""" + result = { + "runtime_import_succeeded": error is None, + "missing_dependency": error.name if type(error) is ModuleNotFoundError else None, + } + if network_attempts: + return {**result, "status": "network_attempt_detected"}, 1 + all_present = all( + numeric_presence[name]["distribution_present"] and numeric_presence[name]["module_present"] + for name in _NUMERIC_MODULES + ) + if error is not None: + return {**result, "status": "runtime_import_failed", "error_type": type(error).__name__}, 1 + if not all_present: + return {**result, "status": "numeric_distribution_metadata_missing"}, 1 + return {**result, "status": "runtime_import_succeeded"}, 0 + + +def verify(expected_qpk_pin: str) -> tuple[dict, int]: + report = { + "schema_version": "qsl.normal_wheel_import.v1", + "expected_qpk_pin": expected_qpk_pin, + "runtime_import_succeeded": False, + "missing_dependency": None, + "network_attempts": [], + } + try: + if re.fullmatch(r"[0-9a-f]{40}", expected_qpk_pin) is None: + raise VerificationError("expected QPK pin must be a full lowercase Git SHA") + roots = _library_roots() + report["site_packages"] = [str(root) for root in roots] + if any(name in sys.modules for name in (*_REQUIRED_MODULES, *_NUMERIC_MODULES)): + raise VerificationError("target modules must not be preloaded") + + def network_guard(event, _arguments): + if event in _NETWORK_EVENTS: + report["network_attempts"].append(event) + raise VerificationError("network access attempted during the import probe") + + sys.addaudithook(network_guard) + report["distributions"] = {} + report["module_origins_before_import"] = {} + for distribution_name, module_name in ( + ("crypto-strategies", "crypto_strategies"), + ("quant-platform-kit", "quant_platform_kit"), + ): + info = _distribution(distribution_name, roots, expected_qpk_pin) + if info is None: + raise VerificationError(f"{distribution_name} is not installed") + report["distributions"][distribution_name] = info + origin = _module_origin(module_name, roots) + if origin is None: + raise VerificationError(f"{module_name} has no installed module") + report["module_origins_before_import"][module_name] = origin + + numeric = {} + for name in _NUMERIC_MODULES: + info = _distribution(name, roots, expected_qpk_pin) + origin = _module_origin(name, roots) + numeric[name] = { + "distribution_present": info is not None, + "module_present": origin is not None, + "version": None if info is None else info["version"], + "module_origin": origin, + } + report["numeric_presence_before_import"] = numeric + + error = None + try: + importlib.import_module("crypto_strategies.entrypoints") + except Exception as exc: + error = exc + report["loaded_module_origins"] = {} + for name in (*_REQUIRED_MODULES, *_NUMERIC_MODULES, "crypto_strategies.entrypoints"): + if name in sys.modules: + origin = _module_origin(name, roots, loaded=True) + if origin is None: + raise VerificationError(f"{name} has no loaded file origin") + report["loaded_module_origins"][name] = origin + if error is None: + required = {*_REQUIRED_MODULES, *_NUMERIC_MODULES, "crypto_strategies.entrypoints"} + if not required.issubset(report["loaded_module_origins"]): + raise VerificationError("successful import did not load the required installed modules") + result, code = classify_import(error, numeric, report["network_attempts"]) + return {**report, **result}, code + except Exception as exc: + report["status"] = "verification_failed" + report["error_type"] = type(exc).__name__ + if isinstance(exc, VerificationError): + report["verification_error"] = str(exc) + return report, 1 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--expected-qpk-commit", required=True) + parser.add_argument("--output", type=Path) + arguments = parser.parse_args(argv) + report, code = verify(arguments.expected_qpk_commit) + text = json.dumps(report, indent=2, sort_keys=True) + "\n" + if arguments.output is not None: + arguments.output.write_text(text) + print(text, end="") + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/normal-wheel-binance-constraints.txt b/tests/fixtures/normal-wheel-binance-constraints.txt new file mode 100644 index 0000000..4861f5e --- /dev/null +++ b/tests/fixtures/normal-wheel-binance-constraints.txt @@ -0,0 +1,5 @@ +# Installation-test constraints from the existing Binance Python 3.11 lock. +# https://github.com/QuantStrategyLab/BinancePlatform/blob/20209604a9288482bed9e25506d3c9b6942ea2f1/uv.lock +# This fixture does not update or replace the consumer's dependency lock. +numpy==2.4.6 +pandas==3.0.3 diff --git a/tests/test_backtest_correctness.py b/tests/test_backtest_correctness.py index 27a302b..c59d0a9 100644 --- a/tests/test_backtest_correctness.py +++ b/tests/test_backtest_correctness.py @@ -375,16 +375,15 @@ def digest(seed: int) -> str: assert digest(1) == digest(1) -def test_research_dependencies_are_optional_only() -> None: +def test_runtime_monitoring_dependencies_preserve_research_extra() -> None: root = Path(__file__).resolve().parents[1] with (root / "pyproject.toml").open("rb") as handle: project = tomllib.load(handle)["project"] + assert project["optional-dependencies"]["research"] == [ + "numpy>=2.4.6", "pandas>=3.1.0", + ] assert {"numpy", "pandas"} <= { - requirement.split(">", 1)[0].split("=", 1)[0].split("<", 1)[0] - for requirement in project["optional-dependencies"]["research"] - } - assert not {"numpy", "pandas"} & { requirement.split(">", 1)[0].split("=", 1)[0].split("<", 1)[0] for requirement in project["dependencies"] } diff --git a/tests/test_normal_wheel_install.py b/tests/test_normal_wheel_install.py new file mode 100644 index 0000000..3713fa7 --- /dev/null +++ b/tests/test_normal_wheel_install.py @@ -0,0 +1,161 @@ +"""Offline checks for strict installation and provenance requirements.""" + +from contextlib import redirect_stderr +import importlib.util +from io import StringIO +import json +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +_CHECKER = Path(__file__).resolve().parents[1] / "scripts/check_normal_wheel_install.py" +_spec = importlib.util.spec_from_file_location("normal_wheel_checker", _CHECKER) +checker = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(checker) + + +def presence(*, numpy=False, pandas=False): + return { + name: {"distribution_present": installed, "module_present": installed} + for name, installed in (("numpy", numpy), ("pandas", pandas)) + } + + +class NormalWheelClassificationTests(unittest.TestCase): + def test_missing_pandas_fails_without_claiming_runtime_success(self): + result, code = checker.classify_import( + ModuleNotFoundError("No module named 'pandas'", name="pandas"), + presence(), [], + ) + self.assertEqual(code, 1) + self.assertEqual(result, { + "status": "runtime_import_failed", + "runtime_import_succeeded": False, + "missing_dependency": "pandas", + "error_type": "ModuleNotFoundError", + }) + + def test_removed_baseline_option_is_rejected_before_verification(self): + stderr = StringIO() + with patch.object(checker, "verify") as verify, redirect_stderr(stderr): + with self.assertRaises(SystemExit) as error: + checker.main(["--expected-qpk-commit", "a" * 40, "--expect-missing", "pandas"]) + self.assertEqual(error.exception.code, 2) + self.assertIn("unrecognized arguments: --expect-missing pandas", stderr.getvalue()) + verify.assert_not_called() + + def test_network_attempt_rejects_both_missing_and_successful_imports(self): + for error in (ModuleNotFoundError(name="pandas"), None): + for event in ("socket.connect", "socket.bind", "socket.getaddrinfo"): + with self.subTest(error=error, event=event): + result, code = checker.classify_import( + error, presence(numpy=True, pandas=True), [event], + ) + self.assertEqual(code, 1) + self.assertEqual(result["status"], "network_attempt_detected") + + def test_all_import_failures_are_rejected(self): + class MissingSubclass(ModuleNotFoundError): + pass + + for error in ( + ModuleNotFoundError(name="numpy"), + ModuleNotFoundError(name="pandas._libs"), + ModuleNotFoundError(name="google"), + ModuleNotFoundError(), + ImportError("pandas"), + RuntimeError("pandas"), + MissingSubclass(name="pandas"), + ): + with self.subTest(error=type(error).__name__, missing=getattr(error, "name", None)): + result, code = checker.classify_import(error, presence(), []) + self.assertEqual(code, 1) + self.assertFalse(result["runtime_import_succeeded"]) + self.assertEqual(result["status"], "runtime_import_failed") + self.assertEqual(result["error_type"], type(error).__name__) + + def test_partial_numeric_metadata_never_accepts_a_missing_import(self): + for module in ("numpy", "pandas"): + for field in ("distribution_present", "module_present"): + with self.subTest(module=module, field=field): + before = presence() + before[module][field] = True + result, code = checker.classify_import( + ModuleNotFoundError(name="pandas"), before, [], + ) + self.assertEqual(code, 1) + self.assertFalse(result["runtime_import_succeeded"]) + self.assertEqual(result["status"], "runtime_import_failed") + + def test_strict_success_requires_distribution_and_module_metadata(self): + result, code = checker.classify_import(None, presence(numpy=True, pandas=True), []) + self.assertEqual(code, 0) + self.assertTrue(result["runtime_import_succeeded"]) + self.assertEqual(result["status"], "runtime_import_succeeded") + for module in ("numpy", "pandas"): + for field in ("distribution_present", "module_present"): + with self.subTest(module=module, field=field): + before = presence(numpy=True, pandas=True) + before[module][field] = False + result, code = checker.classify_import(None, before, []) + self.assertEqual(code, 1) + self.assertEqual(result["status"], "numeric_distribution_metadata_missing") + + def test_missing_numeric_metadata_fails_even_when_entrypoint_import_succeeds(self): + result, code = checker.classify_import(None, presence(), []) + self.assertEqual(code, 1) + self.assertTrue(result["runtime_import_succeeded"]) + self.assertEqual(result["status"], "numeric_distribution_metadata_missing") + + +class NormalWheelProvenanceTests(unittest.TestCase): + def distribution(self, direct_url, location="/synthetic-venv/site-packages"): + return SimpleNamespace( + version="1.0.0", + locate_file=lambda _path: Path(location), + read_text=lambda _name: json.dumps(direct_url), + ) + + def test_qpk_requires_both_the_repository_and_exact_commit(self): + pin = "a" * 40 + direct = { + "url": "https://github.com/QuantStrategyLab/QuantPlatformKit.git", + "vcs_info": {"vcs": "git", "commit_id": pin}, + } + roots = (Path("/synthetic-venv/site-packages"),) + with patch.object(checker.importlib.metadata, "distribution", return_value=self.distribution(direct)): + self.assertEqual(checker._distribution("quant-platform-kit", roots, pin)["vcs_commit"], pin) + for changed in ( + {**direct, "url": "https://example.invalid/QuantPlatformKit.git"}, + {**direct, "vcs_info": {"vcs": "git", "commit_id": "b" * 40}}, + {**direct, "vcs_info": {"vcs": "git", "requested_revision": pin}}, + {**direct, "dir_info": {"editable": True}}, + ): + with self.subTest(direct_url=changed): + with patch.object(checker.importlib.metadata, "distribution", return_value=self.distribution(changed)): + with self.assertRaises(checker.VerificationError): + checker._distribution("quant-platform-kit", roots, pin) + + def test_crypto_requires_a_wheel_and_rejects_a_source_archive(self): + roots = (Path("/synthetic-venv/site-packages"),) + for url in ("file:///build/crypto_strategies-0.4.11.tar.gz", "file:///source"): + with self.subTest(url=url): + distribution = self.distribution({"url": url, "archive_info": {}}) + with patch.object(checker.importlib.metadata, "distribution", return_value=distribution): + with self.assertRaises(checker.VerificationError): + checker._distribution("crypto-strategies", roots, "a" * 40) + + def test_distribution_and_module_origins_cannot_escape_the_virtualenv(self): + roots = (Path("/synthetic-venv/site-packages"),) + distribution = self.distribution({}, location="/sibling-source") + with patch.object(checker.importlib.metadata, "distribution", return_value=distribution): + with self.assertRaises(checker.VerificationError): + checker._distribution("numpy", roots, "a" * 40) + with patch.object(checker.importlib.util, "find_spec", return_value=SimpleNamespace(origin="/sibling-source/pandas.py")): + with self.assertRaises(checker.VerificationError): + checker._module_origin("pandas", roots) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_qsl_compat_metadata.py b/tests/test_qsl_compat_metadata.py index f5ae4a3..6c04553 100644 --- a/tests/test_qsl_compat_metadata.py +++ b/tests/test_qsl_compat_metadata.py @@ -27,7 +27,7 @@ def test_qpk_pin_lock_and_ci_are_dependency_enabled() -> None: with (ROOT / "uv.lock").open("rb") as file: lock = tomllib.load(file) - assert pyproject["project"]["dependencies"] == [QPK_URL] + assert pyproject["project"]["dependencies"] == ["numpy", "pandas", QPK_URL] assert qsl["compat"]["requires"] == [QPK_URL] packages = {package["name"]: package for package in lock["package"]} diff --git a/uv.lock b/uv.lock index 4d81f50..3fa64cf 100644 --- a/uv.lock +++ b/uv.lock @@ -18,6 +18,9 @@ name = "crypto-strategies" version = "0.4.11" source = { editable = "." } dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas" }, { name = "quant-platform-kit" }, ] @@ -30,6 +33,8 @@ research = [ [package.metadata] requires-dist = [ + { name = "numpy" }, + { name = "pandas" }, { name = "numpy", marker = "extra == 'research'", specifier = ">=2.4.6" }, { name = "pandas", marker = "extra == 'research'", specifier = ">=3.0.5" }, { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=c7646a7168b3dafa763ef7751a182d23e8de7790" },