• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pronovic / apologies-server / 21082700793

16 Jan 2026 10:21PM UTC coverage: 91.667% (-0.01%) from 91.68%
21082700793

push

github

web-flow
Address most Ruff and MyPy warnings (#66)

82 of 87 new or added lines in 11 files covered. (94.25%)

1 existing line in 1 file now uncovered.

1782 of 1944 relevant lines covered (91.67%)

3.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.76
/src/apologiesserver/interface.py
1
# vim: set ft=python ts=4 sw=4 expandtab:
2

3
"""
4
Definition of the public interface for the server.
5

6
Both requests (message sent from a client to the server) and events (published
7
from the server to one or more clients) can be serialized and deserialized to
8
and from JSON.  However, we apply much tighter validation rules on the context
9
associated with requests, since the input is untrusted.  We assume that the
10
unit tests and the Python type validations imposed by MyPy give us everything
11
we need for events that are only built internally.
12

13
The file docs/design.rst includes a detailed discussion of each request and event.
14
"""
15

16
from __future__ import annotations  # see: https://stackoverflow.com/a/33533514/2907667
4✔
17

18
import json
4✔
19
from abc import ABC
4✔
20
from enum import Enum
4✔
21
from typing import TYPE_CHECKING, Any
4✔
22

23
import cattrs
4✔
24
from apologies import Action, ActionType, CardType, GameMode, History, Move, Pawn, Player, PlayerColor, PlayerView, Position
4✔
25
from arrow import Arrow
4✔
26
from arrow import get as arrow_get
4✔
27
from attr.validators import and_, in_
4✔
28
from attrs import define, field, frozen
4✔
29
from cattrs.errors import ClassValidationError
4✔
30

31
from apologiesserver.validator import enum, length, notempty, regex, string, stringlist
4✔
32

33
if TYPE_CHECKING:
34
    from attr import Attribute
35

36
__all__ = [
4✔
37
    "ActivityState",
38
    "AdvertiseGameContext",
39
    "AdvertisedGame",
40
    "AvailableGamesContext",
41
    "CancelledReason",
42
    "ConnectionState",
43
    "ExecuteMoveContext",
44
    "FailureReason",
45
    "GameAction",
46
    "GameAdvertisedContext",
47
    "GameCancelledContext",
48
    "GameCompletedContext",
49
    "GameIdleContext",
50
    "GameInactiveContext",
51
    "GameInvitationContext",
52
    "GameJoinedContext",
53
    "GameMove",
54
    "GamePlayer",
55
    "GamePlayerChangeContext",
56
    "GamePlayerQuitContext",
57
    "GamePlayerTurnContext",
58
    "GameStartedContext",
59
    "GameState",
60
    "GameStateChangeContext",
61
    "GameStateHistory",
62
    "GameStatePawn",
63
    "GameStatePlayer",
64
    "JoinGameContext",
65
    "Message",
66
    "MessageType",
67
    "PlayerIdleContext",
68
    "PlayerInactiveContext",
69
    "PlayerMessageReceivedContext",
70
    "PlayerRegisteredContext",
71
    "PlayerState",
72
    "PlayerType",
73
    "PlayerUnregisteredContext",
74
    "ProcessingError",
75
    "RegisterPlayerContext",
76
    "RegisteredPlayer",
77
    "RegisteredPlayersContext",
78
    "RequestFailedContext",
79
    "ReregisterPlayerContext",
80
    "SendMessageContext",
81
    "Visibility",
82
]
83

84

85
class Visibility(Enum):
4✔
86
    """Visibility for advertised games."""
87

88
    PUBLIC = "Public"
4✔
89
    PRIVATE = "Private"
4✔
90

91

92
class CancelledReason(Enum):
4✔
93
    """Reasons a game can be cancelled."""
94

95
    CANCELLED = "Game was cancelled by advertiser"
4✔
96
    NOT_VIABLE = "Game is no longer viable."
4✔
97
    INACTIVE = "The game was idle too long and was marked inactive"
4✔
98
    SHUTDOWN = "Game was cancelled due to system shutdown"
4✔
99

100

101
class PlayerType(Enum):
4✔
102
    """Types of players."""
103

104
    HUMAN = "Human"
4✔
105
    PROGRAMMATIC = "Programmatic"
4✔
106

107

108
class PlayerState(Enum):
4✔
109
    """A player's game state."""
110

111
    WAITING = "Waiting"
4✔
112
    JOINED = "Joined"
4✔
113
    PLAYING = "Playing"
4✔
114
    FINISHED = "Finished"
4✔
115
    QUIT = "Quit"
4✔
116
    DISCONNECTED = "Disconnected"
4✔
117

118

119
class ConnectionState(Enum):
4✔
120
    """A player's connection state."""
121

122
    CONNECTED = "Connected"
4✔
123
    DISCONNECTED = "Disconnected"
4✔
124

125

126
class ActivityState(Enum):
4✔
127
    """A player's activity state."""
128

129
    ACTIVE = "Active"
4✔
130
    IDLE = "Idle"
4✔
131
    INACTIVE = "Inactive"
4✔
132

133

134
class GameState(Enum):
4✔
135
    """A game's state."""
136

137
    ADVERTISED = "Advertised"
4✔
138
    PLAYING = "Playing"
4✔
139
    COMPLETED = "Completed"
4✔
140
    CANCELLED = "Cancelled"
4✔
141

142

143
class FailureReason(Enum):
4✔
144
    """Failure reasons advertised to clients."""
145

146
    INVALID_REQUEST = "Invalid request"
4✔
147
    DUPLICATE_USER = "Handle is already in use"
4✔
148
    INVALID_AUTH = "Missing or invalid authorization header"
4✔
149
    WEBSOCKET_LIMIT = "Connection limit reached; try again later"
4✔
150
    USER_LIMIT = "System user limit reached; try again later"
4✔
151
    GAME_LIMIT = "System game limit reached; try again later"
4✔
152
    INVALID_PLAYER = "Unknown or invalid player"
4✔
153
    INVALID_GAME = "Unknown or invalid game"
4✔
154
    NOT_PLAYING = "Player is not playing a game"
4✔
155
    NOT_ADVERTISER = "Player did not advertise this game"
4✔
156
    ALREADY_PLAYING = "Player is already playing a game"
4✔
157
    NO_MOVE_PENDING = "No move is pending for this player"
4✔
158
    ILLEGAL_MOVE = "The chosen move is not legal"
4✔
159
    ADVERTISER_MAY_NOT_QUIT = "Advertiser may not quit a game (cancel instead)"
4✔
160
    INTERNAL_ERROR = "Internal error"
4✔
161

162

163
class MessageType(Enum):
4✔
164
    """Enumeration of all message types, including received events and published requests."""
165

166
    # Requests sent from client to server
167
    REGISTER_PLAYER = "Register Player"
4✔
168
    REREGISTER_PLAYER = "Reregister Player"
4✔
169
    UNREGISTER_PLAYER = "Unregister Player"
4✔
170
    LIST_PLAYERS = "List Players"
4✔
171
    ADVERTISE_GAME = "Advertise Game"
4✔
172
    LIST_AVAILABLE_GAMES = "List Available"
4✔
173
    JOIN_GAME = "Join Game"
4✔
174
    QUIT_GAME = "Quit Game"
4✔
175
    START_GAME = "Start Game"
4✔
176
    CANCEL_GAME = "Cancel Game"
4✔
177
    EXECUTE_MOVE = "Execute Move"
4✔
178
    OPTIMAL_MOVE = "Optimal Move"
4✔
179
    RETRIEVE_GAME_STATE = "Retrieve Game State"
4✔
180
    SEND_MESSAGE = "Send Message"
4✔
181

182
    # Events published from server to one or more clients
183
    SERVER_SHUTDOWN = "Server Shutdown"
4✔
184
    REQUEST_FAILED = "Request Failed"
4✔
185
    REGISTERED_PLAYERS = "Registered Players"
4✔
186
    AVAILABLE_GAMES = "Available Games"
4✔
187
    PLAYER_REGISTERED = "Player Registered"
4✔
188
    PLAYER_UNREGISTERED = "Player Unregistered"
4✔
189
    WEBSOCKET_IDLE = "Connection Idle"
4✔
190
    WEBSOCKET_INACTIVE = "Connection Inactive"
4✔
191
    PLAYER_IDLE = "Player Idle"
4✔
192
    PLAYER_INACTIVE = "Player Inactive"
4✔
193
    PLAYER_MESSAGE_RECEIVED = "Player Message Received"
4✔
194
    GAME_ADVERTISED = "Game Advertise"
4✔
195
    GAME_INVITATION = "Game Invitation"
4✔
196
    GAME_JOINED = "Game Joined"
4✔
197
    GAME_STARTED = "Game Started"
4✔
198
    GAME_CANCELLED = "Game Cancelled"
4✔
199
    GAME_COMPLETED = "Game Completed"
4✔
200
    GAME_IDLE = "Game Idle"
4✔
201
    GAME_INACTIVE = "Game Inactive"
4✔
202
    GAME_PLAYER_QUIT = "Game Player Quit"
4✔
203
    GAME_PLAYER_CHANGE = "Game Player Change"
4✔
204
    GAME_STATE_CHANGE = "Game State Change"
4✔
205
    GAME_PLAYER_TURN = "Game Player Turn"
4✔
206

207

208
@frozen(repr=False)
4✔
209
class ProcessingError(RuntimeError):
4✔
210
    """Exception thrown when there is a general processing error."""
211

212
    reason: FailureReason
4✔
213
    comment: str | None = None
4✔
214
    handle: str | None = None
4✔
215

216
    def __repr__(self) -> str:
4✔
217
        return self.comment or self.reason.value
4✔
218

219
    def __str__(self) -> str:
4✔
220
        return self.__repr__()
4✔
221

222

223
@frozen
4✔
224
class GamePlayer:
4✔
225
    """The public definition of a player within a game."""
226

227
    handle: str
4✔
228
    player_color: PlayerColor | None
4✔
229
    player_type: PlayerType
4✔
230
    player_state: PlayerState
4✔
231

232

233
@frozen
4✔
234
class RegisteredPlayer:
4✔
235
    """The public definition of a player registered with the system."""
236

237
    handle: str
4✔
238
    registration_date: Arrow
4✔
239
    last_active_date: Arrow
4✔
240
    connection_state: ConnectionState
4✔
241
    activity_state: ActivityState
4✔
242
    player_state: PlayerState
4✔
243
    game_id: str | None
4✔
244

245

246
@frozen
4✔
247
class AdvertisedGame:
4✔
248
    """A game that has been advertised in the system."""
249

250
    game_id: str
4✔
251
    name: str
4✔
252
    mode: GameMode
4✔
253
    advertiser_handle: str
4✔
254
    players: int
4✔
255
    available: int
4✔
256
    visibility: Visibility
4✔
257
    invited_handles: list[str]
4✔
258

259

260
@frozen
4✔
261
class GameStatePawn:
4✔
262
    """State of a pawn in a game."""
263

264
    color: PlayerColor
4✔
265
    id: str
4✔
266
    start: bool
4✔
267
    home: bool
4✔
268
    safe: int | None
4✔
269
    square: int | None
4✔
270

271
    @staticmethod
4✔
272
    def for_pawn(pawn: Pawn) -> GameStatePawn:
4✔
273
        """Create a GameStatePawn based on apologies.game.Pawn."""
274
        color = pawn.color
4✔
275
        index = "%s" % pawn.index
4✔
276
        start = pawn.position.start
4✔
277
        home = pawn.position.home
4✔
278
        safe = pawn.position.safe
4✔
279
        square = pawn.position.square
4✔
280
        return GameStatePawn(color, index, start, home, safe, square)
4✔
281

282
    @staticmethod
4✔
283
    def for_position(pawn: Pawn, position: Position) -> GameStatePawn:
4✔
284
        """Create a GameStatePawn based on apologies.game.Pawn and apologies.gamePosition."""
285
        color = pawn.color
4✔
286
        index = "%s" % pawn.index
4✔
287
        start = position.start
4✔
288
        home = position.home
4✔
289
        safe = position.safe
4✔
290
        square = position.square
4✔
291
        return GameStatePawn(color, index, start, home, safe, square)
4✔
292

293

294
@frozen
4✔
295
class GameStatePlayer:
4✔
296
    """Player in a game, when describing the state of the board."""
297

298
    color: PlayerColor
4✔
299
    turns: int
4✔
300
    hand: list[CardType]
4✔
301
    pawns: list[GameStatePawn]
4✔
302

303
    @staticmethod
4✔
304
    def for_player(player: Player) -> GameStatePlayer:
4✔
305
        """Create a GameStatePlayer based on apologies.game.Player."""
306
        color = player.color
4✔
307
        turns = player.turns
4✔
308
        hand = [card.cardtype for card in player.hand]
4✔
309
        pawns = [GameStatePawn.for_pawn(pawn) for pawn in player.pawns]
4✔
310
        return GameStatePlayer(color, turns, hand, pawns)
4✔
311

312

313
@define
4✔
314
class GameStateHistory:
4✔
315
    """History for a game."""
316

317
    action: str
4✔
318
    color: PlayerColor | None
4✔
319
    card: CardType | None
4✔
320
    timestamp: Arrow
4✔
321

322
    @staticmethod
4✔
323
    def for_history(history: History) -> GameStateHistory:
4✔
324
        return GameStateHistory(action=history.action, color=history.color, card=history.card, timestamp=history.timestamp)
4✔
325

326

327
@frozen
4✔
328
class GameAction:
4✔
329
    """An action applied to a pawn in a game."""
330

331
    start: GameStatePawn
4✔
332
    end: GameStatePawn
4✔
333

334
    @staticmethod
4✔
335
    def for_action(action: Action) -> GameAction:
4✔
336
        """Create a GamePlayerAction based on apologies.rules.Action."""
337
        if action.actiontype == ActionType.MOVE_TO_START:
4✔
338
            # We normalize a MOVE_TO_START action to just a position change, to simplify what the client sees
339
            start = GameStatePawn.for_pawn(action.pawn)
4✔
340
            end = GameStatePawn.for_position(action.pawn, Position().move_to_start())
4✔
341
            return GameAction(start, end)
4✔
342
        if not action.position:
4✔
UNCOV
343
            raise ValueError("Action has no associated position")
×
344
        start = GameStatePawn.for_pawn(action.pawn)
4✔
345
        end = GameStatePawn.for_position(action.pawn, action.position)
4✔
346
        return GameAction(start, end)
4✔
347

348

349
@frozen
4✔
350
class GameMove:
4✔
351
    """A move that may be executed as a result of a player's turn."""
352

353
    move_id: str
4✔
354
    card: CardType
4✔
355
    actions: list[GameAction]
4✔
356
    side_effects: list[GameAction]
4✔
357

358
    @staticmethod
4✔
359
    def for_move(move: Move) -> GameMove:
4✔
360
        """Create a GameMove based on apologies.rules.Move."""
361
        move_id = move.id
4✔
362
        card = move.card.cardtype
4✔
363
        actions = [GameAction.for_action(action) for action in move.actions]
4✔
364
        side_effects = [GameAction.for_action(side_effect) for side_effect in move.side_effects]
4✔
365
        return GameMove(move_id, card, actions, side_effects)
4✔
366

367

368
class Context(ABC):  # noqa: B024
4✔
369
    """Abstract message context."""
370

371

372
MAX_HANDLE = 25
4✔
373
"""Maximum length of a player handle."""
4✔
374

375
MAX_GAME_NAME = 40
4✔
376
"""Maximum length of a game name."""
4✔
377

378
HANDLE_REGEX = r"[a-zA-Z0-9_-]+"
4✔
379
"""Regular expression that handles must match."""
4✔
380

381

382
@frozen
4✔
383
class RegisterPlayerContext(Context):
4✔
384
    """Context for a REGISTER_PLAYER request."""
385

386
    handle: str = field(validator=and_(string, length(MAX_HANDLE), regex(HANDLE_REGEX)))
4✔
387

388

389
@frozen
4✔
390
class ReregisterPlayerContext(Context):
4✔
391
    """Context for a REREGISTER_PLAYER request."""
392

393
    handle: str = field(validator=and_(string, length(MAX_HANDLE), regex(HANDLE_REGEX)))
4✔
394

395

396
@frozen
4✔
397
class AdvertiseGameContext(Context):
4✔
398
    """Context for an ADVERTISE_GAME request."""
399

400
    name: str = field(validator=and_(string, length(MAX_GAME_NAME)))
4✔
401
    mode: GameMode = field(validator=enum(GameMode))
4✔
402
    players: int = field(validator=in_([2, 3, 4]))
4✔
403
    visibility: Visibility = field(validator=enum(Visibility))
4✔
404
    invited_handles: list[str] = field(validator=stringlist)
4✔
405

406

407
@frozen
4✔
408
class JoinGameContext(Context):
4✔
409
    """Context for a JOIN_GAME request."""
410

411
    game_id: str = field(validator=string)
4✔
412

413

414
@frozen
4✔
415
class ExecuteMoveContext(Context):
4✔
416
    """Context for an EXECUTE_MOVE request."""
417

418
    move_id: str = field(validator=string)
4✔
419

420

421
@frozen
4✔
422
class SendMessageContext(Context):
4✔
423
    """Context for an SEND_MESSAGE request."""
424

425
    message: str = field(validator=string)
4✔
426
    recipient_handles: list[str] = field(validator=and_(stringlist, notempty))
4✔
427

428

429
@frozen
4✔
430
class RequestFailedContext(Context):
4✔
431
    """Context for a REQUEST_FAILED event."""
432

433
    reason: FailureReason
4✔
434
    comment: str | None
4✔
435
    handle: str | None = None
4✔
436

437

438
@frozen
4✔
439
class RegisteredPlayersContext(Context):
4✔
440
    """Context for a REGISTERED_PLAYERS event."""
441

442
    players: list[RegisteredPlayer]
4✔
443

444

445
@frozen
4✔
446
class AvailableGamesContext(Context):
4✔
447
    """Context for an AVAILABLE_GAMES event."""
448

449
    games: list[AdvertisedGame]
4✔
450

451

452
@frozen
4✔
453
class PlayerRegisteredContext(Context):
4✔
454
    """Context for a PLAYER_REGISTERED event."""
455

456
    handle: str
4✔
457

458

459
@frozen
4✔
460
class PlayerUnregisteredContext(Context):
4✔
461
    """Context for a PLAYER_UNREGISTERED event."""
462

463
    handle: str
4✔
464

465

466
@frozen
4✔
467
class PlayerIdleContext(Context):
4✔
468
    """Context for a PLAYER_IDLE event."""
469

470
    handle: str
4✔
471

472

473
@frozen
4✔
474
class PlayerInactiveContext(Context):
4✔
475
    """Context for a PLAYER_INACTIVE event."""
476

477
    handle: str
4✔
478

479

480
@frozen
4✔
481
class PlayerMessageReceivedContext(Context):
4✔
482
    """Context for a PLAYER_MESSAGE_RECEIVED event."""
483

484
    sender_handle: str
4✔
485
    recipient_handles: list[str]
4✔
486
    message: str
4✔
487

488

489
@frozen
4✔
490
class GameAdvertisedContext(Context):
4✔
491
    """Context for a GAME_ADVERTISED event."""
492

493
    game: AdvertisedGame
4✔
494

495

496
@frozen
4✔
497
class GameInvitationContext(Context):
4✔
498
    """Context for a GAME_INVITATION event."""
499

500
    game: AdvertisedGame
4✔
501

502

503
@frozen
4✔
504
class GameJoinedContext(Context):
4✔
505
    """Context for a GAME_JOINED event."""
506

507
    player_handle: str
4✔
508
    game_id: str
4✔
509
    name: str
4✔
510
    mode: GameMode
4✔
511
    advertiser_handle: str
4✔
512

513

514
@frozen
4✔
515
class GameStartedContext(Context):
4✔
516
    """Context for a GAME_STARTED event."""
517

518
    game_id: str
4✔
519

520

521
@frozen
4✔
522
class GameCancelledContext(Context):
4✔
523
    """Context for a GAME_CANCELLED event."""
524

525
    game_id: str
4✔
526
    reason: CancelledReason
4✔
527
    comment: str | None
4✔
528

529

530
@frozen
4✔
531
class GameCompletedContext(Context):
4✔
532
    """Context for a GAME_COMPLETED event."""
533

534
    game_id: str
4✔
535
    winner: str
4✔
536
    comment: str
4✔
537

538

539
@frozen
4✔
540
class GameIdleContext(Context):
4✔
541
    """Context for a GAME_IDLE event."""
542

543
    game_id: str
4✔
544

545

546
@frozen
4✔
547
class GameInactiveContext(Context):
4✔
548
    """Context for a GAME_INACTIVE event."""
549

550
    game_id: str
4✔
551

552

553
@frozen
4✔
554
class GamePlayerQuitContext(Context):
4✔
555
    """Context for a GAME_PLAYER_LEFT event."""
556

557
    handle: str
4✔
558
    game_id: str
4✔
559

560

561
@frozen
4✔
562
class GamePlayerChangeContext(Context):
4✔
563
    """Context for a GAME_PLAYER_CHANGE event."""
564

565
    game_id: str
4✔
566
    comment: str | None
4✔
567
    players: list[GamePlayer]
4✔
568

569

570
@frozen
4✔
571
class GameStateChangeContext(Context):
4✔
572
    """Context for a GAME_STATE_CHANGE event."""
573

574
    game_id: str
4✔
575
    recent_history: list[GameStateHistory]
4✔
576
    player: GameStatePlayer
4✔
577
    opponents: list[GameStatePlayer]
4✔
578

579
    @staticmethod
4✔
580
    def for_context(game_id: str, view: PlayerView, history: list[History]) -> GameStateChangeContext:
4✔
581
        """Create a GameStateChangeContext based on apologies.game.PlayerView."""
582
        player = GameStatePlayer.for_player(view.player)
4✔
583
        recent_history = [GameStateHistory.for_history(entry) for entry in history]
4✔
584
        opponents = [GameStatePlayer.for_player(opponent) for opponent in view.opponents.values()]
4✔
585
        return GameStateChangeContext(game_id=game_id, recent_history=recent_history, player=player, opponents=opponents)
4✔
586

587

588
@frozen
4✔
589
class GamePlayerTurnContext(Context):
4✔
590
    """Context for a GAME_PLAYER_TURN event."""
591

592
    handle: str
4✔
593
    game_id: str
4✔
594
    drawn_card: CardType | None
4✔
595
    moves: dict[str, GameMove]
4✔
596

597
    @staticmethod
4✔
598
    def for_moves(handle: str, game_id: str, moves: list[Move]) -> GamePlayerTurnContext:
4✔
599
        """Create a GamePlayerTurnContext based on a sequence of apologies.rules.Move."""
600
        cards = {move.card.cardtype for move in moves}
4✔
601
        drawn_card = None if len(cards) > 1 else next(iter(cards))  # if there's only one card, it's the one they drew from the deck
4✔
602
        converted = {move.id: GameMove.for_move(move) for move in moves}
4✔
603
        return GamePlayerTurnContext(handle, game_id, drawn_card, converted)
4✔
604

605

606
# Map from MessageType to whether player id field is allowed/required
607
_PLAYER_ID: dict[MessageType, bool] = {
4✔
608
    MessageType.REGISTER_PLAYER: False,
609
    MessageType.REREGISTER_PLAYER: True,
610
    MessageType.UNREGISTER_PLAYER: True,
611
    MessageType.LIST_PLAYERS: True,
612
    MessageType.ADVERTISE_GAME: True,
613
    MessageType.LIST_AVAILABLE_GAMES: True,
614
    MessageType.JOIN_GAME: True,
615
    MessageType.QUIT_GAME: True,
616
    MessageType.START_GAME: True,
617
    MessageType.CANCEL_GAME: True,
618
    MessageType.EXECUTE_MOVE: True,
619
    MessageType.OPTIMAL_MOVE: True,
620
    MessageType.RETRIEVE_GAME_STATE: True,
621
    MessageType.SEND_MESSAGE: True,
622
    MessageType.SERVER_SHUTDOWN: False,
623
    MessageType.REQUEST_FAILED: False,
624
    MessageType.WEBSOCKET_IDLE: False,
625
    MessageType.WEBSOCKET_INACTIVE: False,
626
    MessageType.REGISTERED_PLAYERS: False,
627
    MessageType.AVAILABLE_GAMES: False,
628
    MessageType.PLAYER_REGISTERED: True,
629
    MessageType.PLAYER_UNREGISTERED: False,
630
    MessageType.PLAYER_IDLE: False,
631
    MessageType.PLAYER_INACTIVE: False,
632
    MessageType.PLAYER_MESSAGE_RECEIVED: False,
633
    MessageType.GAME_ADVERTISED: False,
634
    MessageType.GAME_INVITATION: False,
635
    MessageType.GAME_JOINED: False,
636
    MessageType.GAME_STARTED: False,
637
    MessageType.GAME_CANCELLED: False,
638
    MessageType.GAME_COMPLETED: False,
639
    MessageType.GAME_IDLE: False,
640
    MessageType.GAME_INACTIVE: False,
641
    MessageType.GAME_PLAYER_QUIT: False,
642
    MessageType.GAME_PLAYER_CHANGE: False,
643
    MessageType.GAME_STATE_CHANGE: False,
644
    MessageType.GAME_PLAYER_TURN: False,
645
}
646

647
# Map from MessageType to context
648
_CONTEXT: dict[MessageType, type[Context] | None] = {
4✔
649
    MessageType.REGISTER_PLAYER: RegisterPlayerContext,
650
    MessageType.REREGISTER_PLAYER: ReregisterPlayerContext,
651
    MessageType.UNREGISTER_PLAYER: None,
652
    MessageType.LIST_PLAYERS: None,
653
    MessageType.ADVERTISE_GAME: AdvertiseGameContext,
654
    MessageType.LIST_AVAILABLE_GAMES: None,
655
    MessageType.JOIN_GAME: JoinGameContext,
656
    MessageType.QUIT_GAME: None,
657
    MessageType.START_GAME: None,
658
    MessageType.CANCEL_GAME: None,
659
    MessageType.EXECUTE_MOVE: ExecuteMoveContext,
660
    MessageType.OPTIMAL_MOVE: None,
661
    MessageType.RETRIEVE_GAME_STATE: None,
662
    MessageType.SEND_MESSAGE: SendMessageContext,
663
    MessageType.SERVER_SHUTDOWN: None,
664
    MessageType.REQUEST_FAILED: RequestFailedContext,
665
    MessageType.WEBSOCKET_IDLE: None,
666
    MessageType.WEBSOCKET_INACTIVE: None,
667
    MessageType.REGISTERED_PLAYERS: RegisteredPlayersContext,
668
    MessageType.AVAILABLE_GAMES: AvailableGamesContext,
669
    MessageType.PLAYER_REGISTERED: PlayerRegisteredContext,
670
    MessageType.PLAYER_UNREGISTERED: PlayerUnregisteredContext,
671
    MessageType.PLAYER_IDLE: PlayerIdleContext,
672
    MessageType.PLAYER_INACTIVE: PlayerInactiveContext,
673
    MessageType.PLAYER_MESSAGE_RECEIVED: PlayerMessageReceivedContext,
674
    MessageType.GAME_ADVERTISED: GameAdvertisedContext,
675
    MessageType.GAME_INVITATION: GameInvitationContext,
676
    MessageType.GAME_JOINED: GameJoinedContext,
677
    MessageType.GAME_STARTED: GameStartedContext,
678
    MessageType.GAME_CANCELLED: GameCancelledContext,
679
    MessageType.GAME_COMPLETED: GameCompletedContext,
680
    MessageType.GAME_IDLE: GameIdleContext,
681
    MessageType.GAME_INACTIVE: GameInactiveContext,
682
    MessageType.GAME_PLAYER_QUIT: GamePlayerQuitContext,
683
    MessageType.GAME_PLAYER_CHANGE: GamePlayerChangeContext,
684
    MessageType.GAME_STATE_CHANGE: GameStateChangeContext,
685
    MessageType.GAME_PLAYER_TURN: GamePlayerTurnContext,
686
}
687

688
# List of all enumerations that are part of the public interface
689
_ENUMS = [
4✔
690
    Visibility,
691
    FailureReason,
692
    CancelledReason,
693
    PlayerType,
694
    PlayerState,
695
    ConnectionState,
696
    ActivityState,
697
    MessageType,
698
    GameMode,
699
    PlayerColor,
700
    CardType,
701
]
702

703
_DATE_FORMAT = "YYYY-MM-DDTHH:mm:ss,SSSZ"  # gives us something like "2020-04-27T09:02:14,334+00:00"
4✔
704

705

706
class _CattrConverter(cattrs.GenConverter):
4✔
707
    """
708
    Cattr converter for requests and events, to standardize conversion of dates and enumerations.
709
    """
710

711
    def __init__(self) -> None:
4✔
712
        super().__init__()
4✔
713
        self.register_unstructure_hook(Arrow, lambda value: value.format(_DATE_FORMAT) if value else None)
4✔
714
        self.register_structure_hook(Arrow, lambda value, _: arrow_get(value) if value else None)
4✔
715
        for element in _ENUMS:
4✔
716
            self.register_unstructure_hook(element, lambda value: value.name if value else None)
4✔
717
            self.register_structure_hook(element, lambda value, _, e=element: e[value] if value else None)  # type: ignore
4✔
718

719

720
# Cattr converter used to serialize and deserialize requests and responses
721
_CONVERTER = _CattrConverter()
4✔
722

723

724
# noinspection PyTypeChecker
725
@frozen
4✔
726
class Message:
4✔
727
    """A message that is part of the public interface, either a client request or a published event."""
728

729
    message: MessageType = field()
4✔
730
    player_id: str | None = field(default=None, repr=False)  # this is a secret, so we don't want it printed or logged
4✔
731
    context: Any = field(default=None)
4✔
732

733
    @message.validator
4✔
734
    def _validate_message(self, attribute: Attribute[MessageType], value: MessageType) -> None:
4✔
735
        if value is None or not isinstance(value, MessageType):
4✔
736
            raise ValueError("'%s' must be a MessageType" % attribute.name)
4✔
737

738
    # noinspection PyUnresolvedReferences
739
    @player_id.validator
4✔
740
    def _validate_player_id(self, _attribute: Attribute[str], value: str) -> None:
4✔
741
        if _PLAYER_ID[self.message]:
4✔
742
            if value is None:
4✔
743
                raise ValueError("Message type %s requires a player id" % self.message.name)
4✔
744
        elif value is not None:
4✔
745
            raise ValueError("Message type %s does not allow a player id" % self.message.name)
4✔
746

747
    # noinspection PyTypeHints
748
    @context.validator
4✔
749
    def _validate_context(self, _attribute: Attribute[Context], value: Context) -> None:
4✔
750
        if _CONTEXT[self.message] is not None:
4✔
751
            if value is None:
4✔
752
                raise ValueError("Message type %s requires a context" % self.message.name)
4✔
753
            if not isinstance(value, _CONTEXT[self.message]):  # type: ignore
4✔
754
                raise ValueError("Message type %s does not support this context" % self.message.name)
4✔
755
        elif value is not None:
4✔
756
            raise ValueError("Message type %s does not allow a context" % self.message.name)
4✔
757

758
    def to_json(self) -> str:
4✔
759
        """Convert the request to JSON."""
760
        d = _CONVERTER.unstructure(self)
4✔
761
        d["context"] = _CONVERTER.unstructure(self.context)
4✔
762
        if d["player_id"] is None:
4✔
763
            del d["player_id"]
4✔
764
        if d["context"] is None:
4✔
765
            del d["context"]
4✔
766
        return json.dumps(d, indent="  ")
4✔
767

768
    @staticmethod
4✔
769
    def for_json(data: str) -> Message:  # noqa: PLR0912
4✔
770
        """Create a request based on JSON data."""
771
        d = json.loads(data)
4✔
772
        if "message" not in d or d["message"] is None:
4✔
773
            raise ValueError("Message type is required")
4✔
774
        try:
4✔
775
            message = MessageType[d["message"]]
4✔
776
        except KeyError as e:
4✔
777
            raise ValueError("Unknown message type: %s" % d["message"]) from e
4✔
778
        if _PLAYER_ID[message]:
4✔
779
            if "player_id" not in d or d["player_id"] is None:
4✔
780
                raise ValueError("Message type %s requires a player id" % message.name)
4✔
781
            player_id = d["player_id"]
4✔
782
        else:
783
            if "player_id" in d and d["player_id"] is not None:
4✔
784
                raise ValueError("Message type %s does not allow a player id" % message.name)
4✔
785
            player_id = None
4✔
786
        if _CONTEXT[message] is None:
4✔
787
            if "context" in d and d["context"] is not None:
4✔
788
                raise ValueError("Message type %s does not allow a context" % message.name)
4✔
789
            context = None
4✔
790
        else:
791
            if "context" not in d or d["context"] is None:
4✔
792
                raise ValueError("Message type %s requires a context" % message.name)
4✔
793
            try:
4✔
794
                context = _CONVERTER.structure(d["context"], _CONTEXT[message])  # type: ignore
4✔
795
            except ClassValidationError as e:
4✔
796
                # Unfortunately, we can't always distinguish between all different kinds of bad
797
                # input.  In particular, it's sometimes difficult to tell apart a single bad field
798
                # in a valid context from a context of the wrong type.  We used to be able to
799
                # distinguish more cases when using cattrs.Converter, but now that we need
800
                # catts.GenConverter, we're stuck with less-useful error messages in some cases.
801
                value_errors = [c for c in e.exceptions if isinstance(c, ValueError)]
4✔
802
                key_errors = [c for c in e.exceptions if isinstance(c, KeyError)]
4✔
803
                if value_errors:
4✔
804
                    raise value_errors[0] from None
4✔
805
                if key_errors:
4✔
806
                    raise ValueError("Invalid value %s" % str(key_errors[0])) from e
4✔
807
                raise ValueError("Message type %s does not support this context" % message.name, e) from e
4✔
808
        return Message(message, player_id, context)
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc