Compare commits

..

No commits in common. "add05bc761f598a19a3ebe980aad411afe6248b2" and "dcc8bb9636d6c041ecde419e380255e802b0adc0" have entirely different histories.

2 changed files with 74 additions and 137 deletions

View File

@ -20,7 +20,7 @@ def main():
import_name=__name__, import_name=__name__,
) )
player.add_tic_tac_toe(on_move=play_tic_tac_toe, profile={}) player.add_tic_tac_toe({}, play_tic_tac_toe)
player.start(port=5001) player.start(port=5001)
@ -83,7 +83,7 @@ if __name__ == "__main__":
c = PyClient([ c = PyClient([
"http://127.0.0.1:5001", "http://127.0.0.1:5001",
"http://127.0.0.1:5002", "http://127.0.0.1:5002"
], debug=True) ], debug=True)
out = c.play_game("tic-tac-toe", TicTacToe) out = c.play_game("/tic-tac-toe", TicTacToe)

View File

@ -5,187 +5,124 @@ from __future__ import annotations
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from typing import Any, Optional from typing import Any, Optional
from flask import Flask, Response, jsonify, request from flask import Flask, jsonify, request
from functools import wraps
PayloadType = dict[str, Any]
GameHandler = Callable[[PayloadType], PayloadType] GameHandler = Callable[[Any], Any]
class PyServer: class PyServer:
"""A tiny stateless Flask app that serves discovery and game routes.""" """A tiny stateless Flask app that serves discovery and game routes."""
def __init__(self, def __init__(self, name: str, profile: Optional[Mapping[str, Any]] = None, import_name: str = __name__) -> None:
name: str,
import_name: str = __name__,
profile: Optional[PayloadType] = None,
subpath : str = "",
) -> None:
""" """
Create a PyServer. Create a PyServer.
:param name: Preferred display name for discovery. :param name: Preferred display name for discovery.
:type name: str
:param import_name: Flask import name.
:type import_name: str
:param profile: Additional root-level discovery metadata. :param profile: Additional root-level discovery metadata.
:type profile: Optional[Dict[str, Any]] :param import_name: Flask import name.
:type subpath: str
:raises ValueError: The input contains invalid information.
""" """
self.name = name self.name = name
self.profile = dict(profile or {}) self.profile = dict(profile or {})
if "name" in self.profile: if "name" in self.profile:
raise ValueError( raise ValueError("Root profile metadata must not define 'name'.")
"Root profile metadata must not define 'name'."
)
if "games" in self.profile: if "games" in self.profile:
raise ValueError( raise ValueError("Root profile metadata must not define 'games'.")
"Root profile metadata must not define 'games'."
)
self.app = Flask(import_name) self.app = Flask(import_name)
self.__games: dict[str, PayloadType] = {} self._games: dict[str, dict[str, Any]] = {}
self.__registered_routes: set[str] = set() self._registered_routes: set[str] = set()
# Register the root self._register_root()
self.__add_api_endpoint("", "", lambda _ : self.__discovery())
# self.app.add_url_rule("/",
# endpoint="botman_discovery",
# view_func=self.__discovery,
# methods=["GET"]
# )
def __add_api_endpoint(self, name : str, route : str, func : GameHandler) -> None: def _register_root(self) -> None:
""" def discovery() -> Any:
Create a new API endpoint. payload = {"name": self.name, **self.profile, "games": dict(self._games)}
return jsonify(payload)
:param name: The name of the action to undertake. self.app.add_url_rule("/", endpoint="botman_discovery", view_func=discovery, methods=["GET"])
:type name: str
:param func: The player's function that determines what action to take.
:type func: Callable[[dict[str, Any]], dict[str, Any]]
:raises ValueError: The URL has already been registered.
"""
url = self.__make_url(name, route)
if url in self.__registered_routes: @staticmethod
raise ValueError( def _normalize_segment(segment: str) -> str:
f"Route already registered: {url}" normalized = segment.strip("/")
) if not normalized:
raise ValueError("Route segments must not be empty.")
return normalized
self.__registered_routes.add(url) def _compose_route(self, game_name: str, action_name: str) -> str:
normalized_game = self._normalize_segment(game_name)
normalized_action = action_name.strip("/")
return f"/{normalized_game}" if not normalized_action else f"/{normalized_game}/{normalized_action}"
return self.app.add_url_rule(url, def _register_action(self, route: str, handler: GameHandler) -> None:
endpoint="botman_" + name.replace("/", "_"), if route in self._registered_routes:
view_func=self.__func_wrapper(func), raise ValueError(f"Route already registered: {route}")
methods=["GET"],
)
def __discovery(self) -> PayloadType: endpoint = f"botman_{len(self._registered_routes)}"
"""
Return the server's discovery information.
:return: The personal discovery information. def view() -> Any:
:rtype: dict[str, Any] payload = request.get_json(silent=True)
""" return jsonify(handler(payload))
return {
"name": self.name,
"games": dict(self.__games),
**self.profile,
}
def __func_wrapper(self, func : GameHandler) -> Callable[[], Response]: self.app.add_url_rule(route, endpoint=endpoint, view_func=view, methods=["GET"])
""" self._registered_routes.add(route)
Wrapper that catches an incoming request, parses it, and responds
with a player's action response.
"""
@wraps(func)
def exec():
payload = request.get_json(silent=True) or {}
result = func(payload) or {}
return jsonify(result)
return exec
def __make_url(self, name : str, route : str) -> str:
return "/" + "/".join([ name.strip("/"), route.strip("/") ]).strip("/")
def add_game( def add_game(
self, self,
name: str, game_name: str,
profile: PayloadType, custom_profile: Mapping[str, Any],
actions: dict[str, GameHandler], actions: Mapping[str, GameHandler],
required_actions: Optional[list[str]] = None, required_actions: Optional[tuple[str, ...]] = None,
) -> None: ) -> None:
""" """
Register a stateless game with one or more action routes. Register a stateless game with one or more action routes.
:param name: Base route name for the game. :param game_name: Base route name for the game.
:type name: str :param custom_profile: Game-specific discovery metadata.
:param profile: Game-specific discovery metadata.
:type profile: dict[str, Any]
:param actions: Mapping of action subpaths to handlers. :param actions: Mapping of action subpaths to handlers.
:type actions: dict[str, Callable[[dict[str, Any]], dict[str, Any]]]
:param required_actions: Optional completeness check. If provided, all :param required_actions: Optional completeness check. If provided, all
required action names must exist in ``actions`` and map to callables. required action names must exist in ``actions`` and map to callables.
:type required_actions: Optional[list[str]]
:raises AssertionError: Some of the required actions aren't present.
:raises ValueError: Some of the requested URL paths are already occupied.
""" """
if not isinstance(custom_profile, Mapping):
raise TypeError("custom_profile must be a mapping.")
if not isinstance(actions, Mapping) or not actions:
raise ValueError("actions must be a non-empty mapping.")
normalized_profile = dict(custom_profile)
# Verify that all required actions are present
if required_actions is not None: if required_actions is not None:
missing_actions = [ action for action in required_actions if action not in actions ] missing_actions = [action_name for action_name in required_actions if action_name not in actions]
if missing_actions:
raise ValueError(f"Missing required action handlers: {', '.join(sorted(missing_actions))}")
if len(missing_actions) > 0: prepared_routes: list[tuple[str, GameHandler]] = []
raise AssertionError( for action_name, handler in actions.items():
f"Missing required action handlers: {', '.join(sorted(missing_actions))}" if not isinstance(action_name, str):
) raise TypeError("Action names must be strings.")
if handler is None or not callable(handler):
raise TypeError(f"Action handler for '{action_name}' must be callable.")
prepared_routes.append((self._compose_route(game_name, action_name), handler))
# Verify that there are no duplicate URLs being registered normalized_routes = [route for route, _ in prepared_routes]
# Even though this will automatically be checked later, checking this if len(set(normalized_routes)) != len(normalized_routes):
# now ensures that the operation is atomic and doesn't add raise ValueError("Action routes must be unique after normalization.")
# games partially.
new_routes : set[str] = set()
for route in actions: duplicate_routes = [route for route, _ in prepared_routes if route in self._registered_routes or route in self._games]
url = self.__make_url(name, route) if duplicate_routes:
raise ValueError(f"Route already registered: {duplicate_routes[0]}")
if url in self.__registered_routes: for route, handler in prepared_routes:
raise ValueError( self._register_action(route, handler)
"Route {url} was already registered" self._games[route] = dict(normalized_profile)
)
if url in new_routes:
raise ValueError(
"Route {url} was registered twice by the same function call"
)
new_routes.add(url) def add_tic_tac_toe(self, custom_profile: Mapping[str, Any], on_move: GameHandler) -> None:
# Register all actions
for route, func in actions.items():
self.__add_api_endpoint(name=name, route=route, func=func)
# Add profile data
self.__games[name] = profile
def add_tic_tac_toe(self, on_move: GameHandler, profile: PayloadType = {}) -> None:
""" """
Convenience registration for tic-tac-toe. Convenience registration for tic-tac-toe.
The game is exposed at `/tic-tac-toe`. The game is exposed at ``/tic-tac-toe``.
:param profile: The player's custom profile.
:type profile: dict[str, Any]
:param on_move:
""" """
self.add_game( self.add_game("tic-tac-toe", custom_profile, {"": on_move})
name="tic-tac-toe",
profile=profile,
actions={"": on_move},
required_actions=[""],
)
def start(self, host: str = "127.0.0.1", port: int = 5000, debug: bool = False, **kwargs: Any) -> None: def start(self, host: str = "127.0.0.1", port: int = 5000, debug: bool = False, **kwargs: Any) -> None:
"""Start the Flask development server.""" """Start the Flask development server."""