Source code for gamesheet_sdk.common.cli.game_options

# Copyright (c) 2026 bdperkin
# SPDX-License-Identifier: MIT

"""The unified game option set shared by ``gamesheet-admin games`` and ``gamesheet-teams schedule games``.

The two command trees drive different backends — a JSON:API season schedule for admin, the teams gateway's
``/api/schedule-game`` for teams — but expose one option vocabulary, so a command line written for either CLI
runs unchanged on the other.

The two backends disagree on how a game's two teams are named. admin names them **absolutely**, as
``--home-team-id`` / ``--visitor-team-id`` and their divisions. teams names them **relative to "my" team**,
as ``--team-id`` / ``--opposing-team-id`` plus a side flag — ``--home`` / ``--visitor``, spelled ``--away``
on ``update`` — saying which side that team is on.

Both spellings are accepted by both CLIs. :func:`resolve_game_sides` translates whichever was given into
:class:`GameSides`, which carries the absolute pair plus the ``home_flag`` each backend needs. These are
therefore equivalent everywhere::

    --home-team-id 10 --visitor-team-id 20
    --team-id 10 --opposing-team-id 20 --home
    --team-id 20 --opposing-team-id 10 --visitor

Naming the same side twice with different values is a usage error.
"""

from __future__ import annotations

from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Final, TypeVar

import rich_click as click

from gamesheet_sdk.common.cli.game_constants import (
    ADMIN_ONLY_SUFFIX,
    BROADCASTER_HELP,
    DURATION_HELP,
    FLEXIBLE_DATETIME_HELP,
    GAME_TYPE_HELP,
    IANA_TIMEZONE_HELP_TEXT,
    LOCATION_HELP,
    SPLIT_DATE_HELP,
    SPLIT_TIME_HELP,
    TIMEZONE_OFFSET_HELP_TEXT,
)

F = TypeVar("F", bound=Callable[..., Any])

#: Absolute spelling of each side, and the relative spelling that fills the same slot.
_TEAM_NAMES: Final[dict[str, str]] = {
    "home": "--home-team-id",
    "visitor": "--visitor-team-id",
    "mine": "--team-id",
    "opposing": "--opposing-team-id",
}
_DIVISION_NAMES: Final[dict[str, str]] = {
    "home": "--home-division-id",
    "visitor": "--visitor-division-id",
    "mine": "--division-id",
    "opposing": "--opposing-division-id",
}

#: ``GameSides`` field to the pair of spellings that can fill it, for ``create``'s error message.
_REQUIRED_SIDE_FIELDS: Final[tuple[tuple[str, str], ...]] = (
    ("home_team_id", f"{_TEAM_NAMES['home']}/{_TEAM_NAMES['mine']}"),
    ("home_division_id", f"{_DIVISION_NAMES['home']}/{_DIVISION_NAMES['mine']}"),
    ("visitor_team_id", f"{_TEAM_NAMES['visitor']}/{_TEAM_NAMES['opposing']}"),
    ("visitor_division_id", f"{_DIVISION_NAMES['visitor']}/{_DIVISION_NAMES['opposing']}"),
)


