uagent-testkit 0.1.0__tar.gz → 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/.gitignore +2 -0
- uagent_testkit-0.1.0/README.md → uagent_testkit-0.1.1/PKG-INFO +84 -4
- uagent_testkit-0.1.0/PKG-INFO → uagent_testkit-0.1.1/README.md +55 -31
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/pyproject.toml +10 -7
- uagent_testkit-0.1.1/tests/test_chain.py +227 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/uagent_testkit/__init__.py +11 -1
- uagent_testkit-0.1.1/uagent_testkit/chain.py +383 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/uagent_testkit/plugin.py +14 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/.github/workflows/ci.yml +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/LICENSE +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/examples/__init__.py +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/examples/quote_agents.py +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/examples/test_quote_agents.py +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/tests/__init__.py +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/tests/conftest.py +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/tests/test_chat_protocol.py +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/tests/test_harness.py +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/tests/test_network.py +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/tests/test_no_network.py +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/tests/test_verification.py +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/uagent_testkit/_loop.py +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/uagent_testkit/_recording.py +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/uagent_testkit/_storage.py +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/uagent_testkit/harness.py +0 -0
- {uagent_testkit-0.1.0 → uagent_testkit-0.1.1}/uagent_testkit/network.py +0 -0
|
@@ -1,5 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uagent-testkit
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Test Fetch.ai uAgents offline — handlers, agent-to-agent flows, and BNB Chain reads.
|
|
5
|
+
Project-URL: Homepage, https://github.com/pgalyen1987/uagent-testkit
|
|
6
|
+
Project-URL: Repository, https://github.com/pgalyen1987/uagent-testkit
|
|
7
|
+
Project-URL: Issues, https://github.com/pgalyen1987/uagent-testkit/issues
|
|
8
|
+
Author-email: Patrick Galyen <pgalyen87@gmail.com>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent-launch,agentverse,asi,bnb-chain,bsc,fetchai,pytest,testing,uagents,web3
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: Pytest
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: uagents>=0.22
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: aiohttp>=3.9; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
27
|
+
Requires-Dist: requests>=2.31; extra == 'test'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
1
30
|
# uagent-testkit
|
|
2
31
|
|
|
32
|
+
[](https://pypi.org/project/uagent-testkit/)
|
|
33
|
+
[](https://github.com/pgalyen1987/uagent-testkit/actions/workflows/ci.yml)
|
|
34
|
+
|
|
3
35
|
Test [Fetch.ai uAgents](https://github.com/fetchai/uAgents) without a network, a wallet, or a `sleep()`.
|
|
4
36
|
|
|
5
37
|
```python
|
|
@@ -21,14 +53,17 @@ resolution, a funded wallet on some paths, and `asyncio.sleep()` calls sprinkled
|
|
|
21
53
|
the test to wait for messages that may never arrive. That is slow, flaky, and awkward to
|
|
22
54
|
run in CI.
|
|
23
55
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
56
|
+
There is also a subtler problem. `Agent._handle_message` catches every exception a
|
|
57
|
+
handler raises and writes it to a log. That is the right call at runtime — an agent
|
|
58
|
+
processes messages from peers it does not control, and one malformed message must not
|
|
59
|
+
take the process down. But the same containment applies under pytest, and there is no
|
|
60
|
+
strict mode to turn it off, so a handler that crashes on every message still looks
|
|
61
|
+
like a green test.
|
|
27
62
|
|
|
28
63
|
`uagent-testkit` routes messages straight to the registered handler with a context that
|
|
29
64
|
records instead of transmitting. No sockets, no registration, no waiting.
|
|
30
65
|
|
|
31
|
-
- **Handler exceptions are raised
|
|
66
|
+
- **Handler exceptions are raised** rather than contained into a log.
|
|
32
67
|
- **Storage is in-memory**, so state doesn't leak between tests via the JSON file the
|
|
33
68
|
stock `KeyValueStore` writes to your working directory.
|
|
34
69
|
- **Reply contracts are enforced** — if `@on_message(replies=Pong)` doesn't send a
|
|
@@ -37,6 +72,8 @@ records instead of transmitting. No sockets, no registration, no waiting.
|
|
|
37
72
|
is testable without waiting an hour.
|
|
38
73
|
- **Multi-agent conversations replay deterministically**, with a loop guard instead of
|
|
39
74
|
a hung test.
|
|
75
|
+
- **Agents that read BNB Chain are testable offline** — `ChainDouble` answers BEP-20
|
|
76
|
+
and balance queries from state your test sets, and blocks any other outbound HTTP.
|
|
40
77
|
|
|
41
78
|
## Install
|
|
42
79
|
|
|
@@ -178,6 +215,49 @@ async def test_chat():
|
|
|
178
215
|
assert result.replies(ChatMessage)[0].text() == "here is what we have"
|
|
179
216
|
```
|
|
180
217
|
|
|
218
|
+
## Agents that read BNB Chain
|
|
219
|
+
|
|
220
|
+
The harness intercepts `ctx.send`, which covers agent-to-agent messaging. It does not
|
|
221
|
+
cover a handler that reaches out to an RPC endpoint — an agent reading a BEP-20 balance
|
|
222
|
+
still talks to the network during a test. `ChainDouble` closes that gap:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from uagent_testkit import ChainDouble, FET_BSC, harness
|
|
226
|
+
|
|
227
|
+
async def test_treasury_agent():
|
|
228
|
+
chain = ChainDouble()
|
|
229
|
+
chain.add_token(FET_BSC, symbol="FET", decimals=18,
|
|
230
|
+
balances={wallet: 5_500_000_000_000_000_000})
|
|
231
|
+
|
|
232
|
+
h = harness(treasury_agent)
|
|
233
|
+
with chain.install():
|
|
234
|
+
result = await h.deliver(CheckBalance(wallet=wallet))
|
|
235
|
+
|
|
236
|
+
assert result.reply(BalanceReport).amount == 5.5
|
|
237
|
+
chain.assert_called("eth_call")
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
It patches whichever of `requests`, `httpx` and `aiohttp` are importable, so it works
|
|
241
|
+
whether the agent uses web3.py or raw async HTTP, and it understands web3.py's
|
|
242
|
+
JSON-encoded `data=` body as well as `json=`.
|
|
243
|
+
|
|
244
|
+
Supported: `eth_call` (BEP-20 `balanceOf`, `symbol`, `name`, `decimals`, `totalSupply`),
|
|
245
|
+
`eth_getBalance`, `eth_getCode`, `eth_blockNumber`, `eth_chainId`, batched requests.
|
|
246
|
+
|
|
247
|
+
Two deliberate refusals, both of which turn a silent pass into a loud failure:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
# reading state the test never set up
|
|
251
|
+
with pytest.raises(UnstubbedCall):
|
|
252
|
+
... # rather than quietly returning zero
|
|
253
|
+
|
|
254
|
+
# any non-JSON-RPC HTTP from inside a handler
|
|
255
|
+
with pytest.raises(NetworkCallBlocked):
|
|
256
|
+
... # rather than hitting a real API in CI
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
There's also a `bsc` fixture that gives you an installed double directly.
|
|
260
|
+
|
|
181
261
|
## pytest fixtures
|
|
182
262
|
|
|
183
263
|
Installing the package registers two factory fixtures:
|
|
@@ -1,32 +1,8 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: uagent-testkit
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Test Fetch.ai uAgents without a network, a wallet, or a sleep().
|
|
5
|
-
Project-URL: Homepage, https://github.com/pgalyen1987/fetchai_harness
|
|
6
|
-
Project-URL: Repository, https://github.com/pgalyen1987/fetchai_harness
|
|
7
|
-
Project-URL: Issues, https://github.com/pgalyen1987/fetchai_harness/issues
|
|
8
|
-
Author-email: pgalyen1987 <pgalyen87@gmail.com>
|
|
9
|
-
License-Expression: Apache-2.0
|
|
10
|
-
License-File: LICENSE
|
|
11
|
-
Keywords: agentverse,asi,fetchai,pytest,testing,uagents
|
|
12
|
-
Classifier: Development Status :: 4 - Beta
|
|
13
|
-
Classifier: Framework :: Pytest
|
|
14
|
-
Classifier: Intended Audience :: Developers
|
|
15
|
-
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
-
Classifier: Topic :: Software Development :: Testing
|
|
21
|
-
Requires-Python: >=3.10
|
|
22
|
-
Requires-Dist: uagents>=0.22
|
|
23
|
-
Provides-Extra: test
|
|
24
|
-
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
25
|
-
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
26
|
-
Description-Content-Type: text/markdown
|
|
27
|
-
|
|
28
1
|
# uagent-testkit
|
|
29
2
|
|
|
3
|
+
[](https://pypi.org/project/uagent-testkit/)
|
|
4
|
+
[](https://github.com/pgalyen1987/uagent-testkit/actions/workflows/ci.yml)
|
|
5
|
+
|
|
30
6
|
Test [Fetch.ai uAgents](https://github.com/fetchai/uAgents) without a network, a wallet, or a `sleep()`.
|
|
31
7
|
|
|
32
8
|
```python
|
|
@@ -48,14 +24,17 @@ resolution, a funded wallet on some paths, and `asyncio.sleep()` calls sprinkled
|
|
|
48
24
|
the test to wait for messages that may never arrive. That is slow, flaky, and awkward to
|
|
49
25
|
run in CI.
|
|
50
26
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
27
|
+
There is also a subtler problem. `Agent._handle_message` catches every exception a
|
|
28
|
+
handler raises and writes it to a log. That is the right call at runtime — an agent
|
|
29
|
+
processes messages from peers it does not control, and one malformed message must not
|
|
30
|
+
take the process down. But the same containment applies under pytest, and there is no
|
|
31
|
+
strict mode to turn it off, so a handler that crashes on every message still looks
|
|
32
|
+
like a green test.
|
|
54
33
|
|
|
55
34
|
`uagent-testkit` routes messages straight to the registered handler with a context that
|
|
56
35
|
records instead of transmitting. No sockets, no registration, no waiting.
|
|
57
36
|
|
|
58
|
-
- **Handler exceptions are raised
|
|
37
|
+
- **Handler exceptions are raised** rather than contained into a log.
|
|
59
38
|
- **Storage is in-memory**, so state doesn't leak between tests via the JSON file the
|
|
60
39
|
stock `KeyValueStore` writes to your working directory.
|
|
61
40
|
- **Reply contracts are enforced** — if `@on_message(replies=Pong)` doesn't send a
|
|
@@ -64,6 +43,8 @@ records instead of transmitting. No sockets, no registration, no waiting.
|
|
|
64
43
|
is testable without waiting an hour.
|
|
65
44
|
- **Multi-agent conversations replay deterministically**, with a loop guard instead of
|
|
66
45
|
a hung test.
|
|
46
|
+
- **Agents that read BNB Chain are testable offline** — `ChainDouble` answers BEP-20
|
|
47
|
+
and balance queries from state your test sets, and blocks any other outbound HTTP.
|
|
67
48
|
|
|
68
49
|
## Install
|
|
69
50
|
|
|
@@ -205,6 +186,49 @@ async def test_chat():
|
|
|
205
186
|
assert result.replies(ChatMessage)[0].text() == "here is what we have"
|
|
206
187
|
```
|
|
207
188
|
|
|
189
|
+
## Agents that read BNB Chain
|
|
190
|
+
|
|
191
|
+
The harness intercepts `ctx.send`, which covers agent-to-agent messaging. It does not
|
|
192
|
+
cover a handler that reaches out to an RPC endpoint — an agent reading a BEP-20 balance
|
|
193
|
+
still talks to the network during a test. `ChainDouble` closes that gap:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from uagent_testkit import ChainDouble, FET_BSC, harness
|
|
197
|
+
|
|
198
|
+
async def test_treasury_agent():
|
|
199
|
+
chain = ChainDouble()
|
|
200
|
+
chain.add_token(FET_BSC, symbol="FET", decimals=18,
|
|
201
|
+
balances={wallet: 5_500_000_000_000_000_000})
|
|
202
|
+
|
|
203
|
+
h = harness(treasury_agent)
|
|
204
|
+
with chain.install():
|
|
205
|
+
result = await h.deliver(CheckBalance(wallet=wallet))
|
|
206
|
+
|
|
207
|
+
assert result.reply(BalanceReport).amount == 5.5
|
|
208
|
+
chain.assert_called("eth_call")
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
It patches whichever of `requests`, `httpx` and `aiohttp` are importable, so it works
|
|
212
|
+
whether the agent uses web3.py or raw async HTTP, and it understands web3.py's
|
|
213
|
+
JSON-encoded `data=` body as well as `json=`.
|
|
214
|
+
|
|
215
|
+
Supported: `eth_call` (BEP-20 `balanceOf`, `symbol`, `name`, `decimals`, `totalSupply`),
|
|
216
|
+
`eth_getBalance`, `eth_getCode`, `eth_blockNumber`, `eth_chainId`, batched requests.
|
|
217
|
+
|
|
218
|
+
Two deliberate refusals, both of which turn a silent pass into a loud failure:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
# reading state the test never set up
|
|
222
|
+
with pytest.raises(UnstubbedCall):
|
|
223
|
+
... # rather than quietly returning zero
|
|
224
|
+
|
|
225
|
+
# any non-JSON-RPC HTTP from inside a handler
|
|
226
|
+
with pytest.raises(NetworkCallBlocked):
|
|
227
|
+
... # rather than hitting a real API in CI
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
There's also a `bsc` fixture that gives you an installed double directly.
|
|
231
|
+
|
|
208
232
|
## pytest fixtures
|
|
209
233
|
|
|
210
234
|
Installing the package registers two factory fixtures:
|
|
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "uagent-testkit"
|
|
7
|
-
version = "0.1.
|
|
8
|
-
description = "Test Fetch.ai uAgents
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Test Fetch.ai uAgents offline — handlers, agent-to-agent flows, and BNB Chain reads."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
|
-
keywords = ["fetchai", "uagents", "agentverse", "
|
|
13
|
-
authors = [{ name = "
|
|
12
|
+
keywords = ["fetchai", "uagents", "agentverse", "asi", "agent-launch", "bnb-chain", "bsc", "web3", "testing", "pytest"]
|
|
13
|
+
authors = [{ name = "Patrick Galyen", email = "pgalyen87@gmail.com" }]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 4 - Beta",
|
|
16
16
|
"Framework :: Pytest",
|
|
@@ -31,12 +31,15 @@ dependencies = [
|
|
|
31
31
|
test = [
|
|
32
32
|
"pytest>=8.0",
|
|
33
33
|
"pytest-asyncio>=0.24",
|
|
34
|
+
# exercised by the ChainDouble tests, which drive real HTTP clients
|
|
35
|
+
"requests>=2.31",
|
|
36
|
+
"aiohttp>=3.9",
|
|
34
37
|
]
|
|
35
38
|
|
|
36
39
|
[project.urls]
|
|
37
|
-
Homepage = "https://github.com/pgalyen1987/
|
|
38
|
-
Repository = "https://github.com/pgalyen1987/
|
|
39
|
-
Issues = "https://github.com/pgalyen1987/
|
|
40
|
+
Homepage = "https://github.com/pgalyen1987/uagent-testkit"
|
|
41
|
+
Repository = "https://github.com/pgalyen1987/uagent-testkit"
|
|
42
|
+
Issues = "https://github.com/pgalyen1987/uagent-testkit/issues"
|
|
40
43
|
|
|
41
44
|
[project.entry-points.pytest11]
|
|
42
45
|
uagent_testkit = "uagent_testkit.plugin"
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Agents that read BNB Chain, tested offline.
|
|
2
|
+
|
|
3
|
+
These drive real HTTP clients (requests / aiohttp) so the patching is exercised the
|
|
4
|
+
way an actual agent would exercise it, not through a hand-rolled shim.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from uagents import Agent, Context, Model
|
|
9
|
+
|
|
10
|
+
from uagent_testkit import (
|
|
11
|
+
FET_BSC,
|
|
12
|
+
ChainDouble,
|
|
13
|
+
NetworkCallBlocked,
|
|
14
|
+
UnstubbedCall,
|
|
15
|
+
harness,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
RPC = "https://bsc-dataseed.binance.org/"
|
|
19
|
+
WALLET = "0x8E57BFDE053dBb6862991759c19affC5F383d5D0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _rpc(method, params):
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
return requests.Session().request(
|
|
26
|
+
"POST", RPC, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
|
|
27
|
+
).json()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CheckBalance(Model):
|
|
31
|
+
wallet: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BalanceReport(Model):
|
|
35
|
+
symbol: str
|
|
36
|
+
amount: float
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_treasury_agent(seed: str) -> Agent:
|
|
40
|
+
"""An agent whose whole job is reading a BEP-20 balance off BNB Chain."""
|
|
41
|
+
agent = Agent(name="treasury", seed=seed)
|
|
42
|
+
|
|
43
|
+
@agent.on_message(model=CheckBalance, replies=BalanceReport)
|
|
44
|
+
async def on_check(ctx: Context, sender: str, msg: CheckBalance):
|
|
45
|
+
import requests
|
|
46
|
+
|
|
47
|
+
session = requests.Session()
|
|
48
|
+
|
|
49
|
+
def call(data):
|
|
50
|
+
return session.request(
|
|
51
|
+
"POST",
|
|
52
|
+
RPC,
|
|
53
|
+
json={
|
|
54
|
+
"jsonrpc": "2.0",
|
|
55
|
+
"id": 1,
|
|
56
|
+
"method": "eth_call",
|
|
57
|
+
"params": [{"to": FET_BSC, "data": data}, "latest"],
|
|
58
|
+
},
|
|
59
|
+
).json()["result"]
|
|
60
|
+
|
|
61
|
+
raw = int(call("0x70a08231" + "0" * 24 + msg.wallet[2:].lower()), 16)
|
|
62
|
+
decimals = int(call("0x313ce567"), 16)
|
|
63
|
+
symbol_hex = call("0x95d89b41")
|
|
64
|
+
length = int(symbol_hex[2 + 64 : 2 + 128], 16)
|
|
65
|
+
symbol = bytes.fromhex(symbol_hex[2 + 128 : 2 + 128 + length * 2]).decode()
|
|
66
|
+
|
|
67
|
+
ctx.storage.set("last_wallet", msg.wallet)
|
|
68
|
+
await ctx.send(
|
|
69
|
+
sender, BalanceReport(symbol=symbol, amount=raw / 10**decimals)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return agent
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_agent_reads_a_bep20_balance_offline():
|
|
76
|
+
chain = ChainDouble()
|
|
77
|
+
chain.add_token(
|
|
78
|
+
FET_BSC, symbol="FET", decimals=18, balances={WALLET: 5_500_000_000_000_000_000}
|
|
79
|
+
)
|
|
80
|
+
h = harness(build_treasury_agent("treasury seed one"))
|
|
81
|
+
|
|
82
|
+
with chain.install():
|
|
83
|
+
result = await h.deliver(CheckBalance(wallet=WALLET))
|
|
84
|
+
|
|
85
|
+
report = result.reply(BalanceReport)
|
|
86
|
+
assert report.symbol == "FET"
|
|
87
|
+
assert report.amount == 5.5
|
|
88
|
+
chain.assert_called("eth_call")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def test_native_bnb_balance():
|
|
92
|
+
chain = ChainDouble().set_balance(WALLET, 2 * 10**18)
|
|
93
|
+
|
|
94
|
+
with chain.install():
|
|
95
|
+
got = _rpc("eth_getBalance", [WALLET, "latest"])
|
|
96
|
+
|
|
97
|
+
assert int(got["result"], 16) == 2 * 10**18
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def test_chain_metadata():
|
|
101
|
+
chain = ChainDouble().set_block_number(41_234_567)
|
|
102
|
+
|
|
103
|
+
with chain.install():
|
|
104
|
+
assert int(_rpc("eth_chainId", [])["result"], 16) == 56
|
|
105
|
+
assert int(_rpc("eth_blockNumber", [])["result"], 16) == 41_234_567
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def test_unstubbed_balance_raises_rather_than_returning_zero():
|
|
109
|
+
"""A silent zero would let a test pass on data nobody set up."""
|
|
110
|
+
chain = ChainDouble()
|
|
111
|
+
|
|
112
|
+
with chain.install():
|
|
113
|
+
with pytest.raises(UnstubbedCall, match="never set"):
|
|
114
|
+
_rpc("eth_getBalance", [WALLET, "latest"])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def test_unregistered_contract_raises():
|
|
118
|
+
chain = ChainDouble()
|
|
119
|
+
|
|
120
|
+
with chain.install():
|
|
121
|
+
with pytest.raises(UnstubbedCall, match="never registered"):
|
|
122
|
+
_rpc("eth_call", [{"to": FET_BSC, "data": "0x313ce567"}, "latest"])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def test_unstubbed_holder_balance_raises():
|
|
126
|
+
chain = ChainDouble()
|
|
127
|
+
chain.add_token(FET_BSC, symbol="FET", balances={})
|
|
128
|
+
|
|
129
|
+
with chain.install():
|
|
130
|
+
with pytest.raises(UnstubbedCall, match="balance of"):
|
|
131
|
+
_rpc(
|
|
132
|
+
"eth_call",
|
|
133
|
+
[
|
|
134
|
+
{"to": FET_BSC, "data": "0x70a08231" + "0" * 24 + WALLET[2:].lower()},
|
|
135
|
+
"latest",
|
|
136
|
+
],
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def test_non_rpc_http_is_blocked():
|
|
141
|
+
"""An agent calling some other API mid-handler should fail the test, not the CI run."""
|
|
142
|
+
import requests
|
|
143
|
+
|
|
144
|
+
chain = ChainDouble()
|
|
145
|
+
|
|
146
|
+
with chain.install():
|
|
147
|
+
with pytest.raises(NetworkCallBlocked, match="non-JSON-RPC"):
|
|
148
|
+
requests.Session().request("GET", "https://example.com/prices")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def test_web3_style_data_body_is_understood():
|
|
152
|
+
"""web3.py posts a JSON-encoded body via data=, not json=."""
|
|
153
|
+
import json
|
|
154
|
+
|
|
155
|
+
import requests
|
|
156
|
+
|
|
157
|
+
chain = ChainDouble().set_balance(WALLET, 7)
|
|
158
|
+
|
|
159
|
+
with chain.install():
|
|
160
|
+
resp = requests.Session().request(
|
|
161
|
+
"POST",
|
|
162
|
+
RPC,
|
|
163
|
+
data=json.dumps(
|
|
164
|
+
{"jsonrpc": "2.0", "id": 1, "method": "eth_getBalance",
|
|
165
|
+
"params": [WALLET, "latest"]}
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
assert int(resp.json()["result"], 16) == 7
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def test_batch_requests():
|
|
173
|
+
chain = ChainDouble().set_balance(WALLET, 3)
|
|
174
|
+
|
|
175
|
+
with chain.install():
|
|
176
|
+
import requests
|
|
177
|
+
|
|
178
|
+
resp = requests.Session().request(
|
|
179
|
+
"POST",
|
|
180
|
+
RPC,
|
|
181
|
+
json=[
|
|
182
|
+
{"jsonrpc": "2.0", "id": 1, "method": "eth_chainId", "params": []},
|
|
183
|
+
{"jsonrpc": "2.0", "id": 2, "method": "eth_getBalance",
|
|
184
|
+
"params": [WALLET, "latest"]},
|
|
185
|
+
],
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
body = resp.json()
|
|
189
|
+
assert len(body) == 2
|
|
190
|
+
assert int(body[1]["result"], 16) == 3
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def test_aiohttp_client_is_patched():
|
|
194
|
+
import aiohttp
|
|
195
|
+
|
|
196
|
+
chain = ChainDouble().set_balance(WALLET, 11)
|
|
197
|
+
|
|
198
|
+
with chain.install():
|
|
199
|
+
async with aiohttp.ClientSession() as session:
|
|
200
|
+
async with session.post(
|
|
201
|
+
RPC,
|
|
202
|
+
json={"jsonrpc": "2.0", "id": 1, "method": "eth_getBalance",
|
|
203
|
+
"params": [WALLET, "latest"]},
|
|
204
|
+
) as resp:
|
|
205
|
+
body = await resp.json()
|
|
206
|
+
|
|
207
|
+
assert int(body["result"], 16) == 11
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def test_patches_are_removed_on_exit():
|
|
211
|
+
"""Leaving the context must restore the real client, or later tests get poisoned."""
|
|
212
|
+
import requests
|
|
213
|
+
|
|
214
|
+
original = requests.Session.request
|
|
215
|
+
with ChainDouble().install():
|
|
216
|
+
assert requests.Session.request is not original
|
|
217
|
+
assert requests.Session.request is original
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def test_assert_no_calls():
|
|
221
|
+
chain = ChainDouble()
|
|
222
|
+
with chain.install():
|
|
223
|
+
pass
|
|
224
|
+
chain.assert_no_calls()
|
|
225
|
+
|
|
226
|
+
with pytest.raises(AssertionError, match="expected a eth_call call"):
|
|
227
|
+
chain.assert_called("eth_call")
|
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
from ._loop import ensure_event_loop
|
|
11
11
|
from ._recording import Sent
|
|
12
12
|
from ._storage import InMemoryStore
|
|
13
|
+
from .chain import (
|
|
14
|
+
FET_BSC,
|
|
15
|
+
ChainDouble,
|
|
16
|
+
NetworkCallBlocked,
|
|
17
|
+
UnstubbedCall,
|
|
18
|
+
)
|
|
13
19
|
from .harness import (
|
|
14
20
|
ANY,
|
|
15
21
|
DEFAULT_SENDER,
|
|
@@ -31,18 +37,22 @@ __all__ = [
|
|
|
31
37
|
"ANY",
|
|
32
38
|
"AgentHarness",
|
|
33
39
|
"AgentNetwork",
|
|
40
|
+
"ChainDouble",
|
|
34
41
|
"ConversationTooLong",
|
|
35
42
|
"DEFAULT_SENDER",
|
|
36
43
|
"Delivery",
|
|
37
44
|
"Exchange",
|
|
45
|
+
"FET_BSC",
|
|
38
46
|
"HandlerNotFound",
|
|
39
47
|
"InMemoryStore",
|
|
48
|
+
"NetworkCallBlocked",
|
|
40
49
|
"Sent",
|
|
41
50
|
"Transcript",
|
|
51
|
+
"UnstubbedCall",
|
|
42
52
|
"UnverifiedSender",
|
|
43
53
|
"ensure_event_loop",
|
|
44
54
|
"harness",
|
|
45
55
|
"user_sender",
|
|
46
56
|
]
|
|
47
57
|
|
|
48
|
-
__version__ = "0.1.
|
|
58
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""An offline stand-in for BNB Chain, for agents that read on-chain state.
|
|
2
|
+
|
|
3
|
+
The rest of this package intercepts `ctx.send`, which covers agent-to-agent messaging.
|
|
4
|
+
It does not cover a handler that reaches out to an RPC endpoint — an agent that reads a
|
|
5
|
+
BEP-20 balance, a token's symbol, or a wallet's BNB balance still talks to the network
|
|
6
|
+
during a test. That makes those tests slow, rate-limited, dependent on mainnet state
|
|
7
|
+
that changes underneath them, and impossible to run in CI offline.
|
|
8
|
+
|
|
9
|
+
`ChainDouble` blocks outbound HTTP from handlers and answers JSON-RPC from state the
|
|
10
|
+
test sets up, so an agent that reads BNB Chain becomes as testable as one that doesn't:
|
|
11
|
+
|
|
12
|
+
chain = ChainDouble()
|
|
13
|
+
chain.add_token(FET, symbol="FET", decimals=18, balances={wallet: 5 * 10**18})
|
|
14
|
+
|
|
15
|
+
with chain.install():
|
|
16
|
+
result = await h.deliver(CheckBalance(wallet=wallet))
|
|
17
|
+
|
|
18
|
+
assert result.reply(BalanceReport).amount == 5.0
|
|
19
|
+
chain.assert_called("eth_call")
|
|
20
|
+
|
|
21
|
+
Anything the double has not been told about raises rather than silently returning zero,
|
|
22
|
+
so a test cannot pass on data that was never stubbed.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from contextlib import ExitStack, contextmanager
|
|
28
|
+
from typing import Any, Iterator
|
|
29
|
+
|
|
30
|
+
#: BEP-20 / ERC-20 selectors. Stable across every token that implements the standard.
|
|
31
|
+
SELECTOR_BALANCE_OF = "70a08231"
|
|
32
|
+
SELECTOR_SYMBOL = "95d89b41"
|
|
33
|
+
SELECTOR_DECIMALS = "313ce567"
|
|
34
|
+
SELECTOR_TOTAL_SUPPLY = "18160ddd"
|
|
35
|
+
SELECTOR_NAME = "06fdde03"
|
|
36
|
+
|
|
37
|
+
#: FET on BNB Chain — the asset Agent Launch grants are paid in.
|
|
38
|
+
FET_BSC = "0x031b41e504677879370e9dbcf937283a8691fa7f"
|
|
39
|
+
|
|
40
|
+
BSC_CHAIN_ID = 56
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NetworkCallBlocked(AssertionError):
|
|
44
|
+
"""Raised when a handler tries to reach the network during a test."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UnstubbedCall(AssertionError):
|
|
48
|
+
"""Raised when a handler asks the chain for something the test never set up."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _norm(address: str) -> str:
|
|
52
|
+
return address.lower()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _enc_uint(value: int) -> str:
|
|
56
|
+
return f"{value:064x}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _enc_string(value: str) -> str:
|
|
60
|
+
raw = value.encode()
|
|
61
|
+
padded = raw + b"\x00" * ((32 - len(raw) % 32) % 32)
|
|
62
|
+
return _enc_uint(32) + _enc_uint(len(raw)) + padded.hex()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _Token:
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
symbol: str,
|
|
69
|
+
decimals: int,
|
|
70
|
+
total_supply: int,
|
|
71
|
+
balances: dict[str, int],
|
|
72
|
+
name: str | None,
|
|
73
|
+
):
|
|
74
|
+
self.symbol = symbol
|
|
75
|
+
self.decimals = decimals
|
|
76
|
+
self.total_supply = total_supply
|
|
77
|
+
self.balances = {_norm(k): v for k, v in balances.items()}
|
|
78
|
+
self.name = name if name is not None else symbol
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ChainDouble:
|
|
82
|
+
"""A stubbable JSON-RPC endpoint standing in for BNB Chain."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, *, chain_id: int = BSC_CHAIN_ID, block_number: int = 40_000_000):
|
|
85
|
+
self.chain_id = chain_id
|
|
86
|
+
self.block_number = block_number
|
|
87
|
+
self._balances: dict[str, int] = {}
|
|
88
|
+
self._tokens: dict[str, _Token] = {}
|
|
89
|
+
self._code: dict[str, str] = {}
|
|
90
|
+
#: Every JSON-RPC method the agent asked for, in order.
|
|
91
|
+
self.calls: list[tuple[str, Any]] = []
|
|
92
|
+
|
|
93
|
+
# -- setting up state ------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def set_balance(self, address: str, wei: int) -> "ChainDouble":
|
|
96
|
+
"""Set a wallet's native BNB balance, in wei."""
|
|
97
|
+
self._balances[_norm(address)] = wei
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def add_token(
|
|
101
|
+
self,
|
|
102
|
+
address: str,
|
|
103
|
+
*,
|
|
104
|
+
symbol: str,
|
|
105
|
+
decimals: int = 18,
|
|
106
|
+
total_supply: int = 0,
|
|
107
|
+
balances: dict[str, int] | None = None,
|
|
108
|
+
name: str | None = None,
|
|
109
|
+
) -> "ChainDouble":
|
|
110
|
+
"""Register a BEP-20 token and the balances it should report."""
|
|
111
|
+
self._tokens[_norm(address)] = _Token(
|
|
112
|
+
symbol, decimals, total_supply, balances or {}, name
|
|
113
|
+
)
|
|
114
|
+
self._code.setdefault(_norm(address), "0x60806040")
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def set_block_number(self, n: int) -> "ChainDouble":
|
|
118
|
+
self.block_number = n
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
# -- assertions ------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def assert_called(self, method: str) -> "ChainDouble":
|
|
124
|
+
if not any(m == method for m, _ in self.calls):
|
|
125
|
+
raise AssertionError(
|
|
126
|
+
f"expected a {method} call; got {[m for m, _ in self.calls] or 'none'}"
|
|
127
|
+
)
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def assert_no_calls(self) -> "ChainDouble":
|
|
131
|
+
if self.calls:
|
|
132
|
+
raise AssertionError(f"expected no chain access, got {self.calls}")
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
# -- JSON-RPC --------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def rpc(self, payload: Any) -> Any:
|
|
138
|
+
"""Answer a single JSON-RPC request or a batch of them."""
|
|
139
|
+
if isinstance(payload, list):
|
|
140
|
+
return [self._one(p) for p in payload]
|
|
141
|
+
return self._one(payload)
|
|
142
|
+
|
|
143
|
+
def _one(self, payload: dict) -> dict:
|
|
144
|
+
method = payload.get("method")
|
|
145
|
+
params = payload.get("params") or []
|
|
146
|
+
self.calls.append((method, params))
|
|
147
|
+
return {
|
|
148
|
+
"jsonrpc": "2.0",
|
|
149
|
+
"id": payload.get("id", 1),
|
|
150
|
+
"result": self._dispatch(method, params),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
def _dispatch(self, method: str, params: list) -> Any:
|
|
154
|
+
if method == "eth_chainId":
|
|
155
|
+
return hex(self.chain_id)
|
|
156
|
+
if method == "eth_blockNumber":
|
|
157
|
+
return hex(self.block_number)
|
|
158
|
+
if method == "net_version":
|
|
159
|
+
return str(self.chain_id)
|
|
160
|
+
if method == "eth_getBalance":
|
|
161
|
+
address = _norm(params[0])
|
|
162
|
+
if address not in self._balances:
|
|
163
|
+
raise UnstubbedCall(
|
|
164
|
+
f"handler read the BNB balance of {params[0]}, which the test never "
|
|
165
|
+
f"set. Call chain.set_balance({params[0]!r}, wei) first."
|
|
166
|
+
)
|
|
167
|
+
return hex(self._balances[address])
|
|
168
|
+
if method == "eth_getCode":
|
|
169
|
+
return self._code.get(_norm(params[0]), "0x")
|
|
170
|
+
if method == "eth_getTransactionCount":
|
|
171
|
+
return "0x0"
|
|
172
|
+
if method == "eth_call":
|
|
173
|
+
return self._eth_call(params[0])
|
|
174
|
+
raise UnstubbedCall(
|
|
175
|
+
f"handler called {method}, which ChainDouble does not implement. "
|
|
176
|
+
f"Supported: eth_call, eth_getBalance, eth_getCode, eth_blockNumber, "
|
|
177
|
+
f"eth_chainId, eth_getTransactionCount, net_version."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def _eth_call(self, call: dict) -> str:
|
|
181
|
+
to = _norm(call.get("to", ""))
|
|
182
|
+
data = (call.get("data") or call.get("input") or "").removeprefix("0x")
|
|
183
|
+
selector, args = data[:8], data[8:]
|
|
184
|
+
|
|
185
|
+
token = self._tokens.get(to)
|
|
186
|
+
if token is None:
|
|
187
|
+
raise UnstubbedCall(
|
|
188
|
+
f"handler called contract {call.get('to')}, which the test never "
|
|
189
|
+
f"registered. Call chain.add_token({call.get('to')!r}, symbol=...) first."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if selector == SELECTOR_BALANCE_OF:
|
|
193
|
+
holder = "0x" + args[24:64]
|
|
194
|
+
if holder not in token.balances:
|
|
195
|
+
raise UnstubbedCall(
|
|
196
|
+
f"handler read the {token.symbol} balance of {holder}, which the "
|
|
197
|
+
f"test never set. Add it to the balances= of add_token()."
|
|
198
|
+
)
|
|
199
|
+
return "0x" + _enc_uint(token.balances[holder])
|
|
200
|
+
if selector == SELECTOR_DECIMALS:
|
|
201
|
+
return "0x" + _enc_uint(token.decimals)
|
|
202
|
+
if selector == SELECTOR_TOTAL_SUPPLY:
|
|
203
|
+
return "0x" + _enc_uint(token.total_supply)
|
|
204
|
+
if selector == SELECTOR_SYMBOL:
|
|
205
|
+
return "0x" + _enc_string(token.symbol)
|
|
206
|
+
if selector == SELECTOR_NAME:
|
|
207
|
+
return "0x" + _enc_string(token.name)
|
|
208
|
+
raise UnstubbedCall(
|
|
209
|
+
f"handler called selector 0x{selector} on {call.get('to')}; ChainDouble "
|
|
210
|
+
f"implements balanceOf, symbol, name, decimals and totalSupply."
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# -- installing over the HTTP clients --------------------------------------
|
|
214
|
+
|
|
215
|
+
@contextmanager
|
|
216
|
+
def install(self) -> Iterator["ChainDouble"]:
|
|
217
|
+
"""Route HTTP from handlers here, and block anything that isn't JSON-RPC.
|
|
218
|
+
|
|
219
|
+
Patches whichever of requests / httpx / aiohttp are importable, so this works
|
|
220
|
+
whether the agent uses web3.py (requests) or raw async HTTP.
|
|
221
|
+
"""
|
|
222
|
+
with ExitStack() as stack:
|
|
223
|
+
for patch in (self._patch_requests, self._patch_httpx, self._patch_aiohttp):
|
|
224
|
+
ctx = patch()
|
|
225
|
+
if ctx is not None:
|
|
226
|
+
stack.enter_context(ctx)
|
|
227
|
+
yield self
|
|
228
|
+
|
|
229
|
+
def _payload_or_block(self, url: str, json_body: Any, data: Any = None) -> Any:
|
|
230
|
+
"""Resolve a request body to a JSON-RPC payload, or refuse it.
|
|
231
|
+
|
|
232
|
+
web3.py's HTTPProvider posts a JSON-encoded body via `data=` rather than
|
|
233
|
+
`json=`, so both have to be understood.
|
|
234
|
+
"""
|
|
235
|
+
payload = json_body
|
|
236
|
+
if payload is None and data is not None:
|
|
237
|
+
import json as _json
|
|
238
|
+
|
|
239
|
+
if isinstance(data, (bytes, bytearray)):
|
|
240
|
+
data = data.decode()
|
|
241
|
+
if isinstance(data, str):
|
|
242
|
+
try:
|
|
243
|
+
payload = _json.loads(data)
|
|
244
|
+
except ValueError:
|
|
245
|
+
payload = None
|
|
246
|
+
if payload is None:
|
|
247
|
+
raise NetworkCallBlocked(
|
|
248
|
+
f"handler made a non-JSON-RPC HTTP request to {url} during a test. "
|
|
249
|
+
f"Stub it, or assert the handler does not call out."
|
|
250
|
+
)
|
|
251
|
+
return self.rpc(payload)
|
|
252
|
+
|
|
253
|
+
def _patch_requests(self):
|
|
254
|
+
try:
|
|
255
|
+
import requests
|
|
256
|
+
except ImportError:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
double = self
|
|
260
|
+
|
|
261
|
+
class _Response:
|
|
262
|
+
def __init__(self, payload):
|
|
263
|
+
self._payload = payload
|
|
264
|
+
self.status_code = 200
|
|
265
|
+
self.reason = "OK"
|
|
266
|
+
self.headers = {"Content-Type": "application/json"}
|
|
267
|
+
|
|
268
|
+
def json(self):
|
|
269
|
+
return self._payload
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def content(self):
|
|
273
|
+
import json
|
|
274
|
+
|
|
275
|
+
return json.dumps(self._payload).encode()
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def text(self):
|
|
279
|
+
return self.content.decode()
|
|
280
|
+
|
|
281
|
+
def raise_for_status(self):
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
@contextmanager
|
|
285
|
+
def _ctx():
|
|
286
|
+
original = requests.Session.request
|
|
287
|
+
|
|
288
|
+
def patched(self, method, url, *args, json=None, data=None, **kwargs):
|
|
289
|
+
return _Response(double._payload_or_block(url, json, data))
|
|
290
|
+
|
|
291
|
+
requests.Session.request = patched
|
|
292
|
+
try:
|
|
293
|
+
yield
|
|
294
|
+
finally:
|
|
295
|
+
requests.Session.request = original
|
|
296
|
+
|
|
297
|
+
return _ctx()
|
|
298
|
+
|
|
299
|
+
def _patch_httpx(self):
|
|
300
|
+
try:
|
|
301
|
+
import httpx
|
|
302
|
+
except ImportError:
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
double = self
|
|
306
|
+
|
|
307
|
+
@contextmanager
|
|
308
|
+
def _ctx():
|
|
309
|
+
original = httpx.Client.request
|
|
310
|
+
original_async = httpx.AsyncClient.request
|
|
311
|
+
|
|
312
|
+
def _resp(url, json_body, data):
|
|
313
|
+
return httpx.Response(
|
|
314
|
+
200, json=double._payload_or_block(str(url), json_body, data)
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def patched(self, method, url, *args, json=None, content=None, **kwargs):
|
|
318
|
+
return _resp(url, json, content)
|
|
319
|
+
|
|
320
|
+
async def patched_async(
|
|
321
|
+
self, method, url, *args, json=None, content=None, **kwargs
|
|
322
|
+
):
|
|
323
|
+
return _resp(url, json, content)
|
|
324
|
+
|
|
325
|
+
httpx.Client.request = patched
|
|
326
|
+
httpx.AsyncClient.request = patched_async
|
|
327
|
+
try:
|
|
328
|
+
yield
|
|
329
|
+
finally:
|
|
330
|
+
httpx.Client.request = original
|
|
331
|
+
httpx.AsyncClient.request = original_async
|
|
332
|
+
|
|
333
|
+
return _ctx()
|
|
334
|
+
|
|
335
|
+
def _patch_aiohttp(self):
|
|
336
|
+
try:
|
|
337
|
+
import aiohttp
|
|
338
|
+
except ImportError:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
double = self
|
|
342
|
+
|
|
343
|
+
class _Response:
|
|
344
|
+
def __init__(self, payload):
|
|
345
|
+
self._payload = payload
|
|
346
|
+
self.status = 200
|
|
347
|
+
|
|
348
|
+
async def json(self, **_kwargs):
|
|
349
|
+
return self._payload
|
|
350
|
+
|
|
351
|
+
async def text(self):
|
|
352
|
+
import json
|
|
353
|
+
|
|
354
|
+
return json.dumps(self._payload)
|
|
355
|
+
|
|
356
|
+
def raise_for_status(self):
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
async def __aenter__(self):
|
|
360
|
+
return self
|
|
361
|
+
|
|
362
|
+
async def __aexit__(self, *exc):
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
async def release(self):
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
@contextmanager
|
|
369
|
+
def _ctx():
|
|
370
|
+
original = aiohttp.ClientSession._request
|
|
371
|
+
|
|
372
|
+
# _request is a coroutine function, and session.post() wraps its result
|
|
373
|
+
# in _RequestContextManager, which awaits it. It must stay awaitable.
|
|
374
|
+
async def patched(self, method, url, *args, json=None, data=None, **kwargs):
|
|
375
|
+
return _Response(double._payload_or_block(str(url), json, data))
|
|
376
|
+
|
|
377
|
+
aiohttp.ClientSession._request = patched
|
|
378
|
+
try:
|
|
379
|
+
yield
|
|
380
|
+
finally:
|
|
381
|
+
aiohttp.ClientSession._request = original
|
|
382
|
+
|
|
383
|
+
return _ctx()
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
7
|
from ._loop import ensure_event_loop
|
|
8
|
+
from .chain import ChainDouble
|
|
8
9
|
from .harness import AgentHarness, harness as _harness
|
|
9
10
|
from .network import AgentNetwork
|
|
10
11
|
|
|
@@ -43,6 +44,19 @@ def agent_harness():
|
|
|
43
44
|
return _factory
|
|
44
45
|
|
|
45
46
|
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def bsc():
|
|
49
|
+
"""An installed ChainDouble standing in for BNB Chain.
|
|
50
|
+
|
|
51
|
+
def test_reads_balance(bsc):
|
|
52
|
+
bsc.add_token(FET_BSC, symbol="FET", balances={wallet: 10**18})
|
|
53
|
+
...
|
|
54
|
+
"""
|
|
55
|
+
double = ChainDouble()
|
|
56
|
+
with double.install():
|
|
57
|
+
yield double
|
|
58
|
+
|
|
59
|
+
|
|
46
60
|
@pytest.fixture
|
|
47
61
|
def agent_network():
|
|
48
62
|
"""Factory fixture building an AgentNetwork from agents or harnesses.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|