Skip to content

Add Pyodide/Emscripten client support via FetchConnector (continuation of #7803)#13057

Draft
bendichter wants to merge 4 commits into
aio-libs:masterfrom
bendichter:pyodide-support-v2
Draft

Add Pyodide/Emscripten client support via FetchConnector (continuation of #7803)#13057
bendichter wants to merge 4 commits into
aio-libs:masterfrom
bendichter:pyodide-support-v2

Conversation

@bendichter

Copy link
Copy Markdown

What do these changes do?

Adds client support for running under Pyodide / Emscripten (WebAssembly in browsers and Node.js), picking up where #7803 left off. I'd really like to be able to use aiohttp from Pyodide (e.g. pyodide/pyodide#4998), and since #7803 invited someone to continue the work, I took a stab at it.

Full disclosure up front: AI (Claude) was used heavily to write this. I've reviewed it and tested it end-to-end and it looks reasonable to me, but I'm very aware this could be more noise than help for maintainers — if it isn't useful, please feel free to close it, no hard feelings. If the overall direction is right, I'm happy to keep working on it based on your feedback.

Design

Following the suggestion in #7803 (comment) (encapsulate the logic in a custom connector so _request() needs minimal changes), everything lives in a new aiohttp/pyodide.py module providing FetchConnector, which ClientSession selects as the default connector when sys.platform == "emscripten" — that default selection is the only change to the client flow.

Rather than subclassing ClientRequest/ClientResponse (as #7803 did against the 3.x codebase), the connector uses the HTTP/1.1 wire format as its interface:

  • a fake transport captures the serialized request and decodes it with aiohttp's own HttpRequestParser (so cookies, auto-added headers, and chunked bodies are all handled by existing code),
  • the request is dispatched through the JavaScript fetch() API,
  • the response is re-serialized into HTTP/1.1 bytes and fed through the standard ResponseHandler/HttpResponseParser.

The fetch-specific details handled: framing/hop-by-hop headers are stripped in both directions (fetch delivers bodies already decompressed/unframed, so e.g. a stale Content-Encoding would corrupt parsing), repeated Set-Cookie headers are recovered via Headers.getSetCookie(), cancellation/timeouts abort the fetch via AbortController, Expect: 100-continue is answered locally, and proxies / CONNECT / upgrades (WebSockets) raise a clear ClientConnectionError.

Two side discoveries:

  • FetchConnector(fetch=...) accepts an injected fetch implementation, so nearly the whole pipeline is unit-testable on regular CPython without a WebAssembly runtime (tests/test_fetch_connector.py) — this should help with the coverage concerns from ENH Add Pyodide support #7803.
  • aiohttp's C extensions could not load under Pyodide at all: llhttp's api.c references JavaScript-provided wasm_on_* callbacks whenever __wasm__ is defined, leaving undefined symbols in the extension. aiohttp/_llhttp_wasm_shim.c provides no-op definitions (aiohttp uses llhttp_init() with its own callbacks and never touches that path).

Testing

  • tests/test_fetch_connector.py: 18 unit tests with a stubbed fetch() (GET/POST/chunked async-generator bodies, header filtering both directions, multi Set-Cookie → cookie jar, 204/HEAD, error propagation, expect100, fetch_options, timeout-cancels-fetch, concurrency). These run in the normal test matrix.
  • tests/test_pyodide.py: pytest-pyodide integration tests that run the built wasm wheel (with compiled C extensions) in Node.js against a live local HTTP server — real POSTs, redirects, cookies, 404s, concurrency, and connection errors. Run by the new test-pyodide CI job (cibuildwheel --platform pyodide, modeled on the test-mobile job).
  • Locally I also ran a 13-scenario end-to-end script on Pyodide 314.0.2 (Python 3.14) under Node, including gzip responses and raise_for_status — plain ClientSession() + session.get(...) works unmodified.

Are there changes in behavior for the user?

None on existing platforms. Under Emscripten/Pyodide (previously unusable — getaddrinfo raises NotImplementedError), ClientSession now works out of the box via fetch(). Platform limitations are documented in docs/client_reference.rst: no proxies or WebSockets, redirects are followed transparently by fetch() before aiohttp sees the response, ssl arguments are ignored, and browsers enforce their own cookie/CORS policies.

Is it a substantial burden for the maintainers to support this?

I've tried to minimize it: the feature is a single self-contained module plus a 2-line default-connector selection; no existing code paths change. Because it talks to the rest of the client through the HTTP wire format and the public parser/protocol interfaces, it should be fairly insulated from internal refactors. The main ongoing costs are the extra CI job (one Ubuntu runner building a cp313 wasm wheel) and keeping the pinned Pyodide/cibuildwheel versions fresh. That said, you're the best judges of this — and if the answer is "yes, too much", closing this is a completely fair outcome.

Related issue number

Related to #7253 and continues #7803 (original work by @hoodmane, who is credited in the CHANGES fragment). Also relevant to pyodide/pyodide#4998.

Checklist

  • I think the code is well written
  • Unit tests for the changes exist
  • Documentation reflects the changes
  • If you provide code modification, please add yourself to CONTRIBUTORS.txt
  • Add a new news fragment into the CHANGES/ folder

🤖 Generated with Claude Code

bendichter and others added 2 commits July 4, 2026 19:03
FetchConnector dispatches requests through the JavaScript fetch() API,
decoding the serialized request with HttpRequestParser and re-serializing
the fetch() response into HTTP/1.1 bytes for the standard ResponseHandler,
so ClientSession needs no platform-specific changes beyond connector
selection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- tests/test_fetch_connector.py: unit tests for the whole fetch pipeline
  using an injected stub fetch(), runnable on any platform.
- tests/test_pyodide.py: pytest-pyodide integration tests running the
  built wasm wheel in Node.js against a local HTTP server.
- CI: test-pyodide job building the cp313 Emscripten wheel with
  cibuildwheel and running the integration tests with --rt node.
- aiohttp/_llhttp_wasm_shim.c: no-op definitions for llhttp's
  JavaScript-oriented wasm_on_* callbacks so the C extensions load
  under Emscripten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided There is a change note present in this PR label Jul 5, 2026
Comment thread tests/test_pyodide.py Fixed
@codecov

codecov Bot commented Jul 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.68153% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 98.96%. Comparing base (e3774b4) to head (72a65e3).
⚠️ Report is 30 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
aiohttp/pyodide.py 99.07% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##           master   #13057    +/-   ##
========================================
  Coverage   98.96%   98.96%            
========================================
  Files         131      133     +2     
  Lines       48156    48469   +313     
  Branches     2499     2514    +15     
========================================
+ Hits        47656    47968   +312     
  Misses        376      376            
- Partials      124      125     +1     
Flag Coverage Δ
Autobahn 22.25% <28.02%> (+0.03%) ⬆️
CI-GHA 98.91% <99.68%> (+<0.01%) ⬆️
OS-Linux 98.67% <99.68%> (-0.01%) ⬇️
OS-Windows 97.06% <99.68%> (+0.01%) ⬆️
OS-macOS 97.95% <99.68%> (+<0.01%) ⬆️
Py-3.10 98.15% <99.68%> (+<0.01%) ⬆️
Py-3.11 98.42% <99.68%> (+<0.01%) ⬆️
Py-3.12 98.51% <99.68%> (+<0.01%) ⬆️
Py-3.13 98.48% <99.68%> (+<0.01%) ⬆️
Py-3.14 98.50% <99.68%> (+<0.01%) ⬆️
Py-3.14t 97.60% <99.68%> (+<0.01%) ⬆️
Py-pypy-3.11 97.45% <99.68%> (-0.01%) ⬇️
VM-macos 97.95% <99.68%> (+<0.01%) ⬆️
VM-ubuntu 98.67% <99.68%> (-0.01%) ⬇️
VM-windows 97.06% <99.68%> (+0.01%) ⬆️
cython-coverage 37.94% <12.42%> (-0.17%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@codspeed-hq

codspeed-hq Bot commented Jul 5, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 83 untouched benchmarks
⏩ 83 skipped benchmarks1


Comparing bendichter:pyodide-support-v2 (72a65e3) with master (e3774b4)

Open in CodSpeed

Footnotes

  1. 83 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

- Add 'js' (from Node.js) to the docs spelling wordlist.
- Exclude tests/test_pyodide.py from codecov (it only executes inside
  the Pyodide runtime, where coverage is not collected).
- Add unit tests covering the remaining FetchConnector lines: platform
  default-connector selection, large-body flow control, transports
  writelines/abort/double-close, fetch abort on close, and responses
  without Headers.getSetCookie().
- Sanitize the echoed Content-Type in the test HTTP server (CodeQL
  response-splitting warning).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Dreamsorcerer

Copy link
Copy Markdown
Member

picking up where #7803 left off.

If you've picked up from that code, could you actually fork from that branch and keep the original commits? Otherwise attribution to the original author is missing, which is needed for copyright handling.

@Dreamsorcerer Dreamsorcerer added this to the 4.0 milestone Jul 13, 2026
Comment thread aiohttp/pyodide.py
self._abort_controller = None
if self._fetch_task is not None and not self._fetch_task.done():
self._fetch_task.cancel()
if not self._connection_lost_called:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just looking through the coverage. Currently no test reaches the situation that connection_lost_called is true. There are also 3 no cover comments below which I'm not clear why they shouldn't be covered?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two separate things here.

The 3 # pragma: no cover: all guard the IS_EMSCRIPTEN path — from js import ... and AbortController. Only importable inside Pyodide. Normal CI runs on CPython, cannot reach them. Real coverage comes from test_pyodide.py.

The _connection_lost_called branch: dead in this flow. _FetchTransport.close() already guards via _closing, and nothing else calls connection_lost(), so the flag is always False. Drop the guard (call unconditionally) or add # pragma: no branch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally this whole file should be excluded from coverage when tests are run natively and it should have to be covered in the Pyodide tests. See here for run_in_pyodide_coverage used in httpx2:
https://github.com/pydantic/httpx2/pull/1022/changes#diff-5e8856b9064bafa3712edb7239b9ff3da7d898b2178e2b4bcb3bd7dfcbe9c355R10

Each test makes it's own separate coverage file and they have to be combined. There's some tricky details about fixing up the file paths:
https://github.com/pydantic/httpx2/pull/1022/changes#diff-50c86b7ed8ac2cf95bd48334961bf0530cdc77b5a56f852c5c61b89d735fd711R89

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each test makes it's own separate coverage file and they have to be combined. There's some tricky details about fixing up the file paths:

That code looks like it just ends up calling coverage as normal:
https://github.com/pyodide/pytest-pyodide/blob/25f4c87780b984bfe44c8bd54ef2081e52071de7/pytest_pyodide/_decorator_in_pyodide.py#L153

So, I'd assume that if the correct arguments are passed, it should be fine. Maybe it makes more sense to run coverage directly ourselves (I'm unclear why the normal method of executing coverage wouldn't work here?). Otherwise, I'd expect it to still be possible to configure it such that it just appends to the same file as we do elsewhere (e.g.

run: pytest -m dev_mode --cov=aiohttp/ --cov=tests/ --cov-append
).

@Dreamsorcerer

Copy link
Copy Markdown
Member

This is substantial PR, so I'll loop back round to it before the 3.15 release.

@aiolibsbot review

@aiolibsbot

Copy link
Copy Markdown
Contributor

@Dreamsorcerer: > picking up where #7803 left off.

Valid copyright concern. CHANGES credit alone does not preserve authorship. Recommend rebasing this work onto #7803's branch so @hoodmane's original commits stay in history, then layer the new changes on top.

@aiolibsbot

Copy link
Copy Markdown
Contributor

PR Review — Add Pyodide/Emscripten client support via FetchConnector (continuation of #7803)

Solid, well-insulated design; blocked mainly on a redirect-behavior gap and the outstanding attribution/copyright request.

Strengths worth calling out:

  • The wire-format bridge (fake transport → HttpRequestParserfetch() → re-serialized HTTP/1.1 → ResponseHandler) is a genuinely clever way to reuse cookies, header handling, and chunked-body de-framing without touching the client core — the only client change is a 2-line default-connector switch.
  • The fetch= injection point makes almost the whole pipeline unit-testable on CPython; test_fetch_connector.py covers header filtering both directions, multi Set-Cookie recovery, expect100, timeout-cancels-fetch, flow control on >64KB bodies, and concurrency.
  • Careful, correct handling of hop-by-hop/framing headers in both directions, and clean rejection of proxies/CONNECT/upgrades.

Needs attention:

  • allow_redirects=False / max_redirects are silently ignored under FetchConnector — a footgun with SSRF implications; document explicitly or honor the flag.
  • Redundant _connection_lost_called guard is dead in this flow (source of the coverage warning) — simplify or annotate.
  • Add the PR-numbered changelog symlink (13057).
  • Process blocker (not code): @Dreamsorcerer asked that the branch be re-forked from ENH Add Pyodide support #7803 to preserve @hoodmane's original commits for copyright; the CHANGES credit alone doesn't satisfy this.

🟡 Important

1. `allow_redirects=False` / `max_redirects` are silently ignored
aiohttp/pyodide.py:21-23

fetch() is invoked with its default redirect: "follow" mode, so the browser/runtime follows redirects before aiohttp ever sees the response. But ClientSession._request() still accepts allow_redirects=False and max_redirects=N, and under FetchConnector those arguments become silent no-ops — the connector never receives them and cannot honor them.

Why it matters: code that passes allow_redirects=False is often doing so deliberately (e.g. to inspect a Location header, or as an SSRF mitigation that refuses to chase a redirect to an internal host). Under Pyodide that protection silently disappears and the request is redirected anyway — a surprising and potentially security-relevant behavior change, not just a missing feature.

Suggested fix: at minimum document this explicitly in the limitations list here and in docs/client_reference.rst (the current docs say redirects are followed transparently, but never say that disabling them has no effect). Better, honor the flag by mapping allow_redirects=False onto fetch's redirect: "manual" where the request exposes it, or raise a clear error when it is set so callers aren't misled.

* Redirects are followed transparently by the browser; aiohttp only sees
  the final response and cannot report redirect history.

🟢 Suggestions

1. Dead `_connection_lost_called` guard causes the partial-coverage warning
aiohttp/pyodide.py:169-171

In this connector's flow connection_lost() is only ever reached through this one call_soon(), and _FetchTransport.close() already guards against running _transport_closed() twice via self._closing. So self._connection_lost_called is always False here and the else/skip branch is unreachable — which is exactly the partial-branch coverage @Dreamsorcerer flagged.

Either drop the guard (call self._loop.call_soon(self.connection_lost, None) unconditionally) since it can't be re-entered, or keep it and annotate with # pragma: no branch. Dropping it is cleaner and removes the coverage noise.

if not self._connection_lost_called:
    self._loop.call_soon(self.connection_lost, None)
2. Add a PR-numbered changelog symlink
CHANGES/7803.feature.rst:1

Per AGENTS.md ("Both issue and PR number wanted: keep the issue-numbered file and symlink"), the changelog fragment should also be reachable by the PR number. Add CHANGES/13057.feature.rst as a symlink to 7803.feature.rst so the entry is attributed to both the originating issue and this PR.


Checklist

  • No injection / response-splitting in header serialization (fetch normalizes headers; test server strips CRLF)
  • Error paths converted to ClientConnectionError, not silently swallowed
  • Public API behavior consistent with existing platforms — warning #1
  • Test coverage for new branches — suggestion #1
  • Changelog fragment present and correctly attributed — suggestion #2
  • Docs updated for new public connector

To rebase specific severity levels, mention me: @aiolibsbot rebase critical (fixes 🔴 only), @aiolibsbot rebase important (fixes 🔴 + 🟡), or just @aiolibsbot rebase for all.


Silent Failure Analysis

🟡 **MEDIUM** — catch-all masking non-network errors
aiohttp/pyodide.py:205-215

Risk: Every exception raised in the fetch/serialize path — including genuine bugs like an AttributeError in _serialize_response or a bad JS interop conversion — is reported to callers as a ClientConnectionError, so retry/backoff logic that catches connection errors will silently swallow and retry real code defects as if they were transient network faults.

except Exception as exc:
    set_exception(
        self,
        ClientConnectionError(f"fetch() failed: {exc!r}"),
        exc,
    )

Fix: Narrow the caught type (or re-raise non-network exception classes) so that only genuine fetch/network failures are converted to ClientConnectionError, letting programming errors propagate as themselves.


Automated review by Kōan (Claude) HEAD=72a65e3 5 min 22s

Comment thread aiohttp/pyodide.py

__all__ = ("FetchConnector",)

IS_EMSCRIPTEN: Final = sys.platform == "emscripten"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this whole file be emscripten only?

Comment thread setup.py

# NOTE: makefile cythonizes all Cython modules

if USE_SYSTEM_DEPS:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we fail or ignore it if USE_SYSTEM_DEPS and sys.platform == "emscripten"?

Comment thread setup.py
# JavaScript-provided wasm_on_* callbacks; provide no-op stubs so the
# extension has no undefined symbols (aiohttp uses llhttp_init() with
# its own callbacks instead).
if sys.platform == "emscripten" or "emscripten" in os.environ.get(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should only need sys.platform == "emcripten". But also, the entirety of _llhttp_wasm_shim.c is wrapped in an #ifdef __wasm__, so you could include it unconditionally.

Comment thread setup.py
Comment on lines +58 to +61
# When targeting Emscripten (Pyodide), llhttp's api.c expects
# JavaScript-provided wasm_on_* callbacks; provide no-op stubs so the
# extension has no undefined symbols (aiohttp uses llhttp_init() with
# its own callbacks instead).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment seems redundant with the comment in __llhttp_wasm_shim.c, maybe just keep the one there.

Comment thread .mypy.ini
Comment on lines +47 to +51
# The run_in_pyodide decorator comes from pytest.importorskip() because
# pytest-pyodide is only installed for the dedicated Pyodide CI job.
[mypy-test_pyodide]
disallow_any_decorated = False
disallow_untyped_decorators = False

@hoodmane hoodmane Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it fix this type error to install pytest-pyodide in the mypy job?

Comment thread .mypy.ini
Comment on lines +41 to +45
[mypy-js]
ignore_missing_imports = True

[mypy-pyodide.*]
ignore_missing_imports = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it fix these type errors to install pyodide-py in the mypy job?

submodules: true
- name: Setup Python ${{ matrix.pyver }}
id: python-install
# important: do not use system python

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to know why it's important.

Comment on lines +384 to +387
PYODIDE_VERSION: 0.29.3
strategy:
matrix:
pyver: ["cp313"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great to also build and test for Pyodide 314.0.2 / Python 314.

- name: Install test dependencies
# aiohttp itself is needed on the host because tests/conftest.py
# imports it; the pure-Python build is sufficient.
run: AIOHTTP_NO_EXTENSIONS=1 uv pip install -e . pytest-pyodide==0.59.2 pytest pytest-aiohttp pytest-timeout coverage

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we pin pytest-pyodide? I would think we'd either want to pin all the deps or only ones that actually need a pin.

@hoodmane

Copy link
Copy Markdown

Thanks @bendichter for working on this!

@@ -0,0 +1,365 @@
"""Tests for aiohttp.pyodide.FetchConnector using a stubbed fetch().

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be really nice to delete the mocks and get coverage working in the real Pyodide tests, since I think any code path that is not exercised with the real fetch is unreliable.

Comment thread tests/test_pyodide.py
Comment on lines +213 to +214
def test_get_json(selenium_with_aiohttp: Any, echo_server_url: str) -> None:
_get_json(selenium_with_aiohttp, echo_server_url)

@hoodmane hoodmane Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline the test bodies:

Suggested change
def test_get_json(selenium_with_aiohttp: Any, echo_server_url: str) -> None:
_get_json(selenium_with_aiohttp, echo_server_url)
@run_in_pyodide
def test_get_json(selenium_with_aiohttp: Any, echo_server_url: str) -> None:
# ... actual test code here

Comment thread tests/test_pyodide.py
Comment on lines +191 to +196
try:
await session.get(base_url + "/missing", raise_for_status=True)
except aiohttp.ClientResponseError as e:
assert e.status == 404
else:
raise AssertionError("expected ClientResponseError")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use pytest.raises?

Comment thread tests/test_pyodide.py
Comment on lines +205 to +210
try:
await session.get("http://127.0.0.1:2/")
except aiohttp.ClientConnectionError:
pass
else:
raise AssertionError("expected ClientConnectionError")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use pytest.raises?

enable-cache: true
- name: Install build tooling and cython
run: |
uv pip install -U pip wheel setuptools build twine -r requirements/cython.in -c requirements/cython.txt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see where we use wheel, setuptools, build, or twine?

Suggested change
uv pip install -U pip wheel setuptools build twine -r requirements/cython.in -c requirements/cython.txt
uv pip install -U pip -r requirements/cython.in -c requirements/cython.txt

run: uv pip install cibuildwheel==3.4.1
- name: Build wheel
# See the comment in test-mobile about unsetting GITHUB_ACTIONS.
run: env -u GITHUB_ACTIONS cibuildwheel --output-dir dist

@hoodmane hoodmane Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@henryiii should cibuildwheel have some explicit way to opt out of folding?

cibuildwheel normally uses grouping in its outputs for its build/test steps. But the loading time when expanding large groups in GitHub Actions is very high. So by unsetting GITHUB_ACTIONS, cibuildwheel does not know that it is running in a GitHub Action and thus does not use groups.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided There is a change note present in this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants