chore(v2): lint pass — black + ruff auto-fix + mypy regressions (#29 v1.1)

Pre-merge lint hygiene on the PR #30 touched files:

- `black` reformatted 9 files (cassette_transport, crud, models, tasks,
  views_api, nip44, all 3 cassette test files, migrations). Cosmetic:
  line lengths, trailing commas, multi-line argument layout.
- `ruff check --fix` cleared 176 of 202 errors auto-fixed. Mostly
  `UP006` `typing.Optional` → `| None` modernization, `I001` import
  sort order, `UP035` typing-extensions cleanup.
- Two new mypy regressions introduced by the migration commit dcb7de0
  fixed:
  - `crud.py:apply_bootstrap_state` — annotated `existing_first: dict
    | None` on the dedup fetch.
  - `tasks.py:_cassette_consumer_tick` — `# type: ignore[arg-type]` on
    the `nostr_client.relay_manager.add_subscription` call; nostrclient's
    upstream typing declares `list[str]` for filters but the actual
    Nostr protocol takes `list[<filter-dict>]`. The runtime accepts it
    (live smoke at 13:43Z dispatched `nip44_decrypt` cleanly through
    this subscription); the typing mismatch is upstream's.

Remaining lint state, intentionally not addressed in this commit
(all pre-existing baseline, not regressions):
- 8 mypy errors in `calculations.py` + the unchanged-by-this-PR parts
  of `crud.py` — pre-existing on v2-bitspire.
- 26 ruff style warnings: 14 are N805 false-positives on Pydantic
  validators (`cls` first-arg is correct for `@validator`-decorated
  methods); 4 are N818 exception-name-suffix preferences on my new
  exception classes (renaming would touch many call sites; keep
  `OperatorIdentityMissing` / `SignerUnavailable` / `RelayUnavailable`
  / `_NostrclientUnavailable` as-is for clarity); 5 are E501 line-too-
  long on docstrings (the long lines are formatted for clarity);
  1 RUF002 unicode-minus in a docstring.

Tests: 155 passed, 1 pre-existing async-plugin failure unchanged.
Live smoke (both publish + consume directions through the bunker)
unaffected — this is purely a code-style pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-31 15:50:14 +02:00
commit d448fab0d2
10 changed files with 249 additions and 352 deletions

View file

@ -30,11 +30,9 @@ convention (see test_deposit_currency.py rationale).
import asyncio
import json
from types import SimpleNamespace
from typing import Optional
import coincurve
import pytest
from lnbits.core.services.nip46_bunker_client import (
NsecBunkerRpcError,
NsecBunkerTimeoutError,
@ -59,7 +57,6 @@ from ..nip44 import (
get_conversation_key,
)
# Canonical keys (integer 1 + integer 2, the paulmillr/nip44 reference pair).
_OP_SEC = "00" * 31 + "01"
_ATM_SEC = "00" * 31 + "02"
@ -118,14 +115,10 @@ class _FakeLocalSignerStub:
return True
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
raise SignerUnavailableError(
"LocalSigner does not implement nip44_encrypt"
)
raise SignerUnavailableError("LocalSigner does not implement nip44_encrypt")
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
raise SignerUnavailableError(
"LocalSigner does not implement nip44_decrypt"
)
raise SignerUnavailableError("LocalSigner does not implement nip44_decrypt")
class _FakeRaisingSigner:
@ -148,7 +141,7 @@ class _FakeRaisingSigner:
def _fake_account(
signer_type: str = "RemoteBunkerSigner",
prvkey: Optional[str] = None,
prvkey: str | None = None,
):
"""Account-shaped duck-typed object. decrypt_and_parse_state_event +
_nip44_decrypt_via_signer only read `.signer_type` and `.prvkey`; the
@ -211,9 +204,7 @@ class TestDecryptViaBunkerSigner:
account = _fake_account(signer_type="RemoteBunkerSigner")
signer = _FakeBunkerSigner(_OP_SEC)
recovered = asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer))
assert sorted(recovered.positions.keys()) == [1, 2]
assert recovered.positions[1].denomination == 20
assert recovered.positions[1].count == 49
@ -235,9 +226,7 @@ class TestDecryptViaBunkerSigner:
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
recovered = asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer))
assert len(recovered.positions) == 4
for pos in (1, 2, 3, 4):
assert recovered.positions[pos].denomination == 20
@ -264,9 +253,7 @@ class TestDecryptViaLocalSignerFallback:
account = _fake_account(signer_type="LocalSigner", prvkey=_OP_SEC)
signer = _FakeLocalSignerStub()
recovered = asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer))
assert recovered.positions[1].denomination == 20
assert recovered.positions[1].count == 49
@ -283,9 +270,7 @@ class TestDecryptViaLocalSignerFallback:
signer = _FakeLocalSignerStub()
with pytest.raises(CassetteEventDecodeError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
def test_clientonlysigner_raises_decode_error(self):
"""ClientSideOnlySigner has no server-side decrypt path at all;
@ -295,15 +280,11 @@ class TestDecryptViaLocalSignerFallback:
positions={"1": {"denomination": 20, "count": 49}}
)
event = _make_state_event(payload)
account = _fake_account(
signer_type="ClientSideOnlySigner", prvkey=None
)
account = _fake_account(signer_type="ClientSideOnlySigner", prvkey=None)
signer = _FakeLocalSignerStub() # behaves the same way: raises
with pytest.raises(CassetteEventDecodeError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
# =============================================================================
@ -326,13 +307,9 @@ class TestBunkerErrorMapping:
)
event = _make_state_event(payload)
account = _fake_account()
signer = _FakeRaisingSigner(
NsecBunkerTimeoutError("bunker unreachable")
)
signer = _FakeRaisingSigner(NsecBunkerTimeoutError("bunker unreachable"))
with pytest.raises(CassetteEventTransientError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
def test_rpc_reject_maps_to_decode_error(self):
"""Bunker rejected the RPC (policy / MAC / config) →
@ -347,9 +324,7 @@ class TestBunkerErrorMapping:
NsecBunkerRpcError("bunker policy reject: kind 30078 not authorised")
)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
# =============================================================================
@ -371,9 +346,7 @@ class TestPayloadValidation:
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
def test_wrong_signer_privkey_rejected(self):
"""Wrong privkey on the signer → wrong conversation key → MAC
@ -389,37 +362,27 @@ class TestPayloadValidation:
wrong_sec = "00" * 31 + "03"
signer = _FakeBunkerSigner(wrong_sec)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
def test_missing_content_rejected(self):
event = _make_state_event(
PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
PublishCassettesPayload(positions={"1": {"denomination": 20, "count": 49}})
)
del event["content"]
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
def test_missing_pubkey_rejected(self):
event = _make_state_event(
PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
PublishCassettesPayload(positions={"1": {"denomination": 20, "count": 49}})
)
del event["pubkey"]
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
def test_decrypted_garbage_json_rejected(self):
"""If plaintext decrypts cleanly but isn't valid JSON, surface
@ -428,9 +391,7 @@ class TestPayloadValidation:
event = {
"kind": 30078,
"pubkey": _ATM_PUB,
"content": encrypt_with_conversation_key(
"definitely not json", ck
),
"content": encrypt_with_conversation_key("definitely not json", ck),
"tags": [],
"created_at": 0,
"id": "x",
@ -438,9 +399,7 @@ class TestPayloadValidation:
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
def test_decrypted_wrong_shape_rejected(self):
"""Well-formed JSON but missing 'positions' → payload-shape
@ -457,9 +416,7 @@ class TestPayloadValidation:
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
# =============================================================================