pytonapi 2.1.0__tar.gz → 2.2.0__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.
Files changed (82) hide show
  1. {pytonapi-2.1.0/pytonapi.egg-info → pytonapi-2.2.0}/PKG-INFO +23 -6
  2. {pytonapi-2.1.0 → pytonapi-2.2.0}/README.md +11 -5
  3. {pytonapi-2.1.0 → pytonapi-2.2.0}/pyproject.toml +20 -3
  4. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/__meta__.py +1 -1
  5. pytonapi-2.2.0/pytonapi/streaming/__init__.py +35 -0
  6. pytonapi-2.2.0/pytonapi/streaming/base.py +492 -0
  7. pytonapi-2.2.0/pytonapi/streaming/models.py +196 -0
  8. pytonapi-2.2.0/pytonapi/streaming/sse.py +159 -0
  9. pytonapi-2.2.0/pytonapi/streaming/ws.py +282 -0
  10. {pytonapi-2.1.0 → pytonapi-2.2.0/pytonapi.egg-info}/PKG-INFO +23 -6
  11. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi.egg-info/SOURCES.txt +1 -1
  12. pytonapi-2.2.0/pytonapi.egg-info/requires.txt +13 -0
  13. pytonapi-2.1.0/pytonapi/streaming/__init__.py +0 -19
  14. pytonapi-2.1.0/pytonapi/streaming/client.py +0 -95
  15. pytonapi-2.1.0/pytonapi/streaming/models.py +0 -33
  16. pytonapi-2.1.0/pytonapi/streaming/sse.py +0 -254
  17. pytonapi-2.1.0/pytonapi/streaming/ws.py +0 -229
  18. pytonapi-2.1.0/pytonapi.egg-info/requires.txt +0 -2
  19. {pytonapi-2.1.0 → pytonapi-2.2.0}/LICENSE +0 -0
  20. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/__init__.py +0 -0
  21. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/cli.py +0 -0
  22. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/client.py +0 -0
  23. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/exceptions.py +1 -1
  24. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/py.typed +0 -0
  25. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/__init__.py +0 -0
  26. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/client.py +0 -0
  27. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/limiter.py +0 -0
  28. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/mixin.py +0 -0
  29. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/__init__.py +0 -0
  30. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/_enums.py +0 -0
  31. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/accounts.py +0 -0
  32. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/blockchain.py +0 -0
  33. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/connect.py +0 -0
  34. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/dns.py +0 -0
  35. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/emulation.py +0 -0
  36. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/events.py +0 -0
  37. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/extra_currency.py +0 -0
  38. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/gasless.py +0 -0
  39. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/jettons.py +0 -0
  40. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/lite_server.py +0 -0
  41. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/multisig.py +0 -0
  42. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/nft.py +0 -0
  43. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/purchases.py +0 -0
  44. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/rates.py +0 -0
  45. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/staking.py +0 -0
  46. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/storage.py +0 -0
  47. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/traces.py +0 -0
  48. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/utilities.py +0 -0
  49. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/models/wallet.py +0 -0
  50. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/__init__.py +0 -0
  51. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/_base.py +0 -0
  52. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/accounts.py +0 -0
  53. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/blockchain.py +0 -0
  54. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/connect.py +0 -0
  55. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/dns.py +0 -0
  56. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/emulation.py +0 -0
  57. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/events.py +0 -0
  58. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/extra_currency.py +0 -0
  59. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/gasless.py +0 -0
  60. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/jettons.py +0 -0
  61. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/lite_server.py +0 -0
  62. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/multisig.py +0 -0
  63. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/nft.py +0 -0
  64. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/purchases.py +0 -0
  65. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/rates.py +0 -0
  66. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/staking.py +0 -0
  67. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/storage.py +0 -0
  68. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/traces.py +0 -0
  69. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/utilities.py +0 -0
  70. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/resources/wallet.py +0 -0
  71. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/rest/rotator.py +0 -0
  72. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/types.py +0 -0
  73. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/utils.py +0 -0
  74. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/webhook/__init__.py +0 -0
  75. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/webhook/client.py +0 -0
  76. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/webhook/dispatcher.py +0 -0
  77. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi/webhook/models.py +0 -0
  78. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi.egg-info/dependency_links.txt +0 -0
  79. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi.egg-info/entry_points.txt +0 -0
  80. {pytonapi-2.1.0 → pytonapi-2.2.0}/pytonapi.egg-info/top_level.txt +0 -0
  81. {pytonapi-2.1.0 → pytonapi-2.2.0}/setup.cfg +0 -0
  82. {pytonapi-2.1.0 → pytonapi-2.2.0}/tests/test_utils.py +0 -0
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytonapi
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Python SDK for TONAPI — REST API, streaming, and webhooks for TON blockchain.
5
5
  Author: nessshon
