Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12", "3.13"]
python-version: ["3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v7
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ jobs:
# newest at run time. Confirmed 2026-08-19: it silently picked up
# 3.14.7 for the v6.1.0 release run and 37 tests/micro-tests/my_profile
# (Textual TUI) tests failed with `assert task is not None` inside
# textual/rlock.py -- Textual's RLock breaks under 3.14. The full
# suite passes clean on 3.13.7 (this repo's actual dev/verification
# version), so pin to 3.13 until Textual's 3.14 compatibility is
# confirmed separately.
# textual/rlock.py. The cause was nest_asyncio (applied at pyegeria
# import time), which makes asyncio.current_task() return None on 3.14;
# ISSUE-168 removed it and python-tests.yml now covers 3.14. Releases
# stay on 3.13, this repo's dev/verification version.
run: uv python install 3.13
- name: Install dependencies
run: uv sync --all-extras --dev -p 3.13
Expand Down
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@
- `pyegeria/core/_base_platform_client.py` → `_base_server_client.py` → `_server_client.py`: layered transport/auth/GUID-resolution stack used by all OMVS clients.
- **httpx session** (`_base_platform_client.py`): `AsyncClient` is created with `keepalive_expiry=20 s`, `connect` timeout 10 s, and pool limits (`max_connections=10`, `max_keepalive_connections=5`). These settings are load-bearing for long-running sessions behind reverse proxies — do not remove them.
- **401 auto-refresh**: `_async_make_request` checks for a 401/403 before calling `raise_for_status()` and, if `token_src == "Egeria"`, calls `_async_refresh_egeria_bearer_token()` and retries once (`_retrying=True` guards against loops). Externally-supplied tokens (via `set_bearer_token`) are not auto-refreshed.
- **`__exit__` is async-aware**: uses `loop.run_until_complete(self.session.aclose())` — `aclose()` is a coroutine and must be awaited. Never revert to a bare `self.session.aclose()` call.
- **Async context rule**: `create_egeria_bearer_token()` (sync) calls `run_until_complete` internally and raises `RuntimeError` if called from a running event loop. Always use `await client._async_create_egeria_bearer_token()` inside async routes or coroutines.
- **Sync wrappers use `run_sync`** (`pyegeria/core/async_runner.py`, ISSUE-168): with no loop running it uses a per-thread persistent loop; inside a running loop it runs the coroutine on the `pyegeria-sync-runner` background thread and blocks. So sync methods work in Jupyter and async apps without `nest_asyncio` (which pyegeria no longer applies; it breaks asyncio on Python 3.14). Never use `asyncio.run` or `get_event_loop().run_until_complete` in library code; `tests/micro-tests/test_async_runner.py` enforces this.
- **The httpx session is per thread and per loop**: the `session` property opens a new `AsyncClient` when the calling thread's running loop changes, unless a session was set explicitly (pinned).
- **`__exit__` is async-aware**: uses `run_sync(self.session.aclose())` — `aclose()` is a coroutine and must be awaited. Never revert to a bare `self.session.aclose()` call.
- **Async context rule**: inside coroutines and async routes, `await client._async_*` (including `_async_create_egeria_bearer_token()` and `_async_close_session()`). The sync forms work there but block the event loop until Egeria answers.
- `pyegeria/core/config.py`: Pydantic-settings–based config; precedence = explicit args > OS env > `.env` > `config.json` > defaults.
- `pyegeria/egeria_tech_client.py`: lazy facade (`__getattr__`) across OMVS subclients; token propagation happens via `set_bearer_token()`.
- `pyegeria/egeria_client.py` (`Egeria`) / `pyegeria/egeria_cat_client.py` (`EgeriaCat`): additional role-based facades; `EgeriaCat` uses MI over `ProjectManager`, `GlossaryManager`, `AssetCatalog`, `MyProfile`.
Expand Down Expand Up @@ -72,7 +74,7 @@
- See `High-signal rules` for async-wrapper, snake_case param, and lazy-loading requirements.

## High-signal rules (verbatim)
- [HIGH SIGNAL] New OMVS operations should follow async-core + sync-wrapper pattern (`_async_*` + public sync method).
- [HIGH SIGNAL] New OMVS operations should follow async-core + sync-wrapper pattern (`_async_*` + public sync method); the wrapper calls `run_sync(self._async_*(...))`.
- [HIGH SIGNAL] Keep CLI/report params in snake_case (`page_size`, `start_from`); adapters handle wire camelCase.
- [HIGH SIGNAL] Preserve lazy-loading behavior in facade clients; do not eagerly instantiate all subclients unless clearly justified.
- [HIGH SIGNAL] For config-sensitive changes, respect precedence: explicit args > env vars > `.env` > JSON config > defaults.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ Changes to `pyegeria/` (SDK API, config, report formatting) can break both.
- **Gotcha — a request-body Pydantic model (`pyegeria/models/models.py`) missing a field silently drops it, not an error.** Every model inherits `PyegeriaModel`'s `extra='ignore'`, so a caller-supplied dict (or a raw no-body-provided construction in `_server_client.py`'s `validate_delete_*_request`-style helpers) with a field the model doesn't declare validates successfully and just vanishes before serialization — no exception, no warning. Confirmed live impact: `DeleteElementRequestBody` had neither a cascade field nor `deleteMethod` for a long time — every caller passing `cascade_delete=True` through `_async_delete_element_request` had silently been sending it as unset (PYEGERIA_ISSUES.md ISSUE-62). The same thing happens on the server side: Egeria's DTOs ignore unknown fields too, so a misspelled name is dropped there. The ISSUE-62 fix chose `cascadeDelete` from the `.http` files, but Egeria's real field is `cascadedDelete` (`DeleteOptions.java`), so cascade still never reached the server (ISSUE-132, on hold for an Egeria fix). When adding/using a request-body model, check its fields against the Egeria Java DTO (or at least `pyegeria/http-clients/Egeria-api-*.http`, knowing it can be wrong) rather than assuming "it validated without error" means the field will actually be sent and read.

