Skip to content

Commit 86cd318

Browse files
Refactor: Transition to synchronous live policy fee computation
- Replaced `async` logic in `live_test.py` with synchronous calls for better compatibility. - Refactored `Transaction.fee()` to include a private helper for asynchronous fee handling. - Updated `LivePolicy` tests to utilize `default_http_client` with mock responses. - Improved test coverage for cache utilization and fallback scenarios. - Removed outdated and unused code in `LivePolicy` test suite.
1 parent 8513eee commit 86cd318

File tree

3 files changed

+161
-114
lines changed

3 files changed

+161
-114
lines changed

bsv/transaction.py

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -171,43 +171,54 @@ def estimated_byte_length(self) -> int:
171171

172172
estimated_size = estimated_byte_length
173173

174+
# Private helper method for handling asynchronous fee resolution and application
175+
async def _resolve_and_apply_fee(self, fee_estimate, change_distribution):
176+
"""
177+
A helper method to resolve and apply the transaction fee asynchronously.
178+
179+
:param fee_estimate: An awaitable object that resolves to the estimated fee
180+
:param change_distribution: The method of distributing change ('equal' or 'random')
181+
:return: The resolved fee value (int) or None in case of an error
182+
"""
183+
try:
184+
# Resolve the fee asynchronously
185+
resolved_fee = await fee_estimate
186+
# Apply the resolved fee to the transaction
187+
self._apply_fee_amount(resolved_fee, change_distribution)
188+
return resolved_fee
189+
except Exception as e:
190+
# Handle any errors and return None on failure
191+
return None
192+
174193
def fee(self, model_or_fee=None, change_distribution='equal'):
175194
"""
176-
Computes the fee for the transaction and adjusts the change outputs accordingly.
177-
This method can be called synchronously, even if it internally uses async operations.
195+
Computes the transaction fee and adjusts the change outputs accordingly.
196+
This method can be called synchronously, even if it internally uses asynchronous operations.
178197
179-
:param model_or_fee: Fee model or fee amount. Defaults to a `LivePolicy` instance
180-
that retrieves the latest mining fees from ARC if not provided.
181-
:param change_distribution: Method of change distribution ('equal' or 'random'). Defaults to 'equal'.
198+
:param model_or_fee: A fee model or a fee amount. If not provided, it defaults to an instance
199+
of `LivePolicy` that fetches the latest mining fees.
200+
:param change_distribution: Method of distributing change ('equal' or 'random'). Defaults to 'equal'.
182201
"""
183202
if model_or_fee is None:
203+
# Retrieve the default fee model
184204
model_or_fee = LivePolicy.get_instance(
185205
fallback_sat_per_kb=int(TRANSACTION_FEE_RATE)
186206
)
187207

188-
# モデルが同期型の処理を返す場合
208+
# If the fee is provided as a fixed value (synchronous)
189209
if isinstance(model_or_fee, int):
190210
self._apply_fee_amount(model_or_fee, change_distribution)
191211
return model_or_fee
192212

193-
# 非同期型の処理を返す場合
213+
# If the fee estimation requires asynchronous computation
194214
fee_estimate = model_or_fee.compute_fee(self)
195215

196216
if inspect.isawaitable(fee_estimate):
197-
198-
async def _resolve_and_apply():
199-
try:
200-
resolved_fee = await fee_estimate
201-
self._apply_fee_amount(resolved_fee, change_distribution)
202-
return resolved_fee
203-
except Exception as e:
204-
return None
205-
206-
# `async` を内部で実行して結果を取得
207-
resolved_fee = asyncio.run(_resolve_and_apply())
217+
# Execute the asynchronous task synchronously and get the result
218+
resolved_fee = asyncio.run(self._resolve_and_apply_fee(fee_estimate, change_distribution))
208219
return resolved_fee
209220

210-
# 同期的な計算の結果を返す
221+
# Apply the fee directly if it is computed synchronously
211222
self._apply_fee_amount(fee_estimate, change_distribution)
212223
return fee_estimate
213224

live_test.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
logging.basicConfig(level=logging.INFO)
1111
logging.getLogger("bsv.fee_models.live_policy").setLevel(logging.DEBUG)
1212

