little-a2s 0.5.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 thegamecracks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: little-a2s
3
+ Version: 0.5.0
4
+ Summary: A sync + async + sans-I/O library for the Valve Source Query (A2S) protocol.
5
+ Author: thegamecracks
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: SQL
19
+ Classifier: Topic :: Internet
20
+ Classifier: Topic :: System :: Networking
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Project-URL: Documentation, https://little-a2s.rtfd.io/
24
+ Project-URL: GitHub, https://github.com/thegamecracks/little-a2s
25
+ Project-URL: Homepage, https://little-a2s.rtfd.io/
26
+ Project-URL: Issues, https://github.com/thegamecracks/little-a2s/issues
27
+ Description-Content-Type: text/markdown
28
+
29
+ # little-a2s
30
+
31
+ [![](https://img.shields.io/pypi/v/little-a2s?style=flat-square&logo=pypi)](https://pypi.org/project/little-a2s/)
32
+ [![](https://readthedocs.org/projects/little-a2s/badge/?style=flat-square)](http://little-a2s.readthedocs.io/)
33
+ [![](https://img.shields.io/github/actions/workflow/status/thegamecracks/little-a2s/publish.yml?style=flat-square&logo=uv&label=build)](https://docs.astral.sh/uv/)
34
+ [![](https://img.shields.io/github/actions/workflow/status/thegamecracks/little-a2s/pytest.yml?style=flat-square&logo=pytest&label=tests)](https://docs.pytest.org/)
35
+ [![](https://img.shields.io/github/actions/workflow/status/thegamecracks/little-a2s/pyright-lint.yml?style=flat-square&label=pyright)](https://microsoft.github.io/pyright/#/)
36
+ [![](https://img.shields.io/github/actions/workflow/status/thegamecracks/little-a2s/ruff-check.yml?style=flat-square&logo=ruff&label=lints)](https://docs.astral.sh/ruff/)
37
+ [![](https://img.shields.io/github/actions/workflow/status/thegamecracks/little-a2s/ruff-format.yml?style=flat-square&logo=ruff&label=style)](https://docs.astral.sh/ruff/)
38
+
39
+ A sync + async + sans-I/O library for the Valve Source Query (A2S) protocol.
40
+
41
+ ```py
42
+ from little_a2s import A2S, AsyncA2S
43
+
44
+ with A2S.from_addr("example.com", 27015, timeout=1) as a2s:
45
+ print(a2s.info())
46
+ print(a2s.players())
47
+ print(a2s.rules())
48
+
49
+ addr = ("127.0.0.1", 27015)
50
+ async with AsyncA2S.from_ipv4() as a2s, asyncio.timeout(1):
51
+ info = await a2s.info(addr)
52
+ players = await a2s.players(addr)
53
+ rules = await a2s.rules(addr)
54
+ ```
55
+
56
+ Read the [documentation] or see the [examples] directory to get started!
57
+
58
+ [documentation]: https://little-a2s.rtfd.io/
59
+ [examples]: https://github.com/thegamecracks/little-a2s/tree/main/examples
60
+
61
+ ## Installation
62
+
63
+ The minimum Python version required is **3.11**. No other dependencies are required.
64
+
65
+ This package can be installed from PyPI using one of the following commands:
66
+
67
+ ```sh
68
+ # Linux/MacOS
69
+ python3 -m pip install little-a2s
70
+
71
+ # Windows
72
+ py -m pip install little-a2s
73
+ ```
74
+
75
+ To install the development version of the library (requires Git), you can download
76
+ it from GitHub directly:
77
+
78
+ ```sh
79
+ pip install git+https://github.com/thegamecracks/little-a2s
80
+ ```
81
+
82
+ ## License
83
+
84
+ This project is written under the [MIT License].
85
+
86
+ [MIT License]: /LICENSE
@@ -0,0 +1,58 @@
1
+ # little-a2s
2
+
3
+ [![](https://img.shields.io/pypi/v/little-a2s?style=flat-square&logo=pypi)](https://pypi.org/project/little-a2s/)
4
+ [![](https://readthedocs.org/projects/little-a2s/badge/?style=flat-square)](http://little-a2s.readthedocs.io/)
5
+ [![](https://img.shields.io/github/actions/workflow/status/thegamecracks/little-a2s/publish.yml?style=flat-square&logo=uv&label=build)](https://docs.astral.sh/uv/)
6
+ [![](https://img.shields.io/github/actions/workflow/status/thegamecracks/little-a2s/pytest.yml?style=flat-square&logo=pytest&label=tests)](https://docs.pytest.org/)
7
+ [![](https://img.shields.io/github/actions/workflow/status/thegamecracks/little-a2s/pyright-lint.yml?style=flat-square&label=pyright)](https://microsoft.github.io/pyright/#/)
8
+ [![](https://img.shields.io/github/actions/workflow/status/thegamecracks/little-a2s/ruff-check.yml?style=flat-square&logo=ruff&label=lints)](https://docs.astral.sh/ruff/)
9
+ [![](https://img.shields.io/github/actions/workflow/status/thegamecracks/little-a2s/ruff-format.yml?style=flat-square&logo=ruff&label=style)](https://docs.astral.sh/ruff/)
10
+
11
+ A sync + async + sans-I/O library for the Valve Source Query (A2S) protocol.
12
+
13
+ ```py
14
+ from little_a2s import A2S, AsyncA2S
15
+
16
+ with A2S.from_addr("example.com", 27015, timeout=1) as a2s:
17
+ print(a2s.info())
18
+ print(a2s.players())
19
+ print(a2s.rules())
20
+
21
+ addr = ("127.0.0.1", 27015)
22
+ async with AsyncA2S.from_ipv4() as a2s, asyncio.timeout(1):
23
+ info = await a2s.info(addr)
24
+ players = await a2s.players(addr)
25
+ rules = await a2s.rules(addr)
26
+ ```
27
+
28
+ Read the [documentation] or see the [examples] directory to get started!
29
+
30
+ [documentation]: https://little-a2s.rtfd.io/
31
+ [examples]: https://github.com/thegamecracks/little-a2s/tree/main/examples
32
+
33
+ ## Installation
34
+
35
+ The minimum Python version required is **3.11**. No other dependencies are required.
36
+
37
+ This package can be installed from PyPI using one of the following commands:
38
+
39
+ ```sh
40
+ # Linux/MacOS
41
+ python3 -m pip install little-a2s
42
+
43
+ # Windows
44
+ py -m pip install little-a2s
45
+ ```
46
+
47
+ To install the development version of the library (requires Git), you can download
48
+ it from GitHub directly:
49
+
50
+ ```sh
51
+ pip install git+https://github.com/thegamecracks/little-a2s
52
+ ```
53
+
54
+ ## License
55
+
56
+ This project is written under the [MIT License].
57
+
58
+ [MIT License]: /LICENSE
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["uv-build~=0.9.7"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "little-a2s"
7
+ version = "0.5.0"
8
+ description = "A sync + async + sans-I/O library for the Valve Source Query (A2S) protocol."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "thegamecracks" }]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Programming Language :: SQL",
26
+ "Topic :: Internet",
27
+ "Topic :: System :: Networking",
28
+ "Typing :: Typed",
29
+ ]
30
+ dependencies = []
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "coverage~=7.11",
35
+ "pytest~=8.4",
36
+ ]
37
+ docs = [
38
+ "sphinx~=8.2",
39
+ "sphinx-autodoc-typehints~=3.5",
40
+ "sphinx-book-theme~=1.1",
41
+ ]
42
+
43
+ [project.urls]
44
+ # https://packaging.python.org/en/latest/specifications/well-known-project-urls/#well-known-labels
45
+ Homepage = "https://little-a2s.rtfd.io/"
46
+ Documentation = "https://little-a2s.rtfd.io/"
47
+ GitHub = "https://github.com/thegamecracks/little-a2s"
48
+ Issues = "https://github.com/thegamecracks/little-a2s/issues"
49
+
50
+ [tool.pytest.ini_options]
51
+ addopts = ["--import-mode=importlib"]
52
+
53
+ [tool.uv]
54
+ default-groups = "all"
@@ -0,0 +1,64 @@
1
+ from .client import (
2
+ A2S as A2S,
3
+ A2SGoldsource as A2SGoldsource,
4
+ AsyncA2S as AsyncA2S,
5
+ AsyncA2SGoldsource as AsyncA2SGoldsource,
6
+ filter_type as filter_type,
7
+ first as first,
8
+ last as last,
9
+ )
10
+ from .events import (
11
+ ClientEvent as ClientEvent,
12
+ ClientEventChallenge as ClientEventChallenge,
13
+ ClientEventGoldsourceInfo as ClientEventGoldsourceInfo,
14
+ ClientEventInfo as ClientEventInfo,
15
+ ClientEventPlayers as ClientEventPlayers,
16
+ ClientEventRules as ClientEventRules,
17
+ Environment as Environment,
18
+ Event as Event,
19
+ ExtraInfo as ExtraInfo,
20
+ GoldsourceMod as GoldsourceMod,
21
+ GoldsourceModDLL as GoldsourceModDLL,
22
+ GoldsourceModType as GoldsourceModType,
23
+ Player as Player,
24
+ ServerType as ServerType,
25
+ VAC as VAC,
26
+ Visibility as Visibility,
27
+ )
28
+ from .headers import (
29
+ Compression as Compression,
30
+ Header as Header,
31
+ HeaderType as HeaderType,
32
+ MultiGoldsourceHeader as MultiGoldsourceHeader,
33
+ MultiHeader as MultiHeader,
34
+ SimpleHeader as SimpleHeader,
35
+ )
36
+ from .packets import (
37
+ ClientPacket as ClientPacket,
38
+ ClientPacketInfo as ClientPacketInfo,
39
+ ClientPacketPlayers as ClientPacketPlayers,
40
+ ClientPacketRules as ClientPacketRules,
41
+ Packet as Packet,
42
+ )
43
+ from .protocol import (
44
+ A2SClientProtocol as A2SClientProtocol,
45
+ A2SGoldsourceClientProtocol as A2SGoldsourceClientProtocol,
46
+ MultiPartResponse as MultiPartResponse,
47
+ )
48
+ from .reader import Readable as Readable, Reader as Reader
49
+ from .rules import (
50
+ Arma3DLC as Arma3DLC,
51
+ Arma3Difficulty as Arma3Difficulty,
52
+ Arma3Mod as Arma3Mod,
53
+ Arma3Rules as Arma3Rules,
54
+ )
55
+
56
+
57
+ def _get_version() -> str:
58
+ from importlib.metadata import version
59
+
60
+ return version(_dist_name)
61
+
62
+
63
+ _dist_name = "little-a2s"
64
+ __version__ = _get_version()
@@ -0,0 +1,7 @@
1
+ from .async_ import AsyncA2S as AsyncA2S, AsyncA2SGoldsource as AsyncA2SGoldsource
2
+ from .sync import A2S as A2S, A2SGoldsource as A2SGoldsource
3
+ from .types import (
4
+ filter_type as filter_type,
5
+ first as first,
6
+ last as last,
7
+ )
@@ -0,0 +1,392 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import socket
6
+ from contextlib import asynccontextmanager
7
+ from functools import partial
8
+ from typing import AsyncIterator, Awaitable, Callable, Self, Type
9
+
10
+ from little_a2s.client.types import Address, ClientEventT
11
+ from little_a2s.events import (
12
+ ClientEvent,
13
+ ClientEventChallenge,
14
+ ClientEventGoldsourceInfo,
15
+ ClientEventInfo,
16
+ ClientEventPlayers,
17
+ ClientEventRules,
18
+ )
19
+ from little_a2s.packets import ClientPacket
20
+ from little_a2s.protocol import A2SClientProtocol, A2SGoldsourceClientProtocol
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+
25
+ class AsyncA2S(asyncio.DatagramProtocol):
26
+ """An asynchronous client for A2S queries.
27
+
28
+ ::
29
+
30
+ a2s = AsyncA2S.from_addr("127.0.0.1", 27015)
31
+ async with a2s, asyncio.timeout(1):
32
+ print(await a2s.info())
33
+ print(await a2s.players())
34
+ print(await a2s.rules())
35
+
36
+ This follows the Source format. For the Goldsource equivalent,
37
+ see :class:`AsyncA2SGoldsource`.
38
+
39
+ This class supports the asynchronous context manager protocol which calls
40
+ the connector function to set the transport + remote address, and closes
41
+ the transport upon exit.
42
+
43
+ :param connector:
44
+ The function to call and await to create a datagram transport
45
+ and return the remote address, if any.
46
+ See also :meth:`from_addr()`, :meth:`from_ipv4()`, and :meth:`from_ipv6()`.
47
+
48
+ .. versionadded:: 0.5.0
49
+
50
+ """
51
+
52
+ _remote_addr: Address | None = None
53
+ _transport: asyncio.DatagramTransport | None = None
54
+ _protocols: dict[Address, A2SClientProtocol]
55
+ _requests: dict[
56
+ tuple[Address, Type[ClientEvent]],
57
+ asyncio.Future[ClientEvent | None],
58
+ ]
59
+ _close_fut: asyncio.Future[None]
60
+
61
+ def __init__(
62
+ self,
63
+ connector: Callable[[Self], Awaitable[Address | None]],
64
+ ) -> None:
65
+ self.connector = connector
66
+
67
+ self._lock = asyncio.Lock()
68
+ self._request_cond = asyncio.Condition(self._lock)
69
+ self._reset()
70
+
71
+ def _reset(self) -> None:
72
+ self._protocols = {}
73
+ self._requests = {}
74
+
75
+ loop = asyncio.get_running_loop()
76
+ self._close_fut = loop.create_future()
77
+
78
+ # Connection methods
79
+
80
+ async def __aenter__(self) -> Self:
81
+ await self.start()
82
+ return self
83
+
84
+ async def __aexit__(self, exc_type, exc_val, tb):
85
+ await self.close()
86
+ self._reset()
87
+
88
+ async def start(self) -> None:
89
+ """Call the connector function to create the datagram transport.
90
+
91
+ :raises OSError: The address could not be resolved.
92
+ :raises RuntimeError: The transport is already connected.
93
+
94
+ """
95
+ async with self._lock:
96
+ if self._transport is not None:
97
+ raise RuntimeError("Transport already connected")
98
+
99
+ self._remote_addr = await self.connector(self)
100
+
101
+ if self._transport is None:
102
+ raise RuntimeError("Connector failed to call connection_made()")
103
+
104
+ async def close(self) -> None:
105
+ """Close the current datagram transport, raising any exception
106
+ if the connection improperly closed.
107
+
108
+ :raises RuntimeError: The transport is not connected.
109
+
110
+ """
111
+ self.transport.close()
112
+ return await asyncio.shield(self._close_fut)
113
+
114
+ @property
115
+ def transport(self) -> asyncio.DatagramTransport:
116
+ """The current datagram transport.
117
+
118
+ :raises RuntimeError: The transport is not connected.
119
+
120
+ """
121
+ if self._transport is None:
122
+ raise RuntimeError("Transport not connected")
123
+ return self._transport
124
+
125
+ # Constructor methods
126
+
127
+ @classmethod
128
+ def from_addr(
129
+ cls,
130
+ host: str,
131
+ port: int,
132
+ *,
133
+ prefer_ipv4: bool = True,
134
+ ) -> Self:
135
+ """Resolve the given host and create an A2S query.
136
+
137
+ :param host: The IPv4 address, IPv6 address, or domain name to query.
138
+ :param port: The port to query.
139
+ :param prefer_ipv4: If True, prefer to resolve hostnames to IPv4 addresses.
140
+
141
+ """
142
+ connector = partial(
143
+ cls._connect_from_addr,
144
+ host=host,
145
+ port=port,
146
+ prefer_ipv4=prefer_ipv4,
147
+ )
148
+ return cls(connector)
149
+
150
+ async def _connect_from_addr(
151
+ self,
152
+ *,
153
+ host: str,
154
+ port: int,
155
+ prefer_ipv4: bool = True,
156
+ ) -> Address:
157
+ loop = asyncio.get_running_loop()
158
+ addresses = await loop.getaddrinfo(
159
+ host,
160
+ port,
161
+ type=socket.SOCK_DGRAM,
162
+ proto=socket.IPPROTO_UDP,
163
+ )
164
+ if not addresses:
165
+ raise OSError("Address could not be resolved")
166
+
167
+ if prefer_ipv4:
168
+ addr = next((a for a in addresses if a[0] == socket.AF_INET), addresses[0])
169
+ else:
170
+ addr = addresses[0]
171
+
172
+ family, _, proto, _, addr = addr
173
+ await loop.create_datagram_endpoint(
174
+ lambda: self,
175
+ remote_addr=addr[:2],
176
+ family=family,
177
+ proto=proto,
178
+ )
179
+ return addr
180
+
181
+ @classmethod
182
+ def from_ipv4(cls) -> Self:
183
+ """Create an A2S query with a UDP IPv4 socket not connected to any address.
184
+
185
+ This allows you to use the same socket with ``addr`` arguments::
186
+
187
+ async with AsyncA2S.from_ipv4() as a2s, asyncio.timeout(1):
188
+ info = await a2s.info(("127.0.0.1", 2303))
189
+ info = await a2s.info(("127.0.0.1", 27015))
190
+
191
+ """
192
+ connector = partial(cls._connect_from_family, family=socket.AF_INET)
193
+ return cls(connector)
194
+
195
+ @classmethod
196
+ def from_ipv6(cls) -> Self:
197
+ """Create an A2S query with a UDP IPv6 socket not connected to any address.
198
+
199
+ This allows you to use the same socket with ``addr`` arguments::
200
+
201
+ async with AsyncA2S.from_ipv6() as a2s, asyncio.timeout(1):
202
+ info = await a2s.info(("::1", 2303))
203
+ info = await a2s.info(("::1", 27015))
204
+
205
+ """
206
+ connector = partial(cls._connect_from_family, family=socket.AF_INET6)
207
+ return cls(connector)
208
+
209
+ async def _connect_from_family(self, *, family: socket.AddressFamily) -> None:
210
+ loop = asyncio.get_running_loop()
211
+ await loop.create_datagram_endpoint(
212
+ lambda: self,
213
+ local_addr=("::" if family == socket.AF_INET6 else "0.0.0.0", 0),
214
+ family=family,
215
+ proto=socket.IPPROTO_UDP,
216
+ )
217
+
218
+ # Request methods
219
+
220
+ async def info(self, addr: Address | None = None) -> ClientEventInfo:
221
+ """Send an A2S_INFO request and wait for a response.
222
+
223
+ :param addr:
224
+ The address to send the request to.
225
+ Does not apply if socket is already connected to an address,
226
+ such as from :meth:`from_addr()`.
227
+
228
+ :raises TimeoutError: The server did not respond.
229
+ :raises TypeError: The addr argument was required or forbidden.
230
+ :raises ValueError: The server sent a malformed packet.
231
+
232
+ """
233
+ addr = self._get_addr(addr)
234
+ proto = self._get_protocol(addr)
235
+ return await self._send(ClientEventInfo, addr, proto.info)
236
+
237
+ async def players(self, addr: Address | None = None) -> ClientEventPlayers:
238
+ """Send an A2S_PLAYER request and wait for a response.
239
+
240
+ :param addr:
241
+ The address to send the request to.
242
+ Does not apply if socket is already connected to an address,
243
+ such as from :meth:`from_addr()`.
244
+
245
+ :raises TimeoutError: The server did not respond.
246
+ :raises TypeError: The addr argument was required or forbidden.
247
+ :raises ValueError: The server sent a malformed packet.
248
+
249
+ """
250
+ addr = self._get_addr(addr)
251
+ proto = self._get_protocol(addr)
252
+ return await self._send(ClientEventPlayers, addr, proto.players)
253
+
254
+ async def rules(self, addr: Address | None = None) -> ClientEventRules:
255
+ """Send an A2S_RULES request and wait for a response.
256
+
257
+ :param addr:
258
+ The address to send the request to.
259
+ Does not apply if socket is already connected to an address,
260
+ such as from :meth:`from_addr()`.
261
+
262
+ :raises TimeoutError: The server did not respond.
263
+ :raises TypeError: The addr argument was required or forbidden.
264
+ :raises ValueError: The server sent a malformed packet.
265
+
266
+ """
267
+ addr = self._get_addr(addr)
268
+ proto = self._get_protocol(addr)
269
+ return await self._send(ClientEventRules, addr, proto.rules)
270
+
271
+ def _get_addr(self, addr: Address | None) -> Address:
272
+ if self._remote_addr and addr:
273
+ raise TypeError("Transport has remote address, addr= is disallowed")
274
+ elif not self._remote_addr and not addr:
275
+ raise TypeError("Transport has no remote address, addr= is required")
276
+ return addr or self._remote_addr # type: ignore
277
+
278
+ def _get_protocol(self, addr: Address) -> A2SClientProtocol:
279
+ """Get the A2S protocol for the given address, creating a new one
280
+ if it doesn't already exist.
281
+
282
+ :param addr: The address to bind to.
283
+
284
+ """
285
+ proto = self._protocols.get(addr)
286
+ if proto is not None:
287
+ return proto
288
+
289
+ proto = self._create_protocol()
290
+ self._protocols[addr] = proto
291
+ return proto
292
+
293
+ def _create_protocol(self) -> A2SClientProtocol:
294
+ """Create the A2S protocol to manage state.
295
+
296
+ This method can be overridden by subclasses.
297
+
298
+ """
299
+ return A2SClientProtocol()
300
+
301
+ # DatagramProtocol methods
302
+
303
+ def connection_made(self, transport: asyncio.DatagramTransport) -> None:
304
+ self._transport = transport
305
+
306
+ def connection_lost(self, exc: Exception | None) -> None:
307
+ if exc is None:
308
+ self._close_fut.set_result(None)
309
+ else:
310
+ self._close_fut.set_exception(exc)
311
+
312
+ def datagram_received(self, data: bytes, addr: Address) -> None:
313
+ proto = self._protocols.get(addr)
314
+ if proto is None:
315
+ return log.debug("Ignoring unexpected address %r", addr)
316
+
317
+ proto.receive_datagram(data)
318
+
319
+ for packet in proto.packets_to_send():
320
+ self.transport.sendto(bytes(packet), addr)
321
+
322
+ events = proto.events_received()
323
+ challenge = None
324
+
325
+ for e in events:
326
+ if isinstance(e, ClientEventChallenge):
327
+ challenge = e
328
+ continue
329
+
330
+ key = (addr, type(e))
331
+ fut = self._requests.get(key)
332
+ if fut is None or fut.done():
333
+ log.debug("Ignoring unexpected %s", type(e).__name__)
334
+ continue
335
+
336
+ fut.set_result(e)
337
+
338
+ if challenge is None:
339
+ return
340
+
341
+ for (fut_addr, _), fut in self._requests.items():
342
+ if addr == fut_addr and not fut.done():
343
+ fut.set_result(None)
344
+
345
+ async def _send(
346
+ self,
347
+ t: Type[ClientEventT],
348
+ addr: Address,
349
+ payload: Callable[[], ClientPacket],
350
+ ) -> ClientEventT:
351
+ key = (addr, t)
352
+
353
+ for _ in range(3):
354
+ async with self._claim_request(key) as fut:
355
+ self.transport.sendto(bytes(payload()), addr)
356
+ event = await fut
357
+
358
+ if event is not None:
359
+ return event
360
+
361
+ # FIXME: not really a timeout, should use a custom exception
362
+ raise TimeoutError(f"Server failed to respond with {t.__name__}")
363
+
364
+ @asynccontextmanager
365
+ async def _claim_request(
366
+ self,
367
+ key: tuple[Address, Type[ClientEvent]],
368
+ ) -> AsyncIterator[asyncio.Future]:
369
+ loop = asyncio.get_running_loop()
370
+
371
+ async with self._request_cond:
372
+ await self._request_cond.wait_for(lambda: key not in self._requests)
373
+ self._requests[key] = fut = loop.create_future()
374
+
375
+ try:
376
+ yield fut
377
+ finally:
378
+ async with self._request_cond:
379
+ self._requests.pop(key, None)
380
+ self._request_cond.notify_all()
381
+
382
+
383
+ class AsyncA2SGoldsource(AsyncA2S):
384
+ """A asynchronous client for A2S Goldsource queries."""
385
+
386
+ async def info(self, addr: Address | None = None) -> ClientEventGoldsourceInfo: # type: ignore
387
+ addr = self._get_addr(addr)
388
+ proto = self._get_protocol(addr)
389
+ return await self._send(ClientEventGoldsourceInfo, addr, proto.info)
390
+
391
+ def _create_protocol(self) -> A2SGoldsourceClientProtocol:
392
+ return A2SGoldsourceClientProtocol()
@@ -0,0 +1 @@
1
+ DEFAULT_TIMEOUT = 3.0