Skip to content

Run sync wrappers without nest_asyncio so pyegeria works on Python 3.14 (ISSUE-168) - #513

Merged
dwolfson merged 3 commits into
odpi:mainfrom
dwolfson:fix/issue-168-py314-sync-runner
Oct 30, 2026
Merged

dwolfson merged 3 commits into
odpi:mainfrom
dwolfson:fix/issue-168-py314-sync-runner

Conversation

@dwolfson

@dwolfson dwolfson commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Release: ship this as its own pyegeria version, with a release note that code relying on pyegeria applying nest_asyncio must change. The downstream fixes (see Downstream below) land with each repo's floor bump to that version.

Why

requires-python was capped at <3.14, and the release workflow blamed Textual. The real cause: pyegeria applies nest_asyncio on import (pyegeria/view/mermaid_utilities.py), and on Python 3.14 nest_asyncio 1.6.0 (no release since 2024) makes asyncio.current_task() return None. That breaks Textual's RLock and aiohttp's timeouts: 162 of 1661 micro-tests fail on 3.14, all in the Textual apps and serve_my_egeria. nest_asyncio was there so the sync wrappers' get_event_loop().run_until_complete(...) worked inside a running loop.

What

  • pyegeria/core/async_runner.run_sync(coro):
    • no loop running: this thread's persistent loop;
    • a loop running (Jupyter, Textual, async callers): a shared background loop thread;
    • already on that background loop: a one-off thread, so nested calls can't deadlock.
  • All 1310 sync-wrapper call sites in 41 files call run_sync (a mechanical rewrite). So do both make_request sync paths, which deadlocked inside a running loop without nest_asyncio.
  • Second commit: the rewrite missed 15 ActorManager wrappers that used asyncio.run() (contribution records, contact details, perspectives, skills). They would have failed in Jupyter once nest_asyncio was gone; they now use run_sync. So does the library process_md_file, which now works in a Jupyter cell. A new test fails if asyncio.run/get_event_loop/new_event_loop appears in pyegeria/ or md_processing/ outside a __main__ block.
  • Third commit: run_sync's nested path (a sync call from a coroutine already on the background loop) ran on a new thread with an empty context, so a surrounding lineage_visible() was silently dropped and the request went without forLineage=true. That thread now runs in a copy of the caller's context. The other two paths already kept it, and tests now cover all three.
  • BaseServerClient.__exit__ now awaits session.aclose(); it never did, on main either. EgeriaTech gains _async_close_session() so async code can close sessions on its own loop.
  • The per-thread httpx session is replaced when the thread runs a different event loop; a session assigned via the setter is kept.
  • nest_asyncio removed from pyegeria, commands/, my_egeria and the dependencies. requires-python is now <3.15. CI tests 3.12/3.13/3.14; releases stay on 3.13.
  • ISSUE-168 logged in PYEGERIA_ISSUES.md.

Docs

docs/user_programming.md has a new Sync and async section, replacing the old "Async API" section and the outdated advice that the sync token call fails in coroutines. It covers:

  • which form to use where: scripts, Jupyter, async apps, asyncio.gather, worker threads;
  • an example for each;
  • what happens underneath, and how to close a client from each form.

All the examples were run against a live quickstart on 3.13 and 3.14. Measured there:

  • gather over three queries took about 4 s, against about 7.5 s one after another;
  • one sync call made inside a coroutine stalled that event loop for about 3 s.

The old doc example _async_find_in_asset_domain("*") is a 400 on the server (a null search string), so the new one uses a real search string. The Dr.Egeria manual, CONTRIBUTING (with a wrapper example), AGENTS, CLAUDE.md and the package READMEs now describe run_sync instead of run_until_complete and nest_asyncio.