[docs] @dataclass(frozen=True) class RequiredGameSides: """A fully specified pair of sides, as ``create`` needs. Attributes: home_team_id (str): Home team identifier. home_division_id (str): Home team's division identifier. visitor_team_id (str): Visitor team identifier. visitor_division_id (str): Visitor team's division identifier. home_flag (bool): Whether the acting team (``--team-id``) is the home team. """ home_team_id: str home_division_id: str visitor_team_id: str visitor_division_id: str home_flag: bool @property def team_id(self: RequiredGameSides) -> str: """The acting team's identifier. Returns: str: ``home_team_id`` when the acting team is home, else ``visitor_team_id``. """ return self.home_team_id if self.home_flag else self.visitor_team_id @property def division_id(self: RequiredGameSides) -> str: """The acting team's division identifier. Returns: str: ``home_division_id`` when the acting team is home, else ``visitor_division_id``. """ return self.home_division_id if self.home_flag else self.visitor_division_id @property def opposing_team_id(self: RequiredGameSides) -> str: """The opposing team's identifier. Returns: str: ``visitor_team_id`` when the acting team is home, else ``home_team_id``. """ return self.visitor_team_id if self.home_flag else self.home_team_id @property def opposing_division_id(self: RequiredGameSides) -> str: """The opposing team's division identifier. Returns: str: ``visitor_division_id`` when the acting team is home, else ``home_division_id``. """ return self.visitor_division_id if self.home_flag else self.home_division_id
[docs] @dataclass(frozen=True) class GameSides: """A game's two teams, held absolutely, plus which side is the acting team. Attributes: home_team_id (str | None): Home team identifier. home_division_id (str | None): Home team's division identifier. visitor_team_id (str | None): Visitor team identifier. visitor_division_id (str | None): Visitor team's division identifier. home_flag (bool): Whether the acting team (``--team-id``) is the home team. """ home_team_id: str | None home_division_id: str | None visitor_team_id: str | None visitor_division_id: str | None home_flag: bool @property def team_id(self: GameSides) -> str | None: """The acting team's identifier. Returns: str | None: ``home_team_id`` when the acting team is home, else ``visitor_team_id``. """ return self.home_team_id if self.home_flag else self.visitor_team_id @property def division_id(self: GameSides) -> str | None: """The acting team's division identifier. Returns: str | None: ``home_division_id`` when the acting team is home, else ``visitor_division_id``. """ return self.home_division_id if self.home_flag else self.visitor_division_id @property def opposing_team_id(self: GameSides) -> str | None: """The opposing team's identifier. Returns: str | None: ``visitor_team_id`` when the acting team is home, else ``home_team_id``. """ return self.visitor_team_id if self.home_flag else self.home_team_id @property def opposing_division_id(self: GameSides) -> str | None: """The opposing team's division identifier. Returns: str | None: ``visitor_division_id`` when the acting team is home, else ``home_division_id``. """ return self.visitor_division_id if self.home_flag else self.home_division_id
[docs] def require(self: GameSides) -> RequiredGameSides: """Narrow to a :class:`RequiredGameSides`, rejecting any side left unspecified. Returns: RequiredGameSides: The same sides with every identifier known to be present. """ _require_sides(self) return RequiredGameSides( home_team_id=str(self.home_team_id), home_division_id=str(self.home_division_id), visitor_team_id=str(self.visitor_team_id), visitor_division_id=str(self.visitor_division_id), home_flag=self.home_flag, )
[docs] def resolve_side_flag(*, home_flag: bool | None, away_flag: bool, default: bool = True) -> bool: """Collapse ``--home/--visitor`` and the ``--away`` alias into one boolean. Args: home_flag (bool | None): ``True`` for ``--home``, ``False`` for ``--visitor``, ``None`` if neither. away_flag (bool): Whether ``--away`` was given. default (bool): Value to use when neither flag was given. ``create`` leaves this at ``True``; ``update`` passes the game's current side so the absolute option names keep their meaning. Returns: bool: Whether the acting team is the home team. Raises: UsageError: If ``--home`` and ``--away`` are both given. """ if away_flag and home_flag: msg = "Cannot combine --home with --away/--visitor." raise click.UsageError(msg) if away_flag: return False return default if home_flag is None else home_flag
[docs] def explicit_side_flag(params: Mapping[str, Any]) -> bool | None: """Return the side the user actually named, or ``None`` if they named none. ``update`` sends ``home_flag`` to the teams gateway only when it was explicitly given, so an update that does not mention a side leaves the game's existing side alone. Args: params (Mapping[str, Any]): The command's collected parameters. Returns: bool | None: ``True`` for ``--home``, ``False`` for ``--visitor``/``--away``, ``None`` for neither. """ if params.get("away_flag"): return False home_flag = params.get("home_flag") return bool(home_flag) if home_flag is not None else None
def _merge_slot( absolute: str | None, relative: str | None, absolute_name: str, relative_name: str, ) -> str | None: """Reconcile the absolute and relative spellings of one side. Args: absolute (str | None): Value from the ``--home-*`` / ``--visitor-*`` spelling. relative (str | None): Value from the ``--team-id`` / ``--opposing-*`` spelling. absolute_name (str): Option name behind ``absolute``, for the error message. relative_name (str): Option name behind ``relative``, for the error message. Returns: str | None: The agreed value, or ``None`` if neither spelling was used. Raises: UsageError: If both spellings were used with different values. """ if absolute is not None and relative is not None and absolute != relative: msg = ( f"{absolute_name} ({absolute}) and {relative_name} ({relative}) both name the same team " "but disagree. Supply only one of them." ) raise click.UsageError(msg) return absolute if absolute is not None else relative def _resolve_pair( home_value: str | None, visitor_value: str | None, mine_value: str | None, opposing_value: str | None, names: Mapping[str, str], *, is_home: bool, ) -> tuple[str | None, str | None]: """Map the relative pair onto the absolute pair according to which side is acting. Args: home_value (str | None): Value from the ``--home-*`` option. visitor_value (str | None): Value from the ``--visitor-*`` option. mine_value (str | None): Value from the acting team's relative option. opposing_value (str | None): Value from the opposing team's relative option. names (Mapping[str, str]): Option names keyed ``home``/``visitor``/``mine``/``opposing``. is_home (bool): Whether the acting team is the home team. Returns: tuple[str | None, str | None]: The resolved ``(home, visitor)`` pair. """ if is_home: home = _merge_slot(home_value, mine_value, names["home"], names["mine"]) visitor = _merge_slot(visitor_value, opposing_value, names["visitor"], names["opposing"]) else: home = _merge_slot(home_value, opposing_value, names["home"], names["opposing"]) visitor = _merge_slot(visitor_value, mine_value, names["visitor"], names["mine"]) return home, visitor def _require_sides(sides: GameSides) -> None: """Reject a create whose home/visitor teams or divisions were not fully specified. Args: sides (GameSides): The resolved sides. Raises: UsageError: If any of the four identifiers is missing. """ missing = [label for field, label in _REQUIRED_SIDE_FIELDS if getattr(sides, field) is None] if missing: msg = f"Missing required option(s): {', '.join(missing)}." raise click.UsageError(msg)
[docs] def resolve_game_sides( *, home_team_id: str | None, home_division_id: str | None, visitor_team_id: str | None, visitor_division_id: str | None, team_id: str | None, division_id: str | None, opposing_team_id: str | None, opposing_division_id: str | None, home_flag: bool | None, away_flag: bool = False, default_home: bool = True, ) -> GameSides: """Translate whichever team-naming spelling was used into a single :class:`GameSides`. Args: home_team_id (str | None): ``--home-team-id``. home_division_id (str | None): ``--home-division-id``. visitor_team_id (str | None): ``--visitor-team-id``. visitor_division_id (str | None): ``--visitor-division-id``. team_id (str | None): ``--team-id``, the acting team. division_id (str | None): ``--division-id``, the acting team's division. opposing_team_id (str | None): ``--opposing-team-id``. opposing_division_id (str | None): ``--opposing-division-id`` / ``--opposing-division``. home_flag (bool | None): ``--home`` / ``--visitor``. away_flag (bool): ``--away``, an alias for ``--visitor``. default_home (bool): Which side to assume when no side flag was given. Returns: GameSides: The resolved sides. """ is_home = resolve_side_flag(home_flag=home_flag, away_flag=away_flag, default=default_home) resolved_home_team, resolved_visitor_team = _resolve_pair( home_team_id, visitor_team_id, team_id, opposing_team_id, _TEAM_NAMES, is_home=is_home, ) resolved_home_division, resolved_visitor_division = _resolve_pair( home_division_id, visitor_division_id, division_id, opposing_division_id, _DIVISION_NAMES, is_home=is_home, ) return GameSides( home_team_id=resolved_home_team, home_division_id=resolved_home_division, visitor_team_id=resolved_visitor_team, visitor_division_id=resolved_visitor_division, home_flag=is_home, )
[docs] @dataclass(frozen=True) class GameTimeArgs: """The seven start/end/duration inputs, straight off the command line. Attributes: start_datetime (str | None): ``--start-datetime`` / ``--start-date-time`` / ``--start``. start_date (str | None): ``--start-date`` / ``--date``. start_time (str | None): ``--start-time``. end_datetime (str | None): ``--end-datetime`` / ``--end-date-time`` / ``--end``. end_date (str | None): ``--end-date``. end_time (str | None): ``--end-time``. duration (str | None): ``--duration``, in any accepted spelling. """ start_datetime: str | None start_date: str | None start_time: str | None end_datetime: str | None end_date: str | None end_time: str | None duration: str | None
[docs] @dataclass(frozen=True) class GameArgs: """A typed view over the unified game option set. The game commands take ``**params`` rather than three dozen named parameters, and turn it into one of these. Every field is optional at this level; ``create`` enforces its own requirements via :meth:`GameSides.require` and click's ``required=``. Attributes: sides (GameSides): The resolved home/visitor pair. times (GameTimeArgs): The raw start/end/duration inputs. season_id (str | None): ``--season-id``. game_id (str | None): ``--game-id`` / ``-g`` / ``--id``. number (str | None): ``--number`` / ``--game-number``. game_type (str | None): ``--game-type``. location (str | None): ``--location``. scorekeeper_name (str | None): ``--scorekeeper-name``. scorekeeper_phone (str | None): ``--scorekeeper-phone``. broadcaster (str | None): ``--broadcaster`` / ``--broadcast-provider``. time_zone_name (str | None): ``--time-zone-name`` / ``--timezone``. time_zone_offset (int | None): ``--time-zone-offset``. home_label (str | None): ``--home-label``; admin-only. visitor_label (str | None): ``--visitor-label``; admin-only. output_format (str): ``--format`` / ``-F``. output_path (str | None): ``--output`` / ``-o``. columns_spec (str | None): ``--columns`` / ``-c``. """ sides: GameSides times: GameTimeArgs season_id: str | None game_id: str | None number: str | None game_type: str | None location: str | None scorekeeper_name: str | None scorekeeper_phone: str | None broadcaster: str | None time_zone_name: str | None time_zone_offset: int | None home_label: str | None visitor_label: str | None output_format: str output_path: str | None columns_spec: str | None
def _parse_time_args(params: Mapping[str, Any]) -> GameTimeArgs: """Collect the time options out of the raw click params. Args: params (Mapping[str, Any]): The command's collected parameters. Returns: GameTimeArgs: The time inputs. """ return GameTimeArgs( start_datetime=params.get("start_datetime"), start_date=params.get("start_date"), start_time=params.get("start_time"), end_datetime=params.get("end_datetime"), end_date=params.get("end_date"), end_time=params.get("end_time"), duration=params.get("duration"), )
[docs] def sides_from_params(params: Mapping[str, Any], *, default_home: bool = True) -> GameSides: """Collect and resolve the side options out of the raw click params. Args: params (Mapping[str, Any]): The command's collected parameters. default_home (bool): Which side to assume when no side flag was given. Returns: GameSides: The resolved sides. """ return resolve_game_sides( home_team_id=params.get("home_team_id"), home_division_id=params.get("home_division_id"), visitor_team_id=params.get("visitor_team_id"), visitor_division_id=params.get("visitor_division_id"), team_id=params.get("team_id"), division_id=params.get("division_id"), opposing_team_id=params.get("opposing_team_id"), opposing_division_id=params.get("opposing_division_id"), home_flag=params.get("home_flag"), away_flag=bool(params.get("away_flag")), default_home=default_home, )
[docs] def parse_game_args(params: Mapping[str, Any]) -> GameArgs: """Turn a game command's collected click parameters into a :class:`GameArgs`. Args: params (Mapping[str, Any]): The command's collected parameters. Returns: GameArgs: The typed view. """ return GameArgs( sides=sides_from_params(params), times=_parse_time_args(params), season_id=params.get("season_id"), game_id=params.get("game_id"), number=params.get("number"), game_type=params.get("game_type"), location=params.get("location"), scorekeeper_name=params.get("scorekeeper_name"), scorekeeper_phone=params.get("scorekeeper_phone"), broadcaster=params.get("broadcaster"), time_zone_name=params.get("time_zone_name"), time_zone_offset=params.get("time_zone_offset"), home_label=params.get("home_label"), visitor_label=params.get("visitor_label"), output_format=params.get("output_format", "simple"), output_path=params.get("output_path"), columns_spec=params.get("columns_spec"), )
[docs] def warn_unsupported_options(cli_name: str, ignored: Mapping[str, object]) -> None: """Warn on stderr for options this backend cannot send, then carry on. The unified option set is deliberately wider than either backend's payload so that a command line stays portable. Anything the receiving backend has no field for is dropped with a warning rather than an error, and the exit status is unaffected. Args: cli_name (str): The CLI the caller is running as, e.g. ``'gamesheet-teams'``. ignored (Mapping[str, object]): Option name to supplied value. Falsy values are not reported. """ for name, value in ignored.items(): if value: click.secho( f"Warning: {name} is not supported by `{cli_name}` and was ignored.", fg="yellow", err=True, )
[docs] def requiredness(*, required: bool) -> dict[str, Any]: """Build the ``required``/``default`` keyword pair for an option. **Gotcha worth preserving:** click 8.4.2 silently ignores ``required=True`` when ``default=None`` is also passed explicitly — the option simply arrives as ``None`` and the body runs. Passing no ``default`` at all is not the same thing as passing ``default=None``, even though click's implicit default *is* ``None``. So a required option must omit the key entirely. Args: required (bool): Whether the option is mandatory. Returns: dict[str, Any]: Keyword arguments to splat into :func:`click.option`. """ return {"required": True} if required else {"required": False, "default": None}
def _apply(func: F, options: Sequence[Callable[[F], F]]) -> F: """Apply option decorators so their declaration order is their ``--help`` order. Args: func (F): The command function to decorate. options (Sequence[Callable[[F], F]]): Option decorators, in the desired help order. Returns: F: The decorated command function. """ for option in reversed(options): func = option(func) return func
[docs] def game_time_options(func: F) -> F: """Add the seven start/end/duration options both CLIs accept. Args: func (F): The command function to decorate. Returns: F: The decorated command function. """ return _apply( func, [ click.option( "--start-datetime", "--start-date-time", "--start", "start_datetime", type=str, default=None, help=( f"Start date and time. {FLEXIBLE_DATETIME_HELP} " "Mutually exclusive with --start-date/--start-time." ), ), click.option( "--end-datetime", "--end-date-time", "--end", "end_datetime", type=str, default=None, help=( f"End date and time. {FLEXIBLE_DATETIME_HELP} " "Mutually exclusive with --end-date/--end-time." ), ), click.option( "--start-date", "--date", "start_date", type=str, default=None, help=f"Start {SPLIT_DATE_HELP} Also supplies the end date unless --end-date is given.", ), click.option( "--start-time", "start_time", type=str, default=None, help=f"Start {SPLIT_TIME_HELP} Use with --start-date/--date.", ), click.option( "--end-date", "end_date", type=str, default=None, help=f"End {SPLIT_DATE_HELP} Defaults to the start date.", ), click.option( "--end-time", "end_time", type=str, default=None, help=f"End {SPLIT_TIME_HELP} Lands on the start date unless --end-date is given.", ), click.option( "--duration", type=str, default=None, help=DURATION_HELP, ), ], )
def _absolute_side_options() -> list[Callable[[F], F]]: """Build the ``--home-*`` / ``--visitor-*`` option decorators. Returns: list[Callable[[F], F]]: Option decorators in help order. """ return [ click.option( "--home-team-id", type=str, default=None, help="Home team identifier.", ), click.option( "--home-division-id", type=str, default=None, help="Home team division identifier.", ), click.option( "--visitor-team-id", type=str, default=None, help="Visitor team identifier.", ), click.option( "--visitor-division-id", type=str, default=None, help="Visitor team division identifier.", ), ] def _relative_side_options() -> list[Callable[[F], F]]: """Build the ``--team-id`` / ``--opposing-*`` option decorators and the side flags. Returns: list[Callable[[F], F]]: Option decorators in help order. """ return [ click.option( "--team-id", "-t", type=str, envvar="GAMESHEET_TEAM_ID", default=None, help="Acting team identifier. Home unless --visitor/--away is given.", ), click.option( "--division-id", type=str, envvar="GAMESHEET_DIVISION_ID", default=None, help="Acting team's division identifier.", ), click.option( "--opposing-team-id", type=str, default=None, help="Opposing team identifier.", ), click.option( "--opposing-division-id", "--opposing-division", "opposing_division_id", type=str, default=None, help="Opposing team's division identifier.", ), click.option( "--home/--visitor", "home_flag", default=None, help="Which side --team-id is on. [default: --home]", ), click.option( "--away", "away_flag", is_flag=True, default=False, help="Alias for --visitor.", ), ]
[docs] def game_side_options(func: F) -> F: """Add both spellings of the home/visitor team and division options. Requiredness is enforced by :func:`resolve_game_sides`, not by click, because each side has two accepted spellings and click can only require one option at a time. Args: func (F): The command function to decorate. Returns: F: The decorated command function. """ return _apply(func, [*_absolute_side_options(), *_relative_side_options()])
def _detail_options(*, required: bool) -> list[Callable[[F], F]]: """Build the detail option decorators. Args: required (bool): Whether ``--game-type`` and ``--number`` are mandatory. Returns: list[Callable[[F], F]]: Option decorators in help order. """ return [ click.option( "--number", "--game-number", "number", type=str, help="Game number.", **requiredness(required=required), ), click.option( "--game-type", type=str, help=GAME_TYPE_HELP, **requiredness(required=required), ), click.option("--location", type=str, default=None, help=LOCATION_HELP), click.option("--scorekeeper-name", type=str, default=None, help="Scorekeeper's full name."), click.option("--scorekeeper-phone", type=str, default=None, help="Scorekeeper's phone number."), click.option( "--broadcaster", "--broadcast-provider", "broadcaster", type=str, default=None, help=BROADCASTER_HELP, ), click.option( "--time-zone-name", "--timezone", "time_zone_name", type=str, default=None, help=f"{IANA_TIMEZONE_HELP_TEXT}. Defaults to the system timezone.", ), click.option( "--time-zone-offset", type=int, default=None, help=f"{TIMEZONE_OFFSET_HELP_TEXT}. Defaults to the system timezone offset.", ), click.option( "--home-label", type=str, default=None, help=f"Home team label override. {ADMIN_ONLY_SUFFIX}", ), click.option( "--visitor-label", type=str, default=None, help=f"Visitor team label override. {ADMIN_ONLY_SUFFIX}", ), ]
[docs] def game_detail_options(*, required: bool) -> Callable[[F], F]: """Build the decorator adding the non-time, non-side game options. Args: required (bool): Whether ``--game-type`` and ``--number`` are mandatory, as they are on ``create``. Returns: Callable[[F], F]: The option decorator. """ def decorator(func: F) -> F: """Apply the detail options to ``func``. Args: func (F): The command function to decorate. Returns: F: The decorated command function. """ return _apply(func, _detail_options(required=required)) return decorator
[docs] def season_id_option(*, required: bool) -> Callable[[F], F]: """Build the ``--season-id`` decorator. Args: required (bool): Whether the option is mandatory. ``gamesheet-admin`` passes ``False`` because the value may instead come from the ``games`` group. Returns: Callable[[F], F]: The option decorator. """ def decorator(func: F) -> F: """Apply the ``--season-id`` option to ``func``. Args: func (F): The command function to decorate. Returns: F: The decorated command function. """ return click.option( "--season-id", type=str, envvar="GAMESHEET_SEASON_ID", help="Season identifier.", **requiredness(required=required), )(func) return decorator
[docs] def game_id_option(func: F) -> F: """Add the ``--game-id`` option under all three accepted spellings. Args: func (F): The command function to decorate. Returns: F: The decorated command function. """ return click.option( "--game-id", "-g", "--id", "game_id", type=str, envvar="GAMESHEET_GAME_ID", required=True, help="Game identifier.", )(func)
__all__ = [ "GameArgs", "GameSides", "GameTimeArgs", "RequiredGameSides", "explicit_side_flag", "game_detail_options", "game_id_option", "game_side_options", "game_time_options", "parse_game_args", "requiredness", "resolve_game_sides", "resolve_side_flag", "season_id_option", "sides_from_params", "warn_unsupported_options", ]