13-
async def main():
13+
# async def main():
14+
15+
def main():
1416
"""
1517
A live test script to send BSV.
1618
@@ -77,9 +79,15 @@ async def main():
7779

7880
# Build, sign, and broadcast the transaction
7981
print("\nFetching live fee policy...")
80-
live_policy = LivePolicy.get_instance() # Use a safer fallback rate
81-
fee_rate = await live_policy.current_rate_sat_per_kb()
82-
print(f"Using fee rate: {fee_rate} sat/kB")
82+
# live_policy = LivePolicy.get_instance() # Use a safer fallback rate
83+
live_policy = LivePolicy(
84+
cache_ttl_ms=60_000,
85+
arc_policy_url="https://arc.taal.com/v1/policy",
86+
api_key="Bearer <token>"
87+
)
88+
89+
# fee_rate = await live_policy.current_rate_sat_per_kb()
90+
# print(f"Using fee rate: {fee_rate} sat/kB")
8391

8492
tx = Transaction([tx_input], [tx_output_recipient, tx_output_change])
8593
fee = tx.fee(live_policy) # Automatically calculate fee and adjust change
@@ -93,4 +101,5 @@ async def main():
93101
print(f"\nCheck on WhatsOnChain: https://whatsonchain.com/tx/{tx.txid()}")
94102

95103
if __name__ == "__main__":
96-
asyncio.run(main())
104+
# asyncio.run(main())
105+
main()

tests/test_live_policy.py

Lines changed: 117 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,138 +1,165 @@
1-
from unittest.mock import MagicMock, patch
2-
3-
import requests
4-
1+
import asyncio
2+
from unittest.mock import AsyncMock, patch, MagicMock
53
from bsv.fee_models.live_policy import LivePolicy
64

7-
5+
# Reset the singleton instance before each test
86
def setup_function(_):
97
LivePolicy._instance = None
108

11-
9+
# Reset the singleton instance after each test
1210
def teardown_function(_):
1311
LivePolicy._instance = None
1412

15-
16-
def _mock_response(payload):
17-
response = MagicMock()
18-
response.raise_for_status.return_value = None
19-
response.json.return_value = payload
20-
return response
21-
22-
23-
@patch("bsv.fee_models.live_policy.requests.get")
24-
def test_parses_mining_fee(mock_get):
25-
payload = {
26-
"policy": {
27-
"fees": {
28-
"miningFee": {"satoshis": 5, "bytes": 250}
13+
@patch("bsv.fee_models.live_policy.default_http_client", autospec=True)
14+
def test_parses_mining_fee(mock_http_client_factory):
15+
# Prepare the mocked DefaultHttpClient instance
16+
mock_http_client = AsyncMock()
17+
mock_http_client_factory.return_value = mock_http_client
18+
19+
# Set up a mock response
20+
mock_http_client.get.return_value.json_data = {
21+
"data": {
22+
"policy": {
23+
"fees": {
24+
"miningFee": {"satoshis": 5, "bytes": 250}
25+
}
2926
}
3027
}
3128
}
32-
mock_get.return_value = _mock_response(payload)
33-
34-
policy = LivePolicy(cache_ttl_ms=60000, fallback_sat_per_kb=1)
35-
36-
assert policy.current_rate_sat_per_kb() == 20
3729

30+
# Create the test instance
31+
policy = LivePolicy(
32+
cache_ttl_ms=60000,
33+
fallback_sat_per_kb=1,
34+
arc_policy_url="https://arc.mock/policy"
35+
)
36+
37+
# Execute and verify the result
38+
rate = asyncio.run(policy.current_rate_sat_per_kb())
39+
assert rate == 20
40+
mock_http_client.get.assert_called_once()
41+
42+
43+
@patch("bsv.fee_models.live_policy.default_http_client", autospec=True)
44+
def test_cache_reused_when_valid(mock_http_client_factory):
45+
# Prepare the mocked DefaultHttpClient instance
46+
mock_http_client = AsyncMock()
47+
mock_http_client_factory.return_value = mock_http_client
48+
49+
# Set up a mock response
50+
mock_http_client.get.return_value.json_data = {
51+
"data": {
52+
"policy": {"satPerKb": 50}
53+
}
54+
}
3855

39-
@patch("bsv.fee_models.live_policy.requests.get")
40-
def test_cache_reused_when_valid(mock_get):
41-
payload = {"policy": {"satPerKb": 50}}
42-
mock_get.return_value = _mock_response(payload)
43-
44-
policy = LivePolicy(cache_ttl_ms=60000, fallback_sat_per_kb=1)
56+
policy = LivePolicy(
57+
cache_ttl_ms=60000,
58+
fallback_sat_per_kb=1,
59+
arc_policy_url="https://arc.mock/policy"
60+
)
4561

46-
first = policy.current_rate_sat_per_kb()
47-
second = policy.current_rate_sat_per_kb()
62+
# Call multiple times within the cache validity period
63+
first_rate = asyncio.run(policy.current_rate_sat_per_kb())
64+
second_rate = asyncio.run(policy.current_rate_sat_per_kb())
4865

49-
assert first == 50
50-
assert second == 50
51-
mock_get.assert_called_once()
66+
# Verify the results
67+
assert first_rate == 50
68+
assert second_rate == 50
69+
mock_http_client.get.assert_called_once()
5270

5371

54-
@patch("bsv.fee_models.live_policy.requests.get")
72+
@patch("bsv.fee_models.live_policy.default_http_client", autospec=True)
5573
@patch("bsv.fee_models.live_policy.logger.warning")
56-
def test_uses_cached_value_when_fetch_fails(mock_log, mock_get):
57-
payload = {"policy": {"satPerKb": 75}}
58-
mock_get.side_effect = [
59-
_mock_response(payload),
60-
requests.RequestException("Network down"),
74+
def test_uses_cached_value_when_fetch_fails(mock_log, mock_http_client_factory):
75+
# Prepare the mocked DefaultHttpClient instance
76+
mock_http_client = AsyncMock()
77+
mock_http_client_factory.return_value = mock_http_client
78+
79+
# Set up mock responses (success first, then failure)
80+
mock_http_client.get.side_effect = [
81+
AsyncMock(json_data={"data": {"policy": {"satPerKb": 75}}}),
82+
Exception("Network down")
6183
]
6284

63-
policy = LivePolicy(cache_ttl_ms=1, fallback_sat_per_kb=5)
85+
policy = LivePolicy(
86+
cache_ttl_ms=1,
87+
fallback_sat_per_kb=5,
88+
arc_policy_url="https://arc.mock/policy"
89+
)
6490

65-
first = policy.current_rate_sat_per_kb()
66-
assert first == 75
91+
# The first execution succeeds
92+
first_rate = asyncio.run(policy.current_rate_sat_per_kb())
93+
assert first_rate == 75
6794

68-
# Expire cache manually
95+
# Force invalidation of the cache
6996
with policy._cache_lock:
7097
policy._cache.fetched_at_ms -= 10
7198

72-
second = policy.current_rate_sat_per_kb()
73-
assert second == 75
99+
# The second execution uses the cache
100+
second_rate = asyncio.run(policy.current_rate_sat_per_kb())
101+
assert second_rate == 75
74102

103+
# Verify that a log is recorded for cache usage
75104
assert mock_log.call_count == 1
76105
args, _ = mock_log.call_args
77106
assert args[0] == "Failed to fetch live fee rate, using cached value: %s"
78-
assert isinstance(args[1], requests.RequestException)
79-
assert str(args[1]) == "Network down"
107+
mock_http_client.get.assert_called()
80108

81109

82-
@patch("bsv.fee_models.live_policy.requests.get", side_effect=requests.RequestException("boom"))
110+
@patch("bsv.fee_models.live_policy.default_http_client", autospec=True)
83111
@patch("bsv.fee_models.live_policy.logger.warning")
84-
def test_falls_back_to_default_when_no_cache(mock_log, _mock_get):
85-
policy = LivePolicy(cache_ttl_ms=60000, fallback_sat_per_kb=9)
112+
def test_falls_back_to_default_when_no_cache(mock_log, mock_http_client_factory):
113+
# Prepare the mocked DefaultHttpClient instance
114+
mock_http_client = AsyncMock()
115+
mock_http_client_factory.return_value = mock_http_client
86116

87-
assert policy.current_rate_sat_per_kb() == 9
117+
# Set up a mock response (always failing)
118+
mock_http_client.get.side_effect = Exception("Network failure")
88119

120+
policy = LivePolicy(
121+
cache_ttl_ms=60000,
122+
fallback_sat_per_kb=9,
123+
arc_policy_url="https://arc.mock/policy"
124+
)
125+
126+
# Fallback value is returned during execution
127+
rate = asyncio.run(policy.current_rate_sat_per_kb())
128+
assert rate == 9
129+
130+
# Verify that a log is recorded
89131
assert mock_log.call_count == 1
90132
args, _ = mock_log.call_args
91133
assert args[0] == "Failed to fetch live fee rate, using fallback %d sat/kB: %s"
92134
assert args[1] == 9
93-
assert isinstance(args[2], requests.RequestException)
94-
assert str(args[2]) == "boom"
135+
mock_http_client.get.assert_called()
95136

96137

97-
@patch("bsv.fee_models.live_policy.requests.get")
138+
@patch("bsv.fee_models.live_policy.default_http_client", autospec=True)
98139
@patch("bsv.fee_models.live_policy.logger.warning")
99-
def test_invalid_response_triggers_fallback(mock_log, mock_get):
100-
mock_get.return_value = _mock_response({"policy": {"invalid": True}})
140+
def test_invalid_response_triggers_fallback(mock_log, mock_http_client_factory):
141+
# Prepare the mocked DefaultHttpClient instance
142+
mock_http_client = AsyncMock()
143+
mock_http_client_factory.return_value = mock_http_client
144+
145+
# Set up an invalid response
146+
mock_http_client.get.return_value.json_data = {
147+
"data": {"policy": {"invalid": True}}
148+
}
101149

102-
policy = LivePolicy(cache_ttl_ms=60000, fallback_sat_per_kb=3)
150+
policy = LivePolicy(
151+
cache_ttl_ms=60000,
152+
fallback_sat_per_kb=3,
153+
arc_policy_url="https://arc.mock/policy"
154+
)
103155

104-
assert policy.current_rate_sat_per_kb() == 3
156+
# Fallback value is returned due to the invalid response
157+
rate = asyncio.run(policy.current_rate_sat_per_kb())
158+
assert rate == 3
105159

160+
# Verify that a log is recorded
106161
assert mock_log.call_count == 1
107162
args, _ = mock_log.call_args
108163
assert args[0] == "Failed to fetch live fee rate, using fallback %d sat/kB: %s"
109164
assert args[1] == 3
110-
assert isinstance(args[2], ValueError)
111-
assert str(args[2]) == "Invalid policy response format"
112-
113-
114-
def test_singleton_returns_same_instance():
115-
first = LivePolicy.get_instance(cache_ttl_ms=10000)
116-
second = LivePolicy.get_instance(cache_ttl_ms=20000)
117-
118-
assert first is second
119-
assert first.cache_ttl_ms == 10000
120-
121-
122-
def test_custom_instance_uses_provided_ttl():
123-
policy = LivePolicy(cache_ttl_ms=30000)
124-
assert policy.cache_ttl_ms == 30000
125-
126-
127-
@patch("bsv.fee_models.live_policy.requests.get")
128-
def test_singleton_cache_shared(mock_get):
129-
payload = {"policy": {"satPerKb": 25}}
130-
mock_get.return_value = _mock_response(payload)
131-
132-
policy1 = LivePolicy.get_instance()
133-
policy2 = LivePolicy.get_instance()
134-
135-
assert policy1 is policy2
136-
assert policy1.current_rate_sat_per_kb() == 25
137-
assert policy2.current_rate_sat_per_kb() == 25
138-
mock_get.assert_called_once()
165+
mock_http_client.get.assert_called()

0 commit comments

Comments
 (0)