**`pyegeria/omvs/`** — 40+ service-specific clients (one file per OMVS)
- Every public method has an `_async_*` implementation and a sync wrapper calling `asyncio.get_event_loop().run_until_complete(...)`.
- Every public method has an `_async_*` implementation and a sync wrapper calling `run_sync(self._async_x(...))` (`pyegeria/core/async_runner.py`). Never write `asyncio.run(...)` or `asyncio.get_event_loop().run_until_complete(...)` in a wrapper: both fail inside a running loop (Jupyter, Textual) now that pyegeria doesn't apply `nest_asyncio` (ISSUE-168), and `test_async_runner.py` fails on either in `pyegeria/` or `md_processing/` outside a `__main__` block.
- All public methods decorated with `@dynamic_catch`.
- The reference for API URLs and request bodies is `pyegeria/http-clients/Egeria-api-*.http` — check these files before constructing URLs, and confirm anything decision-critical against Egeria's Java source (see "Auditing the OMVS clients" above).

Expand Down
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ We use `uv` for dependency management. To set up your environment:
### Sync and Async
Every new OMVS method should provide both an `async` version (prefixed with `_async_`) and a synchronous wrapper. This ensures compatibility with both high-performance applications and interactive Jupyter notebooks.

The wrapper calls `run_sync` from `pyegeria.core.async_runner`:

```python
@dynamic_catch
def find_widgets(self, search_string: str = "*", ...) -> list | str:
"""Find widgets. See `_async_find_widgets`."""
return run_sync(self._async_find_widgets(search_string, ...))
```

Don't use `asyncio.run(...)` or `asyncio.get_event_loop().run_until_complete(...)`. Both fail when the caller already has a running event loop (Jupyter, Textual, FastAPI), and `tests/micro-tests/test_async_runner.py` fails if either appears in `pyegeria/` or `md_processing/`. See [Sync and async](docs/user_programming.md#sync-and-async) for how callers choose between the two forms.

### Docstrings
We use the NumPy/SciPy docstring format. Ensure every public method has clear parameter descriptions and a "Notes" section with a sample JSON body where applicable.

Expand Down
29 changes: 28 additions & 1 deletion PYEGERIA_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ content was changed. The numbered section list above describes the

# Open Issues

**At a glance** — 2 open pyegeria items, 0 awaiting re-test, 0 open Egeria Server issues (2026-10-30: ISSUE-146's `remove_note` re-test passed, ISSUE-79's folder survey completes, ISSUE-108 did not reproduce at 200 links; all three moved to the Appendix. 2026-10-29: ISSUE-159, 132 and 85 live-verified; 12 Egeria-side fixes re-confirmed or closed on earlier passes and moved to the Appendix).
**At a glance** — 3 open pyegeria items, 0 awaiting re-test, 0 open Egeria Server issues (2026-10-30: ISSUE-146's `remove_note` re-test passed, ISSUE-79's folder survey completes, ISSUE-108 did not reproduce at 200 links; all three moved to the Appendix. 2026-10-29: ISSUE-159, 132 and 85 live-verified; 12 Egeria-side fixes re-confirmed or closed on earlier passes and moved to the Appendix).

**Egeria team note, 2026-10-18 (Mandy Chessell).** Saved verbatim at
`~/.re-gate/EGERIA-TEAM-NOTE-2026-10-18.md`. Each entry below has a dated
Expand All @@ -172,6 +172,7 @@ re-test candidates moved into *Awaiting re-test* below the open sections.
create-from-template) and 7 (`viewElementGUID`) are still open.

