From d8aff35b4383c845fd63192bb11bbf07549476ad Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 17 Jan 2026 22:19:59 +0000 Subject: [PATCH 1/6] First pass at adding support for match holding & releasing This implements the core logic around exposing match states. --- docs/endpoints.rst | 3 ++ sr/comp/http/query_utils.py | 26 ++++++++++------ tests/test_api.py | 61 ++++++++++++++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/docs/endpoints.rst b/docs/endpoints.rst index 7fca75f..013730f 100644 --- a/docs/endpoints.rst +++ b/docs/endpoints.rst @@ -301,6 +301,9 @@ limits start from the last match and work backwards. "end": "...", "start": "..." }, + "operations": { + "release_threshold": "...", + }, "slot": { "end": "...", "start": "..." diff --git a/sr/comp/http/query_utils.py b/sr/comp/http/query_utils.py index b6a1d3a..0a8d0ad 100644 --- a/sr/comp/http/query_utils.py +++ b/sr/comp/http/query_utils.py @@ -34,6 +34,12 @@ class Times(TypedDict): end: str +class OpsTimes(TypedDict): + # TODO: I don't like this name here. It fits for consistency with its other + # uses, however it doesn't fit so well alongside e.g: start/end, opens/closes. + release_threshold: str + + class StagingTimes(TypedDict): opens: str closes: str @@ -43,6 +49,7 @@ class StagingTimes(TypedDict): class MatchTimings(TypedDict): slot: Times + operations: OpsTimes game: Times staging: StagingTimes @@ -53,6 +60,7 @@ class _MatchInfo(TypedDict): arena: ArenaName teams: list[TLA | None] type: str # noqa:A003 + state: str times: MatchTimings @@ -79,7 +87,8 @@ def match_json_info(comp: SRComp, match: Match) -> MatchInfo: dict A :class:`dict` containing JSON suitable output. """ - match_slot_lengths = comp.schedule.match_slot_lengths + arena_times = comp.operations.get_arena_times(match) + state = comp.operations.get_match_state(match) staging_times = comp.schedule.get_staging_times(match) info = MatchInfo({ @@ -88,21 +97,18 @@ def match_json_info(comp: SRComp, match: Match) -> MatchInfo: 'arena': match.arena, 'teams': match.teams, 'type': match.type.value, + 'state': state.value, 'times': { 'slot': { 'start': match.start_time.isoformat(), 'end': match.end_time.isoformat(), }, + 'operations': { + 'release_threshold': arena_times.release_threshold.isoformat(), + }, 'game': { - 'start': ( - match.start_time + - match_slot_lengths['pre'] - ).isoformat(), - 'end': ( - match.start_time + - match_slot_lengths['pre'] + - match_slot_lengths['match'] - ).isoformat(), + 'start': arena_times.start.isoformat(), + 'end': arena_times.end.isoformat(), }, 'staging': { 'opens': staging_times['opens'].isoformat(), diff --git a/tests/test_api.py b/tests/test_api.py index 149f767..858038e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -28,6 +28,7 @@ 'league': {'CLY': 8, 'TTN': 6}, 'ranking': {'CLY': 1, 'TTN': 2}, }, + 'state': 'released', 'times': { 'slot': { 'start': '2014-04-26T13:00:00+01:00', @@ -37,6 +38,9 @@ 'start': '2014-04-26T13:01:30+01:00', 'end': '2014-04-26T13:04:30+01:00', }, + 'operations': { + 'release_threshold': '2014-04-26T13:01:30+01:00', + }, 'staging': { 'opens': '2014-04-26T12:56:30+01:00', 'closes': '2014-04-26T12:59:30+01:00', @@ -59,6 +63,7 @@ 'league': {'QMC': 6, 'GRS': 8}, 'ranking': {'QMC': 2, 'GRS': 1}, }, + 'state': 'released', 'times': { 'slot': { 'start': '2014-04-26T13:00:00+01:00', @@ -68,6 +73,9 @@ 'start': '2014-04-26T13:01:30+01:00', 'end': '2014-04-26T13:04:30+01:00', }, + 'operations': { + 'release_threshold': '2014-04-26T13:01:30+01:00', + }, 'staging': { 'opens': '2014-04-26T12:56:30+01:00', 'closes': '2014-04-26T12:59:30+01:00', @@ -338,6 +346,7 @@ def test_matches(self) -> None: 'league': {'CLY': 8, 'TTN': 6}, 'ranking': {'CLY': 1, 'TTN': 2}, }, + 'state': 'released', 'times': { 'slot': { 'start': '2014-04-26T13:00:00+01:00', @@ -347,6 +356,9 @@ def test_matches(self) -> None: 'start': '2014-04-26T13:01:30+01:00', 'end': '2014-04-26T13:04:30+01:00', }, + 'operations': { + 'release_threshold': '2014-04-26T13:01:30+01:00', + }, 'staging': { 'opens': '2014-04-26T12:56:30+01:00', 'closes': '2014-04-26T12:59:30+01:00', @@ -380,6 +392,7 @@ def test_match_forwards_limit(self) -> None: 'league': {'CLY': 8, 'TTN': 6}, 'ranking': {'CLY': 1, 'TTN': 2}, }, + 'state': 'released', 'times': { 'slot': { 'start': '2014-04-26T13:00:00+01:00', @@ -389,6 +402,9 @@ def test_match_forwards_limit(self) -> None: 'start': '2014-04-26T13:01:30+01:00', 'end': '2014-04-26T13:04:30+01:00', }, + 'operations': { + 'release_threshold': '2014-04-26T13:01:30+01:00', + }, 'staging': { 'opens': '2014-04-26T12:56:30+01:00', 'closes': '2014-04-26T12:59:30+01:00', @@ -416,6 +432,7 @@ def test_match_backwards_limit(self) -> None: 'type': 'knockout', 'num': 129, 'arena': 'A', + 'state': 'released', 'times': { 'game': { 'end': '2014-04-27T17:29:30+01:00', @@ -425,6 +442,9 @@ def test_match_backwards_limit(self) -> None: 'end': '2014-04-27T17:30:00+01:00', 'start': '2014-04-27T17:25:00+01:00', }, + 'operations': { + 'release_threshold': '2014-04-27T17:26:30+01:00', + }, 'staging': { 'opens': '2014-04-27T17:21:30+01:00', 'closes': '2014-04-27T17:24:30+01:00', @@ -453,6 +473,7 @@ def test_match_filter_naive_datetime(self) -> None: 'type': 'knockout', 'num': 129, 'arena': 'A', + 'state': 'released', 'times': { 'game': { 'end': '2014-04-27T17:29:30+01:00', @@ -462,6 +483,9 @@ def test_match_filter_naive_datetime(self) -> None: 'end': '2014-04-27T17:30:00+01:00', 'start': '2014-04-27T17:25:00+01:00', }, + 'operations': { + 'release_threshold': '2014-04-27T17:26:30+01:00', + }, 'staging': { 'opens': '2014-04-27T17:21:30+01:00', 'closes': '2014-04-27T17:24:30+01:00', @@ -498,6 +522,7 @@ def test_match_filter_time(self) -> None: 'type': 'knockout', 'num': 129, 'arena': 'A', + 'state': 'released', 'times': { 'game': { 'end': '2014-04-27T17:29:30+01:00', @@ -507,6 +532,9 @@ def test_match_filter_time(self) -> None: 'end': '2014-04-27T17:30:00+01:00', 'start': '2014-04-27T17:25:00+01:00', }, + 'operations': { + 'release_threshold': '2014-04-27T17:26:30+01:00', + }, 'staging': { 'opens': '2014-04-27T17:21:30+01:00', 'closes': '2014-04-27T17:24:30+01:00', @@ -661,6 +689,7 @@ def test_knockouts(self) -> None: 'num': 111, 'display_name': 'Match 111', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -669,6 +698,7 @@ def test_knockouts(self) -> None: 'num': 111, 'display_name': 'Match 111', 'teams': [None, '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -677,6 +707,7 @@ def test_knockouts(self) -> None: 'num': 112, 'display_name': 'Match 112', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -685,6 +716,7 @@ def test_knockouts(self) -> None: 'num': 112, 'display_name': 'Match 112', 'teams': ['???', '???', '???', None], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -693,6 +725,7 @@ def test_knockouts(self) -> None: 'num': 113, 'display_name': 'Match 113', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -701,6 +734,7 @@ def test_knockouts(self) -> None: 'num': 113, 'display_name': 'Match 113', 'teams': ['???', '???', '???', None], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -709,6 +743,7 @@ def test_knockouts(self) -> None: 'num': 114, 'display_name': 'Match 114', 'teams': ['???', '???', None, '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -717,6 +752,7 @@ def test_knockouts(self) -> None: 'num': 114, 'display_name': 'Match 114', 'teams': ['???', None, '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -725,6 +761,7 @@ def test_knockouts(self) -> None: 'num': 115, 'display_name': 'Match 115', 'teams': ['???', None, '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -733,6 +770,7 @@ def test_knockouts(self) -> None: 'num': 115, 'display_name': 'Match 115', 'teams': [None, '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -741,6 +779,7 @@ def test_knockouts(self) -> None: 'num': 116, 'display_name': 'Match 116', 'teams': [None, '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -749,6 +788,7 @@ def test_knockouts(self) -> None: 'num': 116, 'display_name': 'Match 116', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -757,6 +797,7 @@ def test_knockouts(self) -> None: 'num': 117, 'display_name': 'Match 117', 'teams': [None, '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -765,6 +806,7 @@ def test_knockouts(self) -> None: 'num': 117, 'display_name': 'Match 117', 'teams': ['???', '???', '???', None], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -773,6 +815,7 @@ def test_knockouts(self) -> None: 'num': 118, 'display_name': 'Match 118', 'teams': [None, '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -781,6 +824,7 @@ def test_knockouts(self) -> None: 'num': 118, 'display_name': 'Match 118', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -791,6 +835,7 @@ def test_knockouts(self) -> None: 'num': 119, 'display_name': 'Match 119', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -799,6 +844,7 @@ def test_knockouts(self) -> None: 'num': 119, 'display_name': 'Match 119', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -807,6 +853,7 @@ def test_knockouts(self) -> None: 'num': 120, 'display_name': 'Match 120', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -815,6 +862,7 @@ def test_knockouts(self) -> None: 'num': 120, 'display_name': 'Match 120', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -823,6 +871,7 @@ def test_knockouts(self) -> None: 'num': 121, 'display_name': 'Match 121', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -831,6 +880,7 @@ def test_knockouts(self) -> None: 'num': 121, 'display_name': 'Match 121', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -839,6 +889,7 @@ def test_knockouts(self) -> None: 'num': 122, 'display_name': 'Match 122', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -847,6 +898,7 @@ def test_knockouts(self) -> None: 'num': 122, 'display_name': 'Match 122', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -857,6 +909,7 @@ def test_knockouts(self) -> None: 'num': 123, 'display_name': 'Quarter 1 (#123)', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -865,6 +918,7 @@ def test_knockouts(self) -> None: 'num': 124, 'display_name': 'Quarter 2 (#124)', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -873,6 +927,7 @@ def test_knockouts(self) -> None: 'num': 125, 'display_name': 'Quarter 3 (#125)', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -881,6 +936,7 @@ def test_knockouts(self) -> None: 'num': 126, 'display_name': 'Quarter 4 (#126)', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -891,6 +947,7 @@ def test_knockouts(self) -> None: 'num': 127, 'display_name': 'Semi 1 (#127)', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -899,6 +956,7 @@ def test_knockouts(self) -> None: 'num': 128, 'display_name': 'Semi 2 (#128)', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -909,6 +967,7 @@ def test_knockouts(self) -> None: 'num': 129, 'display_name': 'Final (#129)', 'teams': ['???', '???', '???', '???'], + 'state': 'released', 'times': None, 'type': 'knockout', }, @@ -916,7 +975,7 @@ def test_knockouts(self) -> None: ] actual_rounds = self.server_get('knockout')['rounds'] - times_keys = ['game', 'slot', 'staging'] + times_keys = ['game', 'operations', 'slot', 'staging'] for r in actual_rounds: for m in r: with self.subTest(match=m): From 929c2e5bde1476e4dcc9a60064dd980d7a804ebe Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 18 Jan 2026 16:02:16 +0000 Subject: [PATCH 2/6] Move to using `MatchOperations.get_matches_at` helper This helper handles scheduled and operational delays, rather than us needing to handle each case ourselves. --- sr/comp/http/json_provider.py | 2 +- sr/comp/http/query_utils.py | 6 +++-- sr/comp/http/server.py | 42 +++++++++++++---------------------- 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/sr/comp/http/json_provider.py b/sr/comp/http/json_provider.py index 2f64628..72a7738 100644 --- a/sr/comp/http/json_provider.py +++ b/sr/comp/http/json_provider.py @@ -34,7 +34,7 @@ def default(self, obj: object) -> Any: return obj.value elif isinstance(obj, Match): comp: SRComp = g.comp_man.get_comp() - return match_json_info(comp, obj) + return match_json_info(comp, obj, comp.schedule.datetime_now) elif isinstance(obj, datetime.datetime): return http_date(obj.utctimetuple()) elif isinstance(obj, datetime.date): diff --git a/sr/comp/http/query_utils.py b/sr/comp/http/query_utils.py index 0a8d0ad..1f0a32d 100644 --- a/sr/comp/http/query_utils.py +++ b/sr/comp/http/query_utils.py @@ -71,7 +71,7 @@ class MatchInfo(_MatchInfo, total=False): TParseable = TypeVar('TParseable', int, str, datetime.datetime) -def match_json_info(comp: SRComp, match: Match) -> MatchInfo: +def match_json_info(comp: SRComp, match: Match, when: datetime.datetime) -> MatchInfo: """ Get match JSON information. @@ -81,6 +81,8 @@ def match_json_info(comp: SRComp, match: Match) -> MatchInfo: A competition instance. match : sr.comp.match_periods.Match A match. + when : datetime.datetime + The current time. Returns ------- @@ -88,7 +90,7 @@ def match_json_info(comp: SRComp, match: Match) -> MatchInfo: A :class:`dict` containing JSON suitable output. """ arena_times = comp.operations.get_arena_times(match) - state = comp.operations.get_match_state(match) + state = comp.operations.get_match_state(match, when) staging_times = comp.schedule.get_staging_times(match) info = MatchInfo({ diff --git a/sr/comp/http/server.py b/sr/comp/http/server.py index b0623b8..128345b 100644 --- a/sr/comp/http/server.py +++ b/sr/comp/http/server.py @@ -272,10 +272,11 @@ def last_scored_match() -> Response: @app.route("/matches") def matches() -> Response: comp: SRComp = g.comp_man.get_comp() + now = comp.schedule.datetime_now matches: list[MatchInfo] = [] for slots in comp.schedule.matches: matches.extend( - match_json_info(comp, match) + match_json_info(comp, match, now) for match in slots.values() ) @@ -372,36 +373,23 @@ def current_state() -> Response: delay = comp.schedule.delay_at(time) delay_seconds = int(delay.total_seconds()) - matches = [ - match_json_info(comp, x) - for x in comp.schedule.matches_at(time) - ] - - staging_matches = [] - shepherding_matches = [] - for slot in comp.schedule.matches: - for match in slot.values(): - staging_times = comp.schedule.get_staging_times(match) - - if time > staging_times['closes']: - # Already done staging - continue - - if staging_times['opens'] <= time: - staging_matches.append(match_json_info(comp, match)) - - signal_shepherds = staging_times['signal_shepherds'] - if signal_shepherds: - first_signal = min(signal_shepherds.values()) - if first_signal <= time: - shepherding_matches.append(match_json_info(comp, match)) + current_matches = comp.operations.get_matches_at(time) return jsonify( delay=delay_seconds, time=time.isoformat(), - matches=matches, - staging_matches=staging_matches, - shepherding_matches=shepherding_matches, + matches=[ + match_json_info(comp, x, time) + for x in current_matches.matches + ], + staging_matches=[ + match_json_info(comp, x, time) + for x in current_matches.staging_matches + ], + shepherding_matches=[ + match_json_info(comp, x, time) + for x in current_matches.shepherding_matches + ], ) From a911fd8698549911490e4f3f437a3efcf409d41e Mon Sep 17 00:00:00 2001 From: Peter Law Date: Mon, 19 Jan 2026 21:53:10 +0000 Subject: [PATCH 3/6] Use srcomp development branch --- .circleci/requirements.txt | 3 +++ setup.py | 1 + 2 files changed, 4 insertions(+) diff --git a/.circleci/requirements.txt b/.circleci/requirements.txt index 3735b3f..97337a8 100644 --- a/.circleci/requirements.txt +++ b/.circleci/requirements.txt @@ -4,3 +4,6 @@ pip >= 20 wheel >= 0.36 setuptools >= 50 + +# Temporarily include SRComp development branch +git+https://github.com/PeterJCLaw/srcomp@match-release diff --git a/setup.py b/setup.py index f13fa87..4db4843 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ author="Student Robotics Competition Software SIG", author_email="srobo-devel@googlegroups.com", install_requires=[ + # TODO(PR): bump srcomp version once we know what version will include match-release 'sr.comp >=1.5, <2', 'Flask >=2.2', 'Werkzeug >= 2, <4', From 333817b4e2e30d1e9110acb0e4bc54f69efffd15 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 1 Feb 2026 13:28:35 +0000 Subject: [PATCH 4/6] Expose the most recently released match This is useful for clients who want to pick up when this changes. --- .circleci/requirements.txt | 2 +- docs/endpoints.rst | 16 +++++++++++++++- sr/comp/http/server.py | 12 +++++++++++- tests/test_api.py | 12 ++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.circleci/requirements.txt b/.circleci/requirements.txt index 97337a8..fe82aa3 100644 --- a/.circleci/requirements.txt +++ b/.circleci/requirements.txt @@ -6,4 +6,4 @@ wheel >= 0.36 setuptools >= 50 # Temporarily include SRComp development branch -git+https://github.com/PeterJCLaw/srcomp@match-release +git+https://github.com/PeterJCLaw/srcomp@9dfcea54f073101da73e8fb62c8e4b108c118f1b # match-release diff --git a/docs/endpoints.rst b/docs/endpoints.rst index 013730f..1665900 100644 --- a/docs/endpoints.rst +++ b/docs/endpoints.rst @@ -261,6 +261,7 @@ limits start from the last match and work backwards. .. code-block:: json { + "last_released": "...", "last_scored": "...", "matches": [ { @@ -322,7 +323,8 @@ limits start from the last match and work backwards. ] } -``last_scored`` contains the same value as in the following endpoint. +``last_released`` contains the same value as in its endpoint below. +``last_scored`` contains the same value as in its endpoint below. Any dates are in ISO 8601 format. Only one of the ``league`` or ``normalised`` sub-keys of ``scores`` will be @@ -336,6 +338,18 @@ The staging deadline is available in ``times.staging.closes`` while the ``times.staging.signal_shepherds`` value is when shepherds should start looking for teams although this isn't a strict value. +/matches/last_released +---------------------- + +.. code-block:: json + + { + "last_released": "..." + } + +``last_released`` contains the highest match number which has been released, +but may be ``null`` if no matches have yet been released. + /matches/last_scored -------------------- diff --git a/sr/comp/http/server.py b/sr/comp/http/server.py index 128345b..b0971cc 100644 --- a/sr/comp/http/server.py +++ b/sr/comp/http/server.py @@ -263,6 +263,12 @@ def config() -> Response: return jsonify(config=get_config_dict(comp)) +@app.route("/matches/last_released") +def last_released_match() -> Response: + comp: SRComp = g.comp_man.get_comp() + return jsonify(last_released=comp.operations.last_released_match) + + @app.route("/matches/last_scored") def last_scored_match() -> Response: comp: SRComp = g.comp_man.get_comp() @@ -339,7 +345,11 @@ def parse_date(string: str) -> datetime.datetime: else: raise AssertionError("Limit isn't a number?") - return jsonify(matches=matches, last_scored=comp.scores.last_scored_match) + return jsonify( + matches=matches, + last_released=comp.operations.last_released_match, + last_scored=comp.scores.last_scored_match, + ) @app.route("/periods") diff --git a/tests/test_api.py b/tests/test_api.py index 858038e..04222b9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -152,6 +152,7 @@ def test_endpoints(self) -> None: '/matches?arena=B&num=1', '/matches?type=knockout', '/matches?type=league&limit=10', + '/matches/last_released', '/matches/last_scored', '/periods', '/state', @@ -371,6 +372,7 @@ def test_matches(self) -> None: }, }, ], + 'last_released': 129, 'last_scored': 99, } self.assertEqual( @@ -417,6 +419,7 @@ def test_match_forwards_limit(self) -> None: }, }, ], + 'last_released': 129, 'last_scored': 99, } self.assertEqual( @@ -458,6 +461,7 @@ def test_match_backwards_limit(self) -> None: 'teams': ['???', '???', '???', '???'], }, ], + 'last_released': 129, 'last_scored': 99, } self.assertEqual( @@ -499,6 +503,7 @@ def test_match_filter_naive_datetime(self) -> None: 'teams': ['???', '???', '???', '???'], }, ], + 'last_released': 129, 'last_scored': 99, } self.assertEqual( @@ -548,6 +553,7 @@ def test_match_filter_time(self) -> None: 'teams': ['???', '???', '???', '???'], }, ], + 'last_released': 129, 'last_scored': 99, } self.assertEqual( @@ -573,6 +579,12 @@ def test_last_scored(self) -> None: self.server_get('/matches/last_scored'), ) + def test_last_released(self) -> None: + self.assertEqual( + {'last_released': 129}, + self.server_get('/matches/last_released'), + ) + def test_invalid_match_type(self) -> None: with self.assertRaisesApiError('BadRequest', 400): self.server_get('/matches?type=bees') From 08784db01354c3ce1b9ac12758f0c61d6e916196 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 1 Feb 2026 14:06:54 +0000 Subject: [PATCH 5/6] Expose only whether or not a match is released The previous spelling was potentially misleading, especially if data were cached, since the "hold" vs "future" states could change without the state changing. Exposing this simpler value turns out to be all that clients actually need (since they end up doing the time comparison themselves anyway) and avoids introducing time-sensitive data into endpoints which are currently expected to be time-invariant. --- docs/endpoints.rst | 1 + sr/comp/http/query_utils.py | 5 ++- tests/test_api.py | 76 ++++++++++++++++++------------------- 3 files changed, 42 insertions(+), 40 deletions(-) diff --git a/docs/endpoints.rst b/docs/endpoints.rst index 1665900..b498e22 100644 --- a/docs/endpoints.rst +++ b/docs/endpoints.rst @@ -268,6 +268,7 @@ limits start from the last match and work backwards. "arena": "...", "display_name": "Match ...", "num": "...", + "is_released": "bool", "scores": { "game": { "...": "...", diff --git a/sr/comp/http/query_utils.py b/sr/comp/http/query_utils.py index 1f0a32d..5ee7b01 100644 --- a/sr/comp/http/query_utils.py +++ b/sr/comp/http/query_utils.py @@ -10,6 +10,7 @@ from league_ranker import LeaguePoints, RankedPosition from sr.comp.comp import SRComp +from sr.comp.match_operations import MatchState from sr.comp.match_period import Match, MatchType from sr.comp.types import ArenaName, GamePoints, MatchNumber, ShepherdName, TLA @@ -60,7 +61,7 @@ class _MatchInfo(TypedDict): arena: ArenaName teams: list[TLA | None] type: str # noqa:A003 - state: str + is_released: bool times: MatchTimings @@ -99,7 +100,7 @@ def match_json_info(comp: SRComp, match: Match, when: datetime.datetime) -> Matc 'arena': match.arena, 'teams': match.teams, 'type': match.type.value, - 'state': state.value, + 'is_released': state == MatchState.RELEASED, 'times': { 'slot': { 'start': match.start_time.isoformat(), diff --git a/tests/test_api.py b/tests/test_api.py index 04222b9..876e551 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -28,7 +28,7 @@ 'league': {'CLY': 8, 'TTN': 6}, 'ranking': {'CLY': 1, 'TTN': 2}, }, - 'state': 'released', + 'is_released': True, 'times': { 'slot': { 'start': '2014-04-26T13:00:00+01:00', @@ -63,7 +63,7 @@ 'league': {'QMC': 6, 'GRS': 8}, 'ranking': {'QMC': 2, 'GRS': 1}, }, - 'state': 'released', + 'is_released': True, 'times': { 'slot': { 'start': '2014-04-26T13:00:00+01:00', @@ -347,7 +347,7 @@ def test_matches(self) -> None: 'league': {'CLY': 8, 'TTN': 6}, 'ranking': {'CLY': 1, 'TTN': 2}, }, - 'state': 'released', + 'is_released': True, 'times': { 'slot': { 'start': '2014-04-26T13:00:00+01:00', @@ -394,7 +394,7 @@ def test_match_forwards_limit(self) -> None: 'league': {'CLY': 8, 'TTN': 6}, 'ranking': {'CLY': 1, 'TTN': 2}, }, - 'state': 'released', + 'is_released': True, 'times': { 'slot': { 'start': '2014-04-26T13:00:00+01:00', @@ -435,7 +435,7 @@ def test_match_backwards_limit(self) -> None: 'type': 'knockout', 'num': 129, 'arena': 'A', - 'state': 'released', + 'is_released': True, 'times': { 'game': { 'end': '2014-04-27T17:29:30+01:00', @@ -477,7 +477,7 @@ def test_match_filter_naive_datetime(self) -> None: 'type': 'knockout', 'num': 129, 'arena': 'A', - 'state': 'released', + 'is_released': True, 'times': { 'game': { 'end': '2014-04-27T17:29:30+01:00', @@ -527,7 +527,7 @@ def test_match_filter_time(self) -> None: 'type': 'knockout', 'num': 129, 'arena': 'A', - 'state': 'released', + 'is_released': True, 'times': { 'game': { 'end': '2014-04-27T17:29:30+01:00', @@ -701,7 +701,7 @@ def test_knockouts(self) -> None: 'num': 111, 'display_name': 'Match 111', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -710,7 +710,7 @@ def test_knockouts(self) -> None: 'num': 111, 'display_name': 'Match 111', 'teams': [None, '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -719,7 +719,7 @@ def test_knockouts(self) -> None: 'num': 112, 'display_name': 'Match 112', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -728,7 +728,7 @@ def test_knockouts(self) -> None: 'num': 112, 'display_name': 'Match 112', 'teams': ['???', '???', '???', None], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -737,7 +737,7 @@ def test_knockouts(self) -> None: 'num': 113, 'display_name': 'Match 113', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -746,7 +746,7 @@ def test_knockouts(self) -> None: 'num': 113, 'display_name': 'Match 113', 'teams': ['???', '???', '???', None], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -755,7 +755,7 @@ def test_knockouts(self) -> None: 'num': 114, 'display_name': 'Match 114', 'teams': ['???', '???', None, '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -764,7 +764,7 @@ def test_knockouts(self) -> None: 'num': 114, 'display_name': 'Match 114', 'teams': ['???', None, '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -773,7 +773,7 @@ def test_knockouts(self) -> None: 'num': 115, 'display_name': 'Match 115', 'teams': ['???', None, '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -782,7 +782,7 @@ def test_knockouts(self) -> None: 'num': 115, 'display_name': 'Match 115', 'teams': [None, '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -791,7 +791,7 @@ def test_knockouts(self) -> None: 'num': 116, 'display_name': 'Match 116', 'teams': [None, '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -800,7 +800,7 @@ def test_knockouts(self) -> None: 'num': 116, 'display_name': 'Match 116', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -809,7 +809,7 @@ def test_knockouts(self) -> None: 'num': 117, 'display_name': 'Match 117', 'teams': [None, '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -818,7 +818,7 @@ def test_knockouts(self) -> None: 'num': 117, 'display_name': 'Match 117', 'teams': ['???', '???', '???', None], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -827,7 +827,7 @@ def test_knockouts(self) -> None: 'num': 118, 'display_name': 'Match 118', 'teams': [None, '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -836,7 +836,7 @@ def test_knockouts(self) -> None: 'num': 118, 'display_name': 'Match 118', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -847,7 +847,7 @@ def test_knockouts(self) -> None: 'num': 119, 'display_name': 'Match 119', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -856,7 +856,7 @@ def test_knockouts(self) -> None: 'num': 119, 'display_name': 'Match 119', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -865,7 +865,7 @@ def test_knockouts(self) -> None: 'num': 120, 'display_name': 'Match 120', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -874,7 +874,7 @@ def test_knockouts(self) -> None: 'num': 120, 'display_name': 'Match 120', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -883,7 +883,7 @@ def test_knockouts(self) -> None: 'num': 121, 'display_name': 'Match 121', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -892,7 +892,7 @@ def test_knockouts(self) -> None: 'num': 121, 'display_name': 'Match 121', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -901,7 +901,7 @@ def test_knockouts(self) -> None: 'num': 122, 'display_name': 'Match 122', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -910,7 +910,7 @@ def test_knockouts(self) -> None: 'num': 122, 'display_name': 'Match 122', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -921,7 +921,7 @@ def test_knockouts(self) -> None: 'num': 123, 'display_name': 'Quarter 1 (#123)', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -930,7 +930,7 @@ def test_knockouts(self) -> None: 'num': 124, 'display_name': 'Quarter 2 (#124)', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -939,7 +939,7 @@ def test_knockouts(self) -> None: 'num': 125, 'display_name': 'Quarter 3 (#125)', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -948,7 +948,7 @@ def test_knockouts(self) -> None: 'num': 126, 'display_name': 'Quarter 4 (#126)', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -959,7 +959,7 @@ def test_knockouts(self) -> None: 'num': 127, 'display_name': 'Semi 1 (#127)', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -968,7 +968,7 @@ def test_knockouts(self) -> None: 'num': 128, 'display_name': 'Semi 2 (#128)', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -979,7 +979,7 @@ def test_knockouts(self) -> None: 'num': 129, 'display_name': 'Final (#129)', 'teams': ['???', '???', '???', '???'], - 'state': 'released', + 'is_released': True, 'times': None, 'type': 'knockout', }, From ad114d934e5b211533affb5ded1e2faec5afcb6d Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 1 Feb 2026 14:16:25 +0000 Subject: [PATCH 6/6] Document the semantics of `/current` relating to match releasing --- docs/endpoints.rst | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/endpoints.rst b/docs/endpoints.rst index b498e22..522e46d 100644 --- a/docs/endpoints.rst +++ b/docs/endpoints.rst @@ -140,9 +140,13 @@ the current time. "time": "..." } -The ``delay`` value is the amount of delay in seconds currently active. -Note that this value is only useful during match periods (it will otherwise -be ``0``). +The ``delay`` value is the amount of committed delay in seconds currently +active. This does not account for matches which have not yet been released but +which have passed their release threshold. Delays from belated match releases +will appear only when the match is eventually released (and the corresponding +delay is committed into the state). +Note that this value is only useful during match periods (it will otherwise be +``0``). The ``matches`` key is a list of the matches which are currently being played, as measured by the current time falling between the start and end @@ -159,7 +163,13 @@ being shepherded for, as measured by the current time falling between the earliest shepherding signal value and time when staging closes. They are presented in the same format as the `/matches`_ endpoint uses. -The ``time`` key is the current time on the server. +Each of ``matches``, ``staging_matches``, ``shepherding_matches`` accounts for +the match releasing mechanism. In the case of a match not being released "on +time" then it and subsequent matches are held back and will remain in their +corresponding keys as if time had stopped at the release threshold. + +The ``time`` key is the current time on the server. This value progresses +regardless of match holds. /state ------