From c58b62f5912ec2153233c8bdb108ffcc1cbec571 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sun, 4 Oct 2026 17:18:43 -0500 Subject: [PATCH 1/3] fix(asset_maker): stop sending CatalogTarget as metadataElementTypeName (ISSUE-122) get_catalog_target(s) put the relationship type name "CatalogTarget" in the request body as metadataElementTypeName; the server rejects it with OMAG-COMMON-400-019. Add filter_results_by_type to _async_get_guid_request (mirroring the results helper) and pass False from both methods. Live verification also showed get_catalog_target's DICT/MD output was blank because the endpoint returns a relationship, so add a small formatter for it. CatalogTargetProperties gains connectionName, metadataCollectionQualifiedName, permittedSynchronization and deleteMethod, which extra='ignore' was dropping. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Dan Wolfson --- PYEGERIA_ISSUES.md | 67 +++++++++++ pyegeria/core/_server_client.py | 7 +- pyegeria/omvs/asset_maker.py | 63 +++++++++- .../test_asset_maker_catalog_targets.py | 109 ++++++++++++++++++ 4 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 tests/micro-tests/test_asset_maker_catalog_targets.py diff --git a/PYEGERIA_ISSUES.md b/PYEGERIA_ISSUES.md index dedd4c26..8714a719 100644 --- a/PYEGERIA_ISSUES.md +++ b/PYEGERIA_ISSUES.md @@ -1119,6 +1119,73 @@ 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-122: `AssetMaker.get_catalog_targets` / `get_catalog_target` send `metadataElementTypeName="CatalogTarget"` (a relationship type) — server rejects with OMAG-COMMON-400-019, surfaced as SERVER_ERROR_500 + +**Status: fixed on branch `fix/issue-122-catalog-target-type` (2026-10-04), pending PR/merge** +(logged 2026-10-04 by the Resource Explorer design session; found read-only while +checking which catalog targets the PostgreSQL cataloguers hold). + +**Fix + live verification (2026-10-04):** both methods now pass +`filter_results_by_type=False`; `_async_get_guid_request` gained that flag +(default `True`, mirroring `_async_get_results_body_request`). +`CatalogTargetProperties` gained `connectionName`, `metadataCollectionQualifiedName`, +`permittedSynchronization`, `deleteMethod`. Verified live with one throwaway Asset + +one CatalogTarget on the JDBC cataloguer (both removed afterwards; confirmed +not-found by GUID): list JSON/DICT/MD and single JSON work. **Second defect found +by that run:** `get_catalog_target` DICT/MD output was an all-blank record, because +the endpoint returns a *relationship* (`relationshipGUID`, `elementAtEnd1/2`) and the +element formatter has nothing to read. Added `_generate_catalog_target_output` for +the single get; it is verified against a stubbed copy of the live shape only, not +re-run live. The daemon's log during the ~1s window was not inspected. + +Original report follows. + +Both methods pass `_type="CatalogTarget"` into the generic results/guid request +helpers, which put it in the request body as `metadataElementTypeName` +(`pyegeria/omvs/asset_maker.py:1371` in `_async_get_catalog_target`, `:1480` in +`_async_get_catalog_targets`, present at 6.1.27). `CatalogTarget` is the +*relationship* type between an integration connector and its target, not an +element type, so the view server answers: + +``` +OMAG-COMMON-400-019 ... CatalogTarget ... is not a sub-type of OpenMetadataRoot +``` + +which pyegeria surfaces as `SERVER_ERROR_500`. The call therefore always fails +as shipped; nobody can list a connector's catalog targets through this client. + +**How to trigger:** +```python +am = AssetMaker("qs-view-server", "https://localhost:9443", user, pw) +am.create_egeria_bearer_token(user, pw) +am.get_catalog_targets("70dcd0b7-9f06-48ad-ad44-ae4d7a7762aa") # JDBC cataloguer +# -> PyegeriaException SERVER_ERROR_500 wrapping OMAG-COMMON-400-019 +``` + +**Working alternative (confirmed live 2026-10-04):** supply a body with no +element type, so the helper does not inject `CatalogTarget`: +```python +am.get_catalog_targets( + "70dcd0b7-9f06-48ad-ad44-ae4d7a7762aa", + body={"class": "ResultsRequestBody", "graphQueryDepth": 0}, +) +# -> "No elements found" (zero targets on this platform) +``` +The same applies to `get_catalog_target(relationship_guid)`. + +**Likely fix (not made):** drop the `_type="CatalogTarget"` argument in both +methods (or pass the element type the caller wants, default none), since the +endpoint already scopes to catalog targets by URL. Not verified: the +per-target response shape of a non-empty result (no targets exist on the dev +platform to read), and whether `_generate_referenceable_output` handles the +relationship-plus-element shape the server returns. + +Related: Resource Explorer's `evidence/CATALOGUE-LEVER-FINDINGS.md` §4–5 +(trellis repo), which also notes `CatalogTargetProperties` (`asset_maker.py:46-51`) +lacks `deleteMethod`, `permittedSynchronization`, `connectionName` and +`metadataCollectionQualifiedName`, which the Java relationship accepts. + + --- ### ISSUE-114: `get_guid_for_name`'s miss-sentinel string ("No elements found") is truthy and repeatedly fools callers' existence checks — a caller guideline, not a candidate fix here diff --git a/pyegeria/core/_server_client.py b/pyegeria/core/_server_client.py index ca2a7520..ac6c5530 100644 --- a/pyegeria/core/_server_client.py +++ b/pyegeria/core/_server_client.py @@ -6638,9 +6638,14 @@ async def _async_get_guid_request(self, url: str, _type: str, _gen_output: Calla body: Optional[dict | GetRequestBody | ResultsRequestBody] = None, max_mermaid_node_count=10, body_model: type[GetRequestBody | ResultsRequestBody] = GetRequestBody, + filter_results_by_type: bool = True, **kwargs) -> Any: """Retrieve an element by GUID. + Set `filter_results_by_type=False` when `_type` is only a rendering hint (or names a + relationship type, which the server rejects as a metadataElementTypeName), so it is + not sent in the default request body. + `body_model` selects the request-body class to send. It defaults to GetRequestBody, which is what nearly every endpoint reached through this helper documents. A few endpoints document ResultsRequestBody @@ -6659,7 +6664,7 @@ async def _async_get_guid_request(self, url: str, _type: str, _gen_output: Calla _type = _type.replace(" ", "") body = { "class": body_model.__name__, - "metadataElementTypeName": _type, + "metadataElementTypeName": _type if filter_results_by_type else None, "includeOnlyRelationships": include_only_relationships, "skipRelationships": skip_relationships, "graphQueryDepth": graph_query_depth, diff --git a/pyegeria/omvs/asset_maker.py b/pyegeria/omvs/asset_maker.py index 38d2f35a..78e9db53 100644 --- a/pyegeria/omvs/asset_maker.py +++ b/pyegeria/omvs/asset_maker.py @@ -47,6 +47,12 @@ class CatalogTargetProperties(ReferenceableProperties): """Properties for Catalog Target relationships""" catalogTargetName: str | None = None metadataSourceQualifiedName: str | None = None + connectionName: str | None = None + metadataCollectionQualifiedName: str | None = None + # One of BOTH_DIRECTIONS, FROM_THIRD_PARTY, TO_THIRD_PARTY (Egeria PermittedSynchronization) + permittedSynchronization: str | None = None + # One of LOOK_FOR_LINEAGE, ARCHIVE, SOFT_DELETE, PURGE (see DeleteMethod) + deleteMethod: str | None = None templates: dict | None = None configurationProperties: dict | None = None @@ -1369,7 +1375,8 @@ async def _async_get_catalog_target( return await self._async_get_guid_request( url, _type="CatalogTarget", - _gen_output=self._generate_referenceable_output, + _gen_output=self._generate_catalog_target_output, + filter_results_by_type=False, # CatalogTarget is a relationship type, not an element type graph_query_depth=graph_query_depth, output_format=output_format, report_spec=report_spec, @@ -1479,6 +1486,7 @@ async def _async_get_catalog_targets( url, _type="CatalogTarget", _gen_output=self._generate_referenceable_output, + filter_results_by_type=False, # CatalogTarget is a relationship type, not an element type graph_query_depth=graph_query_depth, start_from=start_from, page_size=page_size, @@ -6799,6 +6807,59 @@ def get_integration_groups( ) ) + def _generate_catalog_target_output( + self, + elements: list | dict, + filter_string: Optional[str] = None, + element_type_name: Optional[str] = None, + output_format: str = "DICT", + report_spec: dict | str | None = None, + **kwargs + ): + """Format a single CatalogTarget *relationship* (ISSUE-122). + + get_catalog_target returns a relationship (relationshipGUID / relationshipProperties / + elementAtEnd1 / elementAtEnd2), not an element, so the element formatters return an + all-blank record for it. `report_spec` is not applied: no spec describes a relationship. + """ + rel = elements[0] if isinstance(elements, list) and elements else elements + if not isinstance(rel, dict): + return rel + + def _end(n: int) -> dict: + end = rel.get(f"elementAtEnd{n}") or {} + props = end.get("properties") or {} + return { + "guid": rel.get(f"elementGUIDAtEnd{n}") or (end.get("elementHeader") or {}).get("guid", ""), + "type": ((end.get("elementHeader") or end).get("type") or {}).get("typeName", ""), + "displayName": props.get("displayName", ""), + "qualifiedName": props.get("qualifiedName", ""), + } + + end1, end2 = _end(1), _end(2) + props = (rel.get("relationshipProperties") or {}).get("propertiesAsStrings") or {} + row = { + "GUID": rel.get("relationshipGUID", ""), + "Type Name": (rel.get("relationshipType") or rel.get("type") or {}).get("typeName", "CatalogTarget"), + "Catalog Target Name": props.get("catalogTargetName", ""), + "Integration Connector GUID": end1["guid"], + "Integration Connector": end1["displayName"] or end1["qualifiedName"], + "Target Element GUID": end2["guid"], + "Target Element": end2["displayName"] or end2["qualifiedName"], + "Target Element Type": end2["type"], + "Relationship Properties": props, + } + if output_format.upper() == "DICT": + return [row] + lines = ["## Catalog Target", ""] + for k, v in row.items(): + if k == "Relationship Properties": + lines += [f"### {k}", "| Parameter Name | Parameter Value |", "| :--- | :--- |"] + lines += [f"| {pk} | {pv} |" for pk, pv in v.items()] + else: + lines += [f"### {k}", str(v), ""] + return "\n".join(lines) + "\n" + def _generate_referenceable_output( self, elements: list | dict, diff --git a/tests/micro-tests/test_asset_maker_catalog_targets.py b/tests/micro-tests/test_asset_maker_catalog_targets.py new file mode 100644 index 00000000..c2a65d59 --- /dev/null +++ b/tests/micro-tests/test_asset_maker_catalog_targets.py @@ -0,0 +1,109 @@ +""" +ISSUE-122: AssetMaker.get_catalog_target(s) must not send +metadataElementTypeName="CatalogTarget" -- that is a relationship type, and the +server rejects it (OMAG-COMMON-400-019). +""" +import json + +import pytest + +from pyegeria.omvs.asset_maker import AssetMaker, CatalogTargetProperties + + +class _Response: + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + +def _client_capturing(bodies: list, payload: dict, monkeypatch) -> AssetMaker: + monkeypatch.setattr(AssetMaker, "check_connection", lambda self: "test") + client = AssetMaker("view-server", "https://localhost:1", "user", "pwd") + + async def fake_make_request(method, url, body=None, *args, **kwargs): + bodies.append(json.loads(body) if isinstance(body, str) else body) + return _Response(payload) + + client._async_make_request = fake_make_request + return client + + +@pytest.mark.asyncio +async def test_get_catalog_targets_sends_no_element_type(monkeypatch): + bodies: list = [] + client = _client_capturing(bodies, {"elements": []}, monkeypatch) + + await client._async_get_catalog_targets("conn-guid", output_format="JSON") + + assert "metadataElementTypeName" not in bodies[0] + + +@pytest.mark.asyncio +async def test_get_catalog_target_sends_no_element_type(monkeypatch): + bodies: list = [] + client = _client_capturing(bodies, {"element": {}}, monkeypatch) + + await client._async_get_catalog_target("rel-guid", output_format="JSON") + + assert "metadataElementTypeName" not in bodies[0] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("flag, expected", [(True, "Widget"), (False, None)]) +async def test_guid_request_filter_flag(flag, expected, monkeypatch): + bodies: list = [] + client = _client_capturing(bodies, {"element": {}}, monkeypatch) + + await client._async_get_guid_request( + "https://localhost:1/x", "Widget", lambda **kw: kw, output_format="JSON", + filter_results_by_type=flag) + + assert bodies[0].get("metadataElementTypeName") == expected + + +def test_catalog_target_properties_keeps_relationship_fields(): + props = CatalogTargetProperties( + qualifiedName="q", displayName="d", catalogTargetName="t", connectionName="c", metadataCollectionQualifiedName="m", + permittedSynchronization="TO_THIRD_PARTY", deleteMethod="SOFT_DELETE") + dumped = props.model_dump(exclude_none=True, by_alias=True) + for key in ("connectionName", "metadataCollectionQualifiedName", + "permittedSynchronization", "deleteMethod"): + assert key in dumped + + +# Trimmed from a live get_catalog_target response (2026-10-04): a relationship, not an element. +_REL = { + "relationshipGUID": "rel-1", + "relationshipType": {"typeName": "CatalogTarget"}, + "relationshipProperties": {"propertiesAsStrings": {"catalogTargetName": "tgt"}}, + "elementGUIDAtEnd1": "conn-1", + "elementAtEnd1": {"type": {"typeName": "IntegrationConnector"}, + "properties": {"displayName": "JDBC Cataloguer"}}, + "elementGUIDAtEnd2": "asset-1", + "elementAtEnd2": {"type": {"typeName": "Asset"}, "properties": {"qualifiedName": "q-asset"}}, +} + + +@pytest.mark.asyncio +async def test_get_catalog_target_dict_is_not_blank(monkeypatch): + client = _client_capturing([], {"element": _REL}, monkeypatch) + + rows = await client._async_get_catalog_target("rel-1", output_format="DICT") + + assert rows[0]["GUID"] == "rel-1" + assert rows[0]["Catalog Target Name"] == "tgt" + assert rows[0]["Integration Connector"] == "JDBC Cataloguer" + assert rows[0]["Target Element GUID"] == "asset-1" + assert rows[0]["Target Element"] == "q-asset" + + +@pytest.mark.asyncio +async def test_get_catalog_target_md_is_not_blank(monkeypatch): + client = _client_capturing([], {"element": _REL}, monkeypatch) + + md = await client._async_get_catalog_target("rel-1", output_format="MD") + + assert "rel-1" in md and "tgt" in md and "JDBC Cataloguer" in md + assert "NO DISPLAY NAME" not in md From 0004ec6c4aa1fa27a1d4153326ce99427cd4e3a0 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sun, 4 Oct 2026 17:23:54 -0500 Subject: [PATCH 2/3] test(my_profile): pin the user in the own-subscriptions screen test The screen filters on settings.User_Profile.user_name (EGERIA_USER), falling back to garygeeke only when unset; the test hard-coded garygeeke as 'mine', so it failed on any machine with EGERIA_USER set to another user. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Dan Wolfson --- tests/micro-tests/my_profile/test_screens.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/micro-tests/my_profile/test_screens.py b/tests/micro-tests/my_profile/test_screens.py index 738a2ee2..80d236b1 100644 --- a/tests/micro-tests/my_profile/test_screens.py +++ b/tests/micro-tests/my_profile/test_screens.py @@ -566,7 +566,14 @@ async def test_lists_own_subscriptions(self, mock_egeria): self._subscription("sub-1", "Sales Feed", "garygeeke", "PROPOSED"), self._subscription("sub-2", "Not Mine", "erinoverview"), ] - app = ScreenTestHostApp(ViewSubscriptionsScreen) + def screen_for_garygeeke(): + # The screen takes its user from settings (EGERIA_USER); pin it so the + # "own subscriptions" filter doesn't depend on the developer's environment. + screen = ViewSubscriptionsScreen() + screen.user_name = "garygeeke" + return screen + + app = ScreenTestHostApp(screen_for_garygeeke) async with app.run_test() as pilot: await pilot.pause() assert client.find_collections.call_args.kwargs["metadata_element_type_name"] == "DigitalSubscription" From d598edc94cde04d4cfbbddb26839bf4bf4f7e94b Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sun, 4 Oct 2026 17:25:32 -0500 Subject: [PATCH 3/3] fix(asset_maker): read catalog-target end names from uniqueName Live verification showed the relationship's elementAtEnd1/2 are element stubs (guid/uniqueName/type) with no properties block, so the formatter's connector and target names were blank. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Dan Wolfson --- PYEGERIA_ISSUES.md | 6 ++++-- pyegeria/omvs/asset_maker.py | 14 +++++++------- .../test_asset_maker_catalog_targets.py | 9 +++++---- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/PYEGERIA_ISSUES.md b/PYEGERIA_ISSUES.md index 8714a719..654cee79 100644 --- a/PYEGERIA_ISSUES.md +++ b/PYEGERIA_ISSUES.md @@ -1135,8 +1135,10 @@ not-found by GUID): list JSON/DICT/MD and single JSON work. **Second defect foun by that run:** `get_catalog_target` DICT/MD output was an all-blank record, because the endpoint returns a *relationship* (`relationshipGUID`, `elementAtEnd1/2`) and the element formatter has nothing to read. Added `_generate_catalog_target_output` for -the single get; it is verified against a stubbed copy of the live shape only, not -re-run live. The daemon's log during the ~1s window was not inspected. +the single get and verified it live in a second throwaway run (also removed, +not-found by GUID): the relationship's two ends are element stubs +(`guid`/`uniqueName`/`type`, no `properties`), so names come from `uniqueName`. +The daemon's log during the ~1s windows was not inspected. Original report follows. diff --git a/pyegeria/omvs/asset_maker.py b/pyegeria/omvs/asset_maker.py index 78e9db53..2d71cf2c 100644 --- a/pyegeria/omvs/asset_maker.py +++ b/pyegeria/omvs/asset_maker.py @@ -6827,13 +6827,13 @@ def _generate_catalog_target_output( return rel def _end(n: int) -> dict: + # The ends are element stubs (guid / uniqueName / type), not full elements -- + # confirmed live 2026-10-04; there is no `properties` block to read names from. end = rel.get(f"elementAtEnd{n}") or {} - props = end.get("properties") or {} return { - "guid": rel.get(f"elementGUIDAtEnd{n}") or (end.get("elementHeader") or {}).get("guid", ""), - "type": ((end.get("elementHeader") or end).get("type") or {}).get("typeName", ""), - "displayName": props.get("displayName", ""), - "qualifiedName": props.get("qualifiedName", ""), + "guid": rel.get(f"elementGUIDAtEnd{n}") or end.get("guid", ""), + "type": (end.get("type") or {}).get("typeName", ""), + "name": end.get("uniqueName", ""), } end1, end2 = _end(1), _end(2) @@ -6843,9 +6843,9 @@ def _end(n: int) -> dict: "Type Name": (rel.get("relationshipType") or rel.get("type") or {}).get("typeName", "CatalogTarget"), "Catalog Target Name": props.get("catalogTargetName", ""), "Integration Connector GUID": end1["guid"], - "Integration Connector": end1["displayName"] or end1["qualifiedName"], + "Integration Connector": end1["name"], "Target Element GUID": end2["guid"], - "Target Element": end2["displayName"] or end2["qualifiedName"], + "Target Element": end2["name"], "Target Element Type": end2["type"], "Relationship Properties": props, } diff --git a/tests/micro-tests/test_asset_maker_catalog_targets.py b/tests/micro-tests/test_asset_maker_catalog_targets.py index c2a65d59..3dd78e76 100644 --- a/tests/micro-tests/test_asset_maker_catalog_targets.py +++ b/tests/micro-tests/test_asset_maker_catalog_targets.py @@ -73,16 +73,17 @@ def test_catalog_target_properties_keeps_relationship_fields(): assert key in dumped -# Trimmed from a live get_catalog_target response (2026-10-04): a relationship, not an element. +# Trimmed from a live get_catalog_target response (2026-10-04): a relationship, not an element; +# its ends are element stubs (guid / uniqueName / type), with no `properties` block. _REL = { "relationshipGUID": "rel-1", "relationshipType": {"typeName": "CatalogTarget"}, "relationshipProperties": {"propertiesAsStrings": {"catalogTargetName": "tgt"}}, "elementGUIDAtEnd1": "conn-1", - "elementAtEnd1": {"type": {"typeName": "IntegrationConnector"}, - "properties": {"displayName": "JDBC Cataloguer"}}, + "elementAtEnd1": {"guid": "conn-1", "type": {"typeName": "IntegrationConnector"}, + "uniqueName": "JDBC Cataloguer"}, "elementGUIDAtEnd2": "asset-1", - "elementAtEnd2": {"type": {"typeName": "Asset"}, "properties": {"qualifiedName": "q-asset"}}, + "elementAtEnd2": {"guid": "asset-1", "type": {"typeName": "Asset"}, "uniqueName": "q-asset"}, }