Skip to content

Comments

Use AnyIO#100

Open
davidbrochart wants to merge 1 commit intovolfpeter:mainfrom
davidbrochart:anyio
Open

Use AnyIO#100
davidbrochart wants to merge 1 commit intovolfpeter:mainfrom
davidbrochart:anyio

Conversation

@davidbrochart
Copy link

@davidbrochart davidbrochart commented Dec 22, 2025

Summary by CodeRabbit

  • Dependencies

    • Migrated async runtime from asyncio to anyio (v4.12.0+); removed async-lru dependency.
  • Documentation

    • Updated all code examples to use anyio for async execution instead of asyncio.
  • Tests

    • Updated async tests to use anyio-compatible test framework markers.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 22, 2025

Walkthrough

Migration from asyncio to anyio across the codebase. Changes include replacing asyncio imports and function calls with anyio equivalents, updating async_lru dependencies to anyio.functools, swapping asyncio.gather with anyio's create_task_group, and updating test markers from pytest-asyncio to pytest-anyio.

Changes

Cohort / File(s) Summary
Documentation and README
README.md, docs/index.md, docs/components-guide.md, docs/function-components.md, docs/examples/internationalization.md, docs/examples/markdown.md
Updated code examples from asyncio.run() to anyio.run() and asyncio imports to anyio. Updated dependency documentation to reflect anyio handling async caching. Adjusted invocation patterns for anyio.run calls.
Example applications
examples/internationalization/app.py, examples/markdown_customization/app.py, examples/markdown_essentials/app.py
Replaced asyncio imports and asyncio.run() calls with anyio equivalents.
Core library — caching
htmy/i18n.py, htmy/snippet.py
Replaced async_lru (alru_cache) with anyio.functools (lru_cache) for asynchronous LRU caching.
Core library — rendering
htmy/renderer/default.py
Replaced asyncio.gather with anyio's create_task_group for concurrent task management. Added exception handler around create_task_group with early return on failure.
Test files — async markers
tests/renderer/test_default_renderer.py, tests/renderer/test_renderer_comparison.py, tests/renderer/test_renderer_in_context.py, tests/test_i18n.py, tests/test_md.py, tests/test_snippet.py
Replaced @pytest.mark.asyncio with @pytest.mark.anyio decorators.
Test files — async calls
tests/test_function_component.py, tests/test_main_components.py
Replaced asyncio.sleep() with anyio.sleep() and updated test markers from pytest-asyncio to pytest-anyio.
Configuration
pyproject.toml
Updated anyio version constraint to >=4.12.0,<5; removed async-lru dependency; removed pytest-asyncio from dev dependencies.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • htmy/renderer/default.py: Contains the most complex logic changes — replacement of asyncio.gather with create_task_group involves different error handling semantics and control flow (early return on failure vs. exception propagation). Requires careful verification of error handling and result collection behavior.
  • pyproject.toml: Dependency constraint updates and removal of pytest-asyncio require validation that the new anyio version (>=4.12.0) provides all necessary functionality (lru_cache, create_task_group).
  • Test invocation pattern changes: Several test files change how anyio.sleep() is called and how functions are passed to anyio.run() — verify correctness of argument passing patterns, especially in docs/function-components.md and example files.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Use AnyIO' directly and clearly reflects the main change across all files: replacing asyncio with anyio as the asynchronous runtime throughout the codebase.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pyproject.toml (1)

17-31: Add pytest-anyio to dev dependencies.

The tests use @pytest.mark.anyio markers (e.g., in tests/test_main_components.py, tests/test_function_component.py, etc.), but pytest-anyio is not listed in the dev dependencies. The anyio marker requires the pytest-anyio plugin to function properly.