Tested

  • Micro-tests: 3.13 and 3.14, 1672 tests, 0 failed (1 skipped), including 11 new runner, session, context and guard tests.

  • Live, read-only, 3.13 and 3.14:

    • a real Jupyter kernel: sync, await, make_request, and notebooks that apply nest_asyncio themselves. On released 6.2.15, make_request inside Jupyter hangs; here it returns 200;
    • My Egeria TUI: login and browsers, via Pilot;
    • serve_my_egeria: a websocket session;
    • the MCP server over stdio: list_reports and run_report twice;
    • plain scripts, threads, and sync calls after asyncio.run;
    • the sync process_md_file (validate) inside a 3.14 Jupyter kernel.
  • Downstream fixes, live, read-only (egeria-workspaces-fs-44, 2026-10-30, Python 3.13 venv on dae2ffe, portal untouched): each break was reproduced with the old code, and each fix passed with the new:

    • data_design_handler, as a sync FastAPI route: the old code raises "no current event loop"; make_request returns 6 DataValueSpecs, twice.
    • dr_egeria_run_block: the old code fails quietly with "asyncio.run() cannot be called from a running event loop"; awaiting the async function validates, and no write was made.
    • lineage notebook cell 7, in a real kernel: the old code raises "This event loop is already running"; make_request works. Sync, then await, then sync on one client also works.

    No unclosed-client, ResourceWarning or event-loop warnings appeared.

  • trellis Resource Explorer suite against dae2ffe (coordinator, 2026-10-30; SQLite, -n 4, no Egeria calls): 4 failed of 13,628, against 1 known flake on the 6.2.15 baseline. None of them needs a pyegeria change:

    • test_catalogue_issue117_block times asyncio.run() around a to_thread read. The real asyncio.run waits for the default executor on exit; nest_asyncio's did not. Only the test changes.
    • The other three are egeria_publisher._resolve_template_guid (in the Downstream list). After any earlier asyncio.run on the same thread, get_event_loop() raises "There is no current event loop", and the publisher catches that as "Could not catalog sub-resource". Reproduced deterministically by adding one asyncio.run before those tests. The same quiet failure would happen in production on such a thread.

Behaviour change

Code that relied on pyegeria applying nest_asyncio for it changes:

  • Its own asyncio.get_event_loop().run_until_complete(...) in a worker thread now raises "There is no current event loop".
  • Its own asyncio.run(...) inside a running loop now fails.
  • Use pyegeria's sync methods (including make_request) or await instead.

