From 7cb6d619453a7045e5ec53e531c6aa9f6f48c42d Mon Sep 17 00:00:00 2001 From: zaphol Date: Wed, 4 Nov 2026 23:14:28 -0600 Subject: [PATCH 1/3] Add staged opt-in compression and compact flight responses --- README.rst | 3 + docs/flight-response-options.rst | 83 +++++++++++++++ service/flight_response.py | 81 +++++++++++++++ service/main.py | 16 ++- tests/test_flight_response_options.py | 140 ++++++++++++++++++++++++++ 5 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 docs/flight-response-options.rst create mode 100644 service/flight_response.py create mode 100644 tests/test_flight_response_options.py diff --git a/README.rst b/README.rst index 8c183a1..269ec11 100644 --- a/README.rst +++ b/README.rst @@ -16,6 +16,9 @@ repository. Installation ------------ +For staged HTTP compression and compact rendering responses, see +`Opt-in flight responses `_. + From an Anaconda environment ---------------------------- diff --git a/docs/flight-response-options.rst b/docs/flight-response-options.rst new file mode 100644 index 0000000..0fa0a0f --- /dev/null +++ b/docs/flight-response-options.rst @@ -0,0 +1,83 @@ +Opt-in flight responses +======================= + +These options apply to POST /api/flight_path, /api/flight_path_from_summary, +and /api/flight_paths. They do not change WebSocket messages. Requests without +response_options retain the existing full-precision, uncompressed JSON response, +even if the client advertises gzip support. Physics and solver settings are unchanged. + +Stage 1: lossless transport compression +-------------------------------------- + +Add this member to the existing flight request body:: + + "response_options": {"version": 1, "compression": "gzip"} + +The HTTP client must also advertise gzip in Accept-Encoding. Both opt-ins are +required. Clients such as browsers manage Accept-Encoding themselves; do not +manually decompress a response already decoded by the networking library. + +The response remains application/json. When compressed it carries +Content-Encoding: gzip and a Content-Length describing the encoded bytes. +Vary: Accept-Encoding is added for requests opting into gzip. Missing or +unacceptable gzip support falls back to identity encoding. Tiny responses remain +uncompressed if gzip would increase their size. Always inspect Content-Encoding +rather than assuming the response is compressed. Gzip level 1 is used to limit +CPU overhead. No new runtime dependency is required. + +Stage 2: compact numerical output +-------------------------------- + +All fields, reduced precision:: + + "response_options": { + "version": 2, + "compression": "gzip", + "profile": "full" + } + +Rendering fields, retaining spin animation:: + + "response_options": { + "version": 2, + "compression": "gzip", + "profile": "render", + "include_spin": true + } + +The render profile omits v (velocity). Set include_spin to false only for +consumers that do not need gamma; the web spin animation uses gamma. Quaternions +are not changed to incorporate gamma when gamma is omitted. All positions, +timestamps, quaternions, and sample counts retain their existing array layout. +Batch responses retain their existing disc-name/index keys. Options apply to +every flight in the batch. There is no response envelope or downsampling. + +Version 2 rounds only the serialized output: + +* p: 3 decimal places in metres (0.5 mm maximum error per component). +* v: 3 decimal places in metres/second. +* t: 5 decimal places in seconds. +* qx, qy, qz, qw: 6 decimal places. +* gamma: 5 decimal places in radians, including any requested gamma offset. + +If rounding would create duplicate/non-increasing timestamps, the entire t array +retains its original precision. Clients should normalize rounded quaternions +before operations requiring exact unit length. Compact values are intended for +display; use version 1 for analytics requiring full precision, particularly +finite differences of position over short time intervals. + +Version defaults to 1, compression to identity, profile to full, and include_spin +to true. Version 2 can be requested without compression. profile and include_spin +are only accepted with version 2; include_spin=false requires profile=render. +Unknown options, unsupported values, and incorrect types return HTTP 400 before +simulation. Brotli and binary formats are not supported in this initial rollout. + +Rollout and measurement +----------------------- + +Deploy with defaults unchanged. Opt a test client into stage 1, verify automatic +decoding and measure encoded bytes, request-to-response time and first rendered +frame on real mobile connections. Then opt that client into stage 2 and compare +flight appearance, landing position, and spin orientation. Compare p50/p95 under +matched concurrency and network conditions. Cloud request latency alone does not +measure client parsing/rendering. Removing response_options rolls back both stages. diff --git a/service/flight_response.py b/service/flight_response.py new file mode 100644 index 0000000..9e9d369 --- /dev/null +++ b/service/flight_response.py @@ -0,0 +1,81 @@ +"""Opt-in HTTP transport and rendering formats; never change solver precision.""" + +import gzip +from dataclasses import dataclass + +from flask import jsonify, request +from werkzeug.exceptions import BadRequest + + +@dataclass(frozen=True) +class ResponseOptions: + version: int = 1 + compression: str = "identity" + profile: str = "full" + include_spin: bool = True + + +def parse_response_options(content): + options = content.get("response_options", {}) + if not isinstance(options, dict): + raise BadRequest("response_options must be an object") + allowed = {"version", "compression", "profile", "include_spin"} + if options.keys() - allowed: + raise BadRequest("Unknown response_options field") + version = options.get("version", 1) + compression = options.get("compression", "identity") + profile = options.get("profile", "full") + include_spin = options.get("include_spin", True) + if type(version) is not int or version not in (1, 2): + raise BadRequest("response_options.version must be 1 or 2") + if compression not in ("identity", "gzip"): + raise BadRequest("response_options.compression must be identity or gzip") + if profile not in ("full", "render"): + raise BadRequest("response_options.profile must be full or render") + if type(include_spin) is not bool: + raise BadRequest("response_options.include_spin must be a boolean") + if version == 1 and ("profile" in options or "include_spin" in options): + raise BadRequest("profile and include_spin require response version 2") + if profile != "render" and not include_spin: + raise BadRequest("include_spin=false requires the render profile") + return ResponseOptions(version, compression, profile, include_spin) + + +def format_flight_result(result, options): + if options.version == 1: + return result + precision = {"p": 3, "v": 3, "t": 5, "qx": 6, "qy": 6, + "qz": 6, "qw": 6, "gamma": 5} + + def rounded(value, digits): + if isinstance(value, list): + return [rounded(item, digits) for item in value] + return round(float(value), digits) + + compact = { + key: rounded(value, precision[key]) + for key, value in result.items() + if not (options.profile == "render" and + (key == "v" or (key == "gamma" and not options.include_spin))) + } + # Very short solver steps must not turn into duplicate timestamps. + times = compact["t"] + if any(right <= left for left, right in zip(times, times[1:])): + compact["t"] = result["t"] + return compact + + +def flight_response(data, options): + response = jsonify(data) + if options.compression != "gzip": + return response + response.vary.add("Accept-Encoding") + # The body opt-in alone does not prove the client can decode gzip. + if request.accept_encodings.quality("gzip") <= 0: + return response + raw = response.get_data() + compressed = gzip.compress(raw, compresslevel=1, mtime=0) + if len(compressed) < len(raw): + response.set_data(compressed) + response.headers["Content-Encoding"] = "gzip" + return response diff --git a/service/main.py b/service/main.py index 83ff5b3..d6622e2 100644 --- a/service/main.py +++ b/service/main.py @@ -15,6 +15,9 @@ from flask_cors import CORS from flask_sock import Sock from frispy.disc import FrisPyResults +from service.flight_response import ( + parse_response_options, format_flight_result, flight_response, +) # import google.cloud.logging # client = google.cloud.logging.Client() @@ -76,6 +79,7 @@ def inc_send_over_websocket(disc, gamma, s) -> bool: @app.route('/api/flight_paths', methods=['POST']) def flight_paths(): content = request.json + options = parse_response_options(content) discs = content.get('disc_names') res = {} if discs: @@ -83,7 +87,7 @@ def flight_paths(): content['disc_name'] = discName disc = create_disc(content) result = compute_trajectory(disc) - res[discName] = to_result(content.get('gamma', 0), result) + res[discName] = format_flight_result(to_result(content.get('gamma', 0), result), options) else: discs = content.get('disc_numbers') if discs: # Check if discs is not None before iterating @@ -91,9 +95,9 @@ def flight_paths(): content['flight_numbers'] = discNumbers disc = create_disc(content) result = compute_trajectory(disc) - res[index] = to_result(content.get('gamma', 0), result) + res[index] = format_flight_result(to_result(content.get('gamma', 0), result), options) - return res + return flight_response(res, options) # this method assumes the disc velocity is in the X direction. @@ -104,9 +108,10 @@ def flight_paths(): @app.route('/api/flight_path', methods=['POST']) def flight_path(): content = request.json + options = parse_response_options(content) disc = create_disc(content) result = compute_trajectory(disc) - return to_result(content.get('gamma', 0), result) + return flight_response(format_flight_result(to_result(content.get('gamma', 0), result), options), options) # send over the throw summary to get the flight directly. @@ -115,10 +120,11 @@ def flight_path(): @app.route('/api/flight_path_from_summary', methods=['POST']) def flight_path_from_summary(): content = request.json + options = parse_response_options(content) content = to_flight_path_request(content) disc = create_disc(content) result = compute_trajectory(disc) - return to_result(0, result) + return flight_response(format_flight_result(to_result(0, result), options), options) def create_disc(content) -> Disc: diff --git a/tests/test_flight_response_options.py b/tests/test_flight_response_options.py new file mode 100644 index 0000000..741492a --- /dev/null +++ b/tests/test_flight_response_options.py @@ -0,0 +1,140 @@ +import gzip +import json +from types import SimpleNamespace +from unittest.mock import patch + +import numpy as np +import pytest + +from service.flight_response import ResponseOptions, format_flight_result + + +@pytest.fixture +def trajectory(): + n = 40 + return SimpleNamespace( + pos=[[1.23456789, -2.34567891, 0.12345678] for _ in range(n)], + times=np.linspace(0, 1.23456789, n), + v=[np.array([12.3456789, -0.12345678, 0.12345678]) for _ in range(n)], + qx=np.full(n, 0.123456789), qy=np.full(n, -0.234567891), + qz=np.full(n, 0.345678912), qw=np.full(n, 0.9), + gamma=[i * 0.123456789 for i in range(n)], + ) + + +@pytest.fixture +def simulation(trajectory): + with patch('service.main.compute_trajectory', return_value=trajectory) as compute: + yield compute + + +PAYLOAD = dict(disc_name='ultrastar', v=20, spin=-80, + uphill_degrees=0, hyzer_degrees=0, nose_up_degrees=0) +ENDPOINTS = [ + ('/api/flight_path', PAYLOAD, None), + ('/api/flight_path_from_summary', + dict(speedMph=60, rotPerSec=10, flight_numbers=dict(speed=12, glide=5, turn=-1, fade=2)), None), + ('/api/flight_paths', dict(PAYLOAD, disc_names=['ultrastar', 'wraith']), 'ultrastar'), + ('/api/flight_paths', dict(PAYLOAD, disc_numbers=[dict(speed=12, glide=5, turn=-1, fade=2)]), '0'), +] + + +@pytest.mark.parametrize('endpoint,payload,key', ENDPOINTS) +def test_legacy_and_lossless_compression(client, simulation, endpoint, payload, key): + plain = client.post(endpoint, json=payload) + advertised = client.post(endpoint, json=payload, headers={'Accept-Encoding': 'gzip'}) + assert plain.status_code == advertised.status_code == 200 + assert plain.data == advertised.data + assert 'Content-Encoding' not in advertised.headers + compressed = client.post(endpoint, json=dict(payload, response_options={'compression': 'gzip'}), + headers={'Accept-Encoding': 'gzip'}) + assert compressed.status_code == 200 + assert compressed.headers['Content-Encoding'] == 'gzip' + assert compressed.mimetype == 'application/json' + assert 'Accept-Encoding' in compressed.vary + assert int(compressed.headers['Content-Length']) == len(compressed.data) + assert len(compressed.data) < len(plain.data) + assert gzip.decompress(compressed.data) == plain.data + + +@pytest.mark.parametrize('accept,compressed', [ + (None, False), ('br', False), ('gzip;q=0', False), + ('*;q=0.5', True), ('gzip;q=0, *;q=1', False), ('gzip;q=0.5', True), +]) +def test_encoding_negotiation(client, simulation, accept, compressed): + headers = {} if accept is None else {'Accept-Encoding': accept} + response = client.post('/api/flight_path', + json=dict(PAYLOAD, response_options={'compression': 'gzip'}), headers=headers) + assert response.status_code == 200 + assert ('Content-Encoding' in response.headers) == compressed + assert 'Accept-Encoding' in response.vary + + +@pytest.mark.parametrize('endpoint,payload,key', ENDPOINTS) +@pytest.mark.parametrize('profile,include_spin', [('full', True), ('render', True), ('render', False)]) +def test_compact_preserves_samples_and_profiles(client, simulation, endpoint, payload, key, profile, include_spin): + options = dict(version=2, profile=profile, include_spin=include_spin, compression='gzip') + response = client.post(endpoint, json=dict(payload, response_options=options), + headers={'Accept-Encoding': 'gzip'}) + assert response.status_code == 200 + data = json.loads(gzip.decompress(response.data)) + result = data if key is None else data[key] + assert all(len(values) == 40 for values in result.values()) + assert result['p'][0] == [1.235, -2.346, 0.123] + assert result['qx'][0] == 0.123457 + assert result['t'][-1] == 1.23457 + assert ('v' in result) == (profile == 'full') + assert ('gamma' in result) == include_spin + + +def test_compact_without_compression(client, simulation): + response = client.post('/api/flight_path', json=dict(PAYLOAD, response_options={'version': 2}), + headers={'Accept-Encoding': 'gzip'}) + assert response.status_code == 200 + assert 'Content-Encoding' not in response.headers + assert response.json['v'][0] == [12.346, -0.123, 0.123] + assert 'gamma' in response.json + + +@pytest.mark.parametrize('options', [ + None, [], 'gzip', {'version': True}, {'version': 1.0}, {'version': 3}, + {'compression': 'br'}, {'compression': []}, {'profile': 'render'}, + {'include_spin': False}, {'version': 2, 'profile': 'unknown'}, + {'version': 2, 'include_spin': 0}, {'version': 2, 'include_spin': False}, + {'versoin': 2}, +]) +@pytest.mark.parametrize('endpoint,payload,key', ENDPOINTS) +def test_invalid_options_fail_before_simulation(client, simulation, options, endpoint, payload, key): + response = client.post(endpoint, json=dict(payload, response_options=options)) + assert response.status_code == 400 + simulation.assert_not_called() + + +def test_rounding_does_not_merge_close_timestamps_or_mutate_input(): + source = {'t': [0.0, 0.000001, 0.000002], 'p': [[0.123456, 0, 1]] * 3} + result = format_flight_result(source, ResponseOptions(version=2)) + assert result['t'] == source['t'] + assert source['p'][0][0] == 0.123456 + assert result['p'][0][0] == 0.123 + + +def test_tiny_response_is_not_expanded(client, simulation): + response = client.post('/api/flight_paths', json={'response_options': {'compression': 'gzip'}}, + headers={'Accept-Encoding': 'gzip'}) + assert response.status_code == 200 + assert response.json == {} + assert 'Content-Encoding' not in response.headers + + +def test_real_flight_compact_error_bounds(client): + plain = client.post('/api/flight_path', json=PAYLOAD).json + response = client.post('/api/flight_path', + json=dict(PAYLOAD, response_options={'version': 2, 'compression': 'gzip'}), + headers={'Accept-Encoding': 'gzip'}) + compact = json.loads(gzip.decompress(response.data)) + assert len(compact['t']) == len(plain['t']) + assert np.all(np.diff(compact['t']) > 0) + for field, tolerance in [('p', 0.0005), ('v', 0.0005), ('t', 0.000005), + ('qx', 0.0000005), ('qy', 0.0000005), + ('qz', 0.0000005), ('qw', 0.0000005), ('gamma', 0.000005)]: + assert np.max(np.abs(np.asarray(compact[field]) - np.asarray(plain[field]))) <= tolerance + 1e-12 From 65dbd6c291a469499fc5fc92dd247bdf31ae53b4 Mon Sep 17 00:00:00 2001 From: zaphol Date: Wed, 11 Nov 2026 00:57:05 -0600 Subject: [PATCH 2/3] Support opt-in compact WebSocket chunks with exact timestamps --- docs/flight-response-options.rst | 24 +++++++++++++++---- service/flight_response.py | 14 +++++++---- service/main.py | 15 +++++++----- tests/test_flight_response_options.py | 34 ++++++++++++++++++++++++++- 4 files changed, 71 insertions(+), 16 deletions(-) diff --git a/docs/flight-response-options.rst b/docs/flight-response-options.rst index 0fa0a0f..688fbd4 100644 --- a/docs/flight-response-options.rst +++ b/docs/flight-response-options.rst @@ -2,7 +2,8 @@ Opt-in flight responses ======================= These options apply to POST /api/flight_path, /api/flight_path_from_summary, -and /api/flight_paths. They do not change WebSocket messages. Requests without +and /api/flight_paths, plus the two /api/ws/flight_path endpoints (including +/api/ws/flight_path_from_summary). Requests without response_options retain the existing full-precision, uncompressed JSON response, even if the client advertises gzip support. Physics and solver settings are unchanged. @@ -56,12 +57,12 @@ Version 2 rounds only the serialized output: * p: 3 decimal places in metres (0.5 mm maximum error per component). * v: 3 decimal places in metres/second. -* t: 5 decimal places in seconds. +* t: exact original timestamps in seconds. * qx, qy, qz, qw: 6 decimal places. * gamma: 5 decimal places in radians, including any requested gamma offset. -If rounding would create duplicate/non-increasing timestamps, the entire t array -retains its original precision. Clients should normalize rounded quaternions +Timestamps always retain their original precision to preserve tiny solver steps +and shared streaming boundaries. Clients should normalize rounded quaternions before operations requiring exact unit length. Compact values are intended for display; use version 1 for analytics requiring full precision, particularly finite differences of position over short time intervals. @@ -81,3 +82,18 @@ frame on real mobile connections. Then opt that client into stage 2 and compare flight appearance, landing position, and spin orientation. Compare p50/p95 under matched concurrency and network conditions. Cloud request latency alone does not measure client parsing/rendering. Removing response_options rolls back both stages. + +WebSocket compact output +------------------------ + +Include response_options in the initial JSON message. Version 2 and profile full +retain all samples, velocity, and gamma for V2 spin processing. Chunk boundaries, +termination marker ({}), and physics are unchanged. Compact formatting only changes +sent values; continuing solver state always uses the original full-precision results. +Use compression=identity (the default). Requesting gzip on a WebSocket is rejected; +WebSocket compression is not implemented. Invalid options close the socket without +running a simulation. Existing clients omitting options receive legacy messages. + +The V2 gateway requests version 2/profile full automatically, HTTP gzip for full +responses, and identity transport for WebSockets. Direct FrisPy clients must still +opt in themselves. Deploy FrisPy before the companion V2 gateway for upstream savings. diff --git a/service/flight_response.py b/service/flight_response.py index 9e9d369..768780b 100644 --- a/service/flight_response.py +++ b/service/flight_response.py @@ -41,6 +41,13 @@ def parse_response_options(content): return ResponseOptions(version, compression, profile, include_spin) +def parse_websocket_response_options(content): + options = parse_response_options(content) + if options.compression != "identity": + raise BadRequest("WebSocket compression is not supported") + return options + + def format_flight_result(result, options): if options.version == 1: return result @@ -53,15 +60,12 @@ def rounded(value, digits): return round(float(value), digits) compact = { - key: rounded(value, precision[key]) + key: value if key == "t" else rounded(value, precision[key]) for key, value in result.items() if not (options.profile == "render" and (key == "v" or (key == "gamma" and not options.include_spin))) } - # Very short solver steps must not turn into duplicate timestamps. - times = compact["t"] - if any(right <= left for left, right in zip(times, times[1:])): - compact["t"] = result["t"] + # Exact timestamps preserve short steps and shared WebSocket chunk boundaries. return compact diff --git a/service/main.py b/service/main.py index d6622e2..4b445c0 100644 --- a/service/main.py +++ b/service/main.py @@ -16,7 +16,8 @@ from flask_sock import Sock from frispy.disc import FrisPyResults from service.flight_response import ( - parse_response_options, format_flight_result, flight_response, + ResponseOptions, parse_response_options, parse_websocket_response_options, + format_flight_result, flight_response, ) # import google.cloud.logging @@ -36,9 +37,10 @@ def ws_flight_path(s): return content = json.loads(data) + options = parse_websocket_response_options(content) gamma = content.get('gamma', 0) disc = create_disc(content) - inc_send_over_websocket(disc, gamma, s) + inc_send_over_websocket(disc, gamma, s, options) finally: s.close() @@ -51,24 +53,25 @@ def ws_flight_path_from_summary(s): return content = json.loads(data) + options = parse_websocket_response_options(content) content = to_flight_path_request(content) disc = create_disc(content) - inc_send_over_websocket(disc, 0, s) + inc_send_over_websocket(disc, 0, s, options) finally: s.close() -def inc_send_over_websocket(disc, gamma, s) -> bool: +def inc_send_over_websocket(disc, gamma, s, options=ResponseOptions()) -> bool: results = compute_trajectory(disc, 1.0) - s.send(json.dumps(to_result(gamma, results))) + s.send(json.dumps(format_flight_result(to_result(gamma, results), options))) disc.set_initial_conditions_from_prev_results(results) while results.z[-1] > 1e-6: # bail early if socket is closed if not s.connected: return False results = compute_trajectory(disc, 1.0, results.times[-1]) - s.send(json.dumps(to_result(gamma, results))) + s.send(json.dumps(format_flight_result(to_result(gamma, results), options))) disc.set_initial_conditions_from_prev_results(results) # send empty object to signal end of flight s.send("{}") diff --git a/tests/test_flight_response_options.py b/tests/test_flight_response_options.py index 741492a..5bdde6d 100644 --- a/tests/test_flight_response_options.py +++ b/tests/test_flight_response_options.py @@ -82,7 +82,7 @@ def test_compact_preserves_samples_and_profiles(client, simulation, endpoint, pa assert all(len(values) == 40 for values in result.values()) assert result['p'][0] == [1.235, -2.346, 0.123] assert result['qx'][0] == 0.123457 - assert result['t'][-1] == 1.23457 + assert result['t'][-1] == 1.23456789 assert ('v' in result) == (profile == 'full') assert ('gamma' in result) == include_spin @@ -138,3 +138,35 @@ def test_real_flight_compact_error_bounds(client): ('qx', 0.0000005), ('qy', 0.0000005), ('qz', 0.0000005), ('qw', 0.0000005), ('gamma', 0.000005)]: assert np.max(np.abs(np.asarray(compact[field]) - np.asarray(plain[field]))) <= tolerance + 1e-12 + +@pytest.mark.parametrize('compact', [False, True]) +def test_websocket_chunks_preserve_boundaries_and_solver_state(compact): + from service.main import inc_send_over_websocket + from unittest.mock import MagicMock + disc = MagicMock() + chunk1 = SimpleNamespace(pos=[[1.12345678, 0, 1], [2.12345678, 0, 0.5]], + times=np.array([0., 1.000000001]), v=[np.array([1., 0, 0])]*2, + qx=np.array([0., 0.]), qy=np.array([0., 0.]), qz=np.array([0., 0.]), qw=np.array([1., 1.]), + gamma=[0., 1.123456789], z=np.array([1., 0.5])) + chunk2 = SimpleNamespace(**vars(chunk1)) + chunk2.times=np.array([1.000000001,1.000000002]) + chunk2.z=np.array([0.5,0.]) + socket=MagicMock();socket.connected=True + options=ResponseOptions(version=2 if compact else 1) + with patch('service.main.compute_trajectory',side_effect=[chunk1,chunk2]) as compute: + assert inc_send_over_websocket(disc,0,socket,options) + first,second,terminal=[json.loads(call.args[0]) for call in socket.send.call_args_list] + assert first['t'][-1]==second['t'][0]==1.000000001 + assert second['t'][1]>second['t'][0] + assert first['p'][0][0]==(1.123 if compact else 1.12345678) + assert 'v' in first and 'gamma' in first + assert terminal=={} + assert disc.set_initial_conditions_from_prev_results.call_args_list[0].args[0] is chunk1 + assert compute.call_args_list[1].args[2]==chunk1.times[-1] + + +def test_websocket_rejects_gzip(): + from service.flight_response import parse_websocket_response_options + from werkzeug.exceptions import BadRequest + with pytest.raises(BadRequest,match='WebSocket compression'): + parse_websocket_response_options({'response_options':{'compression':'gzip'}}) From ab5d3e1230a1fb301029237bb51814b46b576959 Mon Sep 17 00:00:00 2001 From: zaphol Date: Wed, 11 Nov 2026 01:07:58 -0600 Subject: [PATCH 3/3] Fix Python 3.9 CI runner and update setup actions --- .github/workflows/python-app.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index fd42970..641c053 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -12,13 +12,14 @@ on: jobs: build: - runs-on: ubuntu-latest + # Keep the runner on an OS with published Python 3.9 binaries. + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Set up Python 3.9 - uses: actions/setup-python@v2 + uses: actions/setup-python@v6 with: python-version: '3.9' @@ -45,4 +46,4 @@ jobs: run: | coveralls env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}