🔎 Proposed fix
 [dependency-groups]
 dev = [
     "fastapi>=0.116.0",
     "fasthx>=3.0.0",
     "mkdocstrings[python]>=0.30.0",
     "mypy>=1.19.0,<2",
     "poethepoet>=0.38.0",
     "pytest>=9.0.2",
+    "pytest-anyio>=4.7.0",
     "pytest-random-order>=1.2.0",
     "ruff>=0.14.8,<0.15",
     "types-markdown>=3.8.0.20250809,<4",
     "typing-extensions>=4.12.2,<5",
     "types-lxml>=2025.3.30",
     "zensical>=0.0.11",
 ]
📜 Review details

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Knowledge base: Disabled due to Reviews -> Disable Knowledge Base setting

📥 Commits

Reviewing files that changed from the base of the PR and between 9894c9f and e46e3be.

📒 Files selected for processing (21)
  • README.md
  • docs/components-guide.md
  • docs/examples/internationalization.md
  • docs/examples/markdown.md
  • docs/function-components.md
  • docs/index.md
  • examples/internationalization/app.py
  • examples/markdown_customization/app.py
  • examples/markdown_essentials/app.py
  • htmy/i18n.py
  • htmy/renderer/default.py
  • htmy/snippet.py
  • pyproject.toml
  • tests/renderer/test_default_renderer.py
  • tests/renderer/test_renderer_comparison.py
  • tests/renderer/test_renderer_in_context.py
  • tests/test_function_component.py
  • tests/test_i18n.py
  • tests/test_main_components.py
  • tests/test_md.py
  • tests/test_snippet.py
🧰 Additional context used
🧬 Code graph analysis (2)
examples/internationalization/app.py (1)
htmy/renderer/default.py (2)
  • run (174-208)
  • run (229-230)
htmy/renderer/default.py (2)
tests/test_function_component.py (1)
  • renderer (101-102)
htmy/utils.py (1)
  • join (40-44)
🪛 Ruff (0.14.8)
htmy/renderer/default.py

205-205: Do not catch blind exception: Exception

(BLE001)


229-229: Missing return type annotation for private function run

Add return type annotation: None

(ANN202)

🔇 Additional comments (27)
tests/renderer/test_renderer_comparison.py (1)

105-105: LGTM! Test marker correctly updated.

The test decorator has been properly migrated from pytest.mark.asyncio to pytest.mark.anyio, consistent with the project-wide migration to AnyIO.

tests/test_i18n.py (1)

26-26: LGTM! Test markers correctly migrated.

Both test functions have been properly updated to use pytest.mark.anyio instead of pytest.mark.asyncio, aligning with the repository-wide migration to AnyIO.

Also applies to: 71-71

examples/internationalization/app.py (1)

1-1: LGTM! AnyIO migration is correct.

The migration from asyncio to anyio is properly implemented:

  • Import updated from asyncio to anyio
  • Entry point correctly changed from asyncio.run(render_hello()) to anyio.run(render_hello)

Note: The removal of parentheses when calling render_hello is intentional and correct—anyio.run() expects a coroutine function, not a coroutine object, unlike asyncio.run().

Also applies to: 45-45

examples/markdown_customization/app.py (1)

1-1: LGTM! AnyIO migration is correct.

The migration from asyncio to anyio is properly implemented, following the same correct pattern as in other example files:

  • Import updated from asyncio to anyio
  • Entry point changed from asyncio.run(render_post()) to anyio.run(render_post) (note the intentional removal of parentheses)

Also applies to: 99-99

tests/renderer/test_renderer_in_context.py (1)

9-9: LGTM! Test marker correctly updated.

The test decorator has been properly migrated from pytest.mark.asyncio to pytest.mark.anyio, consistent with the project-wide migration to AnyIO.

tests/test_snippet.py (1)

51-51: LGTM! Test markers correctly migrated.

Both test functions have been properly updated to use pytest.mark.anyio instead of pytest.mark.asyncio, aligning with the repository-wide migration to AnyIO.

Also applies to: 204-204

tests/test_md.py (1)

144-144: Test marker migration to AnyIO is correct and complete.

