diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5bb8c890..755877ee 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,9 +18,9 @@ jobs: contents: read security-events: write # Required to upload results to the Security tab. steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: github/codeql-action/init@6f5948dfacef28e207b48d0905cf90c03365536d # v3 + - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: python # Scoped to real package source -- excludes vendored/generated @@ -35,6 +35,6 @@ jobs: **/data/** **/*.json - - uses: github/codeql-action/analyze@6f5948dfacef28e207b48d0905cf90c03365536d # v3 + - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: "/language:python" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de2d7274..dfb778f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,9 +12,9 @@ jobs: permissions: contents: read # Only checks out and reads the repo -- no write access needed. steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python # Pinned explicitly: a bare `uv python install` grabs whatever's # newest at run time. Confirmed 2026-08-19: it silently picked up @@ -56,12 +56,12 @@ jobs: id-token: write # Required for OIDC trusted publishing contents: write # Required to create GitHub Release steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Build package run: uv build @@ -77,7 +77,7 @@ jobs: # write above) -- no extra input needed for Signed-Releases. - name: Create GitHub Release - uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: files: dist/* generate_release_notes: true diff --git a/PYEGERIA_ISSUES.md b/PYEGERIA_ISSUES.md index c0d341c8..a79e2a85 100644 --- a/PYEGERIA_ISSUES.md +++ b/PYEGERIA_ISSUES.md @@ -143,6 +143,106 @@ enough to track there too). --- +### ISSUE-89: No configurable bearer-token lifetime in `application.properties` — platform tokens are fixed at 3600 s, and the signing key is random per restart + +**Layer:** Egeria Server · **Status:** open · **Found:** 2026-09-04 (trellis-auth token contract) + +With `authentication.mode=token`, every token minted by the platform decodes to `exp - iat = 3600` +(observed on `omag-server-platform-6.2-SNAPSHOT`, quickstart, `authentication.source=platform`). +`application.properties` exposes `authentication.mode`, `authentication.source` and `rsa.key-id`, and +nothing that controls the lifetime; with `rsa.key-id` empty the signing key is regenerated at start, so +every token also dies on a platform restart. Client applications that hold a platform token on the +user's behalf (Portal, Egeria Advisor, Resource Explorer — which now carry the Egeria bearer token in +their session and never the password) cannot offer a session longer than an hour without either +re-prompting the user or caching a password, which is what a bearer token exists to avoid. + +**Ask:** a property such as `authentication.token.lifetime=3600` (seconds, default unchanged), read at +start and applied when a token is issued; optionally a refresh operation that returns a new token for a +valid unexpired one. Full draft issue text: trellis session scratch `egeria-issue-token-lifetime.md`. + +### ISSUE-90: `qs-engine-host` retries `startMissedEngineActions` forever when one incomplete engine action's anchor is unreadable by the engine-host user + +**Layer:** Egeria Server (possibly quickstart content) · **Status:** open · **Found:** 2026-09-04 (trevor fresh quickstart) + +On a fresh quickstart repository (6.2-SNAPSHOT builds of 2026-08-31 and 2026-09-04, all standard +content packs, stock `coco-user-directory.omsecrets`) the engine host logs, every few seconds from first +start and without end: + +``` +qs-engine-host Error ENGINE-HOST-SERVICES-2002 startMissedEngineActions caught an exception +UserNotAuthorizedException while restarting incomplete engine actions; the error message was +OMAG-SERVER-SECURITY-403-007 User generalnpa is not authorized to issue operation Read on +a0baa4da-5f85-446c-b655-88ae81455966 anchor element DigitalProductFamily +``` + +Always the same element GUID; 196 refusals in ten minutes, >2,000 in ninety. A second quickstart on +another machine, with the identical platform build, identical server configuration documents (only the +generated `localServerId` differs), identical `coco-user-directory.omsecrets`, `DEMO_MODE=false` on both, +and its repository also reloaded the same day, shows zero refusals in 24 h. The one thing that differs is +history: the affected platform was stopped and restarted several times during its first hour (a +configuration copy, a Kafka log-directory reset, a rebuild) while content packs and the nanny daemon's +integration groups were still initialising; the healthy one was loaded once and left alone. The refused +element cannot be read through the view server by `garygeeke`, `peterprofile` or `erinoverview` either +(401), so it is not merely a `generalnpa` permission gap — the anchor looks unreadable by everyone, as +if left behind by an interrupted initialisation. + +**What it means:** an `EngineAction` exists whose anchor is a `DigitalProductFamily`; the engine host's own +identity (`generalnpa`) may not read that anchor under the security connector; the "restart incomplete +engine actions" pass treats the authorization failure as transient, aborts on it, and retries at its +normal cadence, so one permanently unreadable action becomes a permanent loop. Cost: platform container +at 200-330 % CPU and its Postgres at 500-750 % on an idle box, from `EngineAction` entity searches and +classification reads; everything else on the platform is slower. + +**Ask (either helps):** (1) engine-host-services: treat `UserNotAuthorizedException` for a specific engine +action as terminal for that action — log once, skip it, continue with the rest, do not retry it until the +security context changes; (2) quickstart content: give `generalnpa` read access to the digital-product +elements its engine actions anchor to, or anchor those actions to elements the engine-host identity can +read. Full draft: trellis session scratch `egeria-issue-engine-host-403-loop.md`. + +### ISSUE-91: `pyegeria.core.mcp_server` imports `mcp.server.mcpserver` (mcp 2.x only) but `pyproject.toml` declares `mcp >=0.1` — any consumer that resolves mcp 1.x gets a server that dies at import + +**Layer:** pyegeria packaging · **Status:** open · **Found:** 2026-09-05 (Egeria Advisor dev startup on the M3 Max) + +Commit 2b39ba06 (2026-07-30, "migrate mcp_server.py to mcp 2.0.0's MCPServer") changed the +server's import to: + +```python +from mcp.server.mcpserver import MCPServer +``` + +That module exists only in `mcp >= 2.0.0` (1.x ships `mcp.server.fastmcp` instead). The +dependency declaration was not updated and still reads `"mcp >=0.1"` — verified in the released +6.1.5 wheel's METADATA (`Requires-Dist: mcp>=0.1`) and in the current `pyproject.toml` at 6.1.10. + +**How it shows up.** trellis pinned `mcp>=1.0.0` and its lock resolved mcp 1.29.0, which satisfies +pyegeria's declared range. Launching the server then fails before it can speak MCP: + +``` +$ python -m pyegeria.core.mcp_server +MCP import failed. + File ".../pyegeria/core/mcp_server.py", line 24, in + from mcp.server.mcpserver import MCPServer +ModuleNotFoundError: No module named 'mcp.server.mcpserver' +``` + +A client sees only "No response from MCP server" because the traceback goes to stderr and the +process exits without writing a JSON-RPC frame. With mcp 2.1.1 installed the same command answers +`initialize` normally (`serverInfo.name = "pyegeria-mcp"`). + +**Why it went unnoticed.** The quickstart containers run mcp 2.1.1 (pyegeria 6.1.9) and use their own +`/app/mcp_server.py` mounted over SSE in `pyegeria_handler`, not this stdio module, so the +egeria-workspaces path never exercised it. The egeria-python checkout's own venv also has mcp 2.0.0. + +**Proposed fix (backward compatible for callers).** In `pyproject.toml` declare `"mcp >=2.0"`. Nothing +else needs to change: the import is already 2.x-only, so raising the floor only turns a runtime +import crash into a resolver error at install time. If 1.x support is wanted instead, gate the +import (`try: from mcp.server.mcpserver import MCPServer except ImportError: from mcp.server.fastmcp +import FastMCP as MCPServer`) — but the 2.x API differs beyond the class name, so the floor bump is +the honest option. + +**Consumer-side workaround applied in trellis (8441efb):** both packages now declare `mcp>=2.0.0` +and the lock carries mcp 2.1.1, matching the quickstart containers. + ### ISSUE-38 (PY-18): `count_relationships_between_elements("Exception")` (276) disagrees with `ClassificationExplorer.get_relationships("Exception")` (55) **Update 2026-08-30, from the Egeria team (Mandy Chessell).** Leaving this @@ -695,6 +795,139 @@ on an Egeria Server capability that doesn't exist yet — but the pyegeria/ Dr.Egeria-side work each will need once that capability ships is written into the entry now, so it isn't rediscovered from scratch later. +### ISSUE-91: `pyproject.toml` declares `mcp >=0.1`, but `pyegeria.core.mcp_server` needs `mcp>=2.0` — the declared floor lets a resolver install a version too old to import the module at all + +**Layer:** Pyegeria · **Status:** open · **Found:** 2026-09-06 (Egeria Advisor, containerized demo deployment rebuild against pyegeria 6.1.10). + +`pyproject.toml`'s `[project.dependencies]` declares `"mcp >=0.1"`, but +`pyegeria/core/mcp_server.py` imports `from mcp.server.mcpserver import +MCPServer` — confirmed live in this checkout's dev venv: +`importlib.metadata.version("mcp")` is `2.0.0`, and +`mcp.server.mcpserver.MCPServer` only exists at that version; the module +path is new to the 2.x line, not present in the 0.x/1.x `mcp` package +history. A resolver that's free to pick anything satisfying `>=0.1` (no +upper or tighter lower bound forcing 2.x) can legitimately land on a much +older `mcp` release, at which point `import pyegeria.core.mcp_server` +fails outright rather than degrading gracefully. + +**Why it matters, found how:** a container rebuild of Egeria Advisor's +demo deployment against pyegeria 6.1.10 pinned `mcp==2.1.1` explicitly +(not resolved from pyegeria's own floor) and confirmed EA's MCP agent +pre-warms cleanly on it — so 6.1.10 itself works fine when the caller +pins a modern `mcp`, but nothing in pyegeria's own declared dependency +would have caught a caller who didn't. + +**Ask:** tighten `pyproject.toml`'s `mcp` constraint to actually match +what `mcp_server.py` requires (`mcp>=2.0` at minimum — confirm the exact +version `MCPServer`/`mcp.server.mcpserver` was introduced at, and pin to +that) rather than the inherited placeholder `>=0.1` floor. Not +independently verified against the `mcp` package's own changelog/git +history here — the live import check above shows 2.0.0 works and the +module path is absent from the 0.x/1.x line by inspection, but the exact +first-working version wasn't pinned down. + +### ISSUE-87: `ClassificationExplorer.add_ownership_to_element`'s docstring sample body says `"class": "OwnerProperties"` — the method itself only accepts `"OwnershipProperties"`, so the documented body cannot be sent + +**Layer:** Pyegeria · **Status:** fixed 2026-09-05 (Pyegeria — +`pyegeria/omvs/classification_explorer.py`). Both docstrings (async +`_async_add_ownership_to_element` and its sync wrapper +`add_ownership_to_element`) corrected to `"OwnershipProperties"`, matching +the code's own `prop=["OwnershipProperties"]` and the `.http` ground +truth. Verified: `TypeAdapter(NewClassificationRequestBody).validate_python(...)` +with the corrected body now validates cleanly (previously raised +`ValidationError`/`unexpected property class name` from the class-name +mismatch). Swept the rest of the file per the original report's suggestion +(every `_async_new_classification_request` caller, matched by function +boundary rather than naive line-proximity to avoid false positives from +adjacent functions) — this was the only class-name/`prop=` mismatch in +`classification_explorer.py`; not repeated elsewhere. Found 2026-09-04 +(Resource Explorer, `Ownership` on everything it publishes). + +`classification_explorer.py:8231` `_async_add_ownership_to_element` documents: + +``` +{ + "class" : "NewClassificationRequestBody", + "properties" : { + "class" : "OwnerProperties", + "owner" : "Add value here", + ... +``` + +and then calls `self._async_new_classification_request(url, prop=["OwnershipProperties"], body=body)` +(`:8285`). `_server_client.py:6175` `validate_new_classification_request` compares +`body["properties"]["class"]` against `prop` and raises +`PyegeriaInvalidParameterException(reason="unexpected property class name")` +when it does not match — **before any HTTP request is made**. So the body in the +docstring is rejected client-side, 100% of the time. + +`pyegeria/http clients/Egeria-api-classification-explorer.http:670` uses +`"OwnershipProperties"` and agrees with the code. The docstring is the outlier; +the same sample appears again in the sync wrapper at `:8288`. + +**How it was found:** by running the documented body against the live +quickstart platform. The exception names neither the class it expected nor the +class it got — `unexpected property class name` with an empty context — so +reading the error does not lead to the answer; only reading +`validate_new_classification_request` does. + +**Ask:** correct both docstrings to `"OwnershipProperties"`. Worth a sweep of +the other `_async_new_classification_request` callers in the same file for the +same docstring/`prop=` divergence — the two were written from different sources +here, so this is unlikely to be the only one. Separately, the validation error +would be far more useful if it named the expected and supplied class names; +`{"reason": "unexpected property class name"}` is a hard error to act on. + +### ISSUE-86: `exec_report_spec` (and `_exec_analytic_chart`, `_run_report_spec`) accept only `user`/`user_pass` — no way to run a report with a bearer token the caller already holds + +**Layer:** Pyegeria · **Status:** fixed 2026-09-05 (Pyegeria — +`pyegeria/view/format_set_executor.py`, `pyegeria/core/mcp_adapter.py`). +Added `token: Optional[str] = None` to `exec_report_spec`, +`_exec_analytic_chart`, and `_run_analytic_function` (the function this +entry's title calls `_run_report_spec` — no such name exists in the +codebase; `_run_analytic_function` is the one all three client-building +call sites actually route through), threaded through every place a client +is built from `view_server`/`view_url`/`user`/`user_pass`. When `token` is +given, the client is authenticated with `set_bearer_token(token)` instead +of `create_egeria_bearer_token()`; when omitted, behavior is byte-for-byte +unchanged (`user`/`user_pass` still mint a fresh token exactly as before). +Also threaded through `run_report`/`_execute_egeria_call_blocking` in +`pyegeria/core/mcp_adapter.py`, per the original ask's "same for +run_report/... on the MCP side if they share the builder" — both do, +both now accept `token`. `describe_report` doesn't build a client at all +(pure registry lookup), so it needed no change. The MCP server's own +`run_report` tool (`pyegeria/core/mcp_server.py`) takes a different, +already-token-capable path (`_async_run_report_tool` + a pre-built +`egeria_client`, not `exec_report_spec`'s builder) — out of scope per the +same "if they share the builder" qualifier, not touched. + +New unit test `tests/micro-tests/test_exec_report_spec_token.py` (2 tests, +monkeypatched `EgeriaTech`, no live server): confirms `token` routes +through `set_bearer_token` with zero calls to `create_egeria_bearer_token`, +and confirms the no-token path is unchanged. Full `pytest +tests/micro-tests/` green (exit 0) throughout. + +**Original report, found 2026-09-04** (Egeria Advisor, trellis-auth token +contract change): + +`pyegeria/view/format_set_executor.py:686` `exec_report_spec(..., user=, user_pass=)` builds its own +client (`:189` and `:814`: `client_class(view_server, view_url, user_id=user, user_pwd=user_pass)` +then `client.create_egeria_bearer_token()`) and passes `user`/`user_pass` down to `_exec_analytic_chart` +(`:718`) and the format-row path (`:780`). There is no parameter for a pre-built client or a bearer +token, and `create_egeria_bearer_token()` is unconditional. + +**Why it matters:** Egeria Advisor's app JWT carries the user's Egeria bearer token and no longer +carries the password (matching the Portal, whose `X-Egeria-Token` + `set_bearer_token()` pattern +already does this for every PyegeriaWebHandler endpoint). Every other EA call site builds a client +and calls `set_bearer_token(token)`; report execution cannot, so EA falls back to the service +account for that one path and Egeria's provenance records the service account instead of the +person who ran the report (`advisor/report_pipeline.py`, search "ISSUE-86"). + +**Ask:** add `token: str | None = None` (or `client: EgeriaTech | None = None`) to `exec_report_spec` +and thread it to the two builders; when given, `set_bearer_token(token)` instead of +`create_egeria_bearer_token()`. Same for `run_report`/`describe_report` on the MCP side if they share +the builder. Backward compatible: `user`/`user_pass` keep working when no token is given. + ### ISSUE-84: `SolutionArchitect.create_solution_blueprint`'s own docstring documents a `NewSolutionElementRequestBody` body (with `initialStatus`) for Draft-status creation — that class does not exist as a pydantic model, so the documented shape fails client-side validation before any HTTP call **Status:** fixed and live-verified 2026-09-03 — docstring-only fix (see @@ -861,6 +1094,13 @@ ACTIVE-only workaround. `ComponentMaterializer`'s identical gap is **still open** — same fix applies, not yet applied there, tracked separately on the trellis/Resource Explorer side. +**GUID note (2026-09-04):** `quickstart-egeria-main` was redeployed with a full +repository-store wipe the same day this was verified. `809025b5-cca9-4e9a-a2f7-3a5104138f67` +is historical — valid as of the 2026-09-03 verification date, not resolvable against the +platform after the wipe. The finding (`contentStatus` round-trips via `properties`, no +separate request-body class needed) is a property of the API/fix, not of that instance, and +is unaffected. + ### ISSUE-82: `pyegeria/omvs/valid_metadata.py` sends the literal query string `typeName=None` whenever `type_name` is Python `None` — breaks every Type-Name-omitted (global) Valid Metadata Value, in 12 of 14 methods across `ValidMetadataManager` **Status:** fixed and live-verified 2026-08-28 — all 12 affected methods @@ -1134,6 +1374,21 @@ now for data that may not exist anywhere. ### ISSUE-80: `find_report_specs_by_perspective`/`find_report_specs_by_question` are implemented and tested but not exposed anywhere +**Status:** fixed 2026-09-05 (Pyegeria — `pyegeria/core/mcp_adapter.py`, +`pyegeria/core/mcp_server.py`). Added `run_find_report_specs_by_perspective`/ +`run_find_report_specs_by_question` adapters (same thin-wrapper pattern as +the existing `run_find_report_specs`) and registered them as MCP tools +`find_report_specs_by_perspective`/`find_report_specs_by_question`, +alongside the existing `find_report_specs` tool. No change to the +underlying functions themselves — pure additive wiring, per the candidate +fix below. `hey_egeria` CLI exposure (the "and/or" half of the original +ask) left undone — MCP was the natural fit given the existing +`find_report_specs` tool precedent; a CLI subcommand is separate follow-up +work if wanted. Full `pytest tests/micro-tests/` green (exit 0); the +underlying functions already have their own functional-test coverage +(`tests/functional-tests/test_question_specs.py`, live-server only), not +re-run here. + **Not a bug — a follow-up.** Found 2026-08-28 while reviewing the Question Spec migration (`docs/design/report_spec_migration_design.md`, Phase 1 complete since 2026-05-18/`6c946af`). Both functions @@ -5937,6 +6192,59 @@ deployment-timing issue, not a code defect — see ISSUE-12, below. ## Not a bug / n/a +### ISSUE-88: no `GovernanceZone` create or lookup anywhere in pyegeria — a zone can be *referenced* by every search and classification, but not made + +**Layer:** Pyegeria · **Status: n/a — not a bug, corrected 2026-09-05 by dwolfson.** +`GovernanceZone` (`0424`) is a `GovernanceDefinition` subtype like every other +one `create_governance_definition` already handles — it's created the same +generic way as `BusinessImperative`/`RegulationArticle`/etc.: pass +`GovernanceDefinitionProperties` with `typeName: "GovernanceZone"` (plus +whatever zone-specific fields the type carries) to +`create_governance_definition`. No dedicated `create_governance_zone` +method is needed, same as there's no `create_business_imperative`. The +workaround below (raw `MetadataExpert`) was solving a problem that +`GovernanceOfficer.create_governance_definition` already covers — confirmed +against `GOV_DEF_PROPERTIES_LIST` in `pyegeria/omvs/governance_officer.py`, +which already accepts `GovernanceDefinitionProperties`, and against the +`.http` ground truth, which has no separate `governance-zones` create +endpoint at all (consistent with there being no dedicated endpoint, because +none is needed). **Real, worth fixing separately:** the method's own +docstring only lists 7 example type names and doesn't mention `GovernanceZone` +(or the other `0401`-family subtypes) as valid `typeName` values — that's +a documentation gap, not an API gap; left as a small follow-up rather than +its own numbered issue. + +**Original (incorrect) report, kept for history:** Found 2026-09-04 +(Resource Explorer, trellis-auth adoption / draft-zone partitioning). + +Searched `pyegeria/omvs/` and `pyegeria/*.py` on 2026-09-04 for +`GovernanceZone` / `governance_zone` / `governance-zones`. Every hit is a +**filter parameter** — `governance_zone_filter=` on `AssetMaker`, +`AutomatedCuration`, `GovernanceOfficer` finds — plus +`ClassificationExplorer.add_zone_membership` / `clear_zone_membership`, which +classify an element *into* a zone. `GovernanceOfficer.create_governance_definition` +creates a `GovernanceDefinition` (`0401`); `GovernanceZone` is `0424` and is not +one of the types that method's own docstring lists. There is no +`create_governance_zone`, no `find_governance_zones`, no `get_governance_zone_by_name`. + +**Why it matters (per the original report — since corrected above):** the +whole `ZoneMembership` partitioning story (Egeria's own recommended +mechanism, `0424`) can be *consumed* through pyegeria but not *bootstrapped*. +An application that partitions what it publishes — Resource Explorer puts +everything it publishes into a `resource-explorer-draft` zone and promotes +it out on curate-accept — has to create that zone before it can be navigated +to, and cannot ask pyegeria to do it. A zone classification does not require +the zone element to exist, so the failure is quiet: elements carry a +`zoneMembership` naming a zone the catalogue has no record of, which reads +as working until someone tries to browse by zone. + +**Workaround in use** (`resource_explorer/egeria_identity.py::ensure_draft_zone_exists`): +`MetadataExpert.create_metadata_element` with `typeName: "GovernanceZone"` and an +`ElementProperties` `propertyValueMap`, guarded by +`get_metadata_element_by_unique_name` for idempotency. It works, but per the +correction above it's solving a problem `create_governance_definition` +already handles more simply — worth switching to that instead. + ### ISSUE-65: `GlossaryManager.get_term_by_guid`'s naming-standards classifications (PrimeWord/ClassWord/Modifier) don't surface as individually-named `elementHeader` keys like most classifications do — undocumented response-shape gotcha, not a data-missing bug **Status:** n/a — not a bug, a response-shape gotcha worth documenting so diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py b/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py index a0e4e406..9647425a 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py @@ -68,10 +68,6 @@ def compose(self) -> ComposeResult: Input("Description of Todo", id="todo_description"), Input("Priority of Todo", id="todo_priority"), Static("Status will be automatically set to 'REQUESTED'"), - Horizontal( - Static("Link Todo to your profile? True or False, Default = True"), - Switch(value=True, id="link_todo_to_profile") - ), Horizontal( Button("Add Todo", id="add_todo_button", variant="primary"), Button("Quit", id="quit_button", variant="warning") @@ -95,17 +91,8 @@ def action_add_new_todo(self): priority=self.todo_priority, activity_status="REQUESTED" ) - self.log(f"Created ToDo assigned to the current user: {todo_guid}") - if self.link_todo_to_profile is True: - try: - self.todo_link_guid = tclient.link_todo_to_profile( - todo_guid=todo_guid, - profile_guid=self.user_guid - ) - self.notify(f"Linked ToDo to profile: {self.todo_link_guid}", timeout=10, severity="information") - except PyegeriaException as e: - self.log(f"Link todo to profile failed with return: {e}") - self.notify(f"Link todo to profile failed with return: {e}", timeout=10, severity="error") + self.log(f"Created ToDo: {todo_guid}") + except PyegeriaException as e: self.notify(f"Add todo failed with return: {e}", timeout=10, severity="error") finally: @@ -516,7 +503,6 @@ def action_add_new_blog(self): self.query_one("#blog_entry_name", Input).clear() self.query_one("#blog_entry_text", Input).clear() self.query_one("#blog_entry_situation", Input).clear() - self.query_one("#blog_entry_guid", Input).clear() self.query_one("#blog_entry_container", ScrollableContainer).refresh() return @@ -529,10 +515,6 @@ def handle_input_changed(self, event: Input.Changed): if event.input.id == "blog_entry_situation": self.blog_entry_situation = event.input.value - # @on(Switch.Changed, "#link_blog_entry_to_profile") - # def handle_link_blog_entry_to_profile_changed(self, event: Switch.Changed): - # self.link_blog_entry_to_profile = event.switch.value - def action_quit(self): self.dismiss(200) diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/CreateSubscriptionRequestScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/CreateSubscriptionRequestScreen.py index ae19ac37..795f6eb7 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/CreateSubscriptionRequestScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/CreateSubscriptionRequestScreen.py @@ -22,8 +22,14 @@ class CreateSubscriptionRequestScreen(ModalScreen): CSS_PATH = "my_profile.tcss" - def __init__(self, *args, **kwargs): + def __init__(self, selected_item: str = "", *args, **kwargs): super().__init__(*args, **kwargs) + self.selected_item = selected_item + self.display_name = "" + self.description = "" + self.status = "DRAFT" + self.identifier = "" + self.response = {} self.valid_status = ["DRAFT", "PROPOSED", "ACTIVE", @@ -42,6 +48,10 @@ def compose(self) -> ComposeResult: Static(f"Please provide the following input as appropriate:"), Static("Display Name should be short but descriptive, it will also be used to create the Qualified Name as well"), Input("Display Name for the subscription", id = "sub_display_name"), + Static(f"Description of this Subscription"), + Input(placeholder="Please enter a description", id="sub_description"), + Static(f"A short identifier for the subscription, e..g. MySub1"), + Input(placeholder="Please enter an identifier", id="sub_ID"), Static("By default the user defined status for this subscription will be set to DRAFT, you can override"), Input("User defined Status - optional", id = "sub_status"), Static("When all input is completed please click the 'Create Subscription' (c) key"), @@ -66,12 +76,27 @@ def handle_subscription_status_change(self, event): if self.status not in self.valid_status: self.status = "DRAFT" + @on(Input.Changed, "#sub_description") + def handle_description_change(self, event): + self.description = event.value or "" + + @on(Input.Changed, "#sub_ID") + def handle_subscription_id_change(self, event): + self.identifier = event.value + def action_create_subscription(self): self.log("Creating subscription request") - self.response = [] - if self.display_name and self.display_name != None and self.display_name != "": - self.response.append(self.display_name) - if self.status and self.status != None and self.status != "": - self.response.append(f"Status: {self.status}") + self.response: dict = {} + if self.selected_item: + self.response["externalSourceGUID"] = self.selected_item + self.response["guid"] = self.selected_item + if self.display_name: + self.response["displayName"] = self.display_name + if self.status: + self.response["Status"] = self.status + if self.description: + self.response["description"] = self.description + if self.identifier: + self.response["identifier"] = self.identifier self.dismiss(self.response) return self.response \ No newline at end of file diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py index 0005162e..8fcb2f31 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py @@ -5,7 +5,8 @@ This file provides a set of report specification related functions for my_egeria. """ -import pwd +from typing import Any +from textual import events, on from textual.app import ComposeResult from textual.containers import ScrollableContainer from textual.screen import Screen @@ -18,8 +19,9 @@ class MainScreen(Screen): BINDINGS = [ ("q", "app.quit", "Quit"), - ("ctrl+e", "edit_table", "Edit Selected Table"), + ("ctrl+b", "bookmarks", "Manage Bookmarks"), ("ctrl+s", "show_comments", "Show Comments for Selected Row"), + ("ctrl+t", "edit_table", "Edit Selected Table"), ] CSS_PATH = "my_profile.tcss" @@ -55,7 +57,7 @@ def compose(self) -> ComposeResult: Option("Edit Profile"), Option("Subscriptions"), Option("Technology Types"), - Option("User Bookmarks", disabled=True), + Option("User Bookmarks"), id="other_function_list" ), id="other_function_container" @@ -88,31 +90,94 @@ def compose(self) -> ComposeResult: yield Footer(id="main_footer") + def on_descendant_focus(self, event: events.DescendantFocus) -> None: + """Track which DataTable is currently focused when clicking or navigating into it.""" + if isinstance(event.widget, DataTable): + self.selected_table = event.widget.id + if event.widget.row_count > 0 and 0 <= event.widget.cursor_row < event.widget.row_count: + try: + self.selected_row = event.widget.coordinate_to_cell_key(event.widget.cursor_coordinate).row_key + except Exception: + self.selected_row = None + else: + self.selected_row = None + self.log(f"Focused Table: {self.selected_table}, Row: {self.selected_row}") + + @on(DataTable.RowHighlighted) def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted): """ Collect row and table when the user highlights a row in a table """ self.selected_row = event.row_key self.selected_table = event.data_table.id + self.log(f"Highlighted Table: {self.selected_table}, Row: {self.selected_row}") + @on(DataTable.RowSelected) def on_data_table_row_selected(self, event: DataTable.RowSelected): """ Collect row and table when the user selects a row in a table """ self.selected_row = event.row_key self.selected_table = event.data_table.id + self.log(f"Selected Table: {self.selected_table}, Row: {self.selected_row}") + + @on(DataTable.CellHighlighted) + def on_data_table_cell_highlighted(self, event: DataTable.CellHighlighted): + """ Collect row and table when a cell is highlighted in a table """ + self.selected_row = event.cell_key.row_key + self.selected_table = event.data_table.id + self.log(f"Cell Highlighted Table: {self.selected_table}, Row: {self.selected_row}") + + @on(DataTable.CellSelected) + def on_data_table_cell_selected(self, event: DataTable.CellSelected): + """ Collect row and table when a cell is selected in a table """ + self.selected_row = event.cell_key.row_key + self.selected_table = event.data_table.id + self.log(f"Cell Selected Table: {self.selected_table}, Row: {self.selected_row}") + + @on(DataTable.HeaderSelected) + def on_data_table_header_selected(self, event: DataTable.HeaderSelected): + """ Collect table when header is selected in a table """ + self.selected_table = event.data_table.id + self.log(f"Header Selected Table: {self.selected_table}") + + def get_focused_table(self) -> DataTable | None: + focused_widget = self.screen.focused + + # Check if the focused widget is a DataTable + if isinstance(focused_widget, DataTable): + return focused_widget + + return None - async def action_edit_table(self): + def get_current_table_and_row(self) -> tuple[str | None, Any]: + """Return the currently focused or selected table id and row key.""" + focused_table = self.get_focused_table() + if focused_table is not None: + table_id = focused_table.id + row_key = None + if focused_table.row_count > 0 and 0 <= focused_table.cursor_row < focused_table.row_count: + try: + row_key = focused_table.coordinate_to_cell_key(focused_table.cursor_coordinate).row_key + except Exception: + row_key = None + return table_id, row_key + return self.selected_table, self.selected_row + + def action_edit_table(self): """ Edit the selected table """ - if self.selected_table and self.selected_row: - await self.app.edit_tables(self.selected_table, self.selected_row) + table_name, row_k = self.get_current_table_and_row() + if table_name: + self.selected_table = table_name + self.selected_row = row_k + self.log(f"Editing Table: {table_name}, Row: {row_k}") + self.app.edit_tables(table_name, row_k) else: - active_table = self.get_focused_table() - if active_table: - await self.app.add_to_tables(active_table, "0") - else: - self.notify("Please select at least a table, or a table and row to edit.", timeout=5, severity="warning") + self.notify("Please select at least a table, or a table and row to edit.", timeout=5, severity="warning") async def action_show_comments(self): """ Show comments for the selected table """ - if self.selected_table and self.selected_row: - self.app.show_comments(self.selected_table, self.selected_row) + table_name, row_k = self.get_current_table_and_row() + if table_name and row_k: + self.selected_table = table_name + self.selected_row = row_k + self.app.show_comments(table_name, row_k) else: self.notify("Please select a row and table to show comments.", timeout=5, severity="warning") @@ -122,17 +187,18 @@ async def action_add_to_table(self): Kept as a generic entry point; adding a row is normally reached from within the per-table edit screen (Edit Selected Table -> Add Row). """ - if self.selected_table: - await self.app.add_to_tables(self.selected_table, self.selected_row) + table_name, row_k = self.get_current_table_and_row() + if table_name: + self.selected_table = table_name + self.selected_row = row_k + await self.app.add_to_tables(table_name, row_k) else: self.notify("Please select a table to add to.", timeout=5, severity="warning") - def get_focused_table(self) -> DataTable | None: - focused_widget = self.screen.focused - - # Check if the focused widget is a DataTable - if isinstance(focused_widget, DataTable): - return focused_widget - - return None + def action_bookmarks(self): + """ Manages BookMarks for the currently logged in user + Note - to add a new bookmark you must first have found and copied + the GUID of the item you want to bookmark to the clipboard + as you will need to know it during the add processing""" + self.app.show_my_bookmarks() diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/MyBookMarksScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/MyBookMarksScreen.py new file mode 100644 index 00000000..d97c711c --- /dev/null +++ b/my_egeria/my_egeria/DemoCode/My_Profile/MyBookMarksScreen.py @@ -0,0 +1,118 @@ +""" + PDX-License-Identifier: Apache-2.0 + Copyright Contributors to the ODPi Egeria project. + + This file provides a set of report specification related functions for my_egeria. + +""" +from textual import on +from textual.app import ComposeResult +from textual.containers import ScrollableContainer, Container, Horizontal +from textual.screen import ModalScreen +from textual.widgets import DataTable, Header, Static, Footer, Placeholder, Input, Button +from pyegeria import (load_app_config, + settings, + print_basic_exception, + Egeria) + + +class MyBookMarksScreen(ModalScreen): + """Main Screen for My Profile App.""" + + BINDINGS = [ + ("q", "quit", "Quit"), + ("ctrl+n", "new_bookmark", "Add New Bookmark"), + ("ctrl+r", "remove_bookmark", "Delete Selected Bookmark"), + ] + + CSS_PATH = "my_profile.tcss" + + def __init__(self, my_bookmarks, *args, **kwargs): + super().__init__(id="bookmark_screen", *args, **kwargs) + self.title = "Egeria" + self.sub_title = "My Bookmarks" + load_app_config() + app_config = settings.Environment + app_user = settings.User_Profile + self.user_name = app_user.user_name or "garygeeke" + self.user_password = app_user.user_pwd or "secret" + self.view_server = app_config.egeria_view_server or "qs-view-server" + self.platform_url = app_config.egeria_platform_url or "https://127.0.0.1:9443" + self.my_bookmarks_data = my_bookmarks + + def on_mount(self) -> None: + self.my_bookmark_table: DataTable = DataTable(id="my_bookmark_table") + self.my_bookmark_table.zebra_stripes=True + self.my_bookmark_table.cursor_type="row" + self.my_bookmark_table.add_columns("", "", "") + self.log(f"Bookmark Data:{self.my_bookmarks_data}") + if self.my_bookmarks_data == None: + self.my_bookmark_table.add_row("No Bookmarks found for", self.user_name, "") + else: + for entry in self.my_bookmarks_data: + self.my_bookmark_table.add_row(entry[0], entry[1], entry[2]) + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield ScrollableContainer( + Static(f"Existing Bookmarks, (if any)"), + DataTable(id="my_bookmark_table"), + id="bookmarks_table_container", + ) + yield Container( + Placeholder(id="bookmark_guid"), + id="action_bookmark_container" + ) + yield Footer() + + @on(DataTable.RowHighlighted, "#my_bookmarks_table") + def handle_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + """ Handle the case where the user just highlights the row """ + self.row_key = event.row_key + + @on(DataTable.RowSelected, "#my_bookmarks_table") + def handle_row_selected(self, event: DataTable.RowSelected) -> None: + """ Handle the case where the user selects the row """ + self.row_key = event.row_key + + def action_quit(self) -> None: + """ The user elects to quit the bookmarks function """ + self.dismiss(200) + + def action_new_bookmark(self) -> None: + """ The user wants to add a bookmark""" + input_container = self.query_one("#action_bookmark_container", Container) + input_container.remove_children() + input_container.mount(Input(placeholder="GUID of the target of the bookmark", id="add_bookmark_guid")) + input_container.mount(Horizontal( + Button("Add New Bookmark", id="add_new_bookmark", variant="primary"), + Button("Quit", id="quit_add_bookmark", variant="warning"))) + + @on(Button.Pressed, "#add_new_bookmark") + def handle_add_new_bookmark(self, event: Button.Pressed) -> None: + input_guid = self.query_one("#add_bookmark_guid", Input).value + if input_guid: + self.app.add_my_bookmark(input_guid) + else: + self.notify("You must provide the GUID of the item you want to bookmark before you press the button!", + timeout=10, + severity="warning") + self.dismiss(200) + + def action_remove_bookmark(self) -> None: + """ The user wants to delete a bookmark""" + input_container = self.query_one("#action_bookmark_container", Container) + input_container.remove_children() + input_container.mount(Input(placeholder="GUID of the bookmark to delete", id="del_bookmark_guid")) + input_container.mount(Button("Delete Bookmark", id="delete_bookmark", variant="primary")) + + @on(Button.Pressed, "#delete_bookmark") + def handle_add_new_bookmark(self, event: Button.Pressed) -> None: + input_guid = self.query_one("#del_bookmark_guid", Input).value + if input_guid: + self.app.add_my_bookmark(input_guid) + else: + self.notify("You must provide the GUID of the bookmark you want to delete before you press the button!", + timeout=10, + severity="warning") + self.dismiss(200) \ No newline at end of file diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/RETURN_CODES.md b/my_egeria/my_egeria/DemoCode/My_Profile/RETURN_CODES.md new file mode 100644 index 00000000..90664068 --- /dev/null +++ b/my_egeria/my_egeria/DemoCode/My_Profile/RETURN_CODES.md @@ -0,0 +1,60 @@ +# My Profile Application Return Codes Reference + +This document provides a comprehensive catalog of all return codes and screen dismiss codes used across the `my_profile_app.py` application and its supporting handlers and screens in `my_egeria/my_egeria/DemoCode/My_Profile/`. + +In accordance with application conventions: +- **200 Series**: Successful operations, normal navigation, user selections, and action triggers. +- **400 Series**: Failure, error, cancellation, or exception return codes. + +--- + +## 200 Series: Success, Navigation, and Action Codes + +| Return Code | Name / Type | Description | Source Components | +|---|---|---|---| +| **`200`** | `SUCCESS_OK` / `DEFAULT_EXIT` | Standard success code. Used for successful operations, standard modal dismissal, backing out of dialogs, cancelling inputs without error, or quitting cleanly. | `my_profile_app.py`, `MainScreen.py`, `StatusScreen.py`, `CreateProfileScreen.py`, `EditElementsScreens.py`, `AddToElementsScreens.py`, `TechnologyTypeScreens.py`, `tech_types_handler.py`, `shop_for_data_handler.py`, `team_roles_handler.py`, `SelectionOverviewScreen.py`, `GenericDataViewScreen.py`, `MyBookMarksScreen.py`, `MyTeamScreen.py`, `SearchForTermScreen.py`, `ShowCommentScreen.py` | +| **`201`** | `ALT_NAVIGATION` / `NO_MATCH` | Alternative navigation or fallback action code. In search/team screens, indicates no matching terms/roles found and triggers transition to data shopping or alternate view. In tech type selection, represents secondary navigation action. | `SearchForTermScreen.py`, `team_roles_handler.py`, `TechnologyTypeScreens.py`, `MyTeamScreen.py`, `ShopForDataScreen.py` | +| **`210`** | `QUIT_TO_MAIN` | Exit/Quit action code from data shopping / data viewing screens to dismiss the view and return directly to the main menu screen. | `ShopForDataScreen.py`, `GenericDataViewScreen.py`, `SelectionOverviewScreen.py`, `shop_for_data_handler.py` | +| **`211`** | `SUBSCRIBE_ACTION` | Action code indicating the user has requested to create a digital subscription for the selected data source, catalog product, or data element. | `ShopForDataScreen.py`, `GenericDataViewScreen.py`, `SelectionOverviewScreen.py`, `shop_for_data_handler.py` | +| **`212`** | `SAMPLE_DATA_ACTION` | Action code indicating the user has requested to view sample data for the selected data source in Shop for Data. | `ShopForDataScreen.py`, `shop_for_data_handler.py` | + +--- + +## 400 Series: Failure, Exception, and Error Codes + +| Return Code | Name / Type | Description | Source Components | +|---|---|---|---| +| **`400`** | `GENERIC_ERROR` / `BAD_RESULT` | General error code or user cancellation with bad result. Used in StatusScreen when copying GUID fails or user indicates bad status, and in edit elements when invalid operations occur. | `StatusScreen.py`, `EditElementsScreens.py`, `ShowCommentsScreen.py` | +| **`401`** | `PROFILE_ACTION_FAILED` | Profile creation or profile element editing cancelled, rejected, or failed. | `CreateProfileScreen.py`, `EditElementsScreens.py` | +| **`404`** | `TECH_TYPE_NO_PROCESS_FALLBACK_FAIL` | Failed to mount empty processes option list when fallback handler encountered an unexpected error. | `TechnologyTypeScreens.py` | +| **`405`** | `TECH_TYPE_NO_PROCESS_CREATE_FAIL` | Failed to create empty processes option list during initial widget mount. | `TechnologyTypeScreens.py` | +| **`406`** | `TECH_TYPE_PROCESS_CREATE_FAIL` | Error creating technology type governance action processes option list widget. | `TechnologyTypeScreens.py` | +| **`407`** | `TECH_TYPE_PROCESS_FALLBACK_FAIL` | Error creating technology type governance action processes option list widget during NoMatches fallback recovery. | `TechnologyTypeScreens.py` | +| **`408`** | `TECH_TYPE_NO_TEMPLATE_FALLBACK_FAIL` | Failed to mount empty templates option list when fallback handler encountered an unexpected error. | `TechnologyTypeScreens.py` | +| **`409`** | `TECH_TYPE_NO_TEMPLATE_CREATE_FAIL` | Failed to create empty templates option list during initial widget mount. | `TechnologyTypeScreens.py` | +| **`410`** | `TECH_TYPE_TEMPLATE_FALLBACK_FAIL` / `COLLECTION_CATEGORY_ERROR` | Error mounting template option list during NoMatches fallback recovery in technology type screens; or unknown collection category returned in selection overview processing. | `TechnologyTypeScreens.py`, `shop_for_data_handler.py` | +| **`411`** | `TECH_TYPE_TEMPLATE_CREATE_FAIL` / `GLOSSARY_TREE_MISSING` | Error creating template option list widget in technology type screens; or query_one found no matches for glossary tree in overview processing. | `TechnologyTypeScreens.py`, `shop_for_data_handler.py` | +| **`412`** | `CATALOG_TREE_MISSING` | Query_one found no matches for digital product catalog tree in selection overview processing. | `shop_for_data_handler.py` | +| **`413`** | `DATA_DICTIONARY_TREE_MISSING` | Query_one found no matches for data dictionary tree in selection overview processing. | `shop_for_data_handler.py` | +| **`414`** | `BUSINESS_DOMAIN_TREE_MISSING` | Query_one found no matches for business domain tree in selection overview processing. | `shop_for_data_handler.py` | +| **`415`** | `ROOT_COLLECTION_TREE_MISSING` / `TECH_TYPE_OPTION_INVALID_RC` | Query_one found no matches for root collections tree in selection overview processing; or technology type options screen returned non-200 error code. | `shop_for_data_handler.py`, `tech_types_handler.py` | +| **`416`** | `TECH_TYPE_FETCH_FAIL` | Failed to retrieve technology type hierarchy or details from Egeria Automated Curation service, or invalid option type passed. | `TechnologyTypeScreens.py`, `tech_types_handler.py` | +| **`417`** | `TECH_TYPE_UNPACK_DATA_ERROR` | Unexpected or unparseable data structure received from Egeria when unpacking technology type data. | `tech_types_handler.py` | +| **`418`** | `TECH_TYPE_TEMPLATE_CALLBACK_INVALID` / `SHOP_DATA_PAYLOAD_INVALID` | Invalid payload or missing required arguments received in technology type templates/processes callback; or invalid data payload returned from Shop for Data screen. | `tech_types_handler.py`, `shop_for_data_handler.py` | +| **`419`** | `TECH_TYPE_PLACEHOLDER_NOT_DICT` | Technology type placeholder input data is not a dictionary structure as expected. | `tech_types_handler.py` | +| **`420`** | `TECH_TYPE_CREATE_ELEMENT_FAIL` / `SHOP_DATA_RETRIEVAL_ERROR` | Exception raised during creation of metadata element from template via Automated Curation; or error retrieving details for glossary, catalog, or dictionary data elements. | `tech_types_handler.py`, `shop_for_data_handler.py` | +| **`421`** | `APP_INITIALIZATION_FAIL` / `DOMAIN_DATA_RETRIEVAL_ERROR` | Failure to initialize main profile app / retrieve current user profile details; or error retrieving business domain details in Shop for Data. | `my_profile_app.py`, `shop_for_data_handler.py` | +| **`422`** | `DATA_SPECIFICATION_RETRIEVAL_ERROR` | Error retrieving data specification details from Egeria in Shop for Data handler. | `shop_for_data_handler.py` | +| **`423`** | `BUSINESS_DOMAIN_SPEC_ERROR` | Error executing BusinessCapabilities report specification in Shop for Data handler. | `shop_for_data_handler.py` | +| **`429`** | `SAMPLE_DATA_RETRIEVAL_ERROR` | Failed to retrieve or parse sample data from Egeria Asset Catalog / Data Engine in Shop for Data handler. | `shop_for_data_handler.py` | +| **`440`** | `TEAM_ROLES_OR_GLOSSARY_ERROR` | Error communicating with Egeria or retrieving team members, user roles, or glossary term details. | `team_roles_handler.py` | + +--- + +## Return Code Ranges Summary + +- **200–219**: Normal flow, successful callbacks, dismiss codes, and user navigation/interaction actions. +- **400–409**: Screen UI widget creation, mount, and fallback recovery errors. +- **410–419**: Tree resolution errors in selection overview, and technology type data extraction / callback payload errors. +- **420–429**: Data retrieval exceptions, report specification execution failures, and template instantiation failures across Shop for Data and Technology Types. +- **440**: Team roles and glossary term query communication errors. diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/ShopForDataScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/ShopForDataScreen.py index 0f498bfb..e90f9c77 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/ShopForDataScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/ShopForDataScreen.py @@ -19,6 +19,8 @@ class ShopForDataScreen(Screen): """ Screen to Present a choice of different data sources to the user.""" BINDINGS = [("q", "dismiss(200)", "Quit"), ("s", "sample_data_source", "Sample data source"), + ("u", "subscribe_to_data_source", "Subscribe"), + ("ctrl+s", "subscribe_to_data_source", "Subscribe"), ("b", "back", "Go back")] CSS_PATH = "my_profile.tcss" @@ -121,8 +123,9 @@ def handle_digital_product_catalog_table_selection(self, event: DataTable.RowSel row_display_name = row_values[0] row_description = row_values[1] row_qualified_name = row_values[2] - self.log(f"Row selected: {row_selected}, values: {row_values}, display name: {row_display_name}, description: {row_description}, qualified name: {row_qualified_name}") - self.dismiss (["catalog", row_qualified_name, row_display_name]) + row_guid = row_values[3] if len(row_values) > 3 else None + self.log(f"Row selected: {row_selected}, values: {row_values}, display name: {row_display_name}, description: {row_description}, qualified name: {row_qualified_name}, guid: {row_guid}") + self.dismiss(["catalog", row_qualified_name, row_display_name, row_guid]) @on(DataTable.RowSelected, "#data_dictionary_table") def handle_data_dictionary_table_selection(self, event: DataTable.RowSelected): @@ -244,4 +247,74 @@ def action_sample_data_source(self): getattr(self, "cursor_row_highlighted", None), getattr(self, "data_table_highlighted", None), row_data, - ]) \ No newline at end of file + ]) + + def action_subscribe_to_data_source(self): + """ The subscribe to data source option in the footer has been selected.""" + tables = [ + self.digital_product_catalog_table, + self.glossary_table, + self.data_dictionary_table, + self.business_domain_table, + self.root_collection_table, + self.data_specification_table, + ] + target_table = None + if getattr(self, "data_table_highlighted", None): + for t in tables: + if t is not None and getattr(t, "id", None) == self.data_table_highlighted: + target_table = t + break + + if target_table is None: + focused = getattr(self, "focused", None) + if isinstance(focused, DataTable): + target_table = focused + self.data_table_highlighted = getattr(focused, "id", None) + self.cursor_row_highlighted = focused.cursor_row + try: + self.row_highlighted = list(focused.rows.keys())[focused.cursor_row] if focused.cursor_row is not None and focused.cursor_row < len(focused.rows) else None + except Exception: + self.row_highlighted = None + + if target_table is None: + for t in tables: + if t is not None and getattr(t, "row_count", 0) > 0: + target_table = t + self.data_table_highlighted = getattr(t, "id", None) + self.cursor_row_highlighted = t.cursor_row + try: + self.row_highlighted = list(t.rows.keys())[t.cursor_row] if t.cursor_row is not None and t.cursor_row < len(t.rows) else None + except Exception: + self.row_highlighted = None + break + + row_data = [] + if target_table is not None and getattr(target_table, "row_count", 0) > 0: + try: + if getattr(self, "row_highlighted", None) is not None and hasattr(target_table, "rows") and self.row_highlighted in target_table.rows: + row_data = list(target_table.get_row(self.row_highlighted)) + elif getattr(self, "cursor_row_highlighted", None) is not None and target_table.row_count > self.cursor_row_highlighted: + row_data = list(target_table.get_row_at(self.cursor_row_highlighted)) + elif target_table.cursor_row is not None and target_table.row_count > target_table.cursor_row: + row_data = list(target_table.get_row_at(target_table.cursor_row)) + else: + row_data = list(target_table.get_row_at(0)) + except Exception as e: + self.log(f"Error getting row data from table: {e}") + + self.dismiss([ + 211, + getattr(self, "row_highlighted", None), + getattr(self, "cursor_row_highlighted", None), + getattr(self, "data_table_highlighted", None), + row_data, + ]) + + def action_subscribe(self): + """ Alias for action_subscribe_to_data_source.""" + self.action_subscribe_to_data_source() + + def action_subscribe_data_source(self): + """ Alias for action_subscribe_to_data_source.""" + self.action_subscribe_to_data_source() \ No newline at end of file diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/my_profile_app-orig.py b/my_egeria/my_egeria/DemoCode/My_Profile/my_profile_app-orig.py index c151639e..91fe5524 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/my_profile_app-orig.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/my_profile_app-orig.py @@ -38,7 +38,7 @@ from StatusScreen import StatusScreen from ShopForDataScreen import ShopForDataScreen from SelectionOverviewScreen import SelectionOverviewScreen -from MyTeam import MyTeam +from MyTeamScreen import MyTeam from MainScreen import MainScreen from SearchForTermScreen import SearchForTermScreen from CreateSubscriptionRequestScreen import CreateSubscriptionRequestScreen diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/my_profile_app.py b/my_egeria/my_egeria/DemoCode/My_Profile/my_profile_app.py index e7b2a728..403445c3 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/my_profile_app.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/my_profile_app.py @@ -81,6 +81,7 @@ from shop_for_data_handler import ShopForDataMixin from team_roles_handler import TeamRolesMixin from elements_crud_handler import ElementsCrudMixin +from MyBookMarksScreen import MyBookMarksScreen class MyProfileApp(App, TechTypesMixin, ShopForDataMixin, TeamRolesMixin, ElementsCrudMixin): @@ -132,6 +133,7 @@ class MyProfileApp(App, TechTypesMixin, ShopForDataMixin, TeamRolesMixin, Elemen "view_subscriptions": ViewSubscriptionsScreen, "generic_data_view": GenericDataViewScreen, "data_view": DataViewScreen, + "my_bookmarks": MyBookMarksScreen, } def __init__(self, *args, **kwargs): @@ -143,13 +145,17 @@ def __init__(self, *args, **kwargs): self.description = "Display the user related items for the current user." load_app_config() app_config = settings.Environment + self.log(f"Application Config: {app_config}") app_user = settings.User_Profile - print("Platform:", app_config.egeria_platform_url) - print("View Server:", app_config.egeria_view_server) + self.log(f"User Profile: {app_user}") self.user_name = app_user.user_name or "garygeeke" self.user_password = app_user.user_pwd or "secret" self.view_server = app_config.egeria_view_server or "qs-view-server" self.platform_url = app_config.egeria_platform_url or "https://127.0.0.1:9443" + self.log(f"Platform URL: {self.platform_url}") + self.log(f"View Server: {self.view_server}") + self.log(f"User: {self.user_name}") + self.log(f"User PWD: {self.user_password}") # Ensure compose() is safe before data loads self.actor_profile: dict = {} @@ -388,7 +394,7 @@ async def _populate_tables(self) -> Any: self.communities_table.cursor_type = "row" self.digital_product_catalog_table: DataTable = DataTable(id="digital_product_catalog_table") - self.digital_product_catalog_table.add_columns("Digital Product Catalog Name", "Description", "Qualified Name") + self.digital_product_catalog_table.add_columns("Digital Product Catalog Name", "Description", "Qualified Name", "GUID") self.digital_product_catalog_table.cursor_type = "row" self.digital_product_catalog_table.zebra_stripes = True @@ -541,7 +547,7 @@ async def handle_option_selected(self, event: OptionList.OptionSelected) -> None callback=self.edit_profile_callback, ) elif selected_option == "User Bookmarks": - pass + self.show_my_bookmarks() elif selected_option == "Subscriptions": await self.push_screen(ViewSubscriptionsScreen(), callback=self.view_subscriptions_callback) @@ -577,12 +583,12 @@ def get_data_product_catalog_table(self) -> int: """Fetch and populate digital product catalog table.""" if not hasattr(self, "digital_product_catalog_table") or self.digital_product_catalog_table is None: self.digital_product_catalog_table = DataTable(id="digital_product_catalog_table") - self.digital_product_catalog_table.add_columns("Digital Product Catalog Name", "Description", "Qualified Name") + self.digital_product_catalog_table.add_columns("Digital Product Catalog Name", "Description", "Qualified Name", "GUID") self.digital_product_catalog_table.cursor_type = "row" self.digital_product_catalog_table.zebra_stripes = True else: self.digital_product_catalog_table.clear(columns=True) - self.digital_product_catalog_table.add_columns("Digital Product Catalog Name", "Description", "Qualified Name") + self.digital_product_catalog_table.add_columns("Digital Product Catalog Name", "Description", "Qualified Name", "GUID") self.digital_product_catalog_table.cursor_type = "row" self.digital_product_catalog_table.zebra_stripes = True @@ -617,9 +623,146 @@ def get_data_product_catalog_table(self) -> int: catalog_item.get("Display Name", ""), catalog_item.get("Description", ""), catalog_item.get("Qualified Name", ""), + catalog_item.get("GUID", ""), ) return 200 + def show_my_bookmarks(self) -> None: + """ Access Egeria to retrieve all bookmarks for the current user """ + eclient = Egeria(self.view_server, + self.platform_url, + self.user_name, + self.user_password) + + # Acquire a bearer token for authentication + token = eclient.create_egeria_bearer_token(self.user_name, self.user_password) + try: + + # Retrieve bookmarks for current user + # my_bookmarks = eclient.get_favorite_things(user_id=self.user_name) + # --- API call (show at minimum the required params; document optional ones) --- + body = { + "class": "SearchStringRequestBody", + "searchString": "*" + } + response = eclient.find_locations( + search_string="*", + starts_with=False, + ends_with=True, # default is False + ignore_case=True, # default is True + metadata_element_type_name=None, # optional; e.g. 'LocationProperties' + metadata_element_subtypes=[], + include_only_relationships=[], # list of relationship types to include in the search results + skip_relationships=[], # list of relationship types to exclude from the search results + graph_query_depth=0, # default is 3; max depth for recursive query (0 = no recursion) + as_of_time=None, + start_from=1, # offset into result set (default: 1); use -1 for "all" + page_size=100, # number of items to return per call + sequencing_order="ASC", # optional; e.g. 'DESC' + sequencing_property="", # optional; e.g. 'qualifiedName' or a custom property name + output_format='DICT', # default is json; other options: csv, xml + report_spec=None, + body=body # the full request body for search string requests (optional) + ) + + # --- Output rendering --- + if isinstance(response, list): + self.log(f"Found {len(response)} items") + self.log(f"Response: {response}") + my_bookmarks = response[0].get("Data") or "" + elif isinstance(response, dict): + my_bookmarks = response.get("Data") or "" + elif isinstance(response, str): + self.log(f"Response: {response}") + self.notify(f"Response from get bookmarks:") + my_bookmarks = None + else: + self.log(f"Response unknown: {type(response)}, {response}") + my_bookmarks = None + + #unless there is an errror returned from Egeria + except PyegeriaException as e: + print(f"An error occurred interacting with Egeria: {e}") + self.notify(f"An error occurred interacting with Egeria: {e}") + return + + finally: + # 4. Canonical pattern to cleanly terminate the connection session + if 'eclient' in locals(): + eclient.close_session() + + self.push_screen(MyBookMarksScreen(my_bookmarks)) + + return + + def add_my_bookmark(self, target_guid) -> None: + """ Add a bookmark for the user, input is the GUID of the item to bookmark """ + self.asset_guid = target_guid + eclient = Egeria(self.view_server, + self.platform_url, + self.user_name, + self.user_password) + + # 2. Acquire a bearer token for authentication + token = eclient.create_egeria_bearer_token(self.user_name, self.user_password) + try: + + + self.log(f"Adding asset {self.asset_guid} to {self.user_name}'s Favorite Things Collection...") + + # 3. Attach the asset to the user's bookmark collection + # In pyegeria, this maps directly to the underlying My Profile Open Metadata View Service + bookmark_relationship = eclient.add_asset_to_favorites( + user_id=self.user_name, + asset_guid=self.asset_guid + ) + + self.log("Successfully bookmarked item!") + self.log(f"Relationship Guid: {bookmark_relationship.get('guid')}") + self.notify(f"Successfully bookmarked item! Relationship Guid: {bookmark_relationship.get('guid')}") + + except PyegeriaException as e: + print(f"An error occurred interacting with Egeria: {e}") + + finally: + # 4. Canonical pattern to cleanly terminate the connection session + if 'eclient' in locals(): + eclient.close_session() + + def delete_my_bookmark(self, target_guid) -> None: + """ Delete a bookmark for the user, input is the GUID of the bookmark to delete """ + self.asset_guid = target_guid + eclient = Egeria(self.view_server, + self.platform_url, + self.user_name, + self.user_password) + + # 2. Acquire a bearer token for authentication + token = eclient.create_egeria_bearer_token(self.user_name, self.user_password) + try: + + + self.log(f"Adding asset {self.asset_guid} to {self.user_name}'s Favorite Things Collection...") + + # 3. Attach the asset to the user's bookmark collection + # In pyegeria, this maps directly to the underlying My Profile Open Metadata View Service + eclient.remove_asset_from_favorites( + user_id=self.user_name, + asset_guid=self.asset_guid + ) + + self.log("Successfully deleted bookmark!") + self.log(f"Relationship Guid: {self.asset_guid}") + self.notify(f"Successfully deleted bookmark! Guid: {self.asset_guid}") + + except PyegeriaException as e: + print(f"An error occurred interacting with Egeria: {e}") + + finally: + # 4. Canonical pattern to cleanly terminate the connection session + if 'eclient' in locals(): + eclient.close_session() + # Compatibility wrappers delegating to profile_utils def clean_structure(self, data: Any, target: str = "specificationMermaidGraph") -> Any: return clean_structure(data, target) diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/shop_for_data_handler.py b/my_egeria/my_egeria/DemoCode/My_Profile/shop_for_data_handler.py index 215da338..7f7f9fcb 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/shop_for_data_handler.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/shop_for_data_handler.py @@ -42,6 +42,15 @@ class ShopForDataMixin(): """Mixin class providing Catalogs & Shop For Data functionality for MyProfileApp.""" + _app_instance: Any = None + glossary_data: dict[str, Any] = {} + glossary_data_extract: list[dict[str, Any]] = [] + selected_t_node: str = "*" + selected_item: str = "" + selected_tree: str = "" + digital_product_catalog_table: DataTable | None = None + collections: Any = [] + def __init__(self, app_instance: Any = None, *args, **kwargs) -> None: try: super().__init__(*args, **kwargs) @@ -55,11 +64,18 @@ def __init__(self, app_instance: Any = None, *args, **kwargs) -> None: self.user_password = app_user.user_pwd or "secret" self.view_server = app_config.egeria_view_server or "qs-view-server" self.platform_url = app_config.egeria_platform_url or "https://127.0.0.1:9443" + self.glossary_data = {} + self.glossary_data_extract = [] + self.selected_t_node = "*" + self.selected_item = "" + self.selected_tree = "" + self.digital_product_catalog_table = None + self.collections = [] @property def app(self) -> Any: """Return the bound app instance, falling back to self if mixed into an App.""" - return getattr(self, "_app_instance", None) or self + return self._app_instance or self @app.setter def app(self, value: Any) -> None: @@ -67,15 +83,12 @@ def app(self, value: Any) -> None: def show_main_screen(self) -> None: """Show main screen helper delegating to app instance or unwinding the stack.""" - target_app = self.app - if target_app is not self and hasattr(target_app, "show_main_screen"): - target_app.show_main_screen() - elif target_app is not self and hasattr(target_app, "_show_main_screen"): - target_app._show_main_screen() - elif hasattr(self, "_show_main_screen") and callable(getattr(self, "_show_main_screen")) and getattr(self, "_show_main_screen") != self.show_main_screen: - self._show_main_screen() - elif hasattr(super(), "show_main_screen"): - super().show_main_screen() + if hasattr(self.app, "_show_main_screen"): + self.app._show_main_screen() + elif hasattr(self.app, "show_main_screen") and self.app.show_main_screen != self.show_main_screen: + self.app.show_main_screen() + elif hasattr(self.app, "pop_screen"): + self.app.pop_screen() _show_main_screen = show_main_screen @@ -104,69 +117,58 @@ async def handle_shop_for_data_option(self) -> Any: except PyegeriaException as e: print_basic_exception(e) self.app.log(f"Error retrieving glossary details: {e!s}") - if hasattr(self.app, "exit"): - self.app.exit(420) + self.app.exit(420) return 420 self.app.log(f"Glossary data returned: {self.glossary_data}") self.glossary_data_extract = self.glossary_data.get("data") or [] self.app.log(f"Glossary data extracted: {self.glossary_data_extract}") - if self.glossary_data_extract == []: - self.app.log(f"No glossary data found for search string: {getattr(self, 'selected_t_node', '*')}") + if not self.glossary_data_extract: + self.app.log(f"No glossary data found for search string: {self.selected_t_node}") glossary_table.add_row("No glossaries found", "No data returned from Egeria", "") else: for g in self.glossary_data_extract: glossary_table.add_row(g.get("Display Name"), g.get("Description"), g.get("Qualified Name")) # Digital Product Catalogs - if hasattr(self.app, "get_data_product_catalog_table") and callable(self.app.get_data_product_catalog_table): - return_code = self.app.get_data_product_catalog_table() - self.digital_product_catalog_table: DataTable = getattr(self.app, "digital_product_catalog_table", None) - elif hasattr(self, "get_data_product_catalog_table") and callable(getattr(self, "get_data_product_catalog_table")): - return_code = self.get_data_product_catalog_table() - self.digital_product_catalog_table: DataTable = getattr(self, "digital_product_catalog_table", None) + self.digital_product_catalog_table = DataTable(id="digital_product_catalog_table") + self.digital_product_catalog_table.add_columns("Digital Product Catalog Name", "Description", "Qualified Name", "GUID") + self.digital_product_catalog_table.cursor_type = "row" + self.digital_product_catalog_table.zebra_stripes = True + try: + self.digital_product_catalog_data = exec_report_spec( + format_set_name="Digital-Product-Catalog-MyE", + output_format="DICT", + params={ + "search_string": "*", + "metadata_element_subtypes": ["DigitalProduct", "DigitalProductFamily"], + }, + view_server=self.view_server, + view_url=self.platform_url, + user=self.user_name, + user_pass=self.user_password, + ) + except PyegeriaException as e: + self.app.log(f"Error retrieving digital product catalog details: {e!s}") + print_basic_exception(e) + return 421 + self.app.log(f"Digital Product Catalog data returned: {self.digital_product_catalog_data}") + self.digital_product_catalog_data_extract = self.digital_product_catalog_data.get("data") or [] + self.app.log(f"Digital Product Catalog data extracted: {self.digital_product_catalog_data_extract}") + if not self.digital_product_catalog_data_extract: + self.app.log(f"No digital product catalog data found for user: {self.user_name}") + self.digital_product_catalog_table.add_row("No digital product catalogs found", "No data returned from Egeria", "") else: - self.digital_product_catalog_table = None - - if self.digital_product_catalog_table is None: - self.digital_product_catalog_table = DataTable(id="digital_product_catalog_table") - self.digital_product_catalog_table.add_columns("Digital Product Catalog Name", "Description", "Qualified Name") - self.digital_product_catalog_table.cursor_type = "row" - self.digital_product_catalog_table.zebra_stripes = True - - # try: - # self.digital_product_catalog_data = exec_report_spec( - # format_set_name="Digital-Product-Catalog", - # output_format="DICT", - # params={ - # "search_string": "*", - # "metadata_element_subtypes": ["DigitalProduct", "DigitalProductFamily"], - # }, - # view_server=self.view_server, - # view_url=self.platform_url, - # user=self.user_name, - # user_pass=self.user_password, - # ) - # except PyegeriaException as e: - # self.app.log(f"Error retrieving digital product catalog details: {e!s}") - # # self.exit(421) - # return 421 - # self.app.log(f"Digital Product Catalog data returned: {self.digital_product_catalog_data}") - # self.digital_product_catalog_data_extract = self.digital_product_catalog_data.get("data") or [] - # self.app.log(f"Digital Product Catalog data extracted: {self.digital_product_catalog_data_extract}") - # if self.digital_product_catalog_data_extract == []: - # self.app.log(f"No digital product catalog data found for user: {self.user_name}") - # self.digital_product_catalog_table.add_row("No digital product catalogs found", "No data returned from Egeria", "") - # else: - # for catalog_item in self.digital_product_catalog_data_extract: - # self.digital_product_catalog_table.add_row( - # catalog_item["Display Name"], - # catalog_item["Description"], - # catalog_item["Qualified Name"], - # ) + for catalog_item in self.digital_product_catalog_data_extract: + self.digital_product_catalog_table.add_row( + catalog_item.get("Display Name", ""), + catalog_item.get("Description", ""), + catalog_item.get("Qualified Name", ""), + catalog_item.get("GUID", "") + ) # Data Dictionaries data_dictionary_table: DataTable = DataTable(id="data_dictionary_table") - data_dictionary_table.add_columns("Data Dictionary Name", "Description", "Qualified Name") + data_dictionary_table.add_columns("Data Dictionary Name", "Description", "Qualified Name", "GUID") data_dictionary_table.cursor_type = "row" data_dictionary_table.zebra_stripes = True try: @@ -180,7 +182,8 @@ async def handle_shop_for_data_option(self) -> Any: user_pass=self.user_password, ) except PyegeriaException as e: - self.app.log(f"Error retrieving data dictionary details: {e!s}") + self.app.log(f"Error retrieving data dictionary details: {e}") + print_basic_exception(e) # self.exit(422) return 422 self.data_dictionary_data_extract = self.data_dictionary_data.get("data") or [] @@ -214,7 +217,7 @@ async def handle_shop_for_data_option(self) -> Any: ) except PyegeriaException as e: self.app.log(f"Error retrieving business domain details: {e!s}") - # self.exit(423) + print_basic_exception(e) return 423 self.business_domain_data_extract = self.business_domain_data.get("data") or [] if self.business_domain_data_extract == []: @@ -248,6 +251,7 @@ async def handle_shop_for_data_option(self) -> Any: except PyegeriaException as e: print_basic_exception(e) self.collections = "Error retrieving collections: " + str(e) + self.app.log(f"Found {len(self.collections)} root collections for user {self.user_name}") self.app.log(f"Root collections: {self.collections}") if isinstance(self.collections, str): @@ -321,6 +325,9 @@ async def shop_for_data_callback(self, result: Any) -> Any: return 200 elif selection_type == 211: self.app.log(f"Shop For Data screen returned: {selection_type}, request to subscribe to data source ") + await self.request_to_subscribe_data_source( + selection_parm_1, selection_parm_2, selection_parm_3, row_values=selection_parm_4 + ) return 211 elif selection_type == 212: self.app.log(f"Shop For Data screen returned: {selection_type}, request to sample data source ") @@ -340,8 +347,8 @@ async def shop_for_data_callback(self, result: Any) -> Any: self.app.log(f"Selected business domain with qualified name: {selection_parm_1}") self.build_domain_details(selection_parm_1, selection_parm_2) elif selection_type == "catalog": - self.app.log(f"Selected catalog with qualified name: {selection_parm_1}") - self.build_catalog_details(selection_parm_1, selection_parm_2) + self.app.log(f"Selected catalog with qualified name: {selection_parm_1}, guid: {selection_parm_3}") + self.build_catalog_details(selection_parm_1, selection_parm_2, target_guid=selection_parm_3) elif selection_type == "glossary": self.app.log(f"Selected glossary with qualified name: {selection_parm_2}") self.build_glossary_details(selection_parm_1, selection_parm_2) @@ -372,8 +379,8 @@ def build_dictionary_details(self, target_qualified_name: str, target_display_na except PyegeriaException as e: print_basic_exception(e) self.app.log(f"Error retrieving dictionary details: {e!s}") - # self.exit(420) return 420 + self.app.log(f"Dictionary Details: {self.dictionary_details}") if not self.dictionary_details: error_category = "Dictionary Details" @@ -436,8 +443,8 @@ def build_domain_details(self, target_qualified_name: str, target_type__name: st except PyegeriaException as e: print_basic_exception(e) self.app.log(f"Error retrieving business domain details: {e!s}") - # self.exit(420) return 420 + self.app.log(f"domain_details: {self.domain_details}") if not self.domain_details: error_category = "Business Domain Details" @@ -496,11 +503,12 @@ def build_domain_details(self, target_qualified_name: str, target_type__name: st callback=self.overview_callback, ) - def build_catalog_details(self, target_qualified_name: str, target_display_name: str) -> Any: + def build_catalog_details(self, target_qualified_name: str, target_display_name: str, target_guid: str | None = None) -> Any: """Build the details object for a product catalog details screen.""" - self.app.log(f"Building product catalog details for qualified name: {target_qualified_name}") + self.app.log(f"Building product catalog details for qualified name: {target_qualified_name}, guid: {target_guid}") self.catalog_qualified_name = target_qualified_name self.catalog_display_name = target_display_name + self.catalog_guid = target_guid build_structure: dict = {} try: @@ -516,8 +524,8 @@ def build_catalog_details(self, target_qualified_name: str, target_display_name: except PyegeriaException as e: print_basic_exception(e) self.app.log(f"Error retrieving catalog details: {e!s}") - # self.exit(420) return 420 + self.app.log(f"catalog_details: {self.catalog_details}") if not self.catalog_details: error_category = "Catalog Details" @@ -547,18 +555,23 @@ def build_catalog_details(self, target_qualified_name: str, target_display_name: term_qualified_name = product.get("Qualified Name") or "" term_subject = product.get("Display Name") or "" term_summary = product.get("Description") or "" + product_guid = product.get("GUID") or target_guid or term_qualified_name if term_subject not in build_structure: build_structure[term_subject] = [] - build_structure[term_subject].append({term_qualified_name: term_summary}) + build_structure[term_subject].append({term_qualified_name: (term_summary, product_guid)}) self.app.log(f"build_structure: {build_structure}") sample_data = [] for instance, data_prods in build_structure.items(): catalog_branch = catalog_tree.root.add(instance) for data_prod in data_prods: - for term_qualified_name, term_summary in data_prod.items(): - catalog_branch.add_leaf(term_summary, data=term_qualified_name) - self.app.log(f"term_qualified_name: {term_qualified_name}, term summary: {term_summary}") + for term_qualified_name, item_info in data_prod.items(): + if isinstance(item_info, (tuple, list)): + term_summary, leaf_guid = item_info + else: + term_summary, leaf_guid = item_info, term_qualified_name + catalog_branch.add_leaf(term_summary, data=leaf_guid) + self.app.log(f"term_qualified_name: {term_qualified_name}, term summary: {term_summary}, guid: {leaf_guid}") catalog_tree.root.expand() # get some sample data from the data source for each product @@ -619,7 +632,7 @@ def build_glossary_details(self, target_qualified_name: str, target_display_name glossary_tree: Tree = Tree(label=self.glossary_display_name, id="glossary_details_tree") - for glossary_instance in getattr(self, "glossary_data_extract", []): + for glossary_instance in self.glossary_data_extract: if glossary_instance.get("Qualified Name") == target_qualified_name: self.glossary_folders = glossary_instance.get("Folders") or None self.app.log(f"glossary_folders: {self.glossary_folders}") @@ -743,27 +756,164 @@ def overview_callback(self, r_code: Any) -> None: self.app.log(f"Overview screen callback, return code : {r_code}") if r_code == 211: self.app.log(f"Subscribing to selected item: {self.selected_item} from {self.selected_tree}") - try: - s_client = ProductManager(self.view_server, self.platform_url, self.user_name, self.user_password) - s_client.create_egeria_bearer_token(self.user_name, self.user_password) - s_client.create_digital_subscription(self.selected_item) - except PyegeriaException: - self.app.log(f"Error creating digital subscription: {self.selected_item} from {self.selected_tree}") - self.app.notify("Error creating digital subscription") - self.app.push_screen(CreateSubscriptionRequestScreen(), callback=self.create_subscription_callback) - else: - self.app.push_screen( - ShopForDataScreen(), - callback=self.shop_for_data_callback, - ) + # try: + # s_client = ProductManager(self.view_server, self.platform_url, self.user_name, self.user_password) + # s_client.create_egeria_bearer_token(self.user_name, self.user_password) + # s_client.create_digital_subscription(self.selected_item) + # except PyegeriaException: + # self.app.log(f"Error creating digital subscription: {self.selected_item} from {self.selected_tree}") + # self.app.notify("Error creating digital subscription") + self.app.push_screen(CreateSubscriptionRequestScreen(self.selected_item), callback=self.create_subscription_callback) + # else: + # self.app.push_screen( + # ShopForDataScreen(), + # callback=self.shop_for_data_callback, + # ) def create_subscription_callback(self, result: Any) -> None: """Callback routine for create subscription request screen.""" - if result is None: + if result is None or result == 200: self.app.log("User cancelled subscription creation") return self.app.log(f"Subscription created: {result}") + display_name = result.get("displayName", "") if isinstance(result, dict) else str(result) + description = result.get("description", "") if isinstance(result, dict) else "" + status = result.get("Status", "DRAFT") if isinstance(result, dict) else "DRAFT" + identifier = result.get("identifier", "") if isinstance(result, dict) else "" + item_guid = ( + result.get("externalSourceGUID") + or result.get("guid") + or (self.selected_item if hasattr(self, "selected_item") else "") + or "" + ) if isinstance(result, dict) else (self.selected_item if hasattr(self, "selected_item") else "") + + body = { + "class": "NewAgreementRequestBody", + "isOwnAnchor": True, + "anchorScopeGUID": None, + "parentGUID": None, + "parentRelationshipTypeName": "CollectionMembership", + "parentAtEnd1": True, + "properties": { + "class": "DigitalSubscriptionProperties", + "qualifiedName": "DigitalSubscription::" + display_name, + "displayName": display_name or "display name", + "description": description, + "userDefinedStatus": "DRAFT", + "identifier": identifier, + "supportLevel": "Community", + "serviceLevels": None, + "additionalProperties": None, + }, + "initialStatus": status, + "externalSourceGUID": item_guid, + "externalSourceName": display_name, + "effectiveTime": None, + "forLineage": False, + "forDuplicateProcessing": False, + } + + try: + s_client = ProductManager(self.view_server, self.platform_url, self.user_name, self.user_password) + s_client.create_egeria_bearer_token(self.user_name, self.user_password) + res = s_client.create_digital_subscription(body) + self.app.log(f"Created digital subscription successfully: {res}") + self.app.notify(f"Created digital subscription for {display_name or item_guid}") + except Exception as e: + self.app.log(f"Error creating digital subscription in callback: {e}") + self.app.notify(f"Error creating digital subscription: {e}") + + async def request_to_subscribe_data_source( + self, + selection_parm_1: Any = None, + selection_parm_2: Any = None, + selection_parm_3: Any = None, + row_values: Any = None, + ) -> None: + """The user has requested to subscribe directly to the selected data source without sampling.""" + self.row_highlighted = selection_parm_1 + self.cursor_row_highlighted = selection_parm_2 + self.data_table_highlighted = selection_parm_3 + self.app.log( + f"Direct subscribe requested: row={selection_parm_1}, cursor_row={selection_parm_2}, data_table={selection_parm_3}, row_values={row_values}" + ) + + item_content = [] + if row_values and isinstance(row_values, (list, tuple)) and len(row_values) > 0: + item_content = list(row_values) + else: + # Fallback to query_one on app or active screen + table_obj = None + if self.data_table_highlighted: + table_id = f"#{str(self.data_table_highlighted).lstrip('#')}" + try: + table_obj = self.app.query_one(table_id, DataTable) + except Exception: + try: + table_obj = self.app.screen.query_one(table_id, DataTable) + except Exception: + table_obj = None + + if table_obj: + try: + if self.row_highlighted is not None and hasattr(table_obj, "get_row"): + item_content = list(table_obj.get_row(self.row_highlighted)) + elif self.cursor_row_highlighted is not None and hasattr(table_obj, "get_row_at"): + item_content = list(table_obj.get_row_at(self.cursor_row_highlighted)) + elif hasattr(table_obj, "get_row_at"): + item_content = list(table_obj.get_row_at(0)) + except Exception as e: + self.app.log(f"Error getting row from table fallback: {e}") + + element_name = "Selected Data Element" + element_qname = "" + element_desc = "" + element_guid = "" + + if item_content and len(item_content) > 0: + if self.data_table_highlighted in ["glossary_table", "digital_product_catalog_table", "data_dictionary_table"]: + element_name = str(item_content[0]) if len(item_content) > 0 else "" + element_desc = str(item_content[1]) if len(item_content) > 1 else "" + element_qname = str(item_content[2]) if len(item_content) > 2 else "" + element_guid = str(item_content[3]) if len(item_content) > 3 else "" + else: + element_name = str(item_content[0]) if len(item_content) > 0 else "" + element_desc = str(item_content[1]) if len(item_content) > 1 else "" + element_qname = str(item_content[0]) if len(item_content) > 0 else "" + element_guid = str(item_content[2]) if len(item_content) > 2 else "" + + if element_guid: + self.selected_item = element_guid + elif element_qname: + self.selected_item = element_qname + elif element_name: + self.selected_item = element_name + + is_placeholder = ( + not item_content + or not element_name + or element_name.startswith("No ") + or element_desc == "No data returned from Egeria" + ) + + if is_placeholder: + self.app.log("No valid data element selected to subscribe") + self.app.notify("No data element selected to subscribe", title="Shop for Data", severity="warning") + await self.handle_shop_for_data_option() + return + + target_subscription_item = element_guid or element_qname or self.selected_item + self.app.log(f"Direct subscription for item: {target_subscription_item} ({element_name})") + + push_res = self.app.push_screen( + CreateSubscriptionRequestScreen(self.selected_item), + callback=self.create_subscription_callback, + ) + if asyncio.iscoroutine(push_res): + await push_res + + async def request_to_sample_data_source( self, selection_parm_1: Any, @@ -783,26 +933,19 @@ async def request_to_sample_data_source( if row_values and isinstance(row_values, (list, tuple)) and len(row_values) > 0: item_content = list(row_values) else: - # Fallback to tables stored on self or self.app or via query_one + # Fallback to query_one on app or active screen table_obj = None if self.data_table_highlighted: - table_attr_name = str(self.data_table_highlighted).lstrip("#") - table_obj = getattr(self, table_attr_name, None) or getattr(self.app, table_attr_name, None) - if table_obj is None and hasattr(self.app, "query_one"): - try: - table_obj = self.app.query_one("#" + table_attr_name, DataTable) - except Exception: - try: - table_obj = self.app.query_one("#" + table_attr_name) - except Exception: - table_obj = None - if table_obj is None and hasattr(self, "query_one"): + table_id = f"#{str(self.data_table_highlighted).lstrip('#')}" + try: + table_obj = self.app.query_one(table_id, DataTable) + except Exception: try: - table_obj = self.query_one("#" + table_attr_name) + table_obj = self.app.screen.query_one(table_id, DataTable) except Exception: table_obj = None - if table_obj and (hasattr(table_obj, "row_count") or hasattr(table_obj, "get_row")): + if table_obj: try: if self.row_highlighted is not None and hasattr(table_obj, "get_row"): item_content = list(table_obj.get_row(self.row_highlighted)) @@ -816,16 +959,24 @@ async def request_to_sample_data_source( element_name = "Selected Data Element" element_qname = "" element_desc = "" + element_guid = "" if item_content and len(item_content) > 0: if self.data_table_highlighted in ["glossary_table", "digital_product_catalog_table", "data_dictionary_table"]: element_name = str(item_content[0]) if len(item_content) > 0 else "" element_desc = str(item_content[1]) if len(item_content) > 1 else "" element_qname = str(item_content[2]) if len(item_content) > 2 else "" + element_guid = str(item_content[3]) if len(item_content) > 3 else "" else: element_name = str(item_content[0]) if len(item_content) > 0 else "" element_desc = str(item_content[1]) if len(item_content) > 1 else "" element_qname = str(item_content[0]) if len(item_content) > 0 else "" + element_guid = str(item_content[2]) if len(item_content) > 2 else "" + + if element_guid: + self.selected_item = element_guid + elif element_qname: + self.selected_item = element_qname is_placeholder = ( not item_content @@ -836,10 +987,7 @@ async def request_to_sample_data_source( if is_placeholder: self.app.log("No valid data element selected to sample") - if hasattr(self.app, "notify"): - self.app.notify("No data element selected to sample", title="Shop for Data", severity="warning") - elif hasattr(self, "notify"): - self.notify("No data element selected to sample", title="Shop for Data", severity="warning") + self.app.notify("No data element selected to sample", title="Shop for Data", severity="warning") await self.handle_shop_for_data_option() return @@ -903,7 +1051,7 @@ async def generic_data_view_callback(self, result: Any) -> None: element_qname = ( result[2] if len(result) > 2 and result[2] - else (result[1] if len(result) > 1 and result[1] else getattr(self, "selected_item", "")) + else (result[1] if len(result) > 1 and result[1] else self.selected_item) ) self.app.log(f"Subscribing to data element from sample view: {element_qname}") try: @@ -914,7 +1062,7 @@ async def generic_data_view_callback(self, result: Any) -> None: except Exception as e: self.app.log(f"Error creating digital subscription: {e}") self.app.notify(f"Error creating digital subscription: {e}") - await self.app.push_screen(CreateSubscriptionRequestScreen(), callback=self.create_subscription_callback) + await self.app.push_screen(CreateSubscriptionRequestScreen(self.selected_item), callback=self.create_subscription_callback) elif isinstance(result, int) and result == 210: self.show_main_screen() else: diff --git a/pyegeria/core/mcp_adapter.py b/pyegeria/core/mcp_adapter.py index 5355d73f..f05d0fcb 100644 --- a/pyegeria/core/mcp_adapter.py +++ b/pyegeria/core/mcp_adapter.py @@ -18,6 +18,7 @@ from pyegeria.view.base_report_formats import ( list_mcp_format_sets, select_report_spec, find_report_specs, + find_report_specs_by_perspective, find_report_specs_by_question, ) from pyegeria.egeria_tech_client import EgeriaTech from pyegeria.view.format_set_executor import exec_report_spec, _async_run_report @@ -48,6 +49,50 @@ def run_find_report_specs(perspective: str= None, question: str= None, report_sp raise ValueError(f"No report specs found for perspective '{perspective}' and question '{question}'") return {"Matching Report Specs" : report_specs} + +def run_find_report_specs_by_perspective(perspective: str, case_insensitive: bool = True) -> Dict[str, Any]: + """ + ISSUE-80: expose find_report_specs_by_perspective as an MCP tool. + + Find report specs whose question_spec includes the given perspective + (e.g. "which report specs does the Data Steward perspective care about"). + + Args: + perspective (str): The perspective to search for (e.g., "Data Steward"). + case_insensitive (bool): If True, compare perspectives case-insensitively. + + Returns: + dict: {"Matching Report Specs": [...]}, one entry per matching question_spec item. + """ + results = find_report_specs_by_perspective(perspective, case_insensitive=case_insensitive) + if not results: + raise ValueError(f"No report specs found for perspective '{perspective}'") + return {"Matching Report Specs": results} + + +def run_find_report_specs_by_question( + question: str, case_insensitive: bool = True, substring: bool = True +) -> Dict[str, Any]: + """ + ISSUE-80: expose find_report_specs_by_question as an MCP tool. + + Find report specs whose question_spec includes a matching example question + (e.g. "which report answers this question"). + + Args: + question (str): The question to search for. + case_insensitive (bool): If True, compare questions case-insensitively. + substring (bool): If True, treat `question` as a substring to match; otherwise require exact match. + + Returns: + dict: {"Matching Report Specs": [...]}, one entry per matching question_spec item. + """ + results = find_report_specs_by_question(question, case_insensitive=case_insensitive, substring=substring) + if not results: + raise ValueError(f"No report specs found for question '{question}'") + return {"Matching Report Specs": results} + + def describe_report(name: str, output_type: str = "DICT") -> Dict[str, Any]: """ Describe a format set for MCP discovery. If outputType != ANY, a concrete format @@ -66,12 +111,17 @@ def _execute_egeria_call_blocking( view_server: Optional[str] = None, view_url: Optional[str] = None, user: Optional[str] = None, - user_pass: Optional[str] = None,) -> Dict[str, Any]: + user_pass: Optional[str] = None, + token: Optional[str] = None,) -> Dict[str, Any]: """ Executes the synchronous, blocking Egeria client call on a dedicated worker thread. You must replace the hardcoded return with your actual Egeria client logic here. All code in this function runs in a blocking, synchronous manner. + + ISSUE-86: `token`, when given, is passed through to `exec_report_spec` so + the report runs as the bearer-token-holding caller rather than the + `user`/`user_pass` service account. """ print( @@ -90,6 +140,7 @@ def _execute_egeria_call_blocking( view_url=view_url if view_url is not None else _settings.Environment.egeria_view_server_url, user=user if user is not None else _settings.User_Profile.user_name, user_pass=user_pass if user_pass is not None else _settings.User_Profile.user_pwd, + token=token, ) # # Returning the hardcoded success for now to prove the async structure works. # return { @@ -108,10 +159,17 @@ def run_report( view_url: Optional[str] = None, user: Optional[str] = None, user_pass: Optional[str] = None, + token: Optional[str] = None, ) -> Dict[str, Any]: """ Execute a format set action as an MCP-style tool. Enforces DICT/ALL by default. Caller may pass credentials explicitly; otherwise defaults are used from config. + + ISSUE-86: pass `token` when the caller already holds a bearer token for + the calling user, so the report runs (and its provenance is recorded) + as that user instead of falling back to the `user`/`user_pass` service + account. `user`/`user_pass` remain fully backward compatible when + `token` is not given. """ print(f"Format set: {report}\nparams: {json.dumps(params)}\nview_server: {view_server}\nview_url: {view_url}\nuser: {user}\nuser_pass: {user_pass}", file=sys.stderr) # Lazy import of settings to avoid circulars when optional args are None @@ -125,6 +183,7 @@ def run_report( view_url=view_url if view_url is not None else _settings.Environment.egeria_view_server_url, user=user if user is not None else _settings.User_Profile.user_name, user_pass=user_pass if user_pass is not None else _settings.User_Profile.user_pwd, + token=token, ) async def _async_run_report_tool( diff --git a/pyegeria/core/mcp_server.py b/pyegeria/core/mcp_server.py index 7dbad2f7..efb7e3dc 100644 --- a/pyegeria/core/mcp_server.py +++ b/pyegeria/core/mcp_server.py @@ -26,7 +26,8 @@ list_reports, describe_report, run_report, _execute_egeria_call_blocking, - _async_run_report_tool, run_find_report_specs + _async_run_report_tool, run_find_report_specs, + run_find_report_specs_by_perspective, run_find_report_specs_by_question, ) print("MCP import successful...", file=sys.stderr) @@ -91,6 +92,18 @@ def find_report_specs_tool(perspective: str=None, question: str=None, report_spe logger.debug("Finding report specs...") return _ok(run_find_report_specs(perspective=perspective, question=question, report_spec=report_spec)) + @srv.tool(name="find_report_specs_by_perspective") + def find_report_specs_by_perspective_tool(perspective: str, case_insensitive: bool = True) -> Dict[str, Any]: + """Finds report specs whose question_spec includes the given perspective (e.g. "Data Steward").""" + logger.debug("Finding report specs by perspective...") + return _ok(run_find_report_specs_by_perspective(perspective, case_insensitive=case_insensitive)) + + @srv.tool(name="find_report_specs_by_question") + def find_report_specs_by_question_tool(question: str, case_insensitive: bool = True, substring: bool = True) -> Dict[str, Any]: + """Finds report specs whose question_spec includes a matching example question.""" + logger.debug("Finding report specs by question...") + return _ok(run_find_report_specs_by_question(question, case_insensitive=case_insensitive, substring=substring)) + @srv.tool(name="describe_report") def describe_report_tool(name: str, output_type: Literal["DICT", "JSON", "MARKDOWN"] = "DICT") -> Dict[str, Any]: diff --git a/pyegeria/omvs/classification_explorer.py b/pyegeria/omvs/classification_explorer.py index 3c15dada..472d2d9f 100644 --- a/pyegeria/omvs/classification_explorer.py +++ b/pyegeria/omvs/classification_explorer.py @@ -8264,7 +8264,7 @@ async def _async_add_ownership_to_element( { "class" : "NewClassificationRequestBody", "properties" : { - "class" : "OwnerProperties", + "class" : "OwnershipProperties", "owner" : "Add value here", "ownerTypeName" : "Add value here", "ownerPropertyName" : "Add value here" @@ -8321,7 +8321,7 @@ def add_ownership_to_element( { "class" : "NewClassificationRequestBody", "properties" : { - "class" : "OwnerProperties", + "class" : "OwnershipProperties", "owner" : "Add value here", "ownerTypeName" : "Add value here", "ownerPropertyName" : "Add value here" diff --git a/pyegeria/view/base_report_formats.py b/pyegeria/view/base_report_formats.py index 760cbc99..0210721e 100644 --- a/pyegeria/view/base_report_formats.py +++ b/pyegeria/view/base_report_formats.py @@ -2352,6 +2352,7 @@ def combine_format_set_dicts(dict1: dict, dict2: dict) -> dict: Format( types=["DICT", "TABLE", "LIST", "MD", "FORM", "REPORT"], attributes=COMMON_COLUMNS + [ + Column(name="GUID", key='GUID'), Column(name="Containing Members", key='collection_members'), Column(name="Member Of", key='member_of_collections') ] diff --git a/pyegeria/view/format_set_executor.py b/pyegeria/view/format_set_executor.py index ff6a5387..02f955a2 100644 --- a/pyegeria/view/format_set_executor.py +++ b/pyegeria/view/format_set_executor.py @@ -163,11 +163,22 @@ def run_analytic_action( def _run_analytic_function( action: dict, *, params: Dict[str, Any], view_server: str, view_url: str, user: str, user_pass: str, + token: Optional[str] = None, ) -> Any: """Resolve and call a report spec action's `analytic_function` (extra_find), returning the function's raw result -- no chart-wrapping, no output_format handling. Shared by `_exec_analytic_series` (SERIES/chart path) and - `exec_report_spec`'s analytic-only passthrough (DICT/JSON/etc. path).""" + `exec_report_spec`'s analytic-only passthrough (DICT/JSON/etc. path). + + ISSUE-86: when `token` is given, the client is authenticated with it + directly (`set_bearer_token`) instead of minting a fresh token via + `create_egeria_bearer_token()` -- lets a caller that already holds a + bearer token for the calling user (e.g. Egeria Advisor's app JWT, which + carries the user's Egeria token and no longer the password) run a report + as that user instead of falling back to the `user`/`user_pass` service + account. Backward compatible: `user`/`user_pass` keep working exactly as + before when no token is given. + """ func_decl = action.get("analytic_function") if not func_decl: raise ValueError("Report spec action has no analytic_function (extra_find).") @@ -186,8 +197,12 @@ def _run_analytic_function( call_params.update({k: v for k, v in params.items() if v not in (None, "")}) func = _resolve_analytic_function(func_decl) - client = EgeriaTech(view_server, view_url, user_id=user, user_pwd=user_pass) - client.create_egeria_bearer_token() + if token: + client = EgeriaTech(view_server, view_url, user_id=user, user_pwd=user_pass) + client.set_bearer_token(token) + else: + client = EgeriaTech(view_server, view_url, user_id=user, user_pwd=user_pass) + client.create_egeria_bearer_token() return func(*_bind_client_args(func, client), **call_params) @@ -577,6 +592,7 @@ def _exec_analytic_chart( view_url: str, user: str, user_pass: str, + token: Optional[str] = None, ) -> Dict[str, Any]: """Run a report spec's `analytic_function` (extra_find) and wrap the result as a Vega-Lite chart -- `chart_kind` picks which: @@ -611,7 +627,7 @@ def _exec_analytic_chart( result = _run_analytic_function( action, params=params, view_server=view_server, view_url=view_url, - user=user, user_pass=user_pass, + user=user, user_pass=user_pass, token=token, ) if not result: return {"kind": "empty"} @@ -692,6 +708,7 @@ def exec_report_spec( view_url: str = settings.Environment.egeria_view_server_url, user: str = settings.User_Profile.user_name, user_pass: str = settings.User_Profile.user_pwd, + token: Optional[str] = None, ) -> Dict[str, Any]: """ Execute the action for a given format set and return a normalized result structure. @@ -701,6 +718,15 @@ def exec_report_spec( - {"kind":"json","data": } - {"kind":"text","mime": "text/markdown"|"text/html","content": str} - {"kind":"unknown","raw": any} + + ISSUE-86: `token`, when given, authenticates the client(s) built here with + `set_bearer_token(token)` instead of minting a fresh token from + `user`/`user_pass` via `create_egeria_bearer_token()`. Use this when the + caller already holds a bearer token for the calling user (e.g. an app + whose session carries the user's Egeria token, not their password) so + report execution -- and its provenance -- reflects that user, not a + fallback service account. `user`/`user_pass` remain fully backward + compatible when `token` is not given. """ output_format = (output_format or "DICT").upper() params = _normalize_report_params(dict(params or {}), action_mode="find") @@ -716,6 +742,7 @@ def exec_report_spec( return _exec_analytic_chart( format_set_name, chart_kind=_CHART_KINDS[output_format], params=params, view_server=view_server, view_url=view_url, user=user, user_pass=user_pass, + token=token, ) # Resolve the format set and action @@ -777,7 +804,7 @@ def exec_report_spec( if action.get("analytic_function") and not func_decl: result = _run_analytic_function( action, params=params, view_server=view_server, view_url=view_url, - user=user, user_pass=user_pass, + user=user, user_pass=user_pass, token=token, ) if result is None or result == [] or result == {}: return {"kind": "empty"} @@ -814,7 +841,10 @@ def exec_report_spec( client = client_class(view_server, view_url, user_id=user, user_pwd=user_pass) try: - client.create_egeria_bearer_token() + if token: + client.set_bearer_token(token) + else: + client.create_egeria_bearer_token() func = getattr(client, method_name) if method_name and hasattr(client, method_name) else None if func is None: raise AttributeError( diff --git a/pyproject.toml b/pyproject.toml index 874d9df6..7e46dc3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ [project] name = "pyegeria" -version = "6.1.9" +version = "6.1.10" license = 'Apache-2.0' license-files = ["LICENSE"] authors = [{name = "Dan Wolfson", email = "dan.wolfson@pdr-associates.com"},] @@ -24,7 +24,7 @@ dependencies = [ "urllib3", "requests", "jupyter", - "click==8.3.1", + "click==8.5.0", "trogon", # "mermaid-py", "psycopg2-binary>=2.9.11", @@ -35,7 +35,7 @@ dependencies = [ "pydantic-settings >= 2.10.1", "pydevd-pycharm >=253.27642.35", "wcwidth", - "altair==6.0.0", + "altair==6.2.2", "mcp >=0.1", "markers>=0.3.0", "pytest-asyncio>=1.2.0", diff --git a/tests/micro-tests/my_profile/test_screens.py b/tests/micro-tests/my_profile/test_screens.py index ec993554..b98cc09c 100644 --- a/tests/micro-tests/my_profile/test_screens.py +++ b/tests/micro-tests/my_profile/test_screens.py @@ -216,7 +216,7 @@ class TestCreateSubscriptionRequestScreen: @pytest.mark.asyncio async def test_create_subscription_request_screen(self): - app = ScreenTestHostApp(lambda: CreateSubscriptionRequestScreen()) + app = ScreenTestHostApp(lambda: CreateSubscriptionRequestScreen(selected_item="guid-item-123")) async with app.run_test() as pilot: inp = app.target_screen.query_one("#sub_display_name", Input) inp.value = "My Sub" @@ -225,7 +225,12 @@ async def test_create_subscription_request_screen(self): await pilot.pause() app.target_screen.action_create_subscription() await pilot.pause() - assert app.dismissed_result == ["My Sub", "Status: ACTIVE"] + assert app.dismissed_result == { + "externalSourceGUID": "guid-item-123", + "guid": "guid-item-123", + "displayName": "My Sub", + "Status": "ACTIVE", + } class TestAddToElementsScreens: @@ -396,6 +401,22 @@ async def test_shop_for_data_screen(self): await pilot.pause() assert app.dismissed_result == [210] + @pytest.mark.asyncio + async def test_shop_for_data_screen_catalog_selection(self): + t2 = DataTable(id="digital_product_catalog_table") + app = ScreenTestHostApp(lambda: ShopForDataScreen(digital_product_catalog_table=t2)) + async with app.run_test() as pilot: + target = app.target_screen.query_one("#digital_product_catalog_table", DataTable) + target.add_columns("Name", "Desc", "QN", "GUID") + row_key = target.add_row("Prod1", "Desc1", "Cat::Prod1", "guid-prod-999") + target.move_cursor(row=0) + await pilot.pause() + app.target_screen.handle_digital_product_catalog_table_selection( + DataTable.RowSelected(target, row_key=row_key, cursor_row=0) + ) + await pilot.pause() + assert app.dismissed_result == ["catalog", "Cat::Prod1", "Prod1", "guid-prod-999"] + @pytest.mark.asyncio async def test_shop_for_data_screen_default_config(self): app = ScreenTestHostApp(lambda: ShopForDataScreen()) @@ -403,3 +424,89 @@ async def test_shop_for_data_screen_default_config(self): app.target_screen.action_quit() await pilot.pause() assert app.dismissed_result == [210] + + @pytest.mark.asyncio + async def test_shop_for_data_screen_subscribe_action(self): + t2 = DataTable(id="digital_product_catalog_table") + app = ScreenTestHostApp(lambda: ShopForDataScreen(digital_product_catalog_table=t2)) + async with app.run_test() as pilot: + target = app.target_screen.query_one("#digital_product_catalog_table", DataTable) + target.add_columns("Name", "Desc", "QN", "GUID") + row_key = target.add_row("Prod1", "Desc1", "Cat::Prod1", "guid-prod-999") + target.move_cursor(row=0) + app.target_screen.data_table_highlighted = "digital_product_catalog_table" + app.target_screen.row_highlighted = row_key + app.target_screen.cursor_row_highlighted = 0 + await pilot.pause() + + app.target_screen.action_subscribe_to_data_source() + await pilot.pause() + assert app.dismissed_result is not None + assert app.dismissed_result[0] == 211 + assert app.dismissed_result[4] == ["Prod1", "Desc1", "Cat::Prod1", "guid-prod-999"] + + +class TestMainScreen: + """Tests for MainScreen table selection and edit actions.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "table_id", + [ + "roles_table", + "teams_table", + "blogs_table", + "journal_table", + "todos_table", + "user_identity_table", + "associations_table", + "my_collections_table", + ], + ) + async def test_main_screen_edit_selected_table(self, table_id): + """Verify clicking any DataTable selects it and edit action targets that table.""" + class MockMainHostApp(App): + def __init__(self): + super().__init__() + self.edited_calls = [] + + async def on_mount(self): + self.main_screen = MainScreen() + self.install_screen(self.main_screen, name="main") + await self.push_screen("main") + + def edit_tables(self, table_name, row_k): + self.edited_calls.append((table_name, row_k)) + + app = MockMainHostApp() + async with app.run_test() as pilot: + main_screen = app.main_screen + + # Populate tables with sample data + for t_name in [ + "roles_table", + "teams_table", + "blogs_table", + "journal_table", + "todos_table", + "user_identity_table", + "associations_table", + "my_collections_table", + ]: + t = main_screen.query_one(f"#{t_name}", DataTable) + t.cursor_type = "row" + t.add_columns("Col1", "Col2") + t.add_row("val1", "val2", key=f"{t_name}_row_1") + + await pilot.pause() + + # Click on target table + await pilot.click(f"#{table_id}") + await pilot.pause() + + # Trigger edit table hotkey + await pilot.press("ctrl+t") + await pilot.pause() + + assert len(app.edited_calls) == 1 + assert app.edited_calls[0][0] == table_id diff --git a/tests/micro-tests/my_profile/test_shop_for_data_handler.py b/tests/micro-tests/my_profile/test_shop_for_data_handler.py index c995f106..10abbc61 100644 --- a/tests/micro-tests/my_profile/test_shop_for_data_handler.py +++ b/tests/micro-tests/my_profile/test_shop_for_data_handler.py @@ -97,6 +97,14 @@ async def test_handle_shop_for_data_option_success(self, mock_exec, mock_table_c assert isinstance(screen, ShopForDataScreen) assert cb == app.shop_for_data_callback + # Verify that Digital-Product-Catalog-MyE was called with params={"search_string": "*"} + catalog_calls = [ + call for call in mock_exec.call_args_list + if call.kwargs.get("format_set_name") == "Digital-Product-Catalog-MyE" + ] + assert len(catalog_calls) == 1 + assert catalog_calls[0].kwargs.get("params") == {"search_string": "*"} + @pytest.mark.asyncio @patch("shop_for_data_handler.DataTable") @patch("shop_for_data_handler.exec_report_spec") @@ -254,24 +262,16 @@ def test_overview_callback_error_codes(self): assert len(app.pushed_screens) == 1 assert isinstance(app.pushed_screens[0][0], StatusScreen) - @patch("shop_for_data_handler.ProductManager") - def test_overview_callback_subscribe_success(self, mock_pm_cls): + def test_overview_callback_subscribe_success(self): app = DummyShopApp() - mock_pm = MagicMock() - mock_pm.create_egeria_bearer_token.return_value = "token" - mock_pm_cls.return_value = mock_pm - app.overview_callback([211, "Item1", "Tree1"]) - mock_pm.create_digital_subscription.assert_called_once_with("Item1") + assert len(app.pushed_screens) == 1 + screen, cb = app.pushed_screens[0] + assert isinstance(screen, CreateSubscriptionRequestScreen) + assert cb == app.create_subscription_callback - @patch("shop_for_data_handler.ProductManager") - def test_overview_callback_subscribe_failure(self, mock_pm_cls): + def test_overview_callback_subscribe_failure(self): app = DummyShopApp() - mock_pm = MagicMock() - mock_pm.create_egeria_bearer_token.return_value = "token" - mock_pm.create_digital_subscription.side_effect = PyegeriaException("Subscription failed") - mock_pm_cls.return_value = mock_pm - app.overview_callback([211, "Item1", "Tree1"]) assert len(app.pushed_screens) == 1 screen, cb = app.pushed_screens[0] @@ -286,3 +286,71 @@ def test_create_subscription_callback_success(self): app = DummyShopApp() app.create_subscription_callback("Sub-Result-123") assert any("Subscription created" in msg for msg in app.log_messages) + + def test_create_subscription_callback_dict_with_guid(self): + app = DummyShopApp() + app.selected_item = "fallback-guid" + app.create_subscription_callback({ + "externalSourceGUID": "item-guid-123", + "displayName": "Test Sub", + "Status": "ACTIVE", + "description": "Test Desc", + "identifier": "TS1", + }) + assert any("Subscription created" in msg for msg in app.log_messages) + + @pytest.mark.asyncio + @patch("shop_for_data_handler.ProductManager") + async def test_shop_for_data_callback_direct_subscribe(self, mock_pm_cls): + app = DummyShopApp() + res = await app.shop_for_data_callback([ + 211, + "row1", + 0, + "digital_product_catalog_table", + ["Prod Name", "Prod Desc", "Prod::QN", "guid-prod-123"], + ]) + assert res == 211 + assert len(app.pushed_screens) == 1 + screen, cb = app.pushed_screens[0] + assert isinstance(screen, CreateSubscriptionRequestScreen) + assert cb == app.create_subscription_callback + assert app.selected_item == "guid-prod-123" + + @pytest.mark.asyncio + @patch("shop_for_data_handler.ProductManager") + async def test_request_to_subscribe_data_source_placeholder(self, mock_pm_cls): + app = DummyShopApp() + app.handle_shop_for_data_option = AsyncMock() + await app.request_to_subscribe_data_source( + "row1", + 0, + "digital_product_catalog_table", + row_values=["No digital product catalogs found", "No data returned from Egeria", "", ""], + ) + assert any("No valid data element selected to subscribe" in msg for msg in app.log_messages) + assert app.handle_shop_for_data_option.called + + @patch("shop_for_data_handler.ProductManager") + def test_create_subscription_callback_creates_subscription_with_client(self, mock_pm_cls): + mock_client = MagicMock() + mock_pm_cls.return_value = mock_client + mock_client.create_digital_subscription.return_value = "created-sub-guid" + + app = DummyShopApp() + app.selected_item = "guid-prod-123" + app.create_subscription_callback({ + "displayName": "My Sub", + "description": "Sub Desc", + "Status": "ACTIVE", + "identifier": "MS-01", + "externalSourceGUID": "guid-prod-123", + }) + + assert mock_client.create_digital_subscription.called + call_args = mock_client.create_digital_subscription.call_args[0][0] + assert call_args["class"] == "NewAgreementRequestBody" + assert call_args["initialStatus"] == "ACTIVE" + assert call_args["externalSourceGUID"] == "guid-prod-123" + assert call_args["properties"]["displayName"] == "My Sub" + assert any("Created digital subscription successfully" in msg for msg in app.log_messages) diff --git a/tests/micro-tests/test_exec_report_spec_token.py b/tests/micro-tests/test_exec_report_spec_token.py new file mode 100644 index 00000000..b3fbd145 --- /dev/null +++ b/tests/micro-tests/test_exec_report_spec_token.py @@ -0,0 +1,97 @@ +"""ISSUE-86: exec_report_spec (and _exec_analytic_chart, _run_analytic_function) +should authenticate with a caller-supplied bearer token via set_bearer_token() +instead of always minting a fresh one via create_egeria_bearer_token() when a +token is available -- e.g. Egeria Advisor, whose session carries the user's +Egeria token and no longer their password. + +Unit-level: monkeypatches EgeriaTech inside format_set_executor with a fake +that just records which auth path was taken -- no live server required. +""" +import pytest + +from pyegeria.view import format_set_executor as fse + + +class _FakeEgeriaTech: + """Records how it was authenticated; raises if create_egeria_bearer_token() + is called when a token was supplied (that would mean the token path was + bypassed and a fresh token minted from user/user_pass instead).""" + + def __init__(self, view_server=None, view_url=None, user_id=None, user_pwd=None, **kwargs): + self.view_server = view_server + self.view_url = view_url + self.user_id = user_id + self.user_pwd = user_pwd + self.set_bearer_token_calls = [] + self.create_egeria_bearer_token_calls = 0 + + def set_bearer_token(self, token): + self.set_bearer_token_calls.append(token) + + def create_egeria_bearer_token(self): + self.create_egeria_bearer_token_calls += 1 + + +@pytest.fixture +def fake_egeria_tech(monkeypatch): + instances = [] + + def factory(*args, **kwargs): + inst = _FakeEgeriaTech(*args, **kwargs) + instances.append(inst) + return inst + + monkeypatch.setattr(fse, "EgeriaTech", factory) + return instances + + +def _dummy_analytic_action(): + def _dummy(client): + return {"count": 1} + return {"analytic_function": _dummy, "analytic_spec_params": {}} + + +def test_run_analytic_function_uses_token_when_given(fake_egeria_tech, monkeypatch): + """When a token is supplied, set_bearer_token(token) is used -- not + create_egeria_bearer_token().""" + monkeypatch.setattr( + fse, "_resolve_analytic_function", lambda decl: decl + ) + monkeypatch.setattr( + fse, "_bind_client_args", lambda func, client: (client,) + ) + + action = _dummy_analytic_action() + result = fse._run_analytic_function( + action, params={}, view_server="vs", view_url="url", + user="svc-user", user_pass="svc-pass", token="caller-token-123", + ) + + assert result == {"count": 1} + assert len(fake_egeria_tech) == 1 + client = fake_egeria_tech[0] + assert client.set_bearer_token_calls == ["caller-token-123"] + assert client.create_egeria_bearer_token_calls == 0 + + +def test_run_analytic_function_mints_token_when_none_given(fake_egeria_tech, monkeypatch): + """Backward compatible: with no token, the old create_egeria_bearer_token() + path from user/user_pass is used unchanged.""" + monkeypatch.setattr( + fse, "_resolve_analytic_function", lambda decl: decl + ) + monkeypatch.setattr( + fse, "_bind_client_args", lambda func, client: (client,) + ) + + action = _dummy_analytic_action() + result = fse._run_analytic_function( + action, params={}, view_server="vs", view_url="url", + user="svc-user", user_pass="svc-pass", + ) + + assert result == {"count": 1} + assert len(fake_egeria_tech) == 1 + client = fake_egeria_tech[0] + assert client.set_bearer_token_calls == [] + assert client.create_egeria_bearer_token_calls == 1 diff --git a/tests/micro-tests/test_format_set_executor_token.py b/tests/micro-tests/test_format_set_executor_token.py new file mode 100644 index 00000000..d4ea5360 --- /dev/null +++ b/tests/micro-tests/test_format_set_executor_token.py @@ -0,0 +1,161 @@ +"""ISSUE-86: `exec_report_spec(..., token=...)` must authenticate the client it +builds with `set_bearer_token(token)` instead of minting one from +user/user_pass, on BOTH client-building paths (the format-row/find path and +the analytic_function path). With no token, behaviour is unchanged +(`create_egeria_bearer_token()`).""" +import pytest + +from pyegeria.view import format_set_executor as fse +from pyegeria.core import mcp_adapter + + +class _RecordingClient: + """Stands in for any OMVS client class the executor instantiates.""" + instances: list = [] + + def __init__(self, view_server, view_url, user_id=None, user_pwd=None, **kw): + self.view_server = view_server + self.platform_url = view_url + self.user_id = user_id + self.user_pwd = user_pwd + self.token = None + self.calls: list[str] = [] + _RecordingClient.instances.append(self) + + def create_egeria_bearer_token(self, *a, **kw): + self.calls.append("create") + self.token = "minted" + return self.token + + def set_bearer_token(self, token): + self.calls.append("set") + self.token = token + + def find_things(self, **kwargs): + return [{"guid": "1", "displayName": "x"}] + + +@pytest.fixture(autouse=True) +def _reset_instances(): + _RecordingClient.instances = [] + yield + _RecordingClient.instances = [] + + +_FIND_FMT = { + "action": { + "function": "Fake.find_things", + "required_params": [], + "optional_params": [], + "spec_params": {}, + }, + "target_type": "Referenceable", +} + +_ANALYTIC_FMT = { + "action": {"analytic_function": "fake.analytic", "analytic_spec_params": {}}, + "target_type": "Referenceable", +} + + +def _patch_find_path(monkeypatch): + monkeypatch.setattr(fse, "select_report_spec", lambda name, out: _FIND_FMT) + monkeypatch.setattr(fse, "get_report_registry", lambda: {}) + monkeypatch.setattr(fse, "_resolve_client_and_method", + lambda decl: (_RecordingClient, "find_things")) + + +def _patch_analytic_path(monkeypatch): + def _analytic(client): + return {"count": 3, "token_seen": client.token} + monkeypatch.setattr(fse, "select_report_spec", lambda name, out: _ANALYTIC_FMT) + monkeypatch.setattr(fse, "get_report_registry", lambda: {}) + monkeypatch.setattr(fse, "_resolve_analytic_function", lambda decl: _analytic) + monkeypatch.setattr(fse, "EgeriaTech", _RecordingClient) + + +# --- format-row (find_method) path ------------------------------------------- + +def test_find_path_with_token_uses_set_bearer_token(monkeypatch): + _patch_find_path(monkeypatch) + result = fse.exec_report_spec( + "Anything", output_format="DICT", view_server="vs", view_url="https://x", + user="svc", user_pass="svc-pw", token="user-token", + ) + assert result["kind"] == "json" + (client,) = _RecordingClient.instances + assert client.calls == ["set"] + assert client.token == "user-token" + + +def test_find_path_without_token_mints_bearer_token(monkeypatch): + _patch_find_path(monkeypatch) + result = fse.exec_report_spec( + "Anything", output_format="DICT", view_server="vs", view_url="https://x", + user="svc", user_pass="svc-pw", + ) + assert result["kind"] == "json" + (client,) = _RecordingClient.instances + assert client.calls == ["create"] + assert client.user_id == "svc" and client.user_pwd == "svc-pw" + + +# --- analytic_function path --------------------------------------------------- + +def test_analytic_path_with_token_uses_set_bearer_token(monkeypatch): + _patch_analytic_path(monkeypatch) + result = fse.exec_report_spec( + "Anything", output_format="DICT", view_server="vs", view_url="https://x", + user="svc", user_pass="svc-pw", token="user-token", + ) + assert result == {"kind": "json", "data": {"count": 3, "token_seen": "user-token"}} + (client,) = _RecordingClient.instances + assert client.calls == ["set"] + + +def test_analytic_path_without_token_mints_bearer_token(monkeypatch): + _patch_analytic_path(monkeypatch) + result = fse.exec_report_spec( + "Anything", output_format="DICT", view_server="vs", view_url="https://x", + user="svc", user_pass="svc-pw", + ) + assert result["data"]["token_seen"] == "minted" + (client,) = _RecordingClient.instances + assert client.calls == ["create"] + + +def test_chart_path_threads_token(monkeypatch): + """SERIES/BAR/PIE dispatch to _exec_analytic_chart before the Format-row + lookup -- the token must survive that hop too.""" + _patch_analytic_path(monkeypatch) + monkeypatch.setattr(fse, "get_report_spec_heading", lambda name: "H", raising=False) + fse.exec_report_spec( + "Anything", output_format="BAR", view_server="vs", view_url="https://x", + user="svc", user_pass="svc-pw", token="user-token", + ) + (client,) = _RecordingClient.instances + assert client.calls == ["set"] + assert client.token == "user-token" + + +# --- MCP adapter passthrough -------------------------------------------------- + +def test_mcp_run_report_forwards_token(monkeypatch): + seen = {} + + def _fake_exec(**kwargs): + seen.update(kwargs) + return {"kind": "empty"} + + monkeypatch.setattr(mcp_adapter, "exec_report_spec", _fake_exec) + mcp_adapter.run_report(report="R", user="u", user_pass="p", token="tok") + assert seen["token"] == "tok" + assert seen["user"] == "u" and seen["user_pass"] == "p" + + +def test_mcp_run_report_default_token_is_none(monkeypatch): + seen = {} + monkeypatch.setattr(mcp_adapter, "exec_report_spec", + lambda **kw: seen.update(kw) or {"kind": "empty"}) + mcp_adapter.run_report(report="R", user="u", user_pass="p") + assert seen["token"] is None diff --git a/uv.lock b/uv.lock index 9680caee..de631c73 100644 --- a/uv.lock +++ b/uv.lock @@ -153,7 +153,7 @@ wheels = [ [[package]] name = "altair" -version = "6.0.0" +version = "6.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, @@ -162,9 +162,9 @@ dependencies = [ { name = "packaging" }, { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/c0/184a89bd5feba14ff3c41cfaf1dd8a82c05f5ceedbc92145e17042eb08a4/altair-6.0.0.tar.gz", hash = "sha256:614bf5ecbe2337347b590afb111929aa9c16c9527c4887d96c9bc7f6640756b4", size = 763834, upload-time = "2025-11-12T08:59:11.519Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/a1/5e6cc638a66da48cfc89a79c2f4810dfec00b63385f9b009ab1f069779bb/altair-6.2.2.tar.gz", hash = "sha256:a1ff9d9cfe81c75414641826312b9471780e19d39293ba0b012933f6b6cba0fe", size = 766606, upload-time = "2026-06-23T12:47:13.384Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/33/ef2f2409450ef6daa61459d5de5c08128e7d3edb773fefd0a324d1310238/altair-6.0.0-py3-none-any.whl", hash = "sha256:09ae95b53d5fe5b16987dccc785a7af8588f2dca50de1e7a156efa8a461515f8", size = 795410, upload-time = "2025-11-12T08:59:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/e3/99/d6031f4f146298951c46b1bf1cc160c2a63f6e44b3c13a30054add100d5f/altair-6.2.2-py3-none-any.whl", hash = "sha256:94014f8ad8617c3cb163d1137359cd6db5ba134b9b46d93cfd8b609fd245a583", size = 797613, upload-time = "2026-06-23T12:47:11.451Z" }, ] [[package]] @@ -556,14 +556,11 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] [[package]] @@ -2329,7 +2326,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/f8/f6/8f49005d581cff883 [[package]] name = "pyegeria" -version = "6.1.9" +version = "6.1.10" source = { editable = "." } dependencies = [ { name = "altair" }, @@ -2380,8 +2377,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "altair", specifier = "==6.0.0" }, - { name = "click", specifier = "==8.3.1" }, + { name = "altair", specifier = "==6.2.2" }, + { name = "click", specifier = "==8.5.0" }, { name = "fastapi", marker = "extra == 'spec-editor'", specifier = ">=0.115" }, { name = "httpx" }, { name = "inflect" },