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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,613 changes: 816 additions & 797 deletions PYEGERIA_ISSUES.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -3946,7 +3946,9 @@
"bundle": "Data Value Assignment Base",
"custom_attributes": [
"Data Value Specification",
"Element Id"
"Element Id",
"Label",
"Description"
],
"Journal Entry": ""
},
Expand Down
2 changes: 2 additions & 0 deletions md_processing/v2/curation.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ class ClassificationSpec:
"Criticality": ClassificationSpec(
"_async_set_criticality_classification", "_async_clear_criticality_classification", "CriticalityProperties",
level_field="criticalityLevel", fields=dict(_GOVERNANCE_SHARED_FIELDS)),
# Old class name on purpose: Egeria main (RetentionProperties) accepts it as an alias; older servers
# know only this one (ISSUE-154).
"Retention": ClassificationSpec(
"_async_set_retention_classification", "_async_clear_retention_classification", "RetentionClassificationProperties",
level_field="retentionBasis", # "Retention Basis" attribute holds an enum, reuse level_field slot
Expand Down
3 changes: 3 additions & 0 deletions md_processing/v2/data_designer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,9 @@ async def apply_changes(self) -> str:
body = set_rel_request_body("DataValueAssignment", attributes)
body["properties"] = {
"class": "DataValueAssignmentProperties",
# label/description come from LabeledRelationshipProperties (ISSUE-154 item 4).
"label": attributes.get("Label", {}).get("value"),
"description": attributes.get("Description", {}).get("value"),
"assignmentStatus": attributes.get("Assignment Status", {}).get("value"),
"confidence": attributes.get("Confidence", {}).get("value"),
"method": attributes.get("Method", {}).get("value"),
Expand Down
18 changes: 18 additions & 0 deletions md_processing/v2/governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ def _requires_base_governance_definition_properties(exc: Exception) -> bool:
and "governancedefinitionproperties" in msg
)

@staticmethod
def _fields_dropped_by_base_fallback(create_props: Dict[str, Any], fallback_props: Dict[str, Any]) -> list[str]:
"""The set (non-empty) properties the base GovernanceDefinitionProperties retry would not send.
A field the fallback moves into additionalProperties (SecurityGroup's distinguishedName) is kept."""
carried = fallback_props.get("additionalProperties") or {}
return sorted(
k for k, v in create_props.items()
if k not in fallback_props and k not in carried and k != "class" and v not in (None, "", [], {})
)

@staticmethod
def _to_base_governance_definition_properties(properties: Dict[str, Any], object_type: str) -> Dict[str, Any]:
source = dict(properties)
Expand Down Expand Up @@ -213,6 +223,14 @@ async def apply_changes(self) -> str:
raise
retry_body = dict(body)
retry_body["properties"] = self._to_base_governance_definition_properties(dict(create_props), object_type)
# The base-class retry keeps only cross-type fields. Creating the element without the
# type-specific ones and reporting success lost them silently (ISSUE-158), so refuse instead.
dropped = self._fields_dropped_by_base_fallback(create_props, retry_body["properties"])
if dropped:
raise ValueError(
f"Egeria would only create this {object_type} as a plain governance definition "
f"(OMAG-COMMON-400-029), which would drop: {', '.join(dropped)}. Not created."
) from exc
logger.debug(
f"Create SecurityGroup fallback payload properties: {json.dumps(retry_body.get('properties', {}), sort_keys=True)}"
)
Expand Down
12 changes: 4 additions & 8 deletions md_processing/v2/processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,14 +217,10 @@ def add_related_result(self, label: str, guid: Optional[str] = None, status: str
{"UNCLASSIFIED": 0, "MARGINAL": 1, "IMPORTANT": 2, "CRITICAL": 3, "CATASTROPHIC": 4, "OTHER": 99},
),
"Retention Classification": (
# "RetentionClassificationProperties" (the server's registered Jackson
# subtype ID for this classification - confirmed live via its own
# InvalidTypeIdException error listing all valid ids) is the correct
# class name to send, despite the real Java properties class's own
# simple name being "RetentionProperties" - do not "fix" this to
# "RetentionProperties", that's the wrong direction (confirmed live
# 2026-08-03: sending "RetentionProperties" gets rejected by Jackson
# outright as an unrecognized subtype id).
# Send "RetentionClassificationProperties". Servers before 2026-10 know
# only that subtype id (sending "RetentionProperties" was rejected live
# on 2026-08-03). Egeria main renamed it "RetentionProperties" and keeps
# the old name as an alias, so the old name works on both (ISSUE-154).
#
# Previously blocked server-side (see BACKLOG.md history) by
# OMRS-REPOSITORY-400-028 ("a property called statusIdentifier ...
Expand Down
19 changes: 16 additions & 3 deletions pyegeria/core/_server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@

# Main Client Imports
from pyegeria.models.models import (
DeleteMethod,
NewElementRequestBody,
NewAttachmentRequestBody,
UpdateElementRequestBody,
Expand Down Expand Up @@ -6202,6 +6203,18 @@ def validate_new_classification_request(self, body: dict | NewClassificationRequ

return validated_body

@staticmethod
def _archive_delete_body(body: Optional[dict | DeleteElementRequestBody] = None) -> dict | DeleteElementRequestBody:
"""Return a copy of a delete request body with deleteMethod ARCHIVE (ISSUE-159). Egeria routes an
ARCHIVE delete to its archive operation: the element is classified Memento and its qualifiedName
gets an `_archivedOn_<date>` suffix, and MetadataExpert.restore_metadata_element reverses it."""
if isinstance(body, DeleteElementRequestBody):
return body.model_copy(update={"delete_method": DeleteMethod.ARCHIVE})
body = dict(body or {})
body.setdefault("class", "DeleteElementRequestBody")
body["deleteMethod"] = DeleteMethod.ARCHIVE.value
return body

@dynamic_catch
def validate_delete_element_request(self, body: Optional[dict | DeleteElementRequestBody] = None,
cascade_delete: bool = False) -> DeleteElementRequestBody | None:
Expand All @@ -6212,7 +6225,7 @@ def validate_delete_element_request(self, body: Optional[dict | DeleteElementReq
else: # handle case where body not provided
body = {
"class": "DeleteElementRequestBody",
"cascadeDelete": cascade_delete
"cascadedDelete": cascade_delete
}
validated_body = self._validate_body(DeleteElementRequestBody.model_validate, body)
return validated_body
Expand All @@ -6227,7 +6240,7 @@ def validate_delete_relationship_request(self, body: dict | DeleteRelationshipRe
else: # handle case where body not provided
# body = {
# "class": "DeleteRelationshipRequestBody",
# "cascadeDelete": cascade_delete
# "cascadedDelete": cascade_delete
# }
# validated_body = self._validate_body(DeleteRelationshipRequestBody.model_validate, body)
return None
Expand All @@ -6243,7 +6256,7 @@ def validate_delete_classification_request(self, body: dict | DeleteClassificati
else: # handle case where body not provided
body = {
"class": "DeleteClassificationRequestBody",
"cascadeDelete": cascade_delete
"cascadedDelete": cascade_delete
}
validated_body = self._validate_body(DeleteClassificationRequestBody.model_validate, body)
return validated_body
Expand Down
18 changes: 5 additions & 13 deletions pyegeria/models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,22 +413,14 @@ class DeleteElementRequestBody(RequestBody):
# DeleteElementRequestBody bodies carry "deleteMethod" consistently --
# no spelling ambiguity found for this field.
delete_method: Optional[DeleteMethod] = None
# cascadeDelete: ground-truth .http files are genuinely split roughly
# 50/50 between "cascadeDelete" and "cascadedDelete" for this same class
# -- even within a single file (Egeria-api-actor-manager.http uses
# both). Not resolved here; "cascadeDelete" was chosen as the
# OUTGOING/serialized name because it's what this repo's own code has
# already been trying to send since before this fix
# (validate_delete_element_request's no-body-provided branch). Both
# spellings are accepted on input via validation_alias so a
# caller-supplied dict using either one still populates correctly.
# Flagged in PYEGERIA_ISSUES.md ISSUE-62 for live verification -- if a
# given delete endpoint actually expects "cascadedDelete" instead, this
# choice would need revisiting for that specific endpoint.
# cascadedDelete is Egeria's field (DeleteOptions.java). pyegeria sent "cascadeDelete" from the
# .http files until 6.2.15; Egeria ignored it until odpi/egeria#9365 added it as a @JsonAlias, so
# no cascade reached an older server (ISSUE-132). Sending cascadedDelete works on old and new
# servers (ISSUE-154 item 1). Both spellings are accepted on input.
cascade_delete: Optional[bool] = Field(
default=None,
validation_alias=AliasChoices("cascadeDelete", "cascadedDelete"),
serialization_alias="cascadeDelete",
serialization_alias="cascadedDelete",
)
archive_date: datetime | None = None
archive_process: str | None = None
Expand Down
21 changes: 12 additions & 9 deletions pyegeria/omvs/actor_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3614,7 +3614,10 @@ async def _async_add_security_group_membership(self, user_identity_guid: str, se
"""

url = (f"{self.command_root}/user-identities/{user_identity_guid}/security-group-memberships/classify")
await self._async_new_classification_request(url, ["SecurityGroupMembershipProperties"], body)
# Egeria renamed the class SecurityListMembershipProperties (main, 2026-10); it still accepts the
# old name, which is the only one older servers know, so accept both (ISSUE-154).
await self._async_new_classification_request(
url, ["SecurityGroupMembershipProperties", "SecurityListMembershipProperties"], body)
logger.debug(f"Classifying User Identity {user_identity_guid} with Security Groups {security_groups}")

@dynamic_catch
Expand Down Expand Up @@ -4773,7 +4776,7 @@ async def _async_delete_contribution_record(self, contribution_record_guid: str,
example:
{
"class" : "DeleteElementRequestBody",
"cascadeDelete" : false,
"cascadedDelete" : false,
"externalSourceGUID": "add guid here",
"externalSourceName": "add qualified name here",
"effectiveTime" : "{{$isoTimestamp}}",
Expand Down Expand Up @@ -4814,7 +4817,7 @@ def delete_contribution_record(self, contribution_record_guid: str, body: Option
example:
{
"class" : "DeleteElementRequestBody",
"cascadeDelete" : false,
"cascadedDelete" : false,
"externalSourceGUID": "add guid here",
"externalSourceName": "add qualified name here",
"effectiveTime" : "{{$isoTimestamp}}",
Expand Down Expand Up @@ -5767,7 +5770,7 @@ async def _async_delete_contact_details(self, contact_details_guid: str, body: O
example:
{
"class" : "DeleteElementRequestBody",
"cascadeDelete" : false,
"cascadedDelete" : false,
"externalSourceGUID": "add guid here",
"externalSourceName": "add qualified name here",
"effectiveTime" : "{{$isoTimestamp}}",
Expand Down Expand Up @@ -5808,7 +5811,7 @@ def delete_contact_details(self, contact_details_guid: str, body: Optional[dict
example:
{
"class" : "DeleteElementRequestBody",
"cascadeDelete" : false,
"cascadedDelete" : false,
"externalSourceGUID": "add guid here",
"externalSourceName": "add qualified name here",
"effectiveTime" : "{{$isoTimestamp}}",
Expand Down Expand Up @@ -6761,7 +6764,7 @@ async def _async_delete_perspective(self, perspective_guid: str, body: Optional[
example:
{
"class" : "DeleteElementRequestBody",
"cascadeDelete" : false,
"cascadedDelete" : false,
"deleteMethod" : "LOOK_FOR_LINEAGE",
"externalSourceGUID": "add guid here",
"externalSourceName": "add qualified name here",
Expand Down Expand Up @@ -6803,7 +6806,7 @@ def delete_perspective(self, perspective_guid: str, body: Optional[dict | Delete
example:
{
"class" : "DeleteElementRequestBody",
"cascadeDelete" : false,
"cascadedDelete" : false,
"deleteMethod" : "LOOK_FOR_LINEAGE",
"externalSourceGUID": "add guid here",
"externalSourceName": "add qualified name here",
Expand Down Expand Up @@ -7567,7 +7570,7 @@ async def _async_delete_skill(self, skill_guid: str, body: Optional[dict | Delet
example:
{
"class" : "DeleteElementRequestBody",
"cascadeDelete" : false,
"cascadedDelete" : false,
"deleteMethod" : "LOOK_FOR_LINEAGE",
"externalSourceGUID": "add guid here",
"externalSourceName": "add qualified name here",
Expand Down Expand Up @@ -7609,7 +7612,7 @@ def delete_skill(self, skill_guid: str, body: Optional[dict | DeleteElementReque
example:
{
"class" : "DeleteElementRequestBody",
"cascadeDelete" : false,
"cascadedDelete" : false,
"deleteMethod" : "LOOK_FOR_LINEAGE",
"externalSourceGUID": "add guid here",
"externalSourceName": "add qualified name here",
Expand Down
4 changes: 3 additions & 1 deletion pyegeria/omvs/classification_explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8874,8 +8874,10 @@ async def _async_set_retention_classification(
f"{self.classification_command_root}/elements/{element_guid}/retention"
)

# Egeria renamed the class RetentionProperties (main, 2026-10); it still accepts the old name,
# which is the only one older servers know, so accept both (ISSUE-154).
await self._async_new_classification_request(
url, prop=["RetentionClassificationProperties"], body=body
url, prop=["RetentionClassificationProperties", "RetentionProperties"], body=body
)

def set_retention_classification(
Expand Down
37 changes: 37 additions & 0 deletions pyegeria/omvs/collection_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -6602,6 +6602,43 @@ def delete_collection(self, collection_guid: str, body: Optional[dict | DeleteEl
loop = asyncio.get_event_loop()
loop.run_until_complete(self._async_delete_collection(collection_guid, body, cascade))

@dynamic_catch
async def _async_archive_collection(self, collection_guid: str,
body: Optional[dict | DeleteElementRequestBody] = None) -> None:
"""Archive a collection rather than delete it (ISSUE-159). Sends the collection delete with deleteMethod
ARCHIVE: Egeria classifies the collection Memento, so it stays readable for lineage, and renames its
qualifiedName with an `_archivedOn_<date>` suffix. Reverse it with
MetadataExpert.restore_metadata_element. Async version.

Parameters
----------
collection_guid: str
The guid of the collection to archive.
body: dict | DeleteElementRequestBody, optional
May set archiveDate, archiveProcess and archiveProperties; deleteMethod is always ARCHIVE.
"""
url = f"{self.collection_command_root}/{collection_guid}/delete"
await self._async_delete_element_request(url, self._archive_delete_body(body))
logger.info(f"Archived collection {collection_guid}")

@dynamic_catch
def archive_collection(self, collection_guid: str,
body: Optional[dict | DeleteElementRequestBody] = None) -> None:
"""Archive a collection rather than delete it (ISSUE-159). Sends the collection delete with deleteMethod
ARCHIVE: Egeria classifies the collection Memento, so it stays readable for lineage, and renames its
qualifiedName with an `_archivedOn_<date>` suffix. Reverse it with
MetadataExpert.restore_metadata_element.

Parameters
----------
collection_guid: str
The guid of the collection to archive.
body: dict | DeleteElementRequestBody, optional
May set archiveDate, archiveProcess and archiveProperties; deleteMethod is always ARCHIVE.
"""
loop = asyncio.get_event_loop()
loop.run_until_complete(self._async_archive_collection(collection_guid, body))


@dynamic_catch
async def _async_add_to_collection(self, collection_guid: str, element_guid: str,
Expand Down
8 changes: 6 additions & 2 deletions pyegeria/omvs/community_matters_omvs.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,14 +478,18 @@ def get_community_by_guid(
@dynamic_catch
async def _async_link_crowd_sourcing_contribution(self, element_guid: str, contributor_guid: str,
body: Optional[dict | NewRelationshipRequestBody] = None) -> None:
"""Attach a contributor to an element they crowd-sourced content for (CrowdSourcingContribution relationship). Async version."""
"""Attach a contributor to an element they crowd-sourced content for (CrowdSourcingContribution relationship).
The relationship properties' `roleType` is a CrowdSourcingRole: PROPOSER, REVIEWER, SUPPORTER, APPROVER
or OTHER. Async version."""
url = f"{self.community_command_base}/elements/{element_guid}/crowd-sourcing-contributions/{contributor_guid}/attach"
await self._async_new_relationship_request(url, ["CrowdSourcingContributionProperties"], body)

@dynamic_catch
def link_crowd_sourcing_contribution(self, element_guid: str, contributor_guid: str,
body: Optional[dict | NewRelationshipRequestBody] = None) -> None:
"""Attach a contributor to an element they crowd-sourced content for (CrowdSourcingContribution relationship)."""
"""Attach a contributor to an element they crowd-sourced content for (CrowdSourcingContribution relationship).
The relationship properties' `roleType` is a CrowdSourcingRole: PROPOSER, REVIEWER, SUPPORTER, APPROVER
or OTHER."""
loop = asyncio.get_event_loop()
loop.run_until_complete(self._async_link_crowd_sourcing_contribution(element_guid, contributor_guid, body))

Expand Down
Loading
Loading