6
6
  Maintainer: nessshon
7
7
  License-Expression: MIT
8
8
  Project-URL: Homepage, https://github.com/nessshon/tonapi/
9
9
  Project-URL: Examples, https://github.com/nessshon/tonapi/tree/main/examples/
10
+ Project-URL: Documentation, https://tonapi.ness.su/
10
11
  Keywords: AsyncIO,REST API,SDK,TON,TON blockchain,TONAPI,The Open Network,blockchain,crypto,streaming,webhooks
11
12
  Classifier: Development Status :: 4 - Beta
12
13
  Classifier: Environment :: Console
@@ -25,6 +26,16 @@ Description-Content-Type: text/markdown
25
26
  License-File: LICENSE
26
27
  Requires-Dist: aiohttp>=3.9.0
27
28
  Requires-Dist: pydantic<3.0,>=2.0
29
+ Provides-Extra: dev
30
+ Requires-Dist: environs>=11.0.0; extra == "dev"
31
+ Requires-Dist: fastapi>=0.115.0; extra == "dev"
32
+ Requires-Dist: jinja2>=3.1; extra == "dev"
33
+ Requires-Dist: mypy>=1.19.0; extra == "dev"
34
+ Requires-Dist: pyyaml>=6.0; extra == "dev"
35
+ Requires-Dist: pytest>=8.0; extra == "dev"
36
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
37
+ Requires-Dist: ruff>=0.8.0; extra == "dev"
38
+ Requires-Dist: uvicorn>=0.34.0; extra == "dev"
28
39
  Dynamic: license-file
29
40
 
30
41
  # 📦 TON API
@@ -80,9 +91,20 @@ pip install pytonapi
80
91
 