AnyIO provides built-in support for testing through a pytest plugin with no extra installation required. The decorators have been correctly updated from pytest.mark.asyncio to @pytest.mark.anyio across all three async test functions (lines 144, 160, 187). The project is properly configured with anyio>=4.12.0,<5 as a dependency, and no pytest-asyncio auto mode conflict exists since that plugin is not included in the project.

htmy/snippet.py (1)

8-8: The caching decorator has been successfully migrated from async_lru.alru_cache() to anyio.functools.lru_cache(). Both libraries provide equivalent async LRU cache functionality with compatible default parameters (maxsize=128). No API incompatibilities exist for this usage.

htmy/renderer/default.py (1)

3-3: LGTM!

The import change from asyncio to anyio aligns with the PR objective to migrate to AnyIO.

examples/markdown_essentials/app.py (1)

1-1: LGTM!

The migration to anyio is correct. Note that anyio.run() expects a coroutine function (without parentheses), unlike asyncio.run() which expects a coroutine object (with parentheses). The change from asyncio.run(render_post()) to anyio.run(render_post) reflects this pattern correctly.

Also applies to: 13-13

tests/test_main_components.py (1)

1-1: LGTM!

The migration from asyncio to anyio is straightforward and correct. The imports, sleep calls, and test markers have been properly updated while maintaining the existing test logic.

Also applies to: 58-58, 67-67, 145-145

tests/test_function_component.py (1)

1-1: LGTM!

The migration from asyncio to anyio is consistent across all async components and test markers. The test logic remains unchanged.

Also applies to: 28-28, 44-44, 60-60, 79-79, 90-90, 113-113

docs/function-components.md (1)

137-142: LGTM!

The documentation correctly demonstrates the anyio.run() usage pattern, where the async function and its arguments are passed separately: anyio.run(renderer.render, component) rather than anyio.run(renderer.render(component)). This is the proper anyio API usage.

Also applies to: 212-223

tests/renderer/test_default_renderer.py (1)

9-9: LGTM!

The test marker has been correctly updated from @pytest.mark.asyncio to @pytest.mark.anyio to align with the anyio migration.

pyproject.toml (1)

10-10: Verify that anyio 4.12.0 supports all features used in the codebase.

The asynchronous implementation of the functools module was added in anyio 4.12.0, so anyio.functools.lru_cache is available starting from this version. The version constraint was correctly tightened from >=4.7.0,<5 to >=4.12.0,<5 to ensure this feature is available in htmy/i18n.py and htmy/snippet.py.

htmy/i18n.py (1)

6-6: The replacement is valid — anyio 4.12.0 includes an asynchronous implementation of the functools module, and the functools module does not support coroutine functions, so AnyIO offers its own version that does. The code imports anyio.functools.lru_cache at line 6 and applies it to an async function at line 115 with no parameters, which is compatible with the standard LRU cache behavior used previously.

docs/components-guide.md (2)

14-14: Import correctly updated to anyio.

The migration from asyncio to anyio is straightforward and consistent with the PR objectives.


99-99: Correct anyio.run() syntax.

Line 99 correctly passes the async function (render) without invoking it, which aligns with anyio.run() expectations. Unlike asyncio.run() which requires a coroutine object, anyio.run() accepts the async function directly.

docs/examples/markdown.md (1)

41-56: Documentation and code examples correctly migrated to anyio.

The textual description (line 41) and code example (lines 44, 56) are properly updated. The function is correctly passed to anyio.run() without invocation.

docs/examples/internationalization.md (1)

12-12: Consistent migration from asyncio to anyio.

Import and function invocation are correctly updated. The comment on line 58 accurately reflects the change to anyio usage.

Also applies to: 58-62

docs/index.md (4)

185-208: Rendering section correctly updated with anyio.

Documentation text and code example are properly migrated. The explanation correctly guides users to use anyio.run() in synchronous contexts.


222-274: Context provider examples correctly migrated to anyio.

Multiple instances of import anyio and anyio.run() invocations are consistent and correct.


303-303: anyio.to_thread reference is accurate and helpful.

The documentation correctly references anyio's utility for delegating long-running sync operations, which is a helpful best practice for maintaining event loop responsiveness.


