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.
- little_a2s-0.5.0/LICENSE +21 -0
- little_a2s-0.5.0/PKG-INFO +86 -0
- little_a2s-0.5.0/README.md +58 -0
- little_a2s-0.5.0/pyproject.toml +54 -0
- little_a2s-0.5.0/src/little_a2s/__init__.py +64 -0
- little_a2s-0.5.0/src/little_a2s/client/__init__.py +7 -0
- little_a2s-0.5.0/src/little_a2s/client/async_.py +392 -0
- little_a2s-0.5.0/src/little_a2s/client/constants.py +1 -0
- little_a2s-0.5.0/src/little_a2s/client/sync.py +333 -0
- little_a2s-0.5.0/src/little_a2s/client/types.py +43 -0
- little_a2s-0.5.0/src/little_a2s/enums.py +4 -0
- little_a2s-0.5.0/src/little_a2s/events.py +380 -0
- little_a2s-0.5.0/src/little_a2s/headers.py +60 -0
- little_a2s-0.5.0/src/little_a2s/packets.py +54 -0
- little_a2s-0.5.0/src/little_a2s/protocol.py +317 -0
- little_a2s-0.5.0/src/little_a2s/reader.py +177 -0
- little_a2s-0.5.0/src/little_a2s/rules/__init__.py +6 -0
- little_a2s-0.5.0/src/little_a2s/rules/arma3.py +211 -0
little_a2s-0.5.0/LICENSE
ADDED
|
@@ -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://pypi.org/project/little-a2s/)
|
|
32
|
+
[](http://little-a2s.readthedocs.io/)
|
|
33
|
+
[](https://docs.astral.sh/uv/)
|
|
34
|
+
[](https://docs.pytest.org/)
|
|
35
|
+
[](https://microsoft.github.io/pyright/#/)
|
|
36
|
+
[](https://docs.astral.sh/ruff/)
|
|
37
|
+
[](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://pypi.org/project/little-a2s/)
|
|
4
|
+
[](http://little-a2s.readthedocs.io/)
|
|
5
|
+
[](https://docs.astral.sh/uv/)
|
|
6
|
+
[](https://docs.pytest.org/)
|
|
7
|
+
[](https://microsoft.github.io/pyright/#/)
|
|
8
|
+
[](https://docs.astral.sh/ruff/)
|
|
9
|
+
[](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,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
|