Downstream (to change together with the floor bump to the release carrying this)

  • egeria-workspaces (fs-44's static check of upstream/main 8c4efacd, 2026-10-30). Three sites break and must ship with the floor bump:

    • PyegeriaWebHandler/data_design_handler.py::_search_data_value_specs (quickstart and freshstart) breaks the Data Grains/Classes lists. Change it to mgr.make_request(...).
    • mcp_server.py::dr_egeria_run_block (quickstart and freshstart). The async MCP tool calls the sync dr_egeria_md.process_markdown_file[_structured], which uses asyncio.run (dr_egeria_md.py:192/204). Without nest_asyncio that fails inside the running loop, and the wrapper swallows the error, so the Dr.Egeria MCP tool fails quietly. Fix: await the process_md_file*_async functions directly.
    • Notebook coco-workbooks/1. coco-data-hub/mapping-the-systems/supply-chain-lineage.ipynb, cell 7, runs asyncio.get_event_loop().run_until_complete(egeria_client._async_make_request(...)) in the kernel and relied on pyegeria's nest_asyncio. It breaks on the 3.13 container too. Fix: egeria_client.make_request("POST", url, body).

    Checked and fine: the other asyncio.run callers, which run on sync routes or executor threads; the create_task/run_in_executor uses; and nest_asyncio, which is declared directly in requirements, pyproject, the Dockerfile and the conda env, so there is no transitive ImportError.

  • trellis / Resource Explorer (coordinator's static sweep of trellis main e7cda8ea, 2026-10-30). Four sites run pyegeria coroutines with get_event_loop().run_until_complete. They break on a thread with no loop, or under a running loop:

    • catalogue_gateway.py:1304 (_initiate_action_type)
    • surveyors/database/egeria_database_surveyor.py:1000 (_async_initiate_survey)
    • surveyors/egeria_publisher.py:687 (_async_initiate_survey)
    • surveyors/egeria_publisher.py:2119 (_async_get_template_guid_for_technology_type)

    The _resolve_template_guid fix is the sync get_template_guid_for_technology_type. Its tests' _publisher fake would then fake the sync method instead of the async one. Also, anything that times asyncio.run() around to_thread work now includes the executor shutdown.
    The loop-setting wrappers that exist only to feed them can go too: resource_scope.py:209-225, web/routes/databases.py:1029, egeria_resync.py:1392. The fix is the sync pyegeria call, or await. trellis-analytics is unaffected, and RE itself dropped nest-asyncio on 2026-09-04.

  • trellis / egeria-advisor: conversation_agent.py:435/444/458 call bare asyncio.run, which raises if reached from a running loop once pyegeria stops applying nest_asyncio. It declares nest-asyncio but never applies it.

  • Other sample notebooks: no change needed on the Python 3.13 container (apart from the lineage notebook above). For a 3.14 kernel, drop the nest_asyncio.apply() calls in the two initialize-pyegeria.ipynb copies and in unity_catalog_viewer.ipynb.

🤖 Generated with Claude Code

https://claude.ai/code/session_015pdMuQanRknaXmcpx4LmLb

…14 (ISSUE-168)

pyegeria applied nest_asyncio at import time so that the sync wrappers'
get_event_loop().run_until_complete(...) worked inside a running loop
(Jupyter, Textual). On Python 3.14 nest_asyncio makes
asyncio.current_task() return None, which broke Textual's RLock and
aiohttp's timeout (162 micro-test failures), and get_event_loop() no
longer creates a loop.

- pyegeria/core/async_runner.run_sync: this thread's persistent loop when
  no loop is running; a shared background loop thread when one is; a
  one-off thread when already on that background loop.
- All 1310 sync-wrapper call sites in 41 files use run_sync (mechanical);
  both make_request sync paths too.
- The per-thread httpx session is replaced when the thread runs a
  different event loop; a session assigned via the setter is kept.
- nest_asyncio removed from pyegeria, commands, my_egeria and the
  dependencies; requires-python <3.15; CI tests 3.12/3.13/3.14; release
  stays on 3.13 with a corrected comment.
- Tests: test_async_runner.py. Micro-tests pass on 3.13 and 3.14
  (1669, 0 failed); live read-only smoke on both.

Claude-Session: https://claude.ai/code/session_015pdMuQanRknaXmcpx4LmLb
Signed-off-by: Dan Wolfson <dan.wolfson@pdr-associates.com>
@dwolfson
dwolfson force-pushed the fix/issue-168-py314-sync-runner branch from 3d77bdf to bc41cea Compare October 30, 2026 12:38
Docs: docs/user_programming.md gets a "Sync and async" section with
when-to-use guidance and examples (script, Jupyter, async app,
asyncio.gather, worker threads), all run against a live quickstart on
Python 3.13 and 3.14. The Dr.Egeria manual, CONTRIBUTING, AGENTS,
CLAUDE.md and the package READMEs now describe run_sync instead of
get_event_loop().run_until_complete and nest_asyncio.

Fixes found while writing them:
- 15 ActorManager sync wrappers (contribution records, contact details,
  perspectives, skills) used asyncio.run(), which fails in a running loop
  now that nest_asyncio is gone; they use run_sync.
- md_processing.dr_egeria.process_md_file used asyncio.run(); it uses
  run_sync, so it works in a Jupyter cell.
- BaseServerClient.__exit__ called session.aclose() without awaiting it
  (also on main); it uses run_sync.
- EgeriaTech gains _async_close_session() so async code can close the
  sessions on its own loop.
- A test fails if asyncio.run/get_event_loop/new_event_loop appears in
  pyegeria/ or md_processing/ outside a __main__ block.

Claude-Session: https://claude.ai/code/session_015pdMuQanRknaXmcpx4LmLb
Signed-off-by: Dan Wolfson <dan.wolfson@pdr-associates.com>
@dwolfson
dwolfson marked this pull request as ready for review October 30, 2026 14:07
A sync call made from a coroutine already running on the background
loop runs on a one-off thread, which starts with an empty context, so a
surrounding lineage_visible() block was silently dropped there and the
request went without forLineage=true. The thread now runs inside a copy
of the caller's context. The other two paths already kept it
(run_until_complete and call_soon_threadsafe copy the context); tests
now cover all three.

Claude-Session: https://claude.ai/code/session_015pdMuQanRknaXmcpx4LmLb
Signed-off-by: Dan Wolfson <dan.wolfson@pdr-associates.com>
@dwolfson
dwolfson merged commit 5b98ef3 into odpi:main Oct 30, 2026
6 checks passed
@dwolfson
dwolfson deleted the fix/issue-168-py314-sync-runner branch October 30, 2026 15:58
dwolfson added a commit that referenced this pull request Oct 31, 2026
ISSUE-168 was released in 6.2.16 (#513) but still read "not merged";
ISSUE-169..172 are merged (#516..#519) and ship in 6.2.17. Their
statuses now say so, the five entries move to Fixed / Resolved, and the
open-items index and at-a-glance count (now 2: ISSUE-155, 150) match.
No entry's content changed beyond its status line.

Claude-Session: https://claude.ai/code/session_015pdMuPkqttD1yKAKJLS9Fc

Signed-off-by: Dan Wolfson <dan.wolfson@pdr-associates.com>
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.

1 participant