rlstatsapi 0.1.5__tar.gz → 0.1.6__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 (23) hide show
  1. {rlstatsapi-0.1.5/src/rlstatsapi.egg-info → rlstatsapi-0.1.6}/PKG-INFO +2 -1
  2. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/pyproject.toml +2 -2
  3. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/__init__.py +4 -2
  4. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/__main__.py +6 -0
  5. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/client.py +137 -30
  6. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/config.py +28 -6
  7. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/models.py +31 -0
  8. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/state.py +55 -1
  9. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6/src/rlstatsapi.egg-info}/PKG-INFO +2 -1
  10. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi.egg-info/SOURCES.txt +2 -0
  11. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi.egg-info/requires.txt +1 -0
  12. rlstatsapi-0.1.6/tests/test_client.py +49 -0
  13. rlstatsapi-0.1.6/tests/test_client_unit.py +340 -0
  14. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/tests/test_config.py +10 -10
  15. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/LICENSE +0 -0
  16. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/README.md +0 -0
  17. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/setup.cfg +0 -0
  18. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/events.py +0 -0
  19. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/py.typed +0 -0
  20. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/types.py +0 -0
  21. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi.egg-info/dependency_links.txt +0 -0
  22. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi.egg-info/entry_points.txt +0 -0
  23. {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rlstatsapi
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Rocket League Stats API client
5
5
  Author: Manuel Cabral
6
6
  License-Expression: MIT
@@ -19,6 +19,7 @@ License-File: LICENSE
19
19
  Provides-Extra: dev
20
20
  Requires-Dist: pytest>=8; extra == "dev"
21
21
  Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
22
+ Requires-Dist: black; extra == "dev"
22
23
  Dynamic: license-file
23
24
 
24
25
  <img width="600" alt="rlstatsapi-logo" src="https://github.com/manucabral/RocketLeagueStatsAPI/blob/main/docs/assets/social-preview.png" />
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rlstatsapi"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "Rocket League Stats API client"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -28,7 +28,7 @@ Repository = "https://github.com/manucabral/RocketLeagueStatsAPI"
28
28
  rlstatsapi = "rlstatsapi.__main__:main"
29
29
 
30
30
  [project.optional-dependencies]
31
- dev = ["pytest>=8", "pytest-asyncio>=0.23"]
31
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "black"]
32
32
 
33
33
  [tool.pytest.ini_options]
34
34
  asyncio_mode = "auto"
@@ -8,7 +8,7 @@ helpers so consumers can import from `rlstatsapi` directly.
8
8
  from importlib.metadata import PackageNotFoundError
9
9
  from importlib.metadata import version as _pkg_version
10
10
 