**Open pyegeria items**
- ISSUE-168: pyegeria can't run on Python 3.14: `nest_asyncio`, applied at import time, makes `asyncio.current_task()` return None
- ISSUE-155: pyegeria gaps against Egeria `main` since 6.2 (omvs_audit + source diff, V6.2..05a5e982)
- ISSUE-150: Dr.Egeria commands for an Analytic Function's Egeria declaration: a GovernanceActionType that Resource Explorer runs as an engine host, plus a synchronous path that bypasses Egeria

Expand All @@ -192,6 +193,32 @@ into the entry now, so it isn't rediscovered from scratch later.

---

### ISSUE-168: pyegeria can't run on Python 3.14: `nest_asyncio`, applied at import time, makes `asyncio.current_task()` return None

**Layer:** pyegeria · **Status:** fix drafted on branch `fix/issue-168-py314-sync-runner` (not merged) · **Found:** 2026-10-29, when the owner asked why pyegeria caps Python at `<3.14`.

**What:** `pyproject.toml` caps `requires-python` at `<3.14` and CI tests only 3.12/3.13. The release workflow's comment blamed Textual (37 TUI tests failed with `assert task is not None` in `textual/rlock.py` when a 2026-08-19 release run picked up 3.14). Re-run 2026-10-29 on Python 3.14.5 with the cap lifted in a scratch worktree: **162 of 1661 micro-tests fail, all in the Textual apps (`tests/micro-tests/my_profile/`) and `test_my_egeria_serve.py`**; everything else passes.

**Cause:** not Textual (8.2.8 supports 3.14). pyegeria calls `nest_asyncio.apply()` when `pyegeria/view/mermaid_utilities.py` is imported (also `pyegeria/core/mcp_server.py` and three `commands/` modules). With `nest_asyncio` 1.6.0 (no release since 2024), `asyncio.current_task()` returns None inside a task on 3.14 but not on 3.13:

| | 3.13.2 | 3.14.5 |
|---|---|---|
| plain `asyncio.run`: `current_task()` set | yes | yes |
| after `nest_asyncio.apply()` | yes | **no** |

Textual's `RLock` and aiohttp's timeout ("Timeout context manager should be used inside a task") both need it. `nest_asyncio` is there because every sync wrapper (1310 call sites in 41 files) did `asyncio.get_event_loop().run_until_complete(...)`, which fails inside a running loop (Jupyter, Textual) without it. On 3.14 `get_event_loop()` also no longer creates a loop.

**Fix (drafted):**
- `pyegeria/core/async_runner.run_sync(coro)`: no running loop → this thread's persistent loop; a running loop → one shared background thread with its own persistent loop; called from inside that background loop → a one-off thread (no deadlock).
- All 1310 sync-wrapper call sites now call `run_sync(...)` (mechanical rewrite); both `make_request` sync paths too (their `run_coroutine_threadsafe(coro, <the same thread's running loop>).result()` would deadlock without `nest_asyncio`).
- The per-thread httpx session now remembers its event loop and is replaced when the thread runs a different loop (sync calls, then `asyncio.run` of an async call); a session assigned via the setter is kept.
- `nest_asyncio` removed from pyegeria, `commands/`, `my_egeria`, and the dependency list; `requires-python` is `<3.15`; CI tests 3.12/3.13/3.14; releases stay on 3.13.
- Tests: `tests/micro-tests/test_async_runner.py` (8). Full micro-tests pass on 3.13 and 3.14 (1669, 0 failed). Live read-only smoke on both versions: plain script, sync calls inside a running loop followed by a direct `await`, sync after `asyncio.run`, three threads.

**Not yet checked:** the Textual apps and the MCP server interactively, and a real Jupyter kernel. **Behaviour change for users:** notebooks that relied on pyegeria applying `nest_asyncio` to make their own `asyncio.run(...)` calls work inside Jupyter must now `await` instead (or apply `nest_asyncio` themselves, on 3.13 or older).

---

### ISSUE-155: pyegeria gaps against Egeria `main` since 6.2 (omvs_audit + source diff, V6.2..05a5e982)

