diff --git a/BACKLOG.md b/BACKLOG.md index d419f13f..a726f0ac 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -16,6 +16,8 @@ Once the Egeria release with the delete fixes is published and the local quickst 2. **Other delete issues** to re-check at the same time: ISSUE-117 (Memento cascade delete), ISSUE-125 (`deleteEnumDef`) and ISSUE-132 (`cascadedDelete` field name). Also ISSUE-147, the valid-metadata clears: re-run the ISSUE-147 sequence (setup then clear for a value, a map name and a map value on a made-up property). Expect no 400-011 error after a successful clear, and expect the map value to actually be removed. 3. Update each `PYEGERIA_ISSUES.md` entry with the result, then do the held 6.2.3 release. The owner wants outstanding issues fixed before releasing. + **The 6.2.3 release notes must list this behaviour change:** ISSUE-148. `AssetMaker.find_processes`, `get_processes_by_category`, `get_action_targets`, `get_actions_for_action_target`, `get_assigned_actions`, `get_actions_for_sponsor` and `get_actions_for_requester` now default `activity_status_list` to `None` (no filter) instead of `["IN_PROGRESS"]`. A caller that relied on getting only in-progress actions by default now also gets completed, cancelled and status-less actions (e.g. journal entries), so it should pass `["IN_PROGRESS"]` explicitly. + **Also list this API removal:** ISSUE-151. `AutomatedCuration.get_engine_actions()` is removed, because the View Server has no list-all endpoint for it (it always got a 404). Use `find_engine_actions("*")` instead. --- diff --git a/PYEGERIA_ISSUES.md b/PYEGERIA_ISSUES.md index 9e27dce0..53be9415 100644 --- a/PYEGERIA_ISSUES.md +++ b/PYEGERIA_ISSUES.md @@ -151,10 +151,8 @@ content was changed. The numbered section list above describes the **At a glance** — 7 open pyegeria items, 7 awaiting re-test, 7 open Egeria Server issues. **Open pyegeria items** +- ISSUE-150: Dr.Egeria commands for an Analytic Function's Egeria declaration: a GovernanceActionType Resource Explorer runs as an engine host, plus a synchronous path that bypasses Egeria (direction agreed, not built) - ISSUE-146: Deleting a journal entry fails with OMAG-REPOSITORY-HANDLER-400-008, and pyegeria's `remove_note` reaches the delete only through the generic Asset Maker service -- ISSUE-140: The executor charts any dict with two or more top-level numbers, regardless of the result's state -- ISSUE-139: The executor renders no result envelope, and the Vega bar chart has no subtitle, so a not-established result has nowhere to say so on a chart -- ISSUE-138: Analytic params are JSON-only, and only parameters named `mgr`/`ce`/`expert`/`client` are bound as clients, so a routine taking a reader object can't be named by its own dotted path - ISSUE-132: `DeleteElementRequestBody` serializes the cascade flag as `cascadeDelete`; Egeria's field is `cascadedDelete` and unknown fields are ignored, so no pyegeria cascade delete has ever cascaded - ISSUE-127: `CollectionManager.delete_collection(cascade=True)` silently never sends `cascadeDelete` -- same shape as ISSUE-62 @@ -188,6 +186,75 @@ into the entry now, so it isn't rediscovered from scratch later. --- + +### ISSUE-150: Dr.Egeria commands for an Analytic Function's Egeria declaration: a GovernanceActionType that Resource Explorer runs as an engine host, plus a synchronous path that bypasses Egeria + +**Layer:** Dr.Egeria (commands over existing Action Author / governance commands) · **Status:** open, direction agreed with the owner 2026-10-13 ("a good start; we may need to review and change"), not yet built · **Found:** 2026-10-13, handed off by the Resource Explorer PR/CI session. Source: trellis `docs/extensibility-walking-skeleton-brief.md` §2c, §3a, §8. Egeria references: [Governance Action Processes (0462)](https://egeria-project.org/types/4/0462-Governance-Action-Processes/), [Survey Action Engine](https://egeria-project.org/concepts/survey-action-engine/). + +**Background (Egeria's model):** +- A `GovernanceActionType` is a template for **one** action. Initiating it creates one Engine Action. +- A `GovernanceActionProcessStep` is a subtype of `GovernanceActionType` that exists only inside a process. Its flow links (`GovernanceActionProcessFlow`, `NextGovernanceActionProcessStep`) carry guards and mandatory guards. Each step that fires becomes its own Engine Action. +- Both reach their code through `GovernanceActionExecutor` (a governance engine plus a `requestType`). +- Surveys use the `SurveyingActionProcess` subtype. Survey action services write annotations into a Survey Report linked to the asset. + +**Direction (owner, 2026-10-13):** use Egeria's designed governance-action infrastructure, not a bypass; it may need changing, but it should be used. Resource Explorer **is an engine host** and can present itself to Egeria as one. The owner's concern is timing: running an action or process is asynchronous and possibly long-running, while an analytic function often has to answer synchronously during a screen draw or report generation. So declaring a function is kept separate from running it: + +1. **Declaration (in Egeria):** a `GovernanceActionType` per function. + - A `GovernanceActionExecutor` links it to Resource Explorer's governance engine, with the function's name as the `requestType`. + - It carries the ISSUE-149 manifest fields: `kind`, `version`, `source`, `resource_types`, `annotation_type`, `family`, `tags`. + - It joins its family `Collection`. + - Retirement is a status change, never a delete. + + Within a survey it is used as a `GovernanceActionProcessStep` in a `SurveyingActionProcess`. A single run can be wrapped as a one-step process if a uniform "everything runs as a process" shape is wanted. +2. **Asynchronous run (through Egeria):** initiate the type, or a process containing it, and an Engine Action is created. Resource Explorer, as an engine host, claims it and runs it, using `engine-actions/{guid}/claim`, `status/update`, `completion-status` and the active-claimed listing. Results are stored as annotations carrying their envelope, and Egeria keeps the run record. This path is for surveys and anything slow or scheduled. Mandy's per-engine "approved, unclaimed" engine-action listing (2026-10-05, governance-context-service only so far; see ISSUE-151) is what such an engine host polls. +3. **Synchronous run (no Egeria round-trip):** a screen or report calls the installed implementation directly, as a report spec's `analytic_function` already does through `exec_report_spec` (ISSUE-137/138/139/140/149 made this path work for trellis routines). + - Egeria is not on the hot path; the declaration only identifies which function and version is meant. + - **Synchronous runs are not recorded in Egeria** (owner: lineage doesn't make sense for what is drawn on a screen). Diagnostic logging at most; still to discuss. + +**How a screen uses it:** for each function, one of three choices: +- compute now (cheap functions); +- read the last stored result (expensive functions; the envelope's `measured_at` says how old it is); +- request an asynchronous refresh that the next draw picks up. + +"Routine versus step" therefore becomes a property of each function (is it cheap enough to compute on read?), possibly a manifest field, not two kinds of element. + +**Keeping the two paths honest:** the code run synchronously must be the code the declaration names, at the same version, that Resource Explorer's engine runs. trellis's planned `trellis-analytics drift` (installed vs declared) checks this. The envelope's `producer_version` lets readers tell versions apart. + +**Dr.Egeria work this implies (to be confirmed with the RE sessions):** +- Mostly convenience over existing commands: `Create Governance Action Type`, `Link Action to Action Executor` (`GovernanceActionExecutor`), `Link First/Next Process Step`, and family-collection membership. +- Plus the manifest attributes. Where these live on the element (`additionalProperties`, or real properties if Egeria adds them) is still to decide. +- Possibly a `Create/Update/Retire Analytic Function` alias family that sets all of this in one document, which `trellis-analytics declare` would generate. And `View Analytic Functions [family]`. +- The Action Author processors (`ActionExecutorTargetLinkProcessor`, `ActionProcessStepLinkProcessor`) already handle the links. `register_governance_processors()` is family-name-gated, so a new family name must be added there. + +**Open points:** +- Whether Egeria needs changes, for example to let a synchronous-safe flag or the manifest fields be first-class properties. +- Whether a single run is always wrapped as a one-step process. +- Whether routines that are only ever computed synchronously need a declaration at all, beyond the Report element (`Create Report`). +- Diagnostic logging for synchronous runs. + + +**Resource Explorer's answers (RE PR/CI session, 2026-10-13):** +- **(a) The direction matches RE's engine-host design** (trellis branch `re/design-engine-host`, `packages/resource-explorer/docs/design-notes/DESIGN-RE-ENGINE-HOST-BY-EQUIVALENCE.md`). Its first slice (E1) is exactly the declaration above: an RE governance engine, a `SupportedGovernanceService` whose requestType names the trellis step, and a `GovernanceActionType` with a `GovernanceActionExecutor` to that engine plus `producedGuards`. RE claims and completes Engine Actions. + - Owner decisions recorded there: the engine runs as RE's service account. An action on a resource in a private investigation is left unclaimed by the unattended host and run attended, as the owner. RE may claim and hold future-dated actions. + - RE polls `get_active_engine_actions()` today, filtered client-side to its engine and APPROVED (or `find_engine_actions`, see ISSUE-151). Mandy's per-engine "approved, unclaimed" listing exists only on the metadata server's governance-context service, so RE is asking her to expose it on the View Server. + - Questions open with Mandy: the engine subtype (`SurveyActionEngine`?); whether a GovernanceService whose connector class nothing can load is acceptable; and where guards come from (standard survey guards, or declared by the step from the envelope). +- **(b) RE's survey definitions today** are Dr.Egeria `Create Governance Action Process` (`GovActionProcess::`) plus `Create Governance Action Process Step` (`GovActionProcessStep::::`), linked with First/Next Process Step. They are plain `GovernanceActionProcess`, not `SurveyingActionProcess`: Dr.Egeria's command has `OM_TYPE` `GovernanceActionProcess` and offers no subtype choice. The steps carry **no executor**; RE's own executor reads `executes_at`/`re_analysis_step`/`supported_technology_type` from `additionalProperties`. That is why Egeria can't dispatch any RE step today. Moving the steps onto an executor is part of RE's engine-host slice. +- **(c) Manifest fields go in `additionalProperties` for now,** with stable keys named exactly like the `AnalyticFunctionSpec` fields (`kind`, `source`, `version`, `envelope`, `annotation_type`, `resource_types`, `family`, `tags`). This matches the owner's interim ruling for annotations. Asking Egeria for real properties is a later item, once a few functions exist. +- **Synchronous runs not recorded:** agreed. RE's OpenLineage run events (branch `re/openlineage-run-events`, off by default) cover only step runs inside RE's survey executor, so a screen-draw run never reaches them. + +**Dr.Egeria gaps this exposes (part of this issue's work):** +1. `Create Governance Action Process` should be able to create the process subtypes (`SurveyingActionProcess`, `CataloguingActionProcess`, ...), e.g. via a `Process Type` attribute validated against the 0462 subtypes. Today it always creates the plain supertype. +2. A process step needs its `GovernanceActionExecutor` link and its manifest `additionalProperties`. Check that `Link Action to Action Executor` accepts a `GovernanceActionProcessStep` (a subtype of `GovernanceActionType`) as its subject. It should, but this hasn't been live-verified (see the Action Author note in CLAUDE.md). +3. Declaring the engine side may need `SupportedGovernanceService` / governance-engine commands, if Dr.Egeria doesn't already have them. Check before building. + +**Requirements proposed by RE's expansion-design session (2026-10-13; it found no contradiction with the direction; pending the owner's review):** +1. **One declaration, idempotent.** The manifest's qualifiers must live on the `GovernanceActionType` itself (`supportedRequestParameters` or `additionalProperties`), so Egeria, the Portal and RE all read the same declaration. That means question ids, perspectives, `purpose` (from the existing `VALID_PURPOSES` set) and `family`. Declaring is idempotent by qualifiedName: declaring twice updates, and never creates a second type. Dr.Egeria's upsert (Create→Update by qualified name) already behaves this way. +2. **Label synchronous values differently from recorded ones.** On a screen, a value computed now must be labelled "computed now · not recorded", and a stored result "last recorded ". A reader must never mistake a synchronous draw for a governed measurement. (The envelope gives the state and `measured_at`; the "recorded or not" label is the caller's, from which path it used.) +3. **State the credential tier.** User-written steps stay on the resource's read-only tier (owner ruling, 2026-10-07). A `GovernanceActionType` declared for one must say so, with a property naming the tier (e.g. `credential_tier: read_only`). Otherwise Egeria would schedule it like any engine action. +--- + + + ### ISSUE-146: Deleting a journal entry fails with OMAG-REPOSITORY-HANDLER-400-008, and pyegeria's `remove_note` reaches the delete only through the generic Asset Maker service **Layer:** Egeria Server (pyegeria clean-up) · **Status:** open · **Found:** 2026-10-13, reported by the egeria-workspaces-fs Resource Browser session from a live private-journal test. @@ -275,56 +342,8 @@ Erin's profile. `fef83ed5` stays. --- -### ISSUE-140: The executor charts any dict with two or more top-level numbers, regardless of the result's state - -**Layer:** pyegeria (format_set_executor) · **Status:** open, logged only (no fix without the owner's explicit go-ahead) · **Found:** 2026-10-12, reported by the Resource Explorer PR/CI session from trellis-analytics (trellis PR #622, `trellis_analytics/portal.py`), which works around it today. - -**What:** BAR/SERIES output is drawn whenever the result dict has two or -more numeric top-level keys, whether or not the result says it was -measured. A `not_established` result that happens to carry numbers would be -charted as real zeros/values. - -**Where seen:** trellis `portal.py` is safe only because its adapter omits -numbers for non-measured states. - -**Candidate fix:** honour an envelope state (see ISSUE-139): draw no chart -unless the state is `measured`, and show the state word instead. - ---- - -### ISSUE-139: The executor renders no result envelope, and the Vega bar chart has no subtitle, so a not-established result has nowhere to say so on a chart - -**Layer:** pyegeria (format_set_executor output rendering) · **Status:** open, logged only (no fix without the owner's explicit go-ahead) · **Found:** 2026-10-12, reported by the Resource Explorer PR/CI session from trellis-analytics (trellis PR #622, `trellis_analytics/portal.py`), which works around it today. - -**What:** a routine's result state (`measured` / `nothing_found` / -`not_established` / `not_applicable`, plus a reason) has nowhere to go on a -chart; it survives only in the DICT output. A tile can therefore show a -chart with no indication that the numbers were not established. - -**Where seen:** trellis `tests/test_portal_tile.py` pins the absence, so that -test will fail once this is fixed. - -**Candidate fix:** render an optional `envelope`/`subtitle` field from the -result into the Vega spec's `title.subtitle`, and into every output type. - ---- - -### ISSUE-138: Analytic params are JSON-only, and only parameters named `mgr`/`ce`/`expert`/`client` are bound as clients, so a routine taking a reader object can't be named by its own dotted path -**Layer:** pyegeria (format_set_executor, analytic_registry) · **Status:** open, logged only (no fix without the owner's explicit go-ahead) · **Found:** 2026-10-12, reported by the Resource Explorer PR/CI session from trellis-analytics (trellis PR #622, `trellis_analytics/portal.py`), which works around it today. -**What:** a routine whose first parameter is a reader object (e.g. -`OdsReader`) cannot be named directly by its dotted path; the spec has to -point at an adapter that constructs the reader from JSON params. - -**Where seen:** trellis `trellis_analytics.portal.curation_coverage_tile` (the -adapter), used instead of `trellis_analytics.routines.curation_coverage`. - -**Candidate fix:** a binding hook (e.g. `AnalyticFunctionSpec.binders`) or a -declared factory per parameter type, so non-client objects can be -constructed from params. - ---- ### ISSUE-132: `DeleteElementRequestBody` serializes the cascade flag as `cascadeDelete`; Egeria's field is `cascadedDelete` and unknown fields are ignored, so no pyegeria cascade delete has ever cascaded @@ -1562,6 +1581,163 @@ Sorted newest-first by `ISSUE-#` (2026-10-12). ISSUE-91 has two entries --- +### ISSUE-149: `AnalyticFunctionSpec` lacks the manifest fields trellis-analytics needs (`kind`, `source`, `version`, `envelope`, `annotation_type`, `resource_types`), so trellis subclasses carry them + +**Layer:** pyegeria (`pyegeria/view/analytic_registry.py`) · **Status:** fixed 2026-10-13 · **Found:** 2026-10-13, handed off by the Resource Explorer PR/CI session on the owner's instruction. Source: trellis brief §2a and `packages/trellis-analytics/src/trellis_analytics/manifest.py`. RE runs pyegeria 6.2.2. + +**What:** trellis-analytics' `RoutineManifest`/`StepManifest` subclass `AnalyticFunctionSpec` only to add fields the base should carry: +- `kind`; +- `source`; +- `version`; +- `envelope: bool` (the result carries a state envelope; see ISSUE-139/140); +- `annotation_type` (a step's result type, empty for a routine); +- `resource_types: list[str]`. + +The Portal's "Analytic Functions" tab groups by `family`, then `kind`, then `source`. + +**Proposed change:** add all six as optional fields with defaults (`kind="routine"`, `source=""`, `version=""`, `envelope=False`, `annotation_type=""`, `resource_types=[]`), so every existing registry entry and spec still validates unchanged. Once a pyegeria release carries them, trellis-analytics drops its own copies. + +**Points to settle before building:** +- The handoff lists `kind` as `routine | step | spec`, but trellis's `Kind` is `Literal["routine", "step"]`. Decide whether `spec` is a third kind or a different axis (a report spec is the presentation flavour, brief §2d). +- trellis's manifest also carries `family` and `tags`, which the handoff doesn't list. `FormatSet` already has `family`; decide whether `AnalyticFunctionSpec` should get them too, rather than leaving trellis to carry them. +- `source` values in trellis are `ods | egeria | resource`. Decide between a `Literal` (strict) and `str` (open, so a user step can name a new source). + +**Related:** ISSUE-138 (non-client param binding), ISSUE-139/140 (envelope rendering), ISSUE-150 (the Egeria declaration, which should take its attribute names from these fields). + +**Answers (RE PR/CI session, 2026-10-13, citing the owner's 2026-10-07 organisation ruling; to be confirmed by the owner before building):** +- `kind`: `routine | step` only. "spec" was a slip: a spec is a `FormatSet`, not an `AnalyticFunctionSpec`. This matches trellis-analytics' `Kind` (`manifest.py:14`). +- `family` and `tags`: add both, optional. `family` has the same word and meaning as `FormatSet.family`; in Egeria a family is a `Collection`. +- `source`: an open `str`, not a `Literal`. Document the known values (`ods`, `egeria`). The owner's rule for value lists is "suggestive everywhere": an unknown value warns, never rejects. +- The Advisor side of ISSUE-141 is already done in trellis (`packages/egeria-advisor/tests/unit/test_report_spec_parser.py:263-267`). +- Once a pyegeria release carries these fields, trellis-analytics drops its own copies. Tell the RE PR/CI session the version. + +**Fixed 2026-10-13 (owner's go-ahead; design answers as recorded above):** `AnalyticFunctionSpec` gains `kind: Literal["routine","step"]="routine"`, `source: str=""` (an unknown value logs a warning; known values are in `KNOWN_ANALYTIC_SOURCES` = ods, egeria, resource), `version`, `envelope: bool=False`, `annotation_type`, `resource_types`, `family` and `tags`, all with defaults. All 23 built-in registry entries still load, and trellis's `RoutineManifest`/`StepManifest` subclasses validate unchanged against the new base (checked locally). Once this ships, trellis-analytics can drop its own copies. Tests: as for ISSUE-138. + +--- + +### ISSUE-140: The executor charts any dict with two or more top-level numbers, regardless of the result's state + +**Layer:** pyegeria (format_set_executor) · **Status:** fixed 2026-10-13 · **Found:** 2026-10-12, reported by the Resource Explorer PR/CI session from trellis-analytics (trellis PR #622, `trellis_analytics/portal.py`), which works around it today. + +**What:** BAR/SERIES output is drawn whenever the result dict has two or +more numeric top-level keys, whether or not the result says it was +measured. A `not_established` result that happens to carry numbers would be +charted as real zeros/values. + +**Where seen:** trellis `portal.py` is safe only because its adapter omits +numbers for non-measured states. + +**Candidate fix:** honour an envelope state (see ISSUE-139): draw no chart +unless the state is `measured`, and show the state word instead. + +**Fixed 2026-10-13:** with an envelope present, SERIES/BAR/PIE draw a chart only when the state is `measured` or `nothing_found` (`CHARTABLE_STATES`); for `not_established` and `not_applicable` they return the data plus the envelope. Results without an envelope are charted exactly as before. **Amended the same day at the requester's request (RE PR/CI session):** the candidate fix above said "measured only", but `nothing_found` is an established answer. The routine read the record and the count really is zero, so a chart of real zeros is honest, and trellis's tile already charts it (`_CHARTED`). Only unknown numbers must never be charted. Tests: as for ISSUE-138. + +--- + +### ISSUE-139: The executor renders no result envelope, and the Vega bar chart has no subtitle, so a not-established result has nowhere to say so on a chart + +**Layer:** pyegeria (format_set_executor output rendering) · **Status:** fixed 2026-10-13 · **Found:** 2026-10-12, reported by the Resource Explorer PR/CI session from trellis-analytics (trellis PR #622, `trellis_analytics/portal.py`), which works around it today. + +**What:** a routine's result state (`measured` / `nothing_found` / +`not_established` / `not_applicable`, plus a reason) has nowhere to go on a +chart; it survives only in the DICT output. A tile can therefore show a +chart with no indication that the numbers were not established. + +**Where seen:** trellis `tests/test_portal_tile.py` pins the absence, so that +test will fail once this is fixed. + +**Candidate fix:** render an optional `envelope`/`subtitle` field from the +result into the Vega spec's `title.subtitle`, and into every output type. + +**Fixed 2026-10-13:** `_result_envelope` reads a state from `state`/`state_reason`/`subtitle`, `resultState`/`resultStateReason`, a nested `envelope` dict, or an object's `.envelope` (`result_state`/`result_state_reason`). Every analytic output (DICT/JSON/empty/chart) then carries `"envelope": {state, reason, subtitle}`, and charts get `title: {text, subtitle}`. Pydantic results are returned as plain data. **trellis's `tests/test_portal_tile.py:75` pins the old absence of a subtitle, so it will fail once trellis takes this release, as expected.** Tests: as for ISSUE-138. + +--- + +### ISSUE-138: Analytic params are JSON-only, and only parameters named `mgr`/`ce`/`expert`/`client` are bound as clients, so a routine taking a reader object can't be named by its own dotted path + +**Layer:** pyegeria (format_set_executor, analytic_registry) · **Status:** fixed 2026-10-13 · **Found:** 2026-10-12, reported by the Resource Explorer PR/CI session from trellis-analytics (trellis PR #622, `trellis_analytics/portal.py`), which works around it today. + +**What:** a routine whose first parameter is a reader object (e.g. +`OdsReader`) cannot be named directly by its dotted path; the spec has to +point at an adapter that constructs the reader from JSON params. + +**Where seen:** trellis `trellis_analytics.portal.curation_coverage_tile` (the +adapter), used instead of `trellis_analytics.routines.curation_coverage`. + +**Candidate fix:** a binding hook (e.g. `AnalyticFunctionSpec.binders`) or a +declared factory per parameter type, so non-client objects can be +constructed from params. + +**Fixed 2026-10-13:** `ActionParameter.analytic_binders` maps a parameter name to a factory's dotted path (`pkg.mod.Factory` or `pkg.mod:Factory`). `_apply_analytic_binders` (format_set_executor.py) calls the factory with the call params its signature names, consuming them unless the routine also takes them, and passes the result to the routine by name. trellis can then name `trellis_analytics.routines.curation_coverage` directly, with `analytic_binders={"ods": "trellis_analytics.readers.ods.OdsReader"}` and the caller passing `url`. Tests: `tests/micro-tests/test_issue_138_140_149_analytic_envelope.py`. Documented in `docs/output-formats-and-report-specs.md`. + +--- + +### ISSUE-151: `AutomatedCuration.get_engine_actions()` calls `automated-curation/engine-actions`, which the View Server doesn't have (404, reported as "unable to connect"); `find_engine_actions("*")` is the working list-all + +**Layer:** pyegeria (`pyegeria/omvs/automated_curation.py`) · **Status:** fixed 2026-10-13 (method removed) · **Found:** 2026-10-13 by RE's engine-host design work (the RE PR/CI session confirmed it live on pyegeria 6.2.2). Cause confirmed from Egeria source, and re-checked live read-only, by this session. + +**What:** `_async_get_engine_actions` (~line 2628) posts, through `_async_get_name_request`, to +`{ref_curation_command_base}/engine-actions`. That path doesn't exist. On qs-view-server it answers HTTP 404, which pyegeria surfaces as `PyegeriaClientException` `CLIENT_ERROR_400` with "unable to connect to the Egeria platform". That message is misleading: the platform is reachable, and only the path is wrong. `print_engine_actions()` (~line 5351) calls it, so that fails too. + +**Diagnosis: (b), the View Server has no such endpoint.** It isn't a wrong verb or a wrong path for an endpoint that exists. `AutomatedCurationResource.java` (Egeria `af400039c2`, `view-server-generic-services/automated-curation/automated-curation-spring/...`) maps these engine-action routes: +- `GET /engine-actions/active` (line 435); +- `GET /governance-engines/{guid}/engine-actions/active-claimed` (line 529); +- `POST /engine-actions/{guid}/cancel`, `/claim`, `/status/update`, `/completion-status` (lines 406, 498, 469, 591); +- `POST /engine-actions/action-targets/update` (562); +- `POST /governance-engines/{name}/engine-actions/initiate` (321). + +There is no list-all, and no plain `/engine-actions`. `Egeria-api-automated-curation.http` has no such call either. + +**Working routes today (checked live, read-only, 2026-10-13):** +- **Every engine action:** `find_engine_actions("*")`. It posts to `automated-curation/assets/by-search-string` with `metadata_element_type="EngineAction"`, because in 6.2 EngineAction is an Asset (EngineAction → Action → Process → Asset). It returned a full page of 50, all `COMPLETED`. +- **Only the active ones:** `get_active_engine_actions()`, which returned 1. +- AssetMaker's `find_processes(metadata_element_type="EngineAction")` also works, and since ISSUE-148 it applies no status filter by default. + +**Candidate fixes (needs the owner's choice):** +1. Re-implement `get_engine_actions` as a thin wrapper over `find_engine_actions("*")`, keeping its signature and paging. `print_engine_actions` then works unchanged. (Preferred: callers keep working.) +2. Deprecate it with a clear error naming `find_engine_actions`. + +Either way, add a micro-test that pins the URL. + +**Related, for when Egeria exposes it:** Mandy added a per-engine "approved, unclaimed" engine-action listing on 2026-10-05, but only on the metadata server's `governance-context-service`, not on the View Server, so pyegeria can't reach it through a view service today. RE will ask the Egeria leads to expose it on Automated Curation. Once it's there, add a wrapper next to `get_active_claimed_engine_actions` (same per-engine shape). Until then, approximate it client-side with `find_engine_actions` plus an activity-status filter. + +**Side note (separate, not fixed here):** a 404 reported as "unable to connect" is the same family as the other misleading error labels (compare ISSUE-142). A 404 from a reachable platform should say "endpoint not found". + +**Fixed 2026-10-13 (owner: "remove it and let people use find_engine_actions"):** +`get_engine_actions` / `_async_get_engine_actions` are removed. Before removing them, callers were searched in +egeria-python, egeria-workspaces-fs and trellis. The only callers were in egeria-python: `print_engine_actions`, +one functional test and a docs checklist line. `print_engine_actions` now calls `find_engine_actions("*")`. +The functional test is now `test_find_all_engine_actions`. Test: +`tests/micro-tests/test_issue_151_engine_actions_list_all.py`. **This is a public API removal, so it must go in +the 6.2.3 release notes** (see BACKLOG.md). + +--- + +### ISSUE-148: AssetMaker action/process queries default `activity_status_list` to `["IN_PROGRESS"]`, which silently drops JournalEntries and any other Action with no activity status + +**Layer:** pyegeria (`pyegeria/omvs/asset_maker.py`) · **Status:** fixed 2026-10-13 · **Found:** 2026-10-13 by the egeria-workspaces-fs session (Resource Locator) on pyegeria 6.2.0 in quickstart-pyegeria-web. + +**What:** for resource `4e53842e`, which has an Actions link to JournalEntry `8bec72e4` (see ISSUE-146), `get_actions_for_sponsor` with the default returns 0 elements. Passing `activity_status_list=None` or `[]` returns 1. There is no error and no warning. A JournalEntry has no activity status, so an `IN_PROGRESS` filter can never match it. The same goes for completed, waiting or failed actions. + +**Where:** 7 public methods default to `["IN_PROGRESS"]`, as do their `_async_` versions: +`find_processes`, `get_processes_by_category`, `get_action_targets`, `get_actions_for_action_target`, `get_assigned_actions`, `get_actions_for_sponsor` and `get_actions_for_requester`. All are in `asset_maker.py`, around lines 3881-5210. + +**Why it matters:** a caller who doesn't know about the filter gets "No elements found", and that looks the same as "this element has no actions". The absence is reported as an answer. + +**Candidate fix (needs a decision):** default to `None`, so no status filter is applied and the server returns everything, and let callers ask for `["IN_PROGRESS"]` explicitly. This changes behaviour for existing callers that relied on the filter (for example, To-Do views that expect only open work), so check the callers in egeria-workspaces-fs, trellis and `commands/` first. Then add a micro-test that captures the request body for each method. + +**Fixed 2026-10-13 (owner's go-ahead, after all four live sessions confirmed no caller relies on the default):** +all 14 signatures (7 sync, 7 async) now take `activity_status_list: Optional[list[str]] = None`, and the +docstrings say "no filter". `_async_find_processes` had defaulted to `[]` while the sync `find_processes` +defaulted to `["IN_PROGRESS"]`, so the same query had returned different results depending on which +version was called. Callers checked: egeria-workspaces-fs `tech_catalog_handler` (passes `[]`) and +`resource_browser_handler` (passes `None`), egeria-python `commands/` (passes its own filter); trellis has none. +**This is a behaviour change, so it must go in the 6.2.3 release notes** (see BACKLOG.md). Test: +`tests/micro-tests/test_issue_148_activity_status_default.py`, which checks that the default sends no +`activityStatusList` and that an explicit filter is still sent. + +--- + ### ISSUE-145: Dr.Egeria sends `agreementType` and other `agreement*` fields that Egeria's `AgreementProperties` does not have **Layer:** Dr.Egeria · **Status:** fixed 2026-10-13 · **Found:** 2026-10-13, during the ISSUE-144 status-field sweep. @@ -1662,7 +1838,7 @@ sends `confidence` (`collection_manager.py`, ### ISSUE-141: `ActionParameter.function` is a required string, so analytic-only specs must pass `""`, and the Advisor's validator then warns "No Action Function" -**Layer:** pyegeria (report spec models) · **Status:** fixed 2026-10-12 on branch `docs/issue-136` (PR pending), not yet released · **Found:** 2026-10-12, reported by the Resource Explorer PR/CI session from trellis-analytics (trellis PR #622, `trellis_analytics/portal.py`), which works around it today. +**Layer:** pyegeria (report spec models) · **Status:** fixed 2026-10-12 in #446, released in 6.2.2 · **Found:** 2026-10-12, reported by the Resource Explorer PR/CI session from trellis-analytics (trellis PR #622, `trellis_analytics/portal.py`), which works around it today. **Fixed 2026-10-12:** `function` is now `Optional[str] = None`, and a model validator rejects an action with neither `function` nor `analytic_function`. An @@ -1689,7 +1865,7 @@ and teach the validator the same rule. ### ISSUE-137: `exec_report_spec` always builds an `EgeriaTech` client and mints a token, even for an analytic routine that needs no Egeria -**Layer:** pyegeria (format_set_executor) · **Status:** fixed 2026-10-12 on branch `docs/issue-136` (PR pending), not yet released · **Found:** 2026-10-12, reported by the Resource Explorer PR/CI session from trellis-analytics (trellis PR #622, `trellis_analytics/portal.py`), which works around it today. +**Layer:** pyegeria (format_set_executor) · **Status:** fixed 2026-10-12 in #446, released in 6.2.2 · **Found:** 2026-10-12, reported by the Resource Explorer PR/CI session from trellis-analytics (trellis PR #622, `trellis_analytics/portal.py`), which works around it today. **Fixed 2026-10-12:** `_run_analytic_function` counts the routine's leading client parameters (`_count_client_params`) and builds and authenticates an diff --git a/docs/output-formats-and-report-specs.md b/docs/output-formats-and-report-specs.md index 9b58cea0..bbe6b0b2 100644 --- a/docs/output-formats-and-report-specs.md +++ b/docs/output-formats-and-report-specs.md @@ -957,12 +957,40 @@ A `FormatSet` can declare either path, or both (in which case the analytic path All three chart formats are dispatched *before* the normal Format-row lookup (`exec_report_spec`), since no `FormatSet` needs to declare a `SERIES`/`BAR`/`PIE` `Format` row for this to work — only `DICT`/`JSON` (or whatever find-path formats it also supports) need a declared row. +**Result state (the envelope).** A routine can say how sure it is by returning a state with its result: `measured`, `nothing_found`, `not_established` or `not_applicable`, plus a reason. The executor recognises a dict with `state`/`state_reason` (and an optional ready-made `subtitle`), a dict with `resultState`/`resultStateReason`, a nested `envelope` dict in either style, or an object whose `envelope` attribute has `result_state`/`result_state_reason`. When it finds one: +- every output shape gets `"envelope": {"state", "reason", "subtitle"}` beside `kind`/`data`; +- a chart gets the subtitle on it (`title: {"text", "subtitle"}`); +- **a chart is drawn only for an established answer: `measured`, or `nothing_found`** (the routine read the record and the count really is zero, so a chart of zeros is honest). For `not_established` and `not_applicable` the numbers aren't known, so the caller gets the data and the state instead; zeros there would read as an answer. +A result without an envelope is returned exactly as before. A result that is a pydantic model is returned as plain data (`model_dump`). See ISSUE-139/140. + +**Building non-JSON arguments (`analytic_binders`).** Report-spec params are JSON, and only leading parameters named `mgr`/`ce`/`expert`/`client` get an Egeria client. To name a routine that takes some other object (e.g. a reader) by its own dotted path, map the parameter to a factory: + +```python +ActionParameter( + analytic_function="trellis_analytics.routines.curation_coverage", + analytic_binders={"ods": "trellis_analytics.readers.ods.OdsReader"}, + analytic_spec_params={"resource_type": "database"}, +) +``` + +Here `OdsReader(url)` takes `url`, so the caller passes `url` and the routine receives the built reader as `ods`. The factory is called with the call params its own signature names, and those params are consumed unless the routine also takes them. A routine with no client parameter still connects to nothing (ISSUE-137, ISSUE-138). + **`analytic_spec_params` are defaults, not pins.** Unlike the find path's `spec_params` (always fixed — a caller-supplied value for the same key is silently overridden), `analytic_spec_params` set a *default* that a caller-supplied value for the same key overrides. This is what lets a report spec like the demo "Element Count by Type" (which pins `type_name: "GlossaryTerm"` as its default) actually be re-pointed at a different type at run time instead of being permanently locked to the demo's own default. **The analytic function registry** (`pyegeria/view/analytic_registry.py`) is a separate, hand-written catalog of every analytic function available to point `analytic_function` at — `AnalyticFunctionSpec` entries carry `function` (dotted import path), `description`, `returns` (result shape), `params`, and a `generic: bool` flag with a `binding_note`: - `generic=True` — what the function counts/aggregates is itself a parameter (e.g. `count_elements(type_name=...)`), freely retargetable at any element type via `analytic_spec_params`. - `generic=False` — the vocabulary is hardcoded in the function body (e.g. `governed_coverage` always counts a fixed set of governance classifications); a report spec can still point at it, but always gets that one fixed metric. +**Manifest fields (ISSUE-149).** `AnalyticFunctionSpec` also carries optional fields for analytic libraries such as trellis-analytics. All have defaults, so existing entries are unchanged: +- `kind`: `routine` (runs in the caller's process, the default) or `step` (a survey-style step); +- `source`: where the data comes from, as free text. Known values are `ods`, `egeria` and `resource`; any other value logs a warning and is kept; +- `version`; +- `envelope: bool` (the result carries a state, see above); +- `annotation_type` (a step's result type); +- `resource_types`; +- `family`: the same word and meaning as `FormatSet.family`; in Egeria a family is a Collection; +- `tags`. + `get_analytic_registry()` combines a `BUILTINS` tier (the 10 `overview_metrics` functions shipped today) with optional `CONFIG`/`RUNTIME` tiers, mirroring `base_report_formats.py`'s report-spec registry shape. **Demo report specs** (`pyegeria/view/analytic_demo_specs.py`) — one real, executable `FormatSet` per registered analytic function, proving each is actually runnable as a report spec (not just documented in the registry). Named `Analytic Demo - `, family `"Analytic Function Demo"`. Each spec's `description` states plainly whether the underlying function is generic or a fixed metric (pulled from the registry, not duplicated by hand). diff --git a/docs/parameter_cleanup_plan.md b/docs/parameter_cleanup_plan.md index b2fd9d2c..b5f74612 100644 --- a/docs/parameter_cleanup_plan.md +++ b/docs/parameter_cleanup_plan.md @@ -340,7 +340,7 @@ async def _async_get_xxx_by_guid( - **Planned**: TODO - [ ] `get_create_csv_data_file_element_from_template(self, file_name, file_type, file_path_name, version_identifier, file_encoding='UTF-8', file_extension='csv', file_system_name=None, description=None)` - **Planned**: TODO -- [ ] `get_engine_actions(self, start_from=0, page_size=0, output_format='JSON', report_spec='EngineAction', body=None)` +- [x] ~~`get_engine_actions(...)`~~ removed (ISSUE-151): no View Server endpoint; use `find_engine_actions("*")` - **Planned**: TODO - [x] `get_engine_actions_by_name(self, name, start_from=0, page_size=0, output_format='JSON', report_spec='EngineAction', body=None)` - **Planned**: Standard Get By Name Pattern diff --git a/pyegeria/omvs/asset_maker.py b/pyegeria/omvs/asset_maker.py index 769ae0b3..f1f0fb18 100644 --- a/pyegeria/omvs/asset_maker.py +++ b/pyegeria/omvs/asset_maker.py @@ -3781,7 +3781,7 @@ def detach_software_capability_from_asset( async def _async_find_processes( self, search_string: str = "*", - activity_status_list: list[str] = [], + activity_status_list: Optional[list[str]] = None, starts_with: bool = True, ends_with: bool = False, ignore_case: bool = True, @@ -3814,7 +3814,7 @@ async def _async_find_processes( ---------- search_string: str, default = "*" - search string for process properties - activity_status_list: list[str], default = [] + activity_status_list: list[str], default = None (no filter: every activity status, including none) - optional activity status list to filter by starts_with: bool, default = True ends_with: bool, default = False @@ -3881,7 +3881,7 @@ async def _async_find_processes( def find_processes( self, search_string: str = "*", - activity_status_list: list[str] = ["IN_PROGRESS"], + activity_status_list: Optional[list[str]] = None, starts_with: bool = True, ends_with: bool = False, ignore_case: bool = True, @@ -3914,7 +3914,7 @@ def find_processes( ---------- search_string: str, default = "*" - the search string to use to find matching processes - activity_status_list: list[str], default = ["IN_PROGRESS"] + activity_status_list: list[str], default = None (no filter: every activity status, including none) - optional activity status list to filter by starts_with: bool, default = True - if True, the search string must match the start of the property value @@ -3988,7 +3988,7 @@ def find_processes( async def _async_get_processes_by_category( self, category: str, - activity_status_list: list[str] = ["IN_PROGRESS"], + activity_status_list: Optional[list[str]] = None, graph_query_depth: int = 3, start_from: int = 0, page_size: int = 0, @@ -4003,7 +4003,7 @@ async def _async_get_processes_by_category( Parameters ---------- category: str - activity_status_list: list[str], default = ["IN_PROGRESS"] + activity_status_list: list[str], default = None (no filter: every activity status, including none) graph_query_depth: int, default = 3 - depth for relationship traversal start_from: int, default = 0 @@ -4048,7 +4048,7 @@ async def _async_get_processes_by_category( def get_processes_by_category( self, category: str, - activity_status_list: list[str] = ["IN_PROGRESS"], + activity_status_list: Optional[list[str]] = None, graph_query_depth: int = 3, start_from: int = 0, page_size: int = 0, @@ -4064,7 +4064,7 @@ def get_processes_by_category( ---------- category: str - category name to filter by - activity_status_list: list[str], default = ["IN_PROGRESS"] + activity_status_list: list[str], default = None (no filter: every activity status, including none) - optional activity status list to filter by graph_query_depth: int, default = 3 - depth for relationship traversal @@ -4526,7 +4526,7 @@ def get_action_target( async def _async_get_action_targets( self, action_guid: str, - activity_status_list: list[str] = ["IN_PROGRESS"], + activity_status_list: Optional[list[str]] = None, graph_query_depth: int = 3, start_from: int = 0, page_size: int = 0, @@ -4544,7 +4544,7 @@ async def _async_get_action_targets( Parameters ---------- action_guid: str - activity_status_list: list[str], default = ["IN_PROGRESS"] + activity_status_list: list[str], default = None (no filter: every activity status, including none) graph_query_depth: int, default = 3 - depth for relationship traversal body: dict | ActivityStatusRequestBody, optional @@ -4577,7 +4577,7 @@ async def _async_get_action_targets( def get_action_targets( self, action_guid: str, - activity_status_list: list[str] = ["IN_PROGRESS"], + activity_status_list: Optional[list[str]] = None, graph_query_depth: int = 3, start_from: int = 0, page_size: int = 0, @@ -4595,7 +4595,7 @@ def get_action_targets( Parameters ---------- action_guid: str - activity_status_list: list[str], default = ["IN_PROGRESS"] + activity_status_list: list[str], default = None (no filter: every activity status, including none) graph_query_depth: int, default = 3 - depth for relationship traversal start_from: int, default = 0 @@ -4641,7 +4641,7 @@ def get_action_targets( async def _async_get_actions_for_action_target( self, metadata_element_guid: str, - activity_status_list: list[str] = ["IN_PROGRESS"], + activity_status_list: Optional[list[str]] = None, graph_query_depth: int = 3, start_from: int = 0, page_size: int = 0, @@ -4659,7 +4659,7 @@ async def _async_get_actions_for_action_target( Parameters ---------- metadata_element_guid: str - activity_status_list: list[str], default = ["IN_PROGRESS"] + activity_status_list: list[str], default = None (no filter: every activity status, including none) graph_query_depth: int, default = 3 - depth for relationship traversal body: dict | ActivityStatusRequestBody, optional @@ -4692,7 +4692,7 @@ async def _async_get_actions_for_action_target( def get_actions_for_action_target( self, metadata_element_guid: str, - activity_status_list: list[str] = ["IN_PROGRESS"], + activity_status_list: Optional[list[str]] = None, graph_query_depth: int = 3, start_from: int = 0, page_size: int = 0, @@ -4710,7 +4710,7 @@ def get_actions_for_action_target( Parameters ---------- metadata_element_guid: str - activity_status_list: list[str], default = ["IN_PROGRESS"] + activity_status_list: list[str], default = None (no filter: every activity status, including none) graph_query_depth: int, default = 3 - depth for relationship traversal start_from: int, default = 0 @@ -4925,7 +4925,7 @@ def unassign_action( async def _async_get_assigned_actions( self, actor_guid: str, - activity_status_list: list[str] = ["IN_PROGRESS"], + activity_status_list: Optional[list[str]] = None, graph_query_depth: int = 3, start_from: int = 0, page_size: int = 0, @@ -4943,7 +4943,7 @@ async def _async_get_assigned_actions( Parameters ---------- actor_guid: str - activity_status_list: list[str], default = ["IN_PROGRESS"] + activity_status_list: list[str], default = None (no filter: every activity status, including none) graph_query_depth: int, default = 3 - depth for relationship traversal body: dict | ActivityStatusRequestBody, optional @@ -4976,7 +4976,7 @@ async def _async_get_assigned_actions( def get_assigned_actions( self, actor_guid: str, - activity_status_list: list[str] = ["IN_PROGRESS"], + activity_status_list: Optional[list[str]] = None, graph_query_depth: int = 3, start_from: int = 0, page_size: int = 0, @@ -4994,7 +4994,7 @@ def get_assigned_actions( Parameters ---------- actor_guid: str - activity_status_list: list[str], default = ["IN_PROGRESS"] + activity_status_list: list[str], default = None (no filter: every activity status, including none) graph_query_depth: int, default = 3 - depth for relationship traversal start_from: int, default = 0 @@ -5040,7 +5040,7 @@ def get_assigned_actions( async def _async_get_actions_for_sponsor( self, metadata_element_guid: str, - activity_status_list: list[str] = ["IN_PROGRESS"], + activity_status_list: Optional[list[str]] = None, graph_query_depth: int = 3, start_from: int = 0, page_size: int = 0, @@ -5058,7 +5058,7 @@ async def _async_get_actions_for_sponsor( Parameters ---------- metadata_element_guid: str - activity_status_list: list[str], default = ["IN_PROGRESS"] + activity_status_list: list[str], default = None (no filter: every activity status, including none) graph_query_depth: int, default = 3 - depth for relationship traversal body: dict | ActivityStatusRequestBody, optional @@ -5091,7 +5091,7 @@ async def _async_get_actions_for_sponsor( def get_actions_for_sponsor( self, metadata_element_guid: str, - activity_status_list: list[str] = ["IN_PROGRESS"], + activity_status_list: Optional[list[str]] = None, graph_query_depth: int = 3, start_from: int = 0, page_size: int = 0, @@ -5109,7 +5109,7 @@ def get_actions_for_sponsor( Parameters ---------- metadata_element_guid: str - activity_status_list: list[str], default = ["IN_PROGRESS"] + activity_status_list: list[str], default = None (no filter: every activity status, including none) graph_query_depth: int, default = 3 - depth for relationship traversal start_from: int, default = 0 @@ -5155,7 +5155,7 @@ def get_actions_for_sponsor( async def _async_get_actions_for_requester( self, metadata_element_guid: str, - activity_status_list: list[str] = ["IN_PROGRESS"], + activity_status_list: Optional[list[str]] = None, graph_query_depth: int = 3, start_from: int = 0, page_size: int = 0, @@ -5173,7 +5173,7 @@ async def _async_get_actions_for_requester( Parameters ---------- metadata_element_guid: str - activity_status_list: list[str], default = ["IN_PROGRESS"] + activity_status_list: list[str], default = None (no filter: every activity status, including none) graph_query_depth: int, default = 3 - depth for relationship traversal body: dict | ActivityStatusRequestBody, optional @@ -5206,7 +5206,7 @@ async def _async_get_actions_for_requester( def get_actions_for_requester( self, metadata_element_guid: str, - activity_status_list: list[str] = ["IN_PROGRESS"], + activity_status_list: Optional[list[str]] = None, graph_query_depth: int = 3, start_from: int = 0, page_size: int = 0, @@ -5224,7 +5224,7 @@ def get_actions_for_requester( Parameters ---------- metadata_element_guid: str - activity_status_list: list[str], default = ["IN_PROGRESS"] + activity_status_list: list[str], default = None (no filter: every activity status, including none) graph_query_depth: int, default = 3 - depth for relationship traversal start_from: int, default = 0 diff --git a/pyegeria/omvs/automated_curation.py b/pyegeria/omvs/automated_curation.py index c8f2421c..2ecea735 100644 --- a/pyegeria/omvs/automated_curation.py +++ b/pyegeria/omvs/automated_curation.py @@ -2625,103 +2625,6 @@ def get_active_claimed_engine_actions( self._async_get_active_claimed_engine_actions(governance_engine_guid, start_from, page_size) ) - async def _async_get_engine_actions( - self, - start_from: int = 0, - page_size: int = 0, - output_format: str = "JSON", - report_spec: str | dict = "EngineAction", - body: Optional[dict | FilterRequestBody] = None, - **kwargs, - ) -> list | str: - """Retrieve the list of engine action metadata elements. - - Async version. - - Parameters - ---------- - start_from : int, default 0 - The index from which to start fetching. - page_size : int, default 0 - The maximum number to fetch in a single request. - output_format : str, default "JSON" - The desired output format (e.g., "JSON", "DICT"). - report_spec : str | dict, default "EngineAction" - The report specification to use for formatting. - body : dict | FilterRequestBody, optional - The request body. - **kwargs : Any - Additional keyword arguments to pass to the request. - - Returns - ------- - list | str - A list of dictionaries representing the retrieved engine actions. - - Raises - ------ - PyegeriaException - If there is an error in communication or processing. - - Notes - ----- - For more information see: https://egeria-project.org/concepts/engine-action - """ - - url = f"{self.ref_curation_command_base}/engine-actions" - response = await self._async_get_name_request(url, _type=self.ENGINE_ACTION_LABEL, - _gen_output=self._generate_engine_action_output, - filter_string=None, start_from=start_from, page_size=page_size, - output_format=output_format, report_spec=report_spec, body=body, **kwargs) - return response - - def get_engine_actions( - self, - start_from: int = 0, - page_size: int = 0, - output_format: str = "JSON", - report_spec: str | dict = "EngineAction", - body: Optional[dict | FilterRequestBody] = None, - **kwargs, - ) -> list | str: - """Retrieve the list of engine action metadata elements. - - Parameters - ---------- - start_from : int, optional - The index to start retrieving engine actions from. If not provided, the default value will be used. - page_size : int, optional - The maximum number of engine actions to retrieve in a single request. If not provided, the default global - maximum paging size will be used. - output_format: str, default = "JSON" - - one of "DICT", "MERMAID" or "JSON" - report_spec: dict , optional, default = None - The desired output columns/fields to include. - body: dict, optional, default = None - Provides, a full request body. - **kwargs : Any - Additional keyword arguments to pass to the request. - - Returns - ------- - list of dict | str - A list of dictionaries representing the retrieved engine actions. - Raises: - ------ - PyegeriaException - Notes - ----- - For more information see: https://egeria-project.org/concepts/engine-action - - """ - loop = asyncio.get_event_loop() - response = loop.run_until_complete( - self._async_get_engine_actions( - start_from, page_size, output_format, report_spec, body, **kwargs - ) - ) - return response - async def _async_get_active_engine_actions( self, start_from: int = 0, @@ -5350,7 +5253,9 @@ def print_engine_action_summary(self, governance_action: dict): def print_engine_actions(self): """Print all the engine actions.""" - governance_actions = self.get_engine_actions() + # The View Server has no list-all engine-actions endpoint (ISSUE-151); EngineAction + # is an Asset in 6.2, so the asset search is the list-all. + governance_actions = self.find_engine_actions("*") if governance_actions is not None and isinstance(governance_actions, list): for x in range(len(governance_actions)): self.print_engine_action_summary(governance_actions[x]) diff --git a/pyegeria/view/_output_format_models.py b/pyegeria/view/_output_format_models.py index 1efe9de0..db5ef062 100644 --- a/pyegeria/view/_output_format_models.py +++ b/pyegeria/view/_output_format_models.py @@ -245,6 +245,10 @@ class ActionParameter(BaseModel): analytic_spec_params: Parameters fixed for this action, for `analytic_function` (from `extra_constraints`) -- layered under whatever arbitrary parameters the caller supplies at execution time. + analytic_binders: Parameter name -> dotted path of a factory that builds that + argument (e.g. a reader object) for `analytic_function` (ISSUE-138). The + factory's own parameters are filled from the call params by name, so a + routine that takes a non-JSON object can be named by its own dotted path. """ function: Optional[str] = None required_params: List[str] = Field(default_factory=list) @@ -252,6 +256,7 @@ class ActionParameter(BaseModel): spec_params: Dict[str, Any] = Field(default_factory=dict) analytic_function: Optional[str] = None analytic_spec_params: Dict[str, Any] = Field(default_factory=dict) + analytic_binders: Dict[str, str] = Field(default_factory=dict) @root_validator(pre=True) def _migrate_legacy_user_params(cls, values): diff --git a/pyegeria/view/analytic_registry.py b/pyegeria/view/analytic_registry.py index b40cb58f..ed769323 100644 --- a/pyegeria/view/analytic_registry.py +++ b/pyegeria/view/analytic_registry.py @@ -39,15 +39,16 @@ import os from pathlib import Path -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Union from loguru import logger -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator __all__ = [ 'AnalyticParam', 'AnalyticFunctionSpec', 'AnalyticActionSpec', + 'KNOWN_ANALYTIC_SOURCES', 'AnalyticRegistryCollision', 'refresh_analytic_functions', 'get_analytic_registry', @@ -58,6 +59,11 @@ ] +# Known values for AnalyticFunctionSpec.source (ISSUE-149). Suggestive, not a closed list: +# ods = the operational data store, egeria = Egeria metadata, resource = the resource itself. +KNOWN_ANALYTIC_SOURCES = {"ods", "egeria", "resource"} + + class AnalyticParam(BaseModel): """One keyword argument an analytic function accepts (beyond its leading client argument(s), which the executor supplies automatically).""" @@ -130,6 +136,27 @@ class AnalyticFunctionSpec(BaseModel): params: List[AnalyticParam] = Field(default_factory=list) action: Optional[AnalyticActionSpec] = None + # Manifest fields (ISSUE-149), all optional so every existing entry is unchanged. + # trellis-analytics' RoutineManifest/StepManifest subclass this class and used to + # carry these themselves. + kind: Literal["routine", "step"] = "routine" # routine: runs in the caller; step: a survey-style step + source: str = "" # where the data comes from; known values in KNOWN_ANALYTIC_SOURCES + version: str = "" # the producer's version, stamped on every result + envelope: bool = False # the result carries a state envelope (measured / nothing_found / ...) + annotation_type: str = "" # a step's result type, e.g. ResourceMeasureAnnotation; empty for a routine + resource_types: List[str] = Field(default_factory=list) # e.g. ["database"] + family: str = "" # same word and meaning as FormatSet.family; in Egeria a family is a Collection + tags: List[str] = Field(default_factory=list) + + @field_validator("source") + @classmethod + def _suggest_known_source(cls, value: str) -> str: + """Value lists are suggestive: an unknown source warns, never rejects.""" + if value and value not in KNOWN_ANALYTIC_SOURCES: + logger.warning(f"Analytic source '{value}' is not one of the known values " + f"{sorted(KNOWN_ANALYTIC_SOURCES)}; keeping it.") + return value + class AnalyticFunctionDict(Dict[str, AnalyticFunctionSpec]): """Name -> AnalyticFunctionSpec, mirroring FormatSetDict/DashboardSheetDict.""" diff --git a/pyegeria/view/format_set_executor.py b/pyegeria/view/format_set_executor.py index cd6a2d1d..6b0fd6d6 100644 --- a/pyegeria/view/format_set_executor.py +++ b/pyegeria/view/format_set_executor.py @@ -165,6 +165,89 @@ def run_analytic_action( return analytic_func(raw_result, *fetch_clients, **(analytic_kwargs or {})) +def _apply_analytic_binders(func, binders: Dict[str, str], call_params: Dict[str, Any]) -> Dict[str, Any]: + """Build non-JSON arguments for an analytic function from its call params (ISSUE-138). + + `binders` maps a parameter name of `func` to the dotted path of a factory + (e.g. `{"ods": "trellis_analytics.readers.ods.OdsReader"}`). Each factory is + called with the call params its own signature names; those params are + consumed unless `func` also takes them. A param the caller already supplied + under the bound name wins over the factory. + """ + if not binders: + return call_params + params = dict(call_params) + func_params = set(inspect.signature(func).parameters) + for target, factory_decl in binders.items(): + if target in params: + continue + factory = _resolve_analytic_function(factory_decl.replace(":", ".")) + factory_sig = inspect.signature(factory) + accepts_any = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in factory_sig.parameters.values()) + names = set(params) if accepts_any else set(factory_sig.parameters) & set(params) + kwargs = {n: params[n] for n in names} + for n in names: + if n not in func_params: + params.pop(n) + params[target] = factory(**kwargs) + return params + + +# Result-state envelope (ISSUE-139/140). A routine that reports how sure it is +# carries one of these states; only an established answer (measured, or +# nothing_found = a real zero) has numbers worth charting. +ENVELOPE_STATES = ("measured", "nothing_found", "not_established", "not_applicable") +CHARTABLE_STATES = ("measured", "nothing_found") + + +def _result_envelope(result: Any) -> Optional[Dict[str, str]]: + """Read a result's state envelope, or None if it has none. + + Recognised shapes: a dict with `state` (and optional `state_reason`/`subtitle`), + a dict with `resultState`/`resultStateReason` (the additionalProperties carrier), + a dict with a nested `envelope` dict in either style, or an object whose + `envelope` attribute has `result_state`/`result_state_reason`. + """ + def _from_mapping(m: Dict[str, Any]) -> Optional[Dict[str, str]]: + state = m.get("state", m.get("resultState", m.get("result_state"))) + if state is None: + return None + state = str(getattr(state, "value", state)) + if state not in ENVELOPE_STATES: + return None + reason = str(m.get("state_reason", m.get("resultStateReason", m.get("result_state_reason", ""))) or "") + subtitle = m.get("subtitle") or (state.replace("_", " ") + (f" \u00b7 {reason}" if reason else "")) + return {"state": state, "reason": reason, "subtitle": str(subtitle)} + + if isinstance(result, dict): + found = _from_mapping(result) + if found: + return found + nested = result.get("envelope") + if isinstance(nested, dict): + return _from_mapping(nested) + return None + env = getattr(result, "envelope", None) + if env is not None and not isinstance(env, bool): + if isinstance(env, dict): + return _from_mapping(env) + return _from_mapping({"result_state": getattr(env, "result_state", None), + "result_state_reason": getattr(env, "result_state_reason", "")}) + return None + + +def _jsonable(result: Any) -> Any: + """A pydantic result (e.g. a routine's own result model) as plain data.""" + dump = getattr(result, "model_dump", None) + return dump(mode="json") if callable(dump) else result + + +def _with_envelope(out: Dict[str, Any], envelope: Optional[Dict[str, str]]) -> Dict[str, Any]: + if envelope: + out["envelope"] = envelope + return out + + def _run_analytic_function( action: dict, *, params: Dict[str, Any], view_server: str, view_url: str, user: str, user_pass: str, @@ -202,6 +285,7 @@ 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) + call_params = _apply_analytic_binders(func, action.get("analytic_binders") or {}, call_params) n_clients = _count_client_params(func) if n_clients == 0: # The routine takes no client, so don't connect to Egeria or mint a token (ISSUE-137). @@ -659,37 +743,51 @@ def _exec_analytic_chart( action, params=params, view_server=view_server, view_url=view_url, user=user, user_pass=user_pass, token=token, ) + envelope = _result_envelope(result) + result = _jsonable(result) if not result: - return {"kind": "empty"} + return _with_envelope({"kind": "empty"}, envelope) + # Chart only an established answer (ISSUE-140): measured, or nothing_found (a real + # zero). For not_established / not_applicable the numbers aren't known, so return + # the data and the state, never a chart whose zeros would read as an answer. + if envelope and envelope["state"] not in CHARTABLE_STATES: + return _with_envelope({"kind": "json", "data": result}, envelope) try: heading = get_report_spec_heading(format_set_name) or str(format_set_name) except Exception: # noqa: BLE001 -- registry lookup quirks shouldn't block chart rendering heading = str(format_set_name) + def _chart_out(chart): + if not chart: + return _with_envelope({"kind": "json", "data": result}, envelope) + if envelope: # the state rides on the chart itself (ISSUE-139) + chart["title"] = {"text": chart.get("title") or heading, "subtitle": envelope["subtitle"]} + return _with_envelope({"kind": "json", "data": chart}, envelope) + if chart_kind == "line": if not isinstance(result, list) or not all(isinstance(pt, dict) for pt in result): - return {"kind": "json", "data": result} + return _with_envelope({"kind": "json", "data": result}, envelope) x_field = "label" if any("label" in pt for pt in result) else "date" y_fields = sorted({ k for pt in result for k, v in pt.items() if k not in ("label", "date") and isinstance(v, (int, float)) and not isinstance(v, bool) }) if not y_fields: - return {"kind": "json", "data": result} + return _with_envelope({"kind": "json", "data": result}, envelope) chart = generate_vega_line_chart( result, x_field=x_field, y_fields=y_fields, title=heading, x_label=x_field.capitalize(), y_label="Count", ) - return {"kind": "json", "data": chart} if chart else {"kind": "json", "data": result} + return _chart_out(chart) # bar / pie -- both need a plain {category: numeric_value} mapping cat_values = _as_category_value_dict(result) if not cat_values: - return {"kind": "json", "data": result} + return _with_envelope({"kind": "json", "data": result}, envelope) chart = (generate_vega_bar_chart(cat_values, title=heading) if chart_kind == "bar" else generate_vega_pie_chart(cat_values, title=heading)) - return {"kind": "json", "data": chart} if chart else {"kind": "json", "data": result} + return _chart_out(chart) def _as_category_value_dict(result: Any) -> Optional[Dict[str, Union[int, float]]]: @@ -749,6 +847,11 @@ def exec_report_spec( - {"kind":"text","mime": "text/markdown"|"text/html","content": str} - {"kind":"unknown","raw": any} + An analytic result that carries a state envelope (ISSUE-139) also gets + `"envelope": {"state", "reason", "subtitle"}` on any of these shapes. For + SERIES/BAR/PIE the subtitle is put on the chart, and no chart is drawn + unless the state is `measured` or `nothing_found` (ISSUE-140). + 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 @@ -836,9 +939,11 @@ def exec_report_spec( action, params=params, view_server=view_server, view_url=view_url, user=user, user_pass=user_pass, token=token, ) + envelope = _result_envelope(result) + result = _jsonable(result) if result is None or result == [] or result == {}: - return {"kind": "empty"} - return {"kind": "json", "data": result} + return _with_envelope({"kind": "empty"}, envelope) + return _with_envelope({"kind": "json", "data": result}, envelope) required_params = action.get("required_params", action.get("user_params", [])) or [] optional_params = action.get("optional_params", []) or [] diff --git a/tests/functional-tests/test_automated_curation.py b/tests/functional-tests/test_automated_curation.py index 78ea1291..53f55602 100644 --- a/tests/functional-tests/test_automated_curation.py +++ b/tests/functional-tests/test_automated_curation.py @@ -397,7 +397,9 @@ def test_create_uc_server_element_from_template(self): finally: a_client.close_session() - def test_get_engine_actions(self): + def test_find_all_engine_actions(self): + # get_engine_actions was removed (ISSUE-151): the View Server has no list-all + # endpoint, so find_engine_actions("*") is the list-all. try: a_client = AutomatedCuration( self.good_view_server_2, @@ -409,7 +411,7 @@ def test_get_engine_actions(self): start_time = time.perf_counter() - response = a_client.get_engine_actions(request_id="test-id") + response = a_client.find_engine_actions("*") duration = time.perf_counter() - start_time print(f"\n\tDuration was {duration} seconds") print(f"The type of response is: {type(response)}") diff --git a/tests/micro-tests/test_issue_138_140_149_analytic_envelope.py b/tests/micro-tests/test_issue_138_140_149_analytic_envelope.py new file mode 100644 index 00000000..af98a758 --- /dev/null +++ b/tests/micro-tests/test_issue_138_140_149_analytic_envelope.py @@ -0,0 +1,179 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the ODPi Egeria project. +""" +Regression tests for the analytic executor and registry changes requested by +trellis-analytics (PYEGERIA_ISSUES.md): + +- ISSUE-138: `analytic_binders` builds a non-JSON argument (e.g. a reader) from + the call params, so a routine can be named by its own dotted path. +- ISSUE-139: a result's state envelope is returned on every output and put on + the chart as a subtitle. +- ISSUE-140: no chart is drawn unless the state is `measured`. +- ISSUE-149: AnalyticFunctionSpec carries the manifest fields, all optional. + +No live server: the routines live in a fake module, and the spec lookup is patched. +""" +import sys +import types + +import pytest +from pydantic import BaseModel, ValidationError + +import pyegeria.view.format_set_executor as fse +from pyegeria.view.analytic_registry import AnalyticFunctionSpec + +MOD = "fake_trellis_analytics" + + +class Reader: + def __init__(self, url: str = ""): + self.url = url + + +class Env(BaseModel): + result_state: str + result_state_reason: str = "" + + +class CoverageResult(BaseModel): + envelope: Env + published: int = 0 + + +def read_with(ods: Reader, slug: str) -> dict: + return {"state": "measured", "url": ods.url, "slug": slug, "Published": 3, "Not yet published": 1} + + +def measured() -> dict: + return {"state": "measured", "Published": 3, "Not yet published": 1} + + +def not_established() -> dict: + return {"state": "not_established", "state_reason": "could not open the ODS", "Published": 0, "Blocked": 0} + + +def nothing_found() -> dict: + return {"state": "nothing_found", "Published": 0, "Not yet published": 0} + + +def not_applicable() -> dict: + return {"state": "not_applicable", "state_reason": "not a database", "Published": 0, "Other": 0} + + +def plain_counts() -> dict: + return {"A": 1, "B": 2} + + +def model_result() -> CoverageResult: + return CoverageResult(envelope=Env(result_state="nothing_found"), published=0) + + +@pytest.fixture(autouse=True) +def fake_module(monkeypatch): + mod = types.ModuleType(MOD) + for f in (Reader, read_with, measured, not_established, nothing_found, not_applicable, plain_counts, model_result): + setattr(mod, f.__name__, f) + monkeypatch.setitem(sys.modules, MOD, mod) + + def no_egeria(*a, **k): + raise AssertionError("an analytic routine with no client must not build an Egeria client") + + monkeypatch.setattr(fse, "EgeriaTech", no_egeria) + monkeypatch.setattr(fse, "get_report_spec_heading", lambda name: "Heading") + yield + + +def _use_spec(monkeypatch, action: dict): + fmt = {"target_type": None, "action": {"function": None, **action}} + monkeypatch.setattr(fse, "select_report_spec", lambda name, fmt_type: fmt) + + +# ISSUE-138 +def test_binder_builds_the_reader_from_params(monkeypatch): + _use_spec(monkeypatch, {"analytic_function": f"{MOD}.read_with", + "analytic_binders": {"ods": f"{MOD}.Reader"}}) + out = fse.exec_report_spec("tile", output_format="DICT", params={"slug": "db1", "url": "pg://x"}) + assert out["data"]["url"] == "pg://x" # the factory got url + assert out["data"]["slug"] == "db1" # the routine still got its own param + + +def test_binder_accepts_colon_form(monkeypatch): + _use_spec(monkeypatch, {"analytic_function": f"{MOD}.read_with", + "analytic_binders": {"ods": f"{MOD}:Reader"}}) + out = fse.exec_report_spec("tile", output_format="DICT", params={"slug": "db1"}) + assert out["data"]["url"] == "" + + +# ISSUE-139 / ISSUE-140 +def test_measured_bar_gets_subtitle_and_envelope(monkeypatch): + _use_spec(monkeypatch, {"analytic_function": f"{MOD}.measured"}) + out = fse.exec_report_spec("tile", output_format="BAR") + chart = out["data"] + assert "$schema" in chart + assert chart["title"] == {"text": "Heading", "subtitle": "measured"} + assert out["envelope"]["state"] == "measured" + + +def test_not_established_draws_no_chart(monkeypatch): + _use_spec(monkeypatch, {"analytic_function": f"{MOD}.not_established"}) + out = fse.exec_report_spec("tile", output_format="BAR") + assert "$schema" not in out["data"] + assert out["envelope"] == {"state": "not_established", "reason": "could not open the ODS", + "subtitle": "not established · could not open the ODS"} + + +def test_nothing_found_is_charted_as_real_zeros(monkeypatch): + _use_spec(monkeypatch, {"analytic_function": f"{MOD}.nothing_found"}) + out = fse.exec_report_spec("tile", output_format="BAR") + assert "$schema" in out["data"] + assert out["data"]["title"]["subtitle"] == "nothing found" + + +def test_not_applicable_draws_no_chart(monkeypatch): + _use_spec(monkeypatch, {"analytic_function": f"{MOD}.not_applicable"}) + out = fse.exec_report_spec("tile", output_format="BAR") + assert "$schema" not in out["data"] and out["envelope"]["state"] == "not_applicable" + + +def test_dict_output_carries_envelope(monkeypatch): + _use_spec(monkeypatch, {"analytic_function": f"{MOD}.not_established"}) + out = fse.exec_report_spec("tile", output_format="DICT") + assert out["kind"] == "json" and out["envelope"]["state"] == "not_established" + + +def test_model_result_with_envelope_attribute(monkeypatch): + _use_spec(monkeypatch, {"analytic_function": f"{MOD}.model_result"}) + out = fse.exec_report_spec("tile", output_format="DICT") + assert out["envelope"]["state"] == "nothing_found" + assert out["data"]["published"] == 0 # dumped to plain data + + +def test_no_envelope_keeps_old_shape(monkeypatch): + _use_spec(monkeypatch, {"analytic_function": f"{MOD}.plain_counts"}) + out = fse.exec_report_spec("tile", output_format="BAR") + assert "envelope" not in out + assert out["data"]["title"] == "Heading" # plain title, no subtitle + + +# ISSUE-149 +def test_manifest_fields_default_and_existing_entries_still_load(): + spec = AnalyticFunctionSpec(name="x", function=f"{MOD}.measured") + assert (spec.kind, spec.source, spec.version, spec.envelope) == ("routine", "", "", False) + assert spec.annotation_type == "" and spec.resource_types == [] and spec.family == "" and spec.tags == [] + + +def test_manifest_fields_set(): + spec = AnalyticFunctionSpec(name="x", function=f"{MOD}.measured", kind="step", source="ods", + version="0.1.0", envelope=True, annotation_type="ResourceMeasureAnnotation", + resource_types=["database"], family="Curation", tags=["coverage"]) + assert spec.kind == "step" and spec.resource_types == ["database"] and spec.family == "Curation" + + +def test_unknown_source_warns_not_rejects(): + spec = AnalyticFunctionSpec(name="x", function=f"{MOD}.measured", source="kafka") + assert spec.source == "kafka" + + +def test_kind_is_routine_or_step(): + with pytest.raises(ValidationError): + AnalyticFunctionSpec(name="x", function=f"{MOD}.measured", kind="spec") diff --git a/tests/micro-tests/test_issue_148_activity_status_default.py b/tests/micro-tests/test_issue_148_activity_status_default.py new file mode 100644 index 00000000..268583bd --- /dev/null +++ b/tests/micro-tests/test_issue_148_activity_status_default.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the ODPi Egeria project. +""" +Regression test for ISSUE-148 (PYEGERIA_ISSUES.md): seven AssetMaker +action/process queries defaulted `activity_status_list` to ["IN_PROGRESS"], +which silently dropped JournalEntries (no activity status) and every +finished action. The default is now None: no status filter is sent. + +No live server needed: a mocked `_async_make_request` captures the body. +""" +import json +from unittest.mock import MagicMock, patch + +import pytest + +from pyegeria.omvs.asset_maker import AssetMaker + +CALLS = [ + ("find_processes", ("*",)), + ("get_processes_by_category", ("cat",)), + ("get_action_targets", ("g",)), + ("get_actions_for_action_target", ("g",)), + ("get_assigned_actions", ("g",)), + ("get_actions_for_sponsor", ("g",)), + ("get_actions_for_requester", ("g",)), +] + + +def _client(): + with patch("pyegeria.core._base_server_client.BaseServerClient.check_connection", return_value=""): + return AssetMaker(view_server="vs", platform_url="https://localhost:9443", user_id="u", user_pwd="p") + + +def _capture(client): + cap = {} + + async def fake(method, url, body=None, **kwargs): + cap["url"] = url + cap["body"] = json.loads(body) if isinstance(body, str) else (body or {}) + resp = MagicMock() + resp.json = MagicMock(return_value={"elements": []}) + return resp + + client._async_make_request = fake + return cap + + +@pytest.mark.parametrize("name,args", CALLS) +def test_default_sends_no_activity_status_filter(name, args): + client = _client() + cap = _capture(client) + getattr(client, name)(*args) + assert not cap["body"].get("activityStatusList") + + +@pytest.mark.parametrize("name,args", CALLS) +def test_explicit_filter_is_still_sent(name, args): + client = _client() + cap = _capture(client) + getattr(client, name)(*args, activity_status_list=["IN_PROGRESS"]) + assert cap["body"]["activityStatusList"] == ["IN_PROGRESS"] diff --git a/tests/micro-tests/test_issue_151_engine_actions_list_all.py b/tests/micro-tests/test_issue_151_engine_actions_list_all.py new file mode 100644 index 00000000..0af7dd34 --- /dev/null +++ b/tests/micro-tests/test_issue_151_engine_actions_list_all.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the ODPi Egeria project. +""" +Regression test for ISSUE-151 (PYEGERIA_ISSUES.md): `get_engine_actions` posted +to `automated-curation/engine-actions`, which the View Server does not have +(404). It was removed; `find_engine_actions("*")` is the list-all, and +`print_engine_actions` now uses it. + +No live server needed: a mocked `_async_make_request` captures the URL. +""" +import json +from unittest.mock import MagicMock, patch + +from pyegeria.omvs.automated_curation import AutomatedCuration + + +def _client(): + with patch("pyegeria.core._base_server_client.BaseServerClient.check_connection", return_value=""): + return AutomatedCuration(view_server="vs", platform_url="https://localhost:9443", + user_id="u", user_pwd="p") + + +def test_get_engine_actions_is_removed(): + client = _client() + assert not hasattr(client, "get_engine_actions") + assert not hasattr(client, "_async_get_engine_actions") + + +def test_print_engine_actions_uses_asset_search(): + client = _client() + cap = {} + + async def fake(method, url, body=None, **kwargs): + cap["url"] = url + cap["body"] = json.loads(body) if isinstance(body, str) else (body or {}) + resp = MagicMock() + resp.json = MagicMock(return_value={"elements": []}) + return resp + + client._async_make_request = fake + client.print_engine_actions() + + assert cap["url"].endswith("/automated-curation/assets/by-search-string") + assert not cap["url"].endswith("/engine-actions") + assert cap["body"].get("metadataElementTypeName") == "EngineAction"