11
- from .client import StatsClient
11
+ from .client import ConnectionState, StatsClient
12
12
  from .config import (
13
13
  DEFAULT_PACKET_SEND_RATE,
14
14
  DEFAULT_STATS_API_FILENAME,
@@ -22,7 +22,7 @@ from .config import (
22
22
  set_stats_api_port,
23
23
  )
24
24
  from .events import KNOWN_EVENTS
25
- from .models import ClientMetrics, EventMessage, MatchStateSnapshot
25
+ from .models import ClientMetrics, EventMessage, MatchStateSnapshot, PlayerSnapshot
26
26
  from .state import MatchStateTracker
27
27
  from .types import (
28
28
  BallHitPayload,
@@ -58,10 +58,12 @@ except PackageNotFoundError:
58
58
  __all__ = [
59
59
  "__version__",
60
60
  "StatsClient",
61
+ "ConnectionState",
61
62
  "StatsAPIConfigStatus",
62
63
  "EventMessage",
63
64
  "ClientMetrics",
64
65
  "MatchStateSnapshot",
66
+ "PlayerSnapshot",
65
67
  "MatchStateTracker",
66
68
  "KNOWN_EVENTS",
67
69
  "DEFAULT_PACKET_SEND_RATE",
@@ -26,6 +26,7 @@ def main(argv: Sequence[str] | None = None) -> int:
26
26
 
27
27
 
28
28
  def _build_parser() -> argparse.ArgumentParser:
29
+ """Build and return the top-level argument parser with all subcommands."""
29
30
  parser = argparse.ArgumentParser(prog="rlstatsapi")
30
31
  subparsers = parser.add_subparsers(dest="command", required=True)
31
32
 
@@ -87,12 +88,14 @@ def _build_parser() -> argparse.ArgumentParser:
87
88
 
88
89
 
89
90
  def _run_status(args: argparse.Namespace) -> int:
91
+ """Print the current TAStatsAPI.ini config as JSON."""
90
92
  status = get_stats_api_status(args.path)
91
93
  print(json.dumps(asdict(status), indent=2))
92
94
  return 0
93
95
 
94
96
 
95
97
  def _run_enable(args: argparse.Namespace) -> int:
98
+ """Enable the Stats API exporter in TAStatsAPI.ini and print the result."""
96
99
  status = configure_stats_api(
97
100
  enabled=True,
98
101
  port=args.port,
@@ -105,6 +108,7 @@ def _run_enable(args: argparse.Namespace) -> int:
105
108
 
106
109
 
107
110
  def _run_disable(args: argparse.Namespace) -> int:
111
+ """Disable the Stats API exporter in TAStatsAPI.ini and print the result."""
108
112
  status = configure_stats_api(
109
113
  enabled=False,
110
114
  port=args.port,
@@ -117,10 +121,12 @@ def _run_disable(args: argparse.Namespace) -> int:
117
121
 
118
122
 
119
123
  def _run_listen(args: argparse.Namespace) -> int:
124
+ """Bridge the sync CLI entry point into the async ``_listen`` coroutine."""
120
125
  return asyncio.run(_listen(args))
121
126
 
122
127
 
123
128
  async def _listen(args: argparse.Namespace) -> int:
129
+ """Connect to the local Stats API and stream events to stdout until Ctrl+C."""
124
130
  async with StatsClient(
125
131
  host=args.host,
126
132
  port=args.port,
@@ -8,11 +8,13 @@ from __future__ import annotations
8
8
 
9
9
  import asyncio
10
10
  import contextlib
11
+ import functools
11
12
  import inspect
12
13
  import json
13
14
  import logging
14
15
  import random
15
16
  from collections import defaultdict
17
+ from enum import Enum
16
18
  from typing import Any, AsyncIterator, Awaitable, Callable, Iterable, Literal, overload
17
19
 
18
20
  from .models import ClientMetrics, EventMessage, MatchStateSnapshot
@@ -43,11 +45,22 @@ from .types import (
43
45
  Handler = Callable[[EventMessage], Awaitable[None] | None]
44
46
  _AnyCallable = Callable[..., Any]
45
47
  _SimpleHandler = Callable[[], Awaitable[None] | None]
46
- _ErrorHandler = Callable[[str, Exception], Awaitable[None] | None]
48
+ _ErrorHandler = Callable[
49
+ [EventMessage, Exception, _AnyCallable], Awaitable[None] | None
50
+ ]
51
+
52
+
53
+ class ConnectionState(Enum):
54
+ """Granular connection lifecycle state for a StatsClient."""
55
+
56
+ DISCONNECTED = "disconnected"
57
+ CONNECTING = "connecting"
58
+ CONNECTED = "connected"
59
+ FAILED = "failed"
47
60
 
48
61
 
49
62
  class StatsClient:
50
- """High-level event client for Rocket League Stats API."""
63
+ """Event client for Rocket League Stats API."""
51
64
 
52
65
  def __init__(
53
66
  self,
@@ -62,7 +75,31 @@ class StatsClient:
62
75
  overflow: Literal["block", "drop", "raise"] = "block",
63
76
  connect_timeout: float = 5.0,
64
77
  drain_on_disconnect: bool = False,
78
+ handler_timeout: float | None = None,
65
79
  ) -> None:
80
+ """Configure the client.
81
+
82
+ Does not connect until ``connect()`` or async context manager entry.
83
+
84
+ Args:
85
+ host: Stats API host address.
86
+ port: Stats API TCP port.
87
+ reconnect: Whether to reconnect automatically on connection loss.
88
+ reconnect_delay: Initial delay in seconds before the first reconnect attempt.
89
+ max_reconnect_delay: Upper bound for exponential backoff delay.
90
+ max_reconnect_attempts: Stop reconnecting after this many failures.
91
+ None means unlimited.
92
+ include_raw: Attach the original JSON string to each ``EventMessage.raw``.
93
+ queue_size: Max events buffered in the internal queue.
94
+ overflow: Queue-full behavior ``"block"`` waits, ``"drop"`` discards,
95
+ ``"raise"`` kills the connection.
96
+ connect_timeout: Seconds to wait for TCP handshake before raising.
97
+ drain_on_disconnect: Clear the queue when the session ends.
98
+ handler_timeout: Max seconds an async handler may run before being cancelled.
99
+ Sync handlers are not affected. None disables the timeout.
100
+ """
101
+ if reconnect_delay <= 0:
102
+ raise ValueError("reconnect_delay must be positive")
66
103
  self.host = host
67
104
  self.port = port
68
105
  self.reconnect = reconnect
@@ -73,6 +110,7 @@ class StatsClient:
73
110
  self.connect_timeout = connect_timeout
74
111
  self.overflow = overflow
75
112
  self.drain_on_disconnect = drain_on_disconnect
113
+ self.handler_timeout = handler_timeout
76
114
 
77
115
  self._queue: asyncio.Queue[EventMessage] = asyncio.Queue(maxsize=queue_size)
78
116
  self._handlers_by_event: dict[str, list[_AnyCallable]] = defaultdict(list)
@@ -86,18 +124,21 @@ class StatsClient:
86
124
  self._writer: asyncio.StreamWriter | None = None
87
125
  self._reader_task: asyncio.Task[None] | None = None
88
126
  self._stopping = False
89
- self._connected = False
127
+ self._connection_state = ConnectionState.DISCONNECTED
90
128
  self._permanently_failed = False
91
129
  self._last_error: Exception | None = None
92
130
  self._metrics = ClientMetrics()
93
131
  self._state_tracker = MatchStateTracker()
94
132
 
133
+ @property
134
+ def connection_state(self) -> ConnectionState:
135
+ """Return the current connection lifecycle state."""
136
+ return self._connection_state
137
+
95
138
  @property
96
139
  def is_connected(self) -> bool:
97
- """
98
- Returns True if the client is currently connected to the Stats API.
99
- """
100
- return self._connected
140
+ """Return True if the client is currently connected to the Stats API."""
141
+ return self._connection_state == ConnectionState.CONNECTED
101
142
 
102
143
  @property
103
144
  def metrics(self) -> ClientMetrics:
@@ -120,13 +161,16 @@ class StatsClient:
120
161
  return self._permanently_failed
121
162
 
122
163
  async def __aenter__(self) -> StatsClient:
164
+ """Connect and return self for use as an async context manager."""
123
165
  await self.connect()
124
166
  return self
125
167
 
126
168
  async def __aexit__(self, *_: object) -> None:
169
+ """Disconnect when leaving the async context manager."""
127
170
  await self.disconnect()
128
171
 
129
172
  async def connect(self) -> None:
173
+ """Start the background reader task and begin connecting. No-op if already running."""
130
174
  if self._reader_task and not self._reader_task.done():
131
175
  self._logger.debug("connect() ignored: reader already running")
132
176
  return
@@ -137,15 +181,17 @@ class StatsClient:
137
181
  self._reader_task = asyncio.create_task(self._run(), name="rlstatsapi-reader")
138
182
  self._logger.debug("reader task started")
139
183
 
140
- async def disconnect(self) -> None:
141
- """
142
- Disconnects from the Stats API and stops the reader task.
184
+ async def disconnect(self, timeout: float = 5.0) -> None:
185
+ """Disconnect from the Stats API and stop the reader task.
186
+
187
+ Args:
188
+ timeout: Seconds to wait for the reader task to finish before giving up.
143
189
  """
144
190
  self._stopping = True
145
191
  self._logger.debug("disconnect requested")
146
192
 
147
193
  if self._writer is not None:
148
- with contextlib.suppress(Exception):
194
+ with contextlib.suppress(OSError, asyncio.TimeoutError):
149
195
  self._writer.close()
150
196
  await self._writer.wait_closed()
151
197
  self._writer = None
@@ -153,8 +199,8 @@ class StatsClient:
153
199
 
154
200
  if self._reader_task is not None:
155
201
  self._reader_task.cancel()
156
- with contextlib.suppress(asyncio.CancelledError):
157
- await self._reader_task
202
+ with contextlib.suppress(asyncio.TimeoutError, asyncio.CancelledError):
203
+ await asyncio.wait_for(self._reader_task, timeout=timeout)
158
204
  self._reader_task = None
159
205
  if self.drain_on_disconnect:
160
206
  self.clear_queue()
@@ -460,8 +506,12 @@ class StatsClient:
460
506
  if handler is None:
461
507
 
462
508
  def decorator(h: _AnyCallable) -> _AnyCallable:
463
- self._handlers_by_event[event_name].append(h)
464
- return h
509
+ @functools.wraps(h)
510
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
511
+ return h(*args, **kwargs)
512
+
513
+ self._handlers_by_event[event_name].append(wrapper)
514
+ return wrapper
465
515
 
466
516
  return decorator
467
517
  self._handlers_by_event[event_name].append(handler)
@@ -490,9 +540,11 @@ class StatsClient:
490
540
 
491
541
  def clear_queue(self) -> None:
492
542
  """Drop any queued events that have not been consumed yet."""
493
- while not self._queue.empty():
494
- with contextlib.suppress(asyncio.QueueEmpty):
543
+ while True:
544
+ try:
495
545
  self._queue.get_nowait()
546
+ except asyncio.QueueEmpty:
547
+ break
496
548
 
497
549
  def once(self, event_name: str, handler: _AnyCallable) -> None:
498
550
  """Register a handler that runs once and removes itself automatically."""
@@ -682,17 +734,51 @@ class StatsClient:
682
734
  if not filters or message.event in filters:
683
735
  yield message
684
736
 
737
+ async def wait_for(
738
+ self,
739
+ event_name: str,
740
+ *,
741
+ timeout: float | None = None,
742
+ ) -> EventMessage:
743
+ """Wait until the next occurrence of ``event_name`` and return the message.
744
+
745
+ Args:
746
+ event_name: The event to wait for (e.g. ``"GoalScored"``).
747
+ timeout: Seconds before raising ``asyncio.TimeoutError``. None waits forever.
748
+
749
+ Returns:
750
+ The first matching ``EventMessage`` received after this call.
751
+
752
+ Raises:
753
+ asyncio.TimeoutError: If ``timeout`` elapses before the event arrives.
754
+ """
755
+ loop = asyncio.get_event_loop()
756
+ future: asyncio.Future[EventMessage] = loop.create_future()
757
+
758
+ def _handler(msg: EventMessage) -> None:
759
+ if not future.done():
760
+ self.off(event_name, _handler)
761
+ future.set_result(msg)
762
+
763
+ self._handlers_by_event[event_name].append(_handler)
764
+ try:
765
+ return await asyncio.wait_for(future, timeout=timeout)
766
+ except asyncio.TimeoutError:
767
+ self.off(event_name, _handler)
768
+ raise
769
+
685
770
  async def _run(self) -> None:
686
771
  """Main loop to manage connection and reading from the Stats API."""
687
772
  attempt = 0
688
773
  while not self._stopping:
689
774
  try:
690
775
  self._logger.debug("connecting to tcp://%s:%d", self.host, self.port)
776
+ self._connection_state = ConnectionState.CONNECTING
691
777
  self._reader, self._writer = await asyncio.wait_for(
692
778
  asyncio.open_connection(host=self.host, port=self.port),
693
779
  timeout=self.connect_timeout,
694
780
  )
695
- self._connected = True
781
+ self._connection_state = ConnectionState.CONNECTED
696
782
  attempt = 0
697
783
  self._logger.debug("tcp socket connected")
698
784
  await self._fire_simple(self._on_connect_handlers)
@@ -716,6 +802,7 @@ class StatsClient:
716
802
  and attempt > self.max_reconnect_attempts
717
803
  ):
718
804
  self._permanently_failed = True
805
+ self._connection_state = ConnectionState.FAILED
719
806
  self._logger.debug(
720
807
  "max reconnect attempts (%d) reached",
721
808
  self.max_reconnect_attempts,
@@ -728,11 +815,11 @@ class StatsClient:
728
815
  self._logger.debug("reconnecting in %.2fs (attempt %d)", delay, attempt)
729
816
  await asyncio.sleep(delay)
730
817
  finally:
731
- if self._connected:
732
- self._connected = False
818
+ if self._connection_state == ConnectionState.CONNECTED:
819
+ self._connection_state = ConnectionState.DISCONNECTED
733
820
  await self._fire_simple(self._on_disconnect_handlers)
734
821
  if self._writer is not None:
735
- with contextlib.suppress(Exception):
822
+ with contextlib.suppress(OSError, asyncio.TimeoutError):
736
823
  self._writer.close()
737
824
  await self._writer.wait_closed()
738
825
  self._writer = None
@@ -749,14 +836,19 @@ class StatsClient:
749
836
  chunk = await reader.read(4096)
750
837
  if not chunk:
751
838
  raise ConnectionAbortedError("Socket closed by peer")
752
- buffer += chunk.decode("utf-8", errors="ignore")
839
+ decoded_chunk = chunk.decode("utf-8", errors="replace")
840
+ if "�" in decoded_chunk:
841
+ self._logger.warning(
842
+ "received invalid UTF-8 bytes; replaced with U+FFFD"
843
+ )
844
+ buffer += decoded_chunk
753
845
 
754
846
  while True:
755
847
  lstripped = buffer.lstrip()
756
848
  if not lstripped:
757
849
  buffer = ""
758
850
  break
759
- if lstripped is not buffer:
851
+ if lstripped != buffer:
760
852
  buffer = lstripped
761
853
 
762
854
  try:
@@ -796,9 +888,7 @@ class StatsClient:
796
888
  await self._dispatch(message)
797
889
 
798
890
  async def _dispatch(self, message: EventMessage) -> None:
799
- """
800
- Dispatches to handlers for `message.event` and global handlers.
801
- """
891
+ """Dispatches to handlers for `message.event` and global handlers."""
802
892
  handlers = list(self._handlers_any) + list(
803
893
  self._handlers_by_event.get(message.event, [])
804
894
  )
@@ -806,25 +896,40 @@ class StatsClient:
806
896
  try:
807
897
  result = handler(message)
808
898
  if inspect.isawaitable(result):
809
- await result
899
+ if (
900
+ self.handler_timeout is not None
901
+ and inspect.iscoroutinefunction(handler)
902
+ ):
903
+ await asyncio.wait_for(result, timeout=self.handler_timeout)
904
+ else:
905
+ await result
810
906
  except Exception as exc:
811
907
  self._metrics.handler_errors += 1
812
908
  if self._error_handlers:
813
909
  for error_handler in list(self._error_handlers):
814
- with contextlib.suppress(Exception):
815
- r = error_handler(message.event, exc)
910
+ try:
911
+ r = error_handler(message, exc, handler)
816
912
  if inspect.isawaitable(r):
817
913
  await r
914
+ except Exception as err_exc:
915
+ self._logger.error(
916
+ "error handler itself raised: %s", err_exc
917
+ )
818
918
  else:
819
919
  self._logger.error("handler error for %s: %s", message.event, exc)
820
920
 
821
921
  async def _fire_simple(self, handlers: list[_SimpleHandler]) -> None:
822
922
  """Run connection lifecycle callbacks and await async ones when needed."""
823
923
  for handler in list(handlers):
824
- with contextlib.suppress(Exception):
924
+ try:
825
925
  result = handler()
826
926
  if inspect.isawaitable(result):
827
927
  await result
928
+ except Exception as exc:
929
+ self._logger.error("lifecycle handler error: %s", exc)
930
+
931
+
932
+ _MAX_JSON_FIELD_BYTES = 1 << 20 # 1 MiB
828
933
 
829
934
 
830
935
  def _parse_message_obj(
@@ -836,6 +941,8 @@ def _parse_message_obj(
836
941
  event = decoded.get("Event")
837
942
  data = decoded.get("Data")
838
943
  if isinstance(data, str):
944
+ if len(data) > _MAX_JSON_FIELD_BYTES:
945
+ return None
839
946
  try:
840
947
  data = json.loads(data)
841
948
  except json.JSONDecodeError:
@@ -7,10 +7,13 @@ prepare the game config before starting Rocket League and then connect with
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import logging
10
11
  import os
11
12
  from dataclasses import dataclass
12
13
  from pathlib import Path
13
14
 
15
+ _logger = logging.getLogger("rlstatsapi.config")
16
+
14
17
  DEFAULT_PACKET_SEND_RATE = 30
15
18
  DEFAULT_STATS_API_PORT = 49123
16
19
  DEFAULT_STATS_API_FILENAME = "TAStatsAPI.ini"
@@ -165,6 +168,7 @@ def configure_stats_api(
165
168
 
166
169
 
167
170
  def _require_config_path(path: str | Path | None) -> Path:
171
+ """Return the resolved config path or raise ``FileNotFoundError`` if not found."""
168
172
  config_path = find_stats_api_config(path)
169
173
  if config_path is None:
170
174
  raise FileNotFoundError(_WARNING)
@@ -172,6 +176,7 @@ def _require_config_path(path: str | Path | None) -> Path:
172
176
 
173
177
 
174
178
  def _parse_int(value: str | None) -> int | None:
179
+ """Parse a string to int, returning None on failure or missing input."""
175
180
  if value is None:
176
181
  return None
177
182
  try:
@@ -181,10 +186,14 @@ def _parse_int(value: str | None) -> int | None:
181
186
 
182
187
 
183
188
  def _read_key_values(text: str) -> dict[str, str]:
189
+ """Parse ``key=value`` pairs from INI-style text, ignoring comments and blank lines."""
184
190
  values: dict[str, str] = {}
185
191
  for line in text.splitlines():
186
192
  stripped = line.strip()
187
- if not stripped or stripped.startswith(("#", ";")) or "=" not in stripped:
193
+ if not stripped or stripped.startswith(("#", ";")):
194
+ continue
195
+ if "=" not in stripped:
196
+ _logger.warning("skipped malformed config line: %r", stripped)
188
197
  continue
189
198
  key, value = stripped.split("=", 1)
190
199
  values[key.strip().casefold()] = value.strip()
@@ -192,7 +201,18 @@ def _read_key_values(text: str) -> dict[str, str]:
192
201
 
193
202
 
194
203
  def _status_for_path(path: Path) -> StatsAPIConfigStatus:
195
- text = path.read_text(encoding="utf-8")
204
+ """Build a ``StatsAPIConfigStatus`` by reading and parsing an existing config file."""
205
+ try:
206
+ text = path.read_text(encoding="utf-8")
207
+ except FileNotFoundError:
208
+ return StatsAPIConfigStatus(
209
+ found=False,
210
+ enabled=False,
211
+ path=str(path),
212
+ packet_send_rate=None,
213
+ port=None,
214
+ warning=_WARNING,
215
+ )
196
216
  values = _read_key_values(text)
197
217
  packet_send_rate = _parse_int(values.get("packetsendrate"))
198
218
  port = _parse_int(values.get("port"))
@@ -207,6 +227,7 @@ def _status_for_path(path: Path) -> StatsAPIConfigStatus:
207
227
 
208
228
 
209
229
  def _set_or_append_key(lines: list[str], key: str, value: str) -> list[str]:
230
+ """Replace an existing ``key=value`` line in-place, or append it if not present."""
210
231
  output: list[str] = []
211
232
  replaced = False
212
233
  for line in lines:
@@ -227,17 +248,18 @@ def _set_or_append_key(lines: list[str], key: str, value: str) -> list[str]:
227
248
 
228
249
 
229
250
  def _write_lines(path: Path, lines: list[str], original_text: str) -> None:
251
+ """Write lines back to file, preserving the original trailing newline."""
230
252
  newline = "\n" if original_text.endswith(("\n", "\r\n")) else ""
231
253
  path.write_text("\n".join(lines) + newline, encoding="utf-8")
232
254
 
233
255
 
234
256
  def _validate_packet_send_rate(packet_send_rate: int) -> None:
235
- if packet_send_rate < 0 or packet_send_rate > 120:
236
- raise ValueError("packet_send_rate must be between 0 and 120")
237
- if packet_send_rate == 0:
238
- raise ValueError("packet_send_rate must be greater than 0 when enabled")
257
+ """Raise ``ValueError`` if packet_send_rate is outside the valid range [1, 120]."""
258
+ if packet_send_rate < 1 or packet_send_rate > 120:
259
+ raise ValueError("packet_send_rate must be between 1 and 120 when enabled")
239
260
 
240
261
 
241
262
  def _validate_port(port: int) -> None:
263
+ """Raise ``ValueError`` if port is outside the valid range [1, 65535]."""
242
264
  if port < 1 or port > 65535:
243
265
  raise ValueError("port must be between 1 and 65535")
@@ -7,6 +7,7 @@ through internal queues and user callbacks.
7
7
  from __future__ import annotations
8
8
 
9
9
  from dataclasses import dataclass, field
10
+ from datetime import datetime
10
11
  from typing import Any
11
12
 
12
13
  from .types import EventName, TypedEventMessage, cast_event_data
@@ -40,6 +41,34 @@ class ClientMetrics:
40
41
  reconnect_count: int = 0
41
42
  handler_errors: int = 0
42
43
  connection_failures: int = 0
44
+ started_at: datetime = field(default_factory=datetime.now)
45
+
46
+ def reset(self) -> None:
47
+ """Reset all counters and update started_at to now."""
48
+ self.received_events = 0
49
+ self.queued_events = 0
50
+ self.dropped_events = 0
51
+ self.reconnect_count = 0
52
+ self.handler_errors = 0
53
+ self.connection_failures = 0
54
+ self.started_at = datetime.now()
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class PlayerSnapshot:
59
+ """Per-player state extracted from the latest UpdateState event."""
60
+
61
+ name: str
62
+ shortcut: int
63
+ team_num: int
64
+ score: int = 0
65
+ goals: int = 0
66
+ assists: int = 0
67
+ saves: int = 0
68
+ shots: int = 0
69
+ boost: int = 0
70
+ speed: float = 0.0
71
+ is_demolished: bool = False
43
72
 
44
73
 
45
74
  @dataclass(slots=True)
@@ -58,4 +87,6 @@ class MatchStateSnapshot:
58
87
  last_event: str | None = None
59
88
  last_scorer: str | None = None
60
89
  last_goal_speed: float | None = None
90
+ players: list[PlayerSnapshot] = field(default_factory=list)
91
+ target_player: PlayerSnapshot | None = None
61
92
  event_counts: dict[str, int] = field(default_factory=dict)
@@ -8,7 +8,7 @@ without forcing users to rebuild the same state cache in every script.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- from .models import EventMessage, MatchStateSnapshot
11
+ from .models import EventMessage, MatchStateSnapshot, PlayerSnapshot
12
12
 
13
13
 
14
14
  class MatchStateTracker:
@@ -58,11 +58,13 @@ class MatchStateTracker:
58
58
  snapshot: MatchStateSnapshot,
59
59
  message: EventMessage,
60
60
  ) -> None:
61
+ """Extract game-level fields from an UpdateState payload into the snapshot."""
61
62
  game = message.data.get("Game", {})
62
63
  if not isinstance(game, dict):
63
64
  return
64
65
 
65
66
  self._apply_teams(snapshot, game.get("Teams", []))
67
+ self._apply_players(snapshot, message.data.get("Players", []), game)
66
68
 
67
69
  time_seconds = game.get("TimeSeconds")
68
70
  if isinstance(time_seconds, int):
@@ -81,6 +83,7 @@ class MatchStateTracker:
81
83
  snapshot.winner = winner
82
84
 
83
85
  def _apply_teams(self, snapshot: MatchStateSnapshot, teams: object) -> None:
86
+ """Update blue/orange score from the Teams list in an UpdateState payload."""
84
87
  if not isinstance(teams, list):
85
88
  return
86
89
 
@@ -94,11 +97,62 @@ class MatchStateTracker:
94
97
  if team_num == 1 and isinstance(score, int):
95
98
  snapshot.orange_score = score
96
99
 
100
+ def _apply_players(
101
+ self,
102
+ snapshot: MatchStateSnapshot,
103
+ players: object,
104
+ game: dict,
105
+ ) -> None:
106
+ """Rebuild the players list and resolve target_player from UpdateState."""
107
+ if not isinstance(players, list):
108
+ snapshot.players = []
109
+ snapshot.target_player = None
110
+ return
111
+
112
+ result: list[PlayerSnapshot] = []
113
+ for p in players:
114
+ if not isinstance(p, dict):
115
+ continue
116
+ name = p.get("Name")
117
+ shortcut = p.get("Shortcut")
118
+ team_num = p.get("TeamNum")
119
+ if not isinstance(name, str) or not isinstance(shortcut, int):
120
+ continue
121
+ result.append(
122
+ PlayerSnapshot(
123
+ name=name,
124
+ shortcut=shortcut,
125
+ team_num=team_num if isinstance(team_num, int) else 0,
126
+ score=p.get("Score") or 0,
127
+ goals=p.get("Goals") or 0,
128
+ assists=p.get("Assists") or 0,
129
+ saves=p.get("Saves") or 0,
130
+ shots=p.get("Shots") or 0,
131
+ boost=p.get("Boost") or 0,
132
+ speed=float(p.get("Speed") or 0.0),
133
+ is_demolished=bool(p.get("bDemolished", False)),
134
+ )
135
+ )
136
+ snapshot.players = result
137
+
138
+ target_player: PlayerSnapshot | None = None
139
+ if game.get("bHasTarget"):
140
+ target = game.get("Target")
141
+ if isinstance(target, dict):
142
+ target_shortcut = target.get("Shortcut")
143
+ if isinstance(target_shortcut, int):
144
+ for player in result:
145
+ if player.shortcut == target_shortcut:
146
+ target_player = player
147
+ break
148
+ snapshot.target_player = target_player
149
+
97
150
  def _apply_goal_scored(
98
151
  self,
99
152
  snapshot: MatchStateSnapshot,
100
153
  message: EventMessage,
101
154
  ) -> None:
155
+ """Update last scorer name and goal speed from a GoalScored payload."""
102
156
  scorer = message.data.get("Scorer", {})
103
157
  if isinstance(scorer, dict):
104
158
  name = scorer.get("Name")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rlstatsapi
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Rocket League Stats API client
5
5
  Author: Manuel Cabral
6
6
  License-Expression: MIT
@@ -19,6 +19,7 @@ License-File: LICENSE
19
19
  Provides-Extra: dev
20
20
  Requires-Dist: pytest>=8; extra == "dev"
21
21
  Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
22
+ Requires-Dist: black; extra == "dev"
22
23
  Dynamic: license-file
23
24
 
24
25
  <img width="600" alt="rlstatsapi-logo" src="https://github.com/manucabral/RocketLeagueStatsAPI/blob/main/docs/assets/social-preview.png" />
@@ -16,4 +16,6 @@ src/rlstatsapi.egg-info/dependency_links.txt
16
16
  src/rlstatsapi.egg-info/entry_points.txt
17
17
  src/rlstatsapi.egg-info/requires.txt
18
18
  src/rlstatsapi.egg-info/top_level.txt
19
+ tests/test_client.py
20
+ tests/test_client_unit.py
19
21
  tests/test_config.py
@@ -2,3 +2,4 @@
2
2
  [dev]
3
3
  pytest>=8
4
4
  pytest-asyncio>=0.23
5
+ black
@@ -0,0 +1,49 @@
1
+ """Real integration test for StatsClient against a live Rocket League exporter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import socket
8
+
9
+ import pytest
10
+
11
+ from rlstatsapi import StatsClient
12
+
13
+ RL_STATSAPI_HOST = os.getenv("RL_STATSAPI_HOST", "127.0.0.1")
14
+ RL_STATSAPI_PORT = int(os.getenv("RL_STATSAPI_PORT", "49123"))
15
+
16
+
17
+ def _tcp_open(host: str, port: int) -> bool:
18
+ try:
19
+ with socket.create_connection((host, port), timeout=1.0):
20
+ return True
21
+ except OSError:
22
+ return False
23
+
24
+
25
+ @pytest.mark.asyncio
26
+ @pytest.mark.integration
27
+ async def test_client_receives_real_event() -> None:
28
+ """Connects to a real RL exporter and verifies one event is received."""
29
+
30
+ if not _tcp_open(RL_STATSAPI_HOST, RL_STATSAPI_PORT):
31
+ pytest.skip(
32
+ f"Rocket League exporter is not reachable at {RL_STATSAPI_HOST}:{RL_STATSAPI_PORT}"
33
+ )
34
+
35
+ client = StatsClient(
36
+ host=RL_STATSAPI_HOST,
37
+ port=RL_STATSAPI_PORT,
38
+ reconnect=False,
39
+ connect_timeout=2.0,
40
+ )
41
+
42
+ await client.connect()
43
+ try:
44
+ message = await asyncio.wait_for(client.events().__anext__(), timeout=3.0)
45
+ assert isinstance(message.event, str)
46
+ assert isinstance(message.data, dict)
47
+ assert message.event
48
+ finally:
49
+ await client.disconnect()
@@ -0,0 +1,340 @@
1
+ """Unit coverage for client parsing, filtering, and convenience helpers."""
2
+
3
+ # pylint: disable=protected-access
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ from datetime import datetime
9
+ from typing import Any, Callable
10
+
11
+ import pytest
12
+
13
+ from rlstatsapi.client import ConnectionState, StatsClient, _parse_message_obj
14
+ from rlstatsapi.models import EventMessage, PlayerSnapshot
15
+
16
+
17
+ def test_parse_message_decodes_string_data_payload() -> None:
18
+ message = _parse_message_obj(
19
+ {
20
+ "Event": "GoalScored",
21
+ "Data": '{"Scorer":{"Name":"PlayerA","Shortcut":1,"TeamNum":0}}',
22
+ },
23
+ raw="{}",
24
+ include_raw=True,
25
+ )
26
+
27
+ assert message is not None
28
+ assert message.event == "GoalScored"
29
+ assert message.data["Scorer"]["Name"] == "PlayerA"
30
+ assert message.raw == "{}"
31
+
32
+
33
+ def test_event_message_as_type_exposes_typed_data() -> None:
34
+ message = EventMessage(
35
+ event="GoalScored",
36
+ data={"Scorer": {"Name": "PlayerA", "Shortcut": 1, "TeamNum": 0}},
37
+ )
38
+
39
+ typed_message = message.as_type("GoalScored")
40
+
41
+ assert typed_message.event == "GoalScored"
42
+ assert typed_message.data["Scorer"]["Name"] == "PlayerA"
43
+
44
+
45
+ @pytest.mark.asyncio
46
+ async def test_events_filter_only_yields_requested_names() -> None:
47
+ client = StatsClient(reconnect=False)
48
+ await client._queue.put(EventMessage(event="UpdateState", data={}))
49
+ await client._queue.put(EventMessage(event="GoalScored", data={}))
50
+
51
+ iterator = client.events("GoalScored")
52
+ message = await asyncio.wait_for(iterator.__anext__(), timeout=1.0)
53
+
54
+ assert message.event == "GoalScored"
55
+
56
+
57
+ def test_on_many_registers_same_handler_for_multiple_events() -> None:
58
+ client = StatsClient(reconnect=False)
59
+
60
+ def handler(_message: EventMessage) -> None:
61
+ return None
62
+
63
+ client.on_many(["GoalScored", "MatchEnded"], handler)
64
+
65
+ assert handler in client._handlers_by_event["GoalScored"]
66
+ assert handler in client._handlers_by_event["MatchEnded"]
67
+
68
+
69
+ def test_off_removes_specific_handler() -> None:
70
+ client = StatsClient(reconnect=False)
71
+
72
+ def handler(_: EventMessage) -> None:
73
+ return None
74
+
75
+ client.on("GoalScored", handler)
76
+ client.off("GoalScored", handler)
77
+
78
+ assert handler not in client._handlers_by_event["GoalScored"]
79
+
80
+
81
+ def test_off_any_removes_global_handler() -> None:
82
+ client = StatsClient(reconnect=False)
83
+
84
+ def handler(_: EventMessage) -> None:
85
+ return None
86
+
87
+ client.on_any(handler)
88
+ client.off_any(handler)
89
+
90
+ assert handler not in client._handlers_any
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_once_handler_fires_once_and_removes_itself() -> None:
95
+ client = StatsClient(reconnect=False)
96
+ calls: list[str] = []
97
+
98
+ def handler(msg: EventMessage) -> None:
99
+ calls.append(msg.event)
100
+
101
+ client.once("GoalScored", handler)
102
+ msg = EventMessage(event="GoalScored", data={})
103
+ await client._dispatch(msg)
104
+ await client._dispatch(msg)
105
+
106
+ assert len(calls) == 1
107
+
108
+
109
+ def test_clear_queue_empties_all_items() -> None:
110
+ client = StatsClient(reconnect=False)
111
+ client._queue.put_nowait(EventMessage(event="A", data={}))
112
+ client._queue.put_nowait(EventMessage(event="B", data={}))
113
+ client.clear_queue()
114
+
115
+ assert client._queue.empty()
116
+
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_events_no_filter_yields_all() -> None:
120
+ client = StatsClient(reconnect=False)
121
+ await client._queue.put(EventMessage(event="UpdateState", data={}))
122
+ await client._queue.put(EventMessage(event="GoalScored", data={}))
123
+
124
+ iterator = client.events()
125
+ msg1 = await asyncio.wait_for(iterator.__anext__(), timeout=1.0)
126
+ msg2 = await asyncio.wait_for(iterator.__anext__(), timeout=1.0)
127
+
128
+ assert {msg1.event, msg2.event} == {"UpdateState", "GoalScored"}
129
+
130
+
131
+ @pytest.mark.asyncio
132
+ async def test_overflow_drop_increments_dropped_metric() -> None:
133
+ client = StatsClient(reconnect=False, queue_size=1, overflow="drop")
134
+ client._queue.put_nowait(EventMessage(event="A", data={}))
135
+
136
+ try:
137
+ client._queue.put_nowait(EventMessage(event="B", data={}))
138
+ client._metrics.queued_events += 1
139
+ except asyncio.QueueFull:
140
+ client._metrics.dropped_events += 1
141
+
142
+ assert client._metrics.dropped_events == 1
143
+
144
+
145
+ @pytest.mark.asyncio
146
+ async def test_on_handler_error_called_on_handler_exception() -> None:
147
+ client = StatsClient(reconnect=False)
148
+ errors: list[tuple[EventMessage, Exception, Callable[..., Any]]] = []
149
+
150
+ def error_handler(
151
+ msg: EventMessage, exc: Exception, handler: Callable[..., Any]
152
+ ) -> None:
153
+ errors.append((msg, exc, handler))
154
+
155
+ client.on_handler_error(error_handler)
156
+
157
+ def bad_handler(_: EventMessage) -> None:
158
+ raise RuntimeError("boom")
159
+
160
+ client.on("GoalScored", bad_handler)
161
+ msg = EventMessage(event="GoalScored", data={})
162
+ await client._dispatch(msg)
163
+
164
+ assert len(errors) == 1
165
+ assert errors[0][0] is msg
166
+ assert isinstance(errors[0][1], RuntimeError)
167
+ assert errors[0][2] is bad_handler
168
+
169
+
170
+ def test_reconnect_delay_zero_raises() -> None:
171
+ with pytest.raises(ValueError, match="reconnect_delay"):
172
+ StatsClient(reconnect_delay=0)
173
+
174
+
175
+ def test_state_tracker_updates_from_update_state() -> None:
176
+ client = StatsClient(reconnect=False)
177
+ client._state_tracker.update(
178
+ EventMessage(
179
+ event="UpdateState",
180
+ data={
181
+ "MatchGuid": "abc",
182
+ "Game": {
183
+ "TimeSeconds": 120,
184
+ "bOvertime": False,
185
+ "Arena": "Stadium_P",
186
+ "Teams": [
187
+ {"TeamNum": 0, "Score": 2},
188
+ {"TeamNum": 1, "Score": 1},
189
+ ],
190
+ },
191
+ },
192
+ )
193
+ )
194
+
195
+ assert client.state.match_guid == "abc"
196
+ assert client.state.blue_score == 2
197
+ assert client.state.orange_score == 1
198
+ assert client.state.time_seconds == 120
199
+ assert client.state.arena == "Stadium_P"
200
+
201
+
202
+ def test_connection_state_starts_disconnected() -> None:
203
+ client = StatsClient(reconnect=False)
204
+ assert client.connection_state == ConnectionState.DISCONNECTED
205
+ assert not client.is_connected
206
+
207
+
208
+ def test_metrics_reset_clears_counters() -> None:
209
+ client = StatsClient(reconnect=False)
210
+ client._metrics.received_events = 10
211
+ client._metrics.handler_errors = 3
212
+ before = client.metrics.started_at
213
+ client.metrics.reset()
214
+ assert client.metrics.received_events == 0
215
+ assert client.metrics.handler_errors == 0
216
+ assert client.metrics.started_at >= before
217
+
218
+
219
+ def test_metrics_started_at_is_set() -> None:
220
+ client = StatsClient(reconnect=False)
221
+ assert isinstance(client.metrics.started_at, datetime)
222
+
223
+
224
+ @pytest.mark.asyncio
225
+ async def test_wait_for_resolves_on_matching_event() -> None:
226
+ client = StatsClient(reconnect=False)
227
+ msg = EventMessage(event="GoalScored", data={"GoalSpeed": 99.0})
228
+
229
+ async def dispatch_later() -> None:
230
+ await asyncio.sleep(0)
231
+ await client._dispatch(msg)
232
+
233
+ asyncio.create_task(dispatch_later())
234
+ result = await asyncio.wait_for(client.wait_for("GoalScored"), timeout=1.0)
235
+ assert result is msg
236
+
237
+
238
+ @pytest.mark.asyncio
239
+ async def test_wait_for_raises_timeout_when_event_never_arrives() -> None:
240
+ client = StatsClient(reconnect=False)
241
+ with pytest.raises(asyncio.TimeoutError):
242
+ await client.wait_for("GoalScored", timeout=0.05)
243
+
244
+
245
+ @pytest.mark.asyncio
246
+ async def test_wait_for_cleans_up_handler_on_timeout() -> None:
247
+ client = StatsClient(reconnect=False)
248
+ with pytest.raises(asyncio.TimeoutError):
249
+ await client.wait_for("GoalScored", timeout=0.05)
250
+ assert len(client._handlers_by_event["GoalScored"]) == 0
251
+
252
+
253
+ @pytest.mark.asyncio
254
+ async def test_handler_timeout_cancels_slow_async_handler() -> None:
255
+ client = StatsClient(reconnect=False, handler_timeout=0.05)
256
+ finished = False
257
+
258
+ async def slow_handler(_: EventMessage) -> None:
259
+ nonlocal finished
260
+ await asyncio.sleep(10)
261
+ finished = True
262
+
263
+ client.on("GoalScored", slow_handler)
264
+ await client._dispatch(EventMessage(event="GoalScored", data={}))
265
+ assert not finished
266
+ assert client.metrics.handler_errors == 1
267
+
268
+
269
+ def test_state_tracker_populates_players() -> None:
270
+ client = StatsClient(reconnect=False)
271
+ client._state_tracker.update(
272
+ EventMessage(
273
+ event="UpdateState",
274
+ data={
275
+ "Players": [
276
+ {
277
+ "Name": "Alice",
278
+ "Shortcut": 1,
279
+ "TeamNum": 0,
280
+ "Score": 5,
281
+ "Boost": 80,
282
+ },
283
+ {
284
+ "Name": "Bob",
285
+ "Shortcut": 2,
286
+ "TeamNum": 1,
287
+ "Score": 3,
288
+ "Boost": 20,
289
+ },
290
+ ],
291
+ "Game": {},
292
+ },
293
+ )
294
+ )
295
+
296
+ players = client.state.players
297
+ assert len(players) == 2
298
+ alice = next(p for p in players if p.name == "Alice")
299
+ assert isinstance(alice, PlayerSnapshot)
300
+ assert alice.team_num == 0
301
+ assert alice.score == 5
302
+ assert alice.boost == 80
303
+
304
+
305
+ def test_state_tracker_resolves_target_player() -> None:
306
+ client = StatsClient(reconnect=False)
307
+ client._state_tracker.update(
308
+ EventMessage(
309
+ event="UpdateState",
310
+ data={
311
+ "Players": [
312
+ {"Name": "Alice", "Shortcut": 1, "TeamNum": 0},
313
+ {"Name": "Bob", "Shortcut": 2, "TeamNum": 1},
314
+ ],
315
+ "Game": {
316
+ "bHasTarget": True,
317
+ "Target": {"Name": "Bob", "Shortcut": 2, "TeamNum": 1},
318
+ },
319
+ },
320
+ )
321
+ )
322
+
323
+ assert client.state.target_player is not None
324
+ assert client.state.target_player.name == "Bob"
325
+ assert client.state.target_player.shortcut == 2
326
+
327
+
328
+ def test_state_tracker_no_target_when_flag_false() -> None:
329
+ client = StatsClient(reconnect=False)
330
+ client._state_tracker.update(
331
+ EventMessage(
332
+ event="UpdateState",
333
+ data={
334
+ "Players": [{"Name": "Alice", "Shortcut": 1, "TeamNum": 0}],
335
+ "Game": {"bHasTarget": False},
336
+ },
337
+ )
338
+ )
339
+
340
+ assert client.state.target_player is None
@@ -1,4 +1,4 @@
1
- """Tests for DefaultStatsAPI.ini helpers."""
1
+ """Tests for TAStatsAPI.ini helpers."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -21,13 +21,13 @@ def _write_config(path: Path, text: str) -> None:
21
21
 
22
22
 
23
23
  def test_find_stats_api_config_explicit_path(tmp_path: Path) -> None:
24
- config_path = tmp_path / "DefaultStatsAPI.ini"
24
+ config_path = tmp_path / "TAStatsAPI.ini"
25
25
  _write_config(config_path, "PacketSendRate=30\nPort=49123\n")
26
26
  assert find_stats_api_config(config_path) == config_path.resolve()
27
27
 
28
28
 
29
29
  def test_get_stats_api_status_reads_values(tmp_path: Path) -> None:
30
- config_path = tmp_path / "DefaultStatsAPI.ini"
30
+ config_path = tmp_path / "TAStatsAPI.ini"
31
31
  _write_config(config_path, "PacketSendRate=30\nPort=5000\n")
32
32
 
33
33
  status = get_stats_api_status(config_path)
@@ -39,7 +39,7 @@ def test_get_stats_api_status_reads_values(tmp_path: Path) -> None:
39
39
 
40
40
 
41
41
  def test_get_stats_api_status_missing_keys(tmp_path: Path) -> None:
42
- config_path = tmp_path / "DefaultStatsAPI.ini"
42
+ config_path = tmp_path / "TAStatsAPI.ini"
43
43
  _write_config(config_path, "[TAGame.MatchStatsExporter_TA]\n")
44
44
 
45
45
  status = get_stats_api_status(config_path)
@@ -51,7 +51,7 @@ def test_get_stats_api_status_missing_keys(tmp_path: Path) -> None:
51
51
 
52
52
 
53
53
  def test_set_stats_api_enabled_true_writes_positive_rate(tmp_path: Path) -> None:
54
- config_path = tmp_path / "DefaultStatsAPI.ini"
54
+ config_path = tmp_path / "TAStatsAPI.ini"
55
55
  _write_config(config_path, "Port=49123\nPacketSendRate=0\n")
56
56
 
57
57
  status = set_stats_api_enabled(True, packet_send_rate=45, path=config_path)
@@ -62,7 +62,7 @@ def test_set_stats_api_enabled_true_writes_positive_rate(tmp_path: Path) -> None
62
62
 
63
63
 
64
64
  def test_set_stats_api_enabled_false_writes_zero(tmp_path: Path) -> None:
65
- config_path = tmp_path / "DefaultStatsAPI.ini"
65
+ config_path = tmp_path / "TAStatsAPI.ini"
66
66
  _write_config(config_path, "PacketSendRate=30\nPort=49123\n")
67
67
 
68
68
  status = set_stats_api_enabled(False, path=config_path)
@@ -73,7 +73,7 @@ def test_set_stats_api_enabled_false_writes_zero(tmp_path: Path) -> None:
73
73
 
74
74
 
75
75
  def test_set_stats_api_port_preserves_other_content(tmp_path: Path) -> None:
76
- config_path = tmp_path / "DefaultStatsAPI.ini"
76
+ config_path = tmp_path / "TAStatsAPI.ini"
77
77
  original = (
78
78
  "; comment\n"
79
79
  "[TAGame.MatchStatsExporter_TA]\n"
@@ -92,7 +92,7 @@ def test_set_stats_api_port_preserves_other_content(tmp_path: Path) -> None:
92
92
 
93
93
 
94
94
  def test_configure_stats_api_writes_both_keys(tmp_path: Path) -> None:
95
- config_path = tmp_path / "DefaultStatsAPI.ini"
95
+ config_path = tmp_path / "TAStatsAPI.ini"
96
96
  _write_config(config_path, "PacketSendRate=0\n")
97
97
 
98
98
  status = configure_stats_api(
@@ -108,7 +108,7 @@ def test_configure_stats_api_writes_both_keys(tmp_path: Path) -> None:
108
108
 
109
109
 
110
110
  def test_invalid_packet_send_rate_raises(tmp_path: Path) -> None:
111
- config_path = tmp_path / "DefaultStatsAPI.ini"
111
+ config_path = tmp_path / "TAStatsAPI.ini"
112
112
  _write_config(config_path, "PacketSendRate=0\nPort=49123\n")
113
113
 
114
114
  with pytest.raises(ValueError, match="packet_send_rate"):
@@ -116,7 +116,7 @@ def test_invalid_packet_send_rate_raises(tmp_path: Path) -> None:
116
116
 
117
117
 
118
118
  def test_invalid_port_raises(tmp_path: Path) -> None:
119
- config_path = tmp_path / "DefaultStatsAPI.ini"
119
+ config_path = tmp_path / "TAStatsAPI.ini"
120
120
  _write_config(config_path, "PacketSendRate=30\nPort=49123\n")
121
121
 
122
122
  with pytest.raises(ValueError, match="port"):
File without changes
File without changes
File without changes