81
92
  - [Get account info](https://github.com/nessshon/tonapi/blob/main/examples/get_account_info.py)
82
93
  - [Get account transactions](https://github.com/nessshon/tonapi/blob/main/examples/get_account_transactions.py)
94
+ - [Get jetton info](https://github.com/nessshon/tonapi/blob/main/examples/get_jetton_info.py)
83
95
  - [Get NFTs by owner](https://github.com/nessshon/tonapi/blob/main/examples/get_nft_by_owner.py)
84
96
  - [Get NFTs by collection](https://github.com/nessshon/tonapi/blob/main/examples/get_nft_by_collection.py)
85
97
 
98
+ **Emulation & Sending**
99
+
100
+ - [Send message](https://github.com/nessshon/tonapi/blob/main/examples/send_message.py)
101
+ - [Emulate message](https://github.com/nessshon/tonapi/blob/main/examples/emulate_message.py)
102
+
103
+ **Transfers** (requires [tonutils](https://github.com/nessshon/tonutils))
104
+
105
+ - [Transfer TON](https://github.com/nessshon/tonapi/blob/main/examples/transfer_ton.py)
106
+ - [Gasless transfer](https://github.com/nessshon/tonapi/blob/main/examples/transfer_gasless.py)
107
+
86
108
  **Streaming** (SSE & WebSocket)
87
109
 
88
110
  - [SSE subscriptions](https://github.com/nessshon/tonapi/blob/main/examples/streaming_sse.py)
@@ -93,11 +115,6 @@ pip install pytonapi
93
115
  - [FastAPI webhook server](https://github.com/nessshon/tonapi/blob/main/examples/webhook_fastapi.py)
94
116
  - [aiohttp webhook server](https://github.com/nessshon/tonapi/blob/main/examples/webhook_aiohttp.py)
95
117
 
96
- **Transfers** (requires [tonutils](https://github.com/nessshon/tonutils))
97
-
98
- - [Transfer TON](https://github.com/nessshon/tonapi/blob/main/examples/transfer_ton.py)
99
- - [Gasless transfer](https://github.com/nessshon/tonapi/blob/main/examples/transfer_gasless.py)
100
-
101
118
  ## License
102
119
 
103
120
  This repository is distributed under the [MIT License](https://github.com/nessshon/tonapi/blob/main/LICENSE).
@@ -51,9 +51,20 @@ pip install pytonapi
51
51
 
52
52
  - [Get account info](https://github.com/nessshon/tonapi/blob/main/examples/get_account_info.py)
53
53
  - [Get account transactions](https://github.com/nessshon/tonapi/blob/main/examples/get_account_transactions.py)
54
+ - [Get jetton info](https://github.com/nessshon/tonapi/blob/main/examples/get_jetton_info.py)
54
55
  - [Get NFTs by owner](https://github.com/nessshon/tonapi/blob/main/examples/get_nft_by_owner.py)
55
56
  - [Get NFTs by collection](https://github.com/nessshon/tonapi/blob/main/examples/get_nft_by_collection.py)
56
57
 
58
+ **Emulation & Sending**
59
+
60
+ - [Send message](https://github.com/nessshon/tonapi/blob/main/examples/send_message.py)
61
+ - [Emulate message](https://github.com/nessshon/tonapi/blob/main/examples/emulate_message.py)
62
+
63
+ **Transfers** (requires [tonutils](https://github.com/nessshon/tonutils))
64
+
65
+ - [Transfer TON](https://github.com/nessshon/tonapi/blob/main/examples/transfer_ton.py)
66
+ - [Gasless transfer](https://github.com/nessshon/tonapi/blob/main/examples/transfer_gasless.py)
67
+
57
68
  **Streaming** (SSE & WebSocket)
58
69
 
59
70
  - [SSE subscriptions](https://github.com/nessshon/tonapi/blob/main/examples/streaming_sse.py)
@@ -64,11 +75,6 @@ pip install pytonapi
64
75
  - [FastAPI webhook server](https://github.com/nessshon/tonapi/blob/main/examples/webhook_fastapi.py)
65
76
  - [aiohttp webhook server](https://github.com/nessshon/tonapi/blob/main/examples/webhook_aiohttp.py)
66
77
 
67
- **Transfers** (requires [tonutils](https://github.com/nessshon/tonutils))
68
-
69
- - [Transfer TON](https://github.com/nessshon/tonapi/blob/main/examples/transfer_ton.py)
70
- - [Gasless transfer](https://github.com/nessshon/tonapi/blob/main/examples/transfer_gasless.py)
71
-
72
78
  ## License
73
79
 
74
80
  This repository is distributed under the [MIT License](https://github.com/nessshon/tonapi/blob/main/LICENSE).
@@ -53,6 +53,20 @@ pytonapi = "pytonapi.cli:main"
53
53
  [project.urls]
54
54
  Homepage = "https://github.com/nessshon/tonapi/"
55
55
  Examples = "https://github.com/nessshon/tonapi/tree/main/examples/"
56
+ Documentation = "https://tonapi.ness.su/"
57
+
58
+ [project.optional-dependencies]
59
+ dev = [
60
+ "environs>=11.0.0",
61
+ "fastapi>=0.115.0",
62
+ "jinja2>=3.1",
63
+ "mypy>=1.19.0",
64
+ "pyyaml>=6.0",
65
+ "pytest>=8.0",
66
+ "pytest-asyncio>=0.24",
67
+ "ruff>=0.8.0",
68
+ "uvicorn>=0.34.0",
69
+ ]
56
70
 
57
71
  [tool.setuptools.packages.find]
58
72
  include = ["pytonapi", "pytonapi.*"]
@@ -128,10 +142,10 @@ ignore = [
128
142
  [tool.ruff.lint.per-file-ignores]
129
143
  "__init__.py" = ["F401"]
130
144
  "pytonapi/rest/models/*" = ["D101"]
131
- "tests/*" = ["D101", "D102", "T20"]
132
- "tests/rest/fixtures.py" = ["E501"]
133
145
  "codegen/*" = ["T20"]
134
146
  "examples/*" = ["D", "T20", "RUF006", "RUF059"]
147
+ "tests/*" = ["D101", "D102", "D103", "T20"]
148
+ "tests/rest/fixtures.py" = ["E501"]
135
149
 
136
150
  [tool.ruff.lint.isort]
137
151
  known-first-party = ["pytonapi"]
@@ -158,4 +172,7 @@ enable_error_code = [
158
172
  [[tool.mypy.overrides]]
159
173
  module = "tests.*"
160
174
  disallow_untyped_defs = false
161
- disallow_incomplete_defs = false
175
+ disallow_incomplete_defs = false
176
+
177
+ [tool.pytest.ini_options]
178
+ asyncio_mode = "auto"
@@ -3,6 +3,6 @@
3
3
  # This source code is licensed under the MIT License found in the
4
4
  # LICENSE file in the root directory of this source tree.
5
5
 
6
- __version__ = "2.1.0"
6
+ __version__ = "2.2.0"
7
7
  __author__ = "nessshon"
8
8
  __url__ = "https://github.com/nessshon/tonapi"
@@ -0,0 +1,35 @@
1
+ from pytonapi.streaming.models import (
2
+ AccountState,
3
+ AccountStateNotification,
4
+ ActionsNotification,
5
+ ActionType,
6
+ ConnectionState,
7
+ EventType,
8
+ Finality,
9
+ JettonsNotification,
10
+ JettonWallet,
11
+ StreamNotification,
12
+ TraceInvalidatedNotification,
13
+ TraceNotification,
14
+ TransactionsNotification,
15
+ )
16
+ from pytonapi.streaming.sse import TonapiSSE
17
+ from pytonapi.streaming.ws import TonapiWebSocket
18
+
19
+ __all__ = [
20
+ "AccountState",
21
+ "AccountStateNotification",
22
+ "ActionType",
23
+ "ActionsNotification",
24
+ "ConnectionState",
25
+ "EventType",
26
+ "Finality",
27
+ "JettonWallet",
28
+ "JettonsNotification",
29
+ "StreamNotification",
30
+ "TonapiSSE",
31
+ "TonapiWebSocket",
32
+ "TraceInvalidatedNotification",
33
+ "TraceNotification",
34
+ "TransactionsNotification",
35
+ ]
@@ -0,0 +1,492 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import asyncio
5
+ import inspect
6
+ import typing as t
7
+ from dataclasses import dataclass
8
+
9
+ import aiohttp
10
+
11
+ from pytonapi.client import BaseClient
12
+ from pytonapi.exceptions import (
13
+ STREAMING_RECOVERABLE,
14
+ TONAPIConnectionLostError,
15
+ TONAPIError,
16
+ )
17
+ from pytonapi.streaming.models import (
18
+ NOTIFICATION_MODEL_MAP,
19
+ ActionsNotification,
20
+ ConnectionState,
21
+ EventType,
22
+ Finality,
23
+ StreamNotification,
24
+ _FinalityMixin,
25
+ )
26
+ from pytonapi.types import (
27
+ DEFAULT_RECONNECT_POLICY,
28
+ NETWORK_BASE_URLS,
29
+ Network,
30
+ ReconnectPolicy,
31
+ )
32
+
33
+ _F = t.TypeVar("_F", bound=t.Callable[..., t.Any])
34
+
35
+ _FINALITY_ORDER: t.Final[dict[str, int]] = {
36
+ "pending": 0,
37
+ "confirmed": 1,
38
+ "finalized": 2,
39
+ }
40
+
41
+ _SUBSCRIBABLE: t.Final[frozenset[str]] = frozenset(
42
+ {
43
+ EventType.TRANSACTIONS,
44
+ EventType.ACTIONS,
45
+ EventType.TRACE,
46
+ EventType.ACCOUNT_STATE_CHANGE,
47
+ EventType.JETTONS_CHANGE,
48
+ }
49
+ )
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class _Handler:
54
+ callback: t.Callable[..., t.Any]
55
+ min_finality: Finality | None = None
56
+ action_types: list[str] | None = None
57
+
58
+
59
+ class StreamingBase(BaseClient, abc.ABC):
60
+ """Abstract base for streaming transports (SSE / WebSocket)."""
61
+
62
+ def __init__(
63
+ self,
64
+ api_key: str,
65
+ network: Network,
66
+ *,
67
+ base_url: str | None = None,
68
+ session: aiohttp.ClientSession | None = None,
69
+ headers: dict[str, str] | None = None,
70
+ reconnect_policy: ReconnectPolicy | None = None,
71
+ on_state_change: t.Callable[[ConnectionState], t.Any] | None = None,
72
+ ) -> None:
73
+ super().__init__(
74
+ api_key=api_key,
75
+ base_url=base_url or NETWORK_BASE_URLS[network],
76
+ session=session,
77
+ headers=headers,
78
+ timeout=0.0,
79
+ retry_policy=None,
80
+ )
81
+ self._reconnect_policy = reconnect_policy or DEFAULT_RECONNECT_POLICY
82
+ self._on_state_change = on_state_change
83
+ self._handlers: dict[str, list[_Handler]] = {}
84
+ self._stop: asyncio.Event | None = None
85
+ self._state: ConnectionState = ConnectionState.IDLE
86
+ self._subscribed_event = asyncio.Event()
87
+
88
+ @property
89
+ def state(self) -> ConnectionState:
90
+ """Current connection state."""
91
+ return self._state
92
+
93
+ @property
94
+ def is_subscribed(self) -> bool:
95
+ """``True`` if currently subscribed and receiving notifications."""
96
+ return self._state == ConnectionState.SUBSCRIBED
97
+
98
+ @property
99
+ def is_connecting(self) -> bool:
100
+ """``True`` if establishing initial connection."""
101
+ return self._state == ConnectionState.CONNECTING
102
+
103
+ @property
104
+ def is_reconnecting(self) -> bool:
105
+ """``True`` if reconnecting after a connection loss."""
106
+ return self._state == ConnectionState.RECONNECTING
107
+
108
+ async def wait_subscribed(self, timeout: float | None = None) -> None:
109
+ """Wait until the transport reaches ``SUBSCRIBED`` state.
110
+
111
+ :param timeout: Maximum seconds to wait, or ``None`` for no limit.
112
+ :raises asyncio.TimeoutError: If *timeout* is reached before subscribing.
113
+ """
114
+ if self._state == ConnectionState.SUBSCRIBED:
115
+ return
116
+ await asyncio.wait_for(self._subscribed_event.wait(), timeout=timeout)
117
+
118
+ async def _set_state(self, new: ConnectionState) -> None:
119
+ if self._state == new:
120
+ return
121
+ if self._state == ConnectionState.SUBSCRIBED:
122
+ self._subscribed_event.clear()
123
+ self._state = new
124
+ if new == ConnectionState.SUBSCRIBED:
125
+ self._subscribed_event.set()
126
+ if self._on_state_change is not None:
127
+ result = self._on_state_change(new)
128
+ if inspect.isawaitable(result):
129
+ await result
130
+
131
+ @t.overload
132
+ def _register(
133
+ self,
134
+ notification_type: str,
135
+ callback: _F,
136
+ *,
137
+ min_finality: Finality | str | None = ...,
138
+ action_types: list[str] | None = ...,
139
+ ) -> _F: ...
140
+
141
+ @t.overload
142
+ def _register(
143
+ self,
144
+ notification_type: str,
145
+ callback: None = ...,
146
+ *,
147
+ min_finality: Finality | str | None = ...,
148
+ action_types: list[str] | None = ...,
149
+ ) -> t.Callable[[_F], _F]: ...
150
+
151
+ def _register(
152
+ self,
153
+ notification_type: str,
154
+ callback: _F | None = None,
155
+ *,
156
+ min_finality: Finality | str | None = None,
157
+ action_types: list[str] | None = None,
158
+ ) -> _F | t.Callable[[_F], _F]:
159
+ def decorator(fn: _F) -> _F:
160
+ parsed = Finality(min_finality) if isinstance(min_finality, str) else min_finality
161
+ self._handlers.setdefault(notification_type, []).append(
162
+ _Handler(
163
+ callback=fn,
164
+ min_finality=parsed,
165
+ action_types=(
166
+ [a.value if hasattr(a, "value") else a for a in action_types] if action_types else None
167
+ ),
168
+ ),
169
+ )
170
+ return fn
171
+
172
+ return decorator(callback) if callback is not None else decorator
173
+
174
+ @t.overload
175
+ def on_transactions(self, callback: _F, *, min_finality: Finality | str | None = ...) -> _F: ...
176
+
177
+ @t.overload
178
+ def on_transactions(
179
+ self, callback: None = ..., *, min_finality: Finality | str | None = ...
180
+ ) -> t.Callable[[_F], _F]: ...
181
+
182
+ def on_transactions(
183
+ self,
184
+ callback: _F | None = None,
185
+ *,
186
+ min_finality: Finality | str | None = None,
187
+ ) -> _F | t.Callable[[_F], _F]:
188
+ """Register a handler for ``transactions`` notifications."""
189
+ return self._register(EventType.TRANSACTIONS, callback, min_finality=min_finality)
190
+
191
+ @t.overload
192
+ def on_actions(
193
+ self, callback: _F, *, min_finality: Finality | str | None = ..., action_types: list[str] | None = ...
194
+ ) -> _F: ...
195
+
196
+ @t.overload
197
+ def on_actions(
198
+ self, callback: None = ..., *, min_finality: Finality | str | None = ..., action_types: list[str] | None = ...
199
+ ) -> t.Callable[[_F], _F]: ...
200
+
201
+ def on_actions(
202
+ self,
203
+ callback: _F | None = None,
204
+ *,
205
+ min_finality: Finality | str | None = None,
206
+ action_types: list[str] | None = None,
207
+ ) -> _F | t.Callable[[_F], _F]:
208
+ """Register a handler for ``actions`` notifications."""
209
+ return self._register(
210
+ EventType.ACTIONS,
211
+ callback,
212
+ min_finality=min_finality,
213
+ action_types=action_types,
214
+ )
215
+
216
+ @t.overload
217
+ def on_traces(self, callback: _F, *, min_finality: Finality | str | None = ...) -> _F: ...
218
+
219
+ @t.overload
220
+ def on_traces(self, callback: None = ..., *, min_finality: Finality | str | None = ...) -> t.Callable[[_F], _F]: ...
221
+
222
+ def on_traces(
223
+ self,
224
+ callback: _F | None = None,
225
+ *,
226
+ min_finality: Finality | str | None = None,
227
+ ) -> _F | t.Callable[[_F], _F]:
228
+ """Register a handler for ``trace`` notifications."""
229
+ return self._register(EventType.TRACE, callback, min_finality=min_finality)
230
+
231
+ @t.overload
232
+ def on_account_states(self, callback: _F, *, min_finality: Finality | str | None = ...) -> _F: ...
233
+
234
+ @t.overload
235
+ def on_account_states(
236
+ self, callback: None = ..., *, min_finality: Finality | str | None = ...
237
+ ) -> t.Callable[[_F], _F]: ...
238
+
239
+ def on_account_states(
240
+ self,
241
+ callback: _F | None = None,
242
+ *,
243
+ min_finality: Finality | str | None = None,
244
+ ) -> _F | t.Callable[[_F], _F]:
245
+ """Register a handler for ``account_state_change`` notifications."""
246
+ if min_finality is not None:
247
+ raw = min_finality.value if isinstance(min_finality, Finality) else min_finality
248
+ if raw == Finality.PENDING:
249
+ raise ValueError(
250
+ "account_state_change events are only emitted at confirmed and finalized finality levels"
251
+ )
252
+ return self._register(EventType.ACCOUNT_STATE_CHANGE, callback, min_finality=min_finality)
253
+
254
+ @t.overload
255
+ def on_jettons(self, callback: _F, *, min_finality: Finality | str | None = ...) -> _F: ...
256
+
257
+ @t.overload
258
+ def on_jettons(
259
+ self, callback: None = ..., *, min_finality: Finality | str | None = ...
260
+ ) -> t.Callable[[_F], _F]: ...
261
+
262
+ def on_jettons(
263
+ self,
264
+ callback: _F | None = None,
265
+ *,
266
+ min_finality: Finality | str | None = None,
267
+ ) -> _F | t.Callable[[_F], _F]:
268
+ """Register a handler for ``jettons_change`` notifications."""
269
+ if min_finality is not None:
270
+ raw = min_finality.value if isinstance(min_finality, Finality) else min_finality
271
+ if raw == Finality.PENDING:
272
+ raise ValueError("jettons_change events are only emitted at confirmed and finalized finality levels")
273
+ return self._register(EventType.JETTONS_CHANGE, callback, min_finality=min_finality)
274
+
275
+ @t.overload
276
+ def on_trace_invalidated(self, callback: _F) -> _F: ...
277
+
278
+ @t.overload
279
+ def on_trace_invalidated(self, callback: None = ...) -> t.Callable[[_F], _F]: ...
280
+
281
+ def on_trace_invalidated(
282
+ self,
283
+ callback: _F | None = None,
284
+ ) -> _F | t.Callable[[_F], _F]:
285
+ """Register a handler for ``trace_invalidated`` notifications."""
286
+ return self._register(EventType.TRACE_INVALIDATED, callback)
287
+
288
+ def _build_subscription(
289
+ self,
290
+ addresses: list[str] | None,
291
+ trace_external_hash_norms: list[str] | None,
292
+ ) -> dict[str, t.Any]:
293
+ types = sorted(k for k in self._handlers if k in _SUBSCRIBABLE)
294
+ if not types:
295
+ raise ValueError("No subscribable notification handlers registered")
296
+
297
+ fin = Finality.FINALIZED
298
+ for handlers in self._handlers.values():
299
+ for h in handlers:
300
+ if h.min_finality is not None and _FINALITY_ORDER[h.min_finality] < _FINALITY_ORDER[fin]:
301
+ fin = h.min_finality
302
+
303
+ action_types: list[str] | None = None
304
+ ah = self._handlers.get(EventType.ACTIONS, [])
305
+ if ah and all(h.action_types is not None for h in ah):
306
+ merged = {at for h in ah for at in (h.action_types or [])}
307
+ action_types = sorted(merged) if merged else None
308
+
309
+ non_trace = [et for et in types if et != EventType.TRACE]
310
+ if non_trace and not addresses:
311
+ raise ValueError(
312
+ f"addresses are required for event types: {non_trace}. Only 'trace' subscriptions can omit addresses.",
313
+ )
314
+ if EventType.TRACE in types and not trace_external_hash_norms:
315
+ raise ValueError("trace_external_hash_norms are required for trace subscriptions")
316
+
317
+ result: dict[str, t.Any] = {"types": types, "min_finality": fin}
318
+ if addresses:
319
+ result["addresses"] = addresses
320
+ if trace_external_hash_norms:
321
+ result["trace_external_hash_norms"] = trace_external_hash_norms
322
+ if action_types:
323
+ result["action_types"] = action_types
324
+ return result
325
+
326
+ async def _dispatch(self, notification: StreamNotification) -> None:
327
+ for handler in self._handlers.get(notification.type, []):
328
+ if handler.min_finality is not None and isinstance(notification, _FinalityMixin):
329
+ n_order = _FINALITY_ORDER.get(getattr(notification, "finality", ""), -1)
330
+ if n_order < 0:
331
+ continue
332
+ h_order = _FINALITY_ORDER.get(handler.min_finality.value, 0)
333
+ if n_order < h_order:
334
+ continue
335
+
336
+ if isinstance(notification, ActionsNotification) and handler.action_types is not None:
337
+ wanted = set(handler.action_types)
338
+ if not any(a.get("type") in wanted for a in notification.actions):
339
+ continue
340
+
341
+ result = handler.callback(notification)
342
+ if inspect.isawaitable(result):
343
+ await result
344
+
345
+ async def subscribe(
346
+ self,
347
+ *,
348
+ addresses: list[str] | None = None,
349
+ trace_external_hash_norms: list[str] | None = None,
350
+ types: list[str] | None = None,
351
+ min_finality: Finality | str = Finality.FINALIZED,
352
+ include_address_book: bool = False,
353
+ include_metadata: bool = False,
354
+ action_types: list[str] | None = None,
355
+ supported_action_types: list[str] | None = None,
356
+ stop: asyncio.Event | None = None,
357
+ ) -> t.AsyncGenerator[StreamNotification, None]:
358
+ """Low-level subscription generator.
359
+
360
+ Opens one connection, yields typed notifications. Reconnects automatically.
361
+ For high-level usage prefer ``on_*()`` decorators + ``start()``.
362
+ """
363
+ non_trace = [et for et in (types or []) if et != "trace"]
364
+ if non_trace and not addresses:
365
+ raise ValueError(
366
+ f"addresses are required for event types: {non_trace}. Only 'trace' subscriptions can omit addresses.",
367
+ )
368
+
369
+ params: dict[str, t.Any] = {
370
+ "min_finality": min_finality.value if isinstance(min_finality, Finality) else min_finality,
371
+ "include_address_book": include_address_book,
372
+ "include_metadata": include_metadata,
373
+ }
374
+ if addresses:
375
+ params["addresses"] = addresses
376
+ if trace_external_hash_norms:
377
+ params["trace_external_hash_norms"] = trace_external_hash_norms
378
+ if types:
379
+ params["types"] = [et.value if hasattr(et, "value") else et for et in types]
380
+ if action_types:
381
+ params["action_types"] = [a.value if hasattr(a, "value") else a for a in action_types]
382
+ if supported_action_types:
383
+ params["supported_action_types"] = supported_action_types
384
+
385
+ async for data in self._stream_with_reconnect(params, stop):
386
+ model = NOTIFICATION_MODEL_MAP.get(data.get("type", ""))
387
+ if model is not None:
388
+ yield model.model_validate(data) # type: ignore[misc]
389
+
390
+ async def _stream_with_reconnect(
391
+ self,
392
+ params: dict[str, t.Any],
393
+ stop: asyncio.Event | None = None,
394
+ ) -> t.AsyncGenerator[dict[str, t.Any], None]:
395
+ attempt = 0
396
+ await self._set_state(ConnectionState.CONNECTING)
397
+
398
+ while not (stop and stop.is_set()):
399
+ try:
400
+ async for data in self._open_stream(params, stop):
401
+ attempt = 0
402
+ yield data
403
+ except TONAPIError as exc:
404
+ if not isinstance(exc, STREAMING_RECOVERABLE):
405
+ await self._set_state(ConnectionState.IDLE)
406
+ raise
407
+ except (aiohttp.ClientError, OSError):
408
+ pass
409
+
410
+ if stop and stop.is_set():
411
+ await self._set_state(ConnectionState.IDLE)
412
+ return
413
+
414
+ attempt += 1
415
+ if self._reconnect_policy.max_reconnects != -1 and attempt > self._reconnect_policy.max_reconnects:
416
+ await self._set_state(ConnectionState.IDLE)
417
+ raise TONAPIConnectionLostError(attempts=attempt)
418
+
419
+ await self._set_state(ConnectionState.RECONNECTING)
420
+ await asyncio.sleep(self._reconnect_policy.delay_for_attempt(attempt - 1))
421
+
422
+ @abc.abstractmethod
423
+ async def _open_stream(
424
+ self,
425
+ params: dict[str, t.Any],
426
+ stop: asyncio.Event | None = None,
427
+ ) -> t.AsyncGenerator[dict[str, t.Any], None]:
428
+ """Open a transport-specific streaming connection and yield raw notification dicts."""
429
+ raise NotImplementedError
430
+ yield # type: ignore[unreachable]
431
+
432
+ async def start(
433
+ self,
434
+ addresses: list[str] | None = None,
435
+ *,
436
+ trace_external_hash_norms: list[str] | None = None,
437
+ include_address_book: bool = False,
438
+ include_metadata: bool = False,
439
+ supported_action_types: list[str] | None = None,
440
+ ) -> None:
441
+ """Start the streaming transport: create session, subscribe, and dispatch.
442
+
443
+ Blocks until ``stop()`` is called or a fatal error occurs.
444
+ Creates an internal aiohttp session on entry and closes it on exit
445
+ (unless an external session was provided via the constructor).
446
+
447
+ :param addresses: Wallet/contract addresses to monitor, in any form.
448
+ :param trace_external_hash_norms: Trace hashes for trace subscriptions.
449
+ :param include_address_book: Include DNS-resolved names in notifications.
450
+ :param include_metadata: Include token metadata in notifications.
451
+ :param supported_action_types: Advertise client-supported action types.
452
+ """
453
+ if self._stop is not None:
454
+ raise RuntimeError("start() is already active")
455
+
456
+ sub = self._build_subscription(addresses, trace_external_hash_norms)
457
+ self._stop = asyncio.Event()
458
+ await self.create_session()
459
+ try:
460
+ async for notification in self.subscribe(
461
+ addresses=sub.get("addresses"),
462
+ trace_external_hash_norms=sub.get("trace_external_hash_norms"),
463
+ types=sub.get("types"),
464
+ min_finality=sub["min_finality"],
465
+ action_types=sub.get("action_types"),
466
+ include_address_book=include_address_book,
467
+ include_metadata=include_metadata,
468
+ supported_action_types=supported_action_types,
469
+ stop=self._stop,
470
+ ):
471
+ await self._dispatch(notification)
472
+ finally:
473
+ self._stop = None
474
+ await self.close_session()
475
+
476
+ async def stop(self) -> None:
477
+ """Stop the streaming transport and release resources.
478
+
479
+ Signals the dispatch loop to exit, closes the aiohttp session
480
+ (unless externally provided), and resets the connection state.
481
+ Safe to call multiple times.
482
+ """
483
+ if self._stop is not None:
484
+ self._stop.set()
485
+ await self.close_session()
486
+ await self._set_state(ConnectionState.IDLE)
487
+
488
+ async def __aenter__(self) -> t.NoReturn:
489
+ raise TypeError("Streaming transports do not support 'async with'. Use start() and stop() instead.")
490
+
491
+ async def __aexit__(self, *args: t.Any) -> None:
492
+ pass