pmt4api 1.0.0__py3-none-any.whl

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.
pmt4api/_risk.py ADDED
@@ -0,0 +1,66 @@
1
+ """Position sizing, margin and P/L arithmetic that the core computes for the account's own conventions."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from typing import Optional
6
+
7
+ from ._base import _Base
8
+ from ._util import enum_value, finite, translated
9
+ from .errors import InvalidRequestError
10
+ from .models import LotSizing
11
+
12
+
13
+ class _RiskMixin(_Base):
14
+ @translated
15
+ def lots_for_risk(self, symbol: str, entry_price: float, stop_price: float, risk_amount: float, *,
16
+ lot_step: Optional[float] = None, min_lot: Optional[float] = None,
17
+ max_ratio: Optional[float] = None, max_equity_share: Optional[float] = None,
18
+ max_lot: Optional[float] = None) -> LotSizing:
19
+ """The volume that loses ``risk_amount`` (account currency) if ``stop_price`` is hit, with the evidence.
20
+
21
+ The value of a price unit comes from :meth:`tick_value` and the broker's step / minimum / maximum volume from
22
+ the account's own group (pass one to override it); a size that cannot be justified is refused, not guessed.
23
+ The volume is rounded **down** to the step. Check ``ok``: when false, ``lots`` is 0 and ``reason`` says which
24
+ check failed. Amounts are in the currency the account reports (no cent-account scaling is applied: check the
25
+ first result against your terminal).
26
+ """
27
+ self._require_connected()
28
+ kwargs = {}
29
+ for key, value in (("lotStep", lot_step), ("minLot", min_lot), ("maxRatio", max_ratio),
30
+ ("maxEquityShare", max_equity_share), ("maxLot", max_lot)):
31
+ if value is not None:
32
+ kwargs[key] = float(value)
33
+ result = self._api.CalculateLots(symbol, float(entry_price), float(stop_price), float(risk_amount), **kwargs)
34
+ return LotSizing._from_core(result)
35
+
36
+ @translated
37
+ def required_margin(self, symbol: str, lots: float, side: str = "buy", price: float = 0.0) -> float:
38
+ """The margin needed to open ``lots`` of ``symbol``, computed as the terminal does (``price`` 0 = the live
39
+ quote; Forex uses the account leverage, CFD the symbol's margin divider). Raises :class:`InvalidRequestError`
40
+ when it cannot be computed (a Futures symbol, whose formula is not verified, or no conversion rate yet)."""
41
+ self._require_connected()
42
+ key = str(side).strip().lower()
43
+ if key not in ("buy", "sell"):
44
+ raise ValueError("side must be 'buy' or 'sell'")
45
+ self._spec(symbol)
46
+ kind = enum_value(self._core.OrderType, key, "side")
47
+ value = float(self._api.RequiredMargin(symbol, finite(lots, "lots", positive=True), kind,
48
+ finite(price, "price", non_negative=True)))
49
+ if math.isnan(value) or value <= 0.0:
50
+ raise InvalidRequestError("the margin of %s cannot be computed (a Futures symbol, or no conversion rate "
51
+ "yet)" % symbol)
52
+ return value
53
+
54
+ @translated
55
+ def order_profit(self, symbol: str, open_price: float, close_price: float, lots: float, buy: bool = True) -> float:
56
+ """The P/L, in account currency, of ``lots`` moving from ``open_price`` to ``close_price``. For reporting a
57
+ completed or hypothetical move; use :meth:`lots_for_risk` to size. Raises :class:`InvalidRequestError` when the
58
+ value cannot be computed (no conversion rate yet)."""
59
+ self._require_connected()
60
+ self._spec(symbol)
61
+ value = float(self._api.CalculateOrderProfit(symbol, finite(open_price, "open_price"),
62
+ finite(close_price, "close_price"),
63
+ finite(lots, "lots", positive=True), bool(buy)))
64
+ if math.isnan(value):
65
+ raise InvalidRequestError("the profit of %s cannot be computed (no conversion rate yet)" % symbol)
66
+ return value
pmt4api/_runtime.py ADDED
@@ -0,0 +1,154 @@
1
+ """Locates the .NET runtime and the licensed core library and loads them once per process."""
2
+ from __future__ import annotations
3
+
4
+ import glob
5
+ import importlib
6
+ import os
7
+ import shutil
8
+ import sys
9
+ import threading
10
+ from pathlib import Path
11
+ from typing import Iterator, Optional
12
+
13
+ from .errors import CoreNotFoundError, RuntimeNotFoundError
14
+
15
+ #: Environment variable that points at wmt4api.dll.
16
+ ENV_DLL = "WMT4API_DLL"
17
+
18
+ _NAMESPACE = "wmt4api" # the .NET namespace of the core; NOT the name of this Python package
19
+ _PKG = Path(__file__).resolve().parent
20
+ _BUNDLED_RUNTIME = _PKG / "_dotnet" # present in the platform wheels: host/fxr + shared/Microsoft.NETCore.App
21
+ _lock = threading.Lock()
22
+ _core = None
23
+ _dll: Optional[Path] = None
24
+
25
+
26
+ def find_dll(explicit=None) -> Path:
27
+ """The core library to load: the caller's path, else ``WMTAPI_DLL``, else the one bundled in the wheel.
28
+
29
+ A path the caller gave explicitly (argument or environment) is never silently replaced by another: if it does not
30
+ exist that is an error.
31
+ """
32
+ requested = explicit or os.environ.get(ENV_DLL)
33
+ if requested:
34
+ path = Path(requested)
35
+ if path.is_file():
36
+ return path
37
+ raise CoreNotFoundError("wmt4api.dll was not found at %s (from %s)" %
38
+ (path, "dll_path" if explicit else ENV_DLL))
39
+ bundled = _PKG / "_native" / "wmt4api.dll"
40
+ if bundled.is_file():
41
+ return bundled
42
+ raise CoreNotFoundError(
43
+ "wmt4api.dll was not found. Pass dll_path=..., or set the " + ENV_DLL + " environment variable to the DLL "
44
+ "taken from your wmt4api package, or install a pmt4api wheel that bundles it (looked for " + str(bundled) + ")."
45
+ )
46
+
47
+
48
+ def _dotnet_root_candidates() -> Iterator[Path]:
49
+ exe = shutil.which("dotnet")
50
+ if exe:
51
+ yield Path(exe).resolve().parent
52
+ home = Path.home()
53
+ yield home / ".dotnet"
54
+ for p in ("/usr/share/dotnet", "/usr/lib/dotnet", "/usr/local/share/dotnet", "/opt/dotnet"):
55
+ yield Path(p)
56
+ pf = os.environ.get("ProgramFiles")
57
+ if pf:
58
+ yield Path(pf) / "dotnet"
59
+
60
+
61
+ def _icu_present() -> bool:
62
+ """True when a system ICU library is installed (the .NET runtime needs one unless globalization is invariant)."""
63
+ patterns = ("/usr/lib*/libicuuc.so*", "/usr/lib/*-linux-gnu/libicuuc.so*", "/lib*/libicuuc.so*",
64
+ "/lib/*-linux-gnu/libicuuc.so*", "/usr/local/lib*/libicuuc.so*", "/opt/homebrew/lib/libicuuc*",
65
+ "/usr/lib/libicuuc*")
66
+ return any(glob.glob(p) for p in patterns)
67
+
68
+
69
+ def _ensure_dotnet_root() -> None:
70
+ """Point the runtime host at a .NET runtime: the caller's ``DOTNET_ROOT``, else the one bundled in this package,
71
+ else an installed one. Say so clearly when there is none."""
72
+ # Without an ICU library the runtime aborts the whole process (uncatchably) at start-up unless globalization is
73
+ # invariant. The core does not depend on culture-specific behaviour, so on a machine with no ICU ask for invariant
74
+ # mode — whichever runtime is used, and including a DOTNET_ROOT inherited from a parent process.
75
+ if sys.platform.startswith("linux") and not _icu_present():
76
+ os.environ.setdefault("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "1")
77
+ if os.environ.get("DOTNET_ROOT"):
78
+ return
79
+ if (_BUNDLED_RUNTIME / "shared" / "Microsoft.NETCore.App").is_dir():
80
+ os.environ["DOTNET_ROOT"] = str(_BUNDLED_RUNTIME)
81
+ return
82
+ for root in _dotnet_root_candidates():
83
+ if (root / "shared" / "Microsoft.NETCore.App").is_dir():
84
+ os.environ["DOTNET_ROOT"] = str(root)
85
+ return
86
+ raise RuntimeNotFoundError(
87
+ "No .NET runtime was found. Install the .NET 8 (or later) runtime, or set DOTNET_ROOT to its install directory."
88
+ )
89
+
90
+
91
+ def runtime_root() -> Optional[Path]:
92
+ """The .NET root in use (after ``load``), or None."""
93
+ root = os.environ.get("DOTNET_ROOT")
94
+ return Path(root) if root else None
95
+
96
+
97
+ def _has_entry(path_entry: str, name: str) -> bool:
98
+ base = path_entry or os.getcwd()
99
+ try:
100
+ return os.path.isdir(os.path.join(base, name))
101
+ except OSError:
102
+ return False
103
+
104
+
105
+ def _import_namespace():
106
+ """Import the core's .NET namespace even if a same-named folder is lying around on sys.path."""
107
+ mod = sys.modules.get(_NAMESPACE)
108
+ if mod is not None and hasattr(mod, "MT4API"):
109
+ return mod
110
+ sys.modules.pop(_NAMESPACE, None)
111
+ saved = list(sys.path)
112
+ try:
113
+ sys.path[:] = [p for p in sys.path if not _has_entry(p, _NAMESPACE)]
114
+ importlib.invalidate_caches()
115
+ mod = importlib.import_module(_NAMESPACE)
116
+ finally:
117
+ sys.path[:] = saved
118
+ if not hasattr(mod, "MT4API"):
119
+ raise CoreNotFoundError("the loaded library does not expose MT4API; is it the wmt4api package?")
120
+ return mod
121
+
122
+
123
+ def load(dll_path=None):
124
+ """Load the runtime and the core (idempotent, thread-safe). Returns the core's .NET namespace module."""
125
+ global _core, _dll
126
+ with _lock:
127
+ if _core is not None:
128
+ if dll_path is not None and _dll is not None and Path(dll_path).resolve() != _dll.resolve():
129
+ raise CoreNotFoundError(
130
+ "a different core is already loaded in this process (%s); one process can use one core, "
131
+ "so dll_path=%s was not loaded" % (_dll, dll_path))
132
+ return _core
133
+ dll = find_dll(dll_path)
134
+ _ensure_dotnet_root()
135
+ try:
136
+ from pythonnet import load as _load
137
+ _load("coreclr")
138
+ import clr # noqa: F401 (provided by pythonnet)
139
+ except Exception as e: # noqa: BLE001 — anything here means the runtime could not start
140
+ raise RuntimeNotFoundError("could not start the .NET runtime: %s" % e) from e
141
+ import clr as _clr
142
+ from System.Reflection import Assembly # available once the runtime is up
143
+ try:
144
+ Assembly.LoadFrom(str(dll)) # AddReference(<absolute path>) fails on pythonnet 3.0.x
145
+ except Exception as e: # noqa: BLE001
146
+ raise CoreNotFoundError("could not load %s: %s" % (dll, e)) from e
147
+ _clr.AddReference("wmt4api") # now by name: registers the namespace for `import`
148
+ _core = _import_namespace()
149
+ _dll = dll
150
+ return _core
151
+
152
+
153
+ def loaded_dll() -> Optional[Path]:
154
+ return _dll
pmt4api/_session.py ADDED
@@ -0,0 +1,171 @@
1
+ """Connection lifecycle and session information."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any, List, Optional, Tuple
7
+
8
+ from ._base import _Base
9
+ from ._util import translated
10
+ from .errors import ConnectError, RequestTimeout
11
+ from .models import _time
12
+
13
+
14
+ class _SessionMixin(_Base):
15
+ # ---- lifecycle ---------------------------------------------------------------------------------------------
16
+
17
+ @translated
18
+ def connect(self) -> "_SessionMixin":
19
+ """Connect, authorise through the licence service and load the account. Returns ``self``. Calling it on a
20
+ client that is already connected does nothing (it never opens a second session).
21
+
22
+ Raises :class:`LicenseError` / :class:`TokenServiceError` when the licence is refused or unreachable,
23
+ :class:`LoginRejectedError` when the broker rejects the account or password, and :class:`ConnectError` when
24
+ no access server could be reached within ``connect_timeout``.
25
+ """
26
+ if self._api.Connected:
27
+ return self
28
+ try:
29
+ self._api.Connect()
30
+ except Exception as exc: # noqa: BLE001
31
+ # a dial that timed out is a connect failure, not "a request was sent and not confirmed"
32
+ from .errors import translate
33
+ mapped = translate(exc)
34
+ if isinstance(mapped, RequestTimeout):
35
+ raise ConnectError(str(mapped)) from exc
36
+ raise
37
+ return self
38
+
39
+ @translated
40
+ def reconnect(self) -> bool:
41
+ """Re-establish a dropped session over the primary server, ``servers`` and the list the broker published, and
42
+ restore the quote subscriptions. ``True`` when connected afterwards (also when it already was), ``False`` when
43
+ every server failed — try again later. A refusal retrying cannot cure (an expired licence, a rejected login)
44
+ also returns ``False``: call :meth:`connect` to see the reason."""
45
+ return bool(self._api.Reconnect())
46
+
47
+ @translated
48
+ def disconnect(self) -> None:
49
+ """Close the session and stop the core's reconnect watchdog for good — also when the link is down and the
50
+ watchdog is mid-way through reconnecting. Harmless when never connected."""
51
+ try:
52
+ self._api.Disconnect()
53
+ except Exception as exc: # noqa: BLE001
54
+ if type(exc).__name__ != "NullReferenceException": # never connected: nothing to close
55
+ raise
56
+
57
+ @property
58
+ def connected(self) -> bool:
59
+ """Live: false when the socket died (the core notices a closed or reset connection at once and a silent one
60
+ within ``no_message_timeout``)."""
61
+ return bool(self._api.Connected)
62
+
63
+ def __enter__(self) -> "_SessionMixin":
64
+ return self.connect()
65
+
66
+ def __exit__(self, *exc: Any) -> None:
67
+ self.disconnect()
68
+
69
+ async def aconnect(self) -> "_SessionMixin":
70
+ """Awaitable :meth:`connect`; the blocking connect runs on a worker thread."""
71
+ return await asyncio.get_running_loop().run_in_executor(None, self.connect)
72
+
73
+ async def adisconnect(self) -> None:
74
+ """Awaitable :meth:`disconnect`."""
75
+ await asyncio.get_running_loop().run_in_executor(None, self.disconnect)
76
+
77
+ async def __aenter__(self) -> "_SessionMixin":
78
+ return await self.aconnect()
79
+
80
+ async def __aexit__(self, *exc: Any) -> None:
81
+ await self.adisconnect()
82
+
83
+ # ---- information -------------------------------------------------------------------------------------------
84
+
85
+ @property
86
+ def login(self) -> int:
87
+ """The account login number."""
88
+ return int(self._api.User)
89
+
90
+ @property
91
+ def host(self) -> str:
92
+ """The access server this client was created for (it may fail over to another of :attr:`server_list`)."""
93
+ return str(self._api.Host or self._host)
94
+
95
+ @property
96
+ def port(self) -> int:
97
+ """The access-server port."""
98
+ return int(self._api.Port)
99
+
100
+ @property
101
+ def server_list(self) -> List[Tuple[str, int]]:
102
+ """The access servers the broker published at the last connect, as ``(host, port)``: the pool a reconnect
103
+ fails over across (empty before the first connect)."""
104
+ return [(str(t.Item1), int(t.Item2)) for t in self._api.SrvList]
105
+
106
+ @property
107
+ def servers(self) -> List[Tuple[str, int]]:
108
+ """The extra access servers given with ``servers=`` / :meth:`add_server`, tried after the primary one."""
109
+ return [(str(t.Item1), int(t.Item2)) for t in self._api.Servers]
110
+
111
+ @translated
112
+ def add_server(self, host: str, port: int = 443) -> None:
113
+ """Add a backup access server, tried (in order) when the primary one cannot be dialled. Credential
114
+ rejections do not fail over. Takes effect at the next connect."""
115
+ if not host:
116
+ raise ValueError("host must not be empty")
117
+ import System # noqa: PLC0415
118
+ self._api.Servers.Add(System.ValueTuple[System.String, System.Int32](str(host), int(port)))
119
+
120
+ @property
121
+ def raw(self) -> Any:
122
+ """The underlying core object, for anything this layer does not wrap. Calls made on it raise the core's own
123
+ exceptions, not pmt4api's."""
124
+ return self._api
125
+
126
+ @property
127
+ def core_version(self) -> str:
128
+ """The version of the licensed core library in use."""
129
+ return str(self._api.GetType().Assembly.GetName().Version)
130
+
131
+ @property
132
+ def server_time(self) -> Optional[datetime]:
133
+ """The server's clock (server time, naive): the local UTC clock moved by the server's offset. ``None`` before
134
+ the first connect."""
135
+ if self._api.Account is None:
136
+ return None
137
+ return _time(self._api.ServerTime)
138
+
139
+ @property
140
+ def server_utc_offset(self) -> Optional[timedelta]:
141
+ """The server's offset from UTC, in whole hours as the broker configures it; ``None`` before the first
142
+ connect."""
143
+ st = self.server_time
144
+ if st is None:
145
+ return None
146
+ delta = st - datetime.now(timezone.utc).replace(tzinfo=None)
147
+ return timedelta(hours=round(delta.total_seconds() / 3600.0))
148
+
149
+ @property
150
+ def company(self) -> str:
151
+ """The broker's company name, as the server reports it (empty before the first connect)."""
152
+ a = self._api.Account
153
+ return str(a.Company or "") if a is not None else ""
154
+
155
+ @property
156
+ def trade_build(self) -> int:
157
+ """The server's build number for this session."""
158
+ return int(self._api.ServerTradeBuild)
159
+
160
+ @property
161
+ def is_investor(self) -> bool:
162
+ """The login is the read-only investor password, as the account record reports it (the field the C# client
163
+ reads): every trading call then raises :class:`InvestorModeError` before anything is sent. (Verified on
164
+ trading logins only: no investor password was available to exercise the true case.)"""
165
+ return bool(self._api.IsInvestor)
166
+
167
+ @property
168
+ def can_trade(self) -> bool:
169
+ """Connected with a trading (not investor) password. The broker may still refuse a particular symbol or
170
+ time; this only says the session is allowed to try. See :attr:`is_investor`."""
171
+ return bool(self._api.Connected) and not bool(self._api.IsInvestor)
pmt4api/_streams.py ADDED
@@ -0,0 +1,238 @@
1
+ """Delivery machinery shared by the event API: one dispatcher thread per client, and the sync / async iterators.
2
+
3
+ The core delivers each event on its own thread-pool task, so events can arrive concurrently and slightly out of order,
4
+ and a slow callback would pile up threads. The client therefore converts every event to a plain value the moment it
5
+ arrives (a snapshot — the core's objects are shared and mutable) and hands it to ONE dispatcher thread per client,
6
+ which calls the callbacks one at a time, in arrival order. A callback may take its time; if it falls more than
7
+ ``EVENT_QUEUE`` events behind, the oldest events are dropped (a warning is logged, and ``Client.events_dropped``
8
+ counts them). An exception raised by a callback is logged (under the package's own logger name) and swallowed so it
9
+ cannot break the connection.
10
+
11
+ This file is identical in pmt5api and pmt4api (a repository test keeps them so).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ import queue
18
+ import threading
19
+ import weakref
20
+ from typing import Any, Callable, Optional, Tuple
21
+
22
+ _PACKAGE = __name__.split(".")[0]
23
+ log = logging.getLogger(_PACKAGE)
24
+
25
+ #: How many events may wait for the dispatcher before the oldest are dropped.
26
+ EVENT_QUEUE = 20000
27
+
28
+
29
+ class _Dispatcher:
30
+ """One thread that runs a client's callbacks in arrival order."""
31
+
32
+ def __init__(self) -> None:
33
+ self._q: "queue.Queue[Tuple[Callable[[Any], None], Any, str]]" = queue.Queue(maxsize=EVENT_QUEUE)
34
+ self.dropped = 0
35
+ self._count_lock = threading.Lock()
36
+ self._thread = threading.Thread(target=self._run, name=_PACKAGE + "-events", daemon=True)
37
+ self._thread.start()
38
+
39
+ def submit(self, fn: Callable[[Any], None], arg: Any, event: str) -> None:
40
+ while True:
41
+ try:
42
+ self._q.put_nowait((fn, arg, event))
43
+ return
44
+ except queue.Full:
45
+ try:
46
+ self._q.get_nowait()
47
+ except queue.Empty:
48
+ continue
49
+ with self._count_lock:
50
+ self.dropped += 1
51
+ total = self.dropped
52
+ if total == 1 or total % 1000 == 0:
53
+ log.warning("event queue full: %d event(s) dropped so far; a callback is too slow", total)
54
+
55
+ def _run(self) -> None:
56
+ while True:
57
+ fn, arg, event = self._q.get()
58
+ try:
59
+ fn(arg)
60
+ except Exception: # noqa: BLE001 — keep the dispatcher alive
61
+ log.exception("%s callback raised", event)
62
+
63
+
64
+ _END = object() # put on a queue to wake a consumer that is blocked in it when the iterator is closed
65
+
66
+
67
+ class _QueueIterator:
68
+ """An iterator over an event stream. It subscribes when it is created (not at the first ``next()``), so nothing
69
+ that happens after the call is missed, and unsubscribes when exhausted or closed."""
70
+
71
+ def __init__(self, register: Callable[[Callable[[Any], None]], Callable[[], None]], timeout: Optional[float],
72
+ maxsize: int, predicate: Optional[Callable[[Any], bool]] = None,
73
+ setup: Optional[Callable[[], None]] = None) -> None:
74
+ self._q: "queue.Queue[Any]" = queue.Queue(maxsize=maxsize)
75
+ self._timeout = timeout
76
+ self._pred = predicate
77
+ self._closed = False
78
+ ref = weakref.ref(self)
79
+
80
+ def put(item: Any) -> None: # weakly bound: an abandoned iterator can be collected, and then detaches
81
+ it = ref()
82
+ if it is not None:
83
+ it._put(item)
84
+
85
+ self._remove = register(put)
86
+ if setup is not None:
87
+ try:
88
+ setup()
89
+ except BaseException:
90
+ self.close()
91
+ raise
92
+
93
+ def _put(self, item: Any) -> None:
94
+ if self._pred is not None and not self._pred(item):
95
+ return
96
+ while True:
97
+ try:
98
+ self._q.put_nowait(item)
99
+ return
100
+ except queue.Full:
101
+ try:
102
+ self._q.get_nowait()
103
+ except queue.Empty:
104
+ pass
105
+
106
+ def __iter__(self) -> "_QueueIterator":
107
+ return self
108
+
109
+ def __next__(self) -> Any:
110
+ if self._closed:
111
+ raise StopIteration
112
+ try:
113
+ item = self._q.get(timeout=self._timeout)
114
+ except queue.Empty:
115
+ self.close()
116
+ raise StopIteration
117
+ if item is _END:
118
+ raise StopIteration
119
+ return item
120
+
121
+ def close(self) -> None:
122
+ if not self._closed:
123
+ self._closed = True
124
+ self._remove()
125
+ while True: # wake a thread that is blocked in next(), whatever is queued
126
+ try:
127
+ self._q.put_nowait(_END)
128
+ return
129
+ except queue.Full:
130
+ try:
131
+ self._q.get_nowait()
132
+ except queue.Empty:
133
+ pass
134
+
135
+ def __enter__(self) -> "_QueueIterator":
136
+ return self
137
+
138
+ def __exit__(self, *exc: Any) -> None:
139
+ self.close()
140
+
141
+ def __del__(self) -> None: # pragma: no cover - best effort
142
+ try:
143
+ self.close()
144
+ except Exception: # noqa: BLE001
145
+ pass
146
+
147
+
148
+ class _AsyncQueueIterator:
149
+ """The asyncio counterpart; created inside a running loop, subscribed at creation, ``aclose()`` to stop."""
150
+
151
+ def __init__(self, register: Callable[[Callable[[Any], None]], Callable[[], None]], maxsize: int,
152
+ predicate: Optional[Callable[[Any], bool]] = None,
153
+ setup: Optional[Callable[[], None]] = None) -> None:
154
+ self._loop = asyncio.get_running_loop()
155
+ self._q: "asyncio.Queue[Any]" = asyncio.Queue(maxsize=maxsize)
156
+ self._pred = predicate
157
+ self._closed = False
158
+ ref = weakref.ref(self)
159
+ loop = self._loop
160
+
161
+ def push(item: Any) -> None: # weakly bound, as in the synchronous iterator
162
+ it = ref()
163
+ if it is not None:
164
+ try:
165
+ loop.call_soon_threadsafe(it._push, item)
166
+ except RuntimeError: # the loop is closed
167
+ pass
168
+
169
+ self._remove = register(push)
170
+ if setup is not None:
171
+ try:
172
+ setup()
173
+ except BaseException:
174
+ self._finish()
175
+ raise
176
+
177
+ def _push(self, item: Any) -> None:
178
+ if self._closed or (self._pred is not None and not self._pred(item)):
179
+ return
180
+ if self._q.full():
181
+ try:
182
+ self._q.get_nowait()
183
+ except asyncio.QueueEmpty:
184
+ pass
185
+ try:
186
+ self._q.put_nowait(item)
187
+ except asyncio.QueueFull:
188
+ pass
189
+
190
+ def _finish(self) -> None:
191
+ if not self._closed:
192
+ self._closed = True
193
+ self._remove()
194
+ try: # wake a task that is awaiting the next item
195
+ self._loop.call_soon_threadsafe(self._wake)
196
+ except RuntimeError: # the loop is closed: nobody is waiting
197
+ pass
198
+
199
+ def _wake(self) -> None:
200
+ if self._q.full():
201
+ try:
202
+ self._q.get_nowait()
203
+ except asyncio.QueueEmpty:
204
+ pass
205
+ try:
206
+ self._q.put_nowait(_END)
207
+ except asyncio.QueueFull:
208
+ pass
209
+
210
+ def __aiter__(self) -> "_AsyncQueueIterator":
211
+ return self
212
+
213
+ async def __anext__(self) -> Any:
214
+ if self._closed and self._q.empty():
215
+ raise StopAsyncIteration
216
+ try:
217
+ item = await self._q.get()
218
+ except asyncio.CancelledError:
219
+ self._finish()
220
+ raise
221
+ if item is _END:
222
+ raise StopAsyncIteration
223
+ return item
224
+
225
+ async def aclose(self) -> None:
226
+ self._finish()
227
+
228
+ async def __aenter__(self) -> "_AsyncQueueIterator":
229
+ return self
230
+
231
+ async def __aexit__(self, *exc: Any) -> None:
232
+ self._finish()
233
+
234
+ def __del__(self) -> None: # pragma: no cover - best effort when a loop is abandoned without aclose()
235
+ try:
236
+ self._finish()
237
+ except Exception: # noqa: BLE001
238
+ pass