353-353: Dependencies section accurately reflects anyio's expanded role.

Updated text mentions anyio's support for async file operations, networking, and async caching—consistent with the removal of async-lru dependency mentioned in the PR context.

README.md (3)

185-208: Rendering section correctly updated with anyio.

Consistent with the same section in docs/index.md. Documentation and code examples properly migrated.


222-274: Context provider examples correctly migrated to anyio.

Mirrors updates in docs/index.md. All invocations follow the correct pattern for anyio.run().


303-303: Dependencies and threading guidance correctly updated.

Both the anyio.to_thread reference and the Dependencies section accurately reflect anyio's expanded role in the library, including async caching support.

Also applies to: 357-357

Comment on lines +201 to +206
try:
async with create_task_group() as tg:
for n, ctx in current_async_todos:
tg.start_soon(process_async_node, n, ctx)
except Exception:
return
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Avoid silently swallowing all exceptions with broad exception handler.

The broad except Exception: handler catches all exceptions and returns an empty string, which silently converts rendering errors into empty output. This changes the error semantics from the previous asyncio.gather implementation, which would have propagated exceptions to the caller.

This can mask critical issues like null-pointer errors, logic bugs, or resource exhaustion, making debugging significantly harder.

🔎 Recommended fix

Remove the try-except block and let exceptions propagate naturally:

             if async_todos:
                 current_async_todos = async_todos
                 self._async_todos = async_todos = deque()
 
-                try:
-                    async with create_task_group() as tg:
-                        for n, ctx in current_async_todos:
-                            tg.start_soon(process_async_node, n, ctx)
-                except Exception:
-                    return
+                async with create_task_group() as tg:
+                    for n, ctx in current_async_todos:
+                        tg.start_soon(process_async_node, n, ctx)

This allows exceptions to propagate to the caller, maintaining the expected error handling behavior.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
async with create_task_group() as tg:
for n, ctx in current_async_todos:
tg.start_soon(process_async_node, n, ctx)
except Exception:
return
async with create_task_group() as tg:
for n, ctx in current_async_todos:
tg.start_soon(process_async_node, n, ctx)
🧰 Tools
🪛 Ruff (0.14.8)

205-205: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In htmy/renderer/default.py around lines 201-206 the broad "except Exception:"
swallows all errors from the task group and returns, converting rendering
failures into silent empty output; remove the try/except so exceptions propagate
to the caller (or if you must handle specific cases, catch only those exceptions
such as asyncio.CancelledError and re-raise others), i.e. let "async with
create_task_group() as tg: for n, ctx in current_async_todos:
tg.start_soon(process_async_node, n, ctx)" run without a blanket except that
hides failures.

Comment on lines +227 to +236
items = []

async def run(renderer):
items.append(await renderer.run())

async with create_task_group() as tg:
for r in renderers:
tg.start_soon(run, r)

return "".join(items)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

head -n 250 htmy/renderer/default.py | tail -n 50

Repository: volfpeter/htmy

Length of output: 1658


🏁 Script executed:

find . -name "default.py" -path "*/renderer/*" -type f 2>/dev/null | head -5

Repository: volfpeter/htmy

Length of output: 84


🏁 Script executed:

#!/bin/bash
# Find and examine the default.py file in the htmy/renderer directory
find . -name "default.py" -path "*/renderer/*" -type f 2>/dev/null

# Look for imports related to create_task_group
if [ -f "./htmy/renderer/default.py" ]; then
    head -30 ./htmy/renderer/default.py
    echo "---"
    sed -n '220,240p' ./htmy/renderer/default.py
fi

Repository: volfpeter/htmy

Length of output: 1608


🏁 Script executed:

rg -n "create_task_group" --type py -A 10 -B 5

Repository: volfpeter/htmy

Length of output: 2680


🏁 Script executed:

#!/bin/bash
# Find create_task_group usage and related code
find . -name "*.py" -type f | head -20

Repository: volfpeter/htmy

Length of output: 623