**Status 2026-10-29:** items 1 (Governance Engine moves, #460), 5 (undo/restore) and 6 (usage-activity and popularity classifications) are done and released, and item 2 is fixed under ISSUE-154. Still open: item 4 (`typeName`, which only `OpenMetadataTemplateRequestBody` carries) and item 7 (`GetOptions.viewElementGUID`, low priority).
Expand Down
2 changes: 0 additions & 2 deletions commands/ops/table_integ_daemon_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import time
from typing import Union

import nest_asyncio
from rich import box
from rich.table import Table
from textual.widgets import DataTable
Expand Down Expand Up @@ -122,7 +121,6 @@ def display_integration_daemon_status(
"""

s_client = EgeriaTech(view_server, view_url, user, user_pass)
nest_asyncio.apply()

def generate_table() -> Table:
"""Make a new table."""
Expand Down
2 changes: 0 additions & 2 deletions commands/tech/list_tech_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import time
from typing import Union

import nest_asyncio
from rich import box
from rich.console import Console
from rich.prompt import Prompt
Expand Down Expand Up @@ -97,7 +96,6 @@ def display_templates_spec(
data_table: bool = False,
) -> Table | DataTable:
a_client = AutomatedCuration(server, url, username)
nest_asyncio.apply()
token = a_client.create_egeria_bearer_token(username, password)
tech_list = a_client.find_technology_types(search_string, page_size=0)

Expand Down
8 changes: 4 additions & 4 deletions commands/tech/spec_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,10 +371,10 @@ def main(port: int, compact_dir: str | None) -> None:
_state["compact_dir"] = Path(compact_dir).resolve()
click.echo(f"Serving compact_commands from: {_state['compact_dir']}")
click.echo(f"Open http://localhost:{port} in your browser")
# Some pyegeria imports apply nest_asyncio, which patches asyncio.run() with a
# signature that doesn't accept uvicorn.run()'s internal loop_factory kwarg.
# Building the Server ourselves and calling plain asyncio.run(server.serve())
# avoids that incompatible call path.
# pyegeria imports used to apply nest_asyncio (removed in ISSUE-168), which
# patched asyncio.run() with a signature that doesn't accept uvicorn.run()'s
# internal loop_factory kwarg. Building the Server ourselves and calling plain
# asyncio.run(server.serve()) works either way.
config = uvicorn.Config(app, host="127.0.0.1", port=port)
server = uvicorn.Server(config)
asyncio.run(server.serve())
Expand Down
2 changes: 0 additions & 2 deletions commands/tech/table_tech_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from datetime import datetime
from typing import Union

import nest_asyncio
from rich import box
from rich.console import Console
from rich.markdown import Markdown
Expand Down Expand Up @@ -80,7 +79,6 @@ def display_templates_spec(
placeholders: bool = False,
) -> Table | DataTable:
a_client = AutomatedCuration(server, url, username)
nest_asyncio.apply()
token = a_client.create_egeria_bearer_token(username, password)
tech_list = a_client.find_technology_types(search_string, page_size=0)

Expand Down
4 changes: 3 additions & 1 deletion docs/developer_guide_pyegeria_textual.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,9 @@ Every public method has two forms. `find_glossaries(...)` is synchronous and sim
scripts. `_async_find_glossaries(...)` is the `async` implementation it wraps. Use the async
form when you are already inside an event loop, as Textual apps and Dr. Egeria are, and want to
`await` the call. The My Profile app does this when it loads the profile
(`await self.my_profile_inst._async_get_my_profile(...)`).
(`await self.my_profile_inst._async_get_my_profile(...)`). A sync call from a Textual handler
still works, but the screen stops responding until Egeria answers. See
[Sync and async](user_programming.md#sync-and-async) for more examples.

### Step 1: a first program

Expand Down
4 changes: 2 additions & 2 deletions docs/dr_egeria_manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,7 @@ process_md_file(
)
```

This is the simplest entry point. It creates the `EgeriaTech` client internally from the connection parameters you supply, then runs the async v2 engine synchronously via `asyncio.run()`.
This is the simplest entry point. It creates the `EgeriaTech` client internally from the connection parameters you supply, then runs the async v2 engine and waits for it to finish. It works in a script and in a Jupyter cell.

**Example — validate a file (reading connection details from config):**

Expand Down Expand Up @@ -701,7 +701,7 @@ await process_md_file_v2(
)
```

> **Note**: Jupyter kernels run their own event loop, so `asyncio.run()` will raise a `RuntimeError` inside a notebook cell. Use `await` directly (as shown above) or install `nest_asyncio` and call `nest_asyncio.apply()` once at the top of your notebook if you need to keep `asyncio.run()` calls.
> **Note**: Jupyter kernels run their own event loop, so `asyncio.run()` raises a `RuntimeError` inside a notebook cell. Use `await` directly (as shown above), or call the sync `process_md_file(...)`, which works in a cell. Don't add `nest_asyncio` for this: it breaks asyncio on Python 3.14, and pyegeria no longer needs it. See [Sync and async](user_programming.md#sync-and-async) in the programming guide.

---

Expand Down
Loading
Loading