From 5cae077cfe0d57499840c6f8fd9a25ac67d638a7 Mon Sep 17 00:00:00 2001 From: DivyaNautiyal07 Date: Thu, 21 May 2026 17:39:03 +0530 Subject: [PATCH 01/17] chore: add cleanup plan for review Planning-only commit. Outlines the remaining wave-2-style cleanup work to bring quickstart in line with trader (latest tomte, minimal tox.ini, slim CI). PR #172 already handled the poetry to uv migration. The plan covers what's still missing: tomte in dev deps, a tox.ini, a Makefile fix, a linter CI job, and a CONTRIBUTING.md trim. No runtime change. Once approved, implementation commits will be pushed on top of this same branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLEANUP_PLAN.md | 206 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 CLEANUP_PLAN.md diff --git a/CLEANUP_PLAN.md b/CLEANUP_PLAN.md new file mode 100644 index 00000000..3babe0ee --- /dev/null +++ b/CLEANUP_PLAN.md @@ -0,0 +1,206 @@ +# Cleanup plan for quickstart + +This is a planning-only PR. Nothing in this commit changes runtime +behaviour. Once the plan is approved, implementation commits will be +pushed on top of this same branch. There will be no separate PRs per +phase. This `CLEANUP_PLAN.md` will be deleted in the final +implementation commit. + +The goal is to bring quickstart in line with where `trader` sits today: +on the latest `tomte` for linting, with a minimal `tox.ini`, and a +slim CI workflow that delegates to `tomte tox` for lint envs. + +## What's already done (not in scope) + +PR #172 (chore/uv-migration) already landed most of the "wave 2" +work that applies here: + +- `pyproject.toml` is on PEP 621 with `tool.uv.package = false`. +- `poetry.lock` removed, `uv.lock` present. +- All 8 operator shell scripts (`run_service.sh`, `stop_service.sh`, + `analyse_logs.sh`, `claim_staking_rewards.sh`, `reset_configs.sh`, + `reset_password.sh`, `reset_staking.sh`, + `terminate_on_chain_service.sh`) use `uv sync` and `uv run` instead + of `poetry install` and `poetry run`. + +Nothing in this PR touches those. + +## What's in scope + +### 1. Add tomte and a `[tool.tomte]` block to `pyproject.toml` + +Tomte is not in `[dependency-groups].dev` today. Add it pinned to the +same version trader is on (v0.7.0): + +```toml +[dependency-groups] +dev = [ + "pexpect==4.9.0", + "pytest==9.0.3", + "pytest-cov==7.1.0", + "tomte[cli, tests] @ git+https://github.com/valory-xyz/tomte.git@v0.7.0", +] + +[tool.tomte] +tomte_dep_pin = " @ git+https://github.com/valory-xyz/tomte.git@v0.7.0" +# extra excludes / pylint disables added at implementation time +# based on actual lint output against current scripts/ and tests/. +``` + +The check_dependencies_extra_excludes and pylint disables will be set +empirically. Quickstart has only `scripts/` and `tests/` to lint, so +the list should stay short. + +### 2. Add a minimal `tox.ini` + +Quickstart has no `tox.ini` today. Add one that follows the trader +shape: a thin `[tomte-extensions]` block plus any per-repo overrides. +Drops AEA-only envs (`check-hash`, `check-packages`, +`check-abciapp-specs`, `check-handlers`, `liccheck`, +`check-third-party-hashes`) because quickstart has no `packages/` +directory. + +Draft: + +```ini +; Local extensions to tomte's canonical tox.ini. Consumed by `tomte tox`. + +[tomte-extensions] +extra_pylint_disables = C0114,C0115,C0116,R0801 + +[pytest] +tomte_defaults = true +addopts = -p no:pytest_anchorpy +``` + +The `-p no:pytest_anchorpy` line stays because the same anchorpy +issue that `pyproject.toml` already addresses still applies under +`tomte tox`. If lint surfaces missing imports, add minimal `[mypy-*]` +blocks at implementation time. + +### 3. Fix the Makefile + +The Makefile is the one file PR #172 didn't update. It still uses +poetry: + +```make +install: + poetry install --only main + +test-install: + poetry install + +run_no_staking_tests: + poetry run pytest -v tests/test_run_service.py -s --log-cli-level=INFO + +test: test-install run_no_staking_tests +``` + +Two options for the reviewer to pick: + +- **Option A (keep, fix):** rewrite to use `uv sync` and `uv run + pytest`. One line per target. +- **Option B (drop):** delete the Makefile. It's not referenced from + README, CI, or any other script. The 8 shell scripts already cover + install + run. + +Recommendation: Option B. Open to feedback. + +### 4. Wire CI to run `tomte tox` for lint envs + +Current `.github/workflows/python-tests.yml` is 344 lines. It runs +`uv sync` and `uv run pytest` for three e2e jobs plus a unit test +matrix. It does no linting. No `black`, `isort`, `flake8`, `mypy`, +`pylint`, `darglint`, `bandit`, `safety`. + +Add a `linter_checks` job that mirrors trader's `common_checks.yaml`: + +```yaml +linter_checks: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.10" + - name: Install dependencies + run: pip install 'tomte[tox,cli] @ git+https://github.com/valory-xyz/tomte.git@v0.7.0' tox-uv + - name: Code checks + run: tomte tox -p -e black-check -e isort-check -e flake8 -e mypy -e pylint -e darglint + - name: Security checks + run: tomte tox -p -e safety -e bandit +``` + +The four existing jobs (`setup`, `e2e-test-run-service`, +`e2e-test-staking`, the pearl-migration test, `unit-tests`) stay as +they are. They already use uv correctly. + +Expected drop: ~344 lines is mostly the four big jobs. The linter job +adds ~20 lines net. + +### 5. CONTRIBUTING.md slim down + +Current `CONTRIBUTING.md` is 135 lines with two distinct sections: + +- A generic contribution workflow (lines 1 to 60ish). This is + near-identical to the equivalent section in every other Valory + repo and is exactly the kind of "useless doc" the wave-2 cleanup + pattern replaces with a stub linking to + `open-autonomy/CONTRIBUTING.md`. +- A `config.json` schema reference (lines 60ish to 135). This is + quickstart-specific and useful. + +Plan: + +- Replace the workflow section with a ~10-line stub linking to the + canonical `open-autonomy/CONTRIBUTING.md`. +- Move the schema reference into `README.md` (it sits naturally next + to the existing config explanation there) or into a new + `docs/config_schema.md`. Open question, recommendation is README.md. + +After the move, `CONTRIBUTING.md` ends up ~15 lines. + +## What's explicitly out of scope + +- **`pyproject.toml` and lockfile work**. Done in PR #172. +- **Shell scripts**. Done in PR #172. +- **Removing migration scripts** + (`scripts/predict_trader/migrate_legacy_quickstart.py`, + `scripts/optimus/migrate_legacy_optimus.py`). Both are still + referenced from `scripts/*/README.md` and from + `scripts/pearl_migration/prompts.py`. They're load-bearing. +- **`.gitleaks.toml` stub**. Quickstart runs no gitleaks scan today. + Adding one is a separate security task. +- **README rewrite**. Only one section gets added (the config schema + moved from CONTRIBUTING.md). The rest stays as-is. +- **Removing any `.sh` script**. All 8 are referenced from README as + the public operator interface. +- **Touching `scripts/pearl_migration/`**. It's recently added and + actively used. + +## Open questions for the reviewer + +1. **Makefile: keep or drop?** Recommendation: drop. It's not + referenced anywhere and the shell scripts cover install + run. +2. **Config schema doc: move to `README.md` or to a new + `docs/config_schema.md`?** Recommendation: `README.md` so it + lives next to the existing config explanation. + +## Sequencing once approved + +Implementation lands as commits on this same branch in this order: + +1. Add tomte to dev deps and `[tool.tomte]` to `pyproject.toml`. + Run `uv lock` to refresh `uv.lock`. +2. Add `tox.ini`. Run `tomte tox -p -e black-check -e isort-check -e + flake8 -e mypy -e pylint -e darglint` locally. Fix lint output. +3. Apply the Makefile decision (drop or rewrite). +4. Add the `linter_checks` CI job. +5. Slim `CONTRIBUTING.md` and move the schema section per the + reviewer's pick. +6. Delete this `CLEANUP_PLAN.md`. + +Expected total diff (commits 1 to 6): ~250 lines added (tox.ini, +`[tool.tomte]` block, CI job, README section), ~120 lines removed +(CONTRIBUTING.md trimmed, Makefile possibly dropped, `uv.lock` +delta is mechanical). From 31ffafd1fa6912ed2b361bf784093f1c429f034c Mon Sep 17 00:00:00 2001 From: DivyaNautiyal07 Date: Thu, 21 May 2026 18:27:52 +0530 Subject: [PATCH 02/17] chore: address review feedback on cleanup plan - Use PyPI pin `tomte[cli, tests]==0.7.0` instead of the git URL (v0.7.0 is published, the git form is unnecessary). - Keep `liccheck` in scope: it reads [Licenses] and [Authorized Packages], nothing AEA-specific. - Lock Makefile decision to "delete" (no references). - Config schema doc stays in CONTRIBUTING.md (no move). - Drop the "open questions" section since both are resolved. - Sketch [Licenses] + [Authorized Packages] blocks in the tox.ini draft. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLEANUP_PLAN.md | 99 +++++++++++++++++++++++++++++-------------------- 1 file changed, 58 insertions(+), 41 deletions(-) diff --git a/CLEANUP_PLAN.md b/CLEANUP_PLAN.md index 3babe0ee..e7d9d40d 100644 --- a/CLEANUP_PLAN.md +++ b/CLEANUP_PLAN.md @@ -29,8 +29,9 @@ Nothing in this PR touches those. ### 1. Add tomte and a `[tool.tomte]` block to `pyproject.toml` -Tomte is not in `[dependency-groups].dev` today. Add it pinned to the -same version trader is on (v0.7.0): +Tomte is not in `[dependency-groups].dev` today. Add it pinned to +v0.7.0 (also the version trader is on). v0.7.0 is published on PyPI, +so a regular `==` pin is fine. No need for the git URL form: ```toml [dependency-groups] @@ -38,11 +39,11 @@ dev = [ "pexpect==4.9.0", "pytest==9.0.3", "pytest-cov==7.1.0", - "tomte[cli, tests] @ git+https://github.com/valory-xyz/tomte.git@v0.7.0", + "tomte[cli, tests]==0.7.0", ] [tool.tomte] -tomte_dep_pin = " @ git+https://github.com/valory-xyz/tomte.git@v0.7.0" +tomte_dep_pin = "==0.7.0" # extra excludes / pylint disables added at implementation time # based on actual lint output against current scripts/ and tests/. ``` @@ -54,11 +55,12 @@ the list should stay short. ### 2. Add a minimal `tox.ini` Quickstart has no `tox.ini` today. Add one that follows the trader -shape: a thin `[tomte-extensions]` block plus any per-repo overrides. -Drops AEA-only envs (`check-hash`, `check-packages`, -`check-abciapp-specs`, `check-handlers`, `liccheck`, -`check-third-party-hashes`) because quickstart has no `packages/` -directory. +shape: a thin `[tomte-extensions]` block, a `[Licenses]` block for +`liccheck`, and any per-repo overrides. Drops AEA-only envs +(`check-hash`, `check-packages`, `check-abciapp-specs`, +`check-handlers`, `check-third-party-hashes`) because quickstart has +no `packages/` directory. `liccheck` stays in scope (it reads +`[Licenses]` and `[Authorized Packages]`, not AEA state). Draft: @@ -71,6 +73,23 @@ extra_pylint_disables = C0114,C0115,C0116,R0801 [pytest] tomte_defaults = true addopts = -p no:pytest_anchorpy + +[Licenses] +authorized_licenses = + bsd + new bsd + bsd license + apache + apache 2.0 + apache software + mit + mit license + python software foundation license +unauthorized_licenses = + gpl v3 + +[Authorized Packages] +; per-repo allowlist filled at implementation time, mirroring trader's pattern ``` The `-p no:pytest_anchorpy` line stays because the same anchorpy @@ -78,7 +97,7 @@ issue that `pyproject.toml` already addresses still applies under `tomte tox`. If lint surfaces missing imports, add minimal `[mypy-*]` blocks at implementation time. -### 3. Fix the Makefile +### 3. Delete the Makefile The Makefile is the one file PR #172 didn't update. It still uses poetry: @@ -96,15 +115,9 @@ run_no_staking_tests: test: test-install run_no_staking_tests ``` -Two options for the reviewer to pick: - -- **Option A (keep, fix):** rewrite to use `uv sync` and `uv run - pytest`. One line per target. -- **Option B (drop):** delete the Makefile. It's not referenced from - README, CI, or any other script. The 8 shell scripts already cover - install + run. - -Recommendation: Option B. Open to feedback. +Confirmed: no references from README, CI, or any `.sh` script. The 8 +operator shell scripts already cover install + run. Per reviewer +feedback, the Makefile gets deleted outright at implementation time. ### 4. Wire CI to run `tomte tox` for lint envs @@ -124,11 +137,13 @@ linter_checks: with: python-version: "3.10" - name: Install dependencies - run: pip install 'tomte[tox,cli] @ git+https://github.com/valory-xyz/tomte.git@v0.7.0' tox-uv + run: pip install 'tomte[tox,cli]==0.7.0' tox-uv - name: Code checks run: tomte tox -p -e black-check -e isort-check -e flake8 -e mypy -e pylint -e darglint - name: Security checks run: tomte tox -p -e safety -e bandit + - name: License compatibility check + run: tomte tox -e liccheck ``` The four existing jobs (`setup`, `e2e-test-run-service`, @@ -150,15 +165,15 @@ Current `CONTRIBUTING.md` is 135 lines with two distinct sections: - A `config.json` schema reference (lines 60ish to 135). This is quickstart-specific and useful. -Plan: +Plan (per reviewer): - Replace the workflow section with a ~10-line stub linking to the canonical `open-autonomy/CONTRIBUTING.md`. -- Move the schema reference into `README.md` (it sits naturally next - to the existing config explanation there) or into a new - `docs/config_schema.md`. Open question, recommendation is README.md. +- Keep the `config.json` schema reference where it is, in + `CONTRIBUTING.md`. No move. -After the move, `CONTRIBUTING.md` ends up ~15 lines. +After the trim, `CONTRIBUTING.md` ends up at the stub plus the +existing schema section (~85 lines, down from 135). ## What's explicitly out of scope @@ -178,13 +193,12 @@ After the move, `CONTRIBUTING.md` ends up ~15 lines. - **Touching `scripts/pearl_migration/`**. It's recently added and actively used. -## Open questions for the reviewer +## Reviewer decisions resolved -1. **Makefile: keep or drop?** Recommendation: drop. It's not - referenced anywhere and the shell scripts cover install + run. -2. **Config schema doc: move to `README.md` or to a new - `docs/config_schema.md`?** Recommendation: `README.md` so it - lives next to the existing config explanation. +- Tomte pin: PyPI `==0.7.0` form, not the git URL. +- `liccheck`: kept in scope. +- Makefile: deleted outright. +- Config schema doc: stays in `CONTRIBUTING.md` (no move). ## Sequencing once approved @@ -192,15 +206,18 @@ Implementation lands as commits on this same branch in this order: 1. Add tomte to dev deps and `[tool.tomte]` to `pyproject.toml`. Run `uv lock` to refresh `uv.lock`. -2. Add `tox.ini`. Run `tomte tox -p -e black-check -e isort-check -e - flake8 -e mypy -e pylint -e darglint` locally. Fix lint output. -3. Apply the Makefile decision (drop or rewrite). -4. Add the `linter_checks` CI job. -5. Slim `CONTRIBUTING.md` and move the schema section per the - reviewer's pick. +2. Add `tox.ini` (with `[tomte-extensions]`, `[pytest]`, `[Licenses]`, + `[Authorized Packages]` blocks). Run + `tomte tox -p -e black-check -e isort-check -e flake8 -e mypy -e + pylint -e darglint -e bandit -e safety -e liccheck` locally. Fix + lint output. +3. Delete the Makefile. +4. Add the `linter_checks` CI job (including the `liccheck` step). +5. Slim `CONTRIBUTING.md` (workflow section becomes a stub, schema + stays). 6. Delete this `CLEANUP_PLAN.md`. -Expected total diff (commits 1 to 6): ~250 lines added (tox.ini, -`[tool.tomte]` block, CI job, README section), ~120 lines removed -(CONTRIBUTING.md trimmed, Makefile possibly dropped, `uv.lock` -delta is mechanical). +Expected total diff (commits 1 to 6): ~200 lines added (`tox.ini`, +`[tool.tomte]` block, CI job), ~80 lines removed +(`CONTRIBUTING.md` workflow stub, `Makefile` deletion, plus the +mechanical `uv.lock` delta). From 2df09a1f247b1ed0d7eedecee3110083e15b4274 Mon Sep 17 00:00:00 2001 From: DivyaNautiyal07 Date: Thu, 21 May 2026 18:49:11 +0530 Subject: [PATCH 03/17] chore: fold in agent-review findings on cleanup plan Eight findings from DhairyaPatel7's review, all verified against the actual repo: - Add `linter_checks` to `all-checks-passed.needs` so the new lint job actually gates branch protection. - Pull the 7 stale `poetry` invocations in scripts/*/README.md into scope as commit 6 (PR #172 missed them). - Fix .sh count: 10 files total (was claiming 8). 9 use uv, extract_private_keys.sh is pure bash. - Add `tox-uv` to dev deps + note that `[cli]` already includes `tox` (so local `uv run tomte tox` works out of the box). - Pin action versions to checkout@v4 + setup-python@v5 (matches rest of workflow; was v6/v6). - Replace "four existing jobs" with the actual six. - Replace "Expected drop: ~344 lines" wording with the correct "Net delta: ~+22 lines" (the plan is additive, nothing drops). - Note the sequencing constraint: commit 4 (CI gate) only after commit 2 (lint green locally), else the branch lands red on its own gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLEANUP_PLAN.md | 106 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 80 insertions(+), 26 deletions(-) diff --git a/CLEANUP_PLAN.md b/CLEANUP_PLAN.md index e7d9d40d..ccbf3679 100644 --- a/CLEANUP_PLAN.md +++ b/CLEANUP_PLAN.md @@ -17,13 +17,17 @@ work that applies here: - `pyproject.toml` is on PEP 621 with `tool.uv.package = false`. - `poetry.lock` removed, `uv.lock` present. -- All 8 operator shell scripts (`run_service.sh`, `stop_service.sh`, +- 9 operator shell scripts (`run_service.sh`, `stop_service.sh`, `analyse_logs.sh`, `claim_staking_rewards.sh`, `reset_configs.sh`, `reset_password.sh`, `reset_staking.sh`, - `terminate_on_chain_service.sh`) use `uv sync` and `uv run` instead - of `poetry install` and `poetry run`. + `terminate_on_chain_service.sh`, `migrate_to_pearl.sh`) use + `uv sync` and `uv run` instead of `poetry install` and `poetry + run`. `extract_private_keys.sh` is pure bash and intentionally + not touched (it doesn't invoke a Python interpreter). -Nothing in this PR touches those. +Nothing in this PR touches the shell-script bodies. But PR #172 did +leave 7 stale `poetry` invocations behind in `scripts/*/README.md` +that this PR cleans up — see in-scope section 6. ## What's in scope @@ -40,6 +44,7 @@ dev = [ "pytest==9.0.3", "pytest-cov==7.1.0", "tomte[cli, tests]==0.7.0", + "tox-uv==1.16.0", ; pin set empirically at implementation time ] [tool.tomte] @@ -48,9 +53,18 @@ tomte_dep_pin = "==0.7.0" # based on actual lint output against current scripts/ and tests/. ``` -The check_dependencies_extra_excludes and pylint disables will be set -empirically. Quickstart has only `scripts/` and `tests/` to lint, so -the list should stay short. +Note on tomte extras. The `[cli]` extra already pulls `tox` (checked +tomte v0.7.0 `pyproject.toml`), so `tomte[cli, tests]==0.7.0` is +enough for `tomte tox` to find the `tox` binary. The asymmetry vs +trader's CI install line (`pip install 'tomte[tox,cli]==0.7.0' +tox-uv`) is `tox-uv`, which isn't in any tomte extra and which the +`tomte tox` wrapper expects when uv-managed envs are used. Adding +`tox-uv` to the dev group keeps local `uv run tomte tox -e ` +working out of the box. + +The `check_dependencies_extra_excludes` and pylint disables will be +set empirically. Quickstart has only `scripts/` and `tests/` to lint, +so the list should stay short. ### 2. Add a minimal `tox.ini` @@ -115,7 +129,7 @@ run_no_staking_tests: test: test-install run_no_staking_tests ``` -Confirmed: no references from README, CI, or any `.sh` script. The 8 +Confirmed: no references from README, CI, or any `.sh` script. The 9 operator shell scripts already cover install + run. Per reviewer feedback, the Makefile gets deleted outright at implementation time. @@ -126,18 +140,20 @@ Current `.github/workflows/python-tests.yml` is 344 lines. It runs matrix. It does no linting. No `black`, `isort`, `flake8`, `mypy`, `pylint`, `darglint`, `bandit`, `safety`. -Add a `linter_checks` job that mirrors trader's `common_checks.yaml`: +Add a `linter_checks` job that mirrors trader's `common_checks.yaml`. +Pin action versions to match the rest of the workflow (`checkout@v4`, +`setup-python@v5`): ```yaml linter_checks: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.10" - name: Install dependencies - run: pip install 'tomte[tox,cli]==0.7.0' tox-uv + run: pip install 'tomte[cli, tests]==0.7.0' tox-uv - name: Code checks run: tomte tox -p -e black-check -e isort-check -e flake8 -e mypy -e pylint -e darglint - name: Security checks @@ -146,12 +162,21 @@ linter_checks: run: tomte tox -e liccheck ``` -The four existing jobs (`setup`, `e2e-test-run-service`, -`e2e-test-staking`, the pearl-migration test, `unit-tests`) stay as -they are. They already use uv correctly. +Also append `- linter_checks` to the `needs:` list of the +`all-checks-passed` aggregate gate at the bottom of the workflow +(currently line 324, needs list at lines 331-337). Branch protection +references `all-checks-passed`, so without this addition the new job +runs but is advisory only. + +The six existing jobs (`setup`, `e2e-test-run-service`, +`e2e-test-staking`, `changes`, `e2e-test-migrate-to-pearl`, +`unit-tests`) stay as they are. They already use uv correctly. The +`all-checks-passed` aggregate gate gets one new line in its `needs:` +list. -Expected drop: ~344 lines is mostly the four big jobs. The linter job -adds ~20 lines net. +Net delta: roughly +22 lines (the new `linter_checks` job plus the +one-line update to `all-checks-passed.needs`). Nothing gets removed +from the workflow. ### 5. CONTRIBUTING.md slim down @@ -175,6 +200,26 @@ Plan (per reviewer): After the trim, `CONTRIBUTING.md` ends up at the stub plus the existing schema section (~85 lines, down from 135). +### 6. Replace stale `poetry` references in `scripts/*/README.md` + +PR #172 updated the eight (now nine, counting `migrate_to_pearl.sh`) +root shell scripts to use `uv` but missed seven stale `poetry` +invocations in two sub-READMEs: + +- `scripts/predict_trader/README.md` lines 16, 22, 28: three + `poetry run python -m scripts.predict_trader. ...` + copy-paste commands. +- `scripts/predict_trader/README.md` lines 197 and 198: a + `poetry install` + `poetry run python -m + scripts.predict_trader.migrate_legacy_quickstart` pair. +- `scripts/optimus/README.md` lines 18 and 19: the same + `poetry install` + `poetry run python -m + scripts.optimus.migrate_legacy_optimus` pair. + +All seven get rewritten to the equivalent `uv sync --no-default-groups +--inexact --frozen` (or just `uv run`) command at implementation +time. Pure doc fix, no behaviour change. + ## What's explicitly out of scope - **`pyproject.toml` and lockfile work**. Done in PR #172. @@ -188,8 +233,9 @@ existing schema section (~85 lines, down from 135). Adding one is a separate security task. - **README rewrite**. Only one section gets added (the config schema moved from CONTRIBUTING.md). The rest stays as-is. -- **Removing any `.sh` script**. All 8 are referenced from README as - the public operator interface. +- **Removing any `.sh` script**. All 10 are referenced from README + or are operator entry points (the 9 `uv`-using scripts plus the + pure-bash `extract_private_keys.sh`). - **Touching `scripts/pearl_migration/`**. It's recently added and actively used. @@ -204,20 +250,28 @@ existing schema section (~85 lines, down from 135). Implementation lands as commits on this same branch in this order: -1. Add tomte to dev deps and `[tool.tomte]` to `pyproject.toml`. - Run `uv lock` to refresh `uv.lock`. +1. Add tomte and `tox-uv` to dev deps and `[tool.tomte]` to + `pyproject.toml`. Run `uv lock` to refresh `uv.lock`. 2. Add `tox.ini` (with `[tomte-extensions]`, `[pytest]`, `[Licenses]`, `[Authorized Packages]` blocks). Run `tomte tox -p -e black-check -e isort-check -e flake8 -e mypy -e pylint -e darglint -e bandit -e safety -e liccheck` locally. Fix - lint output. + lint output until every env is green. 3. Delete the Makefile. -4. Add the `linter_checks` CI job (including the `liccheck` step). +4. Add the `linter_checks` CI job (including the `liccheck` step) + and append `- linter_checks` to `all-checks-passed.needs`. 5. Slim `CONTRIBUTING.md` (workflow section becomes a stub, schema stays). -6. Delete this `CLEANUP_PLAN.md`. +6. Replace the 7 stale `poetry` invocations in + `scripts/predict_trader/README.md` and + `scripts/optimus/README.md` with their `uv` equivalents. +7. Delete this `CLEANUP_PLAN.md`. + +Sequencing constraint: commit 4 (CI gate) must only be pushed after +commit 2 has every lint env green locally. Otherwise the branch +lands red on its own newly-added gate. -Expected total diff (commits 1 to 6): ~200 lines added (`tox.ini`, -`[tool.tomte]` block, CI job), ~80 lines removed +Expected total diff (commits 1 to 7): ~200 lines added (`tox.ini`, +`[tool.tomte]` block, CI job, doc rewrites), ~80 lines removed (`CONTRIBUTING.md` workflow stub, `Makefile` deletion, plus the mechanical `uv.lock` delta). From 7ab282c559bd00a75e50df85c9ffead030025aa2 Mon Sep 17 00:00:00 2001 From: DivyaNautiyal07 Date: Fri, 22 May 2026 14:12:31 +0530 Subject: [PATCH 04/17] chore(deps): add tomte v0.7.0 + tox-uv to dev group, declare [tool.tomte] Brings quickstart in line with trader's wave-2 setup: lint via `tomte tox`, configured through a [tool.tomte] block in pyproject. - Add tomte[cli, tests]==0.7.0 (PyPI pin, [cli] extra already pulls tox). - Add tox-uv==1.16.0 so `uv run tomte tox` works out of the box. - Drop the explicit pytest / pytest-cov pins from dev deps; tomte[tests] brings them in at compatible versions (pytest 8.4.2 / pytest-cov 7.0.0). - [tool.tomte] points the canonical linters at scripts/ (excluding tests/ from lint, matching trader's pattern). pytest_targets stays at tests/. - Add a minimal [build-system] + [tool.setuptools] py-modules = [] so that tomte testenvs which trigger an editable install don't blow up on flat-layout auto-discovery (data/, images/, configs/). Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 29 ++++++++- uv.lock | 164 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 175 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ef809cdf..a5c5a1ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,14 +27,39 @@ dependencies = [ [dependency-groups] dev = [ "pexpect==4.9.0", - "pytest==9.0.3", - "pytest-cov==7.1.0", + "tomte[cli, tests]==0.7.0", + "tox-uv==1.16.0", ] +# Note: pytest, pytest-cov and pytest-asyncio are brought in by +# tomte[tests] at pinned versions (pytest==8.4.2, pytest-cov==7.0.0, +# pytest-asyncio==1.3.0). Don't pin them separately or uv will +# refuse to resolve. [tool.uv] package = false default-groups = "all" +# Some tomte testenvs (pylint, py3.x) trigger an editable install via tox. +# Without a build-system block setuptools falls back to flat-layout +# auto-discovery and trips on `data/`, `images/`, `configs/` at repo root. +# Declare an empty package set so the build is a no-op. +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +py-modules = [] + +[tool.tomte] +tomte_dep_pin = "==0.7.0" +# Quickstart has no Valory `packages//...` tree (it consumes +# pre-published packages via `olas-operate-middleware`). Lint scope +# is `scripts/` only — `tests/` is excluded from lint following +# trader's pattern (production code lints, tests don't). +packages_paths = "." +service_specific_packages = ["scripts"] +pytest_targets = ["tests"] + [tool.pytest.ini_options] # anchorpy ships a pytest plugin (via open-autonomy -> open-aea-ledger-solana -> # anchorpy) that imports pytest_xprocess at startup, but xprocess is only in diff --git a/uv.lock b/uv.lock index 8099d819..65bde168 100644 --- a/uv.lock +++ b/uv.lock @@ -542,6 +542,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/1d/52c0741626a17eb1a2f554e27dc2883f9bb98bb7b2392f9cbd1f39c36fea/borsh_construct-0.1.0-py3-none-any.whl", hash = "sha256:f584c791e2a03f8fc36e6c13011a27bcaf028c9c54ba89cd70f485a7d1c687ed", size = 6386, upload-time = "2021-10-20T11:16:47.84Z" }, ] +[[package]] +name = "cachetools" +version = "7.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/c1/67cfb86aa21144796ff51068326d467fbef8ee42f8d08a3a8a926106cf0c/cachetools-7.1.3.tar.gz", hash = "sha256:135cfe944bc3c1e805505f65dae0bef375a2f96261171ab66c79ef77d0bda39d", size = 45780, upload-time = "2026-05-18T18:21:03.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/52/8ff5c1a3b2e821ced9b2998fba3ee29aa4525c0bf51e5ee55dd6f61a4ed5/cachetools-7.1.3-py3-none-any.whl", hash = "sha256:9876787e2346e20584d5cca236cb5d49d04e7193de91646f230725b2e1e8b804", size = 16763, upload-time = "2026-05-18T18:21:02.386Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -808,14 +817,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.3" +version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -2738,9 +2747,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0f/462326910c6172fa2c6ed07922b22ffc8e77432b3affffd9e18f444dbfbb/pynacl-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:84709cea8f888e618c21ed9a0efdb1a59cc63141c403db8bf56c469b71ad56f2", size = 183846, upload-time = "2025-09-10T23:39:10.552Z" }, ] +[[package]] +name = "pyproject-api" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/7b/c0e1333b61d41c69e59e5366e727b18c4992688caf0de1be10b3e5265f6b/pyproject_api-1.10.0.tar.gz", hash = "sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330", size = 22785, upload-time = "2025-10-09T19:12:27.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/cc/cecf97be298bee2b2a37dd360618c819a2a7fd95251d8e480c1f0eb88f3b/pyproject_api-1.10.0-py3-none-any.whl", hash = "sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09", size = 13218, upload-time = "2025-10-09T19:12:24.428Z" }, +] + [[package]] name = "pytest" -version = "9.0.3" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2751,9 +2773,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] @@ -2772,16 +2794,41 @@ wheels = [ [[package]] name = "pytest-cov" -version = "7.1.0" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-randomly" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1d/258a4bf1109258c00c35043f40433be5c16647387b6e7cd5582d638c116b/pytest_randomly-4.0.1.tar.gz", hash = "sha256:174e57bb12ac2c26f3578188490bd333f0e80620c3f47340158a86eca0593cd8", size = 14130, upload-time = "2025-09-12T15:23:00.085Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/3e/a4a9227807b56869790aad3e24472a554b585974fe7e551ea350f50897ae/pytest_randomly-4.0.1-py3-none-any.whl", hash = "sha256:e0dfad2fd4f35e07beff1e47c17fbafcf98f9bf4531fd369d9260e2f858bfcb7", size = 8304, upload-time = "2025-09-12T15:22:58.946Z" }, +] + +[[package]] +name = "pytest-rerunfailures" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/04/71e9520551fc8fe2cf5c1a1842e4e600265b0815f2016b7c27ec85688682/pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e", size = 30889, upload-time = "2025-10-10T07:06:01.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/54/60eabb34445e3db3d3d874dc1dfa72751bfec3265bd611cb13c8b290adea/pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86", size = 14093, upload-time = "2025-10-10T07:06:00.019Z" }, ] [[package]] @@ -2943,8 +2990,8 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pexpect" }, - { name = "pytest" }, - { name = "pytest-cov" }, + { name = "tomte", extra = ["cli", "tests"] }, + { name = "tox-uv" }, ] [package.metadata] @@ -2965,8 +3012,8 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "pexpect", specifier = "==4.9.0" }, - { name = "pytest", specifier = "==9.0.3" }, - { name = "pytest-cov", specifier = "==7.1.0" }, + { name = "tomte", extras = ["cli", "tests"], specifier = "==0.7.0" }, + { name = "tox-uv", specifier = "==1.16.0" }, ] [[package]] @@ -3106,7 +3153,7 @@ wheels = [ [[package]] name = "requests" -version = "2.34.2" +version = "2.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -3114,9 +3161,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] @@ -3446,6 +3493,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tomte" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/aa/da37ab224e5c244df1814da6a33c48a7f17c4a26675b5beadff66412ad20/tomte-0.7.0.tar.gz", hash = "sha256:863e2144aefb13dd988b1c6650167469a82945aa445320d4a4ade41b2edf75e5", size = 45004, upload-time = "2026-05-01T14:56:29.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/17/2c04fb63831600594ee173243ea470abcab04a325387f88822fbb720eb05/tomte-0.7.0-py3-none-any.whl", hash = "sha256:b127c7a14ca07a89d5fbb2c82b9d9ba581f13aee4f082a98261c794e589e1ab4", size = 53070, upload-time = "2026-05-01T14:56:28.539Z" }, +] + +[package.optional-dependencies] +cli = [ + { name = "click" }, + { name = "requests" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tox" }, +] +tests = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-randomly" }, + { name = "pytest-rerunfailures" }, +] + [[package]] name = "toolz" version = "0.11.2" @@ -3455,6 +3526,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/f1/3df506b493736e3ee11fc1a3c2de8014a55f025d830a71bb499acc049a2c/toolz-0.11.2-py3-none-any.whl", hash = "sha256:a5700ce83414c64514d82d60bcda8aabfde092d1c1a8663f9200c07fdcc6da8f", size = 55840, upload-time = "2021-11-06T05:23:07.224Z" }, ] +[[package]] +name = "tox" +version = "4.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ba/140537bbf7dd267b37a1519ac1451b6c062d300cb64b7b273dfa1b2af9aa/tox-4.46.0.tar.gz", hash = "sha256:b9d1aa187101dae184b7e6552f3b1ff85461e9e32f078851426e35a3c2009bc1", size = 249296, upload-time = "2026-02-24T17:53:36.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/15/e344f22ae5adce77769cb93f03635674df40de2604e98a71a3193f632766/tox-4.46.0-py3-none-any.whl", hash = "sha256:828f0a119a22028dbff7495d288e3601406478ab20c97891f66be5516d72e4c3", size = 200281, upload-time = "2026-02-24T17:53:34.228Z" }, +] + +[[package]] +name = "tox-uv" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tox" }, + { name = "uv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/5e/c3d2a45ab5465dddbbc267a589c9cfce23b91750d49af10738a08c98534e/tox_uv-1.16.0.tar.gz", hash = "sha256:71b2e2fa6c35c1360b91a302df1d65b3e5a1f656b321c5ebf7b84545804c9f01", size = 16337, upload-time = "2024-10-30T17:47:56.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/8d/1baa9f725ddd4824708759cf7b74bc43379f5f7feb079fde0629d7b32b3e/tox_uv-1.16.0-py3-none-any.whl", hash = "sha256:e6f0b525a687e745ab878d07cbf5c7e85d582028d4a7c8935f95e84350651432", size = 13661, upload-time = "2024-10-30T17:47:54.893Z" }, +] + [[package]] name = "tqdm" version = "4.67.3" @@ -3509,6 +3615,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "uv" +version = "0.11.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/609d5d01ba21dc8f0974610ca7802fbb2c946a0c38665cfe5c5aeddbefb5/uv-0.11.15.tar.gz", hash = "sha256:755f959ec6a2fd8ccb6ee76ad90ab759d2eb1f4797444078645dd1ee4bca92d6", size = 4159545, upload-time = "2026-05-18T19:57:48.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/7c/dcc230c5911884d8848145dabcac8fb95a5ed6f9fe1c57fae8242618f28a/uv-0.11.15-py3-none-linux_armv6l.whl", hash = "sha256:83b04ab49514a0a761ffedb36a748ee81f87746671e72088e5f32c9585e5f1a9", size = 23110183, upload-time = "2026-05-18T19:57:23.051Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/efd4e044b60eb9c3c12ee386be098d56c335538ccec7caa49349cfba9344/uv-0.11.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6cae61f737be075b90be9e3f07d961072aed7019f4c9b8ed5c5d41c4d6cade3", size = 22637941, upload-time = "2026-05-18T19:57:26.752Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b8/48627f895a1569e576822e0a8416aa4797eb4a4551de21a4ad97b9b5819d/uv-0.11.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9accae33619a9166e5c48531deb455d672cfb89f9357a00975e669c76b0bd49f", size = 21258803, upload-time = "2026-05-18T19:57:05.473Z" }, + { url = "https://files.pythonhosted.org/packages/af/50/4bc8a148274feabee2d9c9f1fa15009e10c0228dfe57981ee3ea2ef1d481/uv-0.11.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c0cf52cd6d50bb9e05e2d968f45f80761107e4cbc8d4a26d9758f9d8274aaec1", size = 23066178, upload-time = "2026-05-18T19:57:33.058Z" }, + { url = "https://files.pythonhosted.org/packages/a9/56/139fc3bec9a8b0a25bfe2196123adb9f16124da437bf4fbcf0d21cfcafb2/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:49dc6ed70bff00937384f96cdc4b1a4742d18e5504ec2c4a1214dba2dee5687a", size = 22705332, upload-time = "2026-05-18T19:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b0/b18b3dd204f8c213236a1ebd148e009861637129a8cce34df0e9aa22ed40/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:adb9a89352539fdd8f7cd5f9966cf9f94fc5b98e0ccdf5003a04123dc6423bec", size = 22707534, upload-time = "2026-05-18T19:58:04.117Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/3ca09f95572df99d361b49c96b1297149e96e120d8d1ecf074095a4b6da4/uv-0.11.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40ff67e3f8e8a7533781a2e892a534975a93acb83ea35460e64e7b2bf2111774", size = 24096607, upload-time = "2026-05-18T19:58:11.625Z" }, + { url = "https://files.pythonhosted.org/packages/64/be/3bdee21a296bbf5336a526e3613d0e7d4538dacc39c62d7fcba55d15f6b0/uv-0.11.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6463a299ed7e6b5a800ed6f108af8e1588352629424133ddef7572b0e1e1118", size = 25082562, upload-time = "2026-05-18T19:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/cd/73/f371f3689ffe741066468d001d85f739fc4b5574de83b639ef19b5e8a7f4/uv-0.11.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68c1e62d4b78578b90b833553286b65d6a7e327537716441068583ba652ec4f5", size = 24253391, upload-time = "2026-05-18T19:57:18.47Z" }, + { url = "https://files.pythonhosted.org/packages/d3/16/fe392d618af6b00c064b3e718d585dcf791546a77c5123a5bec07ce53a0a/uv-0.11.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98edf1bdaf82447014852051d93e3ee95012509c567bf057fd117e6bdbd9a807", size = 24415871, upload-time = "2026-05-18T19:58:19.651Z" }, + { url = "https://files.pythonhosted.org/packages/6e/24/2e92a052fb6334fcd746d1c7cb57847c204b118c84f5da53c0f9e129f7b7/uv-0.11.15-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:be8f76d25bcf4c92bb384240ac1bf9aa7f51063d0bdeca4c9cf0ec3ed8b145e0", size = 23159007, upload-time = "2026-05-18T19:57:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2e/6923d0658d164bb2c435ed1868aa2d49b3074594679917a001ff92dc95bb/uv-0.11.15-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f9f4fbbf4fe485522054f3c7496c6e8e932d6436e4200ff3daf718db0b7c7bd5", size = 23769385, upload-time = "2026-05-18T19:58:15.856Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/7e34cd949e57360814e8064cc9fb7104df445d0f6a663504e5f7473480aa/uv-0.11.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0ed920e896b2fd13a35031707e307e42fbb2681458b967440a17272d86d49137", size = 23860973, upload-time = "2026-05-18T19:57:55.575Z" }, + { url = "https://files.pythonhosted.org/packages/28/98/8fe1f5f9d816e94569a0298dd8e0936801097625fa1952162951f0d628b6/uv-0.11.15-py3-none-musllinux_1_1_i686.whl", hash = "sha256:41d907611f3e6a13262807fd7f0a17849f76285ca80f536f6b3943732bdc6656", size = 23431392, upload-time = "2026-05-18T19:57:59.814Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6b/76a1ce2fa860026913a5941700cdc7d715fce9c3277a3fa3489cf2523ca0/uv-0.11.15-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e3b68f8bf1a4568710f77e5bda9182ce7682811d89a8e7468c22460e032b234d", size = 24519478, upload-time = "2026-05-18T19:57:51.165Z" }, + { url = "https://files.pythonhosted.org/packages/43/60/1d58e8a05718cb50494763115710b73846cacb651fd735d285233fd72c59/uv-0.11.15-py3-none-win32.whl", hash = "sha256:8e2da3076761086a5b76869c3f38ef0509c836046ef41ddd19485dfd7271dca9", size = 22020178, upload-time = "2026-05-18T19:58:07.64Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/40fcefcb348af660488597ed3c01363df7344e60611f8883750dc596f5c6/uv-0.11.15-py3-none-win_amd64.whl", hash = "sha256:cc3915ab291a1ecaf31de05f5d8bd70d09c66fe9911a53f70d9efa62ff0dbd8a", size = 24668779, upload-time = "2026-05-18T19:57:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7d/fa3a9960c95af9bbe2a629048760d0b9b4fead8ccd4f2235af747ec7cdf0/uv-0.11.15-py3-none-win_arm64.whl", hash = "sha256:4f39426a13dee24897aed60c4b98058c66f18bd983885ac5f4a54a04b24fbddf", size = 23198178, upload-time = "2026-05-18T19:57:14.68Z" }, +] + [[package]] name = "uvicorn" version = "0.47.0" From 095315365a5bc987ab016f5ae06a91e2afa809a8 Mon Sep 17 00:00:00 2001 From: DivyaNautiyal07 Date: Fri, 22 May 2026 14:12:56 +0530 Subject: [PATCH 05/17] chore(lint): add tox.ini and fix all lint findings under scripts/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a minimal tox.ini consumed by `tomte tox` (extensions block, [pytest], [Authorized Packages] for liccheck, [mypy-*] ignore blocks), then fixes every violation the 9 lint envs surfaced in scripts/. Real bug fixes: - rank_traders.py: `load_local_config()` was missing the required `operate` and `service_name` kwargs the middleware API needs since 0.15.x. Now passes OperateApp() + service_name="Trader Agent". Test mock updated to accept **_kwargs. - migrate_to_pearl._ensure_signable: 7× B023 closures weren't binding the loop variables qs_master_safe and chain_str. Bound via default args, same pattern as the sibling _ledger_api closure. - migrate_to_pearl: F811 duplicates of Service + Chain removed from the TYPE_CHECKING block (already imported at runtime). - migrate_to_pearl: 6 module-level imports moved out of mid-file (E402); stale `from operate.operate_types import Chain as _Chain` removed and _Chain usage replaced with the top-level Chain import. - migrate_legacy_quickstart.py: stale unused `autonomy.chain.config.ChainType` import dropped. - 10× requests.get/post calls in predict_trader scripts now pass `timeout=30` (W3101). Two test mocks updated to accept the kwarg. Hygiene: - 2× E800 commented-out code blocks removed. - 5× F541 placeholder-less f-strings cleaned up. - 4× E226 missing operator whitespace. - 2× W293 whitespace on blank lines. - 1× C0104 disallowed name `bar` → `progress_bar` in rank_traders. Docstrings (D100/D101/D102/D103/D105/D107/D205): ~28 added across optimus, predict_trader, and pearl_migration. One-liners except where the docstring already had a malformed multi-line form. Bandit (B607): 3× `# nosec` markers on subprocess.run calls that invoke `docker` / `sudo` by name via PATH — desired UX, matches the behaviour of run_service.sh. tox.ini structure: - [tomte-extensions] extra_pylint_disables: 13 codes (C0114/C0115/C0116/R0801 + C0415/W0621/C0411/W0212/W0718/R1723/ R0903/R0914/R0915) covering legacy-script noise. - [pytest] addopts = -p no:pytest_anchorpy (mirrors the same anchorpy guard already in pyproject's pytest config). - [mypy-scripts.*] ignore_errors = True for pearl_migration, predict_trader, optimus, utils — tomte's [testenv:mypy] doesn't install olas-operate-middleware so mypy can't resolve operate.* as namespace imports. Mirrors trader's [mypy-packages.valory.connections.*] pattern. - [mypy-operate.*], [mypy-autonomy.*] etc. — third-party stubs liccheck would normally complain about. - [Authorized Packages] — per-package liccheck overrides for GPL-dual / unknown-license deps (anchorpy, based58, clea, httpx, jsonalias, olas-operate-middleware, open-autonomy, pyinstaller, pyinstaller-hooks-contrib). All 370 unit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/optimus/README.md | 4 +- scripts/optimus/migrate_legacy_optimus.py | 72 +- scripts/pearl_migration/detect.py | 20 +- scripts/pearl_migration/filesystem.py | 7 +- scripts/pearl_migration/migrate_to_pearl.py | 483 +-- scripts/pearl_migration/prompts.py | 17 +- scripts/pearl_migration/status.py | 16 +- scripts/pearl_migration/stop.py | 4 +- scripts/pearl_migration/transfer.py | 36 +- scripts/pearl_migration/wallet.py | 1 + scripts/predict_trader/README.md | 10 +- scripts/predict_trader/mech_events.py | 56 +- .../migrate_legacy_quickstart.py | 47 +- scripts/predict_trader/rank_traders.py | 35 +- scripts/predict_trader/report.py | 232 +- scripts/predict_trader/trades.py | 60 +- scripts/utils.py | 55 +- tests/test_migrate_to_pearl.py | 94 +- tests/test_run_service.py | 722 +++-- .../test_migrate_legacy_optimus.py | 404 +-- tests/test_scripts/test_pearl_migration.py | 2700 ++++++++++++----- .../test_predict_trader/test_mech_events.py | 573 ++-- .../test_migrate_legacy_quickstart.py | 3 +- .../test_predict_trader/test_rank_traders.py | 378 +-- .../test_predict_trader/test_report.py | 868 +++--- .../test_predict_trader/test_trades.py | 841 ++--- tests/test_scripts/test_utils.py | 45 +- tests/test_staking_service.py | 259 +- tox.ini | 91 + 29 files changed, 5089 insertions(+), 3044 deletions(-) create mode 100644 tox.ini diff --git a/scripts/optimus/README.md b/scripts/optimus/README.md index a327882a..0db1330b 100644 --- a/scripts/optimus/README.md +++ b/scripts/optimus/README.md @@ -15,8 +15,8 @@ If you were previously using [optimus-quickstart](https://github.com/valory-xyz/ 2. Run the migration script to create the new `.operate` folder compatible with unified quickstart: ```bash - poetry install - poetry run python -m scripts.optimus.migrate_legacy_optimus configs/config_optimus.json + uv sync + uv run python -m scripts.optimus.migrate_legacy_optimus configs/config_optimus.json ``` 3. Follow the prompts to complete the migration process. The script will: diff --git a/scripts/optimus/migrate_legacy_optimus.py b/scripts/optimus/migrate_legacy_optimus.py index 4470dc30..43293867 100644 --- a/scripts/optimus/migrate_legacy_optimus.py +++ b/scripts/optimus/migrate_legacy_optimus.py @@ -1,18 +1,24 @@ -from argparse import ArgumentParser -from pathlib import Path +"""Migrate a legacy `.optimus/` operator state directory into the modern `.operate/` layout.""" + import json import shutil +from argparse import ArgumentParser from dataclasses import dataclass -from operate.quickstart.run_service import QuickstartConfig +from pathlib import Path + from operate.operate_types import Chain +from operate.quickstart.run_service import QuickstartConfig from operate.quickstart.utils import print_section, print_title -from scripts.utils import validate_config_params, handle_missing_rpcs +from scripts.utils import handle_missing_rpcs, validate_config_params OPTIMUS_PATH = Path(__file__).parent.parent.parent / ".optimus" OPERATE_HOME = Path(__file__).parent.parent.parent / ".operate" + @dataclass class OptimusConfig: + """User-facing optimus quickstart configuration parsed from the legacy `.optimus/` JSON.""" + rpc: dict[str, str] tenderly_access_key: str tenderly_account_slug: str @@ -22,31 +28,36 @@ class OptimusConfig: staking_program_id: str principal_chain: str + def parse_optimus_config() -> OptimusConfig: """Parse essential config data from optimus.""" print_section("Parsing .optimus configuration...") - + config_file = OPTIMUS_PATH / "local_config.json" config = json.loads(config_file.read_text()) - + # Validate required parameters required_params = [ "tenderly_access_key", - "tenderly_account_slug", + "tenderly_account_slug", "tenderly_project_slug", - "coingecko_api_key" + "coingecko_api_key", ] validate_config_params(config, required_params) - + # Map RPC endpoints from source config to new format rpc_mapping = handle_missing_rpcs(config) - + use_staking = config.get("use_staking", False) staking_program_id = "optimus_alpha" if use_staking else "no_staking" - + # Default to OPTIMISM if available, otherwise first available chain - principal_chain = Chain.OPTIMISM.value if "optimistic" in rpc_mapping else next(iter(rpc_mapping), Chain.OPTIMISM.value) - + principal_chain = ( + Chain.OPTIMISM.value + if "optimistic" in rpc_mapping + else next(iter(rpc_mapping), Chain.OPTIMISM.value) + ) + return OptimusConfig( rpc=rpc_mapping, tenderly_access_key=config["tenderly_access_key"], @@ -55,16 +66,17 @@ def parse_optimus_config() -> OptimusConfig: coingecko_api_key=config["coingecko_api_key"], use_staking=use_staking, staking_program_id=staking_program_id, - principal_chain=principal_chain + principal_chain=principal_chain, ) + def copy_optimus_to_operate(): """Copy all files from .optimus to .operate.""" print_section("Copying files from .optimus to .operate...") - + # Create .operate directory if it doesn't exist OPERATE_HOME.mkdir(parents=True, exist_ok=True) - + # Copy all contents except local_config.json for item in OPTIMUS_PATH.iterdir(): if item.name != "local_config.json": @@ -74,10 +86,11 @@ def copy_optimus_to_operate(): else: shutil.copy2(item, target) + def create_operate_config(optimus_config: OptimusConfig, service_name: str): """Create new local_config.json for operate using QuickstartConfig.""" print_section("Creating new operate configuration...") - + for service_dir in (OPERATE_HOME / "services").iterdir(): config_path = service_dir / "config.json" if not config_path.exists(): @@ -85,7 +98,7 @@ def create_operate_config(optimus_config: OptimusConfig, service_name: str): with open(config_path, "r") as f: config = json.load(f) - + if config["name"] != "valory/optimus": continue @@ -101,44 +114,49 @@ def create_operate_config(optimus_config: OptimusConfig, service_name: str): "TENDERLY_ACCESS_KEY": optimus_config.tenderly_access_key, "TENDERLY_ACCOUNT_SLUG": optimus_config.tenderly_account_slug, "TENDERLY_PROJECT_SLUG": optimus_config.tenderly_project_slug, - "COINGECKO_API_KEY": optimus_config.coingecko_api_key + "COINGECKO_API_KEY": optimus_config.coingecko_api_key, }, staking_program_id=optimus_config.staking_program_id, ) qs_config.store() + def main(config_path: Path) -> None: + """Run the optimus-to-operate migration, copying state and rewriting config files.""" print_title("Optimus to Operate Migration") - + if not OPTIMUS_PATH.exists(): print("Error: No .optimus folder found!") return - + try: with open(config_path, "r") as f: config = json.load(f) # Parse optimus config first optimus_config = parse_optimus_config() - + # Copy all files copy_optimus_to_operate() - + # Create new config create_operate_config(optimus_config, config["name"]) - + print_section("Migration completed successfully!") - + except Exception as e: print(f"Error during migration: {e}") raise + if __name__ == "__main__": - parser = ArgumentParser(description="Migrate legacy quickstart to unified quickstart") + parser = ArgumentParser( + description="Migrate legacy quickstart to unified quickstart" + ) parser.add_argument( dest="config_path", type=Path, help="Quickstart config file path", ) args = parser.parse_args() - main(args.config_path) \ No newline at end of file + main(args.config_path) diff --git a/scripts/pearl_migration/detect.py b/scripts/pearl_migration/detect.py index 258ee014..ed3a8bc8 100644 --- a/scripts/pearl_migration/detect.py +++ b/scripts/pearl_migration/detect.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, field from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple +from typing import Dict, List, Optional, TYPE_CHECKING, Tuple from operate.constants import KEYS_DIR, SERVICES_DIR, WALLETS_DIR from operate.operate_types import LedgerType @@ -32,8 +32,8 @@ class Mode(Enum): """Migration mode.""" FRESH_COPY = "fresh_copy" # Pearl `.operate` does not exist (or has no wallet) - MERGE = "merge" # Pearl has its own master wallet; we must merge - NOOP = "noop" # Quickstart `.operate` already is `~/.operate` + MERGE = "merge" # Pearl has its own master wallet; we must merge + NOOP = "noop" # Quickstart `.operate` already is `~/.operate` @dataclass(frozen=True) @@ -50,12 +50,16 @@ class OperateStore: allowing memoization. """ - root: Path # absolute, resolved + root: Path # absolute, resolved _state: Dict[str, "OperateApp"] = field( - default_factory=dict, repr=False, compare=False, hash=False, + default_factory=dict, + repr=False, + compare=False, + hash=False, ) def __post_init__(self) -> None: + """Normalise `root` to an absolute, resolved path; reject unreadable paths.""" # Enforce the "absolute + resolved" invariant the docstring promises. # Always resolve so absolute paths with `..` segments / symlinks # also normalize. Use object.__setattr__ because `frozen=True` @@ -76,19 +80,23 @@ def __post_init__(self) -> None: # target). Lazy import: prompts.py depends on operate.* which # we want kept off the detect.py import path. from .prompts import info as _info + _info(f"Resolved store root: {self.root} -> {resolved}") object.__setattr__(self, "root", resolved) @property def wallets_dir(self) -> Path: + """Absolute path to the `wallets/` sub-directory inside this store root.""" return self.root / WALLETS_DIR @property def keys_dir(self) -> Path: + """Absolute path to the `keys/` sub-directory inside this store root.""" return self.root / KEYS_DIR @property def services_dir(self) -> Path: + """Absolute path to the `services/` sub-directory inside this store root.""" return self.root / SERVICES_DIR def has_master_wallet(self) -> bool: @@ -180,6 +188,7 @@ class Discovery: mode: Mode def __post_init__(self) -> None: + """Validate that `mode` is consistent with whether the two store roots match.""" same_root = self.quickstart.root == self.pearl.root if same_root and self.mode != Mode.NOOP: raise ValueError( @@ -194,6 +203,7 @@ def __post_init__(self) -> None: @property def is_noop(self) -> bool: + """True iff the discovery resolved to Mode.NOOP (no migration needed).""" return self.mode == Mode.NOOP diff --git a/scripts/pearl_migration/filesystem.py b/scripts/pearl_migration/filesystem.py index f022ce64..12803020 100644 --- a/scripts/pearl_migration/filesystem.py +++ b/scripts/pearl_migration/filesystem.py @@ -24,8 +24,7 @@ class MissingAgentKey(OSError): - """Raised by `merge_service` when an agent key referenced by the - service config is not present at the source `keys/` dir. + """Raised when `merge_service` can't find an agent key referenced by the service config. Subclasses `OSError` so the existing `(OSError, shutil.Error)` catch in `_run_mode_b` aggregates it into `MigrationOutcome.unmigratable` @@ -41,6 +40,8 @@ class MissingAgentKey(OSError): @dataclass class CopyOutcome: + """Per-service outcome of a quickstart-to-pearl filesystem merge.""" + service_id: str service_copied: bool service_skipped: bool @@ -100,7 +101,7 @@ def fix_root_ownership(store: OperateStore) -> None: f"'sudo chown -RP {uid}:{gid}'. You may be prompted for your sudo password." ) try: - subprocess.run( + subprocess.run( # nosec B607 # `sudo` looked up via PATH is the desired UX # Plain interactive `sudo` (no `-n`): legacy # `run_service.sh` does the same and prints the same # "Please enter sudo password" notice. Using `-n` here diff --git a/scripts/pearl_migration/migrate_to_pearl.py b/scripts/pearl_migration/migrate_to_pearl.py index 636ac2cf..e611fa02 100644 --- a/scripts/pearl_migration/migrate_to_pearl.py +++ b/scripts/pearl_migration/migrate_to_pearl.py @@ -37,7 +37,6 @@ from dataclasses import dataclass from pathlib import Path from typing import ( - TYPE_CHECKING, Any, Callable, Dict, @@ -45,6 +44,7 @@ Literal, NoReturn, Optional, + TYPE_CHECKING, Tuple, Type, ) @@ -58,6 +58,46 @@ ) from operate.services.service import Service from operate.utils.gnosis import get_asset_balance +from scripts.pearl_migration.detect import ( + Discovery, + Mode, + OperateStore, + discover, +) +from scripts.pearl_migration.filesystem import ( + fix_root_ownership, + fresh_copy_store, + merge_service, + rename_source_for_rollback, + reset_services_staking_to_no_staking, +) +from scripts.pearl_migration.prompts import ( + ask_password_validating, + fatal, + info, + warn, + yes_no, +) +from scripts.pearl_migration.status import ( + docker_quickstart_containers, + pearl_daemon_running, + safe_owners, + safe_threshold, + service_nft_owner, +) +from scripts.pearl_migration.stop import ( + force_remove_known_containers, + stop_via_middleware, +) +from scripts.pearl_migration.wallet import ( + align_quickstart_password, + align_user_account_to_wallet, +) + +if TYPE_CHECKING: + from operate.cli import OperateApp + from operate.services.manage import ServiceManager + from operate.wallet.master import MasterWallet # Programming bugs we DO NOT want wrapped as `_Unmigratable("NFT transfer # failed: ...")`. Letting these propagate surfaces the real fault (with a @@ -91,7 +131,11 @@ def _reraise_if_programming_bug(exc: BaseException) -> None: def _wrap_step_failure( - *, sid: str, chain: str, prefix: str, exc: BaseException, + *, + sid: str, + chain: str, + prefix: str, + exc: BaseException, ) -> "_Unmigratable": """Build the `_Unmigratable` for a step's `except` block. @@ -113,7 +157,8 @@ def _wrap_step_failure( """ _reraise_if_programming_bug(exc) return _Unmigratable( - service_id=sid, chain=chain, + service_id=sid, + chain=chain, reason=f"{prefix}: {_format_exc_chain(exc)}.", ) @@ -148,8 +193,10 @@ def _format_exc_chain(exc: BaseException) -> str: def _is_insufficient_funds_error(exc: BaseException) -> bool: - """Detect the middleware's "no funds" failure so we can wait for the - user to top up instead of marking the service un-migratable. + """Detect the middleware's "no funds" failure so we can wait for a top-up. + + Returning True means the caller should pause and wait for the user + to fund the safe instead of marking the service un-migratable. olas-operate-middleware raises a plain `RuntimeError` (no dedicated exception class) when an EOA can't pay gas — message-matching is the @@ -159,10 +206,7 @@ def _is_insufficient_funds_error(exc: BaseException) -> bool: than looping forever on an unrelated error. """ msg = str(exc).lower() - return ( - "does not have any funds" in msg - or "insufficient funds" in msg - ) + return "does not have any funds" in msg or "insufficient funds" in msg def _wait_for_native_funds( @@ -228,8 +272,7 @@ def _retry_on_funds_shortage( recipient_name: str, log_indent: str = " ", ) -> Any: - """Run `fn`, blocking on insufficient-funds errors with a - wait-and-retry loop against `address`. + """Run `fn` and block on insufficient-funds errors with a retry loop against `address`. Used at the four signing sites where the middleware actually propagates funds errors: `_step_terminate`, `_step_transfer_nft`, @@ -270,8 +313,7 @@ def _create_pearl_safe_with_funds_wait( ledger_api: Any, log_indent: str = " ", ) -> None: - """Create the Pearl master Safe on `chain`, blocking on insufficient - funds with a wait-and-retry loop. + """Create the Pearl master Safe on `chain`, blocking on insufficient funds. On non-funds errors the original exception is re-raised so the caller can wrap it with the right granularity (`_Unmigratable` per-service @@ -289,50 +331,6 @@ def _create_pearl_safe_with_funds_wait( ) -from scripts.pearl_migration.detect import ( - Discovery, - Mode, - OperateStore, - discover, -) - -if TYPE_CHECKING: - from operate.cli import OperateApp - from operate.services.manage import ServiceManager - from operate.services.service import Service - from operate.operate_types import Chain - from operate.wallet.master import MasterWallet -from scripts.pearl_migration.filesystem import ( - fix_root_ownership, - fresh_copy_store, - merge_service, - rename_source_for_rollback, - reset_services_staking_to_no_staking, -) -from scripts.pearl_migration.prompts import ( - ask_password_validating, - fatal, - info, - warn, - yes_no, -) -from scripts.pearl_migration.status import ( - docker_quickstart_containers, - pearl_daemon_running, - safe_owners, - safe_threshold, - service_nft_owner, -) -from scripts.pearl_migration.stop import ( - force_remove_known_containers, - stop_via_middleware, -) -from scripts.pearl_migration.wallet import ( - align_quickstart_password, - align_user_account_to_wallet, -) - - # --------------------------------------------------------------------------- # args # --------------------------------------------------------------------------- @@ -377,6 +375,7 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: # Service selection # --------------------------------------------------------------------------- + def _select_services( qs: OperateStore, config_path: Optional[str], @@ -400,8 +399,10 @@ def _select_services( # Piped/closed stdin (CI, automation) — re-prompting forever # would hang. Surface as a clean fatal instead of a raw # `EOFError: EOF when reading a line` traceback. - fatal("No selection provided (stdin closed). Re-run with " - "`--quickstart-config ` or attach a tty.") + fatal( + "No selection provided (stdin closed). Re-run with " + "`--quickstart-config ` or attach a tty." + ) if raw.isdigit(): n = int(raw) if 1 <= n <= len(services): @@ -412,6 +413,7 @@ def _select_services( # config_path given: match by 'name' or 'hash' import json + try: cfg = json.loads(Path(config_path).read_text()) except (OSError, json.JSONDecodeError) as exc: @@ -430,6 +432,7 @@ def _select_services( # Pre-flight # --------------------------------------------------------------------------- + def _preflight(disc: Discovery) -> None: info(f"Quickstart .operate: {disc.quickstart.root}") info(f"Pearl .operate: {disc.pearl.root}") @@ -460,8 +463,10 @@ def _preflight(disc: Discovery) -> None: # Wallet loading # --------------------------------------------------------------------------- + def _load_wallet( - store: OperateStore, label: str, + store: OperateStore, + label: str, ) -> Tuple["OperateApp", "MasterWallet"]: """Prompt for the password for `store` and return (OperateApp, wallet). @@ -501,6 +506,7 @@ def _load_wallet( # Shared cleanup helper (used by both Mode A and Mode B) # --------------------------------------------------------------------------- + def _ensure_containers_stopped( on_failure: Callable[[str], NoReturn], ) -> List[str]: @@ -551,10 +557,12 @@ def _ensure_containers_stopped( # Mode A — fresh copy # --------------------------------------------------------------------------- + def _run_mode_a(disc: Discovery, dry_run: bool) -> MigrationOutcome: - """Run the fresh-copy mode and return an empty `MigrationOutcome` - (which is `is_complete=True` by construction) so the caller can - plumb a uniform `MigrationOutcome` through `_final_prompt`. + """Run the fresh-copy mode and return an empty `MigrationOutcome`. + + The returned `MigrationOutcome` is `is_complete=True` by construction + so the caller can plumb a uniform value through `_final_prompt`. Mode A has no per-service / per-chain partial-failure state: any `OSError` / `shutil.Error` from the copy or rename callees propagates @@ -567,6 +575,7 @@ def _run_mode_a(disc: Discovery, dry_run: bool) -> MigrationOutcome: # If Pearl dir exists but no wallet, back it up so the copy is clean. if disc.pearl.root.exists(): from scripts.pearl_migration.prompts import backup_suffix as _bs + bak = disc.pearl.root.with_name(f"{disc.pearl.root.name}.bak.{_bs()}") if dry_run: info(f"[dry-run] Would back up empty Pearl dir to {bak}.") @@ -614,6 +623,7 @@ def _run_mode_a(disc: Discovery, dry_run: bool) -> MigrationOutcome: # Mode B — merge # --------------------------------------------------------------------------- + @dataclass(frozen=True) class _Unmigratable(Exception): """Raised when a single service can't proceed but others might. @@ -698,6 +708,7 @@ class MigrationOutcome: @property def is_complete(self) -> bool: + """True iff every selected service migrated cleanly and all drains succeeded.""" return ( not self.unmigratable and not self.drain_failures @@ -706,6 +717,7 @@ def is_complete(self) -> bool: @property def migrated_count(self) -> int: + """Number of services successfully migrated in this run.""" return len(self.migrated) @@ -716,8 +728,10 @@ def _run_mode_b( dry_run: bool, subset_selected: bool = False, ) -> MigrationOutcome: - """Returns the structured `MigrationOutcome`. `outcome.is_complete` False - suppresses the source-`.operate/` rename so the user can resume. + """Return the structured `MigrationOutcome` for the merge-mode migration. + + `outcome.is_complete = False` suppresses the source-`.operate/` rename + so the user can resume. `subset_selected` is the caller's signal that `services` is only some of what the quickstart store currently exposes (the user picked via @@ -729,10 +743,15 @@ def _run_mode_b( existing single-service test fixtures rely on. """ print_section("MERGE") - info(f"Will migrate {len(services)} service(s): " + ", ".join(s.name for s in services)) + info( + f"Will migrate {len(services)} service(s): " + + ", ".join(s.name for s in services) + ) if dry_run: - info("[dry-run] Stopping at discovery; no on-chain or filesystem actions taken.") + info( + "[dry-run] Stopping at discovery; no on-chain or filesystem actions taken." + ) return MigrationOutcome(subset_selected=subset_selected) # If `OperateStore.services()` dropped any malformed configs we MUST NOT @@ -791,8 +810,6 @@ def _run_mode_b( # didn't have one yet (using the same RPC the migration was driven by). chain_rpcs: dict = {} - from operate.operate_types import Chain as _Chain - for svc in services: print_section(f"Migrating service: {svc.name} ({svc.service_config_id})") # Snapshot the service's chain RPCs up front — used by the drain @@ -801,7 +818,7 @@ def _run_mode_b( # wins (deterministic) but we warn so the user can investigate # rather than silently inheriting one over the other. for chain_str, chain_config in svc.chain_configs.items(): - chain_key = _Chain(chain_str) + chain_key = Chain(chain_str) new_rpc = chain_config.ledger_config.rpc existing_rpc = chain_rpcs.get(chain_key) if existing_rpc is None: @@ -838,16 +855,19 @@ def _run_mode_b( merge_service(service=svc, src=disc.quickstart, dest=disc.pearl) except (OSError, shutil.Error) as exc: warn(f"Skipping service {svc.name}: filesystem copy failed: {exc}") - unmigratable.append(_Unmigratable( - service_id=svc.service_config_id, chain=None, - reason=( - f"on-chain migration committed but filesystem copy " - f"failed: {exc}. Manually copy " - f"`services/{svc.service_config_id}/` and the agent " - f"keys (under `keys/`) from {disc.quickstart.root} to " - f"{disc.pearl.root} before launching Pearl." - ), - )) + unmigratable.append( + _Unmigratable( + service_id=svc.service_config_id, + chain=None, + reason=( + f"on-chain migration committed but filesystem copy " + f"failed: {exc}. Manually copy " + f"`services/{svc.service_config_id}/` and the agent " + f"keys (under `keys/`) from {disc.quickstart.root} to " + f"{disc.pearl.root} before launching Pearl." + ), + ) + ) continue migrated.append(svc) @@ -869,7 +889,8 @@ def _run_mode_b( ) else: drain_failures = _drain_master( - qs_wallet=qs_wallet, pearl_wallet=pearl_wallet, + qs_wallet=qs_wallet, + pearl_wallet=pearl_wallet, chain_rpcs=chain_rpcs, ) @@ -896,7 +917,11 @@ def _run_mode_b( ): rename_source_for_rollback(disc.quickstart) elif not outcome.is_complete: - if outcome.subset_selected and not outcome.unmigratable and not outcome.drain_failures: + if ( + outcome.subset_selected + and not outcome.unmigratable + and not outcome.drain_failures + ): reason = "remaining services still live in the source" else: reason = "migration incomplete" @@ -949,10 +974,15 @@ def _print_migration_summary(outcome: MigrationOutcome) -> None: def _step_terminate( - *, manager: "ServiceManager", service: "Service", sid: str, chain_str: str, - ensure_signable, # callable[[], None] + *, + manager: "ServiceManager", + service: "Service", + sid: str, + chain_str: str, + ensure_signable, # callable[[], None] on_chain_state_cls: OnChainState, - ledger_api: Any, qs_address: str, + ledger_api: Any, + qs_address: str, ) -> None: """Move on-chain to PRE_REGISTRATION (idempotent). @@ -967,16 +997,20 @@ def _step_terminate( try: _retry_on_funds_shortage( lambda: manager.terminate_service_on_chain_from_safe( - service_config_id=sid, chain=chain_str, + service_config_id=sid, + chain=chain_str, ), - ledger_api=ledger_api, address=qs_address, chain_str=chain_str, + ledger_api=ledger_api, + address=qs_address, + chain_str=chain_str, recipient_name="Quickstart master EOA", ) except Exception as exc: # pylint: disable=broad-except raise _wrap_step_failure( - sid=sid, chain=chain_str, + sid=sid, + chain=chain_str, prefix="could not unstake/terminate; funds left in master " - "Safe/EOA so you can retry", + "Safe/EOA so you can retry", exc=exc, ) # Verification read after terminate succeeded. Wrap so a transient @@ -987,24 +1021,32 @@ def _step_terminate( on_chain_state = manager._get_on_chain_state(service, chain_str) # noqa: SLF001 except Exception as exc: # pylint: disable=broad-except raise _wrap_step_failure( - sid=sid, chain=chain_str, + sid=sid, + chain=chain_str, prefix="terminate succeeded but post-state verification " - "failed (on-chain progressed; re-run will resume from " - "the next step)", + "failed (on-chain progressed; re-run will resume from " + "the next step)", exc=exc, ) if on_chain_state != on_chain_state_cls.PRE_REGISTRATION: raise _Unmigratable( - service_id=sid, chain=chain_str, + service_id=sid, + chain=chain_str, reason=f"after terminate, service is in state {on_chain_state.name}, " - "expected PRE_REGISTRATION.", + "expected PRE_REGISTRATION.", ) def _step_transfer_nft( - *, ledger_api: Any, qs_wallet: Any, registry_addr: str, - qs_master_safe: str, pearl_master_safe: str, - token_id: int, sid: str, chain_str: str, + *, + ledger_api: Any, + qs_wallet: Any, + registry_addr: str, + qs_master_safe: str, + pearl_master_safe: str, + token_id: int, + sid: str, + chain_str: str, ensure_signable, ) -> None: """Transfer the service NFT to Pearl's master Safe (idempotent).""" @@ -1020,32 +1062,39 @@ def _step_transfer_nft( ) if current_owner is None: raise _Unmigratable( - service_id=sid, chain=chain_str, + service_id=sid, + chain=chain_str, reason=f"could not read NFT owner for token {token_id}; " - "RPC error or contract revert.", + "RPC error or contract revert.", ) if current_owner.lower() == pearl_master_safe.lower(): info(" NFT already owned by Pearl master Safe; skipping transfer.") return if current_owner.lower() != qs_master_safe.lower(): raise _Unmigratable( - service_id=sid, chain=chain_str, + service_id=sid, + chain=chain_str, reason=f"NFT owner is {current_owner}, expected quickstart " - f"({qs_master_safe}) or Pearl ({pearl_master_safe}) Safe.", + f"({qs_master_safe}) or Pearl ({pearl_master_safe}) Safe.", ) ensure_signable() - info(f" transferring service NFT {token_id} {qs_master_safe} -> {pearl_master_safe}") + info( + f" transferring service NFT {token_id} {qs_master_safe} -> {pearl_master_safe}" + ) try: _retry_on_funds_shortage( lambda: transfer_service_nft( - ledger_api=ledger_api, crypto=qs_wallet.crypto, + ledger_api=ledger_api, + crypto=qs_wallet.crypto, service_registry_address=registry_addr, qs_master_safe=qs_master_safe, pearl_master_safe=pearl_master_safe, service_id=token_id, ), - ledger_api=ledger_api, address=qs_wallet.address, - chain_str=chain_str, recipient_name="Quickstart master EOA", + ledger_api=ledger_api, + address=qs_wallet.address, + chain_str=chain_str, + recipient_name="Quickstart master EOA", ) except PostConditionUnknown as exc: # Distinct from "inner reverted": tx submitted, but the post-tx @@ -1056,20 +1105,28 @@ def _step_transfer_nft( # intentional translation so the underlying RPC failure stays # visible in the traceback chain. raise _Unmigratable( - service_id=sid, chain=chain_str, + service_id=sid, + chain=chain_str, reason=str(exc), ) from exc except Exception as exc: # pylint: disable=broad-except raise _wrap_step_failure( - sid=sid, chain=chain_str, - prefix="NFT transfer failed", exc=exc, + sid=sid, + chain=chain_str, + prefix="NFT transfer failed", + exc=exc, ) def _step_swap_service_safe_owner( - *, ledger_api: Any, qs_wallet: Any, service_safe: str, - qs_master_safe: str, pearl_master_safe: str, - sid: str, chain_str: str, + *, + ledger_api: Any, + qs_wallet: Any, + service_safe: str, + qs_master_safe: str, + pearl_master_safe: str, + sid: str, + chain_str: str, ensure_signable, ) -> None: """Swap the service multisig's owner from qs Safe to Pearl Safe (idempotent).""" @@ -1082,7 +1139,8 @@ def _step_swap_service_safe_owner( owners = safe_owners(ledger_api=ledger_api, safe=service_safe) except Exception as exc: # pylint: disable=broad-except raise _wrap_step_failure( - sid=sid, chain=chain_str, + sid=sid, + chain=chain_str, prefix=f"could not read service Safe owners on {service_safe}", exc=exc, ) @@ -1094,21 +1152,26 @@ def _step_swap_service_safe_owner( return if not qs_present: raise _Unmigratable( - service_id=sid, chain=chain_str, + service_id=sid, + chain=chain_str, reason=f"service Safe {service_safe} owners are {owners}; " - f"quickstart Safe ({qs_master_safe}) not found, can't swap.", + f"quickstart Safe ({qs_master_safe}) not found, can't swap.", ) ensure_signable() info(f" swapping owner on service Safe {service_safe}") try: _retry_on_funds_shortage( lambda: swap_service_safe_owner( - ledger_api=ledger_api, crypto=qs_wallet.crypto, + ledger_api=ledger_api, + crypto=qs_wallet.crypto, service_safe=service_safe, - old_owner=qs_master_safe, new_owner=pearl_master_safe, + old_owner=qs_master_safe, + new_owner=pearl_master_safe, ), - ledger_api=ledger_api, address=qs_wallet.address, - chain_str=chain_str, recipient_name="Quickstart master EOA", + ledger_api=ledger_api, + address=qs_wallet.address, + chain_str=chain_str, + recipient_name="Quickstart master EOA", ) except PostConditionUnknown as exc: # State is INDETERMINATE — execTransaction submitted but the @@ -1120,12 +1183,14 @@ def _step_swap_service_safe_owner( # "verify on a block explorer first" guidance. `from exc` # preserves the underlying RPC failure in the traceback chain. raise _Unmigratable( - service_id=sid, chain=chain_str, + service_id=sid, + chain=chain_str, reason=str(exc), ) from exc except Exception as exc: # pylint: disable=broad-except raise _wrap_step_failure( - sid=sid, chain=chain_str, + sid=sid, + chain=chain_str, prefix=( f"service Safe owner swap failed; NFT is now owned by Pearl " f"Safe but service multisig {service_safe} still lists " @@ -1170,6 +1235,7 @@ def _migrate_one_service( # the on-chain branch. _reraise_if_programming_bug(exc) warn(f"stop_service via middleware failed: {exc}; trying force cleanup.") + # Per-service variant of the global stop-and-verify: convert any # cleanup failure into `_Unmigratable(service_id=...)` so the # orchestrator can keep migrating other services. Proceeding to NFT @@ -1184,7 +1250,9 @@ def _bail(reason: str) -> NoReturn: for chain_str, chain_config in service.chain_configs.items(): chain = Chain(chain_str) chain_data = chain_config.chain_data - info(f" chain {chain_str}: service NFT id = {chain_data.token}, multisig = {chain_data.multisig}") + info( + f" chain {chain_str}: service NFT id = {chain_data.token}, multisig = {chain_data.multisig}" + ) # Both `qs_wallet.safes[chain]` and `CONTRACTS[chain][...]` raise # KeyError on missing entries — legitimate per-service conditions @@ -1197,19 +1265,21 @@ def _bail(reason: str) -> NoReturn: qs_master_safe = qs_wallet.safes[chain] except KeyError: raise _Unmigratable( - service_id=sid, chain=chain_str, + service_id=sid, + chain=chain_str, reason=f"quickstart master wallet has no Safe on {chain_str}; " - "service references a chain the wallet was never " - "configured for.", + "service references a chain the wallet was never " + "configured for.", ) try: registry_addr = CONTRACTS[chain]["service_registry"] except KeyError: raise _Unmigratable( - service_id=sid, chain=chain_str, + service_id=sid, + chain=chain_str, reason=f"no ServiceRegistry contract registered for {chain_str} " - "in middleware's CONTRACTS table; cannot read NFT owner " - "or build the transfer.", + "in middleware's CONTRACTS table; cannot read NFT owner " + "or build the transfer.", ) # Ensure Pearl has a master Safe on this chain. `create_safe` is # the same factory that bootstraps Pearl on a new chain — required @@ -1224,13 +1294,15 @@ def _bail(reason: str) -> NoReturn: chain_str=chain_str, rpc=chain_config.ledger_config.rpc, ledger_api=pearl_wallet.ledger_api( - chain=chain, rpc=chain_config.ledger_config.rpc, + chain=chain, + rpc=chain_config.ledger_config.rpc, ), ) except Exception as exc: # pylint: disable=broad-except _reraise_if_programming_bug(exc) raise _Unmigratable( - service_id=sid, chain=chain_str, + service_id=sid, + chain=chain_str, reason=f"could not create Pearl master Safe on {chain_str}: {exc}.", ) pearl_master_safe = pearl_wallet.safes[chain] @@ -1255,54 +1327,76 @@ def _ledger_api(_cfg=chain_config) -> Any: ).ledger_api return ledger_api - def _ensure_signable() -> None: + def _ensure_signable( + _qs_safe: str = qs_master_safe, + _chain: str = chain_str, + ) -> None: """Raise `_Unmigratable` unless the qs master Safe is single-signer. Called lazily, only inside branches that actually sign. Resumed runs that find every step already done skip this entirely. + + Loop-variables (`qs_master_safe`, `chain_str`) are bound via + default args so the closure captures the current iteration's + values, not whatever the loop ends on. """ try: - t = safe_threshold(ledger_api=_ledger_api(), safe=qs_master_safe) + t = safe_threshold(ledger_api=_ledger_api(), safe=_qs_safe) except Exception as exc: # pylint: disable=broad-except _reraise_if_programming_bug(exc) raise _Unmigratable( - service_id=sid, chain=chain_str, + service_id=sid, + chain=_chain, reason=f"could not read quickstart master Safe threshold on " - f"{qs_master_safe}: {exc}.", + f"{_qs_safe}: {exc}.", ) if t == 1: return if t == 0: raise _Unmigratable( - service_id=sid, chain=chain_str, - reason=f"quickstart master Safe {qs_master_safe} reports " - f"threshold 0 — appears unconfigured. Inspect on-chain " - "state before retrying.", + service_id=sid, + chain=_chain, + reason=f"quickstart master Safe {_qs_safe} reports " + f"threshold 0 — appears unconfigured. Inspect on-chain " + "state before retrying.", ) raise _Unmigratable( - service_id=sid, chain=chain_str, - reason=f"quickstart master Safe {qs_master_safe} has threshold " - f"{t}; this script signs single-owner only. Lower the " - "threshold to 1 (temporarily) and re-run.", + service_id=sid, + chain=_chain, + reason=f"quickstart master Safe {_qs_safe} has threshold " + f"{t}; this script signs single-owner only. Lower the " + "threshold to 1 (temporarily) and re-run.", ) _step_terminate( - manager=manager, service=service, sid=sid, chain_str=chain_str, - ensure_signable=_ensure_signable, on_chain_state_cls=OnChainState, - ledger_api=_ledger_api(), qs_address=qs_wallet.address, + manager=manager, + service=service, + sid=sid, + chain_str=chain_str, + ensure_signable=_ensure_signable, + on_chain_state_cls=OnChainState, + ledger_api=_ledger_api(), + qs_address=qs_wallet.address, ) _step_transfer_nft( - ledger_api=_ledger_api(), qs_wallet=qs_wallet, + ledger_api=_ledger_api(), + qs_wallet=qs_wallet, registry_addr=registry_addr, - qs_master_safe=qs_master_safe, pearl_master_safe=pearl_master_safe, - token_id=chain_data.token, sid=sid, chain_str=chain_str, + qs_master_safe=qs_master_safe, + pearl_master_safe=pearl_master_safe, + token_id=chain_data.token, + sid=sid, + chain_str=chain_str, ensure_signable=_ensure_signable, ) _step_swap_service_safe_owner( - ledger_api=_ledger_api(), qs_wallet=qs_wallet, + ledger_api=_ledger_api(), + qs_wallet=qs_wallet, service_safe=chain_data.multisig, - qs_master_safe=qs_master_safe, pearl_master_safe=pearl_master_safe, - sid=sid, chain_str=chain_str, + qs_master_safe=qs_master_safe, + pearl_master_safe=pearl_master_safe, + sid=sid, + chain_str=chain_str, ensure_signable=_ensure_signable, ) @@ -1311,9 +1405,7 @@ def _ensure_signable() -> None: # NFT owned by Pearl Safe). It's required because Pearl's FE expects it # to continue the onbarding of this agent. for chain_config in service.chain_configs.values(): - chain_config.chain_data.user_params.staking_program_id = ( - NO_STAKING_PROGRAM_ID - ) + chain_config.chain_data.user_params.staking_program_id = NO_STAKING_PROGRAM_ID try: service.store() except OSError as exc: @@ -1321,7 +1413,8 @@ def _ensure_signable() -> None: # filesystem copy (`merge_service`) and surfaces in the summary # so the user can re-run after fixing the disk/permission issue. raise _Unmigratable( - service_id=sid, chain=None, + service_id=sid, + chain=None, reason=( "on-chain migration committed but local " f"`staking_program_id` write failed: {exc}. Re-run after " @@ -1332,8 +1425,10 @@ def _ensure_signable() -> None: def _format_amount(amount: int, chain: "Chain", asset: str) -> str: - """Best-effort token formatter; falls back to raw wei when the asset - isn't in the middleware's `CHAIN_TO_METADATA` table. + """Best-effort token formatter that falls back to raw wei for unknown assets. + + Falls back to raw wei when the asset isn't in the middleware's + `CHAIN_TO_METADATA` table. Only catches the lookup failures (`KeyError` for unknown chain/asset); everything else propagates so a bug in the formatter doesn't masquerade @@ -1346,7 +1441,8 @@ def _format_amount(amount: int, chain: "Chain", asset: str) -> str: def _drain_master( - qs_wallet: "MasterWallet", pearl_wallet: "MasterWallet", + qs_wallet: "MasterWallet", + pearl_wallet: "MasterWallet", chain_rpcs: Optional[Dict["Chain", str]] = None, ) -> List[_DrainFailure]: """Drain quickstart master Safe + EOA into Pearl per chain. @@ -1379,15 +1475,17 @@ def _drain_master( "(quickstart had a Safe here but no migrated service " "exposed an RPC for this chain)." ) - failures.append(_DrainFailure( - chain=chain, - source_kind="Safe+EOA", - source_address=qs_wallet.safes[chain], - reason="no RPC available — quickstart had a Safe on " - f"{chain.name} but no migrated service used " - "this chain; cannot create Pearl Safe to " - "drain into.", - )) + failures.append( + _DrainFailure( + chain=chain, + source_kind="Safe+EOA", + source_address=qs_wallet.safes[chain], + reason="no RPC available — quickstart had a Safe on " + f"{chain.name} but no migrated service used " + "this chain; cannot create Pearl Safe to " + "drain into.", + ) + ) continue try: _create_pearl_safe_with_funds_wait( @@ -1401,12 +1499,14 @@ def _drain_master( except Exception as exc: # pylint: disable=broad-except _reraise_if_programming_bug(exc) warn(f" could not create Pearl Safe on {chain.name}: {exc}") - failures.append(_DrainFailure( - chain=chain, - source_kind="Safe+EOA", - source_address=qs_wallet.safes[chain], - reason=f"Pearl Safe creation failed: {exc}.", - )) + failures.append( + _DrainFailure( + chain=chain, + source_kind="Safe+EOA", + source_address=qs_wallet.safes[chain], + reason=f"Pearl Safe creation failed: {exc}.", + ) + ) continue # NOTE: `qs_wallet.drain` is NOT wrapped in `_retry_on_funds_shortage`. @@ -1421,7 +1521,9 @@ def _drain_master( # after topping up; the drain is idempotent (per-asset balance # check before each transfer). # Master Safe -> Pearl master Safe (Pearl Safe receives ERC20s + native). - info(f" [{chain.name}] master Safe -> Pearl master Safe ({pearl_wallet.safes[chain]})") + info( + f" [{chain.name}] master Safe -> Pearl master Safe ({pearl_wallet.safes[chain]})" + ) try: moved = qs_wallet.drain( withdrawal_address=pearl_wallet.safes[chain], @@ -1439,13 +1541,19 @@ def _drain_master( except Exception as exc: # pylint: disable=broad-except _reraise_if_programming_bug(exc) warn(f" drain (Safe) on {chain.name} failed: {exc}") - failures.append(_DrainFailure( - chain=chain, source_kind="Safe", - source_address=qs_wallet.safes[chain], reason=str(exc), - )) + failures.append( + _DrainFailure( + chain=chain, + source_kind="Safe", + source_address=qs_wallet.safes[chain], + reason=str(exc), + ) + ) # Master EOA -> Pearl master EOA. - info(f" [{chain.name}] master EOA -> Pearl master EOA ({pearl_wallet.address})") + info( + f" [{chain.name}] master EOA -> Pearl master EOA ({pearl_wallet.address})" + ) try: moved = qs_wallet.drain( withdrawal_address=pearl_wallet.address, @@ -1460,10 +1568,14 @@ def _drain_master( except Exception as exc: # pylint: disable=broad-except _reraise_if_programming_bug(exc) warn(f" drain (EOA) on {chain.name} failed: {exc}") - failures.append(_DrainFailure( - chain=chain, source_kind="EOA", - source_address=qs_wallet.address, reason=str(exc), - )) + failures.append( + _DrainFailure( + chain=chain, + source_kind="EOA", + source_address=qs_wallet.address, + reason=str(exc), + ) + ) return failures @@ -1472,6 +1584,7 @@ def _drain_master( # Final user message # --------------------------------------------------------------------------- + def _final_prompt(outcome: MigrationOutcome) -> None: if not outcome.is_complete: # Don't ask the "different machine?" question on a partial migration @@ -1483,7 +1596,9 @@ def _final_prompt(outcome: MigrationOutcome) -> None: and not outcome.unmigratable and not outcome.drain_failures ): - print_section("Selected services migrated; remaining services left in source.") + print_section( + "Selected services migrated; remaining services left in source." + ) print() print(" The migrated services will appear in Pearl. The source") print(" quickstart `.operate/` is preserved with the unselected") @@ -1516,7 +1631,9 @@ def _final_prompt(outcome: MigrationOutcome) -> None: # Main # --------------------------------------------------------------------------- + def main(argv: Optional[List[str]] = None) -> int: + """Entry point for the quickstart-to-Pearl migration CLI; returns a process exit code.""" args = _parse_args(argv) print_title("Quickstart -> Pearl migration") diff --git a/scripts/pearl_migration/prompts.py b/scripts/pearl_migration/prompts.py index deb47346..4a91269c 100644 --- a/scripts/pearl_migration/prompts.py +++ b/scripts/pearl_migration/prompts.py @@ -20,6 +20,8 @@ class CollisionChoice(Enum): + """User's resolution for a filesystem collision on a per-service merge.""" + SKIP = "skip" OVERWRITE_WITH_BACKUP = "overwrite" @@ -62,11 +64,13 @@ def yes_no(question: str, default: Optional[bool] = None) -> bool: def _attended() -> bool: import os + return os.environ.get("ATTENDED", "true").lower() == "true" def password( - prompt: str = "Password: ", env_var_name: str = "OPERATE_PASSWORD", + prompt: str = "Password: ", + env_var_name: str = "OPERATE_PASSWORD", ) -> str: """Password prompt, defers to middleware's `ask_or_get_from_env`. @@ -99,8 +103,12 @@ def collision(target: Path, kind: str) -> CollisionChoice: " This usually means you're re-running a migration that previously " "got partway. Pick how to handle it:" ) - print(" 1) Skip — leave the destination untouched (safe if it's the migrated copy).") - print(" 2) Overwrite with backup — rename existing to .bak. then copy fresh.") + print( + " 1) Skip — leave the destination untouched (safe if it's the migrated copy)." + ) + print( + " 2) Overwrite with backup — rename existing to .bak. then copy fresh." + ) while True: try: raw = input(" Choice [1/2]: ").strip() @@ -123,14 +131,17 @@ def backup_suffix() -> str: def info(msg: str) -> None: + """Print an informational line to stdout, prefixed with a bullet marker.""" print(f" - {msg}") def warn(msg: str) -> None: + """Print a warning line to stdout, prefixed with `!`.""" print(f" ! {msg}") def fatal(msg: str, code: int = 1) -> None: + """Print a fatal-error line to stderr and exit with `code` (default 1).""" print(f" X {msg}", file=sys.stderr) sys.exit(code) diff --git a/scripts/pearl_migration/status.py b/scripts/pearl_migration/status.py index e464353c..1f0435b6 100644 --- a/scripts/pearl_migration/status.py +++ b/scripts/pearl_migration/status.py @@ -10,7 +10,7 @@ import socket import subprocess from pathlib import Path -from typing import TYPE_CHECKING, List, Optional +from typing import List, Optional, TYPE_CHECKING if TYPE_CHECKING: from aea.crypto.base import LedgerApi @@ -27,7 +27,9 @@ QUICKSTART_CONTAINER_FRAGMENTS = ("abci0", "node0", "_abci_0", "_tm_0") -def pearl_daemon_running(host: str = "127.0.0.1", port: int = PEARL_DAEMON_PORT) -> bool: +def pearl_daemon_running( + host: str = "127.0.0.1", port: int = PEARL_DAEMON_PORT +) -> bool: """TCP probe: True if anything is listening on Pearl's daemon port. Only treats "connection refused" / "timeout" as "not running". Any @@ -52,7 +54,7 @@ def docker_quickstart_containers() -> List[str]: proceed believing there are no containers when there actually are. """ try: - result = subprocess.run( + result = subprocess.run( # nosec B607 # `docker` looked up via PATH is the desired UX ["docker", "ps", "-a", "--format", "{{.Names}}"], check=False, capture_output=True, @@ -60,7 +62,7 @@ def docker_quickstart_containers() -> List[str]: timeout=10, ) except FileNotFoundError: - return [] # docker not installed at all — caller can proceed. + return [] # docker not installed at all — caller can proceed. # subprocess.TimeoutExpired and any other error propagates. if result.returncode != 0: # Non-zero with docker installed = daemon refusing to talk @@ -79,7 +81,8 @@ def docker_quickstart_containers() -> List[str]: # let the migration race a still-running deployment. names = result.stdout.split() return sorted( - name for name in names + name + for name in names if any(frag in name for frag in QUICKSTART_CONTAINER_FRAGMENTS) ) @@ -128,9 +131,10 @@ def service_nft_owner( other unexpected exception propagate so the caller can distinguish "token doesn't exist" from "we can't tell right now". """ - from autonomy.chain.base import registry_contracts from web3.exceptions import ContractLogicError + from autonomy.chain.base import registry_contracts + instance = registry_contracts.service_registry.get_instance( ledger_api=ledger_api, contract_address=service_registry_address, diff --git a/scripts/pearl_migration/stop.py b/scripts/pearl_migration/stop.py index 41ce1550..6c38149f 100644 --- a/scripts/pearl_migration/stop.py +++ b/scripts/pearl_migration/stop.py @@ -3,7 +3,7 @@ from __future__ import annotations import subprocess -from typing import TYPE_CHECKING, List +from typing import List, TYPE_CHECKING from .status import QUICKSTART_CONTAINER_FRAGMENTS, docker_quickstart_containers @@ -70,7 +70,7 @@ def force_remove_known_containers() -> List[str]: if not leftovers: return [] try: - subprocess.run( + subprocess.run( # nosec B607 # `docker` looked up via PATH is the desired UX ["docker", "rm", "-f", *leftovers], check=True, capture_output=True, diff --git a/scripts/pearl_migration/transfer.py b/scripts/pearl_migration/transfer.py index 0e45f8ed..9d396e27 100644 --- a/scripts/pearl_migration/transfer.py +++ b/scripts/pearl_migration/transfer.py @@ -54,7 +54,7 @@ import asyncio import time -from typing import TYPE_CHECKING, Callable, TypeVar +from typing import Callable, TYPE_CHECKING, TypeVar import requests.exceptions import web3.exceptions @@ -112,6 +112,7 @@ class PostConditionUnknown(RuntimeError): """ def __init__(self, tx_hash: str, last_exc: BaseException) -> None: + """Build the indeterminate-state error for `tx_hash`, carrying `last_exc` as cause.""" super().__init__( f"On-chain state INDETERMINATE after Safe tx {tx_hash}: the " f"outer tx mined, but the post-tx verification read failed " @@ -160,7 +161,7 @@ def _read_with_retry( except _RPC_EXCEPTION_TYPES as exc: last_exc = exc if n + 1 < attempts: - time.sleep(backoff * (2 ** n)) + time.sleep(backoff * (2**n)) raise PostConditionUnknown(tx_hash=tx_hash, last_exc=last_exc) @@ -180,9 +181,10 @@ def transfer_service_nft( Returns the transaction hash from `send_safe_txs`. Raises on chain errors. """ - from autonomy.chain.base import registry_contracts from operate.utils.gnosis import send_safe_txs + from autonomy.chain.base import registry_contracts + instance = registry_contracts.service_registry.get_instance( ledger_api=ledger_api, contract_address=service_registry_address, @@ -254,7 +256,6 @@ def swap_service_safe_owner( and proceeds. The inner call is a self-call into swapOwner, which then sees msg.sender == address(this).) """ - from autonomy.chain.base import registry_contracts from operate.services.protocol import ( get_packed_signature_for_approved_hash, ) @@ -265,8 +266,12 @@ def swap_service_safe_owner( send_safe_txs, ) + from autonomy.chain.base import registry_contracts + prev_owner = get_prev_owner( - ledger_api=ledger_api, safe=service_safe, owner=old_owner, + ledger_api=ledger_api, + safe=service_safe, + owner=old_owner, ) service_safe_instance = registry_contracts.gnosis_safe.get_instance( ledger_api=ledger_api, @@ -308,15 +313,15 @@ def swap_service_safe_owner( exec_data_hex = service_safe_instance.encode_abi( abi_element_identifier="execTransaction", args=[ - service_safe, # to (self-call) - 0, # value - swap_owner_data, # data - SafeOperation.CALL.value, # operation - 0, # safeTxGas - 0, # baseGas - 0, # gasPrice - "0x" + "00" * 20, # gasToken - "0x" + "00" * 20, # refundReceiver + service_safe, # to (self-call) + 0, # value + swap_owner_data, # data + SafeOperation.CALL.value, # operation + 0, # safeTxGas + 0, # baseGas + 0, # gasPrice + "0x" + "00" * 20, # gasToken + "0x" + "00" * 20, # refundReceiver get_packed_signature_for_approved_hash( owners=(old_owner,), ), @@ -337,7 +342,8 @@ def swap_service_safe_owner( # read so a transient RPC hiccup post-mining doesn't surface as # "swap failed" when the on-chain side actually completed. owners_after = { - o.lower() for o in _read_with_retry( + o.lower() + for o in _read_with_retry( lambda: get_owners(ledger_api=ledger_api, safe=service_safe), tx_hash=exec_tx_hash, ) diff --git a/scripts/pearl_migration/wallet.py b/scripts/pearl_migration/wallet.py index 38daa7e3..7221a8b2 100644 --- a/scripts/pearl_migration/wallet.py +++ b/scripts/pearl_migration/wallet.py @@ -24,6 +24,7 @@ recovery artifact, so removing it before the issue is resolved would be unrecoverable. """ + from __future__ import annotations import json diff --git a/scripts/predict_trader/README.md b/scripts/predict_trader/README.md index b5da1d7f..d222f241 100644 --- a/scripts/predict_trader/README.md +++ b/scripts/predict_trader/README.md @@ -13,19 +13,19 @@ A quickstart for the Omenstrat AI agent for AI prediction markets on Gnosis at h 1. Use the `trades` command to display information about placed trades by a given address: ```bash - poetry run python -m scripts.predict_trader.trades --creator YOUR_SAFE_ADDRESS + uv run python -m scripts.predict_trader.trades --creator YOUR_SAFE_ADDRESS ``` Or restrict the search to specific dates by defining the "from" and "to" dates: ```bash - poetry run python -m scripts.predict_trader.trades --creator YOUR_SAFE_ADDRESS --from-date 2023-08-15:03:50:00 --to-date 2023-08-20:13:45:00 + uv run python -m scripts.predict_trader.trades --creator YOUR_SAFE_ADDRESS --from-date 2023-08-15:03:50:00 --to-date 2023-08-20:13:45:00 ``` 2. Use the `report` command to display a summary of the AI agent status: ```bash - poetry run python -m scripts.predict_trader.report + uv run python -m scripts.predict_trader.report ``` 3. Use this command to investigate your agent instance's logs: @@ -194,8 +194,8 @@ If you were previously using [trader-quickstart](https://github.com/valory-xyz/t 2. Run the migration script to create the new `.operate` folder compatible with unified quickstart: ```bash - poetry install - poetry run python -m scripts.predict_trader.migrate_legacy_quickstart configs/config_predict_trader.json + uv sync + uv run python -m scripts.predict_trader.migrate_legacy_quickstart configs/config_predict_trader.json ``` 3. Follow the prompts to complete the migration process. The script will: diff --git a/scripts/predict_trader/mech_events.py b/scripts/predict_trader/mech_events.py index b4428052..52c92bd3 100644 --- a/scripts/predict_trader/mech_events.py +++ b/scripts/predict_trader/mech_events.py @@ -22,21 +22,20 @@ import json import os -import traceback -import requests import sys import time +import traceback from dataclasses import dataclass from pathlib import Path from string import Template -from tqdm import tqdm from typing import Any, ClassVar, Dict, Optional +import requests from gql import Client, gql from gql.transport.requests import RequestsHTTPTransport +from tqdm import tqdm from web3.datastructures import AttributeDict - SCRIPT_PATH = Path(__file__).resolve().parent MECH_EVENTS_JSON_PATH = Path(SCRIPT_PATH.parents[1], "data", "mech_events.json") HTTP = "http://" @@ -47,14 +46,15 @@ DEFAULT_MECH_FEE = 10000000000000000 DEFAULT_FROM_TIMESTAMP = 0 DEFAULT_TO_TIMESTAMP = 2147483647 -MECH_SUBGRAPH_URL_TEMPLATE = "https://api.subgraph.autonolas.tech/api/proxy/marketplace-gnosis" +MECH_SUBGRAPH_URL_TEMPLATE = ( + "https://api.subgraph.autonolas.tech/api/proxy/marketplace-gnosis" +) SUBGRAPH_HEADERS = { "Accept": "application/json, multipart/mixed", "Content-Type": "application/json", } QUERY_BATCH_SIZE = 1000 -MECH_EVENTS_SUBGRAPH_QUERY_TEMPLATE = Template( - """ +MECH_EVENTS_SUBGRAPH_QUERY_TEMPLATE = Template(""" query mech_events_subgraph_query($sender: String, $id_gt: ID, $first: Int) { ${subgraph_event_set_name}( where: {sender: $sender, id_gt: $id_gt} @@ -69,20 +69,20 @@ transactionHash blockNumber blockTimestamp - + # Fetch from legacy mech request mechRequest { ipfsHash } - + # Fetch from marketplace request marketplaceRequest { ipfsHashBytes } } } - """ -) + """) + @dataclass class MechBaseEvent: # pylint: disable=too-many-instance-attributes @@ -131,12 +131,14 @@ def _populate_ipfs_contents(self) -> None: elif self.ipfs_hash_bytes: url = f"{IPFS_ADDRESS}{CID_PREFIX}{self.ipfs_hash_bytes}" else: - print(f"WARNING: No IPFS hash found for Mech event {self.event_name} with ID {self.event_id}.") + print( + f"WARNING: No IPFS hash found for Mech event {self.event_name} with ID {self.event_id}." + ) return for _url in [f"{url}/metadata.json", url]: try: - response = requests.get(_url) + response = requests.get(_url, timeout=30) response.raise_for_status() self.ipfs_contents = response.json() self.ipfs_link = _url @@ -145,7 +147,7 @@ def _populate_ipfs_contents(self) -> None: except Exception: # pylint: disable=broad-except print(traceback.format_exc()) - input(f"Press Enter to continue...") + input("Press Enter to continue...") @dataclass @@ -164,8 +166,14 @@ def __init__(self, event: AttributeDict): super().__init__( event_id=event["id"], sender=event["sender"]["id"], - ipfs_hash=event["mechRequest"]["ipfsHash"] if event["mechRequest"] else None, - ipfs_hash_bytes=event["marketplaceRequest"]["ipfsHashBytes"] if event["marketplaceRequest"] else None, + ipfs_hash=( + event["mechRequest"]["ipfsHash"] if event["mechRequest"] else None + ), + ipfs_hash_bytes=( + event["marketplaceRequest"]["ipfsHashBytes"] + if event["marketplaceRequest"] + else None + ), transaction_hash=event["transactionHash"], block_number=int(event["blockNumber"]), block_timestamp=int(event["blockTimestamp"]), @@ -186,7 +194,9 @@ def _read_mech_events_data_from_file() -> Dict[str, Any]: if mech_events_data.get("db_version", 0) < MECH_EVENTS_DB_VERSION: current_time = time.strftime("%Y-%m-%d_%H-%M-%S") old_db_filename = f"mech_events.{current_time}.old.json" - os.rename(MECH_EVENTS_JSON_PATH, MECH_EVENTS_JSON_PATH.parent / old_db_filename) + os.rename( + MECH_EVENTS_JSON_PATH, MECH_EVENTS_JSON_PATH.parent / old_db_filename + ) mech_events_data = {} mech_events_data["db_version"] = MECH_EVENTS_DB_VERSION except FileNotFoundError: @@ -227,7 +237,9 @@ def _query_mech_events_subgraph( subgraph_event_set_name = f"{event_cls.subgraph_event_name}s" all_results: dict[str, Any] = {"data": {subgraph_event_set_name: []}} - query = MECH_EVENTS_SUBGRAPH_QUERY_TEMPLATE.safe_substitute(subgraph_event_set_name=subgraph_event_set_name) + query = MECH_EVENTS_SUBGRAPH_QUERY_TEMPLATE.safe_substitute( + subgraph_event_set_name=subgraph_event_set_name + ) id_gt = "" while True: variables = { @@ -277,13 +289,9 @@ def _update_mech_events_db( miniters=1, desc=" Processing", ): - if subgraph_event[ - "id" - ] not in stored_events or not stored_events.get( + if subgraph_event["id"] not in stored_events or not stored_events.get( subgraph_event["id"], {} - ).get( - "ipfs_contents" - ): + ).get("ipfs_contents"): mech_event = event_cls(subgraph_event) # type: ignore stored_events[mech_event.event_id] = mech_event.__dict__ diff --git a/scripts/predict_trader/migrate_legacy_quickstart.py b/scripts/predict_trader/migrate_legacy_quickstart.py index 1ed1c069..633252f5 100644 --- a/scripts/predict_trader/migrate_legacy_quickstart.py +++ b/scripts/predict_trader/migrate_legacy_quickstart.py @@ -1,18 +1,17 @@ +"""Migrate a legacy `.trader_runner/` directory into the modern operate-managed layout.""" + +import json +import os +import sys from argparse import ArgumentParser from dataclasses import dataclass -import os -from typing import TypedDict -from dotenv import load_dotenv from getpass import getpass -from halo import Halo -import json from pathlib import Path -import sys +from typing import TypedDict from aea_ledger_ethereum import Account, EthereumCrypto, LocalAccount -from autonomy.chain.config import ChainType -from autonomy.chain.base import registry_contracts -from autonomy.constants import DEFAULT_KEYS_FILE +from dotenv import load_dotenv +from halo import Halo from operate.cli import OperateApp from operate.constants import ( OPERATE, @@ -21,21 +20,24 @@ from operate.ledger.profiles import ERC20_TOKENS from operate.operate_types import Chain, LedgerType, OnChainState, ServiceTemplate from operate.quickstart.run_service import ( + NO_STAKING_PROGRAM_ID, + QuickstartConfig, ask_password_if_needed, get_service, - QuickstartConfig, ) -from operate.services.protocol import StakingManager, StakingState -from operate.services.service import Service from operate.quickstart.utils import ( - ask_yes_or_no, CHAIN_TO_METADATA, + ask_yes_or_no, print_section, print_title, ) -from operate.quickstart.run_service import NO_STAKING_PROGRAM_ID +from operate.services.protocol import StakingManager, StakingState +from operate.services.service import Service from operate.utils.gnosis import get_asset_balance, get_assets_balances +from autonomy.chain.base import registry_contracts +from autonomy.constants import DEFAULT_KEYS_FILE + ROOT_PATH = Path(__file__).parent.parent.parent TRADER_RUNNER_PATH = ROOT_PATH / ".trader_runner" OPERATE_HOME = ROOT_PATH / OPERATE @@ -50,6 +52,8 @@ class StakingVariables(TypedDict): + """Shape of the staking-related env vars carried over from `.trader_runner/.env`.""" + USE_STAKING: bool STAKING_PROGRAM: str AGENT_ID: int @@ -64,6 +68,8 @@ class StakingVariables(TypedDict): @dataclass class TraderData: + """Bundle of state read off a legacy `.trader_runner/` directory.""" + password: str agent_eoa: Path master_eoa: Path @@ -75,9 +81,12 @@ class TraderData: def decrypt_private_keys(eoa: Path, password: str) -> dict[str, str]: + """Decrypt the EOA file (with or without password) and return its key payload.""" if not password: private_key = "0x" + eoa.read_text() + # pylint: disable=no-value-for-parameter # eth_account.Account uses a metaclass; pylint can't see the classmethod account: LocalAccount = Account.from_key(private_key) + # pylint: enable=no-value-for-parameter address = account.address else: crypto = EthereumCrypto(private_key_path=eoa, password=password) @@ -92,6 +101,7 @@ def decrypt_private_keys(eoa: Path, password: str) -> dict[str, str]: def parse_trader_runner() -> TraderData: + """Read every persistent value out of `.trader_runner/` and return it as a TraderData.""" load_dotenv(TRADER_RUNNER_PATH / ".env") subgraph_api_key = os.getenv("SUBGRAPH_API_KEY") @@ -149,6 +159,7 @@ def parse_trader_runner() -> TraderData: def populate_operate( operate: OperateApp, trader_data: TraderData, service_template: ServiceTemplate ) -> Service: + """Set up OperateApp using `trader_data` and return the resulting Service object.""" print_section("Setting up Operate") operate.setup() if operate.user_account is None: @@ -248,9 +259,9 @@ def populate_operate( service.agent_addresses = [agent_eoa["address"]] service.chain_configs[Chain.GNOSIS.value].chain_data.token = trader_data.service_id - service.chain_configs[ - Chain.GNOSIS.value - ].chain_data.multisig = trader_data.service_safe + service.chain_configs[Chain.GNOSIS.value].chain_data.multisig = ( + trader_data.service_safe + ) service.store() spinner.succeed("Service created") @@ -280,6 +291,7 @@ def populate_operate( def migrate_to_master_safe( operate: OperateApp, trader_data: TraderData, service: Service ) -> None: + """Move funds and ownership from the legacy trader-runner setup to the master safe.""" print_section("Migrating service to .operate") chain_config = service.chain_configs[service.home_chain] ledger_config = chain_config.ledger_config @@ -561,6 +573,7 @@ def migrate_to_master_safe( def main(config_path: Path) -> None: + """Run the predict-trader quickstart migration end-to-end against the given config.""" print_title("Predict Trader Quickstart Migration") if not TRADER_RUNNER_PATH.exists(): print("No .trader_runner file found!") diff --git a/scripts/predict_trader/rank_traders.py b/scripts/predict_trader/rank_traders.py index bd063e40..fd5f36ca 100644 --- a/scripts/predict_trader/rank_traders.py +++ b/scripts/predict_trader/rank_traders.py @@ -19,20 +19,24 @@ # ------------------------------------------------------------------------------ """This script queries the OMEN subgraph to obtain the trades of a given address.""" - import datetime -import requests import sys from argparse import ArgumentParser from collections import defaultdict from string import Template from typing import Any +import requests +from operate.cli import OperateApp from operate.operate_types import Chain from operate.quickstart.run_service import load_local_config +from scripts.predict_trader.trades import ( + MarketAttribute, + MarketState, + parse_user, + wei_to_xdai, +) from scripts.utils import get_subgraph_api_key -from scripts.predict_trader.trades import MarketAttribute, MarketState, parse_user, wei_to_xdai - QUERY_BATCH_SIZE = 1000 DUST_THRESHOLD = 10000000000000 @@ -48,8 +52,7 @@ } -omen_xdai_trades_query = Template( - """ +omen_xdai_trades_query = Template(""" { fpmmTrades( where: { @@ -98,8 +101,7 @@ } } } - """ -) + """) ATTRIBUTE_CHOICES = {i.name: i for i in MarketAttribute} @@ -187,7 +189,7 @@ def _query_omen_xdai_subgraph( id_gt=id_gt, ) content_json = _to_content(query) - res = requests.post(url, headers=headers, json=content_json) + res = requests.post(url, headers=headers, json=content_json, timeout=30) result_json = res.json() user_trades = result_json.get("data", {}).get("fpmmTrades", []) @@ -276,7 +278,9 @@ def _print_user_summary( wei_to_xdai(statistics_table[MarketAttribute.INVESTMENT][state]).rjust(13), wei_to_xdai(statistics_table[MarketAttribute.FEES][state]).rjust(13), wei_to_xdai(statistics_table[MarketAttribute.EARNINGS][state]).rjust(13), - wei_to_xdai(statistics_table[MarketAttribute.NET_EARNINGS][state]).rjust(13), + wei_to_xdai(statistics_table[MarketAttribute.NET_EARNINGS][state]).rjust( + 13 + ), wei_to_xdai(statistics_table[MarketAttribute.REDEMPTIONS][state]).rjust(13), f"{statistics_table[MarketAttribute.ROI][state] * 100.0:7.2f}%".rjust(9), "\n", @@ -300,9 +304,11 @@ def _print_progress_bar( # pylint: disable=too-many-arguments percent = ("{0:.1f}").format(100 * (iteration / float(total))) filled_length = int(length * iteration // total) - bar = fill * filled_length + "-" * (length - filled_length) + progress_bar = fill * filled_length + "-" * (length - filled_length) progress_string = f"({iteration} of {total}) - {percent}%" - sys.stdout.write("\r%s |%s| %s %s" % (prefix, bar, progress_string, suffix)) + sys.stdout.write( + "\r%s |%s| %s %s" % (prefix, progress_bar, progress_string, suffix) + ) sys.stdout.flush() @@ -310,7 +316,10 @@ def _print_progress_bar( # pylint: disable=too-many-arguments print("Starting script") user_args = _parse_args() - config = load_local_config() + # `load_local_config` requires an OperateApp + service name since + # olas-operate-middleware 0.15.x. `Trader Agent` matches the + # display name written by the predict-trader quickstart. + config = load_local_config(operate=OperateApp(), service_name="Trader Agent") rpc = config.rpc[Chain.GNOSIS.value] print("Querying Thegraph...") diff --git a/scripts/predict_trader/report.py b/scripts/predict_trader/report.py index c9b72a35..35dd0607 100644 --- a/scripts/predict_trader/report.py +++ b/scripts/predict_trader/report.py @@ -27,15 +27,28 @@ import traceback from argparse import ArgumentParser from collections import Counter - from enum import Enum from pathlib import Path from typing import Any import docker -from psutil import pid_exists import requests import scripts.predict_trader.trades as trades +from operate.cli import OperateApp +from operate.constants import ( + DEPLOYMENT_DIR, + MECH_ACTIVITY_CHECKER_JSON_URL, + MECH_CONTRACT_JSON_URL, + OPERATE_HOME, + SERVICE_REGISTRY_TOKEN_UTILITY_JSON_URL, + STAKING_TOKEN_INSTANCE_ABI_PATH, +) +from operate.ledger.profiles import get_staking_contract +from operate.operate_types import Chain +from operate.quickstart.run_service import ask_password_if_needed, load_local_config +from operate.quickstart.utils import print_title +from operate.services.service import Service +from psutil import pid_exists from scripts.predict_trader.trades import ( MarketAttribute, MarketState, @@ -46,25 +59,10 @@ wei_to_wxdai, wei_to_xdai, ) +from scripts.utils import get_service_from_config from web3 import HTTPProvider, Web3 from web3.exceptions import ABIFunctionNotFound, ContractLogicError -from operate.constants import ( - DEPLOYMENT_DIR, - OPERATE_HOME, - STAKING_TOKEN_INSTANCE_ABI_PATH, - SERVICE_REGISTRY_TOKEN_UTILITY_JSON_URL, - MECH_ACTIVITY_CHECKER_JSON_URL, - MECH_CONTRACT_JSON_URL, -) -from operate.cli import OperateApp -from operate.ledger.profiles import get_staking_contract -from operate.operate_types import Chain -from operate.quickstart.run_service import ask_password_if_needed, load_local_config -from operate.quickstart.utils import print_title -from operate.services.service import Service -from scripts.utils import get_service_from_config - SCRIPT_PATH = Path(__file__).resolve().parent SAFE_BALANCE_THRESHOLD = 500000000000000000 AGENT_XDAI_BALANCE_THRESHOLD = 50000000000000000 @@ -111,8 +109,8 @@ def _color_bool( def _color_percent(p: float, multiplier: float = 100, symbol: str = "%") -> str: if p >= 0: - return f"{p*multiplier:.2f} {symbol}" - return _color_string(f"{p*multiplier:.2f} {symbol}", ColorCode.RED) + return f"{p * multiplier:.2f} {symbol}" + return _color_string(f"{p * multiplier:.2f} {symbol}", ColorCode.RED) def _trades_since_message(trades_json: dict[str, Any], utc_ts: float = 0) -> str: @@ -127,16 +125,21 @@ def _trades_since_message(trades_json: dict[str, Any], utc_ts: float = 0) -> str return f"{trades_count} trades on {markets_count} markets" -def _calculate_retrades_since(trades_json: dict[str, Any], utc_ts: float = 0) -> tuple[Counter[Any], int, int, int]: - filtered_trades = Counter(( - trade.get("fpmm", {}).get("id", None) - for trade in trades_json.get("data", {}).get("fpmmTrades", []) - if float(trade.get("creationTimestamp", 0)) >= utc_ts - )) +def _calculate_retrades_since( + trades_json: dict[str, Any], utc_ts: float = 0 +) -> tuple[Counter[Any], int, int, int]: + filtered_trades = Counter( + ( + trade.get("fpmm", {}).get("id", None) + for trade in trades_json.get("data", {}).get("fpmmTrades", []) + if float(trade.get("creationTimestamp", 0)) >= utc_ts + ) + ) if None in filtered_trades: raise ValueError( - f"Unexpected format in trades_json: {filtered_trades[None]} trades have no associated market ID.") + f"Unexpected format in trades_json: {filtered_trades[None]} trades have no associated market ID." + ) unique_markets = set(filtered_trades) n_unique_markets = len(unique_markets) @@ -145,9 +148,13 @@ def _calculate_retrades_since(trades_json: dict[str, Any], utc_ts: float = 0) -> return filtered_trades, n_unique_markets, n_trades, n_retrades -def _retrades_since_message(n_unique_markets: int, n_trades: int, n_retrades: int) -> str: + +def _retrades_since_message( + n_unique_markets: int, n_trades: int, n_retrades: int +) -> str: return f"{n_retrades} re-trades on total {n_trades} trades in {n_unique_markets} markets" + def _average_trades_since_message(n_trades: int, n_markets: int) -> str: if not n_markets: average_trades = 0 @@ -156,6 +163,7 @@ def _average_trades_since_message(n_trades: int, n_markets: int) -> str: return f"{average_trades} trades per market" + def _max_trades_per_market_since_message(filtered_trades: Counter[Any]) -> str: if not filtered_trades: max_trades = 0 @@ -208,9 +216,13 @@ def _get_agent_status(service: Service) -> str: trader_abci_container = None trader_tm_container = None for container in client.containers.list(): - if container.name.startswith("traderpearl") and container.name.endswith("abci_0"): + if container.name.startswith("traderpearl") and container.name.endswith( + "abci_0" + ): trader_abci_container = container - elif container.name.startswith("traderpearl") and container.name.endswith("tm_0"): + elif container.name.startswith("traderpearl") and container.name.endswith( + "tm_0" + ): trader_tm_container = container if trader_abci_container and trader_tm_container: break @@ -243,7 +255,9 @@ def _parse_args() -> Any: with open(operate_wallet_path) as file: operator_wallet_data = json.load(file) - template_path = Path(SCRIPT_PATH.parents[1], "configs", "config_predict_trader.json") + template_path = Path( + SCRIPT_PATH.parents[1], "configs", "config_predict_trader.json" + ) service = get_service_from_config(template_path, operate) config = load_local_config(operate=operate, service_name=service.name) chain_config = service.chain_configs["gnosis"] @@ -275,14 +289,16 @@ def _parse_args() -> Any: try: get_balance(safe_address, rpc, block_identifier=current_block_number) except TypeError: - print("RPC does not support block number queries, falling back to latest block.") + print( + "RPC does not support block number queries, falling back to latest block." + ) current_block_number = "latest" print("") print_title(f"\nService report on block number {current_block_number}\n") # Performance - _print_section_header(f"Performance") + _print_section_header("Performance") _print_subsection_header("Staking") staking_token_address = get_staking_contract( @@ -293,7 +309,9 @@ def _parse_args() -> Any: is_staked = False staking_state = StakingState.UNSTAKED else: - staking_token_data = requests.get(STAKING_TOKEN_INSTANCE_ABI_PATH).json() + staking_token_data = requests.get( + STAKING_TOKEN_INSTANCE_ABI_PATH, timeout=30 + ).json() staking_token_abi = staking_token_data.get("abi", []) staking_token_contract = w3.eth.contract( @@ -301,9 +319,9 @@ def _parse_args() -> Any: ) staking_state = StakingState( - staking_token_contract.functions.getStakingState( - service_id - ).call(block_identifier=current_block_number) + staking_token_contract.functions.getStakingState(service_id).call( + block_identifier=current_block_number + ) ) is_staked = ( @@ -317,21 +335,33 @@ def _parse_args() -> Any: if staking_state == StakingState.STAKED: _print_status("Staking state", staking_state.name) elif staking_state == StakingState.EVICTED: - _print_status("Staking state", _color_string(staking_state.name, ColorCode.RED)) + _print_status( + "Staking state", _color_string(staking_state.name, ColorCode.RED) + ) if is_staked: - activity_checker_address = staking_token_contract.functions.activityChecker().call(block_identifier=current_block_number) - activity_checker_data = requests.get(MECH_ACTIVITY_CHECKER_JSON_URL).json() + activity_checker_address = ( + staking_token_contract.functions.activityChecker().call( + block_identifier=current_block_number + ) + ) + activity_checker_data = requests.get( + MECH_ACTIVITY_CHECKER_JSON_URL, timeout=30 + ).json() activity_checker_abi = activity_checker_data.get("abi", []) activity_checker_contract = w3.eth.contract( address=activity_checker_address, abi=activity_checker_abi # type: ignore ) - service_registry_token_utility_data = requests.get(SERVICE_REGISTRY_TOKEN_UTILITY_JSON_URL).json() + service_registry_token_utility_data = requests.get( + SERVICE_REGISTRY_TOKEN_UTILITY_JSON_URL, timeout=30 + ).json() service_registry_token_utility_contract_address = ( - staking_token_contract.functions.serviceRegistryTokenUtility().call(block_identifier=current_block_number) + staking_token_contract.functions.serviceRegistryTokenUtility().call( + block_identifier=current_block_number + ) ) service_registry_token_utility_abi = ( service_registry_token_utility_data.get("abi", []) @@ -342,57 +372,73 @@ def _parse_args() -> Any: ) try: - activity_checker_data = requests.get(MECH_CONTRACT_JSON_URL).json() + activity_checker_data = requests.get( + MECH_CONTRACT_JSON_URL, timeout=30 + ).json() activity_checker_abi = activity_checker_data.get("abi", []) mm_activity_checker_contract = w3.eth.contract( address=activity_checker_address, abi=activity_checker_abi # type: ignore ) - mech_contract_address = mm_activity_checker_contract.functions.mechMarketplace().call(block_identifier=current_block_number) + mech_contract_address = ( + mm_activity_checker_contract.functions.mechMarketplace().call( + block_identifier=current_block_number + ) + ) except (ContractLogicError, ValueError): - mech_contract_address = activity_checker_contract.functions.agentMech().call(block_identifier=current_block_number) + mech_contract_address = ( + activity_checker_contract.functions.agentMech().call( + block_identifier=current_block_number + ) + ) mech_contract_abi = [ { "inputs": [ { - "internalType": "address", - "name": "account", - "type": "address" + "internalType": "address", + "name": "account", + "type": "address", } ], "name": function_name, "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } + {"internalType": "uint256", "name": "", "type": "uint256"} ], "stateMutability": "view", - "type": "function" + "type": "function", } for function_name in ("mapRequestsCounts", "mapRequestCounts") ] mech_contract = w3.eth.contract( - address=mech_contract_address, abi=mech_contract_abi # type: ignore + address=mech_contract_address, abi=mech_contract_abi # type: ignore ) try: - mech_request_count = mech_contract.functions.mapRequestsCounts(safe_address).call(block_identifier=current_block_number) + mech_request_count = mech_contract.functions.mapRequestsCounts( + safe_address + ).call(block_identifier=current_block_number) except (ContractLogicError, ABIFunctionNotFound, ValueError): # Use mapRequestCounts for newer mechs - mech_request_count = mech_contract.functions.mapRequestCounts(safe_address).call(block_identifier=current_block_number) + mech_request_count = mech_contract.functions.mapRequestCounts( + safe_address + ).call(block_identifier=current_block_number) security_deposit = ( service_registry_token_utility_contract.functions.getOperatorBalance( operator_address, service_id ).call(block_identifier=current_block_number) ) - agent_id = int(staking_token_contract.functions.getAgentIds().call(block_identifier=current_block_number)[0]) + agent_id = int( + staking_token_contract.functions.getAgentIds().call( + block_identifier=current_block_number + )[0] + ) agent_bond = service_registry_token_utility_contract.functions.getAgentBond( service_id, agent_id ).call(block_identifier=current_block_number) min_staking_deposit = ( - staking_token_contract.functions.minStakingDeposit().call(block_identifier=current_block_number) + staking_token_contract.functions.minStakingDeposit().call( + block_identifier=current_block_number + ) ) # In the setting 1 agent instance as of now: minOwnerBond = minStakingDeposit @@ -412,32 +458,35 @@ def _parse_args() -> Any: rewards = service_info[3] _print_status("Accrued rewards", f"{wei_to_olas(rewards)}") - liveness_ratio = ( - activity_checker_contract.functions.livenessRatio().call(block_identifier=current_block_number) + liveness_ratio = activity_checker_contract.functions.livenessRatio().call( + block_identifier=current_block_number ) current_timestamp = w3.eth.get_block(current_block_number).timestamp - last_ts_checkpoint = staking_token_contract.functions.tsCheckpoint().call(block_identifier=current_block_number) - liveness_period = ( - staking_token_contract.functions.livenessPeriod().call(block_identifier=current_block_number) + last_ts_checkpoint = staking_token_contract.functions.tsCheckpoint().call( + block_identifier=current_block_number + ) + liveness_period = staking_token_contract.functions.livenessPeriod().call( + block_identifier=current_block_number ) mech_requests_24h_threshold = math.ceil( max(liveness_period, (current_timestamp - last_ts_checkpoint)) * liveness_ratio - / 10 ** 18 + / 10**18 ) - next_checkpoint_ts = ( - staking_token_contract.functions.getNextRewardCheckpointTimestamp().call(block_identifier=current_block_number) + next_checkpoint_ts = staking_token_contract.functions.getNextRewardCheckpointTimestamp().call( + block_identifier=current_block_number ) last_checkpoint_ts = next_checkpoint_ts - liveness_period mech_request_count_on_last_checkpoint = ( - staking_token_contract.functions.getServiceInfo(service_id).call(block_identifier=current_block_number) + staking_token_contract.functions.getServiceInfo(service_id).call( + block_identifier=current_block_number + ) )[2][1] - mech_requests_since_last_cp = mech_request_count - mech_request_count_on_last_checkpoint - # mech_requests_current_epoch = _get_mech_requests_count( - # mech_requests, last_checkpoint_ts - # ) + mech_requests_since_last_cp = ( + mech_request_count - mech_request_count_on_last_checkpoint + ) mech_requests_current_epoch = mech_requests_since_last_cp _print_status( "Num. Mech txs current epoch", @@ -460,13 +509,25 @@ def _parse_args() -> Any: _trades_since_message(trades_json, since_ts), ) - #Multi trade strategy + # Multi trade strategy retrades_since_ts = time.time() - SECONDS_PER_DAY * MULTI_TRADE_LOOKBACK_DAYS - filtered_trades, n_unique_markets, n_trades, n_retrades = _calculate_retrades_since(trades_json, retrades_since_ts) - _print_subsection_header(f"Multi-trade markets in previous {MULTI_TRADE_LOOKBACK_DAYS} days") - _print_status(f"Multi-trade markets", _retrades_since_message(n_unique_markets, n_trades, n_retrades)) - _print_status(f"Average trades per market", _average_trades_since_message(n_trades, n_unique_markets)) - _print_status(f"Max trades per market", _max_trades_per_market_since_message(filtered_trades)) + filtered_trades, n_unique_markets, n_trades, n_retrades = _calculate_retrades_since( + trades_json, retrades_since_ts + ) + _print_subsection_header( + f"Multi-trade markets in previous {MULTI_TRADE_LOOKBACK_DAYS} days" + ) + _print_status( + "Multi-trade markets", + _retrades_since_message(n_unique_markets, n_trades, n_retrades), + ) + _print_status( + "Average trades per market", + _average_trades_since_message(n_trades, n_unique_markets), + ) + _print_status( + "Max trades per market", _max_trades_per_market_since_message(filtered_trades) + ) # Service _print_section_header("Service") @@ -485,7 +546,12 @@ def _parse_args() -> Any: # Safe safe_xdai = get_balance(safe_address, rpc, block_identifier=current_block_number) - safe_wxdai = get_token_balance(safe_address, trades.WXDAI_CONTRACT_ADDRESS, rpc, block_identifier=current_block_number) + safe_wxdai = get_token_balance( + safe_address, + trades.WXDAI_CONTRACT_ADDRESS, + rpc, + block_identifier=current_block_number, + ) _print_subsection_header( f"Safe {_warning_message(safe_xdai + safe_wxdai, SAFE_BALANCE_THRESHOLD)}" ) @@ -494,7 +560,9 @@ def _parse_args() -> Any: _print_status("WxDAI Balance", wei_to_wxdai(safe_wxdai)) # Master Safe - Agent Owner/Operator - operator_xdai = get_balance(operator_address, rpc, block_identifier=current_block_number) + operator_xdai = get_balance( + operator_address, rpc, block_identifier=current_block_number + ) _print_subsection_header("Master Safe - Agent Owner/Operator") _print_status("Address", operator_address) _print_status( @@ -503,7 +571,9 @@ def _parse_args() -> Any: ) # Master EOA - Master Safe Owner - master_eoa_xdai = get_balance(master_eoa, rpc, block_identifier=current_block_number) + master_eoa_xdai = get_balance( + master_eoa, rpc, block_identifier=current_block_number + ) _print_subsection_header("Master EOA - Master Safe Owner") _print_status("Address", master_eoa) _print_status( diff --git a/scripts/predict_trader/trades.py b/scripts/predict_trader/trades.py index 6cb27106..9cc7829e 100644 --- a/scripts/predict_trader/trades.py +++ b/scripts/predict_trader/trades.py @@ -22,7 +22,6 @@ import datetime import re -import requests import sys from argparse import Action, ArgumentError, ArgumentParser, Namespace from collections import defaultdict @@ -31,13 +30,13 @@ from string import Template from typing import Any, Dict, Optional +import requests from operate.cli import OperateApp from operate.operate_types import Chain from operate.quickstart.run_service import ask_password_if_needed, load_local_config from scripts.predict_trader.mech_events import get_mech_requests from scripts.utils import get_service_from_config, get_subgraph_api_key - IRRELEVANT_TOOLS = [ "openai-text-davinci-002", "openai-text-davinci-003", @@ -55,7 +54,7 @@ INVALID_ANSWER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF FPMM_CREATORS = ( "0x89c5cc945dd550BcFfb72Fe42BfF002429F46Fec", - "0xFfc8029154ECD55ABED15BD428bA596E7D23f557" + "0xFfc8029154ECD55ABED15BD428bA596E7D23f557", ) DEFAULT_FROM_DATE = "1970-01-01T00:00:00" DEFAULT_TO_DATE = "2038-01-19T03:14:07" @@ -71,8 +70,7 @@ } -omen_xdai_trades_query = Template( - """ +omen_xdai_trades_query = Template(""" { fpmmTrades( where: { @@ -122,12 +120,10 @@ } } } - """ -) + """) -conditional_tokens_gc_user_query = Template( - """ +conditional_tokens_gc_user_query = Template(""" { user(id: "${id}") { userPositions( @@ -148,8 +144,7 @@ } } } - """ -) + """) class MarketState(Enum): @@ -214,12 +209,15 @@ def get_balance(address: str, rpc_url: str, block_identifier: str = "latest") -> "params": [address, block_identifier], "id": 1, } - response = requests.post(rpc_url, headers=headers, json=data) + response = requests.post(rpc_url, headers=headers, json=data, timeout=30) return int(response.json().get("result"), 16) def get_token_balance( - gnosis_address: str, token_contract_address: str, rpc_url: str, block_identifier: str = "latest" + gnosis_address: str, + token_contract_address: str, + rpc_url: str, + block_identifier: str = "latest", ) -> int: """Get the token balance of an address in wei.""" function_selector = "70a08231" # function selector for balanceOf(address) @@ -234,7 +232,7 @@ def get_token_balance( "params": [{"to": token_contract_address, "data": data}, block_identifier], "id": 1, } - response = requests.post(rpc_url, json=payload) + response = requests.post(rpc_url, json=payload, timeout=30) result = response.json().get("result", "0x0") balance_wei = int(result, 16) # convert hex to int return balance_wei @@ -293,12 +291,18 @@ def _parse_args() -> Any: args = parser.parse_args() if args.creator is None: - template_path = Path(SCRIPT_PATH.parents[1], "configs", "config_predict_trader.json") + template_path = Path( + SCRIPT_PATH.parents[1], "configs", "config_predict_trader.json" + ) if not template_path.exists(): print("No Safe address found!") sys.exit(1) - args.creator = get_service_from_config(template_path).chain_configs["gnosis"].chain_data.multisig + args.creator = ( + get_service_from_config(template_path) + .chain_configs["gnosis"] + .chain_data.multisig + ) args.from_date = args.from_date.replace(tzinfo=datetime.timezone.utc) args.to_date = args.to_date.replace(tzinfo=datetime.timezone.utc) @@ -350,7 +354,7 @@ def _query_omen_xdai_subgraph( # pylint: disable=too-many-locals creationTimestamp_gt=creationTimestamp_gt, ) content_json = _to_content(query) - res = requests.post(url, headers=headers, json=content_json) + res = requests.post(url, headers=headers, json=content_json, timeout=30) result_json = res.json() trades = result_json.get("data", {}).get("fpmmTrades", []) @@ -390,7 +394,7 @@ def _query_conditional_tokens_gc_subgraph(creator: str) -> Dict[str, Any]: userPositions_id_gt=userPositions_id_gt, ) content_json = {"query": query} - res = requests.post(url, headers=headers, json=content_json) + res = requests.post(url, headers=headers, json=content_json, timeout=30) result_json = res.json() user_data = result_json.get("data", {}).get("user", {}) @@ -661,7 +665,7 @@ def _format_table(table: Dict[Any, Dict[Any, Any]]) -> str: f"{MarketAttribute.ROI:<{column_width}}" + "".join( [ - f"{table[MarketAttribute.ROI][c]*100.0:>{column_width-5}.2f} % " + f"{table[MarketAttribute.ROI][c] * 100.0:>{column_width - 5}.2f} % " for c in STATS_TABLE_COLS ] ) @@ -780,18 +784,14 @@ def parse_user( # pylint: disable=too-many-locals,too-many-statements earnings = 0 output += f" Final answer: {fpmm['outcomes'][current_answer]!r} - The trade was for the loser answer.\n" + statistics_table[MarketAttribute.EARNINGS][market_status] += earnings - statistics_table[MarketAttribute.EARNINGS][ - market_status - ] += earnings - - statistics_table[MarketAttribute.NUM_VALID_TRADES][ - market_status - ] = statistics_table[MarketAttribute.NUM_TRADES][ - market_status - ] - statistics_table[MarketAttribute.NUM_INVALID_MARKET][ + statistics_table[MarketAttribute.NUM_VALID_TRADES][market_status] = ( + statistics_table[MarketAttribute.NUM_TRADES][market_status] + - statistics_table[MarketAttribute.NUM_INVALID_MARKET][ market_status ] + ) if 0 < earnings < DUST_THRESHOLD: output += "Earnings are dust.\n" @@ -856,7 +856,9 @@ def get_mech_statistics(mech_requests: Dict[str, Any]) -> Dict[str, Dict[str, in if __name__ == "__main__": user_args = _parse_args() - template_path = Path(SCRIPT_PATH.parents[1], "configs", "config_predict_trader.json") + template_path = Path( + SCRIPT_PATH.parents[1], "configs", "config_predict_trader.json" + ) operate = OperateApp() ask_password_if_needed(operate) service = get_service_from_config(template_path) diff --git a/scripts/utils.py b/scripts/utils.py index 2d05e9f1..5222a04e 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -23,11 +23,16 @@ import sys from pathlib import Path from typing import Optional + from operate.cli import OperateApp from operate.constants import OPERATE_HOME -from operate.quickstart.run_service import ask_password_if_needed, configure_local_config, get_service -from operate.services.service import Service from operate.operate_types import Chain +from operate.quickstart.run_service import ( + ask_password_if_needed, + configure_local_config, + get_service, +) +from operate.services.service import Service def get_subgraph_api_key() -> str: @@ -35,14 +40,16 @@ def get_subgraph_api_key() -> str: subgraph_api_key_path = OPERATE_HOME / "subgraph_api_key.txt" if subgraph_api_key_path.exists(): return subgraph_api_key_path.read_text() - + subgraph_api_key = input("Please enter your subgraph api key: ") subgraph_api_key_path.parent.mkdir(parents=True, exist_ok=True) subgraph_api_key_path.write_text(subgraph_api_key) - return subgraph_api_key + return subgraph_api_key -def get_service_from_config(config_path: Path, operate: Optional[OperateApp] = None) -> Service: +def get_service_from_config( + config_path: Path, operate: Optional[OperateApp] = None +) -> Service: """Get service safe.""" if not config_path.exists(): print("No trader agent config found!") @@ -50,7 +57,7 @@ def get_service_from_config(config_path: Path, operate: Optional[OperateApp] = N with open(config_path, "r") as config_file: template = json.load(config_file) - + if operate is None: operate = OperateApp() ask_password_if_needed(operate) @@ -59,14 +66,15 @@ def get_service_from_config(config_path: Path, operate: Optional[OperateApp] = N configure_local_config(template, operate) return get_service(manager, template) + def validate_config_params(config_data: dict, required_params: list[str]) -> None: """ Validates required configuration parameters from the source config. - + Args: config_data (dict): Configuration dictionary to validate required_params (list[str]): List of required parameter keys - + Raises: ValueError: If any required parameter is missing or empty """ @@ -74,58 +82,63 @@ def validate_config_params(config_data: dict, required_params: list[str]) -> Non for param in required_params: if not config_data.get(param): # Checks for None, empty string, or missing key missing_params.append(param) - + if missing_params: raise ValueError( f"Missing required configuration parameters: {', '.join(missing_params)}. " "Please ensure all required parameters are provided in the local_config.json file." ) + def handle_missing_rpcs(config: dict) -> dict: """ Checks for required RPC endpoints and prompts the user to input them if missing. - + Args: config: The configuration dictionary from optimus - + Returns: Updated dictionary with RPC endpoints """ - + required_rpcs = { Chain.OPTIMISM.value: "optimism_rpc", Chain.BASE.value: "base_rpc", - Chain.MODE.value: "mode_rpc" + Chain.MODE.value: "mode_rpc", } - + rpc_mapping = {} missing_rpcs = [] - + # Check which RPCs are missing for chain, config_key in required_rpcs.items(): if config_key in config and config[config_key]: rpc_mapping[chain] = config[config_key] else: missing_rpcs.append((chain, config_key)) - + # If any RPCs are missing, prompt the user to input them if missing_rpcs: print("\nSome required RPC endpoints are missing. Please provide them:") - + for chain, config_key in missing_rpcs: while True: user_input = input(f"Enter {chain} RPC endpoint: ").strip() - + if user_input: rpc_mapping[chain] = user_input # Update the original config as well config[config_key] = user_input break else: - print(f"Error: RPC endpoint for {chain} is required. Please enter a valid RPC endpoint.") - - return rpc_mapping + print( + f"Error: RPC endpoint for {chain} is required. Please enter a valid RPC endpoint." + ) + + return rpc_mapping + def input_with_default_value(prompt: str, default_value: str) -> str: + """Prompt the user with a default and return their input or the default.""" user_input = input(f"{prompt} [{default_value}]: ") return str(user_input) if user_input else default_value diff --git a/tests/test_migrate_to_pearl.py b/tests/test_migrate_to_pearl.py index 9690527a..6f36f224 100644 --- a/tests/test_migrate_to_pearl.py +++ b/tests/test_migrate_to_pearl.py @@ -87,6 +87,7 @@ # Helpers # --------------------------------------------------------------------------- + def _copy_repo_to(dest: Path, logger: logging.Logger) -> None: """Copy the repo into `dest`, excluding heavy/local-only paths. @@ -98,10 +99,18 @@ def _copy_repo_to(dest: Path, logger: logging.Logger) -> None: started from the source tree. Sharing the runner's venv avoids both pitfalls.""" shutil.copytree( - ".", dest, dirs_exist_ok=True, + ".", + dest, + dirs_exist_ok=True, ignore=shutil.ignore_patterns( - ".operate", ".pytest_cache", "__pycache__", - "*.pyc", "logs", "*.log", ".env", ".venv", + ".operate", + ".pytest_cache", + "__pycache__", + "*.pyc", + "logs", + "*.log", + ".env", + ".venv", ), ) src_venv = Path(".venv").resolve() @@ -155,7 +164,10 @@ def _spawn_run_service( logger.info(f" -> run_service.sh {config_path} in {cwd}") child = pexpect.spawn( f"bash ./run_service.sh {config_path}", - encoding="utf-8", timeout=600, env=spawn_env, cwd=str(cwd), + encoding="utf-8", + timeout=600, + env=spawn_env, + cwd=str(cwd), logfile=sys.stdout, ) try: @@ -190,7 +202,10 @@ def _spawn_stop_service( logger.info(f" -> stop_service.sh {config_path} in {cwd}") child = pexpect.spawn( f"bash ./stop_service.sh {config_path}", - encoding="utf-8", timeout=120, env=spawn_env, cwd=str(cwd), + encoding="utf-8", + timeout=120, + env=spawn_env, + cwd=str(cwd), logfile=sys.stdout, ) try: @@ -229,12 +244,13 @@ def _spawn_migrate_to_pearl( * Rename source for rollback? → "y" * Run Pearl on a different machine? → "n" """ - logger.info( - f" -> migrate_to_pearl.sh in {cwd} (pearl_home={pearl_home})" - ) + logger.info(f" -> migrate_to_pearl.sh in {cwd} (pearl_home={pearl_home})") child = pexpect.spawn( f"bash ./migrate_to_pearl.sh --pearl-home {pearl_home}", - encoding="utf-8", timeout=MIGRATION_TIMEOUT, env=env, cwd=str(cwd), + encoding="utf-8", + timeout=MIGRATION_TIMEOUT, + env=env, + cwd=str(cwd), logfile=sys.stdout, ) # Track which password we're answering next. @@ -256,6 +272,7 @@ def _spawn_migrate_to_pearl( def _all_choice(out: str) -> str: import re + m = re.search(r"Select \[1-(\d+)\]:", out) return m.group(1) if m else "1" @@ -287,6 +304,7 @@ def _list_pearl_services(pearl_home: Path) -> List[Path]: # Test class # --------------------------------------------------------------------------- + class TestMigrateToPearlEndToEnd: """4 services across 2 quickstart cwds → 1 Pearl `.operate` → all start. @@ -305,11 +323,11 @@ class TestMigrateToPearlEndToEnd: qs_dirs: Tuple[Path, Path] pearl_home: Path test_env: Dict[str, str] - password: str # qs1 master password (and Pearl's, since Pearl - # inherits qs1's wallet via the Mode A copy) - qs2_password: str # qs2's master password — deliberately different, - # so test_03 exercises Mode B with two distinct - # passwords (quickstart vs Pearl). + password: str # qs1 master password (and Pearl's, since Pearl + # inherits qs1's wallet via the Mode A copy) + qs2_password: str # qs2's master password — deliberately different, + # so test_03 exercises Mode B with two distinct + # passwords (quickstart vs Pearl). _failed: bool = False @classmethod @@ -405,7 +423,9 @@ def _body() -> None: ) for cfg in QS_CONFIGS: _spawn_run_service(qs, cfg, self.test_env, self.logger, password=pw) - _spawn_stop_service(qs, cfg, self.test_env, self.logger, password=pw) + _spawn_stop_service( + qs, cfg, self.test_env, self.logger, password=pw + ) # Sanity check: each cwd should now have 2 sc-* dirs in its .operate. for qs in self.qs_dirs: @@ -422,8 +442,9 @@ def _body() -> None: # ------------------------------------------------------------------ def test_02_migrate_first_quickstart_mode_a(self) -> None: def _body() -> None: - assert not self.pearl_home.exists(), \ - "Pearl home should not exist before the first migration" + assert ( + not self.pearl_home.exists() + ), "Pearl home should not exist before the first migration" _spawn_migrate_to_pearl( cwd=self.qs_dirs[0], @@ -447,8 +468,9 @@ def _body() -> None: def test_03_migrate_second_quickstart_mode_b(self) -> None: def _body() -> None: - assert self.pearl_home.exists(), \ - "Pearl home must exist before the Mode B migration" + assert ( + self.pearl_home.exists() + ), "Pearl home must exist before the Mode B migration" # qs2 has its own master password; Pearl's wallet is qs1's # (carried over by the Mode A copy in test_02), so its password @@ -472,9 +494,9 @@ def _body() -> None: for svc_dir in services: cfg = json.loads((svc_dir / "config.json").read_text()) for addr in cfg.get("agent_addresses", []): - assert (self.pearl_home / "keys" / addr).exists(), ( - f"Missing key {addr} for service {svc_dir.name}" - ) + assert ( + self.pearl_home / "keys" / addr + ).exists(), f"Missing key {addr} for service {svc_dir.name}" self._step(_body) @@ -487,9 +509,9 @@ def _body() -> None: operate.password = self.password # Sanity: the wallet manager unlocks with the same TEST_PASSWORD, # demonstrating the migrated wallet is usable. - assert operate.wallet_manager.is_password_valid(self.password), ( - "Migrated master wallet rejected the test password" - ) + assert operate.wallet_manager.is_password_valid( + self.password + ), "Migrated master wallet rejected the test password" # Loading the wallet shouldn't raise. operate.wallet_manager.load(LedgerType.ETHEREUM) @@ -517,10 +539,11 @@ def _body() -> None: # Anything else means the service is stuck mid-state. from operate.ledger.profiles import CONTRACTS from operate.operate_types import Chain + from scripts.pearl_migration.status import safe_owners as _safe_owners from scripts.pearl_migration.status import ( - safe_owners as _safe_owners, service_nft_owner as _service_nft_owner, ) + pearl_safes = operate.wallet_manager.load(LedgerType.ETHEREUM).safes for svc_dir in services: cfg = json.loads((svc_dir / "config.json").read_text()) @@ -534,10 +557,15 @@ def _body() -> None: ) nft_owner = _service_nft_owner( ledger_api=sftxb.ledger_api, - service_registry_address=CONTRACTS[chain_enum]["service_registry"], + service_registry_address=CONTRACTS[chain_enum][ + "service_registry" + ], service_id=chain_config.chain_data.token, ) - assert nft_owner is not None and nft_owner.lower() == pearl_safe.lower(), ( + assert ( + nft_owner is not None + and nft_owner.lower() == pearl_safe.lower() + ), ( f"[{chain_str}] service NFT {chain_config.chain_data.token} " f"owner is {nft_owner}, expected Pearl Safe {pearl_safe}" ) @@ -587,7 +615,9 @@ def _body() -> None: # 2) Local docker deploy. self.logger.info(f" local docker deploy ({chain})") manager.deploy_service_locally( - service_config_id=sid, chain=chain, use_docker=True, + service_config_id=sid, + chain=chain, + use_docker=True, ) time.sleep(STARTUP_WAIT) @@ -599,9 +629,9 @@ def _body() -> None: f"Migrated service name {cfg['name']!r} doesn't map back to " f"any of QS_CONFIGS; cannot resolve container name." ) - assert check_docker_status(self.logger, origin_cfg), ( - f"Service {sid} failed to start under Pearl `.operate`" - ) + assert check_docker_status( + self.logger, origin_cfg + ), f"Service {sid} failed to start under Pearl `.operate`" started.append(sid) # Stop again before the next iteration so concurrent diff --git a/tests/test_run_service.py b/tests/test_run_service.py index 7259e2c1..37164364 100644 --- a/tests/test_run_service.py +++ b/tests/test_run_service.py @@ -1,28 +1,29 @@ # -*- coding: utf-8 -*- """Test run_service.py script using pytest for reliable automation.""" -import re -import sys +import json import logging -import pexpect import os -import json -import time -import pytest -import tempfile +import re import shutil +import sys +import tempfile +import time from datetime import datetime from pathlib import Path from typing import Callable, Optional -from termcolor import colored -from colorama import init -from web3 import Web3 -import requests + import docker +import pexpect +import pytest +import requests +from colorama import init from dotenv import load_dotenv from operate.cli import OperateApp from operate.constants import HEALTH_CHECK_URL, OPERATE from operate.operate_types import Chain, LedgerType +from termcolor import colored +from web3 import Web3 pytestmark = pytest.mark.e2e @@ -44,18 +45,19 @@ require_extra_coins = False # Handle the distutils warning -os.environ['SETUPTOOLS_USE_DISTUTILS'] = 'stdlib' +os.environ["SETUPTOOLS_USE_DISTUTILS"] = "stdlib" + def get_service_config(config_path: str) -> dict: """ Get service-specific configuration. - + Args: config_path (str): Path to the config file - + Returns: dict: Dictionary containing service configuration with container name and health check URL - + Raises: ValueError: If no matching service configuration is found """ @@ -75,29 +77,28 @@ def get_service_config(config_path: str) -> dict: "agents.fun": { "container_name": "memeooorr", "health_check_url": HEALTH_CHECK_URL, - } + }, } - + # Additional name mappings - SERVICE_ALIASES = { - "predict_trader": "traderpearl" - } - + SERVICE_ALIASES = {"predict_trader": "traderpearl"} + # Convert config path to lowercase for case-insensitive matching config_path_lower = config_path.lower() - + # Check for direct service match first for service_name, config in SERVICE_CONFIGS.items(): if service_name in config_path_lower: return config - + # Check aliases if no direct match found for alias, service_name in SERVICE_ALIASES.items(): if alias in config_path_lower: return SERVICE_CONFIGS[service_name] - + raise ValueError(f"No matching service configuration found for {config_path}") + def check_docker_status(logger: logging.Logger, config_path: str) -> bool: """ Check if Docker ABCI containers are running properly. @@ -106,132 +107,156 @@ def check_docker_status(logger: logging.Logger, config_path: str) -> bool: """ service_config = get_service_config(config_path) container_base_name = service_config["container_name"] - + # Use filters with just "_abci_0" suffix to catch all ABCI containers abci_suffix = "_abci_0" - + max_retries = 3 retry_delay = 20 - + for attempt in range(max_retries): - logger.info(f"Checking ABCI container status (attempt {attempt + 1}/{max_retries})") + logger.info( + f"Checking ABCI container status (attempt {attempt + 1}/{max_retries})" + ) try: client = docker.from_env() - + # List all containers that have the ABCI suffix and start with the base name all_containers = client.containers.list(all=True) - abci_containers = [c for c in all_containers - if c.name.endswith(abci_suffix) and - c.name.startswith(container_base_name)] - + abci_containers = [ + c + for c in all_containers + if c.name.endswith(abci_suffix) + and c.name.startswith(container_base_name) + ] + running_containers = client.containers.list() - running_abci = [c for c in running_containers - if c.name.endswith(abci_suffix) and - c.name.startswith(container_base_name)] - + running_abci = [ + c + for c in running_containers + if c.name.endswith(abci_suffix) + and c.name.startswith(container_base_name) + ] + if not abci_containers: - logger.error(f"No ABCI container found with base name {container_base_name} (attempt {attempt + 1}/{max_retries})") + logger.error( + f"No ABCI container found with base name {container_base_name} (attempt {attempt + 1}/{max_retries})" + ) if attempt == max_retries - 1: return False logger.info(f"Waiting {retry_delay} seconds before retry...") time.sleep(retry_delay) continue - + # Should only be one ABCI container abci_container = abci_containers[0] - logger.info(f"ABCI Container {abci_container.name} status: {abci_container.status}") - + logger.info( + f"ABCI Container {abci_container.name} status: {abci_container.status}" + ) + if abci_container.status == "exited": inspect = client.api.inspect_container(abci_container.id) - exit_code = inspect['State']['ExitCode'] - logger.error(f"ABCI Container {abci_container.name} exited with code {exit_code}") - logs = abci_container.logs(tail=50).decode('utf-8') + exit_code = inspect["State"]["ExitCode"] + logger.error( + f"ABCI Container {abci_container.name} exited with code {exit_code}" + ) + logs = abci_container.logs(tail=50).decode("utf-8") logger.error(f"ABCI Container logs:\n{logs}") - + elif abci_container.status == "restarting": - logger.error(f"ABCI Container {abci_container.name} is restarting. Last logs:") - logs = abci_container.logs(tail=50).decode('utf-8') + logger.error( + f"ABCI Container {abci_container.name} is restarting. Last logs:" + ) + logs = abci_container.logs(tail=50).decode("utf-8") logger.error(f"ABCI Container logs:\n{logs}") - + if not running_abci: if attempt == max_retries - 1: return False - logger.info(f"Waiting {retry_delay} seconds for ABCI container to start...") + logger.info( + f"Waiting {retry_delay} seconds for ABCI container to start..." + ) time.sleep(retry_delay) continue - + # Check if ABCI container is running if abci_container.status == "running": logger.info(f"ABCI Container {abci_container.name} is running") return True - + if attempt == max_retries - 1: return False - + logger.info(f"ABCI container not running, waiting {retry_delay} seconds...") time.sleep(retry_delay) - + except Exception as e: logger.error(f"Error checking Docker status: {str(e)}") if attempt == max_retries - 1: return False logger.info(f"Waiting {retry_delay} seconds before retry...") time.sleep(retry_delay) - + return False + def check_service_health(logger: logging.Logger, config_path: str) -> tuple[bool, dict]: """Enhanced service health check with metrics.""" service_config = get_service_config(config_path) health_check_url = service_config["health_check_url"] - + metrics = { - 'response_time': None, - 'status_code': None, - 'error': None, - 'successful_checks': 0, - 'total_checks': 0 + "response_time": None, + "status_code": None, + "error": None, + "successful_checks": 0, + "total_checks": 0, } - + start_monitoring = time.time() while time.time() - start_monitoring < 120: # Run for 2 minutes try: - metrics['total_checks'] += 1 + metrics["total_checks"] += 1 start_time = time.time() response = requests.get(health_check_url, timeout=10) - metrics['response_time'] = time.time() - start_time - metrics['status_code'] = response.status_code - + metrics["response_time"] = time.time() - start_time + metrics["status_code"] = response.status_code + if response.status_code == 200: - metrics['successful_checks'] += 1 - logger.info(f"Health check passed (response time: {metrics['response_time']:.2f}s)") + metrics["successful_checks"] += 1 + logger.info( + f"Health check passed (response time: {metrics['response_time']:.2f}s)" + ) elif response.status_code == 425: - logger.debug('Too early to check health. Waiting 10 seconds...') + logger.debug("Too early to check health. Waiting 10 seconds...") time.sleep(10) else: logger.error(f"Health check failed - Status: {response.status_code}") return False, metrics - + except requests.exceptions.Timeout: - metrics['error'] = 'timeout' + metrics["error"] = "timeout" logger.error("Health check timeout") return False, metrics except requests.exceptions.ConnectionError as e: - metrics['error'] = 'connection_error' + metrics["error"] = "connection_error" logger.error(f"Connection error: {str(e)}") return False, metrics except Exception as e: - metrics['error'] = str(e) + metrics["error"] = str(e) logger.error(f"Unexpected error in health check: {str(e)}") return False, metrics - + elapsed = time.time() - start_time if elapsed < 5: time.sleep(5 - elapsed) - - logger.info(f"Health check completed successfully - {metrics['successful_checks']} checks passed") - return True, metrics + + logger.info( + f"Health check completed successfully - {metrics['successful_checks']} checks passed" + ) + return True, metrics + def get_token_config(): """Get token configurations for different chains""" @@ -239,37 +264,35 @@ def get_token_config(): "mode": { "USDC": { "address": "0xd988097fb8612cc24eeC14542bC03424c656005f", - "decimals": 6 + "decimals": 6, }, - "OLAS": { - "address": "your_olas_address", - "decimals": 18 - } + "OLAS": {"address": "your_olas_address", "decimals": 18}, }, "optimism": { "USDC": { "address": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", - "decimals": 6 + "decimals": 6, } }, "base": { "USDC": { "address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "decimals": 6 + "decimals": 6, } }, - "gnosis":{ + "gnosis": { "USDC": { "address": "0xd988097fb8612cc24eeC14542bC03424c656005f", - "decimals": 6 + "decimals": 6, }, "OLAS": { "address": "0xcE11e14225575945b8E6Dc0D4F2dD4C570f79d9f", - "decimals": 18 - } - } + "decimals": 18, + }, + }, } + def handle_erc20_funding(output: str, logger: logging.Logger, rpc_url: str) -> str: """Handle funding requirement using Tenderly API for ERC20 tokens.""" pattern = r"\[(optimism|base|mode|gnosis)\].*Please transfer at least ([0-9.]+) ([A-Z]+) to the Master (?:EOA|Safe) (0x[a-fA-F0-9]{40})" @@ -283,33 +306,35 @@ def handle_erc20_funding(output: str, logger: logging.Logger, rpc_url: str) -> s token_configs = get_token_config() if chain not in token_configs or token_symbol not in token_configs[chain]: raise Exception(f"Token {token_symbol} not configured for chain {chain}") - + token_config = token_configs[chain][token_symbol] token_address = token_config["address"] decimals = token_config["decimals"] - + try: - amount_in_units = int(required_amount * (10 ** decimals)) + amount_in_units = int(required_amount * (10**decimals)) amount_hex = hex(amount_in_units) - + headers = {"Content-Type": "application/json"} payload = { "jsonrpc": "2.0", "method": "tenderly_setErc20Balance", "params": [token_address, wallet_address, amount_hex], - "id": "1" + "id": "1", } - + logger.info(f"Funding {required_amount} {token_symbol} on {chain} chain") response = requests.post(rpc_url, headers=headers, json=payload) - + if response.status_code == 200: result = response.json() - if 'error' in result: + if "error" in result: raise Exception(f"Tenderly API error: {result['error']}") - - logger.info(f"Successfully funded {required_amount} {token_symbol} to {wallet_address} on {chain} chain") - + + logger.info( + f"Successfully funded {required_amount} {token_symbol} to {wallet_address} on {chain} chain" + ) + try: w3 = Web3(Web3.HTTPProvider(rpc_url)) erc20_abi = [ @@ -318,91 +343,113 @@ def handle_erc20_funding(output: str, logger: logging.Logger, rpc_url: str) -> s "inputs": [{"name": "_owner", "type": "address"}], "name": "balanceOf", "outputs": [{"name": "balance", "type": "uint256"}], - "type": "function" + "type": "function", } ] - token_contract = w3.eth.contract(address=Web3.to_checksum_address(token_address), abi=erc20_abi) - new_balance = token_contract.functions.balanceOf(wallet_address).call() - logger.info(f"New balance: {new_balance / (10 ** decimals)} {token_symbol}") + token_contract = w3.eth.contract( + address=Web3.to_checksum_address(token_address), abi=erc20_abi + ) + new_balance = token_contract.functions.balanceOf( + wallet_address + ).call() + logger.info( + f"New balance: {new_balance / (10 ** decimals)} {token_symbol}" + ) except Exception as e: logger.warning(f"Could not verify balance: {str(e)}") - + return "" else: - error_msg = f"Tenderly API request failed with status {response.status_code}" + error_msg = ( + f"Tenderly API request failed with status {response.status_code}" + ) if response.text: error_msg += f". Response: {response.text}" raise Exception(error_msg) - + except Exception as e: logger.error(f"Failed to fund {token_symbol}: {str(e)}") raise - + return "" + def handle_native_funding(output: str, logger: logging.Logger, rpc_url: str) -> str: """Handle funding requirement using Tenderly API for native tokens.""" patterns = [ r"Please transfer at least (\d+\.\d+) (?:ETH|xDAI) to the Master EOA (0x[a-fA-F0-9]{40})", - r"Please transfer at least (\d+\.\d+) (?:ETH|xDAI) to the Master Safe (0x[a-fA-F0-9]{40})" + r"Please transfer at least (\d+\.\d+) (?:ETH|xDAI) to the Master Safe (0x[a-fA-F0-9]{40})", ] - for pattern in patterns: match = re.search(pattern, output) if match: wallet_address = match.group(2) required_amount = float(match.group(1)) wallet_type = "EOA" if "EOA" in pattern else "Safe" - + try: w3 = Web3(Web3.HTTPProvider(rpc_url)) - amount_wei = w3.to_wei(required_amount, 'ether') + amount_wei = w3.to_wei(required_amount, "ether") amount_hex = hex(amount_wei) - + headers = {"Content-Type": "application/json"} payload = { "jsonrpc": "2.0", "method": "tenderly_addBalance", "params": [wallet_address, amount_hex], - "id": "1" + "id": "1", } - + response = requests.post(rpc_url, headers=headers, json=payload) - + if response.status_code == 200: result = response.json() - if 'error' in result: + if "error" in result: raise Exception(f"Tenderly API error: {result['error']}") - + chain_id = w3.eth.chain_id chain = Chain.from_id(chain_id) - token_name = "ETH" if chain.ledger_type == LedgerType.ETHEREUM else "xDAI" - - logger.info(f"Successfully funded {required_amount} {token_name} to {wallet_type} {wallet_address}") + token_name = ( + "ETH" if chain.ledger_type == LedgerType.ETHEREUM else "xDAI" + ) + + logger.info( + f"Successfully funded {required_amount} {token_name} to {wallet_type} {wallet_address}" + ) new_balance = w3.eth.get_balance(wallet_address) - logger.info(f"New balance: {w3.from_wei(new_balance, 'ether')} {token_name}") + logger.info( + f"New balance: {w3.from_wei(new_balance, 'ether')} {token_name}" + ) return "" else: - raise Exception(f"Tenderly API request failed with status {response.status_code}") - + raise Exception( + f"Tenderly API request failed with status {response.status_code}" + ) + except Exception as e: logger.error(f"Failed to fund {wallet_type}: {str(e)}") raise - + return "" + def create_funding_handler(rpc_url: str): """Create a funding handler with the specified RPC URL and config type.""" + def handler(output: str, logger: logging.Logger) -> str: return handle_native_funding(output, logger, rpc_url) + return handler + def create_token_funding_handler(rpc_url: str): """Create a token funding handler with the specified RPC URL.""" + def handler(output: str, logger: logging.Logger) -> str: return handle_erc20_funding(output, logger, rpc_url) + return handler @@ -412,21 +459,24 @@ def check_shutdown_logs(logger: logging.Logger, config_path: str) -> bool: client = docker.from_env() service_config = get_service_config(config_path) container_name = service_config["container_name"] - + containers = client.containers.list(filters={"name": container_name}) - + for container in containers: - logs = container.logs().decode('utf-8') + logs = container.logs().decode("utf-8") if "Error during shutdown" in logs or "Failed to gracefully stop" in logs: - logger.error(f"Found shutdown errors in container {container.name} logs") + logger.error( + f"Found shutdown errors in container {container.name} logs" + ) return False - + logger.info("Shutdown logs check passed") return True except Exception as e: logger.error(f"Error checking shutdown logs: {str(e)}") return False + def _wait_for_containers_stopped( container_name: str, timeout: int, logger: logging.Logger ) -> bool: @@ -450,7 +500,9 @@ def _wait_for_containers_stopped( return False -def ensure_service_stopped(config_path: str, temp_dir: str, logger: logging.Logger) -> bool: +def ensure_service_stopped( + config_path: str, temp_dir: str, logger: logging.Logger +) -> bool: """ Stop service only if it exists, with verification. Returns True if service is confirmed stopped (or wasn't running). @@ -463,25 +515,27 @@ def ensure_service_stopped(config_path: str, temp_dir: str, logger: logging.Logg except Exception as docker_err: logger.error(f"Docker daemon not accessible: {str(docker_err)}") logger.error("Please ensure Docker is running before starting the tests") - raise RuntimeError("Docker daemon not running or not accessible. Please start Docker first.") from docker_err + raise RuntimeError( + "Docker daemon not running or not accessible. Please start Docker first." + ) from docker_err service_config = get_service_config(config_path) container_name = service_config["container_name"] - + # Check if service is running containers = client.containers.list(filters={"name": container_name}) if not containers: logger.info("No running service found, skipping stop") return True - + logger.info(f"Found {len(containers)} running containers, stopping service") - + # Single attempt with proper force stop process = pexpect.spawn( - f'bash ./stop_service.sh {config_path}', - encoding='utf-8', + f"bash ./stop_service.sh {config_path}", + encoding="utf-8", timeout=30, - cwd=temp_dir + cwd=temp_dir, ) process.expect(pexpect.EOF) # Run the force-stop fallback below regardless of whether the @@ -494,7 +548,9 @@ def ensure_service_stopped(config_path: str, temp_dir: str, logger: logging.Logg # Check if any containers are still running remaining_containers = client.containers.list(filters={"name": container_name}) if remaining_containers: - logger.info("Some containers still running after stop_service, forcing stop...") + logger.info( + "Some containers still running after stop_service, forcing stop..." + ) for container in remaining_containers: try: container.stop(timeout=30) @@ -504,78 +560,80 @@ def ensure_service_stopped(config_path: str, temp_dir: str, logger: logging.Logg return False return not poll_timed_out - + except RuntimeError: raise except Exception as e: logger.error(f"Error stopping service: {str(e)}") return False - + + class ColoredFormatter(logging.Formatter): """Custom formatter with colors.""" + def format(self, record): - is_input = getattr(record, 'is_input', False) - is_expect = getattr(record, 'is_expect', False) - + is_input = getattr(record, "is_input", False) + is_expect = getattr(record, "is_expect", False) + if is_input: - record.msg = colored(record.msg, 'yellow') + record.msg = colored(record.msg, "yellow") elif is_expect: - record.msg = colored(record.msg, 'cyan') + record.msg = colored(record.msg, "cyan") else: - record.msg = colored(record.msg, 'green') - + record.msg = colored(record.msg, "green") + return super().format(record) + def setup_logging(log_file: Optional[Path] = None) -> logging.Logger: """Set up logging configuration.""" logs_dir = Path("logs") logs_dir.mkdir(exist_ok=True) - + # Get the logger - logger = logging.getLogger('test_runner') - + logger = logging.getLogger("test_runner") + # Remove any existing handlers for handler in logger.handlers[:]: logger.removeHandler(handler) - + logger.setLevel(logging.DEBUG) - + # Only add console handler if none exists console_handler = logging.StreamHandler() console_handler.setLevel(logging.DEBUG) console_formatter = ColoredFormatter( - '%(asctime)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' + "%(asctime)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S" ) console_handler.setFormatter(console_formatter) logger.addHandler(console_handler) - + if log_file: log_path = logs_dir / log_file file_handler = logging.FileHandler(log_path) file_handler.setLevel(logging.DEBUG) file_formatter = logging.Formatter( - '%(asctime)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' + "%(asctime)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S" ) file_handler.setFormatter(file_formatter) logger.addHandler(file_handler) - + return logger + def get_config_files(): """Dynamically get all JSON config files from configs directory.""" config_dir = Path("configs") if not config_dir.exists(): raise FileNotFoundError("configs directory not found") - + config_files = list(config_dir.glob("*.json")) if not config_files: raise FileNotFoundError("No JSON config files found in configs directory") - - logger = logging.getLogger('test_runner') + + logger = logging.getLogger("test_runner") logger.info(f"Found config files: {[f.name for f in config_files]}") - + return [str(f) for f in config_files] @@ -587,14 +645,15 @@ def validate_backup_owner(backup_owner: str) -> str: raise ValueError(f"Invalid backup owner address: {backup_owner}") return Web3.to_checksum_address(backup_owner) + def handle_env_var_prompt(output: str, logger: logging.Logger, config_path: str) -> str: """ Simple handler for environment variable prompts, returns value from config. """ try: - with open(config_path, 'r') as f: + with open(config_path, "r") as f: config = json.load(f) - + # Parse the latest real input prompt line. This avoids matching banners like # "Please enter the arguments that will be used by the service.". prompt_pattern = re.compile( @@ -609,45 +668,59 @@ def handle_env_var_prompt(output: str, logger: logging.Logger, config_path: str) if not prompt_text: logger.info("No prompt match found") - return '\n' + return "\n" logger.info(f"Looking for config value for: {prompt_text}") - - env_vars = config.get('env_variables', {}) + + env_vars = config.get("env_variables", {}) # Look for the variable by its name field for key, var_config in env_vars.items(): - if prompt_text == var_config['name']: + if prompt_text == var_config["name"]: logger.info(f"Found matching variable: {key}") - return var_config['value'] - + return var_config["value"] + logger.warning(f"No value found in config for: {prompt_text}") - return '\n' - + return "\n" + except Exception as e: logger.error(f"Error handling env var prompt: {str(e)}") - return '\n' + return "\n" + def get_base_config(config_path: str = "") -> dict: """Get base configuration common to all services.""" base_config = { - "TEST_PASSWORD": os.getenv('TEST_PASSWORD', 'test_secret'), - "BACKUP_WALLET": validate_backup_owner("0x802D8097eC1D49808F3c2c866020442891adde57"), - "STAKING_CHOICE": '1' + "TEST_PASSWORD": os.getenv("TEST_PASSWORD", "test_secret"), + "BACKUP_WALLET": validate_backup_owner( + "0x802D8097eC1D49808F3c2c866020442891adde57" + ), + "STAKING_CHOICE": "1", } # Common prompts used across all services base_prompts = { - r"Please input your password \(or press enter\)\:": base_config["TEST_PASSWORD"], + r"Please input your password \(or press enter\)\:": base_config[ + "TEST_PASSWORD" + ], r"Please confirm your password\:": base_config["TEST_PASSWORD"], - r"Enter local user account password \[hidden input\]\:": base_config["TEST_PASSWORD"], - r"Enter your choice\s*\(\s*\d+\s*-\s*\d+\s*\)\s*:\s*$": base_config["STAKING_CHOICE"], - r"Please input your backup owner \(leave empty to skip\)\:": base_config["BACKUP_WALLET"], + r"Enter local user account password \[hidden input\]\:": base_config[ + "TEST_PASSWORD" + ], + r"Enter your choice\s*\(\s*\d+\s*-\s*\d+\s*\)\s*:\s*$": base_config[ + "STAKING_CHOICE" + ], + r"Please input your backup owner \(leave empty to skip\)\:": base_config[ + "BACKUP_WALLET" + ], r"Press enter to continue\.*\s*$": "\n", - r"Please enter .*(?:\[hidden input\])?:\s*$": lambda output, logger: handle_env_var_prompt(output, logger, config_path) + r"Please enter .*(?:\[hidden input\])?:\s*$": lambda output, logger: handle_env_var_prompt( + output, logger, config_path + ), } return {"config": base_config, "prompts": base_prompts} + def get_config_specific_settings(config_path: str) -> dict: """Get config specific prompts and test settings.""" # Get base configuration @@ -656,14 +729,14 @@ def get_config_specific_settings(config_path: str) -> dict: prompts = base["prompts"].copy() global require_extra_coins require_extra_coins = False - + if "optimus" in config_path.lower(): # Optimus settings with multiple RPCs test_config = { **base_config, # Include base config - "MODE_RPC": os.getenv('MODE_RPC'), - "OPTIMISM_RPC": os.getenv('OPTIMISM_RPC'), - "BASE_RPC": os.getenv('BASE_RPC'), + "MODE_RPC": os.getenv("MODE_RPC"), + "OPTIMISM_RPC": os.getenv("OPTIMISM_RPC"), + "BASE_RPC": os.getenv("BASE_RPC"), } def get_chain_rpc(output: str, logger: logging.Logger) -> str: @@ -671,7 +744,7 @@ def get_chain_rpc(output: str, logger: logging.Logger) -> str: chain = sorted( # get the last occurrence ["[mode]", "[base]", "[optimism]"], reverse=True, - key=lambda x: output.find(x) + key=lambda x: output.find(x), )[0] if "[mode]" == chain: logger.info("Using Mode RPC URL") @@ -686,72 +759,105 @@ def get_chain_rpc(output: str, logger: logging.Logger) -> str: logger.info("Using Mode RPC URL as default") return test_config["MODE_RPC"] - prompts.update({ - r"Enter a Mode RPC that supports eth_newFilter \[hidden input\]": test_config["MODE_RPC"], - r"Enter a Optimism RPC that supports eth_newFilter \[hidden input\]": test_config["OPTIMISM_RPC"], - r"Enter a Base RPC that supports eth_newFilter \[hidden input\]": test_config["BASE_RPC"], - r"\[(?:optimism|base|mode)\].*Please transfer at least.*(?:ETH|xDAI) to the Master (EOA|Safe) (0x[a-fA-F0-9]{40})": - lambda output, logger: create_funding_handler(get_chain_rpc(output, logger))(output, logger), - r"\[(?:optimism|base|mode)\].*Please transfer at least.*(?:USDC|OLAS) to the Master (?:EOA|Safe) (0x[a-fA-F0-9]{40})": - lambda output, logger: create_token_funding_handler(get_chain_rpc(output, logger))(output, logger) - }) + prompts.update( + { + r"Enter a Mode RPC that supports eth_newFilter \[hidden input\]": test_config[ + "MODE_RPC" + ], + r"Enter a Optimism RPC that supports eth_newFilter \[hidden input\]": test_config[ + "OPTIMISM_RPC" + ], + r"Enter a Base RPC that supports eth_newFilter \[hidden input\]": test_config[ + "BASE_RPC" + ], + r"\[(?:optimism|base|mode)\].*Please transfer at least.*(?:ETH|xDAI) to the Master (EOA|Safe) (0x[a-fA-F0-9]{40})": lambda output, logger: create_funding_handler( + get_chain_rpc(output, logger) + )( + output, logger + ), + r"\[(?:optimism|base|mode)\].*Please transfer at least.*(?:USDC|OLAS) to the Master (?:EOA|Safe) (0x[a-fA-F0-9]{40})": lambda output, logger: create_token_funding_handler( + get_chain_rpc(output, logger) + )( + output, logger + ), + } + ) elif "agents.fun" in config_path.lower(): # Agents.fun specific settings test_config = { **base_config, # Include base config - "BASE_RPC": os.getenv('BASE_RPC'), + "BASE_RPC": os.getenv("BASE_RPC"), } # Add Agents.fun-specific prompts - prompts.update({ - r"Enter a Base RPC that supports eth_newFilter \[hidden input\]": test_config["BASE_RPC"], - r"Please transfer at least.*(?:ETH|xDAI) to the Master (EOA|Safe) (0x[a-fA-F0-9]{40})": - lambda output, logger: create_funding_handler(test_config["BASE_RPC"])(output, logger), - }) + prompts.update( + { + r"Enter a Base RPC that supports eth_newFilter \[hidden input\]": test_config[ + "BASE_RPC" + ], + r"Please transfer at least.*(?:ETH|xDAI) to the Master (EOA|Safe) (0x[a-fA-F0-9]{40})": lambda output, logger: create_funding_handler( + test_config["BASE_RPC"] + )( + output, logger + ), + } + ) else: # Default PredictTrader settings test_config = { **base_config, # Include base config - "RPC_URL": os.getenv('GNOSIS_RPC', '') + "RPC_URL": os.getenv("GNOSIS_RPC", ""), } # Add PredictTrader-specific prompts - prompts.update({ - r"eth_newFilter \[hidden input\]": test_config["RPC_URL"], - r"Please transfer at least.*(?:ETH|xDAI) to the Master (EOA|Safe) (0x[a-fA-F0-9]{40})": - lambda output, logger: create_funding_handler(test_config["RPC_URL"])(output, logger), - r"Please transfer at least.*(?:USDC|OLAS) to the Master (?:EOA|Safe) (0x[a-fA-F0-9]{40})": - lambda output, logger: create_token_funding_handler(test_config["RPC_URL"])(output, logger) - }) + prompts.update( + { + r"eth_newFilter \[hidden input\]": test_config["RPC_URL"], + r"Please transfer at least.*(?:ETH|xDAI) to the Master (EOA|Safe) (0x[a-fA-F0-9]{40})": lambda output, logger: create_funding_handler( + test_config["RPC_URL"] + )( + output, logger + ), + r"Please transfer at least.*(?:USDC|OLAS) to the Master (?:EOA|Safe) (0x[a-fA-F0-9]{40})": lambda output, logger: create_token_funding_handler( + test_config["RPC_URL"] + )( + output, logger + ), + } + ) return {"prompts": prompts} + def log_expect_match(child, pattern, match_index, logger): """Log minimal match information without exposing sensitive data.""" logger.debug(f"Pattern matched at index: {match_index}") + def send_input_safely(child, response, logger): """Send input safely with basic delay.""" try: # Force string encoding if isinstance(response, bytes): - response = response.decode('utf-8') + response = response.decode("utf-8") elif not isinstance(response, str): response = str(response) - + # Clean the response response = response.strip() - + child.write(response + os.linesep) - + except Exception as e: logger.error(f"Error sending input: {str(e)}") raise + def cleanup_directory(path: str, logger: logging.Logger) -> bool: """ Platform-agnostic directory cleanup. """ + def remove_readonly(func, path, _): os.chmod(path, 0o666) func(path) @@ -765,8 +871,10 @@ def remove_readonly(func, path, _): logger.debug(f"Cleanup failed: {e}") return False + class BaseTestService: """Base test service class containing core test logic.""" + config_path = None config_settings = None logger = None @@ -780,41 +888,44 @@ class BaseTestService: @classmethod def setup_class(cls): """Setup for all tests""" - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - cls.log_file = Path(f'test_run_service_{timestamp}.log') + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + cls.log_file = Path(f"test_run_service_{timestamp}.log") cls.logger = setup_logging(cls.log_file) - + # Handle agents.fun config modifications if needed if "agents.fun" in cls.config_path.lower(): - temp_config_path = os.path.join(cls.temp_dir.name, 'configs', os.path.basename(cls.config_path)) + temp_config_path = os.path.join( + cls.temp_dir.name, "configs", os.path.basename(cls.config_path) + ) try: - with open(temp_config_path, 'r') as f: + with open(temp_config_path, "r") as f: config_data = json.load(f) - - + # Modify env variables - create new dict instead of modifying - if 'env_variables' in config_data: + if "env_variables" in config_data: # Add TWEEPY_SKIP_AUTH - config_data['env_variables']['TWEEPY_SKIP_AUTH'] = { + config_data["env_variables"]["TWEEPY_SKIP_AUTH"] = { "name": "Skip Twitter connection", "description": "Skip Twitter connection for testing", - "value": "true", - "provision_type": "fixed" + "value": "true", + "provision_type": "fixed", } - + # Write modified config back with read/write permissions for all - with open(temp_config_path, 'w') as f: + with open(temp_config_path, "w") as f: json.dump(config_data, f, indent=2) - + # Ensure file permissions are set correctly os.chmod(temp_config_path, 0o666) - - cls.logger.info(f"Modified agents.fun config in temp dir: {temp_config_path}") - + + cls.logger.info( + f"Modified agents.fun config in temp dir: {temp_config_path}" + ) + except Exception as e: cls.logger.error(f"Error modifying agents.fun config: {str(e)}") raise - + # Setup environment cls._setup_environment() @@ -822,39 +933,43 @@ def setup_class(cls): cls.config_settings = get_config_specific_settings(cls.config_path) cls.logger.info(f"Loaded settings for config: {cls.config_path}") cls.operate = OperateApp(home=Path(cls.temp_dir.name) / OPERATE) - + # Start the service cls.start_service() time.sleep(STARTUP_WAIT) - + cls._setup_complete = True @classmethod def _setup_environment(cls): """Setup environment for tests""" cls.logger.info("Setting up test environment...") - - venv_path = os.environ.get('VIRTUAL_ENV') - + + venv_path = os.environ.get("VIRTUAL_ENV") + cls.temp_env = os.environ.copy() - cls.temp_env.pop('VIRTUAL_ENV', None) - cls.temp_env.pop('POETRY_ACTIVE', None) - + cls.temp_env.pop("VIRTUAL_ENV", None) + cls.temp_env.pop("POETRY_ACTIVE", None) + if venv_path: - if os.name == 'nt': # Windows - site_packages = Path(venv_path) / 'Lib' / 'site-packages' + if os.name == "nt": # Windows + site_packages = Path(venv_path) / "Lib" / "site-packages" else: # Unix-like - site_packages = list(Path(venv_path).glob('lib/python*/site-packages'))[0] - - pythonpath = cls.temp_env.get('PYTHONPATH', '') - cls.temp_env['PYTHONPATH'] = f"{site_packages}:{pythonpath}" if pythonpath else str(site_packages) - - paths = cls.temp_env['PATH'].split(os.pathsep) + site_packages = list(Path(venv_path).glob("lib/python*/site-packages"))[ + 0 + ] + + pythonpath = cls.temp_env.get("PYTHONPATH", "") + cls.temp_env["PYTHONPATH"] = ( + f"{site_packages}:{pythonpath}" if pythonpath else str(site_packages) + ) + + paths = cls.temp_env["PATH"].split(os.pathsep) paths = [p for p in paths if not p.startswith(str(venv_path))] - cls.temp_env['PATH'] = os.pathsep.join(paths) + cls.temp_env["PATH"] = os.pathsep.join(paths) else: cls.logger.warning("No virtualenv detected") - + cls.logger.info("Environment setup completed") @classmethod @@ -883,7 +998,9 @@ def teardown_class(cls): containers = client.containers.list(filters={"name": container_name}) if containers: - cls.logger.warning("Found running containers after stop_service, forcing removal...") + cls.logger.warning( + "Found running containers after stop_service, forcing removal..." + ) for container in containers: container.stop(timeout=30) container.remove() @@ -909,59 +1026,61 @@ def teardown_class(cls): def start_service(cls): """Start the service and handle initial setup with input validation.""" try: - cls.logger.info(f"Starting run_service.py test with config: {cls.config_path}") - + cls.logger.info( + f"Starting run_service.py test with config: {cls.config_path}" + ) + # Enable extended logging for pexpect only in debug mode if cls.logger.getEffectiveLevel() <= logging.DEBUG: cls.child = pexpect.spawn( - f'bash ./run_service.sh {cls.config_path}', - encoding='utf-8', + f"bash ./run_service.sh {cls.config_path}", + encoding="utf-8", timeout=600, env=cls.temp_env, cwd=".", - logfile=sys.stdout + logfile=sys.stdout, ) else: cls.child = pexpect.spawn( - f'bash ./run_service.sh {cls.config_path}', - encoding='utf-8', + f"bash ./run_service.sh {cls.config_path}", + encoding="utf-8", timeout=600, env=cls.temp_env, - cwd="." + cwd=".", ) - + try: while True: patterns = list(cls.config_settings["prompts"].keys()) index = cls.child.expect(patterns, timeout=600) pattern = patterns[index] - + log_expect_match(cls.child, pattern, index, cls.logger) - + response = cls.config_settings["prompts"][pattern] if callable(response): output = cls.child.before + cls.child.after response = response(output, cls.logger) - + send_input_safely(cls.child, response, cls.logger) - + except pexpect.EOF: cls.logger.info("Initial setup completed") time.sleep(SERVICE_INIT_WAIT) - + retries = 5 while retries > 0: if check_docker_status(cls.logger, cls.config_path): break time.sleep(STARTUP_WAIT) retries -= 1 - + if retries == 0: service_config = get_service_config(cls.config_path) container_name = service_config["container_name"] raise Exception(f"{container_name} containers failed to start") - - cls.operate.password = os.getenv('TEST_PASSWORD', 'test_secret') + + cls.operate.password = os.getenv("TEST_PASSWORD", "test_secret") except Exception as e: cls.logger.error(f"Service start failed: {str(e)}") raise @@ -970,30 +1089,30 @@ def start_service(cls): def stop_service(cls): """Stop the service ensuring we're in temp directory""" cls.logger.info("Stopping service...") - if hasattr(cls, 'temp_dir') and cls.temp_dir: + if hasattr(cls, "temp_dir") and cls.temp_dir: stop_dir = cls.temp_dir.name else: stop_dir = os.getcwd() - + process = pexpect.spawn( - f'bash ./stop_service.sh {cls.config_path}', - encoding='utf-8', + f"bash ./stop_service.sh {cls.config_path}", + encoding="utf-8", timeout=30, - cwd=stop_dir # Explicitly set working directory for stop_service + cwd=stop_dir, # Explicitly set working directory for stop_service ) try: while True: patterns = list(cls.config_settings["prompts"].keys()) index = process.expect(patterns, timeout=600) pattern = patterns[index] - + log_expect_match(process, pattern, index, cls.logger) - + response = cls.config_settings["prompts"][pattern] if callable(response): output = process.before + process.after response = response(output, cls.logger) - + send_input_safely(process, response, cls.logger) except pexpect.EOF: cls.logger.info("Service stop completed") @@ -1004,7 +1123,7 @@ def test_health_check(self): status, metrics = check_service_health(self.logger, self.config_path) self.logger.info(f"Health check metrics: {metrics}") assert status == True, f"Health check failed with metrics: {metrics}" - + def test_shutdown_logs(self): """Test service shutdown logs""" self.logger.info("Testing shutdown logs...") @@ -1021,10 +1140,14 @@ def test_shutdown_logs(self): f"Containers with name {container_name} are still running " f"after {CONTAINER_STOP_WAIT}s" ) - assert check_shutdown_logs(self.logger, self.config_path) == True, "Shutdown logs check failed" + assert ( + check_shutdown_logs(self.logger, self.config_path) == True + ), "Shutdown logs check failed" + class TempDirMixin: """Mixin to set up and clean up a temporary directory for tests.""" + logger: logging.Logger get_test_class: Callable[[str, str], BaseTestService] temp_dir: tempfile.TemporaryDirectory @@ -1040,21 +1163,33 @@ def setup_class(self): # Windows (Developer Mode / admin required), and the repo's CI # runs on ubuntu-24.04 only — skip cleanly before doing any # tempdir I/O so unsupported platforms don't pay the copy cost. - if os.name != 'posix': + if os.name != "posix": pytest.skip( "test_run_service requires symlink support; only POSIX " "platforms are supported. Run on Linux or macOS." ) # Create a temporary directory for stop_service - self.temp_dir = tempfile.TemporaryDirectory(prefix='operate_test_') + self.temp_dir = tempfile.TemporaryDirectory(prefix="operate_test_") # Exclude `.venv` from the copy and symlink it back in below; # see the docstring on the POSIX guard above for why. - shutil.copytree('.', self.temp_dir.name, dirs_exist_ok=True, - ignore=shutil.ignore_patterns('.operate', '.pytest_cache', '__pycache__', - '*.pyc', 'logs', '*.log', '.env', '.venv')) - src_venv = Path('.venv').resolve() + shutil.copytree( + ".", + self.temp_dir.name, + dirs_exist_ok=True, + ignore=shutil.ignore_patterns( + ".operate", + ".pytest_cache", + "__pycache__", + "*.pyc", + "logs", + "*.log", + ".env", + ".venv", + ), + ) + src_venv = Path(".venv").resolve() if not src_venv.is_dir(): # Without an existing source venv, `uv sync --inexact` # inside the tempdir would create a fresh one with a @@ -1066,7 +1201,7 @@ def setup_class(self): "`uv sync --all-groups --frozen` from the repo root " "before invoking the e2e suite." ) - link = Path(self.temp_dir.name) / '.venv' + link = Path(self.temp_dir.name) / ".venv" if link.exists() or link.is_symlink(): link.unlink() link.symlink_to(src_venv) @@ -1090,7 +1225,7 @@ def setup(self, request): yield if self.test_class._setup_complete: self.test_class.teardown_class() - + finally: pass @@ -1108,21 +1243,26 @@ def teardown_class(self): class TestAgentService(TempDirMixin): """Test class that runs tests for all configs.""" - logger = setup_logging(Path(f'test_run_service_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log')) + + logger = setup_logging( + Path(f'test_run_service_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log') + ) get_test_class = lambda _, config_path, temp_dir: type( - f'TestService_{Path(config_path).stem}', + f"TestService_{Path(config_path).stem}", (BaseTestService,), - {'config_path': config_path, 'temp_dir': temp_dir} + {"config_path": config_path, "temp_dir": temp_dir}, ) - @pytest.mark.parametrize('setup', get_config_files(), indirect=True, ids=lambda x: Path(x).stem) + @pytest.mark.parametrize( + "setup", get_config_files(), indirect=True, ids=lambda x: Path(x).stem + ) def test_agent_full_suite(self, setup): """Run all tests for each config.""" test_instance = self.test_class() - + # Run health check test_instance.test_health_check() - + # Run shutdown logs test test_instance.test_shutdown_logs() diff --git a/tests/test_scripts/test_optimus/test_migrate_legacy_optimus.py b/tests/test_scripts/test_optimus/test_migrate_legacy_optimus.py index d1313e3c..cee50f5b 100644 --- a/tests/test_scripts/test_optimus/test_migrate_legacy_optimus.py +++ b/tests/test_scripts/test_optimus/test_migrate_legacy_optimus.py @@ -6,248 +6,280 @@ from pathlib import Path import pytest - from scripts.optimus import migrate_legacy_optimus as migrate -def test_parse_optimus_config_happy_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Should parse config and derive staking/principal settings.""" - - optimus_path = tmp_path / ".optimus" - optimus_path.mkdir(parents=True) - (optimus_path / "local_config.json").write_text( - json.dumps( - { - "tenderly_access_key": "a", - "tenderly_account_slug": "b", - "tenderly_project_slug": "c", - "coingecko_api_key": "d", - "use_staking": True, - } - ), - encoding="utf-8", - ) - - monkeypatch.setattr(migrate, "OPTIMUS_PATH", optimus_path) - monkeypatch.setattr( - migrate, - "handle_missing_rpcs", - lambda _cfg: {"optimistic": "http://optimism", "base": "http://base"}, - ) - monkeypatch.setattr(migrate, "print_section", lambda *_args, **_kwargs: None) - - parsed = migrate.parse_optimus_config() - - assert parsed.use_staking is True - assert parsed.staking_program_id == "optimus_alpha" - assert parsed.principal_chain == "optimism" - assert parsed.rpc["base"] == "http://base" +def test_parse_optimus_config_happy_path( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Should parse config and derive staking/principal settings.""" + + optimus_path = tmp_path / ".optimus" + optimus_path.mkdir(parents=True) + (optimus_path / "local_config.json").write_text( + json.dumps( + { + "tenderly_access_key": "a", + "tenderly_account_slug": "b", + "tenderly_project_slug": "c", + "coingecko_api_key": "d", + "use_staking": True, + } + ), + encoding="utf-8", + ) + + monkeypatch.setattr(migrate, "OPTIMUS_PATH", optimus_path) + monkeypatch.setattr( + migrate, + "handle_missing_rpcs", + lambda _cfg: {"optimistic": "http://optimism", "base": "http://base"}, + ) + monkeypatch.setattr(migrate, "print_section", lambda *_args, **_kwargs: None) + + parsed = migrate.parse_optimus_config() + + assert parsed.use_staking is True + assert parsed.staking_program_id == "optimus_alpha" + assert parsed.principal_chain == "optimism" + assert parsed.rpc["base"] == "http://base" def test_parse_optimus_config_missing_required_fields( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """Missing required keys should raise ValueError.""" + """Missing required keys should raise ValueError.""" - optimus_path = tmp_path / ".optimus" - optimus_path.mkdir(parents=True) - (optimus_path / "local_config.json").write_text(json.dumps({"use_staking": False}), encoding="utf-8") - monkeypatch.setattr(migrate, "OPTIMUS_PATH", optimus_path) - monkeypatch.setattr(migrate, "print_section", lambda *_args, **_kwargs: None) + optimus_path = tmp_path / ".optimus" + optimus_path.mkdir(parents=True) + (optimus_path / "local_config.json").write_text( + json.dumps({"use_staking": False}), encoding="utf-8" + ) + monkeypatch.setattr(migrate, "OPTIMUS_PATH", optimus_path) + monkeypatch.setattr(migrate, "print_section", lambda *_args, **_kwargs: None) - with pytest.raises(ValueError, match="Missing required configuration parameters"): - migrate.parse_optimus_config() + with pytest.raises(ValueError, match="Missing required configuration parameters"): + migrate.parse_optimus_config() -def test_copy_optimus_to_operate(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Should copy all files except local_config.json.""" +def test_copy_optimus_to_operate( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Should copy all files except local_config.json.""" - optimus_path = tmp_path / ".optimus" - operate_home = tmp_path / ".operate" - (optimus_path / "nested").mkdir(parents=True) - (optimus_path / "local_config.json").write_text("{}", encoding="utf-8") - (optimus_path / "state.json").write_text("{\"x\":1}", encoding="utf-8") - (optimus_path / "nested" / "a.txt").write_text("a", encoding="utf-8") + optimus_path = tmp_path / ".optimus" + operate_home = tmp_path / ".operate" + (optimus_path / "nested").mkdir(parents=True) + (optimus_path / "local_config.json").write_text("{}", encoding="utf-8") + (optimus_path / "state.json").write_text('{"x":1}', encoding="utf-8") + (optimus_path / "nested" / "a.txt").write_text("a", encoding="utf-8") - monkeypatch.setattr(migrate, "OPTIMUS_PATH", optimus_path) - monkeypatch.setattr(migrate, "OPERATE_HOME", operate_home) - monkeypatch.setattr(migrate, "print_section", lambda *_args, **_kwargs: None) + monkeypatch.setattr(migrate, "OPTIMUS_PATH", optimus_path) + monkeypatch.setattr(migrate, "OPERATE_HOME", operate_home) + monkeypatch.setattr(migrate, "print_section", lambda *_args, **_kwargs: None) - migrate.copy_optimus_to_operate() + migrate.copy_optimus_to_operate() - assert (operate_home / "state.json").exists() - assert (operate_home / "nested" / "a.txt").exists() - assert not (operate_home / "local_config.json").exists() + assert (operate_home / "state.json").exists() + assert (operate_home / "nested" / "a.txt").exists() + assert not (operate_home / "local_config.json").exists() def test_create_operate_config_updates_service_and_stores_qs( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """Should rename service name and persist QuickstartConfig.""" + """Should rename service name and persist QuickstartConfig.""" - operate_home = tmp_path / ".operate" - services_dir = operate_home / "services" / "svc" - services_dir.mkdir(parents=True) - config_path = services_dir / "config.json" - config_path.write_text(json.dumps({"name": "valory/optimus"}), encoding="utf-8") + operate_home = tmp_path / ".operate" + services_dir = operate_home / "services" / "svc" + services_dir.mkdir(parents=True) + config_path = services_dir / "config.json" + config_path.write_text(json.dumps({"name": "valory/optimus"}), encoding="utf-8") - monkeypatch.setattr(migrate, "OPERATE_HOME", operate_home) - monkeypatch.setattr(migrate, "print_section", lambda *_args, **_kwargs: None) + monkeypatch.setattr(migrate, "OPERATE_HOME", operate_home) + monkeypatch.setattr(migrate, "print_section", lambda *_args, **_kwargs: None) - stored = {"called": False} + stored = {"called": False} - class _QS: - def __init__(self, **kwargs): - self.kwargs = kwargs + class _QS: + def __init__(self, **kwargs): + self.kwargs = kwargs - def store(self): - stored["called"] = True - stored["kwargs"] = self.kwargs + def store(self): + stored["called"] = True + stored["kwargs"] = self.kwargs - monkeypatch.setattr(migrate, "QuickstartConfig", _QS) + monkeypatch.setattr(migrate, "QuickstartConfig", _QS) - optimus_config = migrate.OptimusConfig( - rpc={"optimistic": "http://optimism"}, - tenderly_access_key="a", - tenderly_account_slug="b", - tenderly_project_slug="c", - coingecko_api_key="d", - use_staking=False, - staking_program_id="no_staking", - principal_chain="optimistic", - ) + optimus_config = migrate.OptimusConfig( + rpc={"optimistic": "http://optimism"}, + tenderly_access_key="a", + tenderly_account_slug="b", + tenderly_project_slug="c", + coingecko_api_key="d", + use_staking=False, + staking_program_id="no_staking", + principal_chain="optimistic", + ) - migrate.create_operate_config(optimus_config, "my/optimus") + migrate.create_operate_config(optimus_config, "my/optimus") - updated_config = json.loads(config_path.read_text(encoding="utf-8")) - assert updated_config["name"] == "my/optimus" - assert stored["called"] is True - assert stored["kwargs"]["staking_program_id"] == "no_staking" + updated_config = json.loads(config_path.read_text(encoding="utf-8")) + assert updated_config["name"] == "my/optimus" + assert stored["called"] is True + assert stored["kwargs"]["staking_program_id"] == "no_staking" -def test_main_returns_when_optimus_missing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """If .optimus is missing, main should return cleanly.""" +def test_main_returns_when_optimus_missing( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """If .optimus is missing, main should return cleanly.""" - monkeypatch.setattr(migrate, "OPTIMUS_PATH", tmp_path / ".optimus") - monkeypatch.setattr(migrate, "print_title", lambda *_args, **_kwargs: None) + monkeypatch.setattr(migrate, "OPTIMUS_PATH", tmp_path / ".optimus") + monkeypatch.setattr(migrate, "print_title", lambda *_args, **_kwargs: None) - config = tmp_path / "cfg.json" - config.write_text(json.dumps({"name": "my/optimus"}), encoding="utf-8") + config = tmp_path / "cfg.json" + config.write_text(json.dumps({"name": "my/optimus"}), encoding="utf-8") - # Should not raise. - migrate.main(config) + # Should not raise. + migrate.main(config) def test_create_operate_config_skips_missing_and_non_matching_service_dirs( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """Service dirs without config or non-optimus names should be skipped.""" - - operate_home = tmp_path / ".operate" - services = operate_home / "services" - (services / "missing_config").mkdir(parents=True) - non_match = services / "non_matching" - non_match.mkdir(parents=True) - (non_match / "config.json").write_text(json.dumps({"name": "other/service"}), encoding="utf-8") - - monkeypatch.setattr(migrate, "OPERATE_HOME", operate_home) - monkeypatch.setattr(migrate, "print_section", lambda *_args, **_kwargs: None) - - stored = {"called": False} - - class _QS: - def __init__(self, **kwargs): - self.kwargs = kwargs + """Service dirs without config or non-optimus names should be skipped.""" - def store(self): - stored["called"] = True - stored["kwargs"] = self.kwargs + operate_home = tmp_path / ".operate" + services = operate_home / "services" + (services / "missing_config").mkdir(parents=True) + non_match = services / "non_matching" + non_match.mkdir(parents=True) + (non_match / "config.json").write_text( + json.dumps({"name": "other/service"}), encoding="utf-8" + ) - monkeypatch.setattr(migrate, "QuickstartConfig", _QS) + monkeypatch.setattr(migrate, "OPERATE_HOME", operate_home) + monkeypatch.setattr(migrate, "print_section", lambda *_args, **_kwargs: None) - optimus_config = migrate.OptimusConfig( - rpc={"optimism": "http://optimism"}, - tenderly_access_key="a", - tenderly_account_slug="b", - tenderly_project_slug="c", - coingecko_api_key="d", - use_staking=False, - staking_program_id="no_staking", - principal_chain="optimism", - ) + stored = {"called": False} - migrate.create_operate_config(optimus_config, "my/optimus") + class _QS: + def __init__(self, **kwargs): + self.kwargs = kwargs - assert stored["called"] is True - updated = json.loads((non_match / "config.json").read_text(encoding="utf-8")) - assert updated["name"] == "other/service" + def store(self): + stored["called"] = True + stored["kwargs"] = self.kwargs + monkeypatch.setattr(migrate, "QuickstartConfig", _QS) -def test_main_success_calls_all_steps(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Main should parse config and invoke parse/copy/create sequence.""" + optimus_config = migrate.OptimusConfig( + rpc={"optimism": "http://optimism"}, + tenderly_access_key="a", + tenderly_account_slug="b", + tenderly_project_slug="c", + coingecko_api_key="d", + use_staking=False, + staking_program_id="no_staking", + principal_chain="optimism", + ) - optimus_path = tmp_path / ".optimus" - optimus_path.mkdir(parents=True) - monkeypatch.setattr(migrate, "OPTIMUS_PATH", optimus_path) - monkeypatch.setattr(migrate, "print_title", lambda *_args, **_kwargs: None) + migrate.create_operate_config(optimus_config, "my/optimus") - config_path = tmp_path / "cfg.json" - config_path.write_text(json.dumps({"name": "my/optimus"}), encoding="utf-8") + assert stored["called"] is True + updated = json.loads((non_match / "config.json").read_text(encoding="utf-8")) + assert updated["name"] == "other/service" - calls: list[str] = [] - optimus_config = migrate.OptimusConfig( - rpc={"optimism": "http://optimism"}, - tenderly_access_key="a", - tenderly_account_slug="b", - tenderly_project_slug="c", - coingecko_api_key="d", - use_staking=False, - staking_program_id="no_staking", - principal_chain="optimism", - ) - monkeypatch.setattr(migrate, "parse_optimus_config", lambda: calls.append("parse") or optimus_config) - monkeypatch.setattr(migrate, "copy_optimus_to_operate", lambda: calls.append("copy")) - monkeypatch.setattr(migrate, "create_operate_config", lambda cfg, name: calls.append(f"create:{name}:{cfg.principal_chain}")) - monkeypatch.setattr(migrate, "print_section", lambda msg: calls.append(f"section:{msg}")) - - migrate.main(config_path) - - assert calls[0] == "parse" - assert "copy" in calls - assert "create:my/optimus:optimism" in calls - assert any(c.startswith("section:Migration completed successfully") for c in calls) - - -def test_main_prints_and_reraises_on_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Main should print error and re-raise exceptions from migration steps.""" +def test_main_success_calls_all_steps( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Main should parse config and invoke parse/copy/create sequence.""" + + optimus_path = tmp_path / ".optimus" + optimus_path.mkdir(parents=True) + monkeypatch.setattr(migrate, "OPTIMUS_PATH", optimus_path) + monkeypatch.setattr(migrate, "print_title", lambda *_args, **_kwargs: None) + + config_path = tmp_path / "cfg.json" + config_path.write_text(json.dumps({"name": "my/optimus"}), encoding="utf-8") + + calls: list[str] = [] + optimus_config = migrate.OptimusConfig( + rpc={"optimism": "http://optimism"}, + tenderly_access_key="a", + tenderly_account_slug="b", + tenderly_project_slug="c", + coingecko_api_key="d", + use_staking=False, + staking_program_id="no_staking", + principal_chain="optimism", + ) + + monkeypatch.setattr( + migrate, "parse_optimus_config", lambda: calls.append("parse") or optimus_config + ) + monkeypatch.setattr( + migrate, "copy_optimus_to_operate", lambda: calls.append("copy") + ) + monkeypatch.setattr( + migrate, + "create_operate_config", + lambda cfg, name: calls.append(f"create:{name}:{cfg.principal_chain}"), + ) + monkeypatch.setattr( + migrate, "print_section", lambda msg: calls.append(f"section:{msg}") + ) + + migrate.main(config_path) + + assert calls[0] == "parse" + assert "copy" in calls + assert "create:my/optimus:optimism" in calls + assert any(c.startswith("section:Migration completed successfully") for c in calls) + + +def test_main_prints_and_reraises_on_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Main should print error and re-raise exceptions from migration steps.""" - optimus_path = tmp_path / ".optimus" - optimus_path.mkdir(parents=True) - monkeypatch.setattr(migrate, "OPTIMUS_PATH", optimus_path) - monkeypatch.setattr(migrate, "print_title", lambda *_args, **_kwargs: None) + optimus_path = tmp_path / ".optimus" + optimus_path.mkdir(parents=True) + monkeypatch.setattr(migrate, "OPTIMUS_PATH", optimus_path) + monkeypatch.setattr(migrate, "print_title", lambda *_args, **_kwargs: None) - config_path = tmp_path / "cfg.json" - config_path.write_text(json.dumps({"name": "my/optimus"}), encoding="utf-8") + config_path = tmp_path / "cfg.json" + config_path.write_text(json.dumps({"name": "my/optimus"}), encoding="utf-8") - monkeypatch.setattr(migrate, "parse_optimus_config", lambda: (_ for _ in ()).throw(RuntimeError("boom"))) + monkeypatch.setattr( + migrate, + "parse_optimus_config", + lambda: (_ for _ in ()).throw(RuntimeError("boom")), + ) - printed: list[str] = [] - monkeypatch.setattr("builtins.print", lambda *args, **kwargs: printed.append(" ".join(str(a) for a in args))) + printed: list[str] = [] + monkeypatch.setattr( + "builtins.print", + lambda *args, **kwargs: printed.append(" ".join(str(a) for a in args)), + ) - with pytest.raises(RuntimeError, match="boom"): - migrate.main(config_path) + with pytest.raises(RuntimeError, match="boom"): + migrate.main(config_path) - assert any("Error during migration: boom" in line for line in printed) + assert any("Error during migration: boom" in line for line in printed) -def test_module_main_entrypoint(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """__main__ block should execute argument parsing path without crashing.""" +def test_module_main_entrypoint( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """__main__ block should execute argument parsing path without crashing.""" - config_path = tmp_path / "cfg.json" - config_path.write_text("{}", encoding="utf-8") + config_path = tmp_path / "cfg.json" + config_path.write_text("{}", encoding="utf-8") - monkeypatch.setattr(sys, "argv", ["migrate_legacy_optimus.py", str(config_path)]) + monkeypatch.setattr(sys, "argv", ["migrate_legacy_optimus.py", str(config_path)]) - # Executes parser.add_argument/parse_args and main invocation in module namespace. - runpy.run_module("scripts.optimus.migrate_legacy_optimus", run_name="__main__") + # Executes parser.add_argument/parse_args and main invocation in module namespace. + runpy.run_module("scripts.optimus.migrate_legacy_optimus", run_name="__main__") diff --git a/tests/test_scripts/test_pearl_migration.py b/tests/test_scripts/test_pearl_migration.py index 16c31983..ac80b684 100644 --- a/tests/test_scripts/test_pearl_migration.py +++ b/tests/test_scripts/test_pearl_migration.py @@ -18,23 +18,26 @@ from typing import Any, Iterable, Optional import pytest - from scripts.pearl_migration import detect, filesystem, prompts, status - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def _write_wallet(operate_root: Path, master_eoa: str = "0xeoa") -> None: wallets = operate_root / "wallets" wallets.mkdir(parents=True, exist_ok=True) - (wallets / "ethereum.json").write_text(json.dumps({ - "address": master_eoa, - "safes": {"gnosis": "0xsafe"}, - "safe_chains": ["gnosis"], - "ledger_type": "ethereum", - })) + (wallets / "ethereum.json").write_text( + json.dumps( + { + "address": master_eoa, + "safes": {"gnosis": "0xsafe"}, + "safe_chains": ["gnosis"], + "ledger_type": "ethereum", + } + ) + ) (wallets / "ethereum.txt").write_text("encrypted-master-key") @@ -46,12 +49,16 @@ def _write_service( ) -> Path: sdir = operate_root / "services" / config_id sdir.mkdir(parents=True, exist_ok=True) - (sdir / "config.json").write_text(json.dumps({ - "name": name, - "service_config_id": config_id, - "agent_addresses": agent_addresses or ["0xagent1"], - "hash": "bafy-test", - })) + (sdir / "config.json").write_text( + json.dumps( + { + "name": name, + "service_config_id": config_id, + "agent_addresses": agent_addresses or ["0xagent1"], + "hash": "bafy-test", + } + ) + ) (sdir / "persistent_data").mkdir() (sdir / "persistent_data" / "log.txt").write_text("hello") return sdir @@ -80,6 +87,7 @@ def _fake_service( `SimpleNamespace` with those fields keeps the fixtures small. """ import types + return types.SimpleNamespace( service_config_id=config_id, name=name, @@ -114,6 +122,7 @@ def _fake_load(path: Path) -> Any: # detect.py # --------------------------------------------------------------------------- + class TestDiscover: def test_noop_when_paths_resolve_equal(self, tmp_path: Path) -> None: # Both stores point at the same .operate @@ -163,10 +172,14 @@ def test_lists_only_sc_prefixed_dirs_with_config( # reach Service.load (filtered out by the prefix / config.json # checks in OperateStore.services()). patch_service_load[(root / "services/sc-aaa").resolve()] = _fake_service( - "sc-aaa", name="A", agent_addresses=["0x1", "0x2"], + "sc-aaa", + name="A", + agent_addresses=["0x1", "0x2"], ) patch_service_load[(root / "services/sc-bbb").resolve()] = _fake_service( - "sc-bbb", name="B", agent_addresses=["0x3"], + "sc-bbb", + name="B", + agent_addresses=["0x3"], ) store = detect.OperateStore(root=root.resolve()) @@ -200,6 +213,7 @@ def _flaky_load(path: Path) -> Any: # filesystem.py # --------------------------------------------------------------------------- + class TestFreshCopy: def test_copies_whole_tree(self, tmp_path: Path) -> None: src_root = tmp_path / "src/.operate" @@ -226,7 +240,8 @@ def test_refuses_existing_dest(self, tmp_path: Path) -> None: class TestMergeService: def _setup( - self, tmp_path: Path, + self, + tmp_path: Path, ) -> tuple[detect.OperateStore, detect.OperateStore, Any]: src_root = tmp_path / "src/.operate" _write_service(src_root, "sc-aaa", agent_addresses=["0xagent1"]) @@ -266,14 +281,17 @@ def test_collision_skip( # Always pick "skip". monkeypatch.setattr( - filesystem, "collision", + filesystem, + "collision", lambda target, kind: prompts.CollisionChoice.SKIP, ) outcome = filesystem.merge_service(svc, src, dest) assert outcome.service_skipped is True assert outcome.keys_skipped == ["0xagent1"] - assert (dest.services_dir / "sc-aaa" / "marker.txt").read_text() == "dest-original" + assert ( + dest.services_dir / "sc-aaa" / "marker.txt" + ).read_text() == "dest-original" assert (dest.keys_dir / "0xagent1").read_text() == "dest-key" assert outcome.backups_made == [] @@ -287,7 +305,8 @@ def test_collision_overwrite_with_backup( (dest.keys_dir / "0xagent1").write_text("dest-key") monkeypatch.setattr( - filesystem, "collision", + filesystem, + "collision", lambda target, kind: prompts.CollisionChoice.OVERWRITE_WITH_BACKUP, ) @@ -301,10 +320,12 @@ def test_collision_overwrite_with_backup( # Fresh copy in place. assert (dest.services_dir / "sc-aaa" / "config.json").exists() + # --------------------------------------------------------------------------- # prompts.py # --------------------------------------------------------------------------- + class TestPrompts: @pytest.fixture(autouse=True) def _attended_default(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -363,22 +384,24 @@ def test_yes_no_attended_default_honors_empty_input( assert prompts.yes_no("?", default=True) is True assert prompts.yes_no("?", default=False) is False - def test_yes_no_eof_returns_default( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_yes_no_eof_returns_default(self, monkeypatch: pytest.MonkeyPatch) -> None: """Closed/piped stdin in attended mode falls back to the declared default — the rename-source and "different machine?" prompts at the end of a successful migration both pass an explicit default, so this avoids a raw EOFError traceback at the very end of a working migration.""" + def boom(_: str) -> str: raise EOFError + monkeypatch.setattr("builtins.input", boom) assert prompts.yes_no("?", default=True) is True assert prompts.yes_no("?", default=False) is False def test_collision_eof_defaults_to_skip( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """`collision()` is invoked from `merge_service` AFTER on-chain @@ -386,15 +409,19 @@ def test_collision_eof_defaults_to_skip( the for-loop, skip drain + rename, and leave the user with no summary. Default to SKIP (the safe choice) and warn so it's visible.""" + def boom(_: str) -> str: raise EOFError + monkeypatch.setattr("builtins.input", boom) result = prompts.collision(tmp_path / "x", kind="key") assert result == prompts.CollisionChoice.SKIP assert "stdin closed" in capsys.readouterr().out def test_collision_invalid_input_re_prompts( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """Invalid input must surface a hint rather than silently @@ -434,6 +461,7 @@ def test_ask_password_validating_returns_none_on_exhaustion( # status.py (the OS-level bits — no mocking of the chain here) # --------------------------------------------------------------------------- + class TestStatusOSChecks: def test_pearl_daemon_running_false_on_unused_port(self) -> None: # Port 1 is privileged + nothing listens on it under tests. @@ -467,7 +495,8 @@ def test_docker_quickstart_containers_returns_intersection( self, monkeypatch: pytest.MonkeyPatch ) -> None: completed = subprocess.CompletedProcess( - args=[], returncode=0, + args=[], + returncode=0, stdout="abci0\nnode0\nrandom_other\n", ) monkeypatch.setattr(status.subprocess, "run", lambda *a, **k: completed) @@ -483,7 +512,8 @@ def test_docker_quickstart_containers_matches_per_service_substrings( let the migration race a still-running deployment. Substring matching on `_abci_0` / `_tm_0` catches them all.""" completed = subprocess.CompletedProcess( - args=[], returncode=0, + args=[], + returncode=0, stdout=( "trader_abci_0\n" "trader_tm_0\n" @@ -503,6 +533,7 @@ def test_docker_quickstart_containers_handles_missing_docker( ) -> None: def boom(*_a: Any, **_k: Any) -> None: raise FileNotFoundError("docker not installed") + monkeypatch.setattr(status.subprocess, "run", boom) assert status.docker_quickstart_containers() == [] @@ -512,6 +543,7 @@ def test_docker_quickstart_containers_propagates_timeout( # A hung docker daemon must NOT be silently mistaken for "no containers". def boom(*_a: Any, **_k: Any) -> None: raise subprocess.TimeoutExpired(cmd="docker", timeout=10) + monkeypatch.setattr(status.subprocess, "run", boom) with pytest.raises(subprocess.TimeoutExpired): status.docker_quickstart_containers() @@ -523,13 +555,15 @@ def test_docker_quickstart_containers_raises_on_nonzero_exit( /var/run/docker.sock, daemon refusing) MUST raise. Returning `[]` would let callers conclude "no containers" and race a still-running deployment.""" + def fake_run(*_a: Any, **_k: Any) -> Any: return types.SimpleNamespace( returncode=1, stdout="", stderr="permission denied while trying to connect to " - "the Docker daemon socket", + "the Docker daemon socket", ) + monkeypatch.setattr(status.subprocess, "run", fake_run) with pytest.raises(RuntimeError, match="exited 1"): status.docker_quickstart_containers() @@ -555,8 +589,10 @@ def test_is_root_owned_propagates_oserror( f = tmp_path / "x" f.write_text("hi") monkeypatch.setattr(Path, "exists", lambda self, **kwargs: True) + def boom(self: Path, **kwargs: Any) -> None: raise OSError("permission denied") + monkeypatch.setattr(Path, "stat", boom) with pytest.raises(OSError, match="permission denied"): status.is_root_owned(f) @@ -571,6 +607,7 @@ def test_any_root_owned_under_descendant( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: import stat as _statmod + child = tmp_path / "child" child.write_text("x") @@ -587,6 +624,7 @@ class FakeStatChild: def fake_stat(self: Path, **kwargs: Any) -> Any: return FakeStatChild() if self == child else FakeStatRoot() + monkeypatch.setattr(Path, "stat", fake_stat) assert status.any_root_owned_under(tmp_path) is True @@ -618,6 +656,7 @@ def stat_raises_for_child(self: Path, **kwargs: Any) -> Any: if self.name == "x": raise OSError("permission") return original_stat(self, **kwargs) + monkeypatch.setattr(Path, "stat", stat_raises_for_child) with pytest.raises(OSError, match="permission"): status.any_root_owned_under(tmp_path) @@ -631,6 +670,7 @@ def test_any_root_owned_under_propagates_rglob_oserror( def rglob_raises(self: Path, pattern: str) -> Iterable[Path]: raise OSError("nope") + monkeypatch.setattr(Path, "rglob", rglob_raises) with pytest.raises(OSError, match="nope"): status.any_root_owned_under(tmp_path) @@ -640,6 +680,7 @@ def rglob_raises(self: Path, pattern: str) -> Iterable[Path]: # status.py — on-chain query wrappers (mocked) # --------------------------------------------------------------------------- + def _install_fake_autonomy(monkeypatch: pytest.MonkeyPatch, registry_obj: Any) -> None: """Install a stub `autonomy.chain.base` exposing a `registry_contracts` attribute.""" autonomy_pkg = types.ModuleType("autonomy") @@ -666,20 +707,26 @@ def test_service_nft_owner_returns_owner( ), ) _install_fake_autonomy(monkeypatch, registry) - assert status.service_nft_owner( - ledger_api=object(), - service_registry_address="0xreg", - service_id=42, - ) == "0xowner" + assert ( + status.service_nft_owner( + ledger_api=object(), + service_registry_address="0xreg", + service_id=42, + ) + == "0xowner" + ) def test_service_nft_owner_returns_none_on_revert( self, monkeypatch: pytest.MonkeyPatch ) -> None: from web3.exceptions import ContractLogicError + instance = types.SimpleNamespace( functions=types.SimpleNamespace( ownerOf=lambda sid: types.SimpleNamespace( - call=lambda: (_ for _ in ()).throw(ContractLogicError("revert: NOT_MINTED")), + call=lambda: (_ for _ in ()).throw( + ContractLogicError("revert: NOT_MINTED") + ), ), ), ) @@ -689,11 +736,14 @@ def test_service_nft_owner_returns_none_on_revert( ), ) _install_fake_autonomy(monkeypatch, registry) - assert status.service_nft_owner( - ledger_api=object(), - service_registry_address="0xreg", - service_id=42, - ) is None + assert ( + status.service_nft_owner( + ledger_api=object(), + service_registry_address="0xreg", + service_id=42, + ) + is None + ) def test_service_nft_owner_propagates_other_errors( self, monkeypatch: pytest.MonkeyPatch @@ -732,9 +782,7 @@ def test_safe_owners_uses_gnosis_helper( assert status.safe_owners(ledger_api=object(), safe="0xs") == ["0xa", "0xb"] - def test_safe_threshold_returns_int( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_safe_threshold_returns_int(self, monkeypatch: pytest.MonkeyPatch) -> None: instance = types.SimpleNamespace( functions=types.SimpleNamespace( getThreshold=lambda: types.SimpleNamespace(call=lambda: 2), @@ -753,6 +801,7 @@ def test_safe_threshold_returns_int( # detect.py — additional branches # --------------------------------------------------------------------------- + class TestDetectExtras: def test_list_services_returns_empty_when_no_services_dir( self, tmp_path: Path @@ -769,6 +818,7 @@ def test_list_services_skips_malformed_config( def _boom(_: Path) -> Any: raise RuntimeError("malformed") + monkeypatch.setattr(detect.Service, "load", staticmethod(_boom)) store = detect.OperateStore(root=tmp_path) @@ -788,7 +838,9 @@ def __init__(self, home: Path) -> None: # Insert a fake `operate.cli` so the lazy `from operate.cli import OperateApp` # picks it up. - import sys, types + import sys + import types + fake_cli = types.ModuleType("operate.cli") fake_cli.OperateApp = FakeApp # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "operate.cli", fake_cli) @@ -811,7 +863,9 @@ def __init__(self, home: Path) -> None: def test_operate_app_first_call_without_password( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - import sys, types + import sys + import types + class FakeApp: def __init__(self, home: Path) -> None: self.home = home @@ -831,7 +885,9 @@ def test_discovery_is_noop_property(self, tmp_path: Path) -> None: assert d.is_noop is True # Distinct stores → can construct with non-NOOP mode. store2 = detect.OperateStore(root=(tmp_path / "other").resolve()) - d2 = detect.Discovery(quickstart=store, pearl=store2, mode=detect.Mode.FRESH_COPY) + d2 = detect.Discovery( + quickstart=store, pearl=store2, mode=detect.Mode.FRESH_COPY + ) assert d2.is_noop is False def test_discovery_rejects_inconsistent_invariants(self, tmp_path: Path) -> None: @@ -876,8 +932,10 @@ def test_operate_store_post_init_resolve_failure_becomes_value_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """OSError from Path.resolve() should become a clean ValueError.""" + def boom(self: Path, *, strict: bool = False) -> Path: raise OSError("permission denied: '/some/parent'") + monkeypatch.setattr(Path, "resolve", boom) with pytest.raises(ValueError, match="Cannot resolve store root"): detect.OperateStore(root=tmp_path) @@ -887,6 +945,7 @@ def boom(self: Path, *, strict: bool = False) -> Path: # wallet.py — quickstart-side password alignment # --------------------------------------------------------------------------- + class TestAlignQuickstartPassword: def test_no_op_when_passwords_match(self) -> None: from scripts.pearl_migration import wallet as wallet_mod @@ -900,7 +959,9 @@ def test_no_op_when_passwords_match(self) -> None: update_password=lambda new: update_calls.append(new), ) wallet_mod.align_quickstart_password( - qs_app=qs_app, qs_wallet=qs_wallet, new_password="same", + qs_app=qs_app, + qs_wallet=qs_wallet, + new_password="same", ) assert update_calls == [] assert qs_app.password == "same" @@ -946,11 +1007,15 @@ def test_reencrypts_master_and_agent_keys(self, tmp_path: Path) -> None: ) keys_manager = types.SimpleNamespace(path=keys_dir, password=old_pw) qs_app = types.SimpleNamespace( - password=old_pw, keys_manager=keys_manager, user_account=None, + password=old_pw, + keys_manager=keys_manager, + user_account=None, ) wallet_mod.align_quickstart_password( - qs_app=qs_app, qs_wallet=qs_wallet, new_password=new_pw, + qs_app=qs_app, + qs_wallet=qs_wallet, + new_password=new_pw, ) # Master keyfile re-encrypt was delegated to operate's update_password. @@ -980,7 +1045,9 @@ def test_reencrypts_master_and_agent_keys(self, tmp_path: Path) -> None: assert bytes(Account.decrypt(snap_inner, old_pw)) == raw def test_reencrypt_failure_is_propagated_and_warned( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Failure inside _reencrypt_agent_key MUST propagate so the caller aborts the migration rather than half-converting the keys dir.""" @@ -998,10 +1065,12 @@ def test_reencrypt_failure_is_propagated_and_warned( def boom(**kwargs: Any) -> None: raise RuntimeError("disk full") + monkeypatch.setattr(wallet_mod, "_reencrypt_agent_key", boom) qs_wallet = types.SimpleNamespace( - path=wallets_dir, update_password=lambda new: None, + path=wallets_dir, + update_password=lambda new: None, ) qs_app = types.SimpleNamespace( password="old", @@ -1010,14 +1079,18 @@ def boom(**kwargs: Any) -> None: ) with pytest.raises(RuntimeError, match="disk full"): wallet_mod.align_quickstart_password( - qs_app=qs_app, qs_wallet=qs_wallet, new_password="new", + qs_app=qs_app, + qs_wallet=qs_wallet, + new_password="new", ) # User is told the recovery path — must include the snapshot dir. assert any("failed to re-encrypt" in msg for msg in warn_calls) assert any(".pre-align." in msg for msg in warn_calls) def test_snapshot_preserves_originals_after_partial_failure( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """The whole point of the snapshot is recovery. After a mid-loop failure (key 1 already mutated, key 2 raised), the snapshot @@ -1054,6 +1127,7 @@ def test_snapshot_preserves_originals_after_partial_failure( # failure shape: walk gets through some files before erroring. seen: list[Path] = [] original = wallet_mod._reencrypt_agent_key + def partial(*, key_path: Path, old_password: str, new_password: str) -> None: seen.append(key_path) if len(seen) == 1: @@ -1064,10 +1138,12 @@ def partial(*, key_path: Path, old_password: str, new_password: str) -> None: ) return raise RuntimeError("io error mid-walk") + monkeypatch.setattr(wallet_mod, "_reencrypt_agent_key", partial) qs_wallet = types.SimpleNamespace( - path=wallets_dir, update_password=lambda new: None, + path=wallets_dir, + update_password=lambda new: None, ) qs_app = types.SimpleNamespace( password=old_pw, @@ -1076,7 +1152,9 @@ def partial(*, key_path: Path, old_password: str, new_password: str) -> None: ) with pytest.raises(RuntimeError, match="io error mid-walk"): wallet_mod.align_quickstart_password( - qs_app=qs_app, qs_wallet=qs_wallet, new_password=new_pw, + qs_app=qs_app, + qs_wallet=qs_wallet, + new_password=new_pw, ) # Snapshot must exist and must contain ORIGINAL (old-pw) copies of @@ -1109,13 +1187,18 @@ def test_skips_walk_when_keys_dir_missing(self, tmp_path: Path) -> None: update_password=lambda new: master_calls.append(new), ) keys_manager = types.SimpleNamespace( - path=operate_root / "absent_keys", password="old", + path=operate_root / "absent_keys", + password="old", ) qs_app = types.SimpleNamespace( - password="old", keys_manager=keys_manager, user_account=None, + password="old", + keys_manager=keys_manager, + user_account=None, ) wallet_mod.align_quickstart_password( - qs_app=qs_app, qs_wallet=qs_wallet, new_password="new", + qs_app=qs_app, + qs_wallet=qs_wallet, + new_password="new", ) assert master_calls == ["new"] assert qs_app.password == "new" @@ -1134,7 +1217,8 @@ def test_skips_non_file_entries_in_keys_dir(self, tmp_path: Path) -> None: (keys_dir / "subdir").mkdir() # not-a-file entry qs_wallet = types.SimpleNamespace( - path=wallets_dir, update_password=lambda new: None, + path=wallets_dir, + update_password=lambda new: None, ) qs_app = types.SimpleNamespace( password="old", @@ -1143,7 +1227,9 @@ def test_skips_non_file_entries_in_keys_dir(self, tmp_path: Path) -> None: ) # Should not raise — subdir gets skipped silently. wallet_mod.align_quickstart_password( - qs_app=qs_app, qs_wallet=qs_wallet, new_password="new", + qs_app=qs_app, + qs_wallet=qs_wallet, + new_password="new", ) assert qs_app.password == "new" @@ -1165,7 +1251,8 @@ def test_skips_dotfiles_in_keys_dir(self, tmp_path: Path) -> None: (keys_dir / ".DS_Store").write_bytes(b"\x00\x01binary-junk") qs_wallet = types.SimpleNamespace( - path=wallets_dir, update_password=lambda new: None, + path=wallets_dir, + update_password=lambda new: None, ) qs_app = types.SimpleNamespace( password="old", @@ -1174,19 +1261,22 @@ def test_skips_dotfiles_in_keys_dir(self, tmp_path: Path) -> None: ) called: list[Any] = [] from unittest.mock import patch + with patch.object( - wallet_mod, "_reencrypt_agent_key", + wallet_mod, + "_reencrypt_agent_key", lambda **kw: called.append(kw["key_path"]), ): wallet_mod.align_quickstart_password( - qs_app=qs_app, qs_wallet=qs_wallet, new_password="new", + qs_app=qs_app, + qs_wallet=qs_wallet, + new_password="new", ) - assert called == [], ( - "dotfiles must be skipped — got: " + repr(called) - ) + assert called == [], "dotfiles must be skipped — got: " + repr(called) def test_old_password_captured_before_update_password( - self, tmp_path: Path, + self, + tmp_path: Path, ) -> None: """qs_wallet.update_password may mutate qs_app.password as a side effect (the wallet shares state with the manager). The function @@ -1207,21 +1297,28 @@ def test_old_password_captured_before_update_password( keys_manager=types.SimpleNamespace(path=keys_dir, password="real-old"), user_account=None, ) + def mutate_password(new: str) -> None: qs_app.password = new + qs_wallet = types.SimpleNamespace( - path=wallets_dir, update_password=mutate_password, + path=wallets_dir, + update_password=mutate_password, ) captured_old: list[str] = [] original_reencrypt = wallet_mod._reencrypt_agent_key + def capture(*, key_path: Any, old_password: str, new_password: str) -> None: captured_old.append(old_password) + wallet_mod._reencrypt_agent_key = capture try: (keys_dir / "0xagent").write_text("{}", encoding="utf-8") wallet_mod.align_quickstart_password( - qs_app=qs_app, qs_wallet=qs_wallet, new_password="real-new", + qs_app=qs_app, + qs_wallet=qs_wallet, + new_password="real-new", ) finally: wallet_mod._reencrypt_agent_key = original_reencrypt @@ -1231,7 +1328,9 @@ def capture(*, key_path: Any, old_password: str, new_password: str) -> None: ) def test_invokes_user_account_alignment_after_rotation( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """After rotating the wallet+keys to Pearl's password, `user.json` must be re-aligned. Without this, qs's `user.json` @@ -1247,24 +1346,29 @@ def test_invokes_user_account_alignment_after_rotation( wallets_dir.mkdir(parents=True) qs_wallet = types.SimpleNamespace( - path=wallets_dir, update_password=lambda new: None, + path=wallets_dir, + update_password=lambda new: None, ) qs_app = types.SimpleNamespace( password="old", keys_manager=types.SimpleNamespace( - path=operate_root / "absent_keys", password="old", + path=operate_root / "absent_keys", + password="old", ), user_account=None, ) align_calls: list[Any] = [] monkeypatch.setattr( - wallet_mod, "align_user_account_to_wallet", + wallet_mod, + "align_user_account_to_wallet", lambda app: align_calls.append((app, app.password)), ) wallet_mod.align_quickstart_password( - qs_app=qs_app, qs_wallet=qs_wallet, new_password="new", + qs_app=qs_app, + qs_wallet=qs_wallet, + new_password="new", ) assert align_calls == [(qs_app, "new")], ( @@ -1274,7 +1378,9 @@ def test_invokes_user_account_alignment_after_rotation( ) def test_alignment_failure_warns_about_keys_already_rotated( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """If `align_user_account_to_wallet` raises AFTER the wallet+keys are already rotated, the warn message must NOT instruct the user @@ -1291,12 +1397,14 @@ def test_alignment_failure_warns_about_keys_already_rotated( wallets_dir.mkdir(parents=True) qs_wallet = types.SimpleNamespace( - path=wallets_dir, update_password=lambda new: None, + path=wallets_dir, + update_password=lambda new: None, ) qs_app = types.SimpleNamespace( password="old", keys_manager=types.SimpleNamespace( - path=operate_root / "absent_keys", password="old", + path=operate_root / "absent_keys", + password="old", ), user_account=None, ) @@ -1317,13 +1425,18 @@ def test_alignment_failure_warns_about_keys_already_rotated( def fail_alignment(app: Any) -> None: password_at_alignment.append(app.password) raise OSError("disk full") + monkeypatch.setattr( - wallet_mod, "align_user_account_to_wallet", fail_alignment, + wallet_mod, + "align_user_account_to_wallet", + fail_alignment, ) with pytest.raises(OSError, match="disk full"): wallet_mod.align_quickstart_password( - qs_app=qs_app, qs_wallet=qs_wallet, new_password="new", + qs_app=qs_app, + qs_wallet=qs_wallet, + new_password="new", ) # Structural placement assertion: alignment ran on the @@ -1339,15 +1452,14 @@ def fail_alignment(app: Any) -> None: assert any("Do NOT restore" in m for m in warn_calls), warn_calls # And must NOT print the snapshot-recovery `rm -rf wallets/ keys/` # message — that'd point the user at the wrong recovery. - assert not any( - "Recover with: rm -rf" in m for m in warn_calls - ), warn_calls + assert not any("Recover with: rm -rf" in m for m in warn_calls), warn_calls # --------------------------------------------------------------------------- # filesystem.py — extras # --------------------------------------------------------------------------- + class TestFilesystemExtras: def test_rename_source_for_rollback(self, tmp_path: Path) -> None: src_root = tmp_path / ".operate" @@ -1389,9 +1501,11 @@ def test_fix_root_ownership_runs_chown( pdata = tmp_path / "services" / "sc-aaa" / "persistent_data" pdata.mkdir(parents=True) invoked: list[list[str]] = [] + def fake_run(cmd: list[str], **kw: Any) -> Any: invoked.append(cmd) return subprocess.CompletedProcess(args=cmd, returncode=0) + monkeypatch.setattr(filesystem.subprocess, "run", fake_run) store = detect.OperateStore(root=tmp_path) filesystem.fix_root_ownership(store) @@ -1402,8 +1516,10 @@ def test_fix_root_ownership_chown_failure_raises( ) -> None: pdata = tmp_path / "services" / "sc-aaa" / "persistent_data" pdata.mkdir(parents=True) + def fake_run(cmd: list[str], **kw: Any) -> Any: raise subprocess.CalledProcessError(1, cmd) + monkeypatch.setattr(filesystem.subprocess, "run", fake_run) store = detect.OperateStore(root=tmp_path.resolve()) # Must raise — caller would otherwise corrupt the destination. @@ -1411,7 +1527,9 @@ def fake_run(cmd: list[str], **kw: Any) -> Any: filesystem.fix_root_ownership(store) def test_fix_root_ownership_partial_failure_lists_audit_trail( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """When chown succeeds on the first dir then fails on the second, the raised message MUST list the successful one under 'Already @@ -1427,12 +1545,14 @@ def test_fix_root_ownership_partial_failure_lists_audit_trail( # guaranteed — make the LAST chown call fail and capture the # iteration order so the test reasons about it directly. seen: list[str] = [] + def fake_run(cmd: list[str], **kw: Any) -> Any: target = cmd[-1] seen.append(target) if len(seen) == 2: # second dir, whichever it is raise subprocess.CalledProcessError(1, cmd) return subprocess.CompletedProcess(args=cmd, returncode=0) + monkeypatch.setattr(filesystem.subprocess, "run", fake_run) store = detect.OperateStore(root=tmp_path.resolve()) @@ -1466,9 +1586,11 @@ def test_fix_root_ownership_skips_non_service_entries( (services_dir / ".DS_Store").write_text("junk") (services_dir / "stray-dir").mkdir() invoked_targets: list[str] = [] + def fake_run(cmd: list[str], **kw: Any) -> Any: invoked_targets.append(cmd[-1]) return subprocess.CompletedProcess(args=cmd, returncode=0) + monkeypatch.setattr(filesystem.subprocess, "run", fake_run) store = detect.OperateStore(root=tmp_path.resolve()) filesystem.fix_root_ownership(store) @@ -1558,7 +1680,9 @@ def _fake_svc( return svc def test_rewrites_every_chain_and_calls_store_once( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: store_calls: list[str] = [] svc = self._fake_svc( @@ -1568,19 +1692,22 @@ def test_rewrites_every_chain_and_calls_store_once( ) store = detect.OperateStore(root=tmp_path) monkeypatch.setattr( - detect.OperateStore, "services", lambda self: [svc], + detect.OperateStore, + "services", + lambda self: [svc], ) updated = filesystem.reset_services_staking_to_no_staking(store) assert updated == ["sc-aaa"] for chain_config in svc.chain_configs.values(): assert ( - chain_config.chain_data.user_params.staking_program_id - == "no_staking" + chain_config.chain_data.user_params.staking_program_id == "no_staking" ) assert store_calls == ["sc-aaa"], "Service.store() called once per service" def test_already_no_staking_is_noop_and_skips_store( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Re-running a migration must not write already-`no_staking` configs — that would touch the file's mtime for no reason and @@ -1592,7 +1719,9 @@ def test_already_no_staking_is_noop_and_skips_store( store_calls=store_calls, ) monkeypatch.setattr( - detect.OperateStore, "services", lambda self: [svc], + detect.OperateStore, + "services", + lambda self: [svc], ) updated = filesystem.reset_services_staking_to_no_staking( detect.OperateStore(root=tmp_path), @@ -1601,7 +1730,9 @@ def test_already_no_staking_is_noop_and_skips_store( assert store_calls == [] def test_partial_reset_when_some_chains_already_no_staking( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """A service spanning multiple chains where one is already `no_staking` and another is not still triggers `store()` — the @@ -1613,7 +1744,9 @@ def test_partial_reset_when_some_chains_already_no_staking( store_calls=store_calls, ) monkeypatch.setattr( - detect.OperateStore, "services", lambda self: [svc], + detect.OperateStore, + "services", + lambda self: [svc], ) updated = filesystem.reset_services_staking_to_no_staking( detect.OperateStore(root=tmp_path), @@ -1623,25 +1756,32 @@ def test_partial_reset_when_some_chains_already_no_staking( # Both chains end up `no_staking` regardless of pre-state. for chain_config in svc.chain_configs.values(): assert ( - chain_config.chain_data.user_params.staking_program_id - == "no_staking" + chain_config.chain_data.user_params.staking_program_id == "no_staking" ) def test_returns_empty_when_no_services( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( - detect.OperateStore, "services", lambda self: [], + detect.OperateStore, + "services", + lambda self: [], + ) + assert ( + filesystem.reset_services_staking_to_no_staking( + detect.OperateStore(root=tmp_path), + ) + == [] ) - assert filesystem.reset_services_staking_to_no_staking( - detect.OperateStore(root=tmp_path), - ) == [] # --------------------------------------------------------------------------- # prompts.py — extras # --------------------------------------------------------------------------- + class TestPromptsExtras: def test_password_attended_uses_getpass( self, monkeypatch: pytest.MonkeyPatch @@ -1650,6 +1790,7 @@ def test_password_attended_uses_getpass( # when ATTENDED=true regardless of isatty. monkeypatch.setenv("ATTENDED", "true") import getpass + monkeypatch.setattr(getpass, "getpass", lambda prompt: "secret") assert prompts.password("pw: ") == "secret" @@ -1701,7 +1842,7 @@ def test_fatal_exits_with_code(self) -> None: def test_backup_suffix_format(self) -> None: s = prompts.backup_suffix() assert s.startswith("bak.") - assert s[len("bak."):].isdigit() + assert s[len("bak.") :].isdigit() def test_ask_password_prompts_warning_message( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] @@ -1710,7 +1851,9 @@ def test_ask_password_prompts_warning_message( answers = iter(["w1", "w2"]) monkeypatch.setattr(prompts, "password", lambda _: next(answers)) result = prompts.ask_password_validating( - prompt="pw: ", validate=lambda _: False, attempts=2, + prompt="pw: ", + validate=lambda _: False, + attempts=2, ) assert result is None captured = capsys.readouterr().out @@ -1721,6 +1864,7 @@ def test_ask_password_prompts_warning_message( # stop.py # --------------------------------------------------------------------------- + class TestStop: @staticmethod def _patch_middleware_stop( @@ -1742,7 +1886,9 @@ def fake_stop_service(operate: Any, config_path: str) -> None: monkeypatch.setitem(sys.modules, "operate", operate_pkg) monkeypatch.setitem(sys.modules, "operate.quickstart", fake_quickstart) monkeypatch.setitem( - sys.modules, "operate.quickstart.stop_service", fake_stop, + sys.modules, + "operate.quickstart.stop_service", + fake_stop, ) return called @@ -1787,7 +1933,8 @@ def capture_env(o: Any, c: str) -> None: stop.stop_via_middleware(operate=op, config_path="cfg.json") assert seen_env == { - "OPERATE_PASSWORD": "pw_wallet", "ATTENDED": "false", + "OPERATE_PASSWORD": "pw_wallet", + "ATTENDED": "false", } # Env restored to "absent" after the call — middleware's # unattended mode must not leak into anything else. @@ -1866,6 +2013,7 @@ def test_force_remove_known_containers_no_leftovers( self, monkeypatch: pytest.MonkeyPatch ) -> None: from scripts.pearl_migration import stop + monkeypatch.setattr(stop, "docker_quickstart_containers", lambda: []) assert stop.force_remove_known_containers() == [] @@ -1873,10 +2021,12 @@ def test_force_remove_known_containers_removes( self, monkeypatch: pytest.MonkeyPatch ) -> None: from scripts.pearl_migration import stop + monkeypatch.setattr(stop, "docker_quickstart_containers", lambda: ["abci0"]) invoked: list[list[str]] = [] - monkeypatch.setattr(stop.subprocess, "run", - lambda cmd, **kw: invoked.append(cmd)) + monkeypatch.setattr( + stop.subprocess, "run", lambda cmd, **kw: invoked.append(cmd) + ) result = stop.force_remove_known_containers() assert result == ["abci0"] assert invoked and "rm" in invoked[0] @@ -1885,9 +2035,12 @@ def test_force_remove_known_containers_handles_missing_docker( self, monkeypatch: pytest.MonkeyPatch ) -> None: from scripts.pearl_migration import stop + monkeypatch.setattr(stop, "docker_quickstart_containers", lambda: ["abci0"]) + def boom(*_a: Any, **_k: Any) -> None: raise FileNotFoundError("nope") + monkeypatch.setattr(stop.subprocess, "run", boom) assert stop.force_remove_known_containers() == [] @@ -1899,12 +2052,16 @@ def test_force_remove_known_containers_raises_on_nonzero_exit( "don't proceed against still-running deployment" guarantee depends on it.""" from scripts.pearl_migration import stop + monkeypatch.setattr(stop, "docker_quickstart_containers", lambda: ["abci0"]) + def boom(*_a: Any, **_k: Any) -> None: raise subprocess.CalledProcessError( - returncode=1, cmd=["docker", "rm", "-f", "abci0"], + returncode=1, + cmd=["docker", "rm", "-f", "abci0"], stderr=b"permission denied", ) + monkeypatch.setattr(stop.subprocess, "run", boom) with pytest.raises(RuntimeError, match="exited 1.*permission denied"): stop.force_remove_known_containers() @@ -1914,6 +2071,7 @@ def boom(*_a: Any, **_k: Any) -> None: # transfer.py # --------------------------------------------------------------------------- + class TestPostConditionRetry: """`_read_with_retry` and `PostConditionUnknown` make the post-tx verification reads tolerant of transient RPC errors. The on-chain @@ -1925,25 +2083,31 @@ def test_returns_value_on_first_success( self, monkeypatch: pytest.MonkeyPatch ) -> None: from scripts.pearl_migration import transfer + monkeypatch.setattr(transfer.time, "sleep", lambda *a, **k: None) result = transfer._read_with_retry(lambda: "ok", tx_hash="0xtx") assert result == "ok" - def test_retries_then_succeeds( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_retries_then_succeeds(self, monkeypatch: pytest.MonkeyPatch) -> None: from scripts.pearl_migration import transfer + monkeypatch.setattr(transfer.time, "sleep", lambda *a, **k: None) # Network-shaped failures (ConnectionError, TimeoutError) are # retried; programming bugs are NOT — see the test below. - attempts = iter([ - ConnectionError("rpc 502"), TimeoutError("rpc slow"), "good", - ]) + attempts = iter( + [ + ConnectionError("rpc 502"), + TimeoutError("rpc slow"), + "good", + ] + ) + def step() -> str: v = next(attempts) if isinstance(v, Exception): raise v return v + result = transfer._read_with_retry(step, tx_hash="0xtx", attempts=3) assert result == "good" @@ -1955,14 +2119,19 @@ def test_propagates_programming_bugs_without_retry( immediately so the user sees a real traceback rather than the misleading 'verify on a block explorer' framing.""" from scripts.pearl_migration import transfer + sleeps: list[float] = [] monkeypatch.setattr( - transfer.time, "sleep", lambda s: sleeps.append(s), + transfer.time, + "sleep", + lambda s: sleeps.append(s), ) calls = {"n": 0} + def buggy() -> str: calls["n"] += 1 raise TypeError("regression: ownerOf signature changed") + with pytest.raises(TypeError, match="regression"): transfer._read_with_retry(buggy, tx_hash="0xtx", attempts=3) assert calls["n"] == 1, "must NOT retry programming bugs" @@ -1970,6 +2139,7 @@ def buggy() -> str: def test_attempts_must_be_at_least_one(self) -> None: from scripts.pearl_migration import transfer + with pytest.raises(ValueError, match="attempts must be >= 1"): transfer._read_with_retry(lambda: "x", tx_hash="0xtx", attempts=0) @@ -1986,6 +2156,7 @@ def test_rpc_exception_types_tuple_covers_stdlib_and_optional_libs( import requests.exceptions import web3.exceptions from scripts.pearl_migration import transfer + excs = transfer._RPC_EXCEPTION_TYPES # Stdlib bases — always must be present. assert ConnectionError in excs @@ -1995,6 +2166,7 @@ def test_rpc_exception_types_tuple_covers_stdlib_and_optional_libs( # Exception (not the builtin alias as in 3.11+) and the project # supports 3.10 — must be in the tuple regardless of runtime. import asyncio + assert asyncio.TimeoutError in excs # Third-party RPC roots — load-bearing for retry semantics. assert web3.exceptions.Web3Exception in excs @@ -2008,12 +2180,17 @@ def test_raises_post_condition_unknown_after_exhaustion( (PostConditionUnknown). Re-running blindly in the latter case would fail because the on-chain side already mined.""" from scripts.pearl_migration import transfer + monkeypatch.setattr(transfer.time, "sleep", lambda *a, **k: None) + def always_fail() -> str: raise ConnectionError("rpc 503") + with pytest.raises(transfer.PostConditionUnknown) as excinfo: transfer._read_with_retry( - always_fail, tx_hash="0xMINED", attempts=3, + always_fail, + tx_hash="0xMINED", + attempts=3, ) msg = str(excinfo.value) assert "0xMINED" in msg @@ -2032,11 +2209,14 @@ def test_transfer_service_nft_encodes_and_dispatches( # Safe — this is the post-condition the function reads to confirm # the transfer landed on-chain. owner_of_calls: list[int] = [] + class _OwnerOfCallable: def __init__(self, token_id: int) -> None: owner_of_calls.append(token_id) + def call(self) -> str: return "0xpl" + functions = types.SimpleNamespace(ownerOf=_OwnerOfCallable) instance = types.SimpleNamespace( encode_abi=lambda abi_element_identifier, args: ( @@ -2055,9 +2235,13 @@ def call(self) -> str: operate_pkg = types.ModuleType("operate") operate_utils = types.ModuleType("operate.utils") operate_gnosis = types.ModuleType("operate.utils.gnosis") - def fake_send(txd: bytes, safe: str, ledger_api: Any, crypto: Any, to: str) -> str: + + def fake_send( + txd: bytes, safe: str, ledger_api: Any, crypto: Any, to: str + ) -> str: sent.update(txd=txd, safe=safe, to=to) return "0xtxhash" + operate_gnosis.send_safe_txs = fake_send # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "operate", operate_pkg) monkeypatch.setitem(sys.modules, "operate.utils", operate_utils) @@ -2090,8 +2274,12 @@ def test_transfer_service_nft_raises_when_owner_unchanged( from scripts.pearl_migration import transfer class _OwnerOfCallable: - def __init__(self, token_id: int) -> None: pass - def call(self) -> str: return "0xqs" # unchanged: inner call reverted + def __init__(self, token_id: int) -> None: + pass + + def call(self) -> str: + return "0xqs" # unchanged: inner call reverted + instance = types.SimpleNamespace( encode_abi=lambda abi_element_identifier, args: "0xdeadbeef", functions=types.SimpleNamespace(ownerOf=_OwnerOfCallable), @@ -2113,9 +2301,11 @@ def call(self) -> str: return "0xqs" # unchanged: inner call reverted with pytest.raises(RuntimeError, match="still reports owner"): transfer.transfer_service_nft( - ledger_api=object(), crypto=object(), + ledger_api=object(), + crypto=object(), service_registry_address="0xreg", - qs_master_safe="0xqs", pearl_master_safe="0xpl", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", service_id=99, ) @@ -2136,6 +2326,7 @@ def test_swap_service_safe_owner_uses_approve_hash_then_exec_pattern( autonomy_chain = types.ModuleType("autonomy.chain") autonomy_base = types.ModuleType("autonomy.chain.base") encoded: list[tuple[str, list]] = [] + class _FakeInstance: def encode_abi(self, *, abi_element_identifier: str, args: list) -> str: encoded.append((abi_element_identifier, args)) @@ -2146,11 +2337,13 @@ def encode_abi(self, *, abi_element_identifier: str, args: list) -> str: "approveHash": "0x2200", "execTransaction": "0x3300", }[abi_element_identifier] + class _FakeGnosisSafe: @staticmethod def get_instance(*, ledger_api: Any, contract_address: str) -> Any: assert contract_address == "0xservice" return _FakeInstance() + @staticmethod def get_raw_safe_transaction_hash(**kw: Any) -> dict: # Reflect args back so the test asserts the inner-tx-hash @@ -2160,6 +2353,7 @@ def get_raw_safe_transaction_hash(**kw: Any) -> dict: assert kw["to_address"] == "0xservice" assert kw["data"] == bytes.fromhex("1100") return {"tx_hash": "0xabcdef"} + autonomy_base.registry_contracts = types.SimpleNamespace( # type: ignore[attr-defined] gnosis_safe=_FakeGnosisSafe(), ) @@ -2177,9 +2371,11 @@ def get_raw_safe_transaction_hash(**kw: Any) -> dict: operate_protocol.get_packed_signature_for_approved_hash = ( # type: ignore[attr-defined] lambda *, owners: b"PACKED_SIG_FOR_" + owners[0].encode() ) + class _SafeOperation: class CALL: value = 0 + operate_gnosis.SafeOperation = _SafeOperation # type: ignore[attr-defined] operate_gnosis.get_prev_owner = ( # type: ignore[attr-defined] lambda *, ledger_api, safe, owner: "0xprev" @@ -2199,15 +2395,19 @@ class CALL: monkeypatch.setitem(sys.modules, "operate.utils.gnosis", operate_gnosis) transfer.swap_service_safe_owner( - ledger_api="LA", crypto="CR", + ledger_api="LA", + crypto="CR", service_safe="0xservice", - old_owner="0xqsafe", new_owner="0xpearl", + old_owner="0xqsafe", + new_owner="0xpearl", ) # Three encode_abi calls: swapOwner (for the inner-hash and exec # data fields), approveHash, execTransaction. assert [name for name, _ in encoded] == [ - "swapOwner", "approveHash", "execTransaction", + "swapOwner", + "approveHash", + "execTransaction", ] assert encoded[0][1] == ["0xprev", "0xqsafe", "0xpearl"] assert encoded[1][1] == ["0xabcdef"] # approveHash(inner_tx_hash) @@ -2240,15 +2440,20 @@ def test_swap_service_safe_owner_raises_when_post_condition_unmet( autonomy_pkg = types.ModuleType("autonomy") autonomy_chain = types.ModuleType("autonomy.chain") autonomy_base = types.ModuleType("autonomy.chain.base") + class _FakeInstance: def encode_abi(self, *, abi_element_identifier: str, args: list) -> str: return "0x00" + class _FakeGnosisSafe: @staticmethod - def get_instance(**kw: Any) -> Any: return _FakeInstance() + def get_instance(**kw: Any) -> Any: + return _FakeInstance() + @staticmethod def get_raw_safe_transaction_hash(**kw: Any) -> dict: return {"tx_hash": "0xabcdef"} + autonomy_base.registry_contracts = types.SimpleNamespace( # type: ignore[attr-defined] gnosis_safe=_FakeGnosisSafe(), ) @@ -2264,9 +2469,11 @@ def get_raw_safe_transaction_hash(**kw: Any) -> dict: operate_protocol.get_packed_signature_for_approved_hash = ( # type: ignore[attr-defined] lambda *, owners: b"" ) + class _SafeOperation: class CALL: value = 0 + operate_gnosis.SafeOperation = _SafeOperation # type: ignore[attr-defined] operate_gnosis.get_prev_owner = ( # type: ignore[attr-defined] lambda *, ledger_api, safe, owner: "0xprev" @@ -2289,9 +2496,11 @@ class CALL: with pytest.raises(RuntimeError, match="still has owners"): transfer.swap_service_safe_owner( - ledger_api="LA", crypto="CR", + ledger_api="LA", + crypto="CR", service_safe="0xservice", - old_owner="0xqsafe", new_owner="0xpearl", + old_owner="0xqsafe", + new_owner="0xpearl", ) @@ -2299,6 +2508,7 @@ class CALL: # migrate_to_pearl.py — orchestrator # --------------------------------------------------------------------------- + @pytest.fixture def orch(monkeypatch: pytest.MonkeyPatch): """Fresh import of the orchestrator with `operate.*` stubs in place. @@ -2315,11 +2525,14 @@ def orch(monkeypatch: pytest.MonkeyPatch): class FakeChain: GNOSIS = None # filled in below + def __init__(self, value: str) -> None: self.value = value self.name = value.upper() + def __eq__(self, other: object) -> bool: return isinstance(other, FakeChain) and self.value == other.value + def __hash__(self) -> int: return hash(self.value) @@ -2356,10 +2569,12 @@ class FakeLedgerType: # importing — otherwise a previous test's `m` (with stale Chain # binding) silently leaks into this test. import scripts.pearl_migration as _pkg + sys.modules.pop("scripts.pearl_migration.migrate_to_pearl", None) if hasattr(_pkg, "migrate_to_pearl"): delattr(_pkg, "migrate_to_pearl") from scripts.pearl_migration import migrate_to_pearl as m + return m, FakeChain, FakeOnChainState @@ -2384,54 +2599,78 @@ def test_no_services_aborts(self, orch: Any, tmp_path: Path) -> None: m._select_services(store, None) def test_single_service_auto_selected( - self, orch: Any, tmp_path: Path, patch_service_load: dict[Path, Any], + self, + orch: Any, + tmp_path: Path, + patch_service_load: dict[Path, Any], ) -> None: m, *_ = orch _write_service(tmp_path, "sc-aaa", name="A") - patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = ( - _fake_service("sc-aaa", name="A") + patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = _fake_service( + "sc-aaa", name="A" ) store = detect.OperateStore(root=tmp_path) sel = m._select_services(store, None) assert [s.service_config_id for s in sel] == ["sc-aaa"] def test_multi_service_picks_one( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, patch_service_load: dict[Path, Any], ) -> None: m, *_ = orch _write_service(tmp_path, "sc-aaa", name="A") _write_service(tmp_path, "sc-bbb", name="B") - patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = _fake_service("sc-aaa", name="A") - patch_service_load[(tmp_path / "services/sc-bbb").resolve()] = _fake_service("sc-bbb", name="B") + patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = _fake_service( + "sc-aaa", name="A" + ) + patch_service_load[(tmp_path / "services/sc-bbb").resolve()] = _fake_service( + "sc-bbb", name="B" + ) store = detect.OperateStore(root=tmp_path) monkeypatch.setattr("builtins.input", lambda _: "2") sel = m._select_services(store, None) assert [s.service_config_id for s in sel] == ["sc-bbb"] def test_multi_service_picks_all( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, patch_service_load: dict[Path, Any], ) -> None: m, *_ = orch _write_service(tmp_path, "sc-aaa", name="A") _write_service(tmp_path, "sc-bbb", name="B") - patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = _fake_service("sc-aaa", name="A") - patch_service_load[(tmp_path / "services/sc-bbb").resolve()] = _fake_service("sc-bbb", name="B") + patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = _fake_service( + "sc-aaa", name="A" + ) + patch_service_load[(tmp_path / "services/sc-bbb").resolve()] = _fake_service( + "sc-bbb", name="B" + ) store = detect.OperateStore(root=tmp_path) monkeypatch.setattr("builtins.input", lambda _: "3") sel = m._select_services(store, None) assert [s.service_config_id for s in sel] == ["sc-aaa", "sc-bbb"] def test_multi_service_retries_on_bad_input( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, patch_service_load: dict[Path, Any], ) -> None: m, *_ = orch _write_service(tmp_path, "sc-aaa", name="A") _write_service(tmp_path, "sc-bbb", name="B") - patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = _fake_service("sc-aaa", name="A") - patch_service_load[(tmp_path / "services/sc-bbb").resolve()] = _fake_service("sc-bbb", name="B") + patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = _fake_service( + "sc-aaa", name="A" + ) + patch_service_load[(tmp_path / "services/sc-bbb").resolve()] = _fake_service( + "sc-bbb", name="B" + ) store = detect.OperateStore(root=tmp_path) answers = iter(["foo", "9", "1"]) monkeypatch.setattr("builtins.input", lambda _: next(answers)) @@ -2439,7 +2678,10 @@ def test_multi_service_retries_on_bad_input( assert [s.service_config_id for s in sel] == ["sc-aaa"] def test_multi_service_eof_on_stdin_aborts_cleanly( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, patch_service_load: dict[Path, Any], ) -> None: """Closed/piped stdin (CI, automation) must NOT spin in `input()` @@ -2448,22 +2690,31 @@ def test_multi_service_eof_on_stdin_aborts_cleanly( m, *_ = orch _write_service(tmp_path, "sc-aaa", name="A") _write_service(tmp_path, "sc-bbb", name="B") - patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = _fake_service("sc-aaa", name="A") - patch_service_load[(tmp_path / "services/sc-bbb").resolve()] = _fake_service("sc-bbb", name="B") + patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = _fake_service( + "sc-aaa", name="A" + ) + patch_service_load[(tmp_path / "services/sc-bbb").resolve()] = _fake_service( + "sc-bbb", name="B" + ) store = detect.OperateStore(root=tmp_path) + def boom_input(_: str) -> str: raise EOFError + monkeypatch.setattr("builtins.input", boom_input) with pytest.raises(SystemExit): m._select_services(store, None) def test_config_path_matches_by_name( - self, orch: Any, tmp_path: Path, patch_service_load: dict[Path, Any], + self, + orch: Any, + tmp_path: Path, + patch_service_load: dict[Path, Any], ) -> None: m, *_ = orch _write_service(tmp_path, "sc-aaa", name="Trader Agent") - patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = ( - _fake_service("sc-aaa", name="Trader Agent") + patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = _fake_service( + "sc-aaa", name="Trader Agent" ) cfg = tmp_path / "cfg.json" cfg.write_text(json.dumps({"name": "Trader Agent"})) @@ -2472,19 +2723,26 @@ def test_config_path_matches_by_name( assert [s.service_config_id for s in sel] == ["sc-aaa"] def test_config_path_matches_by_hash( - self, orch: Any, tmp_path: Path, patch_service_load: dict[Path, Any], + self, + orch: Any, + tmp_path: Path, + patch_service_load: dict[Path, Any], ) -> None: m, *_ = orch # Different name in service config — match by hash instead. sdir = tmp_path / "services" / "sc-aaa" sdir.mkdir(parents=True) - (sdir / "config.json").write_text(json.dumps({ - "name": "Different Name", - "agent_addresses": [], - "hash": "bafy-test", - })) - patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = ( - _fake_service("sc-aaa", name="Different Name", hash_="bafy-test") + (sdir / "config.json").write_text( + json.dumps( + { + "name": "Different Name", + "agent_addresses": [], + "hash": "bafy-test", + } + ) + ) + patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = _fake_service( + "sc-aaa", name="Different Name", hash_="bafy-test" ) cfg = tmp_path / "cfg.json" cfg.write_text(json.dumps({"name": "Trader Agent", "hash": "bafy-test"})) @@ -2493,12 +2751,15 @@ def test_config_path_matches_by_hash( assert [s.service_config_id for s in sel] == ["sc-aaa"] def test_config_path_no_match_aborts( - self, orch: Any, tmp_path: Path, patch_service_load: dict[Path, Any], + self, + orch: Any, + tmp_path: Path, + patch_service_load: dict[Path, Any], ) -> None: m, *_ = orch _write_service(tmp_path, "sc-aaa", name="A") - patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = ( - _fake_service("sc-aaa", name="A", hash_="bafy-test") + patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = _fake_service( + "sc-aaa", name="A", hash_="bafy-test" ) cfg = tmp_path / "cfg.json" cfg.write_text(json.dumps({"name": "Z", "hash": "h"})) @@ -2507,12 +2768,15 @@ def test_config_path_no_match_aborts( m._select_services(store, str(cfg)) def test_config_path_unreadable_aborts( - self, orch: Any, tmp_path: Path, patch_service_load: dict[Path, Any], + self, + orch: Any, + tmp_path: Path, + patch_service_load: dict[Path, Any], ) -> None: m, *_ = orch _write_service(tmp_path, "sc-aaa", name="A") - patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = ( - _fake_service("sc-aaa", name="A") + patch_service_load[(tmp_path / "services/sc-aaa").resolve()] = _fake_service( + "sc-aaa", name="A" ) store = detect.OperateStore(root=tmp_path) with pytest.raises(SystemExit): @@ -2545,7 +2809,10 @@ def test_pearl_running_aborts( m._preflight(d) def test_with_leftover_containers_warns( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: m, *_ = orch @@ -2569,7 +2836,9 @@ def test_no_wallet_aborts(self, orch: Any, tmp_path: Path) -> None: @staticmethod def _stub_master_wallet_manager( - monkeypatch: pytest.MonkeyPatch, *, valid_password: Optional[str] = None, + monkeypatch: pytest.MonkeyPatch, + *, + valid_password: Optional[str] = None, ) -> list[str]: """Replace `MasterWalletManager` so `is_password_valid` is observable and the constructor never touches disk. Returns the list it appends to.""" @@ -2585,7 +2854,8 @@ def is_password_valid(self, pw: str) -> bool: operate_pkg = sys.modules.setdefault("operate", types.ModuleType("operate")) operate_wallet = sys.modules.setdefault( - "operate.wallet", types.ModuleType("operate.wallet"), + "operate.wallet", + types.ModuleType("operate.wallet"), ) fake_master = types.ModuleType("operate.wallet.master") fake_master.MasterWalletManager = FakeMWM # type: ignore[attr-defined] @@ -2605,7 +2875,9 @@ def test_password_failure_aborts_without_building_app( self._stub_master_wallet_manager(monkeypatch, valid_password=None) # OperateApp should never be constructed on a bad password. operate_app_calls = [] - operate_cli = sys.modules.setdefault("operate.cli", types.ModuleType("operate.cli")) + operate_cli = sys.modules.setdefault( + "operate.cli", types.ModuleType("operate.cli") + ) operate_cli.OperateApp = lambda **kw: operate_app_calls.append(kw) or types.SimpleNamespace() # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "operate.cli", operate_cli) @@ -2634,9 +2906,13 @@ def test_happy_path_validates_then_builds_app( # `_load_wallet` calls it (next test) without diving into its # internals. fake_app = types.SimpleNamespace( - wallet_manager=fake_wm, password=None, user_account=None, + wallet_manager=fake_wm, + password=None, + user_account=None, + ) + operate_cli = sys.modules.setdefault( + "operate.cli", types.ModuleType("operate.cli") ) - operate_cli = sys.modules.setdefault("operate.cli", types.ModuleType("operate.cli")) operate_cli.OperateApp = lambda **kw: fake_app # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "operate.cli", operate_cli) @@ -2645,6 +2921,7 @@ def fake_ask(prompt: str, validate, **kw: Any) -> str: assert validate("wrong") is False assert validate("thepw") is True return "thepw" + monkeypatch.setattr(m, "ask_password_validating", fake_ask) app, wallet = m._load_wallet(store, "label") @@ -2670,15 +2947,20 @@ def test_invokes_user_account_alignment( fake_wallet = types.SimpleNamespace(name="loaded") fake_wm = types.SimpleNamespace(load=lambda lt: fake_wallet) fake_app = types.SimpleNamespace( - wallet_manager=fake_wm, password=None, user_account=None, + wallet_manager=fake_wm, + password=None, + user_account=None, + ) + operate_cli = sys.modules.setdefault( + "operate.cli", types.ModuleType("operate.cli") ) - operate_cli = sys.modules.setdefault("operate.cli", types.ModuleType("operate.cli")) operate_cli.OperateApp = lambda **kw: fake_app # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "operate.cli", operate_cli) align_calls: list[Any] = [] monkeypatch.setattr( - m, "align_user_account_to_wallet", + m, + "align_user_account_to_wallet", lambda app: align_calls.append(app), ) monkeypatch.setattr(m, "ask_password_validating", lambda **kw: "thepw") @@ -2739,7 +3021,10 @@ def test_real_run_copies_and_renames( assert outcome.is_complete is True def test_dry_run_with_existing_pearl_backup_print( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: # Cover the "[dry-run] would back up" branch by ensuring pearl exists. @@ -2757,7 +3042,10 @@ def test_dry_run_with_existing_pearl_backup_print( assert "Would back up" in capsys.readouterr().out def test_mode_a_re_probe_failure_is_fatal( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Mode A re-probes via `docker_quickstart_containers` after `force_remove_known_containers()` returned cleanly. If `docker ps` @@ -2777,15 +3065,20 @@ def test_mode_a_re_probe_failure_is_fatal( mode=detect.Mode.FRESH_COPY, ) monkeypatch.setattr(m, "force_remove_known_containers", lambda: []) + def boom() -> list: raise RuntimeError("docker daemon hung") + monkeypatch.setattr(m, "docker_quickstart_containers", boom) monkeypatch.setattr(m, "fix_root_ownership", lambda store: None) with pytest.raises(SystemExit): m._run_mode_a(disc=d, dry_run=False) def test_mode_a_re_probe_finds_remaining_containers_is_fatal( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """`force_remove_known_containers()` may return cleanly even when the docker daemon refused the rm with a non-zero exit (the @@ -2809,7 +3102,10 @@ def test_mode_a_re_probe_finds_remaining_containers_is_fatal( m._run_mode_a(disc=d, dry_run=False) def test_force_cleanup_failure_is_fatal_in_mode_a( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Mode A has no per-service aggregation: an unstoppable deployment MUST be `fatal()`, not silently proceed to copy a @@ -2825,15 +3121,20 @@ def test_force_cleanup_failure_is_fatal_in_mode_a( pearl=detect.OperateStore(root=pl_root.resolve()), mode=detect.Mode.FRESH_COPY, ) + def boom() -> list: raise subprocess.TimeoutExpired(cmd=["docker", "rm"], timeout=30) + monkeypatch.setattr(m, "force_remove_known_containers", boom) monkeypatch.setattr(m, "fix_root_ownership", lambda store: None) with pytest.raises(SystemExit): m._run_mode_a(disc=d, dry_run=False) def test_real_run_invokes_staking_reset_against_dest_store( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Mode A (no on-chain step) MUST also leave Pearl with `staking_program_id = no_staking` — symmetric with Mode B's @@ -2860,17 +3161,22 @@ def test_real_run_invokes_staking_reset_against_dest_store( # still at qs_root for any subsequent cleanup). events: list[tuple[str, Any]] = [] original_copy = m.fresh_copy_store + def spy_copy(src: Any, dest: Path) -> None: events.append(("copy", dest)) return original_copy(src, dest) + def spy_reset(store: Any) -> list[str]: events.append(("reset", store.root)) assert pl_root.resolve().exists(), "reset must run after copy" return ["sc-aaa"] + original_rename = m.rename_source_for_rollback + def spy_rename(src: Any) -> Path: events.append(("rename", src.root)) return original_rename(src) + monkeypatch.setattr(m, "fresh_copy_store", spy_copy) monkeypatch.setattr(m, "reset_services_staking_to_no_staking", spy_reset) monkeypatch.setattr(m, "rename_source_for_rollback", spy_rename) @@ -2883,7 +3189,10 @@ def spy_rename(src: Any) -> Path: assert events[1][1] == pl_root.resolve() def test_real_run_backs_up_existing_empty_pearl( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """Covers the non-dry-run branch where Pearl dir exists but has no wallet.""" @@ -2983,7 +3292,9 @@ def force_update(pw: str) -> None: assert forced == {} def test_force_updates_when_diverged( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], ) -> None: """Diverged install: `user.json`'s hash is for `pw_user`, wallet is for `pw_wallet`. After alignment `force_update(pw_wallet)` @@ -3111,34 +3422,45 @@ class TestStepTerminate: only when the whole `_migrate_one_service` is exercised.""" def _manager( - self, FakeOnChainState: Any, *, state_seq: list, terminate=None, + self, + FakeOnChainState: Any, + *, + state_seq: list, + terminate=None, ) -> Any: states = list(state_seq) return types.SimpleNamespace( - terminate_service_on_chain_from_safe=( - terminate or (lambda **kw: None) + terminate_service_on_chain_from_safe=(terminate or (lambda **kw: None)), + _get_on_chain_state=lambda s, c: ( + states.pop(0) if states else FakeOnChainState.PRE_REGISTRATION ), - _get_on_chain_state=lambda s, c: states.pop(0) if states else FakeOnChainState.PRE_REGISTRATION, ) def test_skip_when_already_pre_registration( - self, orch: Any, + self, + orch: Any, ) -> None: m, FakeChain, FakeOnChainState = orch signed = {"n": 0} manager = self._manager( - FakeOnChainState, state_seq=[FakeOnChainState.PRE_REGISTRATION], + FakeOnChainState, + state_seq=[FakeOnChainState.PRE_REGISTRATION], ) m._step_terminate( - manager=manager, service=object(), sid="sc-aaa", chain_str="gnosis", + manager=manager, + service=object(), + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: signed.update(n=signed["n"] + 1), on_chain_state_cls=FakeOnChainState, - ledger_api=object(), qs_address="0xqs_eoa", + ledger_api=object(), + qs_address="0xqs_eoa", ) - assert signed["n"] == 0 # never signed because already there + assert signed["n"] == 0 # never signed because already there def test_success_path_signs_and_advances_state( - self, orch: Any, + self, + orch: Any, ) -> None: m, FakeChain, FakeOnChainState = orch signed = {"n": 0} @@ -3147,26 +3469,38 @@ def test_success_path_signs_and_advances_state( state_seq=[FakeOnChainState.DEPLOYED, FakeOnChainState.PRE_REGISTRATION], ) m._step_terminate( - manager=manager, service=object(), sid="sc-aaa", chain_str="gnosis", + manager=manager, + service=object(), + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: signed.update(n=signed["n"] + 1), on_chain_state_cls=FakeOnChainState, - ledger_api=object(), qs_address="0xqs_eoa", + ledger_api=object(), + qs_address="0xqs_eoa", ) - assert signed["n"] == 1 # ensure_signable was called + assert signed["n"] == 1 # ensure_signable was called def test_terminate_raise_wraps_into_unmigratable(self, orch: Any) -> None: m, FakeChain, FakeOnChainState = orch + def boom(**kw: Any) -> None: raise RuntimeError("revert: NotEnoughFunds") + manager = self._manager( - FakeOnChainState, state_seq=[FakeOnChainState.DEPLOYED], + FakeOnChainState, + state_seq=[FakeOnChainState.DEPLOYED], terminate=boom, ) with pytest.raises(m._Unmigratable) as ei: m._step_terminate( - manager=manager, service=object(), sid="sc-aaa", chain_str="gnosis", - ensure_signable=lambda: None, on_chain_state_cls=FakeOnChainState, - ledger_api=object(), qs_address="0xqs_eoa", + manager=manager, + service=object(), + sid="sc-aaa", + chain_str="gnosis", + ensure_signable=lambda: None, + on_chain_state_cls=FakeOnChainState, + ledger_api=object(), + qs_address="0xqs_eoa", ) assert "could not unstake/terminate" in ei.value.reason @@ -3178,20 +3512,27 @@ def test_post_terminate_state_read_failure_wraps(self, orch: Any) -> None: `_Unmigratable` so the next re-run resumes from the next step.""" m, FakeChain, FakeOnChainState = orch calls = {"n": 0} + def states(s: Any, c: str) -> Any: calls["n"] += 1 if calls["n"] == 1: return FakeOnChainState.DEPLOYED # before terminate raise ConnectionError("rpc 502") # post-terminate read fails + manager = types.SimpleNamespace( terminate_service_on_chain_from_safe=lambda **kw: None, _get_on_chain_state=states, ) with pytest.raises(m._Unmigratable) as ei: m._step_terminate( - manager=manager, service=object(), sid="sc-aaa", chain_str="gnosis", - ensure_signable=lambda: None, on_chain_state_cls=FakeOnChainState, - ledger_api=object(), qs_address="0xqs_eoa", + manager=manager, + service=object(), + sid="sc-aaa", + chain_str="gnosis", + ensure_signable=lambda: None, + on_chain_state_cls=FakeOnChainState, + ledger_api=object(), + qs_address="0xqs_eoa", ) assert "post-state verification" in ei.value.reason assert "rpc 502" in ei.value.reason @@ -3205,63 +3546,99 @@ def test_state_unchanged_after_terminate_raises(self, orch: Any) -> None: ) with pytest.raises(m._Unmigratable) as ei: m._step_terminate( - manager=manager, service=object(), sid="sc-aaa", chain_str="gnosis", - ensure_signable=lambda: None, on_chain_state_cls=FakeOnChainState, - ledger_api=object(), qs_address="0xqs_eoa", + manager=manager, + service=object(), + sid="sc-aaa", + chain_str="gnosis", + ensure_signable=lambda: None, + on_chain_state_cls=FakeOnChainState, + ledger_api=object(), + qs_address="0xqs_eoa", ) assert "expected PRE_REGISTRATION" in ei.value.reason - @pytest.mark.parametrize("bug_cls", [ - TypeError, AttributeError, NameError, ImportError, - ]) + @pytest.mark.parametrize( + "bug_cls", + [ + TypeError, + AttributeError, + NameError, + ImportError, + ], + ) def test_programming_bug_propagates_not_wrapped( - self, orch: Any, bug_cls: type, + self, + orch: Any, + bug_cls: type, ) -> None: """Step exception wrapper must NOT swallow programming bugs as 'chain failed'. Covers every member of `_PROGRAMMING_BUGS`.""" m, FakeChain, FakeOnChainState = orch + def buggy(**kw: Any) -> None: raise bug_cls("simulated programming bug") + manager = self._manager( - FakeOnChainState, state_seq=[FakeOnChainState.DEPLOYED], + FakeOnChainState, + state_seq=[FakeOnChainState.DEPLOYED], terminate=buggy, ) with pytest.raises(bug_cls): m._step_terminate( - manager=manager, service=object(), sid="sc-aaa", chain_str="gnosis", - ensure_signable=lambda: None, on_chain_state_cls=FakeOnChainState, - ledger_api=object(), qs_address="0xqs_eoa", + manager=manager, + service=object(), + sid="sc-aaa", + chain_str="gnosis", + ensure_signable=lambda: None, + on_chain_state_cls=FakeOnChainState, + ledger_api=object(), + qs_address="0xqs_eoa", ) - @pytest.mark.parametrize("rpc_cls", [ - KeyError, # web3 / middleware: missing chain metadata - LookupError, # eth_abi / contract attribute resolution - RuntimeError, # generic chain-side - ConnectionError, # transport-layer - ]) + @pytest.mark.parametrize( + "rpc_cls", + [ + KeyError, # web3 / middleware: missing chain metadata + LookupError, # eth_abi / contract attribute resolution + RuntimeError, # generic chain-side + ConnectionError, # transport-layer + ], + ) def test_legitimate_rpc_failures_get_wrapped_not_re_raised( - self, orch: Any, rpc_cls: type, + self, + orch: Any, + rpc_cls: type, ) -> None: """Regression guard: KeyError / LookupError MUST become per-service `_Unmigratable`, not crash the whole batch. Round 4 specifically removed these from `_PROGRAMMING_BUGS` for this reason; this test prevents a future re-widening from regressing the contract.""" m, FakeChain, FakeOnChainState = orch + def chain_failure(**kw: Any) -> None: raise rpc_cls("simulated chain-side failure") + manager = self._manager( - FakeOnChainState, state_seq=[FakeOnChainState.DEPLOYED], + FakeOnChainState, + state_seq=[FakeOnChainState.DEPLOYED], terminate=chain_failure, ) with pytest.raises(m._Unmigratable): m._step_terminate( - manager=manager, service=object(), sid="sc-aaa", chain_str="gnosis", - ensure_signable=lambda: None, on_chain_state_cls=FakeOnChainState, - ledger_api=object(), qs_address="0xqs_eoa", + manager=manager, + service=object(), + sid="sc-aaa", + chain_str="gnosis", + ensure_signable=lambda: None, + on_chain_state_cls=FakeOnChainState, + ledger_api=object(), + qs_address="0xqs_eoa", ) def test_terminate_waits_for_funds_then_retries( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Pin: an insufficient-funds error from `terminate_service_on_chain_from_safe` MUST trigger the wait-and-retry helper, not bail as `_Unmigratable`. @@ -3278,8 +3655,8 @@ def maybe_boom(**kw: Any) -> None: manager = self._manager( FakeOnChainState, state_seq=[ - FakeOnChainState.DEPLOYED, # before terminate - FakeOnChainState.PRE_REGISTRATION, # post-state read after retry + FakeOnChainState.DEPLOYED, # before terminate + FakeOnChainState.PRE_REGISTRATION, # post-state read after retry ], terminate=maybe_boom, ) @@ -3288,9 +3665,14 @@ def maybe_boom(**kw: Any) -> None: monkeypatch.setattr(m, "wei_to_token", lambda *a, **kw: "stub") monkeypatch.setattr(m.time, "sleep", lambda s: None) m._step_terminate( - manager=manager, service=object(), sid="sc-aaa", chain_str="gnosis", - ensure_signable=lambda: None, on_chain_state_cls=FakeOnChainState, - ledger_api=object(), qs_address="0xqs_eoa", + manager=manager, + service=object(), + sid="sc-aaa", + chain_str="gnosis", + ensure_signable=lambda: None, + on_chain_state_cls=FakeOnChainState, + ledger_api=object(), + qs_address="0xqs_eoa", ) assert len(attempts) == 2 # one fail, one retry-after-topup @@ -3305,8 +3687,11 @@ class TestStepTransferNft: def fake_transfer(self, monkeypatch: pytest.MonkeyPatch): # noqa: ANN201 """Install a stub transfer module; return the call recorder.""" calls: list = [] + def fake_nft(**kw: Any) -> str: - calls.append(kw); return "0x1" + calls.append(kw) + return "0x1" + fake_mod = types.ModuleType("scripts.pearl_migration.transfer") fake_mod.PostConditionUnknown = type("PostConditionUnknown", (RuntimeError,), {}) # type: ignore[attr-defined] fake_mod.transfer_service_nft = fake_nft # type: ignore[attr-defined] @@ -3315,7 +3700,10 @@ def fake_nft(**kw: Any) -> str: return calls def test_skip_when_nft_already_on_pearl( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, fake_transfer: list, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, + fake_transfer: list, ) -> None: m, *_ = orch signed = {"n": 0} @@ -3324,15 +3712,21 @@ def test_skip_when_nft_already_on_pearl( ledger_api="LA", qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), registry_addr="0xreg", - qs_master_safe="0xqs", pearl_master_safe="0xpl", - token_id=42, sid="sc-aaa", chain_str="gnosis", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", + token_id=42, + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: signed.update(n=signed["n"] + 1), ) assert fake_transfer == [] assert signed["n"] == 0 def test_owner_unreadable_raises( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, fake_transfer: list, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, + fake_transfer: list, ) -> None: m, *_ = orch monkeypatch.setattr(m, "service_nft_owner", lambda **kw: None) @@ -3341,14 +3735,20 @@ def test_owner_unreadable_raises( ledger_api="LA", qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), registry_addr="0xreg", - qs_master_safe="0xqs", pearl_master_safe="0xpl", - token_id=42, sid="sc-aaa", chain_str="gnosis", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", + token_id=42, + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: None, ) assert "could not read NFT owner" in ei.value.reason def test_owner_third_party_raises( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, fake_transfer: list, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, + fake_transfer: list, ) -> None: m, *_ = orch monkeypatch.setattr(m, "service_nft_owner", lambda **kw: "0xstranger") @@ -3357,14 +3757,20 @@ def test_owner_third_party_raises( ledger_api="LA", qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), registry_addr="0xreg", - qs_master_safe="0xqs", pearl_master_safe="0xpl", - token_id=42, sid="sc-aaa", chain_str="gnosis", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", + token_id=42, + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: None, ) assert "expected quickstart" in ei.value.reason def test_success_path_calls_transfer_then_signs( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, fake_transfer: list, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, + fake_transfer: list, ) -> None: m, *_ = orch signed = {"n": 0} @@ -3373,8 +3779,11 @@ def test_success_path_calls_transfer_then_signs( ledger_api="LA", qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), registry_addr="0xreg", - qs_master_safe="0xqs", pearl_master_safe="0xpl", - token_id=42, sid="sc-aaa", chain_str="gnosis", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", + token_id=42, + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: signed.update(n=signed["n"] + 1), ) assert signed["n"] == 1 @@ -3382,14 +3791,18 @@ def test_success_path_calls_transfer_then_signs( assert fake_transfer[0]["service_id"] == 42 def test_transfer_failure_wraps_into_unmigratable( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, ) -> None: m, *_ = orch monkeypatch.setattr(m, "service_nft_owner", lambda **kw: "0xqs") fake_mod = types.ModuleType("scripts.pearl_migration.transfer") fake_mod.PostConditionUnknown = type("PostConditionUnknown", (RuntimeError,), {}) # type: ignore[attr-defined] + def boom(**kw: Any) -> None: raise RuntimeError("nonce stale") + fake_mod.transfer_service_nft = boom # type: ignore[attr-defined] fake_mod.swap_service_safe_owner = lambda **kw: None # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "scripts.pearl_migration.transfer", fake_mod) @@ -3398,14 +3811,19 @@ def boom(**kw: Any) -> None: ledger_api="LA", qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), registry_addr="0xreg", - qs_master_safe="0xqs", pearl_master_safe="0xpl", - token_id=42, sid="sc-aaa", chain_str="gnosis", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", + token_id=42, + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: None, ) assert "NFT transfer failed" in ei.value.reason def test_post_condition_unknown_surfaces_unwrapped( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, ) -> None: """`PostConditionUnknown` carries critical 'DO NOT re-run blindly' guidance. The step wrapper MUST surface it as an _Unmigratable @@ -3414,14 +3832,18 @@ def test_post_condition_unknown_surfaces_unwrapped( m, *_ = orch monkeypatch.setattr(m, "service_nft_owner", lambda **kw: "0xqs") fake_mod = types.ModuleType("scripts.pearl_migration.transfer") + class _PCU(RuntimeError): pass + fake_mod.PostConditionUnknown = _PCU # type: ignore[attr-defined] + def post_cond_unknown(**kw: Any) -> None: raise _PCU( "On-chain state INDETERMINATE after Safe tx 0xMINED: " "DO NOT re-run blindly." ) + fake_mod.transfer_service_nft = post_cond_unknown # type: ignore[attr-defined] fake_mod.swap_service_safe_owner = lambda **kw: None # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "scripts.pearl_migration.transfer", fake_mod) @@ -3430,8 +3852,11 @@ def post_cond_unknown(**kw: Any) -> None: ledger_api="LA", qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), registry_addr="0xreg", - qs_master_safe="0xqs", pearl_master_safe="0xpl", - token_id=42, sid="sc-aaa", chain_str="gnosis", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", + token_id=42, + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: None, ) # The original "DO NOT re-run blindly" / "INDETERMINATE" message @@ -3442,7 +3867,9 @@ def post_cond_unknown(**kw: Any) -> None: assert "NFT transfer failed" not in ei.value.reason def test_transfer_nft_waits_for_funds_then_retries( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Pin: insufficient-funds during the NFT `safeTransferFrom` MUST trigger the wait-and-retry helper (qs master EOA pays gas), not @@ -3472,8 +3899,11 @@ def maybe_boom(**kw: Any) -> str: ledger_api=object(), qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), registry_addr="0xreg", - qs_master_safe="0xqs", pearl_master_safe="0xpl", - token_id=42, sid="sc-aaa", chain_str="gnosis", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", + token_id=42, + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: None, ) assert len(attempts) == 2 # one fail, one retry-after-topup @@ -3490,88 +3920,128 @@ def fake_swap(self, monkeypatch: pytest.MonkeyPatch): # noqa: ANN201 fake_mod = types.ModuleType("scripts.pearl_migration.transfer") fake_mod.PostConditionUnknown = type("PostConditionUnknown", (RuntimeError,), {}) # type: ignore[attr-defined] fake_mod.transfer_service_nft = lambda **kw: "0x1" # type: ignore[attr-defined] + def fake(**kw: Any) -> None: calls.append(kw) + fake_mod.swap_service_safe_owner = fake # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "scripts.pearl_migration.transfer", fake_mod) return calls def test_skip_when_already_swapped( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, fake_swap: list, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, + fake_swap: list, ) -> None: m, *_ = orch signed = {"n": 0} monkeypatch.setattr(m, "safe_owners", lambda **kw: ["0xpl"]) m._step_swap_service_safe_owner( - ledger_api="LA", qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), - service_safe="0xms", qs_master_safe="0xqs", - pearl_master_safe="0xpl", sid="sc-aaa", chain_str="gnosis", + ledger_api="LA", + qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), + service_safe="0xms", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: signed.update(n=signed["n"] + 1), ) assert fake_swap == [] assert signed["n"] == 0 def test_owners_unreadable_wraps( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, fake_swap: list, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, + fake_swap: list, ) -> None: m, *_ = orch + def boom(**kw: Any) -> Any: raise RuntimeError("rpc 502") + monkeypatch.setattr(m, "safe_owners", boom) with pytest.raises(m._Unmigratable) as ei: m._step_swap_service_safe_owner( - ledger_api="LA", qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), - service_safe="0xms", qs_master_safe="0xqs", - pearl_master_safe="0xpl", sid="sc-aaa", chain_str="gnosis", + ledger_api="LA", + qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), + service_safe="0xms", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: None, ) assert "could not read service Safe owners" in ei.value.reason def test_qs_not_owner_raises( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, fake_swap: list, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, + fake_swap: list, ) -> None: m, *_ = orch monkeypatch.setattr(m, "safe_owners", lambda **kw: ["0xstranger"]) with pytest.raises(m._Unmigratable) as ei: m._step_swap_service_safe_owner( - ledger_api="LA", qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), - service_safe="0xms", qs_master_safe="0xqs", - pearl_master_safe="0xpl", sid="sc-aaa", chain_str="gnosis", + ledger_api="LA", + qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), + service_safe="0xms", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: None, ) assert "can't swap" in ei.value.reason def test_success_path_signs_then_swaps( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, fake_swap: list, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, + fake_swap: list, ) -> None: m, *_ = orch signed = {"n": 0} monkeypatch.setattr(m, "safe_owners", lambda **kw: ["0xqs"]) m._step_swap_service_safe_owner( - ledger_api="LA", qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), - service_safe="0xms", qs_master_safe="0xqs", - pearl_master_safe="0xpl", sid="sc-aaa", chain_str="gnosis", + ledger_api="LA", + qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), + service_safe="0xms", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: signed.update(n=signed["n"] + 1), ) assert signed["n"] == 1 and len(fake_swap) == 1 def test_swap_failure_wraps_into_half_state_message( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, ) -> None: m, *_ = orch monkeypatch.setattr(m, "safe_owners", lambda **kw: ["0xqs"]) fake_mod = types.ModuleType("scripts.pearl_migration.transfer") fake_mod.PostConditionUnknown = type("PostConditionUnknown", (RuntimeError,), {}) # type: ignore[attr-defined] fake_mod.transfer_service_nft = lambda **kw: "0x1" # type: ignore[attr-defined] + def boom(**kw: Any) -> None: raise RuntimeError("safe tx revert") + fake_mod.swap_service_safe_owner = boom # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "scripts.pearl_migration.transfer", fake_mod) with pytest.raises(m._Unmigratable) as ei: m._step_swap_service_safe_owner( - ledger_api="LA", qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), - service_safe="0xms", qs_master_safe="0xqs", - pearl_master_safe="0xpl", sid="sc-aaa", chain_str="gnosis", + ledger_api="LA", + qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), + service_safe="0xms", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: None, ) # Half-state message must call out the inconsistency. @@ -3579,7 +4049,9 @@ def boom(**kw: Any) -> None: assert "still lists 0xqs as owner" in ei.value.reason def test_post_condition_unknown_surfaces_unwrapped( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Same as the NFT-step test: the swap step MUST surface PostConditionUnknown's verbatim 'INDETERMINATE / DO NOT re-run' @@ -3588,22 +4060,30 @@ def test_post_condition_unknown_surfaces_unwrapped( m, *_ = orch monkeypatch.setattr(m, "safe_owners", lambda **kw: ["0xqs"]) fake_mod = types.ModuleType("scripts.pearl_migration.transfer") + class _PCU(RuntimeError): pass + fake_mod.PostConditionUnknown = _PCU # type: ignore[attr-defined] fake_mod.transfer_service_nft = lambda **kw: "0x1" # type: ignore[attr-defined] + def post_cond_unknown(**kw: Any) -> None: raise _PCU( "On-chain state INDETERMINATE after Safe tx 0xMINED: " "DO NOT re-run blindly." ) + fake_mod.swap_service_safe_owner = post_cond_unknown # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "scripts.pearl_migration.transfer", fake_mod) with pytest.raises(m._Unmigratable) as ei: m._step_swap_service_safe_owner( - ledger_api="LA", qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), - service_safe="0xms", qs_master_safe="0xqs", - pearl_master_safe="0xpl", sid="sc-aaa", chain_str="gnosis", + ledger_api="LA", + qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), + service_safe="0xms", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: None, ) assert "INDETERMINATE" in ei.value.reason @@ -3613,7 +4093,9 @@ def post_cond_unknown(**kw: Any) -> None: assert "service Safe owner swap failed" not in ei.value.reason def test_swap_owner_waits_for_funds_then_retries( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Pin: insufficient-funds during the service Safe owner swap MUST trigger the wait-and-retry helper. Same risk shape as the NFT @@ -3639,8 +4121,11 @@ def maybe_boom(**kw: Any) -> None: m._step_swap_service_safe_owner( ledger_api=object(), qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa"), - service_safe="0xms", qs_master_safe="0xqs", - pearl_master_safe="0xpl", sid="sc-aaa", chain_str="gnosis", + service_safe="0xms", + qs_master_safe="0xqs", + pearl_master_safe="0xpl", + sid="sc-aaa", + chain_str="gnosis", ensure_signable=lambda: None, ) assert len(attempts) == 2 # one fail, one retry-after-topup @@ -3652,7 +4137,9 @@ class TestUnmigratableExceptionInit: def test_args_populated(self, orch: Any) -> None: m, *_ = orch exc = m._Unmigratable( - service_id="sc-aaa", chain="gnosis", reason="boom", + service_id="sc-aaa", + chain="gnosis", + reason="boom", ) # Without __post_init__, the dataclass-synthesized __init__ leaves # args=(). With it, the structured fields propagate to Exception. @@ -3661,7 +4148,9 @@ def test_args_populated(self, orch: Any) -> None: def test_repr_shows_all_fields(self, orch: Any) -> None: m, *_ = orch exc = m._Unmigratable( - service_id="sc-aaa", chain="gnosis", reason="boom", + service_id="sc-aaa", + chain="gnosis", + reason="boom", ) # The dataclass-synthesized `__repr__` (which overrides Exception's # default) renders every field by name. @@ -3676,7 +4165,9 @@ def test_chain_none_keeps_args_aligned_with_init(self, orch: Any) -> None: + added `__reduce__` so the alignment is explicit.""" m, *_ = orch exc = m._Unmigratable( - service_id="sc-aaa", chain=None, reason="config malformed", + service_id="sc-aaa", + chain=None, + reason="config malformed", ) assert exc.args == ("sc-aaa", None, "config malformed") # str(exc) (custom __str__) elides "on None" so the user-visible @@ -3690,9 +4181,12 @@ def test_serialization_round_trips(self, orch: Any) -> None: end-to-end via copy.copy (which uses __reduce__ under the hood). Guards against multiprocessing transport breaking on _Unmigratable.""" import copy + m, *_ = orch original = m._Unmigratable( - service_id="sc-aaa", chain=None, reason="boom", + service_id="sc-aaa", + chain=None, + reason="boom", ) clone = copy.copy(original) assert clone == original @@ -3712,9 +4206,13 @@ def test_pickle_round_trip(self, chain: Any) -> None: # noqa: ANN001 and would fail pickle's `cls is sys.modules[mod].cls` identity check, which would mask the real contract being tested.""" import pickle # noqa: S403 — internal serialization, not untrusted input + from scripts.pearl_migration.migrate_to_pearl import _Unmigratable + original = _Unmigratable( - service_id="sc-aaa", chain=chain, reason="boom", + service_id="sc-aaa", + chain=chain, + reason="boom", ) clone = pickle.loads(pickle.dumps(original)) # noqa: S301 assert isinstance(clone, _Unmigratable) @@ -3737,7 +4235,11 @@ def _stub_threshold(self, orch: Any, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(m, "docker_quickstart_containers", lambda: []) def _make_service_obj( - self, fake_chain_cls: Any, *, multisig: str = "0xms", token: int = 7, + self, + fake_chain_cls: Any, + *, + multisig: str = "0xms", + token: int = 7, ) -> Any: # `user_params` and `store` are required by the post-loop staking # reset in `_migrate_one_service`; pre-populating them with a @@ -3751,7 +4253,8 @@ def _make_service_obj( ) ledger_config = types.SimpleNamespace(rpc="http://rpc") chain_config = types.SimpleNamespace( - chain_data=chain_data, ledger_config=ledger_config, + chain_data=chain_data, + ledger_config=ledger_config, ) return types.SimpleNamespace( chain_configs={"gnosis": chain_config}, @@ -3759,7 +4262,9 @@ def _make_service_obj( ) def _setup_manager( - self, FakeChain: Any, FakeOnChainState: Any, + self, + FakeChain: Any, + FakeOnChainState: Any, *, terminate: Any = None, state_seq: Optional[list] = None, @@ -3769,15 +4274,20 @@ def _setup_manager( state on the second call (after-terminate check).""" if terminate is None: terminate = lambda **kw: None # noqa: E731 -- default no-op - states = list(state_seq or [ - FakeOnChainState.DEPLOYED, # before terminate - FakeOnChainState.PRE_REGISTRATION, # after terminate - ]) + states = list( + state_seq + or [ + FakeOnChainState.DEPLOYED, # before terminate + FakeOnChainState.PRE_REGISTRATION, # after terminate + ] + ) svc_obj = self._make_service_obj(FakeChain, multisig=multisig) return svc_obj, types.SimpleNamespace( load=lambda service_config_id: svc_obj, terminate_service_on_chain_from_safe=terminate, - _get_on_chain_state=lambda s, c: states.pop(0) if states else FakeOnChainState.PRE_REGISTRATION, + _get_on_chain_state=lambda s, c: ( + states.pop(0) if states else FakeOnChainState.PRE_REGISTRATION + ), get_eth_safe_tx_builder=lambda ledger_config: types.SimpleNamespace( ledger_api="LA", ), @@ -3793,38 +4303,52 @@ def fake_terminate(service_config_id: str, chain: str) -> None: manager_calls["term"] = chain _, manager = self._setup_manager( - FakeChain, FakeOnChainState, terminate=fake_terminate, + FakeChain, + FakeOnChainState, + terminate=fake_terminate, ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) - qs_wallet = types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}) + qs_wallet = types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ) pearl_wallet = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}) - monkeypatch.setattr(m, "stop_via_middleware", - lambda operate, config_path: None) + monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) monkeypatch.setattr(m, "force_remove_known_containers", lambda: []) # Idempotency probes: NFT not yet transferred, qs Safe still owns service Safe. - monkeypatch.setattr(m, "service_nft_owner", - lambda **kw: "0xqs") + monkeypatch.setattr(m, "service_nft_owner", lambda **kw: "0xqs") monkeypatch.setattr(m, "safe_owners", lambda **kw: ["0xqs"]) # Stub the lazy transfer-module import. fake_transfer = types.ModuleType("scripts.pearl_migration.transfer") fake_transfer.PostConditionUnknown = type("PostConditionUnknown", (RuntimeError,), {}) # type: ignore[attr-defined] moves: list[Any] = [] + def fake_nft(**kw: Any) -> str: - moves.append(("nft", kw)); return "0x1" + moves.append(("nft", kw)) + return "0x1" + def fake_swap(**kw: Any) -> None: moves.append(("swap", kw)) + fake_transfer.transfer_service_nft = fake_nft # type: ignore[attr-defined] fake_transfer.swap_service_safe_owner = fake_swap # type: ignore[attr-defined] monkeypatch.setitem( - sys.modules, "scripts.pearl_migration.transfer", fake_transfer, + sys.modules, + "scripts.pearl_migration.transfer", + fake_transfer, ) ref = _fake_service("sc-aaa", name="A", agent_addresses=["0xa"], path=tmp_path) m._migrate_one_service( - svc=ref, qs_app=qs_app, qs_wallet=qs_wallet, - pearl_wallet=pearl_wallet, config_path="cfg.json", + svc=ref, + qs_app=qs_app, + qs_wallet=qs_wallet, + pearl_wallet=pearl_wallet, + config_path="cfg.json", ) assert manager_calls["term"] == "gnosis" assert [step for step, _ in moves] == ["nft", "swap"] @@ -3840,16 +4364,21 @@ def fake_terminate(**kw: Any) -> None: terminate_calls.append(kw) _, manager = self._setup_manager( - FakeChain, FakeOnChainState, + FakeChain, + FakeOnChainState, terminate=fake_terminate, state_seq=[FakeOnChainState.PRE_REGISTRATION], # already there ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) - qs_wallet = types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}) + qs_wallet = types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ) pearl_wallet = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}) - monkeypatch.setattr(m, "stop_via_middleware", - lambda operate, config_path: None) + monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) monkeypatch.setattr(m, "force_remove_known_containers", lambda: []) # NFT already on Pearl, Safe owner already swapped to Pearl. monkeypatch.setattr(m, "service_nft_owner", lambda **kw: "0xpl") @@ -3862,18 +4391,29 @@ def fake_terminate(**kw: Any) -> None: fake_transfer.transfer_service_nft = lambda **kw: nft_called.append(kw) # type: ignore[attr-defined] fake_transfer.swap_service_safe_owner = lambda **kw: swap_called.append(kw) # type: ignore[attr-defined] monkeypatch.setitem( - sys.modules, "scripts.pearl_migration.transfer", fake_transfer, + sys.modules, + "scripts.pearl_migration.transfer", + fake_transfer, ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) m._migrate_one_service( - svc=ref, qs_app=qs_app, qs_wallet=qs_wallet, - pearl_wallet=pearl_wallet, config_path=None, + svc=ref, + qs_app=qs_app, + qs_wallet=qs_wallet, + pearl_wallet=pearl_wallet, + config_path=None, ) # Every on-chain step was probed-and-skipped. - assert terminate_calls == [], "terminate must be skipped when already PRE_REGISTRATION" - assert nft_called == [], "transfer_service_nft must be skipped when NFT already on Pearl" - assert swap_called == [], "swap_service_safe_owner must be skipped when already swapped" + assert ( + terminate_calls == [] + ), "terminate must be skipped when already PRE_REGISTRATION" + assert ( + nft_called == [] + ), "transfer_service_nft must be skipped when NFT already on Pearl" + assert ( + swap_called == [] + ), "swap_service_safe_owner must be skipped when already swapped" def test_nft_owner_unreadable_raises_unmigratable( self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -3881,16 +4421,21 @@ def test_nft_owner_unreadable_raises_unmigratable( m, FakeChain, FakeOnChainState = orch _, manager = self._setup_manager(FakeChain, FakeOnChainState) qs_app = types.SimpleNamespace(service_manager=lambda: manager) - monkeypatch.setattr(m, "stop_via_middleware", - lambda operate, config_path: None) + monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) monkeypatch.setattr(m, "force_remove_known_containers", lambda: []) monkeypatch.setattr(m, "service_nft_owner", lambda **kw: None) # RPC failure monkeypatch.setattr(m, "safe_owners", lambda **kw: ["0xqs"]) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) @@ -3911,8 +4456,14 @@ def test_nft_owner_unexpected_third_party_raises( ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) @@ -3931,19 +4482,29 @@ def test_nft_transfer_failure_raises_unmigratable( fake_transfer = types.ModuleType("scripts.pearl_migration.transfer") fake_transfer.PostConditionUnknown = type("PostConditionUnknown", (RuntimeError,), {}) # type: ignore[attr-defined] + def boom(**kw: Any) -> None: raise RuntimeError("revert: nonce stale") + fake_transfer.transfer_service_nft = boom # type: ignore[attr-defined] fake_transfer.swap_service_safe_owner = lambda **kw: None # type: ignore[attr-defined] monkeypatch.setitem( - sys.modules, "scripts.pearl_migration.transfer", fake_transfer, + sys.modules, + "scripts.pearl_migration.transfer", + fake_transfer, ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) @@ -3967,21 +4528,34 @@ def test_swap_failure_raises_unmigratable_post_nft( order: list[str] = [] fake_transfer = types.ModuleType("scripts.pearl_migration.transfer") fake_transfer.PostConditionUnknown = type("PostConditionUnknown", (RuntimeError,), {}) # type: ignore[attr-defined] + def fake_nft(**kw: Any) -> str: - order.append("nft"); return "0x1" + order.append("nft") + return "0x1" + def swap_boom(**kw: Any) -> None: - order.append("swap"); raise RuntimeError("safe tx revert") + order.append("swap") + raise RuntimeError("safe tx revert") + fake_transfer.transfer_service_nft = fake_nft # type: ignore[attr-defined] fake_transfer.swap_service_safe_owner = swap_boom # type: ignore[attr-defined] monkeypatch.setitem( - sys.modules, "scripts.pearl_migration.transfer", fake_transfer, + sys.modules, + "scripts.pearl_migration.transfer", + fake_transfer, ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) @@ -3989,32 +4563,47 @@ def swap_boom(**kw: Any) -> None: assert "service multisig" in ei.value.reason assert "owner swap failed" in ei.value.reason # And the half-state message must actually be true: NFT transferred FIRST. - assert order == ["nft", "swap"], ( - f"swap must run AFTER nft transfer for the half-state message to be true; saw {order}" - ) + assert order == [ + "nft", + "swap", + ], f"swap must run AFTER nft transfer for the half-state message to be true; saw {order}" def test_terminate_failure_raises_unmigratable( self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: m, FakeChain, FakeOnChainState = orch + def boom(service_config_id: str, chain: str) -> None: raise RuntimeError("can't unstake yet") + # State is DEPLOYED before terminate runs (so the probe says "act"). _, manager = self._setup_manager( - FakeChain, FakeOnChainState, - terminate=boom, state_seq=[FakeOnChainState.DEPLOYED], + FakeChain, + FakeOnChainState, + terminate=boom, + state_seq=[FakeOnChainState.DEPLOYED], ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) - monkeypatch.setattr(m, "stop_via_middleware", - lambda operate, config_path: (_ for _ in ()).throw(RuntimeError("stop fails too"))) + monkeypatch.setattr( + m, + "stop_via_middleware", + lambda operate, config_path: (_ for _ in ()).throw( + RuntimeError("stop fails too") + ), + ) monkeypatch.setattr(m, "force_remove_known_containers", lambda: []) monkeypatch.setattr(m, "service_nft_owner", lambda **kw: "0xqs") monkeypatch.setattr(m, "safe_owners", lambda **kw: ["0xqs"]) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xqs"}, address="0xqs_eoa", ledger_api=lambda **kw: object()), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xqs"}, + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path="cfg.json", ) @@ -4027,20 +4616,25 @@ def test_wrong_state_after_terminate_raises( m, FakeChain, FakeOnChainState = orch # Both probes return DEPLOYED — terminate "succeeds" but state never moves. _, manager = self._setup_manager( - FakeChain, FakeOnChainState, + FakeChain, + FakeOnChainState, state_seq=[FakeOnChainState.DEPLOYED, FakeOnChainState.DEPLOYED], ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) - monkeypatch.setattr(m, "stop_via_middleware", - lambda operate, config_path: None) + monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) monkeypatch.setattr(m, "force_remove_known_containers", lambda: []) monkeypatch.setattr(m, "service_nft_owner", lambda **kw: "0xqs") monkeypatch.setattr(m, "safe_owners", lambda **kw: ["0xqs"]) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xqs"}, address="0xqs_eoa", ledger_api=lambda **kw: object()), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xqs"}, + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path="cfg.json", ) @@ -4057,14 +4651,21 @@ def test_safe_owners_unreadable_raises_unmigratable( # NFT was already transferred (so we get past step 2), but reading # owners fails (RPC down). monkeypatch.setattr(m, "service_nft_owner", lambda **kw: "0xpl") + def owners_boom(**kw: Any) -> Any: raise RuntimeError("rpc down") + monkeypatch.setattr(m, "safe_owners", owners_boom) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xqs"}, address="0xqs_eoa", ledger_api=lambda **kw: object()), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xqs"}, + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) @@ -4086,15 +4687,24 @@ def test_safe_owner_swap_required_but_qs_not_owner( ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) assert "can't swap" in ei.value.reason def test_multi_chain_per_iteration_ledger_capture( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Service spans 2 chains: each iteration's `_ledger_api(_cfg=...)` default-arg binding must capture THAT iteration's chain_config. @@ -4108,14 +4718,16 @@ def test_multi_chain_per_iteration_ledger_capture( # Two distinct chain_configs, distinguished by RPC. cc_g = types.SimpleNamespace( chain_data=types.SimpleNamespace( - token=1, multisig="0xms-g", + token=1, + multisig="0xms-g", user_params=types.SimpleNamespace(staking_program_id="qsp"), ), ledger_config=types.SimpleNamespace(rpc="https://rpc.example/gnosis"), ) cc_o = types.SimpleNamespace( chain_data=types.SimpleNamespace( - token=2, multisig="0xms-o", + token=2, + multisig="0xms-o", user_params=types.SimpleNamespace(staking_program_id="qsp"), ), ledger_config=types.SimpleNamespace(rpc="https://rpc.example/optimism"), @@ -4149,43 +4761,60 @@ def test_multi_chain_per_iteration_ledger_capture( # (token=1 → gnosis, token=2 → optimism). per_chain_pearl = {1: "0xpl-g", 2: "0xpl-o"} monkeypatch.setattr( - m, "service_nft_owner", - lambda *, ledger_api, service_registry_address, service_id: - per_chain_pearl[service_id], + m, + "service_nft_owner", + lambda *, ledger_api, service_registry_address, service_id: per_chain_pearl[ + service_id + ], ) monkeypatch.setattr( - m, "safe_owners", - lambda *, ledger_api, safe: - ["0xpl-g"] if safe == "0xms-g" else ["0xpl-o"], + m, + "safe_owners", + lambda *, ledger_api, safe: ["0xpl-g"] if safe == "0xms-g" else ["0xpl-o"], ) # Override the autouse stub so safe_threshold actually runs against # the per-chain ledger_api. seen_thresholds: list = [] + def threshold_recorder(*, ledger_api: Any, safe: str) -> int: seen_thresholds.append(ledger_api) return 1 + monkeypatch.setattr(m, "safe_threshold", threshold_recorder) # Force a state mismatch so terminate runs — that triggers the # threshold guard (which calls _ledger_api → records the build). - states = iter([ - FakeOnChainState.DEPLOYED, FakeOnChainState.PRE_REGISTRATION, # gnosis - FakeOnChainState.DEPLOYED, FakeOnChainState.PRE_REGISTRATION, # optimism - ]) + states = iter( + [ + FakeOnChainState.DEPLOYED, + FakeOnChainState.PRE_REGISTRATION, # gnosis + FakeOnChainState.DEPLOYED, + FakeOnChainState.PRE_REGISTRATION, # optimism + ] + ) manager._get_on_chain_state = lambda s, c: next(states) # type: ignore[attr-defined] - monkeypatch.setattr(m, "stop_via_middleware", - lambda operate, config_path: None) + monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) monkeypatch.setattr(m, "force_remove_known_containers", lambda: []) ref = _fake_service("sc-aaa", name="A", path=tmp_path) m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={ - FakeChain.GNOSIS: "0xqs-g", FakeChain.OPTIMISM: "0xqs-o", - }), - pearl_wallet=types.SimpleNamespace(safes={ - FakeChain.GNOSIS: "0xpl-g", FakeChain.OPTIMISM: "0xpl-o", - }), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={ + FakeChain.GNOSIS: "0xqs-g", + FakeChain.OPTIMISM: "0xqs-o", + }, + ), + pearl_wallet=types.SimpleNamespace( + safes={ + FakeChain.GNOSIS: "0xpl-g", + FakeChain.OPTIMISM: "0xpl-o", + } + ), config_path=None, ) @@ -4200,7 +4829,10 @@ def threshold_recorder(*, ledger_api: Any, safe: str) -> int: assert "LA-https://rpc.example/optimism" in seen_thresholds def test_multi_chain_steps_2_and_3_use_per_iteration_ledger( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Sibling to the previous test: this time NFT is still on the QS Safe and the service Safe is still QS-owned, so step 2 (NFT transfer) @@ -4212,14 +4844,16 @@ def test_multi_chain_steps_2_and_3_use_per_iteration_ledger( m, FakeChain, FakeOnChainState = orch cc_g = types.SimpleNamespace( chain_data=types.SimpleNamespace( - token=1, multisig="0xms-g", + token=1, + multisig="0xms-g", user_params=types.SimpleNamespace(staking_program_id="qsp"), ), ledger_config=types.SimpleNamespace(rpc="https://rpc.example/gnosis"), ) cc_o = types.SimpleNamespace( chain_data=types.SimpleNamespace( - token=2, multisig="0xms-o", + token=2, + multisig="0xms-o", user_params=types.SimpleNamespace(staking_program_id="qsp"), ), ledger_config=types.SimpleNamespace(rpc="https://rpc.example/optimism"), @@ -4237,8 +4871,9 @@ def test_multi_chain_steps_2_and_3_use_per_iteration_ledger( terminate_service_on_chain_from_safe=lambda **kw: None, _get_on_chain_state=lambda s, c: FakeOnChainState.PRE_REGISTRATION, get_eth_safe_tx_builder=( - lambda ledger_config: - types.SimpleNamespace(ledger_api=f"LA-{ledger_config.rpc}") + lambda ledger_config: types.SimpleNamespace( + ledger_api=f"LA-{ledger_config.rpc}" + ) ), ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) @@ -4246,45 +4881,60 @@ def test_multi_chain_steps_2_and_3_use_per_iteration_ledger( # NFT still owned by per-chain QS Safe -> step 2 must run. per_chain_qs = {1: "0xqs-g", 2: "0xqs-o"} monkeypatch.setattr( - m, "service_nft_owner", - lambda *, ledger_api, service_registry_address, service_id: - per_chain_qs[service_id], + m, + "service_nft_owner", + lambda *, ledger_api, service_registry_address, service_id: per_chain_qs[ + service_id + ], ) # Service Safe still QS-owned -> step 3 must run. monkeypatch.setattr( - m, "safe_owners", - lambda *, ledger_api, safe: - ["0xqs-g"] if safe == "0xms-g" else ["0xqs-o"], + m, + "safe_owners", + lambda *, ledger_api, safe: ["0xqs-g"] if safe == "0xms-g" else ["0xqs-o"], ) monkeypatch.setattr(m, "safe_threshold", lambda *, ledger_api, safe: 1) - monkeypatch.setattr(m, "stop_via_middleware", - lambda operate, config_path: None) + monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) monkeypatch.setattr(m, "force_remove_known_containers", lambda: []) nft_calls: list = [] swap_calls: list = [] from scripts.pearl_migration import transfer as transfer_mod + monkeypatch.setattr( - transfer_mod, "transfer_service_nft", - lambda *, ledger_api, crypto, service_registry_address, - qs_master_safe, pearl_master_safe, service_id: - nft_calls.append((ledger_api, service_id)), + transfer_mod, + "transfer_service_nft", + lambda *, ledger_api, crypto, service_registry_address, qs_master_safe, pearl_master_safe, service_id: nft_calls.append( + (ledger_api, service_id) + ), ) monkeypatch.setattr( - transfer_mod, "swap_service_safe_owner", - lambda *, ledger_api, crypto, service_safe, old_owner, new_owner: - swap_calls.append((ledger_api, service_safe)), + transfer_mod, + "swap_service_safe_owner", + lambda *, ledger_api, crypto, service_safe, old_owner, new_owner: swap_calls.append( + (ledger_api, service_safe) + ), ) ref = _fake_service("sc-aaa", name="A", path=tmp_path) m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={ - FakeChain.GNOSIS: "0xqs-g", FakeChain.OPTIMISM: "0xqs-o", - }), - pearl_wallet=types.SimpleNamespace(safes={ - FakeChain.GNOSIS: "0xpl-g", FakeChain.OPTIMISM: "0xpl-o", - }), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={ + FakeChain.GNOSIS: "0xqs-g", + FakeChain.OPTIMISM: "0xqs-o", + }, + ), + pearl_wallet=types.SimpleNamespace( + safes={ + FakeChain.GNOSIS: "0xpl-g", + FakeChain.OPTIMISM: "0xpl-o", + } + ), config_path=None, ) @@ -4302,8 +4952,11 @@ def test_creates_pearl_safe_when_missing( """Pearl missing a Safe on this chain -> create_safe is invoked, then proceed.""" m, FakeChain, FakeOnChainState = orch _, manager = self._setup_manager( - FakeChain, FakeOnChainState, - state_seq=[FakeOnChainState.PRE_REGISTRATION], # already there, skip terminate + FakeChain, + FakeOnChainState, + state_seq=[ + FakeOnChainState.PRE_REGISTRATION + ], # already there, skip terminate ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) @@ -4320,16 +4973,26 @@ def test_creates_pearl_safe_when_missing( def fake_create_safe(*, chain: Any, rpc: Any) -> None: creates.append({"chain": chain, "rpc": rpc}) pearl_safes[chain] = "0xpl-new" + pearl_wallet = types.SimpleNamespace( - safes=pearl_safes, create_safe=fake_create_safe, - ledger_api=lambda **kw: object(), address="0xpe", + safes=pearl_safes, + create_safe=fake_create_safe, + ledger_api=lambda **kw: object(), + address="0xpe", ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), - pearl_wallet=pearl_wallet, config_path=None, + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), + pearl_wallet=pearl_wallet, + config_path=None, ) assert len(creates) == 1 assert creates[0]["chain"] == FakeChain.GNOSIS @@ -4340,7 +5003,8 @@ def test_pearl_safe_creation_failure_is_unmigratable( ) -> None: m, FakeChain, FakeOnChainState = orch _, manager = self._setup_manager( - FakeChain, FakeOnChainState, + FakeChain, + FakeOnChainState, state_seq=[FakeOnChainState.PRE_REGISTRATION], ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) @@ -4349,50 +5013,81 @@ def test_pearl_safe_creation_failure_is_unmigratable( def boom(**kw: Any) -> None: raise RuntimeError("rpc rejected safe deployment") + pearl_wallet = types.SimpleNamespace( - safes={}, create_safe=boom, address="0xpe", + safes={}, + create_safe=boom, + address="0xpe", ledger_api=lambda **kw: object(), ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), - pearl_wallet=pearl_wallet, config_path=None, + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), + pearl_wallet=pearl_wallet, + config_path=None, ) assert "could not create Pearl master Safe" in ei.value.reason def test_force_remove_timeout_becomes_unmigratable( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """`force_remove_known_containers` raising `TimeoutExpired` (docker daemon hang) MUST surface as `_Unmigratable` — not propagate raw and abort the batch with on-chain ops about to commit.""" m, FakeChain, FakeOnChainState = orch _, manager = self._setup_manager( - FakeChain, FakeOnChainState, + FakeChain, + FakeOnChainState, state_seq=[FakeOnChainState.PRE_REGISTRATION], ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) + def boom() -> list: raise subprocess.TimeoutExpired(cmd=["docker", "rm"], timeout=30) + monkeypatch.setattr(m, "force_remove_known_containers", boom) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) assert "could not confirm containers are stopped" in ei.value.reason - @pytest.mark.parametrize("bug_cls", [ - TypeError, AttributeError, NameError, ImportError, - ]) + @pytest.mark.parametrize( + "bug_cls", + [ + TypeError, + AttributeError, + NameError, + ImportError, + ], + ) def test_stop_via_middleware_programming_bug_propagates( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, bug_cls: type, ) -> None: """A programming bug in middleware's stop_service must NOT be @@ -4400,58 +5095,96 @@ def test_stop_via_middleware_programming_bug_propagates( on-chain branch — propagate as a real traceback.""" m, FakeChain, FakeOnChainState = orch _, manager = self._setup_manager( - FakeChain, FakeOnChainState, + FakeChain, + FakeOnChainState, state_seq=[FakeOnChainState.PRE_REGISTRATION], ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) + def buggy(operate: Any, config_path: str) -> None: raise bug_cls("middleware refactor") + monkeypatch.setattr(m, "stop_via_middleware", buggy) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(bug_cls): m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path="cfg.json", ) - @pytest.mark.parametrize("bug_cls", [ - TypeError, AttributeError, NameError, ImportError, - ]) + @pytest.mark.parametrize( + "bug_cls", + [ + TypeError, + AttributeError, + NameError, + ImportError, + ], + ) def test_pearl_create_safe_programming_bug_propagates( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, bug_cls: type, ) -> None: """Programming bug in `pearl_wallet.create_safe` must propagate, not get wrapped as `_Unmigratable("could not create Pearl Safe")`.""" m, FakeChain, FakeOnChainState = orch _, manager = self._setup_manager( - FakeChain, FakeOnChainState, + FakeChain, + FakeOnChainState, state_seq=[FakeOnChainState.PRE_REGISTRATION], ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) - monkeypatch.setattr(m, "stop_via_middleware", - lambda operate, config_path: None) + monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) + def buggy(**kw: Any) -> None: raise bug_cls("middleware refactor") + pearl_wallet = types.SimpleNamespace( - safes={}, create_safe=buggy, address="0xpe", + safes={}, + create_safe=buggy, + address="0xpe", ledger_api=lambda **kw: object(), ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(bug_cls): m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), - pearl_wallet=pearl_wallet, config_path=None, + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), + pearl_wallet=pearl_wallet, + config_path=None, ) - @pytest.mark.parametrize("bug_cls", [ - TypeError, AttributeError, NameError, ImportError, - ]) + @pytest.mark.parametrize( + "bug_cls", + [ + TypeError, + AttributeError, + NameError, + ImportError, + ], + ) def test_ensure_signable_programming_bug_propagates( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, bug_cls: type, ) -> None: """A programming bug in `safe_threshold` (caller of @@ -4460,10 +5193,11 @@ def test_ensure_signable_programming_bug_propagates( m, FakeChain, FakeOnChainState = orch _, manager = self._setup_manager(FakeChain, FakeOnChainState) qs_app = types.SimpleNamespace(service_manager=lambda: manager) - monkeypatch.setattr(m, "stop_via_middleware", - lambda operate, config_path: None) + monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) + def buggy(**kw: Any) -> None: raise bug_cls("middleware refactor") + monkeypatch.setattr(m, "safe_threshold", buggy) # Force the signing branch by ensuring NFT is still on QS. monkeypatch.setattr(m, "service_nft_owner", lambda **kw: "0xqs") @@ -4471,73 +5205,107 @@ def buggy(**kw: Any) -> None: ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(bug_cls): m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) def test_force_remove_runtime_error_becomes_unmigratable( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """`docker_quickstart_containers` raising `RuntimeError` (docker daemon refusing to talk) MUST surface as `_Unmigratable`.""" m, FakeChain, FakeOnChainState = orch _, manager = self._setup_manager( - FakeChain, FakeOnChainState, + FakeChain, + FakeOnChainState, state_seq=[FakeOnChainState.PRE_REGISTRATION], ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) + def boom() -> list: raise RuntimeError("docker ps exited 1: permission denied") + monkeypatch.setattr(m, "force_remove_known_containers", boom) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) assert "could not confirm containers are stopped" in ei.value.reason def test_re_probe_runtime_error_becomes_unmigratable( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Re-probe via `docker_quickstart_containers` failing AFTER a clean `force_remove_known_containers` MUST also become `_Unmigratable`. Covers the daemon-hangs-between-rm-and-ps race.""" m, FakeChain, FakeOnChainState = orch _, manager = self._setup_manager( - FakeChain, FakeOnChainState, + FakeChain, + FakeOnChainState, state_seq=[FakeOnChainState.PRE_REGISTRATION], ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) monkeypatch.setattr(m, "force_remove_known_containers", lambda: []) + def boom() -> list: raise RuntimeError("docker ps exited 1: daemon gone") + monkeypatch.setattr(m, "docker_quickstart_containers", boom) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) assert "could not verify containers stopped" in ei.value.reason def test_leftover_containers_after_cleanup_become_unmigratable( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """If `docker rm -f` reported success but containers are still listed afterwards (daemon refused without erroring), MUST surface as `_Unmigratable` — proceeding would race the live deployment.""" m, FakeChain, FakeOnChainState = orch _, manager = self._setup_manager( - FakeChain, FakeOnChainState, + FakeChain, + FakeOnChainState, state_seq=[FakeOnChainState.PRE_REGISTRATION], ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) @@ -4548,8 +5316,14 @@ def test_leftover_containers_after_cleanup_become_unmigratable( ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) @@ -4566,7 +5340,8 @@ def test_qs_wallet_missing_chain_safe_is_unmigratable( the contract.""" m, FakeChain, FakeOnChainState = orch _, manager = self._setup_manager( - FakeChain, FakeOnChainState, + FakeChain, + FakeOnChainState, state_seq=[FakeOnChainState.PRE_REGISTRATION], ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) @@ -4576,9 +5351,15 @@ def test_qs_wallet_missing_chain_safe_is_unmigratable( ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, + svc=ref, + qs_app=qs_app, # No Safe registered for Gnosis -> KeyError on access. - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={}), + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) @@ -4592,7 +5373,8 @@ def test_contracts_missing_chain_is_unmigratable( service `_Unmigratable`, not crash the batch.""" m, FakeChain, FakeOnChainState = orch _, manager = self._setup_manager( - FakeChain, FakeOnChainState, + FakeChain, + FakeOnChainState, state_seq=[FakeOnChainState.PRE_REGISTRATION], ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) @@ -4601,6 +5383,7 @@ def test_contracts_missing_chain_is_unmigratable( # Strip the registry entry for Gnosis to simulate a chain without # a registered ServiceRegistry. from operate.ledger.profiles import CONTRACTS as _CONTRACTS + original = _CONTRACTS.get(FakeChain.GNOSIS, {}).copy() _CONTRACTS[FakeChain.GNOSIS] = { k: v for k, v in original.items() if k != "service_registry" @@ -4609,9 +5392,17 @@ def test_contracts_missing_chain_is_unmigratable( ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), - pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), + pearl_wallet=types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xpl"} + ), config_path=None, ) assert "no ServiceRegistry contract" in ei.value.reason @@ -4627,15 +5418,21 @@ def test_threshold_gt_1_raises_unmigratable( qs_app = types.SimpleNamespace(service_manager=lambda: manager) monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) monkeypatch.setattr(m, "force_remove_known_containers", lambda: []) - monkeypatch.setattr(m, "safe_threshold", lambda **kw: 2) # multi-sig + monkeypatch.setattr(m, "safe_threshold", lambda **kw: 2) # multi-sig monkeypatch.setattr(m, "service_nft_owner", lambda **kw: "0xqs") monkeypatch.setattr(m, "safe_owners", lambda **kw: ["0xqs"]) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) @@ -4656,8 +5453,14 @@ def test_threshold_zero_distinct_message( ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) @@ -4675,8 +5478,9 @@ def test_threshold_check_skipped_when_already_migrated( """ m, FakeChain, FakeOnChainState = orch _, manager = self._setup_manager( - FakeChain, FakeOnChainState, - state_seq=[FakeOnChainState.PRE_REGISTRATION], # already terminated + FakeChain, + FakeOnChainState, + state_seq=[FakeOnChainState.PRE_REGISTRATION], # already terminated ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) @@ -4686,24 +5490,32 @@ def test_threshold_check_skipped_when_already_migrated( monkeypatch.setattr(m, "safe_owners", lambda **kw: ["0xpl"]) threshold_calls: list = [] + def boom_threshold(**kw: Any) -> int: threshold_calls.append(kw) # Simulate user having raised threshold post-migration; would # raise _Unmigratable IF the check ran. return 2 + monkeypatch.setattr(m, "safe_threshold", boom_threshold) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) # Should complete cleanly: no signing → no threshold check. m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) - assert threshold_calls == [], ( - "safe_threshold must NOT be called when no signing branch runs" - ) + assert ( + threshold_calls == [] + ), "safe_threshold must NOT be called when no signing branch runs" def test_threshold_actually_invoked_when_signing( self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -4722,21 +5534,31 @@ def test_threshold_actually_invoked_when_signing( monkeypatch.setattr(m, "safe_owners", lambda **kw: ["0xqs"]) invocations: list = [] + def recording_threshold(**kw: Any) -> int: invocations.append(kw) return 1 + monkeypatch.setattr(m, "safe_threshold", recording_threshold) fake_transfer = types.ModuleType("scripts.pearl_migration.transfer") fake_transfer.PostConditionUnknown = type("PostConditionUnknown", (RuntimeError,), {}) # type: ignore[attr-defined] fake_transfer.transfer_service_nft = lambda **kw: "0x1" # type: ignore[attr-defined] fake_transfer.swap_service_safe_owner = lambda **kw: None # type: ignore[attr-defined] - monkeypatch.setitem(sys.modules, "scripts.pearl_migration.transfer", fake_transfer) + monkeypatch.setitem( + sys.modules, "scripts.pearl_migration.transfer", fake_transfer + ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) @@ -4756,20 +5578,29 @@ def test_threshold_read_failure_raises_unmigratable( def boom(**kw: Any) -> Any: raise RuntimeError("rpc 502") + monkeypatch.setattr(m, "safe_threshold", boom) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) assert "could not read quickstart master Safe threshold" in ei.value.reason def test_print_summary_clean_prints_success( - self, orch: Any, capsys: pytest.CaptureFixture[str], + self, + orch: Any, + capsys: pytest.CaptureFixture[str], ) -> None: """All-clean migration: prints a positive 'fully migrated: N' headline.""" m, *_ = orch @@ -4783,7 +5614,9 @@ def test_print_summary_clean_prints_success( assert "Migration incomplete" not in out def test_print_summary_subset_only_shows_subset_note( - self, orch: Any, capsys: pytest.CaptureFixture[str], + self, + orch: Any, + capsys: pytest.CaptureFixture[str], ) -> None: """When the only reason for incompleteness is a subset selection (no unmigratable, no drain failures), the summary surfaces the @@ -4801,16 +5634,22 @@ def test_print_summary_subset_only_shows_subset_note( assert "drains were skipped" in out def test_print_summary_subset_with_unmigratable_omits_subset_note( - self, orch: Any, capsys: pytest.CaptureFixture[str], + self, + orch: Any, + capsys: pytest.CaptureFixture[str], ) -> None: """Subset note is suppressed when there's also an unmigratable list — stacking 're-run for the rest' on top of 're-run after fixing the failure' would muddle the user's next action.""" m, *_ = orch outcome = m.MigrationOutcome( - unmigratable=(m._Unmigratable( - service_id="sc-aaa", chain="gnosis", reason="boom", - ),), + unmigratable=( + m._Unmigratable( + service_id="sc-aaa", + chain="gnosis", + reason="boom", + ), + ), subset_selected=True, ) m._print_migration_summary(outcome) @@ -4818,22 +5657,32 @@ def test_print_summary_subset_with_unmigratable_omits_subset_note( assert "subset migration:" not in out def test_print_summary_formats_each_failure_line( - self, orch: Any, capsys: pytest.CaptureFixture[str], + self, + orch: Any, + capsys: pytest.CaptureFixture[str], ) -> None: """Direct check on the per-failure formatting — would catch a typo in the `[chain] kind address: reason` template.""" m, FakeChain, _ = orch - unm = (m._Unmigratable( - service_id="sc-zzz", chain="gnosis", reason="cannot unstake yet", - ),) + unm = ( + m._Unmigratable( + service_id="sc-zzz", + chain="gnosis", + reason="cannot unstake yet", + ), + ) drains = ( m._DrainFailure( - chain=FakeChain.GNOSIS, source_kind="Safe", - source_address="0xsafe-addr", reason="insufficient gas", + chain=FakeChain.GNOSIS, + source_kind="Safe", + source_address="0xsafe-addr", + reason="insufficient gas", ), m._DrainFailure( - chain=FakeChain.OPTIMISM, source_kind="EOA", - source_address="0xeoa-addr", reason="rpc 502", + chain=FakeChain.OPTIMISM, + source_kind="EOA", + source_address="0xeoa-addr", + reason="rpc 502", ), ) outcome = m.MigrationOutcome( @@ -4859,8 +5708,9 @@ def test_no_config_path_skips_middleware_stop( _, manager = self._setup_manager(FakeChain, FakeOnChainState) qs_app = types.SimpleNamespace(service_manager=lambda: manager) called: list[str] = [] - monkeypatch.setattr(m, "stop_via_middleware", - lambda **kw: called.append("nope")) + monkeypatch.setattr( + m, "stop_via_middleware", lambda **kw: called.append("nope") + ) monkeypatch.setattr(m, "force_remove_known_containers", lambda: []) monkeypatch.setattr(m, "service_nft_owner", lambda **kw: "0xqs") monkeypatch.setattr(m, "safe_owners", lambda **kw: ["0xqs"]) @@ -4869,19 +5719,30 @@ def test_no_config_path_skips_middleware_stop( fake_transfer.transfer_service_nft = lambda **kw: "0x1" # type: ignore[attr-defined] fake_transfer.swap_service_safe_owner = lambda **kw: None # type: ignore[attr-defined] monkeypatch.setitem( - sys.modules, "scripts.pearl_migration.transfer", fake_transfer, + sys.modules, + "scripts.pearl_migration.transfer", + fake_transfer, ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) m._migrate_one_service( - svc=ref, qs_app=qs_app, - qs_wallet=types.SimpleNamespace(crypto="CR", address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}), + svc=ref, + qs_app=qs_app, + qs_wallet=types.SimpleNamespace( + crypto="CR", + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + safes={FakeChain.GNOSIS: "0xqs"}, + ), pearl_wallet=types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}), config_path=None, ) assert called == [] # config_path None bypasses stop_via_middleware def test_resets_staking_program_id_to_no_staking_after_success( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """After all on-chain steps succeed, every chain's `user_params.staking_program_id` is pinned to `no_staking` and the @@ -4895,14 +5756,16 @@ def test_resets_staking_program_id_to_no_staking_after_success( # so we can assert ALL chains get reset, not just the first. cc_g = types.SimpleNamespace( chain_data=types.SimpleNamespace( - token=1, multisig="0xms-g", + token=1, + multisig="0xms-g", user_params=types.SimpleNamespace(staking_program_id="qsp_g"), ), ledger_config=types.SimpleNamespace(rpc="http://rpc/g"), ) cc_o = types.SimpleNamespace( chain_data=types.SimpleNamespace( - token=2, multisig="0xms-o", + token=2, + multisig="0xms-o", user_params=types.SimpleNamespace(staking_program_id="qsp_o"), ), ledger_config=types.SimpleNamespace(rpc="http://rpc/o"), @@ -4921,8 +5784,7 @@ def test_resets_staking_program_id_to_no_staking_after_success( ) qs_app = types.SimpleNamespace(service_manager=lambda: manager) - monkeypatch.setattr(m, "stop_via_middleware", - lambda operate, config_path: None) + monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) monkeypatch.setattr(m, "force_remove_known_containers", lambda: []) # NFT already on Pearl, Safe owner already swapped — keeps the # test focused on the post-loop staking reset, not on-chain branches. @@ -4931,9 +5793,11 @@ def test_resets_staking_program_id_to_no_staking_after_success( ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) m._migrate_one_service( - svc=ref, qs_app=qs_app, + svc=ref, + qs_app=qs_app, qs_wallet=types.SimpleNamespace( - crypto="CR", address="0xqs_eoa", + crypto="CR", + address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs", FakeChain.OPTIMISM: "0xqs"}, ), @@ -4947,7 +5811,10 @@ def test_resets_staking_program_id_to_no_staking_after_success( assert store_calls == [True], "service.store() must be called once" def test_staking_reset_store_oserror_raises_unmigratable( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """If `service.store()` fails after on-chain ops have committed, a `_Unmigratable` is raised so the filesystem copy is skipped and @@ -4963,8 +5830,7 @@ def boom() -> None: manager.load("anything").store = boom # type: ignore[misc] qs_app = types.SimpleNamespace(service_manager=lambda: manager) - monkeypatch.setattr(m, "stop_via_middleware", - lambda operate, config_path: None) + monkeypatch.setattr(m, "stop_via_middleware", lambda operate, config_path: None) monkeypatch.setattr(m, "force_remove_known_containers", lambda: []) monkeypatch.setattr(m, "service_nft_owner", lambda **kw: "0xpl") monkeypatch.setattr(m, "safe_owners", lambda **kw: ["0xpl"]) @@ -4972,9 +5838,11 @@ def boom() -> None: ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(m._Unmigratable) as ei: m._migrate_one_service( - svc=ref, qs_app=qs_app, + svc=ref, + qs_app=qs_app, qs_wallet=types.SimpleNamespace( - crypto="CR", address="0xqs_eoa", + crypto="CR", + address="0xqs_eoa", ledger_api=lambda **kw: object(), safes={FakeChain.GNOSIS: "0xqs"}, ), @@ -4996,7 +5864,8 @@ class TestPearlSafeFundsWait: `ask_funds_in_address` (run_service.py:597).""" def test_is_insufficient_funds_error_matches_known_phrasings( - self, orch: Any, + self, + orch: Any, ) -> None: m, _, _ = orch assert m._is_insufficient_funds_error( @@ -5012,7 +5881,9 @@ def test_is_insufficient_funds_error_matches_known_phrasings( ) def test_wait_for_native_funds_returns_when_balance_increases( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """Polls until balance > current. First call seeds `current`, @@ -5021,10 +5892,14 @@ def test_wait_for_native_funds_returns_when_balance_increases( m, _, _ = orch balances = iter([100, 100, 250]) monkeypatch.setattr( - m, "get_asset_balance", lambda *a, **kw: next(balances), + m, + "get_asset_balance", + lambda *a, **kw: next(balances), ) monkeypatch.setattr( - m, "wei_to_token", lambda wei, chain, asset: f"{wei}wei", + m, + "wei_to_token", + lambda wei, chain, asset: f"{wei}wei", ) slept: list[float] = [] monkeypatch.setattr(m.time, "sleep", lambda s: slept.append(s)) @@ -5042,7 +5917,9 @@ def test_wait_for_native_funds_returns_when_balance_increases( assert "Pearl master EOA 0xpe" in out def test_create_pearl_safe_waits_then_succeeds_on_insufficient_funds( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """When `create_safe` first raises an insufficient-funds error @@ -5059,11 +5936,15 @@ def create_safe(*, chain: Any, rpc: Any) -> None: # Second attempt: user has topped up, succeed silently. pearl = types.SimpleNamespace( - safes={}, create_safe=create_safe, address="0xpe", + safes={}, + create_safe=create_safe, + address="0xpe", ) balances = iter([0, 1000]) # current=0, then post-topup=1000. monkeypatch.setattr( - m, "get_asset_balance", lambda *a, **kw: next(balances), + m, + "get_asset_balance", + lambda *a, **kw: next(balances), ) monkeypatch.setattr(m, "wei_to_token", lambda *a, **kw: "stub") monkeypatch.setattr(m.time, "sleep", lambda s: None) @@ -5081,7 +5962,9 @@ def create_safe(*, chain: Any, rpc: Any) -> None: assert "Detected" in out def test_wait_for_native_funds_tolerates_rpc_blip_mid_poll( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """Pin: a transient RPC failure during the poll loop MUST NOT @@ -5104,7 +5987,9 @@ def fake_balance(*a: Any, **kw: Any) -> int: monkeypatch.setattr(m, "wei_to_token", lambda wei, *a, **kw: f"{wei}wei") monkeypatch.setattr(m.time, "sleep", lambda s: None) m._wait_for_native_funds( - ledger_api=object(), address="0xpe", chain_str="gnosis", + ledger_api=object(), + address="0xpe", + chain_str="gnosis", recipient_name="Pearl master EOA", ) out = capsys.readouterr().out @@ -5115,16 +6000,22 @@ def fake_balance(*a: Any, **kw: Any) -> int: assert "Detected 250wei arrived" in out def test_create_pearl_safe_propagates_non_funds_error( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, ) -> None: """A non-funds error MUST surface to the caller (which wraps it as `_Unmigratable` / `_DrainFailure`) rather than spinning the wait loop on an unrelated failure that won't ever cure.""" m, FakeChain, _ = orch + def create_safe(*, chain: Any, rpc: Any) -> None: raise RuntimeError("rpc rejected") + pearl = types.SimpleNamespace( - safes={}, create_safe=create_safe, address="0xpe", + safes={}, + create_safe=create_safe, + address="0xpe", ) with pytest.raises(RuntimeError, match="rpc rejected"): m._create_pearl_safe_with_funds_wait( @@ -5138,7 +6029,9 @@ def create_safe(*, chain: Any, rpc: Any) -> None: class TestDrainMaster: def test_announces_pearl_safe_creation( - self, orch: Any, capsys: pytest.CaptureFixture[str], + self, + orch: Any, + capsys: pytest.CaptureFixture[str], ) -> None: """When Pearl lacks a Safe on a chain we announce + create rather than skip.""" m, FakeChain, _ = orch @@ -5147,34 +6040,42 @@ def test_announces_pearl_safe_creation( def fake_create_safe(*, chain: Any, rpc: Any) -> None: pearl_safes[chain] = "0xpl" - qs = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xqs"}, - address="0xqs_eoa", - ledger_api=lambda **kw: object(), - drain=lambda **kw: {}) - pearl = types.SimpleNamespace(safes=pearl_safes, - create_safe=fake_create_safe, - address="0xpe", - ledger_api=lambda **kw: object()) + qs = types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xqs"}, + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + drain=lambda **kw: {}, + ) + pearl = types.SimpleNamespace( + safes=pearl_safes, + create_safe=fake_create_safe, + address="0xpe", + ledger_api=lambda **kw: object(), + ) m._drain_master( - qs_wallet=qs, pearl_wallet=pearl, + qs_wallet=qs, + pearl_wallet=pearl, chain_rpcs={FakeChain.GNOSIS: "https://rpc/gnosis"}, ) out = capsys.readouterr().out assert "Pearl has no master Safe" in out and "creating one" in out def test_drain_empty_moved_emits_info_line( - self, orch: Any, capsys: pytest.CaptureFixture[str], + self, + orch: Any, + capsys: pytest.CaptureFixture[str], ) -> None: """When `qs_wallet.drain()` returns `{}`, emit an explicit "no balances to move" line so silence isn't ambiguous between "nothing to drain" and "silently dropped".""" m, FakeChain, _ = orch - qs = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xqs"}, - address="0xqs_eoa", - ledger_api=lambda **kw: object(), - drain=lambda **kw: {}) - pearl = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}, - address="0xpe") + qs = types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xqs"}, + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + drain=lambda **kw: {}, + ) + pearl = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}, address="0xpe") m._drain_master(qs_wallet=qs, pearl_wallet=pearl) out = capsys.readouterr().out assert "no balances to move from Safe on GNOSIS" in out @@ -5185,15 +6086,20 @@ def test_drain_safe_and_eoa( ) -> None: m, FakeChain, _ = orch called: list[dict[str, Any]] = [] - def fake_drain(withdrawal_address: str, chain: Any, from_safe: bool) -> dict[str, int]: + + def fake_drain( + withdrawal_address: str, chain: Any, from_safe: bool + ) -> dict[str, int]: called.append({"to": withdrawal_address, "from_safe": from_safe}) return {"0xtoken": 100} - qs = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xqs"}, - address="0xqs_eoa", - ledger_api=lambda **kw: object(), - drain=fake_drain) - pearl = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}, - address="0xpe") + + qs = types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xqs"}, + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + drain=fake_drain, + ) + pearl = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}, address="0xpe") failures = m._drain_master(qs_wallet=qs, pearl_wallet=pearl) assert failures == [] assert {c["from_safe"] for c in called} == {True, False} @@ -5201,19 +6107,26 @@ def fake_drain(withdrawal_address: str, chain: Any, from_safe: bool) -> dict[str assert called[1]["to"] == "0xpe" def test_drain_safe_failure_returns_failure_record( - self, orch: Any, capsys: pytest.CaptureFixture[str], + self, + orch: Any, + capsys: pytest.CaptureFixture[str], ) -> None: m, FakeChain, _ = orch - def fake_drain(withdrawal_address: str, chain: Any, from_safe: bool) -> dict[str, int]: + + def fake_drain( + withdrawal_address: str, chain: Any, from_safe: bool + ) -> dict[str, int]: if from_safe: raise RuntimeError("safe drain boom") return {} - qs = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xqs"}, - address="0xqs_eoa", - ledger_api=lambda **kw: object(), - drain=fake_drain) - pearl = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}, - address="0xpe") + + qs = types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xqs"}, + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + drain=fake_drain, + ) + pearl = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}, address="0xpe") failures = m._drain_master(qs_wallet=qs, pearl_wallet=pearl) assert len(failures) == 1 assert failures[0].source_kind == "Safe" @@ -5221,26 +6134,34 @@ def fake_drain(withdrawal_address: str, chain: Any, from_safe: bool) -> dict[str assert "drain (Safe)" in capsys.readouterr().out def test_drain_eoa_failure_returns_failure_record( - self, orch: Any, capsys: pytest.CaptureFixture[str], + self, + orch: Any, + capsys: pytest.CaptureFixture[str], ) -> None: m, FakeChain, _ = orch - def fake_drain(withdrawal_address: str, chain: Any, from_safe: bool) -> dict[str, int]: + + def fake_drain( + withdrawal_address: str, chain: Any, from_safe: bool + ) -> dict[str, int]: if not from_safe: raise RuntimeError("eoa drain boom") return {} - qs = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xqs"}, - address="0xqs_eoa", - ledger_api=lambda **kw: object(), - drain=fake_drain) - pearl = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}, - address="0xpe") + + qs = types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xqs"}, + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + drain=fake_drain, + ) + pearl = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}, address="0xpe") failures = m._drain_master(qs_wallet=qs, pearl_wallet=pearl) assert len(failures) == 1 assert failures[0].source_kind == "EOA" assert "drain (EOA)" in capsys.readouterr().out def test_drain_creates_pearl_safe_when_missing( - self, orch: Any, + self, + orch: Any, ) -> None: """If Pearl has no Safe on a chain, create one then drain into it.""" m, FakeChain, _ = orch @@ -5253,91 +6174,132 @@ def fake_create_safe(*, chain: Any, rpc: Any) -> None: pearl_safes[chain] = "0xpl-fresh" called: list = [] + def fake_drain(withdrawal_address: str, chain: Any, from_safe: bool) -> dict: called.append({"to": withdrawal_address, "from_safe": from_safe}) return {} - qs = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xqs"}, - address="0xqs_eoa", - ledger_api=lambda **kw: object(), - drain=fake_drain) - pearl = types.SimpleNamespace(safes=pearl_safes, - create_safe=fake_create_safe, - address="0xpe", - ledger_api=lambda **kw: object()) + qs = types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xqs"}, + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + drain=fake_drain, + ) + pearl = types.SimpleNamespace( + safes=pearl_safes, + create_safe=fake_create_safe, + address="0xpe", + ledger_api=lambda **kw: object(), + ) failures = m._drain_master( - qs_wallet=qs, pearl_wallet=pearl, + qs_wallet=qs, + pearl_wallet=pearl, chain_rpcs={FakeChain.GNOSIS: "https://rpc.example/gnosis"}, ) assert failures == [] - assert creates == [{"chain": FakeChain.GNOSIS, - "rpc": "https://rpc.example/gnosis"}] + assert creates == [ + {"chain": FakeChain.GNOSIS, "rpc": "https://rpc.example/gnosis"} + ] # Drains then ran into the freshly-created Pearl Safe. assert called[0]["to"] == "0xpl-fresh" assert called[1]["to"] == "0xpe" - @pytest.mark.parametrize("bug_cls", [ - TypeError, AttributeError, NameError, ImportError, - ]) + @pytest.mark.parametrize( + "bug_cls", + [ + TypeError, + AttributeError, + NameError, + ImportError, + ], + ) def test_drain_programming_bug_propagates( - self, orch: Any, bug_cls: type, + self, + orch: Any, + bug_cls: type, ) -> None: """A programming bug in `qs_wallet.drain()` (e.g. middleware signature drift) MUST propagate as a real traceback, NOT get silently aggregated into `_DrainFailure` where the user would chase 'RPC retry' instead of the real bug.""" m, FakeChain, _ = orch + def buggy_drain(withdrawal_address: str, chain: Any, from_safe: bool) -> dict: raise bug_cls("simulated middleware refactor bug") - qs = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xqs"}, - address="0xqs_eoa", - ledger_api=lambda **kw: object(), - drain=buggy_drain) - pearl = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}, - address="0xpe") + + qs = types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xqs"}, + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + drain=buggy_drain, + ) + pearl = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}, address="0xpe") with pytest.raises(bug_cls): m._drain_master(qs_wallet=qs, pearl_wallet=pearl) - @pytest.mark.parametrize("bug_cls", [ - TypeError, AttributeError, NameError, ImportError, - ]) + @pytest.mark.parametrize( + "bug_cls", + [ + TypeError, + AttributeError, + NameError, + ImportError, + ], + ) def test_drain_create_safe_programming_bug_propagates( - self, orch: Any, bug_cls: type, + self, + orch: Any, + bug_cls: type, ) -> None: """Same policy for the Pearl Safe creation site in `_drain_master`.""" m, FakeChain, _ = orch + def buggy_create(*, chain: Any, rpc: Any) -> None: raise bug_cls("simulated middleware refactor bug") - qs = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xqs"}, - address="0xqs_eoa", - ledger_api=lambda **kw: object(), - drain=lambda **kw: {}) - pearl = types.SimpleNamespace(safes={}, create_safe=buggy_create, - address="0xpe", - ledger_api=lambda **kw: object()) + + qs = types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xqs"}, + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + drain=lambda **kw: {}, + ) + pearl = types.SimpleNamespace( + safes={}, + create_safe=buggy_create, + address="0xpe", + ledger_api=lambda **kw: object(), + ) with pytest.raises(bug_cls): m._drain_master( - qs_wallet=qs, pearl_wallet=pearl, + qs_wallet=qs, + pearl_wallet=pearl, chain_rpcs={FakeChain.GNOSIS: "https://rpc/gnosis"}, ) def test_drain_pearl_safe_creation_failure_records_failure( - self, orch: Any, capsys: pytest.CaptureFixture[str], + self, + orch: Any, + capsys: pytest.CaptureFixture[str], ) -> None: m, FakeChain, _ = orch def boom(*, chain: Any, rpc: Any) -> None: raise RuntimeError("create reverted") - qs = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xqs"}, - address="0xqs_eoa", - ledger_api=lambda **kw: object(), - drain=lambda **kw: {}) - pearl = types.SimpleNamespace(safes={}, create_safe=boom, address="0xpe", - ledger_api=lambda **kw: object()) + + qs = types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xqs"}, + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + drain=lambda **kw: {}, + ) + pearl = types.SimpleNamespace( + safes={}, create_safe=boom, address="0xpe", ledger_api=lambda **kw: object() + ) # Provide an RPC so the create_safe boom is reached (new pre-check # would short-circuit otherwise). failures = m._drain_master( - qs_wallet=qs, pearl_wallet=pearl, + qs_wallet=qs, + pearl_wallet=pearl, chain_rpcs={FakeChain.GNOSIS: "https://rpc/gnosis"}, ) assert len(failures) == 1 @@ -5346,7 +6308,9 @@ def boom(*, chain: Any, rpc: Any) -> None: assert "could not create Pearl Safe" in capsys.readouterr().out def test_drain_skips_chain_with_no_rpc_records_failure( - self, orch: Any, capsys: pytest.CaptureFixture[str], + self, + orch: Any, + capsys: pytest.CaptureFixture[str], ) -> None: """Pre-check: if Pearl needs a Safe on a chain but `chain_rpcs` has no entry, we MUST NOT pass `rpc=None` to `create_safe` (silent @@ -5357,13 +6321,19 @@ def test_drain_skips_chain_with_no_rpc_records_failure( def fake_create_safe(*, chain: Any, rpc: Any) -> None: creates.append(chain) - qs = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xqs"}, - address="0xqs_eoa", - ledger_api=lambda **kw: object(), - drain=lambda **kw: {}) - pearl = types.SimpleNamespace(safes={}, create_safe=fake_create_safe, - address="0xpe", - ledger_api=lambda **kw: object()) + + qs = types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xqs"}, + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + drain=lambda **kw: {}, + ) + pearl = types.SimpleNamespace( + safes={}, + create_safe=fake_create_safe, + address="0xpe", + ledger_api=lambda **kw: object(), + ) failures = m._drain_master(qs_wallet=qs, pearl_wallet=pearl, chain_rpcs={}) # create_safe MUST NOT have been called with rpc=None. assert creates == [] @@ -5373,7 +6343,8 @@ def fake_create_safe(*, chain: Any, rpc: Any) -> None: assert "no RPC available" in capsys.readouterr().out def test_drain_per_chain_isolation( - self, orch: Any, + self, + orch: Any, ) -> None: """Docstring claims one chain's RPC outage shouldn't prevent draining the others. Pin it: chain A's Safe creation fails, @@ -5391,16 +6362,19 @@ def fake_create_safe(*, chain: Any, rpc: Any) -> None: qs = types.SimpleNamespace( safes={FakeChain.GNOSIS: "0xqs-g", FakeChain.OPTIMISM: "0xqs-o"}, - address="0xqs_eoa", drain=fake_drain, + address="0xqs_eoa", + drain=fake_drain, ledger_api=lambda **kw: object(), ) pearl = types.SimpleNamespace( - safes={FakeChain.OPTIMISM: "0xpl-o"}, # missing GNOSIS - create_safe=fake_create_safe, address="0xpe", + safes={FakeChain.OPTIMISM: "0xpl-o"}, # missing GNOSIS + create_safe=fake_create_safe, + address="0xpe", ledger_api=lambda **kw: object(), ) failures = m._drain_master( - qs_wallet=qs, pearl_wallet=pearl, + qs_wallet=qs, + pearl_wallet=pearl, chain_rpcs={FakeChain.GNOSIS: "https://rpc/gnosis"}, ) # Gnosis aborted (Safe creation failed) -> single failure. @@ -5414,7 +6388,8 @@ def fake_create_safe(*, chain: Any, rpc: Any) -> None: assert (FakeChain.GNOSIS, True) not in drained def test_safe_failure_does_not_skip_eoa_on_same_chain( - self, orch: Any, + self, + orch: Any, ) -> None: """Safe drain failure on chain X must still attempt EOA drain on chain X. A future refactor combining both into one try block @@ -5427,12 +6402,14 @@ def fake_drain(withdrawal_address: str, chain: Any, from_safe: bool) -> dict: if from_safe: raise RuntimeError("safe drain boom") return {} - qs = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xqs"}, - address="0xqs_eoa", - ledger_api=lambda **kw: object(), - drain=fake_drain) - pearl = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}, - address="0xpe") + + qs = types.SimpleNamespace( + safes={FakeChain.GNOSIS: "0xqs"}, + address="0xqs_eoa", + ledger_api=lambda **kw: object(), + drain=fake_drain, + ) + pearl = types.SimpleNamespace(safes={FakeChain.GNOSIS: "0xpl"}, address="0xpe") m._drain_master(qs_wallet=qs, pearl_wallet=pearl) # Both rails attempted — Safe first (raised), EOA second (succeeded). assert calls == [True, False] @@ -5440,14 +6417,19 @@ def fake_drain(withdrawal_address: str, chain: Any, from_safe: bool) -> dict: class TestRunModeB: def test_password_align_runs_when_passwords_differ( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """qs_app.password != pearl_app.password -> user is asked to confirm and align_quickstart_password is invoked with Pearl's password.""" m, *_ = orch - qs_root = tmp_path / "qs/.operate"; qs_root.mkdir(parents=True) - pl_root = tmp_path / "pl/.operate"; pl_root.mkdir(parents=True) + qs_root = tmp_path / "qs/.operate" + qs_root.mkdir(parents=True) + pl_root = tmp_path / "pl/.operate" + pl_root.mkdir(parents=True) d = detect.Discovery( quickstart=detect.OperateStore(root=qs_root), pearl=detect.OperateStore(root=pl_root), @@ -5456,22 +6438,27 @@ def test_password_align_runs_when_passwords_differ( ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) passwords = iter(["qs-pw", "pearl-pw"]) monkeypatch.setattr( - m, "_load_wallet", + m, + "_load_wallet", lambda store, label: ( types.SimpleNamespace( - password=next(passwords), user_account=None, - ), "WALLET", + password=next(passwords), + user_account=None, + ), + "WALLET", ), ) align_calls: list[dict[str, Any]] = [] monkeypatch.setattr( - m, "align_quickstart_password", + m, + "align_quickstart_password", lambda **kw: align_calls.append(kw), ) monkeypatch.setattr(m, "fix_root_ownership", lambda store: None) monkeypatch.setattr(m, "_migrate_one_service", lambda **kw: None) monkeypatch.setattr( - m, "merge_service", + m, + "merge_service", lambda service, src, dest: types.SimpleNamespace(), ) monkeypatch.setattr(m, "_drain_master", lambda **kw: []) @@ -5486,11 +6473,16 @@ def test_password_align_runs_when_passwords_differ( assert "ALIGNING QUICKSTART PASSWORD" in out def test_password_align_aborts_on_user_decline( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: m, *_ = orch - qs_root = tmp_path / "qs/.operate"; qs_root.mkdir(parents=True) - pl_root = tmp_path / "pl/.operate"; pl_root.mkdir(parents=True) + qs_root = tmp_path / "qs/.operate" + qs_root.mkdir(parents=True) + pl_root = tmp_path / "pl/.operate" + pl_root.mkdir(parents=True) d = detect.Discovery( quickstart=detect.OperateStore(root=qs_root), pearl=detect.OperateStore(root=pl_root), @@ -5499,16 +6491,20 @@ def test_password_align_aborts_on_user_decline( ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) passwords = iter(["qs-pw", "pearl-pw"]) monkeypatch.setattr( - m, "_load_wallet", + m, + "_load_wallet", lambda store, label: ( types.SimpleNamespace( - password=next(passwords), user_account=None, - ), "WALLET", + password=next(passwords), + user_account=None, + ), + "WALLET", ), ) align_called = {"n": 0} monkeypatch.setattr( - m, "align_quickstart_password", + m, + "align_quickstart_password", lambda **kw: align_called.update(n=align_called["n"] + 1), ) monkeypatch.setattr(m, "yes_no", lambda *a, **k: False) @@ -5518,7 +6514,10 @@ def test_password_align_aborts_on_user_decline( assert align_called["n"] == 0 def test_password_align_failure_fatals_with_recovery_message( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """If `align_quickstart_password` raises (e.g. disk-full mid @@ -5529,8 +6528,10 @@ def test_password_align_failure_fatals_with_recovery_message( that tells the user which recovery path applies. """ m, *_ = orch - qs_root = tmp_path / "qs/.operate"; qs_root.mkdir(parents=True) - pl_root = tmp_path / "pl/.operate"; pl_root.mkdir(parents=True) + qs_root = tmp_path / "qs/.operate" + qs_root.mkdir(parents=True) + pl_root = tmp_path / "pl/.operate" + pl_root.mkdir(parents=True) d = detect.Discovery( quickstart=detect.OperateStore(root=qs_root), pearl=detect.OperateStore(root=pl_root), @@ -5539,16 +6540,20 @@ def test_password_align_failure_fatals_with_recovery_message( ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) passwords = iter(["qs-pw", "pearl-pw"]) monkeypatch.setattr( - m, "_load_wallet", + m, + "_load_wallet", lambda store, label: ( types.SimpleNamespace( - password=next(passwords), user_account=None, - ), "WALLET", + password=next(passwords), + user_account=None, + ), + "WALLET", ), ) def boom(**kw: Any) -> None: raise OSError("disk full") + monkeypatch.setattr(m, "align_quickstart_password", boom) monkeypatch.setattr(m, "yes_no", lambda *a, **k: True) @@ -5562,7 +6567,10 @@ def boom(**kw: Any) -> None: assert "nothing on-chain has changed" in combined, combined def test_password_align_failure_does_not_swallow_programming_bugs( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """`_reraise_if_programming_bug` must run before `fatal(...)` so a `TypeError` / `AttributeError` from a middleware refactor @@ -5570,8 +6578,10 @@ def test_password_align_failure_does_not_swallow_programming_bugs( that hides the bug under a "user error" framing. """ m, *_ = orch - qs_root = tmp_path / "qs/.operate"; qs_root.mkdir(parents=True) - pl_root = tmp_path / "pl/.operate"; pl_root.mkdir(parents=True) + qs_root = tmp_path / "qs/.operate" + qs_root.mkdir(parents=True) + pl_root = tmp_path / "pl/.operate" + pl_root.mkdir(parents=True) d = detect.Discovery( quickstart=detect.OperateStore(root=qs_root), pearl=detect.OperateStore(root=pl_root), @@ -5580,16 +6590,20 @@ def test_password_align_failure_does_not_swallow_programming_bugs( ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) passwords = iter(["qs-pw", "pearl-pw"]) monkeypatch.setattr( - m, "_load_wallet", + m, + "_load_wallet", lambda store, label: ( types.SimpleNamespace( - password=next(passwords), user_account=None, - ), "WALLET", + password=next(passwords), + user_account=None, + ), + "WALLET", ), ) def attribute_bug(**kw: Any) -> None: raise AttributeError("middleware refactor: .password no longer exists") + monkeypatch.setattr(m, "align_quickstart_password", attribute_bug) monkeypatch.setattr(m, "yes_no", lambda *a, **k: True) @@ -5623,22 +6637,33 @@ def test_happy_path( mode=detect.Mode.MERGE, ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) - monkeypatch.setattr(m, "_load_wallet", - lambda store, label: (types.SimpleNamespace(password="pw", user_account=None), "WALLET")) + monkeypatch.setattr( + m, + "_load_wallet", + lambda store, label: ( + types.SimpleNamespace(password="pw", user_account=None), + "WALLET", + ), + ) monkeypatch.setattr(m, "fix_root_ownership", lambda store: None) monkeypatch.setattr(m, "_migrate_one_service", lambda **kw: None) - monkeypatch.setattr(m, "merge_service", - lambda service, src, dest: types.SimpleNamespace()) + monkeypatch.setattr( + m, "merge_service", lambda service, src, dest: types.SimpleNamespace() + ) drain_called = {"n": 0} + def fake_drain(**kw: Any) -> list: drain_called["n"] += 1 - return [] # empty failures = full success + return [] # empty failures = full success + monkeypatch.setattr(m, "_drain_master", fake_drain) monkeypatch.setattr(m, "yes_no", lambda *a, **k: True) rename_called = {"n": 0} + def fake_rename(src: Any) -> Path: rename_called["n"] += 1 return src.root + monkeypatch.setattr(m, "rename_source_for_rollback", fake_rename) m._run_mode_b(disc=d, services=[ref], config_path=None, dry_run=False) @@ -5646,7 +6671,10 @@ def fake_rename(src: Any) -> Path: assert rename_called["n"] == 1 def test_subset_selection_skips_drain_and_rename( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """`subset_selected=True` (user picked a single service from many, @@ -5655,29 +6683,47 @@ def test_subset_selection_skips_drain_and_rename( gas funds, and must NOT rename the source `.operate/` because the unselected services still live there.""" m, *_ = orch - qs_root = tmp_path / "qs/.operate"; qs_root.mkdir(parents=True) - pl_root = tmp_path / "pl/.operate"; pl_root.mkdir(parents=True) + qs_root = tmp_path / "qs/.operate" + qs_root.mkdir(parents=True) + pl_root = tmp_path / "pl/.operate" + pl_root.mkdir(parents=True) d = detect.Discovery( quickstart=detect.OperateStore(root=qs_root), pearl=detect.OperateStore(root=pl_root), mode=detect.Mode.MERGE, ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) - monkeypatch.setattr(m, "_load_wallet", - lambda store, label: (types.SimpleNamespace(password="pw", user_account=None), "WALLET")) + monkeypatch.setattr( + m, + "_load_wallet", + lambda store, label: ( + types.SimpleNamespace(password="pw", user_account=None), + "WALLET", + ), + ) monkeypatch.setattr(m, "fix_root_ownership", lambda store: None) monkeypatch.setattr(m, "_migrate_one_service", lambda **kw: None) - monkeypatch.setattr(m, "merge_service", - lambda service, src, dest: types.SimpleNamespace()) + monkeypatch.setattr( + m, "merge_service", lambda service, src, dest: types.SimpleNamespace() + ) called = {"drain": 0, "rename": 0} - monkeypatch.setattr(m, "_drain_master", - lambda **kw: called.update(drain=called["drain"] + 1) or []) - monkeypatch.setattr(m, "rename_source_for_rollback", - lambda store: called.update(rename=called["rename"] + 1) or store.root) + monkeypatch.setattr( + m, + "_drain_master", + lambda **kw: called.update(drain=called["drain"] + 1) or [], + ) + monkeypatch.setattr( + m, + "rename_source_for_rollback", + lambda store: called.update(rename=called["rename"] + 1) or store.root, + ) monkeypatch.setattr(m, "yes_no", lambda *a, **k: True) outcome = m._run_mode_b( - disc=d, services=[ref], config_path=None, dry_run=False, + disc=d, + services=[ref], + config_path=None, + dry_run=False, subset_selected=True, ) @@ -5691,13 +6737,18 @@ def test_subset_selection_skips_drain_and_rename( assert "remaining services still live in the source" in out def test_subset_selection_dry_run_records_subset_flag( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Dry-run must still surface `subset_selected` so the caller can skip the post-run prompts even when no on-chain work happened.""" m, *_ = orch - qs_root = tmp_path / "qs/.operate"; qs_root.mkdir(parents=True) - pl_root = tmp_path / "pl/.operate"; pl_root.mkdir(parents=True) + qs_root = tmp_path / "qs/.operate" + qs_root.mkdir(parents=True) + pl_root = tmp_path / "pl/.operate" + pl_root.mkdir(parents=True) d = detect.Discovery( quickstart=detect.OperateStore(root=qs_root), pearl=detect.OperateStore(root=pl_root), @@ -5705,13 +6756,19 @@ def test_subset_selection_dry_run_records_subset_flag( ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) outcome = m._run_mode_b( - disc=d, services=[ref], config_path=None, dry_run=True, + disc=d, + services=[ref], + config_path=None, + dry_run=True, subset_selected=True, ) assert outcome.subset_selected is True def test_unmigratable_skips_drain( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: m, *_ = orch @@ -5725,19 +6782,33 @@ def test_unmigratable_skips_drain( mode=detect.Mode.MERGE, ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) - monkeypatch.setattr(m, "_load_wallet", - lambda store, label: (types.SimpleNamespace(password="pw", user_account=None), "WALLET")) + monkeypatch.setattr( + m, + "_load_wallet", + lambda store, label: ( + types.SimpleNamespace(password="pw", user_account=None), + "WALLET", + ), + ) monkeypatch.setattr(m, "fix_root_ownership", lambda store: None) + def boom(**kw: Any) -> None: raise m._Unmigratable( - service_id="sc-aaa", chain="gnosis", reason="cannot unstake", + service_id="sc-aaa", + chain="gnosis", + reason="cannot unstake", ) + monkeypatch.setattr(m, "_migrate_one_service", boom) called = {"drain": 0, "rename": 0} - monkeypatch.setattr(m, "_drain_master", - lambda **kw: called.update(drain=called["drain"] + 1)) - monkeypatch.setattr(m, "rename_source_for_rollback", - lambda store: called.update(rename=called["rename"] + 1) or store.root) + monkeypatch.setattr( + m, "_drain_master", lambda **kw: called.update(drain=called["drain"] + 1) + ) + monkeypatch.setattr( + m, + "rename_source_for_rollback", + lambda store: called.update(rename=called["rename"] + 1) or store.root, + ) m._run_mode_b(disc=d, services=[ref], config_path=None, dry_run=False) assert called["drain"] == 0 # Nothing migrated AND incomplete -> never rename source. @@ -5748,7 +6819,10 @@ def boom(**kw: Any) -> None: assert "Source `.operate/` left in place" in out def test_merge_service_oserror_aggregates_as_unmigratable( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """`merge_service` runs AFTER on-chain ops. An OSError here would, @@ -5767,18 +6841,30 @@ def test_merge_service_oserror_aggregates_as_unmigratable( mode=detect.Mode.MERGE, ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) - monkeypatch.setattr(m, "_load_wallet", - lambda store, label: (types.SimpleNamespace(password="pw", user_account=None), "WALLET")) + monkeypatch.setattr( + m, + "_load_wallet", + lambda store, label: ( + types.SimpleNamespace(password="pw", user_account=None), + "WALLET", + ), + ) monkeypatch.setattr(m, "fix_root_ownership", lambda store: None) monkeypatch.setattr(m, "_migrate_one_service", lambda **kw: None) + def boom_copy(service: Any, src: Any, dest: Any) -> None: raise OSError(28, "no space left on device") + monkeypatch.setattr(m, "merge_service", boom_copy) called = {"drain": 0, "rename": 0} - monkeypatch.setattr(m, "_drain_master", - lambda **kw: called.update(drain=called["drain"] + 1)) - monkeypatch.setattr(m, "rename_source_for_rollback", - lambda store: called.update(rename=called["rename"] + 1) or store.root) + monkeypatch.setattr( + m, "_drain_master", lambda **kw: called.update(drain=called["drain"] + 1) + ) + monkeypatch.setattr( + m, + "rename_source_for_rollback", + lambda store: called.update(rename=called["rename"] + 1) or store.root, + ) m._run_mode_b(disc=d, services=[ref], config_path=None, dry_run=False) # Drain skipped (unmigratable present); rename skipped (incomplete). assert called["drain"] == 0 @@ -5790,11 +6876,15 @@ def boom_copy(service: Any, src: Any, dest: Any) -> None: assert "sc-aaa" in out def test_merge_service_shutil_error_also_aggregates( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """shutil.Error (separate exception type from OSError — used for copytree multi-error aggregation) is also caught.""" import shutil as _shutil + m, *_ = orch qs_root = tmp_path / "qs/.operate" qs_root.mkdir(parents=True) @@ -5806,19 +6896,30 @@ def test_merge_service_shutil_error_also_aggregates( mode=detect.Mode.MERGE, ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) - monkeypatch.setattr(m, "_load_wallet", - lambda store, label: (types.SimpleNamespace(password="pw", user_account=None), "WALLET")) + monkeypatch.setattr( + m, + "_load_wallet", + lambda store, label: ( + types.SimpleNamespace(password="pw", user_account=None), + "WALLET", + ), + ) monkeypatch.setattr(m, "fix_root_ownership", lambda store: None) monkeypatch.setattr(m, "_migrate_one_service", lambda **kw: None) + def boom_copy(service: Any, src: Any, dest: Any) -> None: raise _shutil.Error([("a", "b", "permission denied")]) + monkeypatch.setattr(m, "merge_service", boom_copy) monkeypatch.setattr(m, "_drain_master", lambda **kw: []) # Should not raise; drain/rename gating happens at the higher level. m._run_mode_b(disc=d, services=[ref], config_path=None, dry_run=False) def test_drain_failure_blocks_rename( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """All services migrate but a drain fails -> source MUST NOT be renamed.""" @@ -5833,33 +6934,48 @@ def test_drain_failure_blocks_rename( mode=detect.Mode.MERGE, ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) - monkeypatch.setattr(m, "_load_wallet", - lambda store, label: (types.SimpleNamespace(password="pw", user_account=None), "WALLET")) + monkeypatch.setattr( + m, + "_load_wallet", + lambda store, label: ( + types.SimpleNamespace(password="pw", user_account=None), + "WALLET", + ), + ) monkeypatch.setattr(m, "fix_root_ownership", lambda store: None) monkeypatch.setattr(m, "_migrate_one_service", lambda **kw: None) - monkeypatch.setattr(m, "merge_service", - lambda service, src, dest: types.SimpleNamespace()) + monkeypatch.setattr( + m, "merge_service", lambda service, src, dest: types.SimpleNamespace() + ) # Drain returns a non-empty failure list. m_FakeChain = orch[1] fake_failure = m._DrainFailure( - chain=m_FakeChain.GNOSIS, source_kind="Safe", - source_address="0xqs", reason="rpc timeout", + chain=m_FakeChain.GNOSIS, + source_kind="Safe", + source_address="0xqs", + reason="rpc timeout", ) monkeypatch.setattr(m, "_drain_master", lambda **kw: [fake_failure]) rename_called = {"n": 0} - monkeypatch.setattr(m, "rename_source_for_rollback", - lambda store: rename_called.update(n=rename_called["n"] + 1)) + monkeypatch.setattr( + m, + "rename_source_for_rollback", + lambda store: rename_called.update(n=rename_called["n"] + 1), + ) # Force yes_no="yes" so we'd rename if the orchestrator asked. monkeypatch.setattr(m, "yes_no", lambda *a, **k: True) m._run_mode_b(disc=d, services=[ref], config_path=None, dry_run=False) - assert rename_called["n"] == 0 # rename refused + assert rename_called["n"] == 0 # rename refused out = capsys.readouterr().out assert "Migration incomplete" in out assert "rpc timeout" in out assert "Source `.operate/` left in place" in out def test_collects_chain_rpcs_for_drain( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Per-service chain_configs feed `chain_rpcs` so the drain step can create Pearl Safes on chains it doesn't yet have.""" @@ -5879,28 +6995,45 @@ def test_collects_chain_rpcs_for_drain( ledger_config=types.SimpleNamespace(rpc="https://rpc.example/gnosis"), ) ref = _fake_service( - "sc-aaa", name="A", agent_addresses=[], path=tmp_path, + "sc-aaa", + name="A", + agent_addresses=[], + path=tmp_path, chain_configs={"gnosis": chain_config}, ) - monkeypatch.setattr(m, "_load_wallet", - lambda store, label: (types.SimpleNamespace(password="pw", user_account=None), "WALLET")) + monkeypatch.setattr( + m, + "_load_wallet", + lambda store, label: ( + types.SimpleNamespace(password="pw", user_account=None), + "WALLET", + ), + ) monkeypatch.setattr(m, "fix_root_ownership", lambda store: None) monkeypatch.setattr(m, "_migrate_one_service", lambda **kw: None) - monkeypatch.setattr(m, "merge_service", - lambda service, src, dest: types.SimpleNamespace()) + monkeypatch.setattr( + m, "merge_service", lambda service, src, dest: types.SimpleNamespace() + ) captured: dict = {} + def fake_drain(**kw: Any) -> list: captured.update(kw) return [] + monkeypatch.setattr(m, "_drain_master", fake_drain) monkeypatch.setattr(m, "yes_no", lambda *a, **k: False) monkeypatch.setattr(m, "rename_source_for_rollback", lambda store: store.root) m._run_mode_b(disc=d, services=[ref], config_path=None, dry_run=False) - assert captured["chain_rpcs"] == {FakeChain.GNOSIS: "https://rpc.example/gnosis"} + assert captured["chain_rpcs"] == { + FakeChain.GNOSIS: "https://rpc.example/gnosis" + } def test_chain_rpcs_conflict_warns_keeps_first( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """Two services declaring different RPCs for the same chain: warn @@ -5926,20 +7059,28 @@ def test_chain_rpcs_conflict_warns_keeps_first( chain_data=types.SimpleNamespace(token=2, multisig="0xms-b"), ledger_config=types.SimpleNamespace(rpc="https://rpc.public/gnosis"), ) - ref_a = _fake_service("sc-aaa", name="A", path=tmp_path, - chain_configs={"gnosis": cc_a}) - ref_b = _fake_service("sc-bbb", name="B", path=tmp_path, - chain_configs={"gnosis": cc_b}) + ref_a = _fake_service( + "sc-aaa", name="A", path=tmp_path, chain_configs={"gnosis": cc_a} + ) + ref_b = _fake_service( + "sc-bbb", name="B", path=tmp_path, chain_configs={"gnosis": cc_b} + ) - monkeypatch.setattr(m, "_load_wallet", - lambda store, label: (types.SimpleNamespace(password="pw", user_account=None), "WALLET")) + monkeypatch.setattr( + m, + "_load_wallet", + lambda store, label: ( + types.SimpleNamespace(password="pw", user_account=None), + "WALLET", + ), + ) monkeypatch.setattr(m, "fix_root_ownership", lambda store: None) monkeypatch.setattr(m, "_migrate_one_service", lambda **kw: None) - monkeypatch.setattr(m, "merge_service", - lambda service, src, dest: types.SimpleNamespace()) + monkeypatch.setattr( + m, "merge_service", lambda service, src, dest: types.SimpleNamespace() + ) captured: dict = {} - monkeypatch.setattr(m, "_drain_master", - lambda **kw: captured.update(kw) or []) + monkeypatch.setattr(m, "_drain_master", lambda **kw: captured.update(kw) or []) monkeypatch.setattr(m, "yes_no", lambda *a, **k: False) monkeypatch.setattr(m, "rename_source_for_rollback", lambda store: store.root) @@ -5953,7 +7094,10 @@ def test_chain_rpcs_conflict_warns_keeps_first( assert "rpc.private" in out and "rpc.public" in out def test_failed_load_aborts_before_any_action( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """If `services()` dropped any malformed configs, refuse to migrate.""" m, *_ = orch @@ -5963,14 +7107,22 @@ def test_failed_load_aborts_before_any_action( pl_root.mkdir(parents=True) qs_store = detect.OperateStore(root=qs_root) pl_store = detect.OperateStore(root=pl_root) - d = detect.Discovery(quickstart=qs_store, pearl=pl_store, mode=detect.Mode.MERGE) + d = detect.Discovery( + quickstart=qs_store, pearl=pl_store, mode=detect.Mode.MERGE + ) # Pretend the store had a malformed config that didn't load. - monkeypatch.setattr(qs_store.__class__, "failed_services", - lambda self: [(qs_root / "services/sc-bad", RuntimeError("bad"))]) + monkeypatch.setattr( + qs_store.__class__, + "failed_services", + lambda self: [(qs_root / "services/sc-bad", RuntimeError("bad"))], + ) # Capture whether any wallet loading happens (it must not). load_called = {"n": 0} - monkeypatch.setattr(m, "_load_wallet", - lambda store, label: load_called.update(n=load_called["n"] + 1)) + monkeypatch.setattr( + m, + "_load_wallet", + lambda store, label: load_called.update(n=load_called["n"] + 1), + ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) with pytest.raises(SystemExit): m._run_mode_b(disc=d, services=[ref], config_path=None, dry_run=False) @@ -5990,24 +7142,36 @@ def test_rename_declined( mode=detect.Mode.MERGE, ) ref = _fake_service("sc-aaa", name="A", agent_addresses=[], path=tmp_path) - monkeypatch.setattr(m, "_load_wallet", - lambda store, label: (types.SimpleNamespace(password="pw", user_account=None), "WALLET")) + monkeypatch.setattr( + m, + "_load_wallet", + lambda store, label: ( + types.SimpleNamespace(password="pw", user_account=None), + "WALLET", + ), + ) monkeypatch.setattr(m, "fix_root_ownership", lambda store: None) monkeypatch.setattr(m, "_migrate_one_service", lambda **kw: None) - monkeypatch.setattr(m, "merge_service", - lambda service, src, dest: types.SimpleNamespace()) + monkeypatch.setattr( + m, "merge_service", lambda service, src, dest: types.SimpleNamespace() + ) monkeypatch.setattr(m, "_drain_master", lambda **kw: []) monkeypatch.setattr(m, "yes_no", lambda *a, **k: False) called = {"rename": 0} - monkeypatch.setattr(m, "rename_source_for_rollback", - lambda store: called.update(rename=called["rename"] + 1)) + monkeypatch.setattr( + m, + "rename_source_for_rollback", + lambda store: called.update(rename=called["rename"] + 1), + ) m._run_mode_b(disc=d, services=[ref], config_path=None, dry_run=False) assert called["rename"] == 0 class TestFinalPrompt: def test_complete_yes_branch( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: m, *_ = orch @@ -6017,7 +7181,9 @@ def test_complete_yes_branch( assert "different machine" in out.lower() or "Copy `~/.operate/`" in out def test_complete_no_branch( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: m, *_ = orch @@ -6033,7 +7199,9 @@ def test_no_default_arg_required(self, orch: Any) -> None: m._final_prompt() def test_incomplete_skips_prompts_and_warns( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """Partial migration: must NOT ask the 'different machine?' question @@ -6041,12 +7209,17 @@ def test_incomplete_skips_prompts_and_warns( m, *_ = orch # Track that yes_no is never called. called = {"n": 0} - monkeypatch.setattr(m, "yes_no", - lambda *a, **k: called.update(n=called["n"] + 1) or False) + monkeypatch.setattr( + m, "yes_no", lambda *a, **k: called.update(n=called["n"] + 1) or False + ) outcome = m.MigrationOutcome( - unmigratable=(m._Unmigratable( - service_id="sc-aaa", chain="gnosis", reason="boom", - ),), + unmigratable=( + m._Unmigratable( + service_id="sc-aaa", + chain="gnosis", + reason="boom", + ), + ), ) m._final_prompt(outcome) out = capsys.readouterr().out @@ -6055,7 +7228,9 @@ def test_incomplete_skips_prompts_and_warns( assert called["n"] == 0 def test_subset_selection_uses_dedicated_message( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """Subset-only incompleteness gets its own message — phrasing @@ -6064,8 +7239,9 @@ def test_subset_selection_uses_dedicated_message( chose to migrate part of the quickstart.""" m, *_ = orch called = {"n": 0} - monkeypatch.setattr(m, "yes_no", - lambda *a, **k: called.update(n=called["n"] + 1) or False) + monkeypatch.setattr( + m, "yes_no", lambda *a, **k: called.update(n=called["n"] + 1) or False + ) outcome = m.MigrationOutcome( migrated=(_fake_service("sc-aaa"),), subset_selected=True, @@ -6082,28 +7258,39 @@ def test_subset_selection_uses_dedicated_message( class TestMain: def test_noop_returns_via_preflight( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: m, *_ = orch store = tmp_path / ".operate" store.mkdir() - monkeypatch.setattr(m, "discover", lambda quickstart_root, pearl_root: detect.Discovery( - quickstart=detect.OperateStore(root=store), - pearl=detect.OperateStore(root=store), - mode=detect.Mode.NOOP, - )) + monkeypatch.setattr( + m, + "discover", + lambda quickstart_root, pearl_root: detect.Discovery( + quickstart=detect.OperateStore(root=store), + pearl=detect.OperateStore(root=store), + mode=detect.Mode.NOOP, + ), + ) with pytest.raises(SystemExit): m.main([]) def test_discover_value_error_becomes_fatal( - self, orch: Any, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """Invariant violations from Discovery/__post_init__ surface via fatal() instead of leaking a stacktrace to the user.""" m, *_ = orch + def boom(quickstart_root: Any, pearl_root: Any) -> Any: raise ValueError("Discovery: stores share root /x but mode is FRESH_COPY") + monkeypatch.setattr(m, "discover", boom) with pytest.raises(SystemExit): m.main([]) @@ -6112,49 +7299,75 @@ def boom(quickstart_root: Any, pearl_root: Any) -> Any: assert "Discovery failed" in err def test_fresh_copy_dry_run( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: m, *_ = orch qs = detect.OperateStore(root=tmp_path / "qs") pl = detect.OperateStore(root=tmp_path / "pl") - monkeypatch.setattr(m, "discover", lambda quickstart_root, pearl_root: detect.Discovery( - quickstart=qs, pearl=pl, mode=detect.Mode.FRESH_COPY, - )) + monkeypatch.setattr( + m, + "discover", + lambda quickstart_root, pearl_root: detect.Discovery( + quickstart=qs, + pearl=pl, + mode=detect.Mode.FRESH_COPY, + ), + ) monkeypatch.setattr(m, "pearl_daemon_running", lambda: False) monkeypatch.setattr(m, "docker_quickstart_containers", lambda: []) called = {"a": 0, "fp": 0} - monkeypatch.setattr(m, "_run_mode_a", lambda disc, dry_run: called.update(a=called["a"] + 1)) - monkeypatch.setattr(m, "_final_prompt", lambda **kw: called.update(fp=called["fp"] + 1)) + monkeypatch.setattr( + m, "_run_mode_a", lambda disc, dry_run: called.update(a=called["a"] + 1) + ) + monkeypatch.setattr( + m, "_final_prompt", lambda **kw: called.update(fp=called["fp"] + 1) + ) rc = m.main(["--dry-run"]) assert rc == 0 assert called["a"] == 1 assert called["fp"] == 0 # dry-run skips final prompt def test_merge_invokes_select_and_run_b( - self, orch: Any, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + self, + orch: Any, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, patch_service_load: dict[Path, Any], ) -> None: m, *_ = orch qs_root = tmp_path / "qs/.operate" _write_service(qs_root, "sc-aaa", name="A") - patch_service_load[(qs_root / "services/sc-aaa").resolve()] = ( - _fake_service("sc-aaa", name="A") + patch_service_load[(qs_root / "services/sc-aaa").resolve()] = _fake_service( + "sc-aaa", name="A" ) qs = detect.OperateStore(root=qs_root.resolve()) pl = detect.OperateStore(root=tmp_path / "pl/.operate") - monkeypatch.setattr(m, "discover", lambda quickstart_root, pearl_root: detect.Discovery( - quickstart=qs, pearl=pl, mode=detect.Mode.MERGE, - )) + monkeypatch.setattr( + m, + "discover", + lambda quickstart_root, pearl_root: detect.Discovery( + quickstart=qs, + pearl=pl, + mode=detect.Mode.MERGE, + ), + ) monkeypatch.setattr(m, "pearl_daemon_running", lambda: False) monkeypatch.setattr(m, "docker_quickstart_containers", lambda: []) called = {"b": 0, "fp": 0, "outcome": None} + def fake_run_b(**kw: Any) -> Any: called["b"] += 1 return m.MigrationOutcome(migrated=(_fake_service("sc-x"),)) + monkeypatch.setattr(m, "_run_mode_b", fake_run_b) + def fake_final(outcome: Any = None) -> None: called["fp"] += 1 called["outcome"] = outcome + monkeypatch.setattr(m, "_final_prompt", fake_final) rc = m.main([]) assert rc == 0 @@ -6164,7 +7377,8 @@ def fake_final(outcome: Any = None) -> None: def test_orchestrator_module_is_runnable_as_script( - orch: Any, monkeypatch: pytest.MonkeyPatch, + orch: Any, + monkeypatch: pytest.MonkeyPatch, ) -> None: """`if __name__ == "__main__"` block — execute via runpy with main mocked. @@ -6181,7 +7395,9 @@ def test_orchestrator_module_is_runnable_as_script( monkeypatch.setattr( "scripts.pearl_migration.detect.discover", lambda quickstart_root, pearl_root: detect.Discovery( - quickstart=fake_store, pearl=fake_store, mode=detect.Mode.NOOP, + quickstart=fake_store, + pearl=fake_store, + mode=detect.Mode.NOOP, ), ) with pytest.raises(SystemExit): diff --git a/tests/test_scripts/test_predict_trader/test_mech_events.py b/tests/test_scripts/test_predict_trader/test_mech_events.py index 629f1fd4..b8c9de17 100644 --- a/tests/test_scripts/test_predict_trader/test_mech_events.py +++ b/tests/test_scripts/test_predict_trader/test_mech_events.py @@ -5,343 +5,388 @@ from typing import Any import pytest - from scripts.predict_trader import mech_events class _Response: - """Simple fake response object for requests.get tests.""" + """Simple fake response object for requests.get tests.""" - def __init__(self, payload: Any): - self._payload = payload + def __init__(self, payload: Any): + self._payload = payload - def raise_for_status(self) -> None: - """No-op successful status.""" + def raise_for_status(self) -> None: + """No-op successful status.""" - def json(self) -> Any: - """Return payload or raise it if it is an exception.""" - if isinstance(self._payload, Exception): - raise self._payload - return self._payload + def json(self) -> Any: + """Return payload or raise it if it is an exception.""" + if isinstance(self._payload, Exception): + raise self._payload + return self._payload class _DummyEvent(mech_events.MechBaseEvent): - """Concrete helper event used to test base-class behavior.""" + """Concrete helper event used to test base-class behavior.""" - event_name = "Dummy" - subgraph_event_name = "dummy" + event_name = "Dummy" + subgraph_event_name = "dummy" def _make_subgraph_event(event_id: str) -> dict[str, Any]: - """Create a minimal subgraph event payload for MechRequest.""" - return { - "id": event_id, - "sender": {"id": "0xabc"}, - "transactionHash": f"0xtx{event_id}", - "blockNumber": "1", - "blockTimestamp": "100", - "mechRequest": {"ipfsHash": "QmHash"}, - "marketplaceRequest": {"ipfsHashBytes": "0x1234"}, - } - - -def test_populate_ipfs_contents_falls_back_to_non_metadata_url(monkeypatch: pytest.MonkeyPatch) -> None: - """When metadata.json is not JSON, fallback URL should be used.""" + """Create a minimal subgraph event payload for MechRequest.""" + return { + "id": event_id, + "sender": {"id": "0xabc"}, + "transactionHash": f"0xtx{event_id}", + "blockNumber": "1", + "blockTimestamp": "100", + "mechRequest": {"ipfsHash": "QmHash"}, + "marketplaceRequest": {"ipfsHashBytes": "0x1234"}, + } + + +def test_populate_ipfs_contents_falls_back_to_non_metadata_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When metadata.json is not JSON, fallback URL should be used.""" - calls: list[str] = [] + calls: list[str] = [] - def _fake_get(url: str) -> _Response: - calls.append(url) - if url.endswith("/metadata.json"): - return _Response(json.JSONDecodeError("bad", "doc", 0)) - return _Response({"ok": True}) + def _fake_get(url: str, timeout: int = 30) -> _Response: # noqa: ARG001 + calls.append(url) + if url.endswith("/metadata.json"): + return _Response(json.JSONDecodeError("bad", "doc", 0)) + return _Response({"ok": True}) - monkeypatch.setattr(mech_events.requests, "get", _fake_get) + monkeypatch.setattr(mech_events.requests, "get", _fake_get) - event = mech_events.MechBaseEvent( - event_id="1", - sender="0xabc", - transaction_hash="0xtx", - block_number=1, - block_timestamp=100, - ipfs_hash="QmHash", - ) + event = mech_events.MechBaseEvent( + event_id="1", + sender="0xabc", + transaction_hash="0xtx", + block_number=1, + block_timestamp=100, + ipfs_hash="QmHash", + ) - assert calls[0].endswith("/metadata.json") - assert event.ipfs_contents == {"ok": True} - assert event.ipfs_link.endswith("QmHash") + assert calls[0].endswith("/metadata.json") + assert event.ipfs_contents == {"ok": True} + assert event.ipfs_link.endswith("QmHash") def test_read_mech_events_file_not_found_sets_default_version( - monkeypatch: pytest.MonkeyPatch, tmp_path: Any + monkeypatch: pytest.MonkeyPatch, tmp_path: Any ) -> None: - """Missing DB file should initialize default DB structure.""" + """Missing DB file should initialize default DB structure.""" - db_path = tmp_path / "mech_events.json" - monkeypatch.setattr(mech_events, "MECH_EVENTS_JSON_PATH", db_path) + db_path = tmp_path / "mech_events.json" + monkeypatch.setattr(mech_events, "MECH_EVENTS_JSON_PATH", db_path) - data = mech_events._read_mech_events_data_from_file() + data = mech_events._read_mech_events_data_from_file() - assert data == {"db_version": mech_events.MECH_EVENTS_DB_VERSION} + assert data == {"db_version": mech_events.MECH_EVENTS_DB_VERSION} def test_read_mech_events_old_version_renames_and_resets( - monkeypatch: pytest.MonkeyPatch, tmp_path: Any + monkeypatch: pytest.MonkeyPatch, tmp_path: Any ) -> None: - """Old DB version should be moved aside and recreated.""" + """Old DB version should be moved aside and recreated.""" - db_path = tmp_path / "mech_events.json" - db_path.write_text(json.dumps({"db_version": 1}), encoding="utf-8") - monkeypatch.setattr(mech_events, "MECH_EVENTS_JSON_PATH", db_path) - monkeypatch.setattr(mech_events.time, "strftime", lambda _: "2026-03-10_10-00-00") + db_path = tmp_path / "mech_events.json" + db_path.write_text(json.dumps({"db_version": 1}), encoding="utf-8") + monkeypatch.setattr(mech_events, "MECH_EVENTS_JSON_PATH", db_path) + monkeypatch.setattr(mech_events.time, "strftime", lambda _: "2026-03-10_10-00-00") - data = mech_events._read_mech_events_data_from_file() + data = mech_events._read_mech_events_data_from_file() - assert data == {"db_version": mech_events.MECH_EVENTS_DB_VERSION} - assert (tmp_path / "mech_events.2026-03-10_10-00-00.old.json").exists() + assert data == {"db_version": mech_events.MECH_EVENTS_DB_VERSION} + assert (tmp_path / "mech_events.2026-03-10_10-00-00.old.json").exists() def test_read_mech_events_corrupted_json_exits( - monkeypatch: pytest.MonkeyPatch, tmp_path: Any + monkeypatch: pytest.MonkeyPatch, tmp_path: Any ) -> None: - """Corrupted DB JSON should trigger process exit.""" + """Corrupted DB JSON should trigger process exit.""" - db_path = tmp_path / "mech_events.json" - db_path.write_text("{invalid", encoding="utf-8") - monkeypatch.setattr(mech_events, "MECH_EVENTS_JSON_PATH", db_path) + db_path = tmp_path / "mech_events.json" + db_path.write_text("{invalid", encoding="utf-8") + monkeypatch.setattr(mech_events, "MECH_EVENTS_JSON_PATH", db_path) - with pytest.raises(SystemExit): - mech_events._read_mech_events_data_from_file() + with pytest.raises(SystemExit): + mech_events._read_mech_events_data_from_file() def test_write_mech_events_data_respects_delay_and_force_write( - monkeypatch: pytest.MonkeyPatch, tmp_path: Any + monkeypatch: pytest.MonkeyPatch, tmp_path: Any ) -> None: - """Writes should be delayed unless force_write is enabled.""" + """Writes should be delayed unless force_write is enabled.""" - db_path = tmp_path / "mech_events.json" - monkeypatch.setattr(mech_events, "MECH_EVENTS_JSON_PATH", db_path) - monkeypatch.setattr(mech_events, "last_write_time", 100.0) - monkeypatch.setattr(mech_events.time, "time", lambda: 110.0) + db_path = tmp_path / "mech_events.json" + monkeypatch.setattr(mech_events, "MECH_EVENTS_JSON_PATH", db_path) + monkeypatch.setattr(mech_events, "last_write_time", 100.0) + monkeypatch.setattr(mech_events.time, "time", lambda: 110.0) - mech_events._write_mech_events_data_to_file({"db_version": 3}) - assert not db_path.exists() + mech_events._write_mech_events_data_to_file({"db_version": 3}) + assert not db_path.exists() - mech_events._write_mech_events_data_to_file({"db_version": 3}, force_write=True) - assert db_path.exists() + mech_events._write_mech_events_data_to_file({"db_version": 3}, force_write=True) + assert db_path.exists() def test_query_mech_events_subgraph_paginates(monkeypatch: pytest.MonkeyPatch) -> None: - """Subgraph query should request all pages until an empty page.""" + """Subgraph query should request all pages until an empty page.""" - calls: list[dict[str, Any]] = [] + calls: list[dict[str, Any]] = [] - class _FakeClient: - def execute(self, _query: Any, variable_values: dict[str, Any]) -> dict[str, Any]: - calls.append(variable_values) - if variable_values["id_gt"] == "": - return {"requests": [{"id": "1"}, {"id": "2"}]} - return {"requests": []} + class _FakeClient: + def execute( + self, _query: Any, variable_values: dict[str, Any] + ) -> dict[str, Any]: + calls.append(variable_values) + if variable_values["id_gt"] == "": + return {"requests": [{"id": "1"}, {"id": "2"}]} + return {"requests": []} - monkeypatch.setattr(mech_events, "RequestsHTTPTransport", lambda *_args, **_kwargs: object()) - monkeypatch.setattr(mech_events, "Client", lambda **_kwargs: _FakeClient()) - monkeypatch.setattr(mech_events, "gql", lambda q: q) + monkeypatch.setattr( + mech_events, "RequestsHTTPTransport", lambda *_args, **_kwargs: object() + ) + monkeypatch.setattr(mech_events, "Client", lambda **_kwargs: _FakeClient()) + monkeypatch.setattr(mech_events, "gql", lambda q: q) - result = mech_events._query_mech_events_subgraph("0xabc", mech_events.MechRequest) + result = mech_events._query_mech_events_subgraph("0xabc", mech_events.MechRequest) - assert result == {"data": {"requests": [{"id": "1"}, {"id": "2"}]}} - assert calls[0]["id_gt"] == "" - assert calls[1]["id_gt"] == "2" + assert result == {"data": {"requests": [{"id": "1"}, {"id": "2"}]}} + assert calls[0]["id_gt"] == "" + assert calls[1]["id_gt"] == "2" def test_update_mech_events_db_updates_missing_and_incomplete_events( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, ) -> None: - """Only missing events or events without IPFS contents should be refreshed.""" - - sender = "0xabc" - db = { - "db_version": mech_events.MECH_EVENTS_DB_VERSION, - sender: { - "Request": { - "1": {"event_id": "1", "ipfs_contents": {"done": True}}, - "2": {"event_id": "2", "ipfs_contents": {}}, - } - }, - } - - query_result = { - "data": { - "requests": [ - _make_subgraph_event("1"), - _make_subgraph_event("2"), - _make_subgraph_event("3"), - ] - } - } - - writes: list[bool] = [] - - monkeypatch.setattr(mech_events, "_query_mech_events_subgraph", lambda *_args, **_kwargs: query_result) - monkeypatch.setattr(mech_events, "_read_mech_events_data_from_file", lambda: db) - monkeypatch.setattr( - mech_events, - "_write_mech_events_data_to_file", - lambda mech_events_data, force_write=False: writes.append(force_write), - ) - monkeypatch.setattr(mech_events.MechBaseEvent, "_populate_ipfs_contents", lambda self: None) - - mech_events._update_mech_events_db(sender, mech_events.MechRequest) - - stored = db[sender]["Request"] - assert "3" in stored - assert stored["2"]["event_id"] == "2" - assert stored["1"]["ipfs_contents"] == {"done": True} - assert writes[-1] is True - - -def test_get_mech_events_updates_then_returns_sender_events(monkeypatch: pytest.MonkeyPatch) -> None: - """_get_mech_events should update database and return sender event bucket.""" - - updated: list[tuple[str, type[mech_events.MechBaseEvent]]] = [] - monkeypatch.setattr( - mech_events, - "_update_mech_events_db", - lambda sender, event_cls: updated.append((sender, event_cls)), - ) - monkeypatch.setattr( - mech_events, - "_read_mech_events_data_from_file", - lambda: {"0xabc": {"Request": {"evt": {"event_id": "evt"}}}}, - ) - - result = mech_events._get_mech_events("0xabc", mech_events.MechRequest) - - assert updated == [("0xabc", mech_events.MechRequest)] - assert result == {"evt": {"event_id": "evt"}} - - -def test_get_mech_requests_filters_by_timestamp(monkeypatch: pytest.MonkeyPatch) -> None: - """get_mech_requests should include events within inclusive bounds only.""" - - monkeypatch.setattr( - mech_events, - "_get_mech_events", - lambda *_args, **_kwargs: { - "a": {"block_timestamp": "9"}, - "b": {"block_timestamp": "10"}, - "c": {"block_timestamp": "20"}, - "d": {"block_timestamp": "21"}, - }, - ) - - result = mech_events.get_mech_requests("0xabc", from_timestamp=10, to_timestamp=20) - - assert result == { - "b": {"block_timestamp": "10"}, - "c": {"block_timestamp": "20"}, - } - - -def test_populate_ipfs_contents_warns_when_no_hash(capsys: pytest.CaptureFixture[str]) -> None: - """No hash values should print warning and keep empty fields.""" - - event = _DummyEvent( - event_id="1", - sender="0xabc", - transaction_hash="0xtx", - block_number=1, - block_timestamp=100, - ) - - output = capsys.readouterr().out - assert "WARNING: No IPFS hash found" in output - assert event.ipfs_contents == {} - assert event.ipfs_link == "" - - -def test_populate_ipfs_contents_uses_ipfs_hash_bytes(monkeypatch: pytest.MonkeyPatch) -> None: - """When only ipfs_hash_bytes exists, CID-prefixed URL should be used.""" - - called: list[str] = [] - - def _fake_get(url: str) -> _Response: - called.append(url) - return _Response({"ok": True}) - - monkeypatch.setattr(mech_events.requests, "get", _fake_get) - - event = mech_events.MechBaseEvent( - event_id="1", - sender="0xabc", - transaction_hash="0xtx", - block_number=1, - block_timestamp=100, - ipfs_hash_bytes="0x1234", - ) + """Only missing events or events without IPFS contents should be refreshed.""" + + sender = "0xabc" + db = { + "db_version": mech_events.MECH_EVENTS_DB_VERSION, + sender: { + "Request": { + "1": {"event_id": "1", "ipfs_contents": {"done": True}}, + "2": {"event_id": "2", "ipfs_contents": {}}, + } + }, + } + + query_result = { + "data": { + "requests": [ + _make_subgraph_event("1"), + _make_subgraph_event("2"), + _make_subgraph_event("3"), + ] + } + } + + writes: list[bool] = [] + + monkeypatch.setattr( + mech_events, + "_query_mech_events_subgraph", + lambda *_args, **_kwargs: query_result, + ) + monkeypatch.setattr(mech_events, "_read_mech_events_data_from_file", lambda: db) + monkeypatch.setattr( + mech_events, + "_write_mech_events_data_to_file", + lambda mech_events_data, force_write=False: writes.append(force_write), + ) + monkeypatch.setattr( + mech_events.MechBaseEvent, "_populate_ipfs_contents", lambda self: None + ) + + mech_events._update_mech_events_db(sender, mech_events.MechRequest) + + stored = db[sender]["Request"] + assert "3" in stored + assert stored["2"]["event_id"] == "2" + assert stored["1"]["ipfs_contents"] == {"done": True} + assert writes[-1] is True + + +def test_get_mech_events_updates_then_returns_sender_events( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_get_mech_events should update database and return sender event bucket.""" + + updated: list[tuple[str, type[mech_events.MechBaseEvent]]] = [] + monkeypatch.setattr( + mech_events, + "_update_mech_events_db", + lambda sender, event_cls: updated.append((sender, event_cls)), + ) + monkeypatch.setattr( + mech_events, + "_read_mech_events_data_from_file", + lambda: {"0xabc": {"Request": {"evt": {"event_id": "evt"}}}}, + ) + + result = mech_events._get_mech_events("0xabc", mech_events.MechRequest) - assert event.ipfs_hash_bytes == "1234" - assert called[0].endswith(f"{mech_events.CID_PREFIX}1234/metadata.json") - assert event.ipfs_contents == {"ok": True} + assert updated == [("0xabc", mech_events.MechRequest)] + assert result == {"evt": {"event_id": "evt"}} -def test_populate_ipfs_contents_handles_generic_exception(monkeypatch: pytest.MonkeyPatch) -> None: - """Unexpected request errors should print traceback and continue.""" +def test_get_mech_requests_filters_by_timestamp( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """get_mech_requests should include events within inclusive bounds only.""" + + monkeypatch.setattr( + mech_events, + "_get_mech_events", + lambda *_args, **_kwargs: { + "a": {"block_timestamp": "9"}, + "b": {"block_timestamp": "10"}, + "c": {"block_timestamp": "20"}, + "d": {"block_timestamp": "21"}, + }, + ) - class _BadResponse: - def raise_for_status(self) -> None: - raise RuntimeError("boom") + result = mech_events.get_mech_requests("0xabc", from_timestamp=10, to_timestamp=20) - def json(self) -> dict[str, Any]: - return {"unused": True} + assert result == { + "b": {"block_timestamp": "10"}, + "c": {"block_timestamp": "20"}, + } - printed: list[str] = [] - inputs: list[str] = [] - monkeypatch.setattr(mech_events.requests, "get", lambda _url: _BadResponse()) - monkeypatch.setattr(mech_events.traceback, "format_exc", lambda: "traceback-text") - monkeypatch.setattr(builtins, "print", lambda *args, **kwargs: printed.append(" ".join(str(a) for a in args))) - monkeypatch.setattr(builtins, "input", lambda prompt: inputs.append(prompt) or "") - _DummyEvent( - event_id="1", - sender="0xabc", - transaction_hash="0xtx", - block_number=1, - block_timestamp=100, - ipfs_hash="QmHash", - ) +def test_populate_ipfs_contents_warns_when_no_hash( + capsys: pytest.CaptureFixture[str], +) -> None: + """No hash values should print warning and keep empty fields.""" - assert any("traceback-text" in line for line in printed) - assert len(inputs) == 2 + event = _DummyEvent( + event_id="1", + sender="0xabc", + transaction_hash="0xtx", + block_number=1, + block_timestamp=100, + ) + output = capsys.readouterr().out + assert "WARNING: No IPFS hash found" in output + assert event.ipfs_contents == {} + assert event.ipfs_link == "" -def test_update_mech_events_db_handles_keyboard_interrupt(monkeypatch: pytest.MonkeyPatch) -> None: - """KeyboardInterrupt from query should be handled gracefully.""" - messages: list[str] = [] - inputs: list[str] = [] - monkeypatch.setattr(mech_events, "_query_mech_events_subgraph", lambda *_a, **_k: (_ for _ in ()).throw(KeyboardInterrupt())) - monkeypatch.setattr(builtins, "print", lambda *args, **kwargs: messages.append(" ".join(str(a) for a in args))) - monkeypatch.setattr(builtins, "input", lambda prompt: inputs.append(prompt) or "") +def test_populate_ipfs_contents_uses_ipfs_hash_bytes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When only ipfs_hash_bytes exists, CID-prefixed URL should be used.""" - mech_events._update_mech_events_db("0xabc", mech_events.MechRequest) + called: list[str] = [] - assert any("was cancelled" in msg for msg in messages) - assert inputs == ["Press Enter to continue..."] + def _fake_get(url: str, timeout: int = 30) -> _Response: # noqa: ARG001 + called.append(url) + return _Response({"ok": True}) + monkeypatch.setattr(mech_events.requests, "get", _fake_get) -def test_update_mech_events_db_handles_generic_exception(monkeypatch: pytest.MonkeyPatch) -> None: - """Generic exceptions during update should print warning and continue.""" + event = mech_events.MechBaseEvent( + event_id="1", + sender="0xabc", + transaction_hash="0xtx", + block_number=1, + block_timestamp=100, + ipfs_hash_bytes="0x1234", + ) - messages: list[str] = [] - inputs: list[str] = [] - monkeypatch.setattr(mech_events, "_query_mech_events_subgraph", lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("boom"))) - monkeypatch.setattr(mech_events.traceback, "format_exc", lambda: "runtime-trace") - monkeypatch.setattr(builtins, "print", lambda *args, **kwargs: messages.append(" ".join(str(a) for a in args))) - monkeypatch.setattr(builtins, "input", lambda prompt: inputs.append(prompt) or "") + assert event.ipfs_hash_bytes == "1234" + assert called[0].endswith(f"{mech_events.CID_PREFIX}1234/metadata.json") + assert event.ipfs_contents == {"ok": True} - mech_events._update_mech_events_db("0xabc", mech_events.MechRequest) - assert any("runtime-trace" in msg for msg in messages) - assert any("An error occurred while updating" in msg for msg in messages) - assert inputs == ["Press Enter to continue..."] +def test_populate_ipfs_contents_handles_generic_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unexpected request errors should print traceback and continue.""" + + class _BadResponse: + def raise_for_status(self) -> None: + raise RuntimeError("boom") + + def json(self) -> dict[str, Any]: + return {"unused": True} + + printed: list[str] = [] + inputs: list[str] = [] + monkeypatch.setattr(mech_events.requests, "get", lambda _url: _BadResponse()) + monkeypatch.setattr(mech_events.traceback, "format_exc", lambda: "traceback-text") + monkeypatch.setattr( + builtins, + "print", + lambda *args, **kwargs: printed.append(" ".join(str(a) for a in args)), + ) + monkeypatch.setattr(builtins, "input", lambda prompt: inputs.append(prompt) or "") + + _DummyEvent( + event_id="1", + sender="0xabc", + transaction_hash="0xtx", + block_number=1, + block_timestamp=100, + ipfs_hash="QmHash", + ) + + assert any("traceback-text" in line for line in printed) + assert len(inputs) == 2 + + +def test_update_mech_events_db_handles_keyboard_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """KeyboardInterrupt from query should be handled gracefully.""" + + messages: list[str] = [] + inputs: list[str] = [] + monkeypatch.setattr( + mech_events, + "_query_mech_events_subgraph", + lambda *_a, **_k: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + monkeypatch.setattr( + builtins, + "print", + lambda *args, **kwargs: messages.append(" ".join(str(a) for a in args)), + ) + monkeypatch.setattr(builtins, "input", lambda prompt: inputs.append(prompt) or "") + + mech_events._update_mech_events_db("0xabc", mech_events.MechRequest) + + assert any("was cancelled" in msg for msg in messages) + assert inputs == ["Press Enter to continue..."] + + +def test_update_mech_events_db_handles_generic_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Generic exceptions during update should print warning and continue.""" + + messages: list[str] = [] + inputs: list[str] = [] + monkeypatch.setattr( + mech_events, + "_query_mech_events_subgraph", + lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("boom")), + ) + monkeypatch.setattr(mech_events.traceback, "format_exc", lambda: "runtime-trace") + monkeypatch.setattr( + builtins, + "print", + lambda *args, **kwargs: messages.append(" ".join(str(a) for a in args)), + ) + monkeypatch.setattr(builtins, "input", lambda prompt: inputs.append(prompt) or "") + + mech_events._update_mech_events_db("0xabc", mech_events.MechRequest) + + assert any("runtime-trace" in msg for msg in messages) + assert any("An error occurred while updating" in msg for msg in messages) + assert inputs == ["Press Enter to continue..."] diff --git a/tests/test_scripts/test_predict_trader/test_migrate_legacy_quickstart.py b/tests/test_scripts/test_predict_trader/test_migrate_legacy_quickstart.py index 0f0f1ace..4cdc3800 100644 --- a/tests/test_scripts/test_predict_trader/test_migrate_legacy_quickstart.py +++ b/tests/test_scripts/test_predict_trader/test_migrate_legacy_quickstart.py @@ -1,14 +1,13 @@ """Unit tests for predict_trader.migrate_legacy_quickstart.""" +import builtins import json import runpy import sys -import builtins from pathlib import Path from types import SimpleNamespace import pytest - from scripts.predict_trader import migrate_legacy_quickstart as migrate diff --git a/tests/test_scripts/test_predict_trader/test_rank_traders.py b/tests/test_scripts/test_predict_trader/test_rank_traders.py index fd8a29d6..3d657f85 100644 --- a/tests/test_scripts/test_predict_trader/test_rank_traders.py +++ b/tests/test_scripts/test_predict_trader/test_rank_traders.py @@ -5,237 +5,249 @@ from typing import Any import pytest - -from scripts.predict_trader import rank_traders from scripts import utils as scripts_utils +from scripts.predict_trader import rank_traders class _Response: - """Simple fake response for requests.post calls.""" - - def __init__(self, payload: dict[str, Any]): - self._payload = payload - - def json(self) -> dict[str, Any]: - """Return payload JSON.""" - return self._payload - - -def _stats_row(roi: float, trades: int) -> dict[rank_traders.MarketAttribute, dict[rank_traders.MarketState, Any]]: - """Build a minimal statistics table used by _print_user_summary.""" - state = rank_traders.MarketState.CLOSED - return { - rank_traders.MarketAttribute.NUM_TRADES: {state: trades}, - rank_traders.MarketAttribute.WINNER_TRADES: {state: trades - 1}, - rank_traders.MarketAttribute.NUM_REDEEMED: {state: 1}, - rank_traders.MarketAttribute.INVESTMENT: {state: 10**18}, - rank_traders.MarketAttribute.FEES: {state: 10**17}, - rank_traders.MarketAttribute.EARNINGS: {state: 2 * 10**18}, - rank_traders.MarketAttribute.NET_EARNINGS: {state: 9 * 10**17}, - rank_traders.MarketAttribute.REDEMPTIONS: {state: 3 * 10**17}, - rank_traders.MarketAttribute.ROI: {state: roi}, - } + """Simple fake response for requests.post calls.""" + + def __init__(self, payload: dict[str, Any]): + self._payload = payload + + def json(self) -> dict[str, Any]: + """Return payload JSON.""" + return self._payload + + +def _stats_row( + roi: float, trades: int +) -> dict[rank_traders.MarketAttribute, dict[rank_traders.MarketState, Any]]: + """Build a minimal statistics table used by _print_user_summary.""" + state = rank_traders.MarketState.CLOSED + return { + rank_traders.MarketAttribute.NUM_TRADES: {state: trades}, + rank_traders.MarketAttribute.WINNER_TRADES: {state: trades - 1}, + rank_traders.MarketAttribute.NUM_REDEEMED: {state: 1}, + rank_traders.MarketAttribute.INVESTMENT: {state: 10**18}, + rank_traders.MarketAttribute.FEES: {state: 10**17}, + rank_traders.MarketAttribute.EARNINGS: {state: 2 * 10**18}, + rank_traders.MarketAttribute.NET_EARNINGS: {state: 9 * 10**17}, + rank_traders.MarketAttribute.REDEMPTIONS: {state: 3 * 10**17}, + rank_traders.MarketAttribute.ROI: {state: roi}, + } def test_parse_args_sets_utc_timezone(monkeypatch: pytest.MonkeyPatch) -> None: - """All parsed datetime arguments should be UTC-aware.""" - - monkeypatch.setattr( - rank_traders.sys, - "argv", - [ - "rank_traders.py", - "--from-date", - "2025-01-01T00:00:00", - "--to-date", - "2025-01-02T00:00:00", - "--fpmm-created-from-date", - "2024-12-01T00:00:00", - "--fpmm-created-to-date", - "2025-12-01T00:00:00", - "--sort-by", - "ROI", - ], - ) - - args = rank_traders._parse_args() - - assert args.from_date.tzinfo == datetime.timezone.utc - assert args.to_date.tzinfo == datetime.timezone.utc - assert args.fpmm_created_from_date.tzinfo == datetime.timezone.utc - assert args.fpmm_created_to_date.tzinfo == datetime.timezone.utc - assert args.sort_by == rank_traders.MarketAttribute.ROI + """All parsed datetime arguments should be UTC-aware.""" + + monkeypatch.setattr( + rank_traders.sys, + "argv", + [ + "rank_traders.py", + "--from-date", + "2025-01-01T00:00:00", + "--to-date", + "2025-01-02T00:00:00", + "--fpmm-created-from-date", + "2024-12-01T00:00:00", + "--fpmm-created-to-date", + "2025-12-01T00:00:00", + "--sort-by", + "ROI", + ], + ) + + args = rank_traders._parse_args() + + assert args.from_date.tzinfo == datetime.timezone.utc + assert args.to_date.tzinfo == datetime.timezone.utc + assert args.fpmm_created_from_date.tzinfo == datetime.timezone.utc + assert args.fpmm_created_to_date.tzinfo == datetime.timezone.utc + assert args.sort_by == rank_traders.MarketAttribute.ROI def test_to_content_wraps_query() -> None: - """_to_content should return expected Graph payload shape.""" + """_to_content should return expected Graph payload shape.""" - query = "{ fpmmTrades { id } }" - content = rank_traders._to_content(query) + query = "{ fpmmTrades { id } }" + content = rank_traders._to_content(query) - assert content == { - "query": query, - "variables": None, - "extensions": {"headers": None}, - } + assert content == { + "query": query, + "variables": None, + "extensions": {"headers": None}, + } def test_query_omen_xdai_subgraph_paginates_and_groups( - monkeypatch: pytest.MonkeyPatch, tmp_path, requests_mock + monkeypatch: pytest.MonkeyPatch, tmp_path, requests_mock ) -> None: - """Subgraph pagination should continue until empty page and aggregate results.""" + """Subgraph pagination should continue until empty page and aggregate results.""" - calls: list[dict[str, Any]] = [] - responses = [ - { - "data": { - "fpmmTrades": [ - {"id": "1", "fpmm": {"id": "fpmm-a"}}, - {"id": "2", "fpmm": {"id": "fpmm-a"}}, - ] - } - }, - {"data": {"fpmmTrades": []}}, - ] + calls: list[dict[str, Any]] = [] + responses = [ + { + "data": { + "fpmmTrades": [ + {"id": "1", "fpmm": {"id": "fpmm-a"}}, + {"id": "2", "fpmm": {"id": "fpmm-a"}}, + ] + } + }, + {"data": {"fpmmTrades": []}}, + ] - operate_home = tmp_path / ".operate" - operate_home.mkdir(parents=True) - (operate_home / "subgraph_api_key.txt").write_text("dummy_key", encoding="utf-8") - monkeypatch.setattr(scripts_utils, "OPERATE_HOME", operate_home) + operate_home = tmp_path / ".operate" + operate_home.mkdir(parents=True) + (operate_home / "subgraph_api_key.txt").write_text("dummy_key", encoding="utf-8") + monkeypatch.setattr(scripts_utils, "OPERATE_HOME", operate_home) - url = "https://gateway-arbitrum.network.thegraph.com/api/dummy_key/subgraphs/id/9fUVQpFwzpdWS9bq5WkAnmKbNNcoBwatMR4yZq81pbbz" - requests_mock.post(url, [{"json": responses[0]}, {"json": responses[1]}]) + url = "https://gateway-arbitrum.network.thegraph.com/api/dummy_key/subgraphs/id/9fUVQpFwzpdWS9bq5WkAnmKbNNcoBwatMR4yZq81pbbz" + requests_mock.post(url, [{"json": responses[0]}, {"json": responses[1]}]) - original_to_content = rank_traders._to_content + original_to_content = rank_traders._to_content - def _capturing_to_content(query: str) -> dict[str, Any]: - payload = original_to_content(query) - calls.append(payload) - return payload + def _capturing_to_content(query: str) -> dict[str, Any]: + payload = original_to_content(query) + calls.append(payload) + return payload - monkeypatch.setattr(rank_traders, "_to_content", _capturing_to_content) + monkeypatch.setattr(rank_traders, "_to_content", _capturing_to_content) - result = rank_traders._query_omen_xdai_subgraph(10, 20, 5, 15) + result = rank_traders._query_omen_xdai_subgraph(10, 20, 5, 15) - assert len(calls) == 2 - assert 'id_gt: ""' in calls[0]["query"] - assert 'id_gt: "2"' in calls[1]["query"] - assert len(result["data"]["fpmmTrades"]) == 2 + assert len(calls) == 2 + assert 'id_gt: ""' in calls[0]["query"] + assert 'id_gt: "2"' in calls[1]["query"] + assert len(result["data"]["fpmmTrades"]) == 2 def test_group_trades_by_creator() -> None: - """Trades should be bucketed by creator id.""" + """Trades should be bucketed by creator id.""" - trades_json = { - "data": { - "fpmmTrades": [ - {"id": "1", "creator": {"id": "u1"}}, - {"id": "2", "creator": {"id": "u2"}}, - {"id": "3", "creator": {"id": "u1"}}, - ] - } - } + trades_json = { + "data": { + "fpmmTrades": [ + {"id": "1", "creator": {"id": "u1"}}, + {"id": "2", "creator": {"id": "u2"}}, + {"id": "3", "creator": {"id": "u1"}}, + ] + } + } - grouped = rank_traders._group_trades_by_creator(trades_json) + grouped = rank_traders._group_trades_by_creator(trades_json) - assert set(grouped.keys()) == {"u1", "u2"} - assert [trade["id"] for trade in grouped["u1"]["data"]["fpmmTrades"]] == ["1", "3"] - assert [trade["id"] for trade in grouped["u2"]["data"]["fpmmTrades"]] == ["2"] + assert set(grouped.keys()) == {"u1", "u2"} + assert [trade["id"] for trade in grouped["u1"]["data"]["fpmmTrades"]] == ["1", "3"] + assert [trade["id"] for trade in grouped["u2"]["data"]["fpmmTrades"]] == ["2"] -def test_print_user_summary_sorts_descending(capsys: pytest.CaptureFixture[str]) -> None: - """Higher ROI user should appear first in rendered summary.""" +def test_print_user_summary_sorts_descending( + capsys: pytest.CaptureFixture[str], +) -> None: + """Higher ROI user should appear first in rendered summary.""" - creator_to_statistics = { - "user-low": _stats_row(roi=0.10, trades=2), - "user-high": _stats_row(roi=0.50, trades=4), - } + creator_to_statistics = { + "user-low": _stats_row(roi=0.10, trades=2), + "user-high": _stats_row(roi=0.50, trades=4), + } - rank_traders._print_user_summary(creator_to_statistics) - output = capsys.readouterr().out + rank_traders._print_user_summary(creator_to_statistics) + output = capsys.readouterr().out - assert "User summary for Closed markets sorted by ROI:" in output - assert output.find("user-high") < output.find("user-low") + assert "User summary for Closed markets sorted by ROI:" in output + assert output.find("user-high") < output.find("user-low") -def test_print_progress_bar_writes_expected_output(monkeypatch: pytest.MonkeyPatch) -> None: - """Progress bar should write percent and iteration details to stdout.""" +def test_print_progress_bar_writes_expected_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Progress bar should write percent and iteration details to stdout.""" - written: list[str] = [] - flushed = {"called": False} + written: list[str] = [] + flushed = {"called": False} - class _Stdout: - def write(self, text: str) -> None: - written.append(text) + class _Stdout: + def write(self, text: str) -> None: + written.append(text) - def flush(self) -> None: - flushed["called"] = True + def flush(self) -> None: + flushed["called"] = True - monkeypatch.setattr(rank_traders.sys, "stdout", _Stdout()) + monkeypatch.setattr(rank_traders.sys, "stdout", _Stdout()) - rank_traders._print_progress_bar(iteration=5, total=10, prefix="P", suffix="S", length=10, fill="#") + rank_traders._print_progress_bar( + iteration=5, total=10, prefix="P", suffix="S", length=10, fill="#" + ) - assert written - assert "(5 of 10) - 50.0%" in written[0] - assert flushed["called"] is True + assert written + assert "(5 of 10) - 50.0%" in written[0] + assert flushed["called"] is True def test_print_progress_bar_rejects_multi_char_fill() -> None: - """fill must be a single character.""" + """fill must be a single character.""" - with pytest.raises(ValueError, match="single character"): - rank_traders._print_progress_bar(iteration=1, total=2, fill="##") + with pytest.raises(ValueError, match="single character"): + rank_traders._print_progress_bar(iteration=1, total=2, fill="##") def test_main_execution_path( - monkeypatch: pytest.MonkeyPatch, - tmp_path, - requests_mock, - capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + tmp_path, + requests_mock, + capsys: pytest.CaptureFixture[str], ) -> None: - """Execute the module as script and verify the main flow prints expected output.""" - - import operate.quickstart.run_service as run_service - import scripts.predict_trader.trades as trades_module - import scripts.utils as utils_module - - monkeypatch.setattr(rank_traders.sys, "argv", ["rank_traders.py"]) - - class _Config: - rpc = {rank_traders.Chain.GNOSIS.value: "http://rpc"} - - monkeypatch.setattr(run_service, "load_local_config", lambda: _Config()) - operate_home = tmp_path / ".operate" - operate_home.mkdir(parents=True) - (operate_home / "subgraph_api_key.txt").write_text("dummy_key", encoding="utf-8") - monkeypatch.setattr(utils_module, "OPERATE_HOME", operate_home) - monkeypatch.setattr( - trades_module, - "parse_user", - lambda _rpc, _creator, _trades_json, _stats: ("ignored", _stats_row(roi=0.25, trades=1)), - ) - - responses = [ - { - "data": { - "fpmmTrades": [ - { - "id": "1", - "creator": {"id": "user-1"}, - "fpmm": {"id": "market-1"}, - } - ] - } - }, - {"data": {"fpmmTrades": []}}, - ] - - url = "https://gateway-arbitrum.network.thegraph.com/api/dummy_key/subgraphs/id/9fUVQpFwzpdWS9bq5WkAnmKbNNcoBwatMR4yZq81pbbz" - requests_mock.post(url, [{"json": responses[0]}, {"json": responses[1]}]) - - runpy.run_module("scripts.predict_trader.rank_traders", run_name="__main__") - output = capsys.readouterr().out - - assert "Starting script" in output - assert "Total trading transactions: 1" in output - assert "Total traders: 1" in output + """Execute the module as script and verify the main flow prints expected output.""" + + import operate.quickstart.run_service as run_service + import scripts.predict_trader.trades as trades_module + import scripts.utils as utils_module + + monkeypatch.setattr(rank_traders.sys, "argv", ["rank_traders.py"]) + + class _Config: + rpc = {rank_traders.Chain.GNOSIS.value: "http://rpc"} + + monkeypatch.setattr( + run_service, "load_local_config", lambda **_kwargs: _Config() + ) + operate_home = tmp_path / ".operate" + operate_home.mkdir(parents=True) + (operate_home / "subgraph_api_key.txt").write_text("dummy_key", encoding="utf-8") + monkeypatch.setattr(utils_module, "OPERATE_HOME", operate_home) + monkeypatch.setattr( + trades_module, + "parse_user", + lambda _rpc, _creator, _trades_json, _stats: ( + "ignored", + _stats_row(roi=0.25, trades=1), + ), + ) + + responses = [ + { + "data": { + "fpmmTrades": [ + { + "id": "1", + "creator": {"id": "user-1"}, + "fpmm": {"id": "market-1"}, + } + ] + } + }, + {"data": {"fpmmTrades": []}}, + ] + + url = "https://gateway-arbitrum.network.thegraph.com/api/dummy_key/subgraphs/id/9fUVQpFwzpdWS9bq5WkAnmKbNNcoBwatMR4yZq81pbbz" + requests_mock.post(url, [{"json": responses[0]}, {"json": responses[1]}]) + + runpy.run_module("scripts.predict_trader.rank_traders", run_name="__main__") + output = capsys.readouterr().out + + assert "Starting script" in output + assert "Total trading transactions: 1" in output + assert "Total traders: 1" in output diff --git a/tests/test_scripts/test_predict_trader/test_report.py b/tests/test_scripts/test_predict_trader/test_report.py index de2fdbe1..5c66b78a 100644 --- a/tests/test_scripts/test_predict_trader/test_report.py +++ b/tests/test_scripts/test_predict_trader/test_report.py @@ -11,474 +11,526 @@ def _load_report_module(monkeypatch: pytest.MonkeyPatch): - """Import report module with password prompt disabled.""" - import operate.quickstart.run_service as run_service + """Import report module with password prompt disabled.""" + import operate.quickstart.run_service as run_service - monkeypatch.setattr(run_service, "ask_password_if_needed", lambda *_args, **_kwargs: None) - module = importlib.import_module("scripts.predict_trader.report") - return importlib.reload(module) + monkeypatch.setattr( + run_service, "ask_password_if_needed", lambda *_args, **_kwargs: None + ) + module = importlib.import_module("scripts.predict_trader.report") + return importlib.reload(module) def test_color_helpers(monkeypatch: pytest.MonkeyPatch) -> None: - """Color helper functions should format as expected.""" - report = _load_report_module(monkeypatch) + """Color helper functions should format as expected.""" + report = _load_report_module(monkeypatch) - assert report._color_string("ok", report.ColorCode.GREEN).startswith(report.ColorCode.GREEN) - assert "True" in report._color_bool(True) - assert "False" in report._color_bool(False) + assert report._color_string("ok", report.ColorCode.GREEN).startswith( + report.ColorCode.GREEN + ) + assert "True" in report._color_bool(True) + assert "False" in report._color_bool(False) def test_color_percent_handles_negative(monkeypatch: pytest.MonkeyPatch) -> None: - """Negative percentages should be colored as warning text.""" - report = _load_report_module(monkeypatch) + """Negative percentages should be colored as warning text.""" + report = _load_report_module(monkeypatch) - positive = report._color_percent(0.25) - negative = report._color_percent(-0.25) + positive = report._color_percent(0.25) + negative = report._color_percent(-0.25) - assert positive == "25.00 %" - assert report.ColorCode.RED in negative + assert positive == "25.00 %" + assert report.ColorCode.RED in negative def test_trades_since_message(monkeypatch: pytest.MonkeyPatch) -> None: - """Should count filtered trades and unique markets.""" - report = _load_report_module(monkeypatch) + """Should count filtered trades and unique markets.""" + report = _load_report_module(monkeypatch) - trades_json = { - "data": { - "fpmmTrades": [ - {"creationTimestamp": "10", "fpmm": {"id": "m1"}}, - {"creationTimestamp": "11", "fpmm": {"id": "m1"}}, - {"creationTimestamp": "9", "fpmm": {"id": "m2"}}, - ] - } - } + trades_json = { + "data": { + "fpmmTrades": [ + {"creationTimestamp": "10", "fpmm": {"id": "m1"}}, + {"creationTimestamp": "11", "fpmm": {"id": "m1"}}, + {"creationTimestamp": "9", "fpmm": {"id": "m2"}}, + ] + } + } - assert report._trades_since_message(trades_json, utc_ts=10) == "2 trades on 1 markets" + assert ( + report._trades_since_message(trades_json, utc_ts=10) == "2 trades on 1 markets" + ) def test_calculate_retrades_since(monkeypatch: pytest.MonkeyPatch) -> None: - """Should return per-market counts, totals, and retrades.""" - report = _load_report_module(monkeypatch) + """Should return per-market counts, totals, and retrades.""" + report = _load_report_module(monkeypatch) + + trades_json = { + "data": { + "fpmmTrades": [ + {"creationTimestamp": "10", "fpmm": {"id": "m1"}}, + {"creationTimestamp": "11", "fpmm": {"id": "m1"}}, + {"creationTimestamp": "12", "fpmm": {"id": "m2"}}, + ] + } + } + + filtered, n_unique, n_trades, n_retrades = report._calculate_retrades_since( + trades_json, utc_ts=10 + ) + + assert filtered["m1"] == 2 + assert filtered["m2"] == 1 + assert n_unique == 2 + assert n_trades == 3 + assert n_retrades == 1 + + +def test_calculate_retrades_since_raises_on_missing_market_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Trades without fpmm id should raise a ValueError.""" + report = _load_report_module(monkeypatch) - trades_json = { - "data": { - "fpmmTrades": [ - {"creationTimestamp": "10", "fpmm": {"id": "m1"}}, - {"creationTimestamp": "11", "fpmm": {"id": "m1"}}, - {"creationTimestamp": "12", "fpmm": {"id": "m2"}}, - ] - } - } + trades_json = {"data": {"fpmmTrades": [{"creationTimestamp": "10", "fpmm": {}}]}} - filtered, n_unique, n_trades, n_retrades = report._calculate_retrades_since(trades_json, utc_ts=10) + with pytest.raises(ValueError, match="no associated market ID"): + report._calculate_retrades_since(trades_json, utc_ts=0) - assert filtered["m1"] == 2 - assert filtered["m2"] == 1 - assert n_unique == 2 - assert n_trades == 3 - assert n_retrades == 1 +def test_retrade_average_and_max_messages(monkeypatch: pytest.MonkeyPatch) -> None: + """Message formatters should return expected values.""" + report = _load_report_module(monkeypatch) + + assert ( + report._retrades_since_message(2, 3, 1) + == "1 re-trades on total 3 trades in 2 markets" + ) + assert report._average_trades_since_message(5, 2) == "2.5 trades per market" + assert report._average_trades_since_message(5, 0) == "0 trades per market" + assert ( + report._max_trades_per_market_since_message({"m1": 3, "m2": 1}) + == "3 trades per market" + ) + assert report._max_trades_per_market_since_message({}) == "0 trades per market" -def test_calculate_retrades_since_raises_on_missing_market_id(monkeypatch: pytest.MonkeyPatch) -> None: - """Trades without fpmm id should raise a ValueError.""" - report = _load_report_module(monkeypatch) - trades_json = {"data": {"fpmmTrades": [{"creationTimestamp": "10", "fpmm": {}}]}} +def test_get_mech_requests_count(monkeypatch: pytest.MonkeyPatch) -> None: + """Should count only requests above the timestamp threshold.""" + report = _load_report_module(monkeypatch) - with pytest.raises(ValueError, match="no associated market ID"): - report._calculate_retrades_since(trades_json, utc_ts=0) + mech_requests = { + "a": {"block_timestamp": 100}, + "b": {"block_timestamp": 200}, + "c": {"block_timestamp": 50}, + } + assert report._get_mech_requests_count(mech_requests, timestamp=100) == 1 -def test_retrade_average_and_max_messages(monkeypatch: pytest.MonkeyPatch) -> None: - """Message formatters should return expected values.""" - report = _load_report_module(monkeypatch) - assert report._retrades_since_message(2, 3, 1) == "1 re-trades on total 3 trades in 2 markets" - assert report._average_trades_since_message(5, 2) == "2.5 trades per market" - assert report._average_trades_since_message(5, 0) == "0 trades per market" - assert report._max_trades_per_market_since_message({"m1": 3, "m2": 1}) == "3 trades per market" - assert report._max_trades_per_market_since_message({}) == "0 trades per market" +def test_warning_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Should emit warning text only when current value is below threshold.""" + report = _load_report_module(monkeypatch) + warn = report._warning_message(1, threshold=2) + ok = report._warning_message(2, threshold=2) -def test_get_mech_requests_count(monkeypatch: pytest.MonkeyPatch) -> None: - """Should count only requests above the timestamp threshold.""" - report = _load_report_module(monkeypatch) + assert "Balance too low" in warn + assert ok == "" - mech_requests = { - "a": {"block_timestamp": 100}, - "b": {"block_timestamp": 200}, - "c": {"block_timestamp": 50}, - } - assert report._get_mech_requests_count(mech_requests, timestamp=100) == 1 +def test_print_helpers_emit_expected_text( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Header/subheader/status print helpers should format output consistently.""" + report = _load_report_module(monkeypatch) + report._print_section_header("Section") + report._print_subsection_header("Sub") + report._print_status("Key", "Val", "Msg") + output = capsys.readouterr().out -def test_warning_message(monkeypatch: pytest.MonkeyPatch) -> None: - """Should emit warning text only when current value is below threshold.""" - report = _load_report_module(monkeypatch) + assert "Section" in output + assert "Sub" in output + assert "Key" in output + assert "Val" in output + assert "Msg" in output - warn = report._warning_message(1, threshold=2) - ok = report._warning_message(2, threshold=2) - assert "Balance too low" in warn - assert ok == "" +def test_get_agent_status_container_running( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Should report Running when both trader containers are present.""" + report = _load_report_module(monkeypatch) + + containers = [ + SimpleNamespace(name="traderpearl123_abci_0"), + SimpleNamespace(name="traderpearl123_tm_0"), + ] + class _ContainerApi: + def list(self): + return containers -def test_print_helpers_emit_expected_text(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: - """Header/subheader/status print helpers should format output consistently.""" - report = _load_report_module(monkeypatch) + class _DockerClient: + containers = _ContainerApi() - report._print_section_header("Section") - report._print_subsection_header("Sub") - report._print_status("Key", "Val", "Msg") - output = capsys.readouterr().out + monkeypatch.setattr(report.docker, "from_env", lambda: _DockerClient()) + service = SimpleNamespace(path=tmp_path) - assert "Section" in output - assert "Sub" in output - assert "Key" in output - assert "Val" in output - assert "Msg" in output + status = report._get_agent_status(service) + assert "Running" in status -def test_get_agent_status_container_running(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Should report Running when both trader containers are present.""" - report = _load_report_module(monkeypatch) - containers = [ - SimpleNamespace(name="traderpearl123_abci_0"), - SimpleNamespace(name="traderpearl123_tm_0"), - ] +def test_get_agent_status_falls_back_to_pid_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Should use agent.pid + pid_exists when containers are absent.""" + report = _load_report_module(monkeypatch) - class _ContainerApi: - def list(self): - return containers + class _ContainerApi: + def list(self): + return [] - class _DockerClient: - containers = _ContainerApi() + class _DockerClient: + containers = _ContainerApi() - monkeypatch.setattr(report.docker, "from_env", lambda: _DockerClient()) - service = SimpleNamespace(path=tmp_path) + deployment_dir = tmp_path / report.DEPLOYMENT_DIR + deployment_dir.mkdir(parents=True) + (deployment_dir / "agent.pid").write_text("123", encoding="utf-8") - status = report._get_agent_status(service) + monkeypatch.setattr(report.docker, "from_env", lambda: _DockerClient()) + monkeypatch.setattr(report, "pid_exists", lambda pid: pid == 123) + service = SimpleNamespace(path=tmp_path) - assert "Running" in status + status = report._get_agent_status(service) + assert "Running" in status -def test_get_agent_status_falls_back_to_pid_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Should use agent.pid + pid_exists when containers are absent.""" - report = _load_report_module(monkeypatch) - class _ContainerApi: - def list(self): - return [] +def test_parse_args_smoke(monkeypatch: pytest.MonkeyPatch) -> None: + """_parse_args should parse empty argv without errors.""" + report = _load_report_module(monkeypatch) + monkeypatch.setattr(report.sys, "argv", ["report.py"]) + args = report._parse_args() + assert args is not None - class _DockerClient: - containers = _ContainerApi() - deployment_dir = tmp_path / report.DEPLOYMENT_DIR - deployment_dir.mkdir(parents=True) - (deployment_dir / "agent.pid").write_text("123", encoding="utf-8") +def _run_report_main( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + *, + wallet_data: dict[str, Any], + staking_token_address: str | None, + staking_state: int = 1, + raise_balance_type_error_once: bool = False, + raise_activity_checker: bool = False, + raise_mech_marketplace: bool = False, + raise_map_requests_counts: bool = False, +) -> None: + """Execute report.py __main__ with fully mocked external dependencies.""" + + import docker as docker_module + import operate.constants as operate_constants + import operate.ledger.profiles as profiles + import operate.quickstart.run_service as run_service + import requests as requests_module + import scripts.predict_trader.trades as trades_module + import scripts.utils as utils_module + import web3 + from web3.exceptions import ABIFunctionNotFound + + monkeypatch.setattr( + run_service, "ask_password_if_needed", lambda *_args, **_kwargs: None + ) + monkeypatch.setattr(sys, "argv", ["report.py"]) + + operate_home = tmp_path / ".operate" + (operate_home / "wallets").mkdir(parents=True, exist_ok=True) + (operate_home / "wallets" / "ethereum.json").write_text( + importlib.import_module("json").dumps(wallet_data), encoding="utf-8" + ) + monkeypatch.setattr(operate_constants, "OPERATE_HOME", operate_home) + + service_path = tmp_path / "service" + service_path.mkdir(parents=True, exist_ok=True) + chain_config = SimpleNamespace( + chain_data=SimpleNamespace(multisig="0x" + "1" * 40, token=123), + ledger_config=SimpleNamespace(rpc="http://rpc"), + ) + service = SimpleNamespace( + name="service-name", + chain_configs={"gnosis": chain_config}, + agent_addresses=["0x" + "2" * 40], + path=service_path, + ) + + monkeypatch.setattr( + utils_module, "get_service_from_config", lambda *_args, **_kwargs: service + ) + monkeypatch.setattr( + run_service, + "load_local_config", + lambda **_kwargs: SimpleNamespace(staking_program_id="program"), + ) + + trades_json = { + "data": { + "fpmmTrades": [ + {"creationTimestamp": "0", "fpmm": {"id": "m1"}}, + {"creationTimestamp": "0", "fpmm": {"id": "m1"}}, + ] + } + } + stats_table = { + trades_module.MarketAttribute.ROI: {trades_module.MarketState.CLOSED: 0.1} + } + monkeypatch.setattr( + trades_module, "get_mech_requests", lambda *_args, **_kwargs: {} + ) + monkeypatch.setattr( + trades_module, "get_mech_statistics", lambda *_args, **_kwargs: {} + ) + monkeypatch.setattr( + trades_module, + "_query_omen_xdai_subgraph", + lambda *_args, **_kwargs: trades_json, + ) + monkeypatch.setattr( + trades_module, "parse_user", lambda *_args, **_kwargs: ("ok", stats_table) + ) + + balance_calls = {"count": 0} + + def _fake_get_balance(*_args, **_kwargs) -> int: + if raise_balance_type_error_once and balance_calls["count"] == 0: + balance_calls["count"] += 1 + raise TypeError("unsupported block number") + balance_calls["count"] += 1 + return 10**18 + + monkeypatch.setattr(trades_module, "get_balance", _fake_get_balance) + monkeypatch.setattr( + trades_module, "get_token_balance", lambda *_args, **_kwargs: 2 * 10**18 + ) + monkeypatch.setattr( + profiles, "get_staking_contract", lambda **_kwargs: staking_token_address + ) + + class _ContainerApi: + def list(self): + return [ + SimpleNamespace(name="traderpearl123_abci_0"), + SimpleNamespace(name="traderpearl123_tm_0"), + ] + + class _DockerClient: + containers = _ContainerApi() + + monkeypatch.setattr(docker_module, "from_env", lambda: _DockerClient()) + + class _Resp: + def json(self): + return {"abi": []} + + monkeypatch.setattr(requests_module, "get", lambda *_args, **_kwargs: _Resp()) + + class _Call: + def __init__(self, value: Any = None, exc: Exception | None = None): + self.value = value + self.exc = exc + + def call(self, **_kwargs): + if self.exc: + raise self.exc + return self.value + + class _StakingFns: + def getStakingState(self, _service_id): + return _Call(staking_state) + + def activityChecker(self): + if raise_activity_checker: + return _Call(exc=RuntimeError("activity checker failure")) + return _Call("0xactivity") + + def serviceRegistryTokenUtility(self): + return _Call("0xutility") + + def getAgentIds(self): + return _Call(["1"]) + + def minStakingDeposit(self): + return _Call(100) + + def mapServiceInfo(self, _service_id): + return _Call([0, 0, 0, 7]) + + def tsCheckpoint(self): + return _Call(900) + + def livenessPeriod(self): + return _Call(100) + + def getNextRewardCheckpointTimestamp(self): + return _Call(1200) + + def getServiceInfo(self, _service_id): + return _Call([0, 0, [0, 2]]) + + class _ActivityFns: + def livenessRatio(self): + return _Call(10**18) + + def agentMech(self): + return _Call("0xagentmech") + + class _UtilityFns: + def getOperatorBalance(self, _operator_address, _service_id): + return _Call(500) + + def getAgentBond(self, _service_id, _agent_id): + return _Call(300) + + class _MMActivityFns: + def mechMarketplace(self): + if raise_mech_marketplace: + return _Call(exc=ValueError("no mech marketplace")) + return _Call("0xmech") - monkeypatch.setattr(report.docker, "from_env", lambda: _DockerClient()) - monkeypatch.setattr(report, "pid_exists", lambda pid: pid == 123) - service = SimpleNamespace(path=tmp_path) + class _MechFns: + def mapRequestsCounts(self, _safe_address): + if raise_map_requests_counts: + return _Call(exc=ABIFunctionNotFound("not found")) + return _Call(10) - status = report._get_agent_status(service) + def mapRequestCounts(self, _safe_address): + return _Call(10) - assert "Running" in status + class _Contract: + def __init__(self, functions_obj: Any): + self.functions = functions_obj + class _Eth: + block_number = 111 -def test_parse_args_smoke(monkeypatch: pytest.MonkeyPatch) -> None: - """_parse_args should parse empty argv without errors.""" - report = _load_report_module(monkeypatch) - monkeypatch.setattr(report.sys, "argv", ["report.py"]) - args = report._parse_args() - assert args is not None + def __init__(self): + self._activity_contract_calls = 0 + def contract(self, address=None, abi=None): + if address == staking_token_address: + return _Contract(_StakingFns()) + if address == "0xactivity": + self._activity_contract_calls += 1 + if self._activity_contract_calls == 1: + return _Contract(_ActivityFns()) + return _Contract(_MMActivityFns()) + if address == "0xutility": + return _Contract(_UtilityFns()) + if address in ("0xmech", "0xagentmech"): + return _Contract(_MechFns()) + return _Contract(SimpleNamespace()) -def _run_report_main( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - *, - wallet_data: dict[str, Any], - staking_token_address: str | None, - staking_state: int = 1, - raise_balance_type_error_once: bool = False, - raise_activity_checker: bool = False, - raise_mech_marketplace: bool = False, - raise_map_requests_counts: bool = False, + def get_block(self, _block_number): + return SimpleNamespace(timestamp=1000) + + class _Web3: + def __init__(self, _provider): + self.eth = _Eth() + + monkeypatch.setattr(web3, "HTTPProvider", lambda rpc: rpc) + monkeypatch.setattr(web3, "Web3", _Web3) + + runpy.run_module("scripts.predict_trader.report", run_name="__main__") + + +def test_report_main_exits_without_wallet( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """Execute report.py __main__ with fully mocked external dependencies.""" - - import operate.constants as operate_constants - import operate.ledger.profiles as profiles - import operate.quickstart.run_service as run_service - import scripts.predict_trader.trades as trades_module - import scripts.utils as utils_module - import docker as docker_module - import requests as requests_module - import web3 - from web3.exceptions import ABIFunctionNotFound - - monkeypatch.setattr(run_service, "ask_password_if_needed", lambda *_args, **_kwargs: None) - monkeypatch.setattr(sys, "argv", ["report.py"]) - - operate_home = tmp_path / ".operate" - (operate_home / "wallets").mkdir(parents=True, exist_ok=True) - (operate_home / "wallets" / "ethereum.json").write_text( - importlib.import_module("json").dumps(wallet_data), encoding="utf-8" - ) - monkeypatch.setattr(operate_constants, "OPERATE_HOME", operate_home) - - service_path = tmp_path / "service" - service_path.mkdir(parents=True, exist_ok=True) - chain_config = SimpleNamespace( - chain_data=SimpleNamespace(multisig="0x" + "1" * 40, token=123), - ledger_config=SimpleNamespace(rpc="http://rpc"), - ) - service = SimpleNamespace( - name="service-name", - chain_configs={"gnosis": chain_config}, - agent_addresses=["0x" + "2" * 40], - path=service_path, - ) - - monkeypatch.setattr(utils_module, "get_service_from_config", lambda *_args, **_kwargs: service) - monkeypatch.setattr( - run_service, - "load_local_config", - lambda **_kwargs: SimpleNamespace(staking_program_id="program"), - ) - - trades_json = { - "data": { - "fpmmTrades": [ - {"creationTimestamp": "0", "fpmm": {"id": "m1"}}, - {"creationTimestamp": "0", "fpmm": {"id": "m1"}}, - ] - } - } - stats_table = { - trades_module.MarketAttribute.ROI: {trades_module.MarketState.CLOSED: 0.1} - } - monkeypatch.setattr(trades_module, "get_mech_requests", lambda *_args, **_kwargs: {}) - monkeypatch.setattr(trades_module, "get_mech_statistics", lambda *_args, **_kwargs: {}) - monkeypatch.setattr(trades_module, "_query_omen_xdai_subgraph", lambda *_args, **_kwargs: trades_json) - monkeypatch.setattr(trades_module, "parse_user", lambda *_args, **_kwargs: ("ok", stats_table)) - - balance_calls = {"count": 0} - - def _fake_get_balance(*_args, **_kwargs) -> int: - if raise_balance_type_error_once and balance_calls["count"] == 0: - balance_calls["count"] += 1 - raise TypeError("unsupported block number") - balance_calls["count"] += 1 - return 10**18 - - monkeypatch.setattr(trades_module, "get_balance", _fake_get_balance) - monkeypatch.setattr(trades_module, "get_token_balance", lambda *_args, **_kwargs: 2 * 10**18) - monkeypatch.setattr(profiles, "get_staking_contract", lambda **_kwargs: staking_token_address) - - class _ContainerApi: - def list(self): - return [ - SimpleNamespace(name="traderpearl123_abci_0"), - SimpleNamespace(name="traderpearl123_tm_0"), - ] - - class _DockerClient: - containers = _ContainerApi() - - monkeypatch.setattr(docker_module, "from_env", lambda: _DockerClient()) - - class _Resp: - def json(self): - return {"abi": []} - - monkeypatch.setattr(requests_module, "get", lambda *_args, **_kwargs: _Resp()) - - class _Call: - def __init__(self, value: Any = None, exc: Exception | None = None): - self.value = value - self.exc = exc - - def call(self, **_kwargs): - if self.exc: - raise self.exc - return self.value - - class _StakingFns: - def getStakingState(self, _service_id): - return _Call(staking_state) - - def activityChecker(self): - if raise_activity_checker: - return _Call(exc=RuntimeError("activity checker failure")) - return _Call("0xactivity") - - def serviceRegistryTokenUtility(self): - return _Call("0xutility") - - def getAgentIds(self): - return _Call(["1"]) - - def minStakingDeposit(self): - return _Call(100) - - def mapServiceInfo(self, _service_id): - return _Call([0, 0, 0, 7]) - - def tsCheckpoint(self): - return _Call(900) - - def livenessPeriod(self): - return _Call(100) - - def getNextRewardCheckpointTimestamp(self): - return _Call(1200) - - def getServiceInfo(self, _service_id): - return _Call([0, 0, [0, 2]]) - - class _ActivityFns: - def livenessRatio(self): - return _Call(10**18) - - def agentMech(self): - return _Call("0xagentmech") - - class _UtilityFns: - def getOperatorBalance(self, _operator_address, _service_id): - return _Call(500) - - def getAgentBond(self, _service_id, _agent_id): - return _Call(300) - - class _MMActivityFns: - def mechMarketplace(self): - if raise_mech_marketplace: - return _Call(exc=ValueError("no mech marketplace")) - return _Call("0xmech") - - class _MechFns: - def mapRequestsCounts(self, _safe_address): - if raise_map_requests_counts: - return _Call(exc=ABIFunctionNotFound("not found")) - return _Call(10) + """Main should exit if operate wallet file does not exist.""" + + import operate.constants as operate_constants + import operate.quickstart.run_service as run_service + + monkeypatch.setattr( + run_service, "ask_password_if_needed", lambda *_args, **_kwargs: None + ) + monkeypatch.setattr(sys, "argv", ["report.py"]) + monkeypatch.setattr(operate_constants, "OPERATE_HOME", tmp_path / "missing-operate") + + with pytest.raises(SystemExit): + runpy.run_module("scripts.predict_trader.report", run_name="__main__") - def mapRequestCounts(self, _safe_address): - return _Call(10) - - class _Contract: - def __init__(self, functions_obj: Any): - self.functions = functions_obj - class _Eth: - block_number = 111 - - def __init__(self): - self._activity_contract_calls = 0 - - def contract(self, address=None, abi=None): - if address == staking_token_address: - return _Contract(_StakingFns()) - if address == "0xactivity": - self._activity_contract_calls += 1 - if self._activity_contract_calls == 1: - return _Contract(_ActivityFns()) - return _Contract(_MMActivityFns()) - if address == "0xutility": - return _Contract(_UtilityFns()) - if address in ("0xmech", "0xagentmech"): - return _Contract(_MechFns()) - return _Contract(SimpleNamespace()) - - def get_block(self, _block_number): - return SimpleNamespace(timestamp=1000) - - class _Web3: - def __init__(self, _provider): - self.eth = _Eth() - - monkeypatch.setattr(web3, "HTTPProvider", lambda rpc: rpc) - monkeypatch.setattr(web3, "Web3", _Web3) - - runpy.run_module("scripts.predict_trader.report", run_name="__main__") - - -def test_report_main_exits_without_wallet(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Main should exit if operate wallet file does not exist.""" - - import operate.constants as operate_constants - import operate.quickstart.run_service as run_service - - monkeypatch.setattr(run_service, "ask_password_if_needed", lambda *_args, **_kwargs: None) - monkeypatch.setattr(sys, "argv", ["report.py"]) - monkeypatch.setattr(operate_constants, "OPERATE_HOME", tmp_path / "missing-operate") - - with pytest.raises(SystemExit): - runpy.run_module("scripts.predict_trader.report", run_name="__main__") - - -def test_report_main_exits_without_gnosis_safe(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Main should exit if wallet json lacks gnosis safe entry.""" - - wallet_data = {"address": "0x" + "a" * 40, "safes": {}} - with pytest.raises(SystemExit): - _run_report_main( - monkeypatch, - tmp_path, - wallet_data=wallet_data, - staking_token_address=None, - ) - - -def test_report_main_runs_non_staked_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Main should complete when staking contract is unavailable.""" - - wallet_data = { - "address": "0x" + "a" * 40, - "safes": {"gnosis": "0x" + "b" * 40}, - } - _run_report_main( - monkeypatch, - tmp_path, - wallet_data=wallet_data, - staking_token_address=None, - raise_balance_type_error_once=True, - ) - - -def test_report_main_runs_staked_flow_with_fallbacks(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Main should execute staked path and fallback mech calls.""" - - wallet_data = { - "address": "0x" + "a" * 40, - "safes": {"gnosis": "0x" + "b" * 40}, - } - _run_report_main( - monkeypatch, - tmp_path, - wallet_data=wallet_data, - staking_token_address="0xstake", - staking_state=1, - raise_mech_marketplace=True, - raise_map_requests_counts=True, - ) - - -def test_report_main_evicted_and_staking_try_exception(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Main should hit evicted state branch and outer staking exception handler.""" - - wallet_data = { - "address": "0x" + "a" * 40, - "safes": {"gnosis": "0x" + "b" * 40}, - } - _run_report_main( - monkeypatch, - tmp_path, - wallet_data=wallet_data, - staking_token_address="0xstake", - staking_state=2, - raise_activity_checker=True, - ) +def test_report_main_exits_without_gnosis_safe( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Main should exit if wallet json lacks gnosis safe entry.""" + + wallet_data = {"address": "0x" + "a" * 40, "safes": {}} + with pytest.raises(SystemExit): + _run_report_main( + monkeypatch, + tmp_path, + wallet_data=wallet_data, + staking_token_address=None, + ) + + +def test_report_main_runs_non_staked_flow( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Main should complete when staking contract is unavailable.""" + + wallet_data = { + "address": "0x" + "a" * 40, + "safes": {"gnosis": "0x" + "b" * 40}, + } + _run_report_main( + monkeypatch, + tmp_path, + wallet_data=wallet_data, + staking_token_address=None, + raise_balance_type_error_once=True, + ) + + +def test_report_main_runs_staked_flow_with_fallbacks( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Main should execute staked path and fallback mech calls.""" + + wallet_data = { + "address": "0x" + "a" * 40, + "safes": {"gnosis": "0x" + "b" * 40}, + } + _run_report_main( + monkeypatch, + tmp_path, + wallet_data=wallet_data, + staking_token_address="0xstake", + staking_state=1, + raise_mech_marketplace=True, + raise_map_requests_counts=True, + ) + + +def test_report_main_evicted_and_staking_try_exception( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Main should hit evicted state branch and outer staking exception handler.""" + + wallet_data = { + "address": "0x" + "a" * 40, + "safes": {"gnosis": "0x" + "b" * 40}, + } + _run_report_main( + monkeypatch, + tmp_path, + wallet_data=wallet_data, + staking_token_address="0xstake", + staking_state=2, + raise_activity_checker=True, + ) diff --git a/tests/test_scripts/test_predict_trader/test_trades.py b/tests/test_scripts/test_predict_trader/test_trades.py index 85af9103..ca37dfb4 100644 --- a/tests/test_scripts/test_predict_trader/test_trades.py +++ b/tests/test_scripts/test_predict_trader/test_trades.py @@ -7,488 +7,573 @@ from typing import Any import pytest - -from scripts.predict_trader import trades from scripts import utils as scripts_utils +from scripts.predict_trader import trades class _Response: - """Simple fake response for requests.post.""" + """Simple fake response for requests.post.""" - def __init__(self, payload: dict[str, Any]): - self._payload = payload + def __init__(self, payload: dict[str, Any]): + self._payload = payload - def json(self) -> dict[str, Any]: - """Return payload as JSON.""" - return self._payload + def json(self) -> dict[str, Any]: + """Return payload as JSON.""" + return self._payload def test_parse_args_with_creator(monkeypatch: pytest.MonkeyPatch) -> None: - """Argument parsing should set UTC timezone and preserve creator.""" - - creator = "0x" + "a" * 40 - monkeypatch.setattr( - trades.sys, - "argv", - [ - "trades.py", - "--creator", - creator, - "--from-date", - "2025-01-01T00:00:00", - "--to-date", - "2025-01-02T00:00:00", - ], - ) - - args = trades._parse_args() - - assert args.creator == creator - assert args.from_date.tzinfo == datetime.timezone.utc - assert args.to_date.tzinfo == datetime.timezone.utc - - -def test_parse_args_without_creator_uses_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """If creator is omitted, config-derived multisig should be used.""" + """Argument parsing should set UTC timezone and preserve creator.""" + + creator = "0x" + "a" * 40 + monkeypatch.setattr( + trades.sys, + "argv", + [ + "trades.py", + "--creator", + creator, + "--from-date", + "2025-01-01T00:00:00", + "--to-date", + "2025-01-02T00:00:00", + ], + ) + + args = trades._parse_args() + + assert args.creator == creator + assert args.from_date.tzinfo == datetime.timezone.utc + assert args.to_date.tzinfo == datetime.timezone.utc + + +def test_parse_args_without_creator_uses_config( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """If creator is omitted, config-derived multisig should be used.""" - script_path = tmp_path / "scripts" / "predict_trader" - (tmp_path / "configs").mkdir(parents=True) - (tmp_path / "configs" / "config_predict_trader.json").write_text("{}", encoding="utf-8") - script_path.mkdir(parents=True) + script_path = tmp_path / "scripts" / "predict_trader" + (tmp_path / "configs").mkdir(parents=True) + (tmp_path / "configs" / "config_predict_trader.json").write_text( + "{}", encoding="utf-8" + ) + script_path.mkdir(parents=True) - class _Cfg: - chain_configs = { - "gnosis": type("_X", (), {"chain_data": type("_Y", (), {"multisig": "0x" + "b" * 40})()})() - } + class _Cfg: + chain_configs = { + "gnosis": type( + "_X", + (), + {"chain_data": type("_Y", (), {"multisig": "0x" + "b" * 40})()}, + )() + } - monkeypatch.setattr(trades, "SCRIPT_PATH", script_path) - monkeypatch.setattr(trades, "get_service_from_config", lambda *_args, **_kwargs: _Cfg()) - monkeypatch.setattr(trades.sys, "argv", ["trades.py"]) + monkeypatch.setattr(trades, "SCRIPT_PATH", script_path) + monkeypatch.setattr( + trades, "get_service_from_config", lambda *_args, **_kwargs: _Cfg() + ) + monkeypatch.setattr(trades.sys, "argv", ["trades.py"]) - args = trades._parse_args() + args = trades._parse_args() - assert args.creator == "0x" + "b" * 40 + assert args.creator == "0x" + "b" * 40 def test_parse_args_without_creator_and_missing_config_exits( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """Missing default config should exit when creator is not provided.""" + """Missing default config should exit when creator is not provided.""" - script_path = tmp_path / "scripts" / "predict_trader" - script_path.mkdir(parents=True) - monkeypatch.setattr(trades, "SCRIPT_PATH", script_path) - monkeypatch.setattr(trades.sys, "argv", ["trades.py"]) + script_path = tmp_path / "scripts" / "predict_trader" + script_path.mkdir(parents=True) + monkeypatch.setattr(trades, "SCRIPT_PATH", script_path) + monkeypatch.setattr(trades.sys, "argv", ["trades.py"]) - with pytest.raises(SystemExit): - trades._parse_args() + with pytest.raises(SystemExit): + trades._parse_args() def test_parse_args_with_invalid_creator_exits(monkeypatch: pytest.MonkeyPatch) -> None: - """Invalid Ethereum creator value should raise parser error.""" + """Invalid Ethereum creator value should raise parser error.""" - monkeypatch.setattr(trades.sys, "argv", ["trades.py", "--creator", "invalid"]) - with pytest.raises(SystemExit): - trades._parse_args() + monkeypatch.setattr(trades.sys, "argv", ["trades.py", "--creator", "invalid"]) + with pytest.raises(SystemExit): + trades._parse_args() def test_to_content_wraps_query() -> None: - """_to_content should match expected request structure.""" + """_to_content should match expected request structure.""" - query = "{ q }" - assert trades._to_content(query) == { - "query": query, - "variables": None, - "extensions": {"headers": None}, - } + query = "{ q }" + assert trades._to_content(query) == { + "query": query, + "variables": None, + "extensions": {"headers": None}, + } def test_query_omen_xdai_subgraph_paginates( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, requests_mock + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, requests_mock ) -> None: - """Should paginate by creationTimestamp for each FPMM creator.""" + """Should paginate by creationTimestamp for each FPMM creator.""" - creator = "0x" + "c" * 40 - calls: list[dict[str, Any]] = [] - responses = [ - {"data": {"fpmmTrades": [{"id": "1", "creationTimestamp": "10", "fpmm": {"id": "m1"}}]}}, - {"data": {"fpmmTrades": []}}, - {"data": {"fpmmTrades": []}}, - ] + creator = "0x" + "c" * 40 + calls: list[dict[str, Any]] = [] + responses = [ + { + "data": { + "fpmmTrades": [ + {"id": "1", "creationTimestamp": "10", "fpmm": {"id": "m1"}} + ] + } + }, + {"data": {"fpmmTrades": []}}, + {"data": {"fpmmTrades": []}}, + ] - operate_home = tmp_path / ".operate" - operate_home.mkdir(parents=True) - (operate_home / "subgraph_api_key.txt").write_text("k", encoding="utf-8") - monkeypatch.setattr(scripts_utils, "OPERATE_HOME", operate_home) + operate_home = tmp_path / ".operate" + operate_home.mkdir(parents=True) + (operate_home / "subgraph_api_key.txt").write_text("k", encoding="utf-8") + monkeypatch.setattr(scripts_utils, "OPERATE_HOME", operate_home) - url = "https://gateway-arbitrum.network.thegraph.com/api/k/subgraphs/id/9fUVQpFwzpdWS9bq5WkAnmKbNNcoBwatMR4yZq81pbbz" - requests_mock.post(url, [{"json": responses[0]}, {"json": responses[1]}, {"json": responses[2]}]) + url = "https://gateway-arbitrum.network.thegraph.com/api/k/subgraphs/id/9fUVQpFwzpdWS9bq5WkAnmKbNNcoBwatMR4yZq81pbbz" + requests_mock.post( + url, [{"json": responses[0]}, {"json": responses[1]}, {"json": responses[2]}] + ) - original_to_content = trades._to_content + original_to_content = trades._to_content - def _capture(query: str) -> dict[str, Any]: - payload = original_to_content(query) - calls.append(payload) - return payload + def _capture(query: str) -> dict[str, Any]: + payload = original_to_content(query) + calls.append(payload) + return payload - monkeypatch.setattr(trades, "_to_content", _capture) + monkeypatch.setattr(trades, "_to_content", _capture) - result = trades._query_omen_xdai_subgraph(creator, 1, 2, 3, 4) + result = trades._query_omen_xdai_subgraph(creator, 1, 2, 3, 4) - assert len(calls) == 3 - assert 'creationTimestamp_gt: "0"' in calls[0]["query"] - assert 'creationTimestamp_gt: "10"' in calls[1]["query"] - assert result["data"]["fpmmTrades"][0]["id"] == "1" + assert len(calls) == 3 + assert 'creationTimestamp_gt: "0"' in calls[0]["query"] + assert 'creationTimestamp_gt: "10"' in calls[1]["query"] + assert result["data"]["fpmmTrades"][0]["id"] == "1" def test_query_conditional_tokens_gc_subgraph( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, requests_mock + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, requests_mock ) -> None: - """Should paginate user positions until empty page.""" + """Should paginate user positions until empty page.""" - responses = [ - {"data": {"user": {"userPositions": [{"id": "p1"}, {"id": "p2"}]}}}, - {"data": {"user": {"userPositions": []}}}, - ] + responses = [ + {"data": {"user": {"userPositions": [{"id": "p1"}, {"id": "p2"}]}}}, + {"data": {"user": {"userPositions": []}}}, + ] - operate_home = tmp_path / ".operate" - operate_home.mkdir(parents=True) - (operate_home / "subgraph_api_key.txt").write_text("k", encoding="utf-8") - monkeypatch.setattr(scripts_utils, "OPERATE_HOME", operate_home) + operate_home = tmp_path / ".operate" + operate_home.mkdir(parents=True) + (operate_home / "subgraph_api_key.txt").write_text("k", encoding="utf-8") + monkeypatch.setattr(scripts_utils, "OPERATE_HOME", operate_home) - url = "https://gateway-arbitrum.network.thegraph.com/api/k/subgraphs/id/7s9rGBffUTL8kDZuxvvpuc46v44iuDarbrADBFw5uVp2" - requests_mock.post(url, [{"json": responses[0]}, {"json": responses[1]}]) + url = "https://gateway-arbitrum.network.thegraph.com/api/k/subgraphs/id/7s9rGBffUTL8kDZuxvvpuc46v44iuDarbrADBFw5uVp2" + requests_mock.post(url, [{"json": responses[0]}, {"json": responses[1]}]) - result = trades._query_conditional_tokens_gc_subgraph("0xabc") + result = trades._query_conditional_tokens_gc_subgraph("0xabc") - history = requests_mock.request_history - assert len(history) == 2 - assert 'id_gt: ""' in history[0].json()["query"] - assert 'id_gt: "p2"' in history[1].json()["query"] - assert result["data"]["user"]["userPositions"] == [{"id": "p1"}, {"id": "p2"}] + history = requests_mock.request_history + assert len(history) == 2 + assert 'id_gt: ""' in history[0].json()["query"] + assert 'id_gt: "p2"' in history[1].json()["query"] + assert result["data"]["user"]["userPositions"] == [{"id": "p1"}, {"id": "p2"}] def test_query_conditional_tokens_gc_subgraph_returns_none_when_empty( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, requests_mock + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, requests_mock ) -> None: - """No user data should produce {'data': {'user': None}}.""" + """No user data should produce {'data': {'user': None}}.""" - operate_home = tmp_path / ".operate" - operate_home.mkdir(parents=True) - (operate_home / "subgraph_api_key.txt").write_text("k", encoding="utf-8") - monkeypatch.setattr(scripts_utils, "OPERATE_HOME", operate_home) + operate_home = tmp_path / ".operate" + operate_home.mkdir(parents=True) + (operate_home / "subgraph_api_key.txt").write_text("k", encoding="utf-8") + monkeypatch.setattr(scripts_utils, "OPERATE_HOME", operate_home) - url = "https://gateway-arbitrum.network.thegraph.com/api/k/subgraphs/id/7s9rGBffUTL8kDZuxvvpuc46v44iuDarbrADBFw5uVp2" - requests_mock.post(url, json={"data": {"user": {}}}) + url = "https://gateway-arbitrum.network.thegraph.com/api/k/subgraphs/id/7s9rGBffUTL8kDZuxvvpuc46v44iuDarbrADBFw5uVp2" + requests_mock.post(url, json={"data": {"user": {}}}) - result = trades._query_conditional_tokens_gc_subgraph("0xabc") + result = trades._query_conditional_tokens_gc_subgraph("0xabc") - assert result == {"data": {"user": None}} + assert result == {"data": {"user": None}} def test_unit_conversion_helpers() -> None: - """Wei conversion helpers should return expected units and strings.""" + """Wei conversion helpers should return expected units and strings.""" - assert trades.wei_to_unit(10**18) == 1 - assert trades.wei_to_xdai(2 * 10**18) == "2.00 xDAI" - assert trades.wei_to_wxdai(3 * 10**18) == "3.00 WxDAI" - assert trades.wei_to_olas(4 * 10**18) == "4.00 OLAS" + assert trades.wei_to_unit(10**18) == 1 + assert trades.wei_to_xdai(2 * 10**18) == "2.00 xDAI" + assert trades.wei_to_wxdai(3 * 10**18) == "3.00 WxDAI" + assert trades.wei_to_olas(4 * 10**18) == "4.00 OLAS" def test_is_redeemed_paths() -> None: - """_is_redeemed should detect unredeemed, redeemed, and unknown states.""" - - fpmm_trade = { - "outcomeTokensTraded": "10", - "fpmm": {"condition": {"id": "cond-1"}}, - } - - not_redeemed = { - "data": { - "user": { - "userPositions": [ - { - "balance": "10", - "position": {"conditionIds": ["cond-1"]}, - } - ] - } - } - } - redeemed = { - "data": { - "user": { - "userPositions": [ - { - "balance": "0", - "position": {"conditionIds": ["cond-1"]}, - } - ] - } - } - } - unknown = {"data": {"user": {"userPositions": []}}} - - assert trades._is_redeemed(not_redeemed, fpmm_trade) is False - assert trades._is_redeemed(redeemed, fpmm_trade) is True - assert trades._is_redeemed(unknown, fpmm_trade) is False + """_is_redeemed should detect unredeemed, redeemed, and unknown states.""" + + fpmm_trade = { + "outcomeTokensTraded": "10", + "fpmm": {"condition": {"id": "cond-1"}}, + } + + not_redeemed = { + "data": { + "user": { + "userPositions": [ + { + "balance": "10", + "position": {"conditionIds": ["cond-1"]}, + } + ] + } + } + } + redeemed = { + "data": { + "user": { + "userPositions": [ + { + "balance": "0", + "position": {"conditionIds": ["cond-1"]}, + } + ] + } + } + } + unknown = {"data": {"user": {"userPositions": []}}} + + assert trades._is_redeemed(not_redeemed, fpmm_trade) is False + assert trades._is_redeemed(redeemed, fpmm_trade) is True + assert trades._is_redeemed(unknown, fpmm_trade) is False def test_compute_roi() -> None: - """ROI should be computed safely for zero and non-zero initial value.""" + """ROI should be computed safely for zero and non-zero initial value.""" - assert trades._compute_roi(100, 150) == 0.5 - assert trades._compute_roi(0, 999) == 0.0 + assert trades._compute_roi(100, 150) == 0.5 + assert trades._compute_roi(0, 999) == 0.0 def test_compute_totals_recomputes_derived_fields() -> None: - """_compute_totals should fill TOTAL and recompute net earnings and ROI.""" + """_compute_totals should fill TOTAL and recompute net earnings and ROI.""" - table = {row: {col: 0 for col in trades.STATS_TABLE_COLS} for row in trades.STATS_TABLE_ROWS} - table[trades.MarketAttribute.INVESTMENT][trades.MarketState.CLOSED] = 100 - table[trades.MarketAttribute.FEES][trades.MarketState.CLOSED] = 10 - table[trades.MarketAttribute.EARNINGS][trades.MarketState.CLOSED] = 150 - mech_statistics = {"q1": {"count": 2, "fees": 5}, "q2": {"count": 1, "fees": 10}} + table = { + row: {col: 0 for col in trades.STATS_TABLE_COLS} + for row in trades.STATS_TABLE_ROWS + } + table[trades.MarketAttribute.INVESTMENT][trades.MarketState.CLOSED] = 100 + table[trades.MarketAttribute.FEES][trades.MarketState.CLOSED] = 10 + table[trades.MarketAttribute.EARNINGS][trades.MarketState.CLOSED] = 150 + mech_statistics = {"q1": {"count": 2, "fees": 5}, "q2": {"count": 1, "fees": 10}} - trades._compute_totals(table, mech_statistics) + trades._compute_totals(table, mech_statistics) - assert table[trades.MarketAttribute.MECH_CALLS]["TOTAL"] == 3 - assert table[trades.MarketAttribute.MECH_FEES]["TOTAL"] == 15 - assert table[trades.MarketAttribute.INVESTMENT][trades.MarketState.CLOSED] == 90 - assert table[trades.MarketAttribute.NET_EARNINGS][trades.MarketState.CLOSED] == 50 + assert table[trades.MarketAttribute.MECH_CALLS]["TOTAL"] == 3 + assert table[trades.MarketAttribute.MECH_FEES]["TOTAL"] == 15 + assert table[trades.MarketAttribute.INVESTMENT][trades.MarketState.CLOSED] == 90 + assert table[trades.MarketAttribute.NET_EARNINGS][trades.MarketState.CLOSED] == 50 def test_get_market_state_paths(monkeypatch: pytest.MonkeyPatch) -> None: - """_get_market_state should return expected values across branches.""" - - class _FakeDateTime(datetime.datetime): - @classmethod - def utcnow(cls): - return cls(2025, 1, 1, 0, 0, 0) - - monkeypatch.setattr(trades.datetime, "datetime", _FakeDateTime) - - open_market = {"currentAnswer": None, "openingTimestamp": str(1735790400)} # 2025-01-02 - pending_market = {"currentAnswer": None, "openingTimestamp": str(1735603200)} # 2024-12-31 - arbitrating_market = {"currentAnswer": "0x0", "isPendingArbitration": True, "answerFinalizedTimestamp": "0"} - finalizing_market = {"currentAnswer": "0x0", "isPendingArbitration": False, "answerFinalizedTimestamp": str(1735776000)} - closed_market = {"currentAnswer": "0x0", "isPendingArbitration": False, "answerFinalizedTimestamp": str(1735689600)} - - assert trades._get_market_state(open_market) == trades.MarketState.OPEN - assert trades._get_market_state(pending_market) == trades.MarketState.PENDING - assert trades._get_market_state(arbitrating_market) == trades.MarketState.ARBITRATING - assert trades._get_market_state(finalizing_market) == trades.MarketState.FINALIZING - assert trades._get_market_state(closed_market) == trades.MarketState.CLOSED + """_get_market_state should return expected values across branches.""" + + class _FakeDateTime(datetime.datetime): + @classmethod + def utcnow(cls): + return cls(2025, 1, 1, 0, 0, 0) + + monkeypatch.setattr(trades.datetime, "datetime", _FakeDateTime) + + open_market = { + "currentAnswer": None, + "openingTimestamp": str(1735790400), + } # 2025-01-02 + pending_market = { + "currentAnswer": None, + "openingTimestamp": str(1735603200), + } # 2024-12-31 + arbitrating_market = { + "currentAnswer": "0x0", + "isPendingArbitration": True, + "answerFinalizedTimestamp": "0", + } + finalizing_market = { + "currentAnswer": "0x0", + "isPendingArbitration": False, + "answerFinalizedTimestamp": str(1735776000), + } + closed_market = { + "currentAnswer": "0x0", + "isPendingArbitration": False, + "answerFinalizedTimestamp": str(1735689600), + } + + assert trades._get_market_state(open_market) == trades.MarketState.OPEN + assert trades._get_market_state(pending_market) == trades.MarketState.PENDING + assert ( + trades._get_market_state(arbitrating_market) == trades.MarketState.ARBITRATING + ) + assert trades._get_market_state(finalizing_market) == trades.MarketState.FINALIZING + assert trades._get_market_state(closed_market) == trades.MarketState.CLOSED def test_format_table_and_mech_statistics() -> None: - """Formatting and mech aggregation helpers should produce expected content.""" - - table = {row: {col: 0 for col in trades.STATS_TABLE_COLS} for row in trades.STATS_TABLE_ROWS} - rendered = trades._format_table(table) - assert str(trades.MarketAttribute.ROI) in rendered - assert "TOTAL" in rendered - - mech_requests = { - "a": {"ipfs_contents": {"tool": "foo", "prompt": 'Ask "What is this?"'}, "fee": 10}, - "b": {"ipfs_contents": {"tool": "foo", "prompt": "Simple prompt"}, "fee": 5}, - "c": {"ipfs_contents": {"tool": trades.IRRELEVANT_TOOLS[0], "prompt": "skip"}, "fee": 99}, - } - stats = trades.get_mech_statistics(mech_requests) - - assert stats["What is this?"]["count"] == 1 - assert stats["What is this?"]["fees"] == 10 - assert stats["Simple prompt"]["count"] == 1 + """Formatting and mech aggregation helpers should produce expected content.""" + + table = { + row: {col: 0 for col in trades.STATS_TABLE_COLS} + for row in trades.STATS_TABLE_ROWS + } + rendered = trades._format_table(table) + assert str(trades.MarketAttribute.ROI) in rendered + assert "TOTAL" in rendered + + mech_requests = { + "a": { + "ipfs_contents": {"tool": "foo", "prompt": 'Ask "What is this?"'}, + "fee": 10, + }, + "b": {"ipfs_contents": {"tool": "foo", "prompt": "Simple prompt"}, "fee": 5}, + "c": { + "ipfs_contents": {"tool": trades.IRRELEVANT_TOOLS[0], "prompt": "skip"}, + "fee": 99, + }, + } + stats = trades.get_mech_statistics(mech_requests) + + assert stats["What is this?"]["count"] == 1 + assert stats["What is this?"]["fees"] == 10 + assert stats["Simple prompt"]["count"] == 1 def test_balance_helpers_use_rpc(monkeypatch: pytest.MonkeyPatch) -> None: - """RPC balance helpers should parse hex balances to integers.""" + """RPC balance helpers should parse hex balances to integers.""" - responses = [_Response({"result": "0xa"}), _Response({"result": "0xb"})] - idx = {"value": 0} + responses = [_Response({"result": "0xa"}), _Response({"result": "0xb"})] + idx = {"value": 0} - def _fake_post(*_args, **_kwargs): - i = idx["value"] - idx["value"] += 1 - return responses[i] + def _fake_post(*_args, **_kwargs): + i = idx["value"] + idx["value"] += 1 + return responses[i] - monkeypatch.setattr(trades.requests, "post", _fake_post) + monkeypatch.setattr(trades.requests, "post", _fake_post) - assert trades.get_balance("0x" + "1" * 40, "http://rpc") == 10 - assert trades.get_token_balance("0x" + "2" * 40, "0x" + "3" * 40, "http://rpc") == 11 + assert trades.get_balance("0x" + "1" * 40, "http://rpc") == 10 + assert ( + trades.get_token_balance("0x" + "2" * 40, "0x" + "3" * 40, "http://rpc") == 11 + ) def test_market_attribute_repr_and_argparse_error() -> None: - """MarketAttribute repr and invalid argparse input should be covered.""" + """MarketAttribute repr and invalid argparse input should be covered.""" - assert repr(trades.MarketAttribute.ROI) == "ROI" - with pytest.raises(ValueError, match="Invalid MarketAttribute"): - trades.MarketAttribute.argparse("not-an-attr") + assert repr(trades.MarketAttribute.ROI) == "ROI" + with pytest.raises(ValueError, match="Invalid MarketAttribute"): + trades.MarketAttribute.argparse("not-an-attr") def test_get_market_state_exception_returns_unknown() -> None: - """Malformed market input should be handled and return UNKNOWN.""" + """Malformed market input should be handled and return UNKNOWN.""" - assert trades._get_market_state({}) == trades.MarketState.UNKNOWN + assert trades._get_market_state({}) == trades.MarketState.UNKNOWN def test_parse_user_covers_status_branches(monkeypatch: pytest.MonkeyPatch) -> None: - """parse_user should exercise finalizing/closed branches and type error fallback.""" - - def _trade(title: str, fpmm_id: str, current_answer: str, outcome_index: str = "0", traded: str = "100") -> dict[str, Any]: - return { - "title": title, - "collateralAmount": "100", - "outcomeIndex": outcome_index, - "feeAmount": "5", - "outcomeTokensTraded": traded, - "creationTimestamp": "1", - "fpmm": { - "id": fpmm_id, - "outcomes": ["YES", "NO"], - "currentAnswer": current_answer, - "isPendingArbitration": False, - "answerFinalizedTimestamp": "0", - "openingTimestamp": "0", - "condition": {"id": f"cond-{fpmm_id}"}, - }, - } - - trade_type_error = { - "title": "type-error", - "collateralAmount": None, - "outcomeIndex": "0", - "feeAmount": "5", - "outcomeTokensTraded": "100", - "creationTimestamp": "1", - "fpmm": { - "id": "m0", - "outcomes": ["YES", "NO"], - "currentAnswer": "0x0", - "isPendingArbitration": False, - "answerFinalizedTimestamp": "0", - "openingTimestamp": "0", - "condition": {"id": "cond-m0"}, - }, - } - - trades_json = { - "data": { - "fpmmTrades": [ - trade_type_error, - _trade("fin-invalid", "m1", hex(trades.INVALID_ANSWER)), - _trade("fin-winner", "m2", "0x0", outcome_index="0", traded="200"), - _trade("fin-loser", "m3", "0x1", outcome_index="0"), - _trade("closed-invalid", "m4", hex(trades.INVALID_ANSWER)), - _trade("closed-winner", "m5", "0x0", outcome_index="0", traded="1"), - _trade("closed-loser", "m6", "0x1", outcome_index="0"), - ] - } - } - - status_map = { - "m1": trades.MarketState.FINALIZING, - "m2": trades.MarketState.FINALIZING, - "m3": trades.MarketState.FINALIZING, - "m4": trades.MarketState.CLOSED, - "m5": trades.MarketState.CLOSED, - "m6": trades.MarketState.CLOSED, - } - - monkeypatch.setattr(trades, "_get_market_state", lambda fpmm: status_map[fpmm["id"]]) - monkeypatch.setattr( - trades, - "_query_conditional_tokens_gc_subgraph", - lambda *_args, **_kwargs: { - "data": { - "user": { - "userPositions": [ - {"position": {"conditionIds": ["cond-m4"]}, "balance": "0"}, - {"position": {"conditionIds": ["cond-m5"]}, "balance": "0"}, - ] - } - } - }, - ) - monkeypatch.setattr(trades, "get_balance", lambda *_args, **_kwargs: 10**18) - monkeypatch.setattr(trades, "get_token_balance", lambda *_args, **_kwargs: 2 * 10**18) - - mech_stats = { - "fin-invalid": {"count": 1, "fees": 1}, - "closed-winner": {"count": 2, "fees": 2}, - } - - output, table = trades.parse_user("http://rpc", "0x" + "a" * 40, trades_json, mech_stats) - - assert "ERROR RETRIEVING TRADE INFORMATION" in output - assert "Current answer: Market has been declared invalid." in output - assert "Final answer: Market has been declared invalid." in output - assert "Congrats! The trade was for the winner answer." in output - assert "The trade was for the loser answer." in output - assert "Earnings are dust." in output - assert table[trades.MarketAttribute.NUM_INVALID_MARKET][trades.MarketState.CLOSED] == 1 - assert table[trades.MarketAttribute.NUM_REDEEMED][trades.MarketState.CLOSED] == 1 + """parse_user should exercise finalizing/closed branches and type error fallback.""" + + def _trade( + title: str, + fpmm_id: str, + current_answer: str, + outcome_index: str = "0", + traded: str = "100", + ) -> dict[str, Any]: + return { + "title": title, + "collateralAmount": "100", + "outcomeIndex": outcome_index, + "feeAmount": "5", + "outcomeTokensTraded": traded, + "creationTimestamp": "1", + "fpmm": { + "id": fpmm_id, + "outcomes": ["YES", "NO"], + "currentAnswer": current_answer, + "isPendingArbitration": False, + "answerFinalizedTimestamp": "0", + "openingTimestamp": "0", + "condition": {"id": f"cond-{fpmm_id}"}, + }, + } + + trade_type_error = { + "title": "type-error", + "collateralAmount": None, + "outcomeIndex": "0", + "feeAmount": "5", + "outcomeTokensTraded": "100", + "creationTimestamp": "1", + "fpmm": { + "id": "m0", + "outcomes": ["YES", "NO"], + "currentAnswer": "0x0", + "isPendingArbitration": False, + "answerFinalizedTimestamp": "0", + "openingTimestamp": "0", + "condition": {"id": "cond-m0"}, + }, + } + + trades_json = { + "data": { + "fpmmTrades": [ + trade_type_error, + _trade("fin-invalid", "m1", hex(trades.INVALID_ANSWER)), + _trade("fin-winner", "m2", "0x0", outcome_index="0", traded="200"), + _trade("fin-loser", "m3", "0x1", outcome_index="0"), + _trade("closed-invalid", "m4", hex(trades.INVALID_ANSWER)), + _trade("closed-winner", "m5", "0x0", outcome_index="0", traded="1"), + _trade("closed-loser", "m6", "0x1", outcome_index="0"), + ] + } + } + + status_map = { + "m1": trades.MarketState.FINALIZING, + "m2": trades.MarketState.FINALIZING, + "m3": trades.MarketState.FINALIZING, + "m4": trades.MarketState.CLOSED, + "m5": trades.MarketState.CLOSED, + "m6": trades.MarketState.CLOSED, + } + + monkeypatch.setattr( + trades, "_get_market_state", lambda fpmm: status_map[fpmm["id"]] + ) + monkeypatch.setattr( + trades, + "_query_conditional_tokens_gc_subgraph", + lambda *_args, **_kwargs: { + "data": { + "user": { + "userPositions": [ + {"position": {"conditionIds": ["cond-m4"]}, "balance": "0"}, + {"position": {"conditionIds": ["cond-m5"]}, "balance": "0"}, + ] + } + } + }, + ) + monkeypatch.setattr(trades, "get_balance", lambda *_args, **_kwargs: 10**18) + monkeypatch.setattr( + trades, "get_token_balance", lambda *_args, **_kwargs: 2 * 10**18 + ) + + mech_stats = { + "fin-invalid": {"count": 1, "fees": 1}, + "closed-winner": {"count": 2, "fees": 2}, + } + + output, table = trades.parse_user( + "http://rpc", "0x" + "a" * 40, trades_json, mech_stats + ) + + assert "ERROR RETRIEVING TRADE INFORMATION" in output + assert "Current answer: Market has been declared invalid." in output + assert "Final answer: Market has been declared invalid." in output + assert "Congrats! The trade was for the winner answer." in output + assert "The trade was for the loser answer." in output + assert "Earnings are dust." in output + assert ( + table[trades.MarketAttribute.NUM_INVALID_MARKET][trades.MarketState.CLOSED] == 1 + ) + assert table[trades.MarketAttribute.NUM_REDEEMED][trades.MarketState.CLOSED] == 1 def test_get_mech_statistics_skips_missing_ipfs_fields() -> None: - """Missing ipfs tool/prompt keys should be ignored.""" + """Missing ipfs tool/prompt keys should be ignored.""" - stats = trades.get_mech_statistics({ - "a": {"fee": 1}, - "b": {"ipfs_contents": {"tool": "x"}, "fee": 2}, - "c": {"ipfs_contents": {"prompt": "q"}, "fee": 3}, - }) + stats = trades.get_mech_statistics( + { + "a": {"fee": 1}, + "b": {"ipfs_contents": {"tool": "x"}, "fee": 2}, + "c": {"ipfs_contents": {"prompt": "q"}, "fee": 3}, + } + ) - assert dict(stats) == {} + assert dict(stats) == {} def test_main_execution_path( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - requests_mock, - capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + requests_mock, + capsys: pytest.CaptureFixture[str], ) -> None: - """Run trades.py as script with mocks to cover __main__ path.""" - - import operate.cli as operate_cli - import operate.quickstart.run_service as run_service - import scripts.predict_trader.mech_events as mech_events - import scripts.utils as utils_module - import requests - - class _DummyOperate: - pass - - class _Service: - name = "svc" - - monkeypatch.setattr(operate_cli, "OperateApp", _DummyOperate) - monkeypatch.setattr(run_service, "ask_password_if_needed", lambda *_args, **_kwargs: None) - monkeypatch.setattr(run_service, "load_local_config", lambda *_args, **_kwargs: type("_Cfg", (), {"rpc": {trades.Chain.GNOSIS.value: "http://rpc"}})()) - monkeypatch.setattr(utils_module, "get_service_from_config", lambda *_args, **_kwargs: _Service()) - operate_home = tmp_path / ".operate" - operate_home.mkdir(parents=True) - (operate_home / "subgraph_api_key.txt").write_text("dummy_key", encoding="utf-8") - monkeypatch.setattr(utils_module, "OPERATE_HOME", operate_home) - monkeypatch.setattr(mech_events, "get_mech_requests", lambda *_args, **_kwargs: {}) - - subgraph_url_a = "https://gateway-arbitrum.network.thegraph.com/api/dummy_key/subgraphs/id/9fUVQpFwzpdWS9bq5WkAnmKbNNcoBwatMR4yZq81pbbz" - subgraph_url_b = "https://gateway-arbitrum.network.thegraph.com/api/dummy_key/subgraphs/id/7s9rGBffUTL8kDZuxvvpuc46v44iuDarbrADBFw5uVp2" - requests_mock.post(subgraph_url_a, [{"json": {"data": {"fpmmTrades": []}}}, {"json": {"data": {"fpmmTrades": []}}}]) - requests_mock.post(subgraph_url_b, json={"data": {"user": {}}}) - requests_mock.post("http://rpc", [{"json": {"result": "0xa"}}, {"json": {"result": "0xb"}}]) - - monkeypatch.setattr( - sys, - "argv", - [ - "trades.py", - "--creator", - "0x" + "a" * 40, - ], - ) - - runpy.run_module("scripts.predict_trader.trades", run_name="__main__") - output = capsys.readouterr().out - assert "Summary (per market state)" in output - assert "Safe address:" in output + """Run trades.py as script with mocks to cover __main__ path.""" + + import operate.cli as operate_cli + import operate.quickstart.run_service as run_service + import requests + import scripts.predict_trader.mech_events as mech_events + import scripts.utils as utils_module + + class _DummyOperate: + pass + + class _Service: + name = "svc" + + monkeypatch.setattr(operate_cli, "OperateApp", _DummyOperate) + monkeypatch.setattr( + run_service, "ask_password_if_needed", lambda *_args, **_kwargs: None + ) + monkeypatch.setattr( + run_service, + "load_local_config", + lambda *_args, **_kwargs: type( + "_Cfg", (), {"rpc": {trades.Chain.GNOSIS.value: "http://rpc"}} + )(), + ) + monkeypatch.setattr( + utils_module, "get_service_from_config", lambda *_args, **_kwargs: _Service() + ) + operate_home = tmp_path / ".operate" + operate_home.mkdir(parents=True) + (operate_home / "subgraph_api_key.txt").write_text("dummy_key", encoding="utf-8") + monkeypatch.setattr(utils_module, "OPERATE_HOME", operate_home) + monkeypatch.setattr(mech_events, "get_mech_requests", lambda *_args, **_kwargs: {}) + + subgraph_url_a = "https://gateway-arbitrum.network.thegraph.com/api/dummy_key/subgraphs/id/9fUVQpFwzpdWS9bq5WkAnmKbNNcoBwatMR4yZq81pbbz" + subgraph_url_b = "https://gateway-arbitrum.network.thegraph.com/api/dummy_key/subgraphs/id/7s9rGBffUTL8kDZuxvvpuc46v44iuDarbrADBFw5uVp2" + requests_mock.post( + subgraph_url_a, + [ + {"json": {"data": {"fpmmTrades": []}}}, + {"json": {"data": {"fpmmTrades": []}}}, + ], + ) + requests_mock.post(subgraph_url_b, json={"data": {"user": {}}}) + requests_mock.post( + "http://rpc", [{"json": {"result": "0xa"}}, {"json": {"result": "0xb"}}] + ) + + monkeypatch.setattr( + sys, + "argv", + [ + "trades.py", + "--creator", + "0x" + "a" * 40, + ], + ) + + runpy.run_module("scripts.predict_trader.trades", run_name="__main__") + output = capsys.readouterr().out + assert "Summary (per market state)" in output + assert "Safe address:" in output diff --git a/tests/test_scripts/test_utils.py b/tests/test_scripts/test_utils.py index 6d689322..036f60e8 100644 --- a/tests/test_scripts/test_utils.py +++ b/tests/test_scripts/test_utils.py @@ -5,11 +5,12 @@ from types import SimpleNamespace import pytest - from scripts import utils -def test_get_subgraph_api_key_reads_existing_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_get_subgraph_api_key_reads_existing_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: """Existing subgraph key file should be read without prompting.""" operate_home = tmp_path / ".operate" @@ -22,7 +23,9 @@ def test_get_subgraph_api_key_reads_existing_file(monkeypatch: pytest.MonkeyPatc assert utils.get_subgraph_api_key() == "existing-key" -def test_get_subgraph_api_key_prompts_and_writes(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_get_subgraph_api_key_prompts_and_writes( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: """Missing key file should prompt user, persist key, and return it.""" operate_home = tmp_path / ".operate" @@ -32,7 +35,9 @@ def test_get_subgraph_api_key_prompts_and_writes(monkeypatch: pytest.MonkeyPatch result = utils.get_subgraph_api_key() assert result == "prompted-key" - assert (operate_home / "subgraph_api_key.txt").read_text(encoding="utf-8") == "prompted-key" + assert (operate_home / "subgraph_api_key.txt").read_text( + encoding="utf-8" + ) == "prompted-key" def test_get_service_from_config_missing_file_exits(tmp_path: Path) -> None: @@ -67,9 +72,19 @@ def _make_operate(): configured = {"args": None} monkeypatch.setattr(utils, "OperateApp", _make_operate) - monkeypatch.setattr(utils, "ask_password_if_needed", lambda _op: asked.__setitem__("count", asked["count"] + 1)) - monkeypatch.setattr(utils, "configure_local_config", lambda t, o: configured.__setitem__("args", (t, o))) - monkeypatch.setattr(utils, "get_service", lambda m, t: {"manager": m, "template": t}) + monkeypatch.setattr( + utils, + "ask_password_if_needed", + lambda _op: asked.__setitem__("count", asked["count"] + 1), + ) + monkeypatch.setattr( + utils, + "configure_local_config", + lambda t, o: configured.__setitem__("args", (t, o)), + ) + monkeypatch.setattr( + utils, "get_service", lambda m, t: {"manager": m, "template": t} + ) result = utils.get_service_from_config(config_path) @@ -91,9 +106,17 @@ def test_get_service_from_config_uses_provided_operate( manager = object() operate = SimpleNamespace(service_manager=lambda: manager) - monkeypatch.setattr(utils, "ask_password_if_needed", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not ask"))) + monkeypatch.setattr( + utils, + "ask_password_if_needed", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("should not ask") + ), + ) monkeypatch.setattr(utils, "configure_local_config", lambda *_args, **_kwargs: None) - monkeypatch.setattr(utils, "get_service", lambda m, t: {"manager": m, "template": t}) + monkeypatch.setattr( + utils, "get_service", lambda m, t: {"manager": m, "template": t} + ) result = utils.get_service_from_config(config_path, operate=operate) @@ -131,7 +154,9 @@ def test_handle_missing_rpcs_all_present() -> None: } -def test_handle_missing_rpcs_prompts_until_non_empty(monkeypatch: pytest.MonkeyPatch) -> None: +def test_handle_missing_rpcs_prompts_until_non_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Missing RPCs should be requested and empty input retried.""" config = {"optimism_rpc": "http://optimism"} diff --git a/tests/test_staking_service.py b/tests/test_staking_service.py index 647aacdb..29a77cbd 100644 --- a/tests/test_staking_service.py +++ b/tests/test_staking_service.py @@ -1,76 +1,76 @@ -import re -import sys +import json import logging -import pexpect import os -import json +import re +import sys import time -import pytest from datetime import datetime from pathlib import Path -from typing import List, Dict +from typing import Dict, List + +import pexpect +import pytest import requests -from operate.services.protocol import StakingState from operate.data import DATA_DIR from operate.data.contracts.staking_token.contract import StakingTokenContract from operate.ledger.profiles import STAKING from operate.operate_types import Chain, LedgerType +from operate.services.protocol import StakingState from operate.wallet.master import MasterWalletManager + # Import from existing test script from test_run_service import ( + BaseTestService, + STARTUP_WAIT, TempDirMixin, create_funding_handler, create_token_funding_handler, + get_config_files, get_config_specific_settings, handle_env_var_prompt, send_input_safely, setup_logging, - get_config_files, - BaseTestService, - STARTUP_WAIT, ) pytestmark = pytest.mark.e2e + def get_included_test_configs() -> List[str]: """Get list of configs to test for staking-enabled services.""" included_agents = ["trader"] # List of agents with staking enabled all_configs = get_config_files() return [ - config for config in all_configs + config + for config in all_configs if any(included in config.lower() for included in included_agents) ] class StakingOptionParser: """Parser that selects options based on available slots.""" - + @staticmethod def parse_staking_options(output: str, logger: logging.Logger) -> List[Dict]: """Parse staking options and their available slots from CLI output.""" options = [] logger.info("Starting to parse staking options...") - + # Pattern to match option lines with slots - pattern = r'(\d+)\)\s*(.*?)\s*\(available slots\s*:\s*([∞\d]+)\)' + pattern = r"(\d+)\)\s*(.*?)\s*\(available slots\s*:\s*([∞\d]+)\)" matches = re.finditer(pattern, output) - + for match in matches: number = int(match.group(1)) name = match.group(2).strip() slots_str = match.group(3) - slots = float('inf') if slots_str == '∞' else int(slots_str) + slots = float("inf") if slots_str == "∞" else int(slots_str) if name.startswith("[DEPRECATED]"): continue # Skip deprecated options - - option = { - 'number': number, - 'name': name, - 'slots': slots - } + + option = {"number": number, "name": name, "slots": slots} options.append(option) logger.debug(f"Found option: {option}") - + logger.info(f"Found {len(options)} staking options") return options @@ -80,22 +80,25 @@ def select_staking_option(options: List[Dict], logger: logging.Logger) -> str: if not options: logger.warning("No options parsed, defaulting to option 2") return "2" - + # Filter out option 1 and options with no slots valid_options = [ - opt for opt in options - if opt['number'] != 1 and opt['slots'] > 0 + opt for opt in options if opt["number"] != 1 and opt["slots"] > 0 ] - + if not valid_options: - logger.warning("No valid options with available slots, defaulting to option 2") + logger.warning( + "No valid options with available slots, defaulting to option 2" + ) return "2" - + # Select option with maximum available slots - selected = max(valid_options, key=lambda x: x['slots']) - logger.info(f"Selected option {selected['number']}: {selected['name']} with {selected['slots']} slots") - - return str(selected['number']) + selected = max(valid_options, key=lambda x: x["slots"]) + logger.info( + f"Selected option {selected['number']}: {selected['name']} with {selected['slots']} slots" + ) + + return str(selected["number"]) class StakingBaseTestService(BaseTestService): @@ -106,9 +109,11 @@ def get_staking_config_settings(self) -> dict: settings = get_config_specific_settings(self.config_path) base_choice_key = r"Enter your choice\s*\(\s*\d+\s*-\s*\d+\s*\)\s*:\s*$" settings["prompts"].pop(base_choice_key, None) - settings["prompts"][r"Enter your choice \(1 - \d+\):"] = self.handle_staking_choice + settings["prompts"][ + r"Enter your choice \(1 - \d+\):" + ] = self.handle_staking_choice return settings - + def handle_staking_choice(self, output: str, logger: logging.Logger) -> str: """Handle staking choice based on available slots.""" try: @@ -116,7 +121,9 @@ def handle_staking_choice(self, output: str, logger: logging.Logger) -> str: options = parser.parse_staking_options(output, logger) selected = parser.select_staking_option(options, logger) if selected == "1": - logger.warning("Safety check caught attempt to select option 1, forcing option 2") + logger.warning( + "Safety check caught attempt to select option 1, forcing option 2" + ) return "2" logger.info(f"Final choice: option {selected}") return selected @@ -124,10 +131,10 @@ def handle_staking_choice(self, output: str, logger: logging.Logger) -> str: logger.error(f"Error in staking choice handler: {str(e)}") logger.warning("Falling back to option 2") return "2" - + def verify_staking(self) -> bool: """Verify staking status for the service. - + Returns: bool: True if staking verification passes, False otherwise """ @@ -139,120 +146,129 @@ def verify_staking(self) -> bool: sftxb = manager.get_eth_safe_tx_builder( ledger_config=chain_config.ledger_config ) - return sftxb.staking_status( - service_id=chain_config.chain_data.token, - staking_contract=STAKING[Chain.from_string(service.home_chain)][chain_config.chain_data.user_params.staking_program_id] - ) == StakingState.STAKED + return ( + sftxb.staking_status( + service_id=chain_config.chain_data.token, + staking_contract=STAKING[Chain.from_string(service.home_chain)][ + chain_config.chain_data.user_params.staking_program_id + ], + ) + == StakingState.STAKED + ) except Exception as e: self.logger.error(f"Error in staking verification: {e}") - return False - + return False + @classmethod def setup_class(cls): """Setup staking-specific configuration.""" # Save original start_service and setup flag since super() will set them original_start = cls.start_service cls.start_service = lambda: None - + # Don't let super() set _setup_complete original_setup_complete = cls._setup_complete super().setup_class() cls._setup_complete = original_setup_complete with open(cls.config_path) as f: - cls.config = json.load(f) + cls.config = json.load(f) # Setup .operate directory and wallet operate_dir = Path(cls.temp_dir.name) / ".operate" operate_dir.mkdir(exist_ok=True) keys_dir = operate_dir / "keys" keys_dir.mkdir(exist_ok=True) - + # Initialize wallet manager cls.wallet_manager = MasterWalletManager( path=keys_dir, password="DUMMY_PWD", # Use env var in production ).setup() - + # Create wallet if it doesn't exist if not cls.wallet_manager.exists(LedgerType.ETHEREUM): cls.logger.info("Creating new Ethereum wallet...") cls.wallet_manager.create(LedgerType.ETHEREUM) - + # Initialize staking contract and settings cls.staking_contract = StakingTokenContract.from_dir( directory=str(DATA_DIR / "contracts" / "staking_token") ) cls.config_settings = cls().get_staking_config_settings() cls.logger.info(f"Loaded staking settings for config: {cls.config_path}") - + # Restore and call original start_service cls.start_service = original_start cls.start_service() time.sleep(STARTUP_WAIT) - + # Now set setup complete cls._setup_complete = True - def fast_forward_time(self, seconds: int = 86400*3): + def fast_forward_time(self, seconds: int = 86400 * 3): """ Fast forward blockchain time based on agent's chain configuration. Default is 72 hours (86400*3 seconds). - + Args: seconds (int): Number of seconds to fast forward """ try: self.logger.info(f"Fast forwarding time by {seconds} seconds...") - + # Get chain and RPC from agent's config with open(self.config_path) as f: config = json.load(f) - + # Get chain name from config chain_name = config.get("home_chain", "gnosis").lower() - + # Map chain names to environment variables for RPCs rpc_mapping = { "gnosis": "GNOSIS_RPC", "mode": "MODE_RPC", - "optimism": "OPTIMISM_RPC", - "base": "BASE_RPC" + "optimism": "OPTIMISM_RPC", + "base": "BASE_RPC", } - + env_var = rpc_mapping.get(chain_name) if not env_var: raise ValueError(f"Unsupported chain: {chain_name}") - + rpc_url = os.getenv(env_var) if not rpc_url: raise ValueError(f"{env_var} environment variable not set") - + self.logger.info(f"Using RPC for chain: {chain_name}") - + # Prepare request payload headers = {"Content-Type": "application/json"} payload = { "jsonrpc": "2.0", "method": "evm_increaseTime", "params": [seconds], - "id": 1 + "id": 1, } - + # Make request to increase time response = requests.post(rpc_url, headers=headers, json=payload) - + if response.status_code != 200: - raise Exception(f"RPC request failed with status {response.status_code}") - + raise Exception( + f"RPC request failed with status {response.status_code}" + ) + result = response.json() - if 'error' in result: + if "error" in result: raise Exception(f"RPC error: {result['error']}") - - self.logger.info(f"Successfully fast forwarded time by {seconds} seconds on {chain_name} chain") + + self.logger.info( + f"Successfully fast forwarded time by {seconds} seconds on {chain_name} chain" + ) return response.status_code == 200 - + except Exception as e: self.logger.error(f"Error fast forwarding time: {str(e)}") - raise + raise def run_termination_script(self): """ @@ -260,83 +276,95 @@ def run_termination_script(self): """ try: self.logger.info("Running termination script...") - + # Get chain and RPC from config with open(self.config_path) as f: config = json.load(f) # Get chain name from config chain_name = config.get("home_chain", "gnosis").lower() - + # Map chain names to environment variables for RPCs rpc_mapping = { "gnosis": "GNOSIS_RPC", "mode": "MODE_RPC", - "optimism": "OPTIMISM_RPC", - "base": "BASE_RPC" + "optimism": "OPTIMISM_RPC", + "base": "BASE_RPC", } - + env_var = rpc_mapping.get(chain_name) if not env_var: raise ValueError(f"Unsupported chain: {chain_name}") - + rpc_url = os.getenv(env_var) if not rpc_url: raise ValueError(f"{env_var} environment variable not set") - + self.logger.info(f"Using RPC for chain: {chain_name}") - + # Define expected prompts and responses prompts = { r"\(yes/no\):": "yes", - r"Enter local user account password \[hidden input\]:": os.getenv('TEST_PASSWORD', 'test_secret'), - r"\[(?:gnosis|optimism|base|mode)\].*Please make sure Master (EOA|Safe) .*has at least.*(?:ETH|xDAI)": - lambda output, logger: create_funding_handler(rpc_url)(output, logger), - r"\[(?:gnosis|optimism|base|mode)\].*Please make sure Master (?:EOA|Safe) .*has at least.*(?:USDC|OLAS)": - lambda output, logger: create_token_funding_handler(rpc_url)(output, logger), - r"Please enter .*": lambda output, logger: handle_env_var_prompt(output, logger, self.config_path) + r"Enter local user account password \[hidden input\]:": os.getenv( + "TEST_PASSWORD", "test_secret" + ), + r"\[(?:gnosis|optimism|base|mode)\].*Please make sure Master (EOA|Safe) .*has at least.*(?:ETH|xDAI)": lambda output, logger: create_funding_handler( + rpc_url + )( + output, logger + ), + r"\[(?:gnosis|optimism|base|mode)\].*Please make sure Master (?:EOA|Safe) .*has at least.*(?:USDC|OLAS)": lambda output, logger: create_token_funding_handler( + rpc_url + )( + output, logger + ), + r"Please enter .*": lambda output, logger: handle_env_var_prompt( + output, logger, self.config_path + ), } - + # Initialize success flag termination_success = False - + # Run termination script with output logging process = pexpect.spawn( - f'bash ./terminate_on_chain_service.sh {self.config_path}', - encoding='utf-8', + f"bash ./terminate_on_chain_service.sh {self.config_path}", + encoding="utf-8", timeout=300, cwd=self.temp_dir.name, - logfile=sys.stdout + logfile=sys.stdout, ) - + # Compile success pattern once - success_pattern = re.compile(r"Service (\d+) is now terminated and unbonded.*") - + success_pattern = re.compile( + r"Service (\d+) is now terminated and unbonded.*" + ) + # Handle interactive prompts while True: try: patterns = list(prompts.keys()) patterns.append(pexpect.EOF) - + index = process.expect(patterns) - + # Safely handle process output before_output = process.before if process.before else "" after_output = process.after if process.after else "" current_output = str(before_output) + str(after_output) - + # Check for success message in current output if success_pattern.search(current_output): self.logger.info("Found termination success message!") termination_success = True - + # If EOF reached, break if index == len(patterns) - 1: break - + # Get matching prompt and send response matched_prompt = patterns[index] response = prompts[matched_prompt] - + # Handle callable responses (for funding handlers) if callable(response): response = response(current_output, self.logger) @@ -346,20 +374,20 @@ def run_termination_script(self): except pexpect.TIMEOUT: self.logger.error("Timeout waiting for prompt") return False - + # Final check for success in any remaining output final_output = str(process.before) if process.before else "" if success_pattern.search(final_output): self.logger.info("Found termination success message in final output!") termination_success = True - + if termination_success: self.logger.info("Termination confirmed as successful") return True - + self.logger.error("Termination success message not found in output") return False - + except Exception as e: self.logger.error(f"Error running termination script: {str(e)}") raise @@ -367,16 +395,18 @@ def run_termination_script(self): class TestAgentStaking(TempDirMixin): """Test class for staking-specific tests.""" - - logger = setup_logging(Path(f'test_staking_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log')) + + logger = setup_logging( + Path(f'test_staking_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log') + ) get_test_class = lambda _, config_path, temp_dir: type( - f'TestStakingService_{Path(config_path).stem}', + f"TestStakingService_{Path(config_path).stem}", (StakingBaseTestService,), # Use our staking-specific base - {'config_path': config_path, 'temp_dir': temp_dir} + {"config_path": config_path, "temp_dir": temp_dir}, ) @pytest.mark.parametrize( - 'setup', + "setup", get_included_test_configs(), indirect=True, ) @@ -388,7 +418,9 @@ def test_agent_staking(self, setup): test_instance.test_health_check() # Verify staking status after service is up - assert test_instance.verify_staking(), "Staking verification failed after service running" + assert ( + test_instance.verify_staking() + ), "Staking verification failed after service running" # Stop the service and verify it's stopped test_instance.test_shutdown_logs(), "Service stoppage failed" @@ -400,7 +432,10 @@ def test_agent_staking(self, setup): assert test_instance.run_termination_script(), "Service termination failed" # Verify staking status after service is terminated and unstaked - assert not test_instance.verify_staking(), "Staking verification failed after termination" - + assert ( + not test_instance.verify_staking() + ), "Staking verification failed after termination" + + if __name__ == "__main__": pytest.main(["-v", __file__, "-s", "--log-cli-level=INFO"]) diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..858da92b --- /dev/null +++ b/tox.ini @@ -0,0 +1,91 @@ +; Local extensions to tomte's canonical tox.ini. Consumed by `tomte tox`. + +[tomte-extensions] +; Disables (in order): +; C0114 missing-module-docstring — flake8 covers this; avoid duplicate reports +; C0115 missing-class-docstring — same +; C0116 missing-function-docstring — same +; R0801 duplicate-code — false positives across migration scripts +; C0415 import-outside-toplevel — used for lazy imports in legacy scripts +; W0621 redefined-outer-name — module-level helpers reused inside __main__ +; C0411 wrong-import-order — isort + pylint disagree on script-local paths +; W0212 protected-access — intentional use of middleware internals +; W0718 broad-exception-caught — migration scripts intentionally catch broadly +; R1723 no-else-break — readability is fine as written +; R0903 too-few-public-methods — dataclasses with no methods +; R0914 too-many-locals — refactoring legacy scripts is out of scope +; R0915 too-many-statements — same +extra_pylint_disables = C0114,C0115,C0116,R0801,C0415,W0621,C0411,W0212,W0718,R1723,R0903,R0914,R0915 + +[pytest] +tomte_defaults = true +addopts = -p no:pytest_anchorpy + +[Authorized Packages] +; Per-repo overrides for liccheck: each entry maps a package whose +; license metadata liccheck couldn't classify (UNKNOWN, or GPL-dual) +; to a version range we accept anyway. Mirrors trader's pattern. +anchorpy: 0.20.2 +based58: 0.1.1 +clea: <1 +httpx: <2 +jsonalias: 0.1.1 +olas-operate-middleware: ==0.15.21 +open-autonomy: ==0.21.22 +pyinstaller: 6.20.0 +pyinstaller-hooks-contrib: <2027 + +; Per-repo mypy overlays. Concat-appended onto tomte's canonical +; `mypy.ini` at runtime via `tomte render-mypy-config --append-from=tox.ini`. +; Each block silences "no stubs / no implementation" errors for a library +; quickstart imports but tomte's lint env doesn't install. + +; Heavy `operate.*` consumers — tomte's [testenv:mypy] doesn't install +; olas-operate-middleware, so mypy can't resolve `operate.constants`, +; `operate.cli`, etc. as namespace-package imports. Skip type-checking +; these modules entirely (same pattern trader uses for +; `packages.valory.connections.*` / `packages.valory.contracts.*`). +[mypy-scripts.pearl_migration.*] +ignore_errors = True + +[mypy-scripts.predict_trader.*] +ignore_errors = True + +[mypy-scripts.optimus.*] +ignore_errors = True + +[mypy-scripts.utils] +ignore_errors = True + +[mypy-operate.*] +ignore_missing_imports = True + +[mypy-autonomy.*] +ignore_missing_imports = True + +[mypy-aea_ledger_ethereum.*] +ignore_missing_imports = True + +[mypy-aea.*] +ignore_missing_imports = True + +[mypy-halo.*] +ignore_missing_imports = True + +[mypy-dotenv.*] +ignore_missing_imports = True + +[mypy-psutil.*] +ignore_missing_imports = True + +[mypy-requests.*] +ignore_missing_imports = True + +[mypy-gql.*] +ignore_missing_imports = True + +[mypy-docker.*] +ignore_missing_imports = True + +[mypy-web3.*] +ignore_missing_imports = True From e92fc6ab5c3587dee968168c7d9856979afda9d3 Mon Sep 17 00:00:00 2001 From: DivyaNautiyal07 Date: Fri, 22 May 2026 14:13:02 +0530 Subject: [PATCH 06/17] chore: drop the Makefile It was three poetry-wrapper targets, none referenced from the README, CI workflow, or any operator shell script. The 9 .sh scripts at repo root already cover install + run. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 Makefile diff --git a/Makefile b/Makefile deleted file mode 100644 index 63a3d98a..00000000 --- a/Makefile +++ /dev/null @@ -1,16 +0,0 @@ -.PHONY: install test-install test run_no_staking_tests - -# Install project dependencies for produ -install: - poetry install --only main - -# Install project dependencies for testing -test-install: - poetry install - -# Run tests without staking functionality -run_no_staking_tests: - poetry run pytest -v tests/test_run_service.py -s --log-cli-level=INFO - -# Run all commands in sequence -test: test-install run_no_staking_tests \ No newline at end of file From 702e050395cedea00f5060e2fe3dc9f797811587 Mon Sep 17 00:00:00 2001 From: DivyaNautiyal07 Date: Fri, 22 May 2026 14:13:11 +0530 Subject: [PATCH 07/17] chore(ci): add linter_checks job and gate it through all-checks-passed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quickstart's CI had no linting at all. Adds a `linter_checks` job that installs tomte + tox-uv and runs the canonical 9 lint envs in parallel (-p): black-check, isort-check, flake8, mypy, pylint, darglint, safety, bandit; plus liccheck as its own step. Action versions pinned to checkout@v4 + setup-python@v5 to match the rest of the workflow (no version skew within one file). Appends `- linter_checks` to the `all-checks-passed.needs` list so branch protection actually gates on lint — without it the new job would be advisory only. Also extends .gitignore for `.tox/`, `*.egg-info/`, and `.tomte-*` so tomte's runtime artefacts don't leak into commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/python-tests.yml | 18 ++++++++++++++++++ .gitignore | 8 +++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 6d10d669..5b6f73ad 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -321,6 +321,23 @@ jobs: name: unit-test-coverage-xml-${{ matrix.python-version }} path: coverage-unit.xml + linter_checks: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install dependencies + run: pip install 'tomte[cli, tests]==0.7.0' tox-uv + - name: Code checks + run: tomte tox -p -e black-check -e isort-check -e flake8 -e mypy -e pylint -e darglint + - name: Security checks + run: tomte tox -p -e safety -e bandit + - name: License compatibility check + run: tomte tox -e liccheck + all-checks-passed: # Single aggregate gate referenced by branch protection. When adding or # removing a mandatory job, update the `needs:` list here instead of @@ -335,6 +352,7 @@ jobs: - changes - e2e-test-migrate-to-pearl - unit-tests + - linter_checks steps: - name: Verify all dependencies succeeded or were skipped run: | diff --git a/.gitignore b/.gitignore index a0ca3675..44b1edc1 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,10 @@ logs .idea/ .coverage coverage-*.xml -htmlcov/ \ No newline at end of file +htmlcov/ + +# tox / setuptools artefacts (built by `tomte tox` testenvs that +# run an editable install of the project) +.tox/ +*.egg-info/ +.tomte-* \ No newline at end of file From 71ea9f313b96d69ea5e517ab78fa16f4729580b5 Mon Sep 17 00:00:00 2001 From: DivyaNautiyal07 Date: Fri, 22 May 2026 14:13:21 +0530 Subject: [PATCH 08/17] docs: slim CONTRIBUTING.md and fix stale poetry refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRIBUTING.md trim: The generic "open an issue, branch, commit, push, review" workflow section (~60 lines) is near-identical to the equivalent block in every other Valory repo. Replaced with a short stub linking to the canonical open-autonomy/CONTRIBUTING.md, plus repo-specific notes on where to file contributions (configs/, scripts/, tests/) and the exact local commands for `tomte tox` lint envs and unit tests. The config.json schema reference stays in place per reviewer feedback on PR #175 — it's quickstart-specific and useful where it is. Doc cleanup for the uv migration: PR #172 swapped the root .sh scripts from poetry to uv but missed the same commands inside two sub-READMEs: - scripts/predict_trader/README.md lines 16, 22, 28, 197, 198 - scripts/optimus/README.md lines 18, 19 All 7 instances rewritten from `poetry install` / `poetry run` to the equivalent `uv sync` / `uv run` form. Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 78 +++++++++++++------------------------------------ 1 file changed, 20 insertions(+), 58 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d290dac3..a35d508f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,63 +1,25 @@ # Contributing to OLAS AI Agents Quickstart -## How to Contribute - -### Before You Start - -1. **Open an Issue First**: Before starting work on a feature, bug fix, or significant change, please [open an issue](https://github.com/valory-xyz/quickstart/issues) to discuss your idea with the maintainers. This helps ensure your contribution aligns with the project's direction and avoids duplicate work. - -2. **Check Existing Issues**: Review open and closed issues to see if your concern has already been discussed. - -### Contribution Areas - -We welcome contributions in the following areas: - -- **`configs/`**: Agent configurations and setup files -- **`scripts/`**: Agent-specific scripts and utilities -- **`tests/`**: Test cases and testing improvements - -### Git Workflow - -We follow a standard git workflow: - -1. **Create a Branch**: Create a feature branch from `main` - ```bash - git checkout -b feature/your-feature-name - ``` - -2. **Make Your Changes**: Implement your changes following the guidelines below - -3. **Commit Your Work**: Make clear, descriptive commits - ```bash - git commit -m "Brief description of your changes" - ``` - -4. **Push and Create a PR**: Push your branch and create a pull request - ```bash - git push origin feature/your-feature-name - ``` - -5. **Code Review**: Address any feedback from reviewers - -6. **Merge**: Once approved, your PR will be merged into `main` - -### Code Quality - -- **CI Checks**: All code changes are validated through CI checks. Ensure your changes pass all automated checks (linting, testing, etc.) -- **Local Testing**: While not required, testing your changes locally is recommended -- **Documentation**: Include or update documentation as needed for new features or changes - -### Commit Guidelines - -- Use clear, descriptive commit messages -- Reference related issues when applicable: `Fixes #123` or `Related to #123` -- Keep commits focused on a single logical change - -## Questions? - -If you have questions or need clarification: -- Check existing [issues](https://github.com/valory-xyz/quickstart/issues) and [PRs](https://github.com/valory-xyz/quickstart/pulls) -- Open a new issue with your question +This repository follows the Valory Open-Autonomy contribution workflow. + +See the canonical guide for the PR checklist, pre-commit routine, +coding style, and linter / test commands: +**[open-autonomy/CONTRIBUTING.md](https://github.com/valory-xyz/open-autonomy/blob/main/CONTRIBUTING.md)** + +Repo-specific notes for this project: + +- **Scope**: contributions are welcome in `configs/` (agent configs), + `scripts/` (agent-specific scripts), and `tests/`. +- **Open an issue first** for any non-trivial change so it can be + discussed before you spend time on it. +- **Run lint locally** with `uv run tomte tox -p -e black-check -e + isort-check -e flake8 -e mypy -e pylint -e darglint -e safety -e + bandit -e liccheck`. CI runs the same set. +- **Run unit tests locally** with `PYTHONPATH=. uv run pytest tests + --ignore=tests/test_run_service.py + --ignore=tests/test_staking_service.py + --ignore=tests/test_migrate_to_pearl.py`. The ignored files are + e2e tests that need Docker and live RPC secrets. ## Guide for the AI agent `config.json` From 8eca9cc773bffdb00f4ff1caba9af73682f35814 Mon Sep 17 00:00:00 2001 From: DivyaNautiyal07 Date: Fri, 22 May 2026 14:13:36 +0530 Subject: [PATCH 09/17] chore: drop CLEANUP_PLAN.md now that the plan is implemented Per the original PR description, this planning doc is deleted in the final implementation commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLEANUP_PLAN.md | 277 ------------------------------------------------ 1 file changed, 277 deletions(-) delete mode 100644 CLEANUP_PLAN.md diff --git a/CLEANUP_PLAN.md b/CLEANUP_PLAN.md deleted file mode 100644 index ccbf3679..00000000 --- a/CLEANUP_PLAN.md +++ /dev/null @@ -1,277 +0,0 @@ -# Cleanup plan for quickstart - -This is a planning-only PR. Nothing in this commit changes runtime -behaviour. Once the plan is approved, implementation commits will be -pushed on top of this same branch. There will be no separate PRs per -phase. This `CLEANUP_PLAN.md` will be deleted in the final -implementation commit. - -The goal is to bring quickstart in line with where `trader` sits today: -on the latest `tomte` for linting, with a minimal `tox.ini`, and a -slim CI workflow that delegates to `tomte tox` for lint envs. - -## What's already done (not in scope) - -PR #172 (chore/uv-migration) already landed most of the "wave 2" -work that applies here: - -- `pyproject.toml` is on PEP 621 with `tool.uv.package = false`. -- `poetry.lock` removed, `uv.lock` present. -- 9 operator shell scripts (`run_service.sh`, `stop_service.sh`, - `analyse_logs.sh`, `claim_staking_rewards.sh`, `reset_configs.sh`, - `reset_password.sh`, `reset_staking.sh`, - `terminate_on_chain_service.sh`, `migrate_to_pearl.sh`) use - `uv sync` and `uv run` instead of `poetry install` and `poetry - run`. `extract_private_keys.sh` is pure bash and intentionally - not touched (it doesn't invoke a Python interpreter). - -Nothing in this PR touches the shell-script bodies. But PR #172 did -leave 7 stale `poetry` invocations behind in `scripts/*/README.md` -that this PR cleans up — see in-scope section 6. - -## What's in scope - -### 1. Add tomte and a `[tool.tomte]` block to `pyproject.toml` - -Tomte is not in `[dependency-groups].dev` today. Add it pinned to -v0.7.0 (also the version trader is on). v0.7.0 is published on PyPI, -so a regular `==` pin is fine. No need for the git URL form: - -```toml -[dependency-groups] -dev = [ - "pexpect==4.9.0", - "pytest==9.0.3", - "pytest-cov==7.1.0", - "tomte[cli, tests]==0.7.0", - "tox-uv==1.16.0", ; pin set empirically at implementation time -] - -[tool.tomte] -tomte_dep_pin = "==0.7.0" -# extra excludes / pylint disables added at implementation time -# based on actual lint output against current scripts/ and tests/. -``` - -Note on tomte extras. The `[cli]` extra already pulls `tox` (checked -tomte v0.7.0 `pyproject.toml`), so `tomte[cli, tests]==0.7.0` is -enough for `tomte tox` to find the `tox` binary. The asymmetry vs -trader's CI install line (`pip install 'tomte[tox,cli]==0.7.0' -tox-uv`) is `tox-uv`, which isn't in any tomte extra and which the -`tomte tox` wrapper expects when uv-managed envs are used. Adding -`tox-uv` to the dev group keeps local `uv run tomte tox -e ` -working out of the box. - -The `check_dependencies_extra_excludes` and pylint disables will be -set empirically. Quickstart has only `scripts/` and `tests/` to lint, -so the list should stay short. - -### 2. Add a minimal `tox.ini` - -Quickstart has no `tox.ini` today. Add one that follows the trader -shape: a thin `[tomte-extensions]` block, a `[Licenses]` block for -`liccheck`, and any per-repo overrides. Drops AEA-only envs -(`check-hash`, `check-packages`, `check-abciapp-specs`, -`check-handlers`, `check-third-party-hashes`) because quickstart has -no `packages/` directory. `liccheck` stays in scope (it reads -`[Licenses]` and `[Authorized Packages]`, not AEA state). - -Draft: - -```ini -; Local extensions to tomte's canonical tox.ini. Consumed by `tomte tox`. - -[tomte-extensions] -extra_pylint_disables = C0114,C0115,C0116,R0801 - -[pytest] -tomte_defaults = true -addopts = -p no:pytest_anchorpy - -[Licenses] -authorized_licenses = - bsd - new bsd - bsd license - apache - apache 2.0 - apache software - mit - mit license - python software foundation license -unauthorized_licenses = - gpl v3 - -[Authorized Packages] -; per-repo allowlist filled at implementation time, mirroring trader's pattern -``` - -The `-p no:pytest_anchorpy` line stays because the same anchorpy -issue that `pyproject.toml` already addresses still applies under -`tomte tox`. If lint surfaces missing imports, add minimal `[mypy-*]` -blocks at implementation time. - -### 3. Delete the Makefile - -The Makefile is the one file PR #172 didn't update. It still uses -poetry: - -```make -install: - poetry install --only main - -test-install: - poetry install - -run_no_staking_tests: - poetry run pytest -v tests/test_run_service.py -s --log-cli-level=INFO - -test: test-install run_no_staking_tests -``` - -Confirmed: no references from README, CI, or any `.sh` script. The 9 -operator shell scripts already cover install + run. Per reviewer -feedback, the Makefile gets deleted outright at implementation time. - -### 4. Wire CI to run `tomte tox` for lint envs - -Current `.github/workflows/python-tests.yml` is 344 lines. It runs -`uv sync` and `uv run pytest` for three e2e jobs plus a unit test -matrix. It does no linting. No `black`, `isort`, `flake8`, `mypy`, -`pylint`, `darglint`, `bandit`, `safety`. - -Add a `linter_checks` job that mirrors trader's `common_checks.yaml`. -Pin action versions to match the rest of the workflow (`checkout@v4`, -`setup-python@v5`): - -```yaml -linter_checks: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - name: Install dependencies - run: pip install 'tomte[cli, tests]==0.7.0' tox-uv - - name: Code checks - run: tomte tox -p -e black-check -e isort-check -e flake8 -e mypy -e pylint -e darglint - - name: Security checks - run: tomte tox -p -e safety -e bandit - - name: License compatibility check - run: tomte tox -e liccheck -``` - -Also append `- linter_checks` to the `needs:` list of the -`all-checks-passed` aggregate gate at the bottom of the workflow -(currently line 324, needs list at lines 331-337). Branch protection -references `all-checks-passed`, so without this addition the new job -runs but is advisory only. - -The six existing jobs (`setup`, `e2e-test-run-service`, -`e2e-test-staking`, `changes`, `e2e-test-migrate-to-pearl`, -`unit-tests`) stay as they are. They already use uv correctly. The -`all-checks-passed` aggregate gate gets one new line in its `needs:` -list. - -Net delta: roughly +22 lines (the new `linter_checks` job plus the -one-line update to `all-checks-passed.needs`). Nothing gets removed -from the workflow. - -### 5. CONTRIBUTING.md slim down - -Current `CONTRIBUTING.md` is 135 lines with two distinct sections: - -- A generic contribution workflow (lines 1 to 60ish). This is - near-identical to the equivalent section in every other Valory - repo and is exactly the kind of "useless doc" the wave-2 cleanup - pattern replaces with a stub linking to - `open-autonomy/CONTRIBUTING.md`. -- A `config.json` schema reference (lines 60ish to 135). This is - quickstart-specific and useful. - -Plan (per reviewer): - -- Replace the workflow section with a ~10-line stub linking to the - canonical `open-autonomy/CONTRIBUTING.md`. -- Keep the `config.json` schema reference where it is, in - `CONTRIBUTING.md`. No move. - -After the trim, `CONTRIBUTING.md` ends up at the stub plus the -existing schema section (~85 lines, down from 135). - -### 6. Replace stale `poetry` references in `scripts/*/README.md` - -PR #172 updated the eight (now nine, counting `migrate_to_pearl.sh`) -root shell scripts to use `uv` but missed seven stale `poetry` -invocations in two sub-READMEs: - -- `scripts/predict_trader/README.md` lines 16, 22, 28: three - `poetry run python -m scripts.predict_trader. ...` - copy-paste commands. -- `scripts/predict_trader/README.md` lines 197 and 198: a - `poetry install` + `poetry run python -m - scripts.predict_trader.migrate_legacy_quickstart` pair. -- `scripts/optimus/README.md` lines 18 and 19: the same - `poetry install` + `poetry run python -m - scripts.optimus.migrate_legacy_optimus` pair. - -All seven get rewritten to the equivalent `uv sync --no-default-groups ---inexact --frozen` (or just `uv run`) command at implementation -time. Pure doc fix, no behaviour change. - -## What's explicitly out of scope - -- **`pyproject.toml` and lockfile work**. Done in PR #172. -- **Shell scripts**. Done in PR #172. -- **Removing migration scripts** - (`scripts/predict_trader/migrate_legacy_quickstart.py`, - `scripts/optimus/migrate_legacy_optimus.py`). Both are still - referenced from `scripts/*/README.md` and from - `scripts/pearl_migration/prompts.py`. They're load-bearing. -- **`.gitleaks.toml` stub**. Quickstart runs no gitleaks scan today. - Adding one is a separate security task. -- **README rewrite**. Only one section gets added (the config schema - moved from CONTRIBUTING.md). The rest stays as-is. -- **Removing any `.sh` script**. All 10 are referenced from README - or are operator entry points (the 9 `uv`-using scripts plus the - pure-bash `extract_private_keys.sh`). -- **Touching `scripts/pearl_migration/`**. It's recently added and - actively used. - -## Reviewer decisions resolved - -- Tomte pin: PyPI `==0.7.0` form, not the git URL. -- `liccheck`: kept in scope. -- Makefile: deleted outright. -- Config schema doc: stays in `CONTRIBUTING.md` (no move). - -## Sequencing once approved - -Implementation lands as commits on this same branch in this order: - -1. Add tomte and `tox-uv` to dev deps and `[tool.tomte]` to - `pyproject.toml`. Run `uv lock` to refresh `uv.lock`. -2. Add `tox.ini` (with `[tomte-extensions]`, `[pytest]`, `[Licenses]`, - `[Authorized Packages]` blocks). Run - `tomte tox -p -e black-check -e isort-check -e flake8 -e mypy -e - pylint -e darglint -e bandit -e safety -e liccheck` locally. Fix - lint output until every env is green. -3. Delete the Makefile. -4. Add the `linter_checks` CI job (including the `liccheck` step) - and append `- linter_checks` to `all-checks-passed.needs`. -5. Slim `CONTRIBUTING.md` (workflow section becomes a stub, schema - stays). -6. Replace the 7 stale `poetry` invocations in - `scripts/predict_trader/README.md` and - `scripts/optimus/README.md` with their `uv` equivalents. -7. Delete this `CLEANUP_PLAN.md`. - -Sequencing constraint: commit 4 (CI gate) must only be pushed after -commit 2 has every lint env green locally. Otherwise the branch -lands red on its own newly-added gate. - -Expected total diff (commits 1 to 7): ~200 lines added (`tox.ini`, -`[tool.tomte]` block, CI job, doc rewrites), ~80 lines removed -(`CONTRIBUTING.md` workflow stub, `Makefile` deletion, plus the -mechanical `uv.lock` delta). From 3783f4c8c0f520c765d7d77d6199ba33f7298d39 Mon Sep 17 00:00:00 2001 From: DivyaNautiyal07 Date: Fri, 22 May 2026 15:12:25 +0530 Subject: [PATCH 10/17] chore: address PR #175 self-review findings P1 (test coverage): - test_rank_traders: replaced `lambda **_kwargs` with a positional fake that requires `operate` + `service_name`. Reverting the production code to a zero-arg call now raises TypeError and the test fails. - test_mech_events: dropped the `timeout: int = 30` default on both `_fake_get` mocks. Production must pass `timeout=` or it's a TypeError. P2 (latent bugs + tox.ini cleanup): - trades.py: `_query_omen_xdai_subgraph` and `_query_conditional_tokens_gc_subgraph` now wrap their `requests.post(..., timeout=30)` in `except RequestException` and re-raise as a `RuntimeError` with a clearer message. Previously the lint-driven timeout could surface as an opaque `requests.Timeout` traceback to the report script after 30s. - tox.ini: collapsed four [mypy-scripts.*] blocks into one [mypy-scripts.*], and removed ten dead [mypy-*] ignore_missing_imports blocks (operate, autonomy, aea_*, halo, dotenv, psutil, requests, gql, docker, web3). The collapse keeps mypy green on all 19 source files; the import-ignore blocks were dead because the ignore_errors carve-out already skips every module under scripts/. - migrate_legacy_quickstart.py: tightened the pylint disable comment. Fixed the inaccurate "metaclass" claim to point at eth-account's @combomethod descriptor. - utils.py: rewrote the wrong `get_service_from_config` docstring (it said "Get service safe" but returns a Service object) and the trivial `get_subgraph_api_key` docstring. - prompts.py: rewrote info/warn/backup_suffix docstrings so they add information beyond restating the function name. - migrate_to_pearl.py: reworded the `_ensure_signable` docstring so it doesn't claim a present-tense loop-var hazard. The default-arg binding is now framed as defensive against future refactors that defer the call (matching the outer inline comment). - rank_traders.py: pulled the hardcoded "Trader Agent" string into a module constant `PREDICT_TRADER_SERVICE_NAME` so a rename of the JSON-config name has exactly one place to update. CI: - .github/workflows/python-tests.yml: pinned `tox-uv==1.16.0` in the linter_checks install command (matches the dev-deps pin). All 9 lint envs green + 370/370 unit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/python-tests.yml | 2 +- scripts/pearl_migration/migrate_to_pearl.py | 9 ++- scripts/pearl_migration/prompts.py | 8 +-- .../migrate_legacy_quickstart.py | 7 +- scripts/predict_trader/rank_traders.py | 12 +++- scripts/predict_trader/trades.py | 12 +++- scripts/utils.py | 4 +- .../test_predict_trader/test_mech_events.py | 10 ++- .../test_predict_trader/test_rank_traders.py | 9 ++- tox.ini | 69 +++++-------------- 10 files changed, 70 insertions(+), 72 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 5b6f73ad..fc2ca1d1 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -330,7 +330,7 @@ jobs: with: python-version: "3.10" - name: Install dependencies - run: pip install 'tomte[cli, tests]==0.7.0' tox-uv + run: pip install 'tomte[cli, tests]==0.7.0' 'tox-uv==1.16.0' - name: Code checks run: tomte tox -p -e black-check -e isort-check -e flake8 -e mypy -e pylint -e darglint - name: Security checks diff --git a/scripts/pearl_migration/migrate_to_pearl.py b/scripts/pearl_migration/migrate_to_pearl.py index e611fa02..48bf2e36 100644 --- a/scripts/pearl_migration/migrate_to_pearl.py +++ b/scripts/pearl_migration/migrate_to_pearl.py @@ -1336,9 +1336,12 @@ def _ensure_signable( Called lazily, only inside branches that actually sign. Resumed runs that find every step already done skip this entirely. - Loop-variables (`qs_master_safe`, `chain_str`) are bound via - default args so the closure captures the current iteration's - values, not whatever the loop ends on. + `_qs_safe` and `_chain` use default-arg binding to capture the + loop-iteration's `qs_master_safe` / `chain_str` at definition + time. The closure is invoked synchronously within the same + iteration today, so the binding is defensive: it survives a + future refactor that defers the call to after the loop ends. + See B023 in the bugbear ruleset for the underlying pitfall. """ try: t = safe_threshold(ledger_api=_ledger_api(), safe=_qs_safe) diff --git a/scripts/pearl_migration/prompts.py b/scripts/pearl_migration/prompts.py index 4a91269c..481cbff5 100644 --- a/scripts/pearl_migration/prompts.py +++ b/scripts/pearl_migration/prompts.py @@ -126,22 +126,22 @@ def collision(target: Path, kind: str) -> CollisionChoice: def backup_suffix() -> str: - """Timestamp suffix used for `.bak.` paths.""" + """Return `bak.` so two concurrent backups can't collide.""" return f"bak.{int(time.time())}" def info(msg: str) -> None: - """Print an informational line to stdout, prefixed with a bullet marker.""" + """Print `msg` as an info line to stdout.""" print(f" - {msg}") def warn(msg: str) -> None: - """Print a warning line to stdout, prefixed with `!`.""" + """Print `msg` as a warning line to stdout.""" print(f" ! {msg}") def fatal(msg: str, code: int = 1) -> None: - """Print a fatal-error line to stderr and exit with `code` (default 1).""" + """Print `msg` to stderr and exit with `code` (default 1).""" print(f" X {msg}", file=sys.stderr) sys.exit(code) diff --git a/scripts/predict_trader/migrate_legacy_quickstart.py b/scripts/predict_trader/migrate_legacy_quickstart.py index 633252f5..f727b759 100644 --- a/scripts/predict_trader/migrate_legacy_quickstart.py +++ b/scripts/predict_trader/migrate_legacy_quickstart.py @@ -84,7 +84,12 @@ def decrypt_private_keys(eoa: Path, password: str) -> dict[str, str]: """Decrypt the EOA file (with or without password) and return its key payload.""" if not password: private_key = "0x" + eoa.read_text() - # pylint: disable=no-value-for-parameter # eth_account.Account uses a metaclass; pylint can't see the classmethod + # `Account.from_key` is bound via eth-account's `@combomethod` descriptor; + # pylint sees the bound `self` slot and thinks `private_key` is missing. + # Block-style disable + enable scopes the suppression to the one call + # (a trailing same-line suppression lands on the wrapped closing paren + # after black reformats and stops applying to the call itself). + # pylint: disable=no-value-for-parameter account: LocalAccount = Account.from_key(private_key) # pylint: enable=no-value-for-parameter address = account.address diff --git a/scripts/predict_trader/rank_traders.py b/scripts/predict_trader/rank_traders.py index fd5f36ca..fa755bb9 100644 --- a/scripts/predict_trader/rank_traders.py +++ b/scripts/predict_trader/rank_traders.py @@ -45,6 +45,11 @@ DEFAULT_FROM_DATE = "2024-12-01T00:00:00" DEFAULT_TO_DATE = "2038-01-19T03:14:07" +# Matches the `name` field in `configs/config_predict_trader.json`. If +# that JSON is renamed, update this constant too — `load_local_config` +# looks the service up by exact name. +PREDICT_TRADER_SERVICE_NAME = "Trader Agent" + headers = { "Accept": "application/json, multipart/mixed", @@ -317,9 +322,10 @@ def _print_progress_bar( # pylint: disable=too-many-arguments user_args = _parse_args() # `load_local_config` requires an OperateApp + service name since - # olas-operate-middleware 0.15.x. `Trader Agent` matches the - # display name written by the predict-trader quickstart. - config = load_local_config(operate=OperateApp(), service_name="Trader Agent") + # olas-operate-middleware 0.15.x. + config = load_local_config( + operate=OperateApp(), service_name=PREDICT_TRADER_SERVICE_NAME + ) rpc = config.rpc[Chain.GNOSIS.value] print("Querying Thegraph...") diff --git a/scripts/predict_trader/trades.py b/scripts/predict_trader/trades.py index 9cc7829e..2cc64eca 100644 --- a/scripts/predict_trader/trades.py +++ b/scripts/predict_trader/trades.py @@ -354,7 +354,10 @@ def _query_omen_xdai_subgraph( # pylint: disable=too-many-locals creationTimestamp_gt=creationTimestamp_gt, ) content_json = _to_content(query) - res = requests.post(url, headers=headers, json=content_json, timeout=30) + try: + res = requests.post(url, headers=headers, json=content_json, timeout=30) + except requests.RequestException as exc: + raise RuntimeError(f"omen subgraph query failed: {exc}") from exc result_json = res.json() trades = result_json.get("data", {}).get("fpmmTrades", []) @@ -394,7 +397,12 @@ def _query_conditional_tokens_gc_subgraph(creator: str) -> Dict[str, Any]: userPositions_id_gt=userPositions_id_gt, ) content_json = {"query": query} - res = requests.post(url, headers=headers, json=content_json, timeout=30) + try: + res = requests.post(url, headers=headers, json=content_json, timeout=30) + except requests.RequestException as exc: + raise RuntimeError( + f"conditional-tokens subgraph query failed: {exc}" + ) from exc result_json = res.json() user_data = result_json.get("data", {}).get("user", {}) diff --git a/scripts/utils.py b/scripts/utils.py index 5222a04e..c3a95133 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -36,7 +36,7 @@ def get_subgraph_api_key() -> str: - """Get subgraph api key.""" + """Read the persisted subgraph API key from OPERATE_HOME/subgraph_api_key.txt.""" subgraph_api_key_path = OPERATE_HOME / "subgraph_api_key.txt" if subgraph_api_key_path.exists(): return subgraph_api_key_path.read_text() @@ -50,7 +50,7 @@ def get_subgraph_api_key() -> str: def get_service_from_config( config_path: Path, operate: Optional[OperateApp] = None ) -> Service: - """Get service safe.""" + """Return the Service the given config_path maps to via the operate manager.""" if not config_path.exists(): print("No trader agent config found!") sys.exit(1) diff --git a/tests/test_scripts/test_predict_trader/test_mech_events.py b/tests/test_scripts/test_predict_trader/test_mech_events.py index b8c9de17..53a82e00 100644 --- a/tests/test_scripts/test_predict_trader/test_mech_events.py +++ b/tests/test_scripts/test_predict_trader/test_mech_events.py @@ -51,7 +51,10 @@ def test_populate_ipfs_contents_falls_back_to_non_metadata_url( calls: list[str] = [] - def _fake_get(url: str, timeout: int = 30) -> _Response: # noqa: ARG001 + # `timeout` is positional with no default so the production code MUST + # pass it; dropping `timeout=30` from a `requests.get` call raises + # TypeError and the test fails (catching the W3101 regression). + def _fake_get(url: str, timeout: int) -> _Response: # noqa: ARG001 calls.append(url) if url.endswith("/metadata.json"): return _Response(json.JSONDecodeError("bad", "doc", 0)) @@ -284,7 +287,10 @@ def test_populate_ipfs_contents_uses_ipfs_hash_bytes( called: list[str] = [] - def _fake_get(url: str, timeout: int = 30) -> _Response: # noqa: ARG001 + # `timeout` is positional with no default so the production code MUST + # pass it; dropping `timeout=30` from a `requests.get` call raises + # TypeError and the test fails (catching the W3101 regression). + def _fake_get(url: str, timeout: int) -> _Response: # noqa: ARG001 called.append(url) return _Response({"ok": True}) diff --git a/tests/test_scripts/test_predict_trader/test_rank_traders.py b/tests/test_scripts/test_predict_trader/test_rank_traders.py index 3d657f85..3b9144b8 100644 --- a/tests/test_scripts/test_predict_trader/test_rank_traders.py +++ b/tests/test_scripts/test_predict_trader/test_rank_traders.py @@ -211,9 +211,12 @@ def test_main_execution_path( class _Config: rpc = {rank_traders.Chain.GNOSIS.value: "http://rpc"} - monkeypatch.setattr( - run_service, "load_local_config", lambda **_kwargs: _Config() - ) + # Positional, no defaults: a regression that reverts to + # `load_local_config()` (zero args) raises TypeError and the test fails. + def _fake_load_local_config(operate, service_name): # noqa: ARG001 + return _Config() + + monkeypatch.setattr(run_service, "load_local_config", _fake_load_local_config) operate_home = tmp_path / ".operate" operate_home.mkdir(parents=True) (operate_home / "subgraph_api_key.txt").write_text("dummy_key", encoding="utf-8") diff --git a/tox.ini b/tox.ini index 858da92b..fbacb68f 100644 --- a/tox.ini +++ b/tox.ini @@ -35,57 +35,24 @@ open-autonomy: ==0.21.22 pyinstaller: 6.20.0 pyinstaller-hooks-contrib: <2027 -; Per-repo mypy overlays. Concat-appended onto tomte's canonical +; Per-repo mypy overlay. Concat-appended onto tomte's canonical ; `mypy.ini` at runtime via `tomte render-mypy-config --append-from=tox.ini`. -; Each block silences "no stubs / no implementation" errors for a library -; quickstart imports but tomte's lint env doesn't install. - -; Heavy `operate.*` consumers — tomte's [testenv:mypy] doesn't install +; +; `scripts/` consumes `operate.*` (olas-operate-middleware) heavily, +; which is a namespace package. tomte's [testenv:mypy] doesn't install ; olas-operate-middleware, so mypy can't resolve `operate.constants`, -; `operate.cli`, etc. as namespace-package imports. Skip type-checking -; these modules entirely (same pattern trader uses for -; `packages.valory.connections.*` / `packages.valory.contracts.*`). -[mypy-scripts.pearl_migration.*] -ignore_errors = True - -[mypy-scripts.predict_trader.*] -ignore_errors = True - -[mypy-scripts.optimus.*] +; `operate.cli`, etc. Per-package `ignore_missing_imports = True` is +; insufficient because mypy refuses to type-check a module whose +; imports it can't resolve at all. The full-subtree `ignore_errors` +; carve-out matches the pattern trader uses for +; `packages.valory.connections.*` / `packages.valory.contracts.*`. +; +; Trade-off: this silences EVERY mypy error in `scripts/`, including +; real type errors that aren't import-related. Acceptable today +; because `scripts/` is operator tooling, not the core agent. If we +; want real mypy on `scripts/` later, the fix is to install +; olas-operate-middleware into the mypy testenv (requires either a +; tomte change or overriding [testenv:mypy], the latter being +; blocked by tomte's verbatim-merge of consumer-defined testenvs). +[mypy-scripts.*] ignore_errors = True - -[mypy-scripts.utils] -ignore_errors = True - -[mypy-operate.*] -ignore_missing_imports = True - -[mypy-autonomy.*] -ignore_missing_imports = True - -[mypy-aea_ledger_ethereum.*] -ignore_missing_imports = True - -[mypy-aea.*] -ignore_missing_imports = True - -[mypy-halo.*] -ignore_missing_imports = True - -[mypy-dotenv.*] -ignore_missing_imports = True - -[mypy-psutil.*] -ignore_missing_imports = True - -[mypy-requests.*] -ignore_missing_imports = True - -[mypy-gql.*] -ignore_missing_imports = True - -[mypy-docker.*] -ignore_missing_imports = True - -[mypy-web3.*] -ignore_missing_imports = True From 860486dde4ef1e310acba8b35f33d3a51c8da663 Mon Sep 17 00:00:00 2001 From: DivyaNautiyal07 Date: Fri, 22 May 2026 15:18:16 +0530 Subject: [PATCH 11/17] fix(ci): disable pytest-randomly so sequential e2e tests stay ordered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tomte[tests]==0.7.0` pulls in `pytest-randomly==4.0.1`, which shuffles test order by default. tests/test_migrate_to_pearl.py uses `test_01_*`, `test_02_*`, `test_03_*`, `test_04_*` naming because each step relies on state created by the previous one — e.g. test_03_migrate_second_quickstart_mode_b asserts pearl_home exists, which test_02_migrate_first_quickstart_mode_a is responsible for creating. CI run https://github.com/valory-xyz/quickstart/actions/runs/26279378153 hit this: pytest-randomly ran test_03 first, the assertion failed because pearl_home didn't exist, and test_02/01/04 then SKIPPED. Add `-p no:randomly` to addopts in pyproject.toml and tox.ini (the two pytest config sources tomte tox / direct pytest use). Comments on both sides explain the flag's purpose. Verified locally: `pytest --collect-only` now lists the four tests in numeric order. 370/370 unit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 16 +++++++++++----- tox.ini | 4 +++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a5c5a1ac..57642431 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,11 +61,17 @@ service_specific_packages = ["scripts"] pytest_targets = ["tests"] [tool.pytest.ini_options] -# anchorpy ships a pytest plugin (via open-autonomy -> open-aea-ledger-solana -> -# anchorpy) that imports pytest_xprocess at startup, but xprocess is only in -# anchorpy's [pytest] extra and is never installed. Disable the plugin so -# pytest can boot. -addopts = "-p no:pytest_anchorpy" +# `-p no:pytest_anchorpy`: anchorpy ships a pytest plugin (via +# open-autonomy -> open-aea-ledger-solana -> anchorpy) that imports +# pytest_xprocess at startup, but xprocess is only in anchorpy's +# [pytest] extra and is never installed. Disabling the plugin lets +# pytest boot. +# `-p no:randomly`: tomte[tests]==0.7.0 pulls in pytest-randomly which +# shuffles test order by default. tests/test_migrate_to_pearl.py uses +# numeric `test_NN_*` naming because each test depends on state set up +# by the previous one (e.g. test_03_*_mode_b asserts pearl_home from +# test_02_*_mode_a). Disable the plugin so the sequential order holds. +addopts = "-p no:pytest_anchorpy -p no:randomly" markers = [ "e2e: end-to-end tests that may use real network/external systems", "integration: integration tests that may use networked dependencies", diff --git a/tox.ini b/tox.ini index fbacb68f..967b2729 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,9 @@ extra_pylint_disables = C0114,C0115,C0116,R0801,C0415,W0621,C0411,W0212,W0718,R1 [pytest] tomte_defaults = true -addopts = -p no:pytest_anchorpy +; See `[tool.pytest.ini_options].addopts` in pyproject.toml for the +; rationale behind each flag. Keep this line in sync. +addopts = -p no:pytest_anchorpy -p no:randomly [Authorized Packages] ; Per-repo overrides for liccheck: each entry maps a package whose From 3fa51e98629b734f2281a671bf19a0ab9b127163 Mon Sep 17 00:00:00 2001 From: DivyaNautiyal07 Date: Fri, 22 May 2026 15:35:47 +0530 Subject: [PATCH 12/17] fix: address PR #175 self-review comments + restore 100% coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the unit-test job failure: scripts/predict_trader/trades.py lines 359-360 + 402-403 (the new `try/except RequestException → RuntimeError` wrappers from 3783f4c) had zero coverage, dropping total to 99.82% and tripping `--cov-fail-under=100`. Fix: extract one `_post_subgraph_query(url, payload, *, label)` helper that wraps `requests.post` + `raise_for_status` + `.json()` in a single try-block, and apply it at both subgraph call sites. This also closes three concerns flagged in the re-review: - P1: `res.json()` was outside the try-block, so a non-JSON 200 response from the subgraph (e.g. a Cloudflare HTML error page) would raise `JSONDecodeError` and escape the wrapper. The helper's try-block now covers the parse too; `requests.JSONDecodeError` subclasses `RequestException` so the same except branch handles it. - The helper also calls `res.raise_for_status()` so 4xx/5xx responses surface as the same clean `RuntimeError(...)` instead of slipping through to a JSON parse on an HTML body. - P2: the `RuntimeError` message now includes the URL ("omen subgraph query failed for : ") so an operator can act on it. - P2: the duplication between the two subgraph call sites is gone. Tests: - `test_post_subgraph_query_raises_runtimeerror` parametrised over the three failure kinds (network exception, HTTP 500, non-JSON 200 body). Each asserts the resulting `RuntimeError` carries the label + URL. Other re-review findings folded in (smaller): - `test_rank_traders.py:216`: mock now asserts `service_name == rank_traders.PREDICT_TRADER_SERVICE_NAME`, so drift between the constant and the JSON config fails CI. - `rank_traders.py:51`: `PREDICT_TRADER_SERVICE_NAME` is now `Final[str]`, and the comment explicitly says both rename and value changes need updating on both sides. - `pyproject.toml:69`: dropped the `tomte 0.7.0` version from the `-p no:randomly` rationale comment (would rot on tomte bump). The pin in dev-deps stays authoritative. - `tests/test_scripts/test_predict_trader/test_mech_events.py:54, 287`: `_fake_get(url, timeout, **_kwargs)` keeps the timeout regression guard intact while staying forward-compatible if production adds other kwargs (headers=, verify=, etc.). All 9 lint envs green; 373/373 unit tests pass with 100% coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 8 ++-- scripts/predict_trader/rank_traders.py | 11 +++--- scripts/predict_trader/trades.py | 37 +++++++++++++------ .../test_predict_trader/test_mech_events.py | 18 ++++++++- .../test_predict_trader/test_rank_traders.py | 3 ++ .../test_predict_trader/test_trades.py | 30 +++++++++++++++ 6 files changed, 84 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 57642431..d3540fe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,10 +66,10 @@ pytest_targets = ["tests"] # pytest_xprocess at startup, but xprocess is only in anchorpy's # [pytest] extra and is never installed. Disabling the plugin lets # pytest boot. -# `-p no:randomly`: tomte[tests]==0.7.0 pulls in pytest-randomly which -# shuffles test order by default. tests/test_migrate_to_pearl.py uses -# numeric `test_NN_*` naming because each test depends on state set up -# by the previous one (e.g. test_03_*_mode_b asserts pearl_home from +# `-p no:randomly`: tomte[tests] pulls in pytest-randomly which shuffles +# test order by default. tests/test_migrate_to_pearl.py uses numeric +# `test_NN_*` naming because each test depends on state set up by the +# previous one (e.g. test_03_*_mode_b asserts pearl_home from # test_02_*_mode_a). Disable the plugin so the sequential order holds. addopts = "-p no:pytest_anchorpy -p no:randomly" markers = [ diff --git a/scripts/predict_trader/rank_traders.py b/scripts/predict_trader/rank_traders.py index fa755bb9..fe4cc4ce 100644 --- a/scripts/predict_trader/rank_traders.py +++ b/scripts/predict_trader/rank_traders.py @@ -24,7 +24,7 @@ from argparse import ArgumentParser from collections import defaultdict from string import Template -from typing import Any +from typing import Any, Final import requests from operate.cli import OperateApp @@ -45,10 +45,11 @@ DEFAULT_FROM_DATE = "2024-12-01T00:00:00" DEFAULT_TO_DATE = "2038-01-19T03:14:07" -# Matches the `name` field in `configs/config_predict_trader.json`. If -# that JSON is renamed, update this constant too — `load_local_config` -# looks the service up by exact name. -PREDICT_TRADER_SERVICE_NAME = "Trader Agent" +# Must match the `name` field in `configs/config_predict_trader.json`. +# `load_local_config` looks the service up by exact name — if either +# side (this constant or the JSON value) changes, update the other. +# The test_rank_traders.py mock asserts equality so a drift fails CI. +PREDICT_TRADER_SERVICE_NAME: Final[str] = "Trader Agent" headers = { diff --git a/scripts/predict_trader/trades.py b/scripts/predict_trader/trades.py index 2cc64eca..48f22380 100644 --- a/scripts/predict_trader/trades.py +++ b/scripts/predict_trader/trades.py @@ -326,6 +326,27 @@ def _to_content(q: str) -> Dict[str, Any]: return finalized_query +def _post_subgraph_query( + url: str, payload: Dict[str, Any], *, label: str +) -> Dict[str, Any]: + """POST a subgraph query and return its parsed JSON body. + + Wraps `requests.post`, `raise_for_status`, and `.json()` in one + try-block so a network failure, a 4xx/5xx response (e.g. the gateway + returning an HTML error page), or a malformed body all surface as a + single `RuntimeError("