🏁 Script executed:

# Search for the specific file and examine it
git ls-files | grep -E "renderer/default\.py"

Repository: volfpeter/htmy

Length of output: 82


🏁 Script executed:

# List files in the htmy directory to understand structure
find . -type f -name "*.py" | grep -i renderer | head -10

Repository: volfpeter/htmy

Length of output: 370


🏁 Script executed:

#!/bin/bash
cd "$(git rev-parse --show-toplevel 2>/dev/null)" || cd .

# Find the renderer module
find . -path "*/renderer/default.py" -o -path "*htmy*renderer*" | head -10

# Search for create_task_group usage
rg "create_task_group" --type py --max-count=5

# Search for the specific pattern in the review comment
rg "items.append.*renderer.run" --type py

Repository: volfpeter/htmy

Length of output: 496


🏁 Script executed:

#!/bin/bash
# Search for the actual code pattern and understand the context
rg -n "async def run.*renderer" --type py -B 5 -A 15
rg -n "items.*append" --type py | grep -C 3 "render"
rg -n "create_task_group" --type py -B 10 -A 10 | head -100

Repository: volfpeter/htmy

Length of output: 4654


🏁 Script executed:

#!/bin/bash
# Look at test files for renderer to see if ordering is tested
cat tests/renderer/test_default_renderer.py | head -100

Repository: volfpeter/htmy

Length of output: 1278


🏁 Script executed:

#!/bin/bash
# Search for tests that verify component sequence ordering
rg -n "sequence|order" tests/renderer/ --type py -B 2 -A 5 | head -50

Repository: volfpeter/htmy

Length of output: 821


🏁 Script executed:

#!/bin/bash
# Check git history for this specific code pattern
git log --oneline -n 20 -- htmy/renderer/default.py 2>/dev/null || echo "Git not available"

# Search for any TODOs or FIXMEs near the code
rg -n "TODO|FIXME|BUG" htmy/renderer/default.py

Repository: volfpeter/htmy

Length of output: 75


🏁 Script executed:

#!/bin/bash
# Check if there's a test that specifically validates ordering with different async times
rg -n "sleep|delay" tests/renderer/ --type py | head -20

Repository: volfpeter/htmy

Length of output: 40


Results may render out of order if component rendering times differ.

The current implementation appends rendered results as tasks complete, not in submission order. When components have different async execution times, the final HTML will be assembled incorrectly.

Preserve ordering by indexing results before appending:

items = [None] * len(renderers)

async def run(idx, renderer):
    items[idx] = await renderer.run()

async with create_task_group() as tg:
    for idx, r in enumerate(renderers):
        tg.start_soon(run, idx, r)

return "".join(items)
🧰 Tools
🪛 Ruff (0.14.8)

229-229: Missing return type annotation for private function run

Add return type annotation: None

(ANN202)

🤖 Prompt for AI Agents
In htmy/renderer/default.py around lines 227 to 236, rendered component outputs
are appended as tasks complete which can scramble output order; change items to
a list pre-sized to len(renderers), make the run helper accept an index and
assign the awaited result into items[idx], and start tasks with enumerate so you
preserve submission order before returning "".join(items).

@davidbrochart
Copy link
Author

@volfpeter would you be willing to switch to AnyIO instead of directly using asyncio?
While doing that, I am not sure how to handle this:

await asyncio_gather(*(process_async_node(n, ctx) for n, ctx in current_async_todos))

What is the desired behavior here?

@volfpeter
Copy link
Owner

volfpeter commented Dec 22, 2025

Thanks for your contribution.

I wouldn't mind using anyio more heavily, but I need to think about the potential consequences. I think it would be nice if the examples kept using asyncio for simplicity.

What is the desired behavior here?

I assume you mean exception handling? If an exception occurs, the rendering process should fail in my opinion. Order shouldn't matter, because it doesn't effect the linked list that's created after all async components have been processed.

This is different in the baseline renderer (which is also a streaming renderer now). In that case we must preserve component order.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants