From c47a8acbbf51d55e371bbaab573668d98ce145e7 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:00:04 +0800 Subject: [PATCH 1/6] fix: Declare numeric runtime dependencies for normal installs --- .github/workflows/ci.yml | 71 ++++++ ...declare-runtime-monitoring-dependencies.md | 223 ++++++++++-------- pyproject.toml | 2 + scripts/check_normal_wheel_install.py | 209 ++++++++++++++++ .../normal-wheel-binance-constraints.txt | 5 + tests/test_normal_wheel_install.py | 161 +++++++++++++ uv.lock | 5 + 7 files changed, 580 insertions(+), 96 deletions(-) create mode 100644 scripts/check_normal_wheel_install.py create mode 100644 tests/fixtures/normal-wheel-binance-constraints.txt create mode 100644 tests/test_normal_wheel_install.py 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_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/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" }, From 8144c4e95844e38bc73b5cfec8ae4b5a3edf530e Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:06:36 +0800 Subject: [PATCH 2/6] test: Require the declared numeric runtime dependency contract --- tests/test_backtest_correctness.py | 9 ++++----- tests/test_qsl_compat_metadata.py | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) 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_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"]} From bd6195b66fa1a28fb102674418791ec14c1586d3 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:24:30 +0800 Subject: [PATCH 3/6] ci: generate the approved research lock candidate Resolve pandas 3.1.0 in a read-only CI job and retain the complete lock artifact. Reject unrelated dependency identity changes and preserve the QPK record. --- .github/workflows/research-lock.yml | 108 ++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 .github/workflows/research-lock.yml diff --git a/.github/workflows/research-lock.yml b/.github/workflows/research-lock.yml new file mode 100644 index 0000000..57118ca --- /dev/null +++ b/.github/workflows/research-lock.yml @@ -0,0 +1,108 @@ +name: Research lock + +on: + pull_request: + +permissions: + contents: read + +jobs: + generate: + name: Generate research lock candidate + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install the lock resolver + run: python -m pip install uv==0.13.4 + + - name: Preserve the input lock + run: cp uv.lock "${RUNNER_TEMP}/crypto-research-lock-baseline.lock" + + - name: Resolve the approved Pandas version + run: uv lock --upgrade-package pandas==3.1.0 + + - name: Record the resolved lock and dependency delta + run: | + python - <<'PY' + import hashlib + import json + import os + from pathlib import Path + import subprocess + import tomllib + + baseline_bytes = (Path(os.environ["RUNNER_TEMP"]) / "crypto-research-lock-baseline.lock").read_bytes() + candidate_bytes = Path("uv.lock").read_bytes() + baseline = tomllib.loads(baseline_bytes.decode()) + candidate = tomllib.loads(candidate_bytes.decode()) + project = tomllib.loads(Path("pyproject.toml").read_text())["project"] + + def records(document): + result = {} + for package in document["package"]: + result.setdefault(package["name"], []).append(package) + return result + + def identities(packages): + return sorted( + (package["version"], json.dumps(package["source"], sort_keys=True)) + for package in packages + ) + + before = records(baseline) + after = records(candidate) + names = sorted(set(before) | set(after)) + identity_changes = { + name: {"before": identities(before.get(name, [])), "after": identities(after.get(name, []))} + for name in names if identities(before.get(name, [])) != identities(after.get(name, [])) + } + root_package = after[project["name"]][0] + research_requirements = [ + item for item in root_package["metadata"]["requires-dist"] + if item.get("marker") == "extra == 'research'" + ] + receipt = { + "source_commit": os.environ["GITHUB_SHA"], + "uv_version": subprocess.check_output(["uv", "--version"], text=True).strip(), + "baseline_lock_sha256": hashlib.sha256(baseline_bytes).hexdigest(), + "candidate_lock_sha256": hashlib.sha256(candidate_bytes).hexdigest(), + "package_identity_changes": identity_changes, + "changed_package_records": [name for name in names if before.get(name) != after.get(name)], + "declared_research_requirements": project["optional-dependencies"]["research"], + "locked_research_requirements": research_requirements, + "qpk_source_unchanged": before["quant-platform-kit"] == after["quant-platform-kit"], + "candidate_generated": True, + "adoption_authorized_by_this_job": False, + } + output = Path(os.environ["RUNNER_TEMP"]) / "crypto-research-lock" + output.mkdir() + (output / "uv.lock").write_bytes(candidate_bytes) + (output / "resolution-receipt.json").write_text(json.dumps(receipt, indent=2) + "\n") + print(json.dumps(receipt, indent=2)) + + assert set(identity_changes) == {"pandas"}, identity_changes + assert {package["version"] for package in after["pandas"]} == {"3.1.0"} + assert receipt["qpk_source_unchanged"] + assert {item["name"]: item["specifier"] for item in research_requirements} == { + "numpy": ">=2.4.6", "pandas": ">=3.1.0", + } + PY + + - name: Preserve the generated lock for review + if: always() + uses: actions/upload-artifact@v7.0.2 + with: + name: crypto-research-lock-${{ github.sha }} + path: | + ${{ runner.temp }}/crypto-research-lock/uv.lock + ${{ runner.temp }}/crypto-research-lock/resolution-receipt.json + if-no-files-found: error + retention-days: 7 From 809ccc5213a18e24eee0689ad0c4105433bca0c8 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:33:23 +0800 Subject: [PATCH 4/6] ci: expose the generated lock for offline verification Record only the public generated lock as numbered base64 chunks with length and digest. Preserve the artifact, dependency constraints and all validation guards. --- .github/workflows/research-lock.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/research-lock.yml b/.github/workflows/research-lock.yml index 57118ca..92ca677 100644 --- a/.github/workflows/research-lock.yml +++ b/.github/workflows/research-lock.yml @@ -32,6 +32,7 @@ jobs: - name: Record the resolved lock and dependency delta run: | python - <<'PY' + import base64 import hashlib import json import os @@ -87,6 +88,15 @@ jobs: (output / "uv.lock").write_bytes(candidate_bytes) (output / "resolution-receipt.json").write_text(json.dumps(receipt, indent=2) + "\n") print(json.dumps(receipt, indent=2)) + encoded = base64.b64encode(candidate_bytes).decode("ascii") + chunks = [encoded[index:index + 4096] for index in range(0, len(encoded), 4096)] + print("UV_LOCK_BASE64_BEGIN") + print(f"UV_LOCK_BYTE_LENGTH={len(candidate_bytes)}") + print(f"UV_LOCK_SHA256={receipt['candidate_lock_sha256']}") + print(f"UV_LOCK_BASE64_CHUNKS={len(chunks)}") + for index, chunk in enumerate(chunks): + print(f"UV_LOCK_BASE64_{index:04d}={chunk}") + print("UV_LOCK_BASE64_END") assert set(identity_changes) == {"pandas"}, identity_changes assert {package["version"] for package in after["pandas"]} == {"3.1.0"} From ae70a78de271642f43ee3e8eb68a23bd4db3559c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:42:02 +0800 Subject: [PATCH 5/6] fix: reconcile the shared pandas lock --- .github/workflows/research-lock.yml | 150 ++++++++---------- ...declare-runtime-monitoring-dependencies.md | 65 +++++--- tests/test_qsl_compat_metadata.py | 43 +++++ uv.lock | 112 +++++++------ 4 files changed, 212 insertions(+), 158 deletions(-) diff --git a/.github/workflows/research-lock.yml b/.github/workflows/research-lock.yml index 92ca677..e9c9305 100644 --- a/.github/workflows/research-lock.yml +++ b/.github/workflows/research-lock.yml @@ -7,10 +7,17 @@ permissions: contents: read jobs: - generate: - name: Generate research lock candidate + verify: + name: Frozen research install (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + env: + UV_PYTHON: ${{ matrix.python-version }} + UV_PROJECT_ENVIRONMENT: ${{ runner.temp }}/crypto-research-environment steps: - name: Checkout uses: actions/checkout@v6 @@ -18,101 +25,74 @@ jobs: - name: Setup Python uses: actions/setup-python@v6 with: - python-version: "3.11" + python-version: ${{ matrix.python-version }} - name: Install the lock resolver run: python -m pip install uv==0.13.4 - - name: Preserve the input lock - run: cp uv.lock "${RUNNER_TEMP}/crypto-research-lock-baseline.lock" + - name: Validate the committed lock + run: | + uv lock --check + git diff --exit-code -- uv.lock - - name: Resolve the approved Pandas version - run: uv lock --upgrade-package pandas==3.1.0 + - name: Install the frozen research environment + run: | + uv sync --frozen --extra research --no-editable + uv pip check --python "${UV_PROJECT_ENVIRONMENT}/bin/python" - - name: Record the resolved lock and dependency delta + - name: Verify installed versions, origins and public import run: | - python - <<'PY' - import base64 - import hashlib + cd "${RUNNER_TEMP}" + "${UV_PROJECT_ENVIRONMENT}/bin/python" -I - "${GITHUB_WORKSPACE}" <<'PY' + import importlib + import importlib.metadata import json - import os from pathlib import Path - import subprocess + import sys + import sysconfig import tomllib - baseline_bytes = (Path(os.environ["RUNNER_TEMP"]) / "crypto-research-lock-baseline.lock").read_bytes() - candidate_bytes = Path("uv.lock").read_bytes() - baseline = tomllib.loads(baseline_bytes.decode()) - candidate = tomllib.loads(candidate_bytes.decode()) - project = tomllib.loads(Path("pyproject.toml").read_text())["project"] - - def records(document): - result = {} - for package in document["package"]: - result.setdefault(package["name"], []).append(package) - return result + source = Path(sys.argv[1]) + project = tomllib.loads((source / "pyproject.toml").read_text())["project"] + lock = tomllib.loads((source / "uv.lock").read_text()) + prefix = "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@" + pins = [value.removeprefix(prefix) for value in project["dependencies"] if value.startswith(prefix)] + assert len(pins) == 1 + qpk = importlib.metadata.distribution("quant-platform-kit") + direct_url = json.loads(qpk.read_text("direct_url.json")) + assert direct_url["vcs_info"]["commit_id"] == pins[0] + assert direct_url["vcs_info"]["vcs"] == "git" + assert direct_url["url"] == "https://github.com/QuantStrategyLab/QuantPlatformKit.git" - def identities(packages): - return sorted( - (package["version"], json.dumps(package["source"], sort_keys=True)) - for package in packages - ) - - before = records(baseline) - after = records(candidate) - names = sorted(set(before) | set(after)) - identity_changes = { - name: {"before": identities(before.get(name, [])), "after": identities(after.get(name, []))} - for name in names if identities(before.get(name, [])) != identities(after.get(name, [])) - } - root_package = after[project["name"]][0] - research_requirements = [ - item for item in root_package["metadata"]["requires-dist"] - if item.get("marker") == "extra == 'research'" - ] - receipt = { - "source_commit": os.environ["GITHUB_SHA"], - "uv_version": subprocess.check_output(["uv", "--version"], text=True).strip(), - "baseline_lock_sha256": hashlib.sha256(baseline_bytes).hexdigest(), - "candidate_lock_sha256": hashlib.sha256(candidate_bytes).hexdigest(), - "package_identity_changes": identity_changes, - "changed_package_records": [name for name in names if before.get(name) != after.get(name)], - "declared_research_requirements": project["optional-dependencies"]["research"], - "locked_research_requirements": research_requirements, - "qpk_source_unchanged": before["quant-platform-kit"] == after["quant-platform-kit"], - "candidate_generated": True, - "adoption_authorized_by_this_job": False, + installed = {name: importlib.metadata.version(name) for name in ("numpy", "pandas")} + locked = { + name: [package["version"] for package in lock["package"] if package["name"] == name] + for name in installed } - output = Path(os.environ["RUNNER_TEMP"]) / "crypto-research-lock" - output.mkdir() - (output / "uv.lock").write_bytes(candidate_bytes) - (output / "resolution-receipt.json").write_text(json.dumps(receipt, indent=2) + "\n") - print(json.dumps(receipt, indent=2)) - encoded = base64.b64encode(candidate_bytes).decode("ascii") - chunks = [encoded[index:index + 4096] for index in range(0, len(encoded), 4096)] - print("UV_LOCK_BASE64_BEGIN") - print(f"UV_LOCK_BYTE_LENGTH={len(candidate_bytes)}") - print(f"UV_LOCK_SHA256={receipt['candidate_lock_sha256']}") - print(f"UV_LOCK_BASE64_CHUNKS={len(chunks)}") - for index, chunk in enumerate(chunks): - print(f"UV_LOCK_BASE64_{index:04d}={chunk}") - print("UV_LOCK_BASE64_END") + assert all(installed[name] in locked[name] for name in installed) + assert sys.prefix != sys.base_prefix and sys.flags.isolated + roots = tuple(Path(sysconfig.get_path(key)).resolve() for key in ("purelib", "platlib")) + assert all(root.is_relative_to(Path(sys.prefix).resolve()) for root in roots) + modules = ("crypto_strategies", "quant_platform_kit", "numpy", "pandas", "crypto_strategies.entrypoints") + assert not any(name in sys.modules for name in modules) + network_attempts = [] - assert set(identity_changes) == {"pandas"}, identity_changes - assert {package["version"] for package in after["pandas"]} == {"3.1.0"} - assert receipt["qpk_source_unchanged"] - assert {item["name"]: item["specifier"] for item in research_requirements} == { - "numpy": ">=2.4.6", "pandas": ">=3.1.0", - } - PY + def network_guard(event, arguments): + if event in {"socket.connect", "socket.bind", "socket.getaddrinfo"}: + network_attempts.append(event) + raise RuntimeError("network access attempted during the import probe") - - name: Preserve the generated lock for review - if: always() - uses: actions/upload-artifact@v7.0.2 - with: - name: crypto-research-lock-${{ github.sha }} - path: | - ${{ runner.temp }}/crypto-research-lock/uv.lock - ${{ runner.temp }}/crypto-research-lock/resolution-receipt.json - if-no-files-found: error - retention-days: 7 + sys.addaudithook(network_guard) + importlib.import_module("crypto_strategies.entrypoints") + origins = {name: str(Path(sys.modules[name].__file__).resolve()) for name in modules} + assert all(any(Path(origin).is_relative_to(root) for root in roots) for origin in origins.values()) + assert not network_attempts + print(json.dumps({ + "runtime_import_succeeded": True, + "installed_versions": installed, + "locked_versions": locked, + "qpk_commit": pins[0], + "module_origins": origins, + "network_attempts": network_attempts, + }, indent=2)) + PY diff --git a/docs/adr/0002-declare-runtime-monitoring-dependencies.md b/docs/adr/0002-declare-runtime-monitoring-dependencies.md index f4e4e54..00a490c 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; dependency adoption requires owner approval +Status: accepted ## Context @@ -55,7 +55,7 @@ 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 +## Decision Declare `numpy` and `pandas` as normal Crypto runtime requirements, preserving the QPK source pin, package version, runtime imports and strategy implementation. @@ -71,23 +71,35 @@ 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. +The initial runtime declaration change preserved the `research` extra and added +normal dependency edges to the root lock record without changing resolved +package versions. The lock reconciliation below is a separate change. -### Existing research lock inconsistency +### Shared project lock reconciliation -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 project declares `pandas>=3.1.0` for `research`. The previous lock instead +recorded a `>=3.0.5` research edge and Pandas 3.0.5. The reconciled lock selects +Pandas 3.1.0 and records the unchanged declared constraint. Other package +versions and source revisions, including QPK, remain unchanged. + +The lock is generated with uv 0.13.4 using +`uv lock --upgrade-package pandas==3.1.0`. The resolver updates the lock format +revision from 3 to 5 and reorders the existing resolution markers and root +dependency metadata. The marker set is unchanged. Pandas source and wheel +metadata come directly from that resolver output. CI validates the lock with +the same uv version; compatibility with earlier resolver versions is untested. + +This repository has one shared `uv.lock`. Its Pandas selection applies to both +normal frozen sync and research sync; it is not an extra-specific lock. The +normal wheel requirements remain unbounded. Downstream consumer locks and the +Binance Pandas 3.0.3 installation constraint remain unchanged. 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. +general lock consistency. Regression tests compare the research declarations, +locked metadata and selected versions. A separate CI job checks the committed +lock, performs a frozen research installation and verifies installed versions +and the public entrypoint import. Generating a lock candidate alone is not a +successful frozen-install check. ## Validation @@ -107,8 +119,9 @@ with the existing Binance numeric constraints. Each job: 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. +unchanged. Normal installation, frozen research installation and full repository +CI must pass on the independently reviewed change. Repository acceptance uses +automated checks, independent AI review and risk classification. These checks cover the public entrypoint import and existing test suite. They do not establish the dependency closure of every optional runtime module, exercise @@ -141,14 +154,16 @@ 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. +dependency resolution. The shared project lock selects Pandas 3.1.0 without +changing a downstream production pin or deploying a consumer. Green CI remains +packaging and test evidence; it does not authorize live trading or deployment. + +The runtime declaration and shared-lock reconciliation can be reverted +separately through reviewed changes. Reverting the declaration restores the +known normal-install gap. Reverting only the lock reconciliation restores the +recorded research constraint mismatch; it does not remove the runtime +dependency declarations. Preserve the reproduction and verification records +when assessing either rollback. [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 diff --git a/tests/test_qsl_compat_metadata.py b/tests/test_qsl_compat_metadata.py index 6c04553..d21dcb0 100644 --- a/tests/test_qsl_compat_metadata.py +++ b/tests/test_qsl_compat_metadata.py @@ -1,6 +1,9 @@ import tomllib from pathlib import Path +from packaging.requirements import Requirement +import pytest + ROOT = Path(__file__).resolve().parents[1] QPK_REVISION = "c7646a7168b3dafa763ef7751a182d23e8de7790" @@ -56,3 +59,43 @@ def test_qpk_pin_lock_and_ci_are_dependency_enabled() -> None: assert "--no-deps" not in ci assert "python -m pip install -e ." in ci assert "python -m pip check" in ci + + +def test_research_requirements_match_lock_metadata() -> None: + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) + lock = tomllib.loads((ROOT / "uv.lock").read_text()) + requirements = [ + Requirement(value) + for value in pyproject["project"]["optional-dependencies"]["research"] + ] + root_package = next(package for package in lock["package"] if package["name"] == "crypto-strategies") + locked_requirements = [ + requirement + for requirement in root_package["metadata"]["requires-dist"] + if requirement.get("marker") == "extra == 'research'" + ] + + assert sorted(locked_requirements, key=lambda item: item["name"]) == sorted( + [ + {"name": requirement.name, "specifier": str(requirement.specifier), "marker": "extra == 'research'"} + for requirement in requirements + ], + key=lambda item: item["name"], + ) + + +@pytest.mark.parametrize("dependency", ["numpy", "pandas"]) +def test_locked_research_versions_satisfy_declared_requirement(dependency: str) -> None: + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) + lock = tomllib.loads((ROOT / "uv.lock").read_text()) + requirement = next( + requirement + for value in pyproject["project"]["optional-dependencies"]["research"] + if (requirement := Requirement(value)).name == dependency + ) + locked_versions = [package["version"] for package in lock["package"] if package["name"] == dependency] + + assert locked_versions, f"{dependency} is missing from uv.lock" + assert all(requirement.specifier.contains(version) for version in locked_versions), ( + f"{dependency} versions {locked_versions} do not satisfy {requirement.specifier}" + ) diff --git a/uv.lock b/uv.lock index 3fa64cf..1e41d5a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,15 +1,15 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.12' and sys_platform == 'emscripten'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] @@ -34,9 +34,9 @@ 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 = "pandas" }, + { name = "pandas", marker = "extra == 'research'", specifier = ">=3.1.0" }, { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=c7646a7168b3dafa763ef7751a182d23e8de7790" }, ] provides-extras = ["research"] @@ -208,7 +208,7 @@ wheels = [ [[package]] name = "pandas" -version = "3.0.5" +version = "3.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, @@ -216,49 +216,65 @@ dependencies = [ { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/b1/a10c0d1a7e3ce44eb42635771004aa503e3b9ddde0bd39c524dda65f7eff/pandas-3.1.0.tar.gz", hash = "sha256:ecb9b2d8a817b1e1ba1fdb90c0abe9eab31b4fdf2562b7a56b86ac66ace17e49", size = 5933421, upload-time = "2026-10-22T11:57:03.087Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/ef/f1fd7431d635bf20015489bf0bd69c17fff1018de773540f651455a3916b/pandas-3.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2946e77e4a53cd248cbde631a12f0e51c8324ce354c3eba4d20147c1ad6f4282", size = 10397178, upload-time = "2026-07-22T22:17:48.274Z" }, - { url = "https://files.pythonhosted.org/packages/31/b4/0eafac990a431561187694126de01f9b12559549b4d86360c0c4bd870fde/pandas-3.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71ecc8fb7ed1a7aa4392316b5309a6347e8e7f832f38fd897846b3a1457a9298", size = 9990736, upload-time = "2026-07-22T22:17:52.388Z" }, - { url = "https://files.pythonhosted.org/packages/de/21/359880af3ea9b7cb23bea5b51e8e70ef3866c03be09da9a2787e18e330a8/pandas-3.0.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b173f5951ff6b8b0ec7675e20dff3c97b7e7a57dfcce387c2d7c5afe87cb7899", size = 10814438, upload-time = "2026-07-22T22:17:54.708Z" }, - { url = "https://files.pythonhosted.org/packages/d1/50/d6cc4d7e508bbccf5d6027314a8312bc7ac73d0ec7f195f53838daafab40/pandas-3.0.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c0cf1dd9b55a22d105fc46c1b489af3bd42264fcba7c66297bf47a9a1d9c78a", size = 11323634, upload-time = "2026-07-22T22:17:56.858Z" }, - { url = "https://files.pythonhosted.org/packages/70/2b/d5f0a8c90dd0ae04e64ba53b871afb796ec026b615086d382ddc2ade729b/pandas-3.0.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0fac0010c75e4efb6b99e249c183a8993ce0dc95c240f9b120a5e67c727b7928", size = 11850860, upload-time = "2026-07-22T22:17:59.1Z" }, - { url = "https://files.pythonhosted.org/packages/5c/30/183aec2e19adf778a98d29b5729a0a68f4cc4ebf9b9c3b70d0297355bcb1/pandas-3.0.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:08d24fe11a17dc33bd6e937dc9c665f9cba08fbdc9f657f405713515febe300d", size = 12411100, upload-time = "2026-07-22T22:18:01.485Z" }, - { url = "https://files.pythonhosted.org/packages/fa/9a/31f4983f191af51ab2a8f2d0c7b33dff3a84da26533f982fff02c2f9e28b/pandas-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b1261758dfb6cf12c3cff8300e21cefad30e7ec709abb4c24ac7318e6a52462a", size = 9968804, upload-time = "2026-07-22T22:18:03.903Z" }, - { url = "https://files.pythonhosted.org/packages/49/97/7886c89a39045c69ad82cbceaf3343810480c8ef49a216319ce8183860a6/pandas-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:679f4e85b30ddb1515458ab1e788d3e260eae369b1f78da7a3aa4cac8ebf4a2a", size = 9205447, upload-time = "2026-07-22T22:18:06.134Z" }, - { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, - { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, - { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, - { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, - { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, - { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, - { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, - { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, - { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, - { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, - { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, - { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, - { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, - { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, - { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, - { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, - { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, - { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, - { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, - { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, - { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, - { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, - { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, - { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, - { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, + { url = "https://files.pythonhosted.org/packages/36/f2/d8d89ee886df6d959d7b9f483a55a0e5a1d5b034f08c32f4343a524af802/pandas-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d53387f8bbff4e7aa567619d4034ffdb1836db1e768a482851b904bbeeed8ef9", size = 11595013, upload-time = "2026-10-22T11:54:48.33Z" }, + { url = "https://files.pythonhosted.org/packages/ec/73/e5243378792b79d3d08f9a6782a0e23ae65cc8c6d2f476b9d2825946b071/pandas-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0d2ca3b380f0f6cc788780430eb23762619730c11d53989f3982ec96835bfb9e", size = 11159002, upload-time = "2026-10-22T11:54:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4a/1064a6ec84ae047379a5969f6b331bd8d57013f08b30976564780fb0c9bd/pandas-3.1.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:388fad0c683ad061baabe23a76f43bf43c1d2c2fd46572b687082cfa195b7275", size = 11758276, upload-time = "2026-10-22T11:54:53.367Z" }, + { url = "https://files.pythonhosted.org/packages/14/41/2d264868728f7cb2add91a605bea5f2aabd3da41889ee97746a586ec34ee/pandas-3.1.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a28602573f50453b57802d282f9f8cb7663aa10201127ecbbe95ed4a41c64ce", size = 12330371, upload-time = "2026-10-22T11:54:55.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/04/790ac6114ceb45575d7d7a909126210f829978f18f4f10ea8c6c847f0118/pandas-3.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e6f83e44846f9386e3593965bac017a54a5b5c611ea7b836ceab7f465bb4cb24", size = 12777723, upload-time = "2026-10-22T11:54:58.08Z" }, + { url = "https://files.pythonhosted.org/packages/56/b1/0513aaaac23bc1400b831881448665565a1b9b327d3eb7c5aca2b23c0b84/pandas-3.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebbc0e652844e52e06d1525e5084ec1609158c086c8646aa6ccb34802102e533", size = 13411658, upload-time = "2026-10-22T11:55:00.372Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1a/e33f6637445dd4b7323aa7bdaf4cc15c383805b4a2e99f958b7e32296e58/pandas-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:a7704e3d7367cfae71d3f8c95ee81e178c7a90bef8d6eac30df9815010722259", size = 10977787, upload-time = "2026-10-22T11:55:02.967Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f7/86bd686a20a5052e15581fdc7f220098426b1982604fb545e78bdd5b5b2e/pandas-3.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:d3e660af30a6683fa57a5a2969555df117e873eaaeb418ad3b2e59249405ee70", size = 10305708, upload-time = "2026-10-22T11:55:05.046Z" }, + { url = "https://files.pythonhosted.org/packages/e9/12/b94c28870f351e9650c724e6daedf661455b348a9dca6d717c42c30fa216/pandas-3.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f4772320b7735d96dbbef437129f840dec4175f8a18a2391bd1222ed8943825c", size = 11587014, upload-time = "2026-10-22T11:55:07.208Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4b/af8ca15261db52ab07f5d0c2d4c1bc28d822ea4c2a79756bc2606c012e7c/pandas-3.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cc5e4e12d287ea77bbf4c4d4bf47ed152d82a990d3a6794dee7c350f887532b5", size = 11167712, upload-time = "2026-10-22T11:55:09.523Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/cd5127751e2bef3d10f07e0d856e120916a3df0df6450f759ddb48c78b1c/pandas-3.1.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b4e696ff70ed06e986feeec09356c491ce878d0df69a36c8e591b6a38afa29b", size = 11476964, upload-time = "2026-10-22T11:55:11.889Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a6/dc89cdd95e12093bc813e9b814a09f1a056be3e1304edaef9bf9dbee108a/pandas-3.1.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9433b5dc2f26ef9d9b56daaf4d41d078bf4372c5d88c42ed5b397a6eea80de16", size = 12019688, upload-time = "2026-10-22T11:55:14.246Z" }, + { url = "https://files.pythonhosted.org/packages/36/87/da14aa671da792612f15847451e731d5dc0f06f6747b76b15f197ffcce13/pandas-3.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3cba20fd269808e224d6212273008173978033a1dee0f3f217bfdbd58775a72c", size = 12490149, upload-time = "2026-10-22T11:55:16.675Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a0/1ce996c7354ad37983955571058ba0dde585663163ab067275197183868f/pandas-3.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:743554ed96755ad1963397ccadbe9965d92db8777ca2ae8c1fe58bd9549d7f28", size = 13090624, upload-time = "2026-10-22T11:55:18.854Z" }, + { url = "https://files.pythonhosted.org/packages/92/72/d235f82b51a22478938a3583b68e8dc05811937543adf410f34bab76004a/pandas-3.1.0-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:ac2f833557874a79fc0b5fac4c86e402ea6498beba8753dc29ca23d08481689a", size = 8091628, upload-time = "2026-10-22T11:55:21.285Z" }, + { url = "https://files.pythonhosted.org/packages/10/72/06aefff8fe5efc5e1ca8bfb2695b648a435358f6dc224d4e40159314181a/pandas-3.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:b8c75dbb27c7d170b25c13dd35515da4bb92d40fe8bb3bd2c69a95de2218889b", size = 10748836, upload-time = "2026-10-22T11:55:23.47Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/4c86f659f5b14790d14e17253becc93c4e3d1511b73f0ba2184f06b164f8/pandas-3.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:73b6cea22090b7123c78ee662a5108ff36616b0bec4fe47026f86fb04a49bce7", size = 10128898, upload-time = "2026-10-22T11:55:25.995Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/eb85f8b2ce121c21286842cf81abaa670ec3570173c512298f91128927a5/pandas-3.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:849d5019dead65944875ce21fbf71a2ca18e982164cd7367623cbda8567c3122", size = 11609893, upload-time = "2026-10-22T11:55:28.592Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/b327e814327b7e7e61b5c5cceafd5c0c20f0d38e94f99ebf2dd6c1cec85b/pandas-3.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c1e084760f3b275eed5863407d025b933d25f3247515fcc84bf664405e8bb3d2", size = 11182834, upload-time = "2026-10-22T11:55:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6d/2b918a45ad31d85f898567c948f11d0789ffe7fbadd6f487565e99d179b6/pandas-3.1.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1009f5b6819b81319cf177b4f01a2823ed54c81db8bd11cdf2375f42d064c02", size = 11421478, upload-time = "2026-10-22T11:55:33.257Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6f/b63e1d43eef5746014ae3f4786c72e0020e61b6b373a2abb6ebf1be3df49/pandas-3.1.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de6d0fc96e8551b83f400bf212611a0b3a2b389594d20a2540dacc61b21e7bce", size = 11991052, upload-time = "2026-10-22T11:55:35.556Z" }, + { url = "https://files.pythonhosted.org/packages/fd/43/2cf85145d822957a3f8959fd0a850e6bf687dac2823c612b3912b8888dae/pandas-3.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5611344f9f0ed51619ac7acdbf72b86f5043801a66e3f8730c8971257082e8ad", size = 12455560, upload-time = "2026-10-22T11:55:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/0cb19cea2e6afbb0854cbde0ed1345b61600e0de84eb7b73583fde3e3b55/pandas-3.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:39c67ed9e56ad3ac0727dc2ae6a1eef766988c762d5ce2e2a162613d81c8990b", size = 13045764, upload-time = "2026-10-22T11:55:39.969Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5c/ea5472e8444f5871427f3a6818725684fcdce74223ca1b22aa7ca345d490/pandas-3.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:07807035ad66de0d833d68f9bee05801dbf08b88d56a233b0e9fd189372c3261", size = 10729719, upload-time = "2026-10-22T11:55:42.008Z" }, + { url = "https://files.pythonhosted.org/packages/45/f7/8cdd350c67bc62865d1e30cd7add43dd631420223487ba42dfcfd0d0e052/pandas-3.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:54fed4a50b8321ff8d413b46b7e31a023cecefd06c7210c02aa980a9836428b2", size = 10116651, upload-time = "2026-10-22T11:55:44.367Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a9/e82d44fe20dc61dd2e15bdd17c78aa0023d860d55620e4fe82f9e644a089/pandas-3.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a8c44d40cbb6d00128bf2d8f8bb66c8d561744b6d44686b5a1a01f3b3fd1245f", size = 11675868, upload-time = "2026-10-22T11:55:46.617Z" }, + { url = "https://files.pythonhosted.org/packages/83/38/6be618d98e2cbbb99a40349717532f0c2b6794ddc62f1199e3f83e45a40f/pandas-3.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:89b66823678cdd53cf97187e7cd6861402bdd147d11a383706093136a4e83f45", size = 11262553, upload-time = "2026-10-22T11:55:49.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/5c/66e27fb0d691e835bd55b336bdf47312a541a66a17ba4d72a988d743bb79/pandas-3.1.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a8dc7780720b9e2182799ee86279d4cc7d1fd30b230ea1b202da5b6f7e14c3a", size = 11548155, upload-time = "2026-10-22T11:55:51.674Z" }, + { url = "https://files.pythonhosted.org/packages/37/1e/42e81484c5a6791ab1b8c52c1ff1ef0483c119f710ed58ee603d241b8792/pandas-3.1.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4455cdcc03e42c17233184e8e4ea65c3ed2925d8b1d9d7dd8e6fced3f6e43c1", size = 12029136, upload-time = "2026-10-22T11:55:53.823Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c9/a3b4fd156457f3eb2585838cd56427a113eeeba679204de435a3224b8cc3/pandas-3.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:224a9267e5fa70c16bf4f8a317435eef98387940ce6c8c611f1f68f2c3415e10", size = 12575350, upload-time = "2026-10-22T11:55:56.006Z" }, + { url = "https://files.pythonhosted.org/packages/4e/25/23a017003d792117b297bbd87e35a1bb5658509f610ecd5aeaebb84762aa/pandas-3.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c261c7a808982557c2917d40ffd27916769f26aacb4e6775c01e4467bcd34c3b", size = 13096142, upload-time = "2026-10-22T11:55:58.434Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d2/f4074b7d1234d112aaa23a796be1574441a9fa873707eb98312a237a6185/pandas-3.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:e77112cfcabb3bef7281a72170670db9a5b31409a1cd3199939abb1fba67a7ad", size = 10885958, upload-time = "2026-10-22T11:56:00.895Z" }, + { url = "https://files.pythonhosted.org/packages/25/9f/bcfc9e0ea07f5c7583e129e62cd0529747317977ac1b85878b80235bb322/pandas-3.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:6c51273b89784c9d54020ed2f60e20524169aef349bddf6cba9f5e83b46077a7", size = 10281257, upload-time = "2026-10-22T11:56:03.478Z" }, + { url = "https://files.pythonhosted.org/packages/05/e4/b3bf0b702c0a886e4abcfc2ebdc681310498592a10d9ca0ce5db6df47b70/pandas-3.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84377fa82d0b40165379a0058d8a1a267ceb09e65160de6fcc75475d4d595884", size = 12134028, upload-time = "2026-10-22T11:56:05.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ff/9e08cf048d73f4dfdb10da859790cd1a4213465dec39ea367a606d580c4a/pandas-3.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:37f88576f3449bfd86c1052f6b42274b61d7250c8d9dcc55f9dc4de7fe9883ae", size = 11729164, upload-time = "2026-10-22T11:56:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/a0df60d70fac10006841457c30d35647ae0c93af33e420e5e047b15e058d/pandas-3.1.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:756fd67995f3efb6b25f8eaea95c0e893f84e0068e2c53c5fdbb65275041d4db", size = 11413041, upload-time = "2026-10-22T11:56:11.38Z" }, + { url = "https://files.pythonhosted.org/packages/55/04/e6742d85ba7d166a1a8a99ff9f4aa9e585fb24028522daa187859ce50b5d/pandas-3.1.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4d9252b57bded194d09cd5ebf18d9b4edac238b16f56984ea8281d2ecfe39cd", size = 11869427, upload-time = "2026-10-22T11:56:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/c9f4915408ef10b541f31b7812ca038dd903572c163b7be7bdcece8a5110/pandas-3.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:461b79db058639e446ddf90dd7f4804fae385acf0669f6e31321333e14cb6bc5", size = 12430367, upload-time = "2026-10-22T11:56:15.829Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/636b9cc8d30b3e2027b43036dd5a8e9e7cfdd5aa86f4454235c5ef7f96db/pandas-3.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bfac077874a396b7ae61a307b27aa6f0256cf31c143b7f96ac622765754cc300", size = 12945204, upload-time = "2026-10-22T11:56:18.419Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5e/5c28942fa8c6cf2d8b4e941abee43a44ba0a123f9fef6411f0319aefddd5/pandas-3.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1340f823a1d189fae8365de314c4d3c2ea0173c61bca66cc76a91385c64d9d3d", size = 11297553, upload-time = "2026-10-22T11:56:20.934Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a4/5991dfb2cccd233ae754f2fb37a4b1474d90595d83f6790467dc5a7d8041/pandas-3.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2bb3f8cc42fae0c0de5186778f4a4957656b9b3955aca48383abfdac44a83598", size = 10626068, upload-time = "2026-10-22T11:56:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/77/26/5cd42609d8e6cb3991242116a0e499d09f29f5bcf574f72f0a4aba13a87e/pandas-3.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:04ca88727dfa86837030c493d7512bb1c008aa4f5f14149c5f8d3ff446791340", size = 11666949, upload-time = "2026-10-22T11:56:25.842Z" }, + { url = "https://files.pythonhosted.org/packages/97/5f/ca1ac99da700c215c52fb0f5063a7fbf14878c6e100f687704c21985640e/pandas-3.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c0bfec4d188fb0c05bba47b9f9def87624b8c545913aa3585a28f19f27ce847e", size = 11255062, upload-time = "2026-10-22T11:56:28.201Z" }, + { url = "https://files.pythonhosted.org/packages/d3/eb/8d7aef062e7d1f48869288e5c5ae5d109923f37ed3bbd0fdcaecc87784e9/pandas-3.1.0-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfb087db83eb79ca348047961d367bdd4bf9820d90a5945f3652c7060da7363", size = 11547558, upload-time = "2026-10-22T11:56:30.492Z" }, + { url = "https://files.pythonhosted.org/packages/06/c4/a041c786bc29935afe44806f72c0111f2643cbac5cab761007a227e12e97/pandas-3.1.0-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b5ce18612ca955e7a95d0bacc20120aedf97e2fbbebbe4de029fc24479e79b8", size = 12026259, upload-time = "2026-10-22T11:56:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4a/ce6690c7e6081c96857ad7dcbe3b978394d8db682610755919e61863516f/pandas-3.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a13199463b129363701a540c7b79586581b246b10b6a7ad682c41b268e7991d5", size = 12563821, upload-time = "2026-10-22T11:56:35.402Z" }, + { url = "https://files.pythonhosted.org/packages/75/ec/ae440ecbfa8f5ba0230541b4519c1fed0c4293ff362f18f7f353b12e2dca/pandas-3.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:8e4de3fe8017e3445f1174707cd977e4e1154a704af2c060a1f648862f81acb9", size = 13086426, upload-time = "2026-10-22T11:56:37.666Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1e/6459d4b0ed9c3d72b9ab63387ec11e1e658579e702e0ef117be8b5d7d0d1/pandas-3.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:eee72bba8a72f0bf9522132e5c87cd10fce9d77440b50f65326b093ec9fb8d43", size = 10877850, upload-time = "2026-10-22T11:56:39.994Z" }, + { url = "https://files.pythonhosted.org/packages/f5/22/e5fad1828e5f923e80a8d7ba9187f687159004a10f99f95dd5346b04c54e/pandas-3.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:9ea2983fe096a3d997c8c4b9f26775fa51e7fb251b10634bd7f87e6fe3542d52", size = 10276384, upload-time = "2026-10-22T11:56:42.193Z" }, + { url = "https://files.pythonhosted.org/packages/59/76/89820c2e1950425f39d30695d2296a8c7d400c4c2d279e7618363a7289ac/pandas-3.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:84d73e0814a38711e4f2a24b272f748a0b4335169d2f9752407ae26bde22990f", size = 12107257, upload-time = "2026-10-22T11:56:44.498Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2c/af4397e6e855d2c0dcb3392ab7788087be4ef53f6a3f166daad8b83c783e/pandas-3.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d3920d9b4de89fbef76ba6b863d58a330b5bfb720c9f8f58eb9b7da9b711d034", size = 11704430, upload-time = "2026-10-22T11:56:46.791Z" }, + { url = "https://files.pythonhosted.org/packages/fd/9e/7791cd269099f9fca7dc4718955cfe4bb94aedb6634b59eeffefb1ed6a74/pandas-3.1.0-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1d61e0eae5219cd024fcbc21304de759964b5642ddcdbb28d9a8ba442ab6901", size = 11378442, upload-time = "2026-10-22T11:56:49.113Z" }, + { url = "https://files.pythonhosted.org/packages/54/6c/da4013bd33c49a12eb3127771111caeed3222a985651e3465bc06f839ce7/pandas-3.1.0-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbb121205af7bc5e587b94a07b5f67b5ba687a918665b9e8748f21388a88b044", size = 11850193, upload-time = "2026-10-22T11:56:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/14/89/8c820bd6fb6ad8f0edb2877639bc7e164bfb4b8e4cdf7a2202f54959ea1b/pandas-3.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:684ef3add5a92abb99c1622f2bd04b54b8843075a266e8240106ca94ce1d1237", size = 12394022, upload-time = "2026-10-22T11:56:53.785Z" }, + { url = "https://files.pythonhosted.org/packages/92/f5/9d753d85c4ea85b6d1c3735496ae5b416d7ef6905f1845c70e9521d124d9/pandas-3.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dde9a55ecd3c4f9100ac50ab15e852e4c54fcf372d800e4495e0b2159a0122ab", size = 12916085, upload-time = "2026-10-22T11:56:56.084Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f9/4a6965677477dbbb19f8881befb01b67eb6009809db73f7f1b7a0132faf1/pandas-3.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:3fe234bfb9999552dc54c45851752e56e163218d7663897dca950b4a7c32af39", size = 11267097, upload-time = "2026-10-22T11:56:58.479Z" }, + { url = "https://files.pythonhosted.org/packages/99/35/e60fd8228db4eb848802988dbc6e3a0f1a81c57f06ad95ebe4cd534fa655/pandas-3.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f1a93c24175129bfc9922ef748ddae1d8137c743ed835e2615be329f606fed4d", size = 10597861, upload-time = "2026-10-22T11:57:00.776Z" }, ] [[package]] From 454b97d9a82b4d1c910015331d4a23251a67cdc3 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:46:10 +0800 Subject: [PATCH 6/6] fix(ci): scope the research environment to runner steps --- .github/workflows/research-lock.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/research-lock.yml b/.github/workflows/research-lock.yml index e9c9305..8b2bb49 100644 --- a/.github/workflows/research-lock.yml +++ b/.github/workflows/research-lock.yml @@ -17,7 +17,6 @@ jobs: python-version: ["3.11", "3.12"] env: UV_PYTHON: ${{ matrix.python-version }} - UV_PROJECT_ENVIRONMENT: ${{ runner.temp }}/crypto-research-environment steps: - name: Checkout uses: actions/checkout@v6 @@ -36,11 +35,15 @@ jobs: git diff --exit-code -- uv.lock - name: Install the frozen research environment + env: + UV_PROJECT_ENVIRONMENT: ${{ runner.temp }}/crypto-research-environment run: | uv sync --frozen --extra research --no-editable uv pip check --python "${UV_PROJECT_ENVIRONMENT}/bin/python" - name: Verify installed versions, origins and public import + env: + UV_PROJECT_ENVIRONMENT: ${{ runner.temp }}/crypto-research-environment run: | cd "${RUNNER_TEMP}" "${UV_PROJECT_ENVIRONMENT}/bin/python" -I - "${GITHUB_WORKSPACE}" <<'PY'