From a7aa7ec47cf0a3a1dc604c61eed1285b5d9b116b Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 24 Apr 2026 18:41:06 +0530 Subject: [PATCH 1/6] Add pytfe version --- pyproject.toml | 2 +- src/pytfe/__init__.py | 10 +- .../models/notification_configuration.py | 245 +++++++----------- 3 files changed, 101 insertions(+), 156 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c50cdc5d..4f311fc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pytfe" -version = "0.1.4" +version = "0.1.5" description = "Official Python SDK for HashiCorp Terraform Cloud / Terraform Enterprise (TFE) API v2" readme = "README.md" license = { text = "MPL-2.0" } diff --git a/src/pytfe/__init__.py b/src/pytfe/__init__.py index 68cea566..9d518c48 100644 --- a/src/pytfe/__init__.py +++ b/src/pytfe/__init__.py @@ -1,8 +1,16 @@ # Copyright IBM Corp. 2025, 2026 # SPDX-License-Identifier: MPL-2.0 +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version + from . import errors, models from .client import TFEClient from .config import TFEConfig -__all__ = ["TFEConfig", "TFEClient", "errors", "models"] +try: + __version__ = _pkg_version("pytfe") +except PackageNotFoundError: # running from a source checkout without install + __version__ = "0.0.0+unknown" + +__all__ = ["TFEConfig", "TFEClient", "errors", "models", "__version__"] diff --git a/src/pytfe/models/notification_configuration.py b/src/pytfe/models/notification_configuration.py index be9f8801..ca281bfa 100644 --- a/src/pytfe/models/notification_configuration.py +++ b/src/pytfe/models/notification_configuration.py @@ -9,11 +9,12 @@ from __future__ import annotations +from collections.abc import Iterator from datetime import datetime from enum import Enum from typing import Any -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator class NotificationTriggerType(Enum): @@ -49,144 +50,80 @@ class NotificationDestinationType(Enum): MICROSOFT_TEAMS = "microsoft-teams" -class DeliveryResponse: +class DeliveryResponse(BaseModel): """Represents a notification configuration delivery response.""" - # Type annotations for instance attributes - body: str - code: str - headers: dict[str, Any] - sent_at: datetime | None - successful: str - url: str - - def __init__(self, data: dict[str, Any]): - self.body = data.get("body", "") - self.code = data.get("code", "") - self.headers = data.get("headers", {}) - self.sent_at = self._parse_datetime(data.get("sent-at")) - self.successful = data.get("successful", "") - self.url = data.get("url", "") - - def _parse_datetime(self, date_str: str | None) -> datetime | None: - """Parse ISO 8601 datetime string.""" - if not date_str: - return None - try: - return datetime.fromisoformat(date_str.replace("Z", "+00:00")) - except (ValueError, AttributeError): - return None + model_config = ConfigDict(populate_by_name=True) - def __repr__(self) -> str: - return f"DeliveryResponse(url='{self.url}', code='{self.code}', successful='{self.successful}')" + body: str = "" + code: str = "" + headers: dict[str, Any] = Field(default_factory=dict) + sent_at: datetime | None = Field(default=None, alias="sent-at") + successful: str = "" + url: str = "" + def __init__(self, data: dict[str, Any] | None = None, /, **kwargs: Any) -> None: + if data is not None: + super().__init__(**{**data, **kwargs}) + else: + super().__init__(**kwargs) -class NotificationConfigurationSubscribableChoice: - """Choice type struct that represents the possible values within a polymorphic relation.""" - # Type annotations for instance attributes - team: Any | None - workspace: Any | None +class NotificationConfigurationSubscribableChoice(BaseModel): + """Choice type struct that represents the possible values within a polymorphic relation.""" - def __init__(self, team: Any | None = None, workspace: Any | None = None): - self.team = team - self.workspace = workspace + model_config = ConfigDict(arbitrary_types_allowed=True) - def __repr__(self) -> str: - if self.team: - return f"NotificationConfigurationSubscribableChoice(team={self.team})" - elif self.workspace: - return f"NotificationConfigurationSubscribableChoice(workspace={self.workspace})" - return "NotificationConfigurationSubscribableChoice()" + team: Any | None = None + workspace: Any | None = None -class NotificationConfiguration: +class NotificationConfiguration(BaseModel): """Represents a Notification Configuration.""" - # Type annotations for instance attributes - id: str | None - created_at: datetime | None - updated_at: datetime | None - destination_type: str | None - enabled: bool - name: str - token: str - url: str - triggers: list[NotificationTriggerType] - delivery_responses: list[Any] - email_addresses: list[str] - email_users: list[Any] - subscribable: Any - subscribable_choice: Any | None - - def __init__(self, data: dict[str, Any]): - self.id = data.get("id") - self.created_at = self._parse_datetime(data.get("created-at")) - self.updated_at = self._parse_datetime(data.get("updated-at")) - - # Core attributes - self.destination_type = data.get("destination-type") - self.enabled = data.get("enabled", False) - self.name = data.get("name", "") - self.token = data.get("token", "") - self.url = data.get("url", "") - - # Triggers - convert from strings to enum values - self.triggers = self._parse_triggers(data.get("triggers", [])) - - # Delivery responses - delivery_responses_data = data.get("delivery-responses", []) - self.delivery_responses = [ - DeliveryResponse(dr) for dr in delivery_responses_data - ] - - # Email configuration - self.email_addresses = data.get("email-addresses", []) - self.email_users = data.get("email-users", []) - - # Relationships - using polymorphic relation pattern - self.subscribable = data.get( - "subscribable" - ) # Deprecated but maintained for compatibility - self.subscribable_choice = self._parse_subscribable_choice( - data.get("subscribable-choice") - ) - - def _parse_datetime(self, date_str: str | None) -> datetime | None: - """Parse ISO 8601 datetime string.""" - if not date_str: - return None - try: - return datetime.fromisoformat(date_str.replace("Z", "+00:00")) - except (ValueError, AttributeError): - return None - - def _parse_triggers(self, triggers: list[str]) -> list[NotificationTriggerType]: - """Parse trigger strings to enum values.""" - parsed_triggers = [] - for trigger in triggers: - try: - parsed_triggers.append(NotificationTriggerType(trigger)) - except ValueError: - # If trigger is not in enum, keep as string for backwards compatibility - pass - return parsed_triggers + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) - def _parse_subscribable_choice( - self, choice_data: dict[str, Any] | None - ) -> NotificationConfigurationSubscribableChoice | None: - """Parse subscribable choice data.""" - if not choice_data: - return None + id: str | None = None + created_at: datetime | None = Field(default=None, alias="created-at") + updated_at: datetime | None = Field(default=None, alias="updated-at") + destination_type: str | None = Field(default=None, alias="destination-type") + enabled: bool = False + name: str = "" + token: str = "" + url: str = "" + triggers: list[NotificationTriggerType] = Field(default_factory=list) + delivery_responses: list[DeliveryResponse] = Field( + default_factory=list, alias="delivery-responses" + ) + email_addresses: list[str] = Field(default_factory=list, alias="email-addresses") + email_users: list[Any] = Field(default_factory=list, alias="email-users") + subscribable: Any = None + subscribable_choice: NotificationConfigurationSubscribableChoice | None = Field( + default=None, alias="subscribable-choice" + ) - team = choice_data.get("team") - workspace = choice_data.get("workspace") - return NotificationConfigurationSubscribableChoice( - team=team, workspace=workspace - ) + @field_validator("triggers", mode="before") + @classmethod + def _coerce_triggers(cls, value: Any) -> list[NotificationTriggerType]: + if not value: + return [] + parsed: list[NotificationTriggerType] = [] + for trigger in value: + if isinstance(trigger, NotificationTriggerType): + parsed.append(trigger) + continue + try: + parsed.append(NotificationTriggerType(trigger)) + except (ValueError, TypeError): + # Silently drop unknown triggers for backwards compatibility + pass + return parsed - def __repr__(self) -> str: - return f"NotificationConfiguration(id='{self.id}', name='{self.name}', enabled={self.enabled})" + def __init__(self, data: dict[str, Any] | None = None, /, **kwargs: Any) -> None: + if data is not None: + super().__init__(**{**data, **kwargs}) + else: + super().__init__(**kwargs) def _serialize_triggers( @@ -362,43 +299,43 @@ def validate(self) -> list[str]: # type: ignore[override] return errors -class NotificationConfigurationList: +class NotificationConfigurationList(BaseModel): """Represents a list of notification configurations with pagination.""" - # Type annotations for instance attributes - items: list[NotificationConfiguration] - current_page: int - page_size: int - prev_page: int | None - next_page: int | None - total_pages: int - total_count: int - - def __init__(self, data: dict[str, Any]): - self.items = [ - NotificationConfiguration(item.get("attributes", {})) - for item in data.get("data", []) - ] - - # Pagination metadata - meta = data.get("meta", {}) - pagination = meta.get("pagination", {}) - - self.current_page = pagination.get("current-page", 0) - self.page_size = pagination.get("page-size", 20) - self.prev_page = pagination.get("prev-page") - self.next_page = pagination.get("next-page") - self.total_pages = pagination.get("total-pages", 0) - self.total_count = pagination.get("total-count", 0) + model_config = ConfigDict(populate_by_name=True) + + items: list[NotificationConfiguration] = Field(default_factory=list) + current_page: int = 0 + page_size: int = 20 + prev_page: int | None = None + next_page: int | None = None + total_pages: int = 0 + total_count: int = 0 + + def __init__(self, data: dict[str, Any] | None = None, /, **kwargs: Any) -> None: + if data is None: + super().__init__(**kwargs) + return + + items_data = [item.get("attributes", {}) for item in data.get("data") or []] + pagination = (data.get("meta") or {}).get("pagination") or {} + parsed: dict[str, Any] = { + "items": items_data, + "current_page": pagination.get("current-page", 0), + "page_size": pagination.get("page-size", 20), + "prev_page": pagination.get("prev-page"), + "next_page": pagination.get("next-page"), + "total_pages": pagination.get("total-pages", 0), + "total_count": pagination.get("total-count", 0), + } + parsed.update(kwargs) + super().__init__(**parsed) def __len__(self) -> int: return len(self.items) - def __iter__(self) -> Any: + def __iter__(self) -> Iterator[NotificationConfiguration]: # type: ignore[override] return iter(self.items) def __getitem__(self, index: int) -> NotificationConfiguration: return self.items[index] - - def __repr__(self) -> str: - return f"NotificationConfigurationList(count={len(self.items)}, page={self.current_page}, total={self.total_count})" From 7d1e6db5e53156bc67be5a419f4230d1ef3a0e6f Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 24 Apr 2026 18:45:27 +0530 Subject: [PATCH 2/6] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a56dc8..34e73b8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Unreleased # Released +# v0.1.5 +* `pytfe.__version__` added in src/pytfe/init.py via importlib.metadata.version("pytfe"). This will resolve to the version from pyproject.toml. # v0.1.4 From e7082cd7ad3e8230d89d94d5e4077351ad1e5617 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 24 Apr 2026 19:03:18 +0530 Subject: [PATCH 3/6] fix error --- CHANGELOG.md | 1 + .../models/notification_configuration.py | 26 +++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34e73b8d..1d54d872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ # Released # v0.1.5 + * `pytfe.__version__` added in src/pytfe/init.py via importlib.metadata.version("pytfe"). This will resolve to the version from pyproject.toml. # v0.1.4 diff --git a/src/pytfe/models/notification_configuration.py b/src/pytfe/models/notification_configuration.py index ca281bfa..c9b2934a 100644 --- a/src/pytfe/models/notification_configuration.py +++ b/src/pytfe/models/notification_configuration.py @@ -55,12 +55,12 @@ class DeliveryResponse(BaseModel): model_config = ConfigDict(populate_by_name=True) - body: str = "" - code: str = "" - headers: dict[str, Any] = Field(default_factory=dict) + body: str | None = None + code: str | None = None + headers: dict[str, Any] | None = Field(default_factory=dict) sent_at: datetime | None = Field(default=None, alias="sent-at") - successful: str = "" - url: str = "" + successful: str | None = None + url: str | None = None def __init__(self, data: dict[str, Any] | None = None, /, **kwargs: Any) -> None: if data is not None: @@ -88,9 +88,9 @@ class NotificationConfiguration(BaseModel): updated_at: datetime | None = Field(default=None, alias="updated-at") destination_type: str | None = Field(default=None, alias="destination-type") enabled: bool = False - name: str = "" - token: str = "" - url: str = "" + name: str | None = None + token: str | None = None + url: str | None = None triggers: list[NotificationTriggerType] = Field(default_factory=list) delivery_responses: list[DeliveryResponse] = Field( default_factory=list, alias="delivery-responses" @@ -102,6 +102,16 @@ class NotificationConfiguration(BaseModel): default=None, alias="subscribable-choice" ) + @field_validator( + "delivery_responses", + "email_addresses", + "email_users", + mode="before", + ) + @classmethod + def _none_to_empty_list(cls, value: Any) -> Any: + return [] if value is None else value + @field_validator("triggers", mode="before") @classmethod def _coerce_triggers(cls, value: Any) -> list[NotificationTriggerType]: From 0f6036f3dde4d6d70647ae25e9b6508519a81e41 Mon Sep 17 00:00:00 2001 From: Sivaselvan I Date: Mon, 27 Apr 2026 11:46:22 +0530 Subject: [PATCH 4/6] bugfix(comment & cost-estimate): Updated attributes of the models to be optional during relationship mapper (#137) --- src/pytfe/models/comment.py | 2 +- src/pytfe/models/cost_estimate.py | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/pytfe/models/comment.py b/src/pytfe/models/comment.py index da2cd213..19cc25ca 100644 --- a/src/pytfe/models/comment.py +++ b/src/pytfe/models/comment.py @@ -10,4 +10,4 @@ class Comment(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str - body: str = Field(..., alias="body") + body: str = Field(default="", alias="body") diff --git a/src/pytfe/models/cost_estimate.py b/src/pytfe/models/cost_estimate.py index d1b6ff6b..4ae1c614 100644 --- a/src/pytfe/models/cost_estimate.py +++ b/src/pytfe/models/cost_estimate.py @@ -13,17 +13,17 @@ class CostEstimate(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str - delta_monthly_cost: str = Field(..., alias="delta-monthly-cost") - error_message: str = Field(..., alias="error-message") - matched_resources_count: int = Field(..., alias="matched-resources-count") - prior_monthly_cost: str = Field(..., alias="prior-monthly-cost") - proposed_monthly_cost: str = Field(..., alias="proposed-monthly-cost") - resources_count: int = Field(..., alias="resources-count") - status: CostEstimateStatus = Field(..., alias="status") - status_timestamps: CostEstimateStatusTimestamps = Field( - ..., alias="status-timestamps" + delta_monthly_cost: str = Field(default="", alias="delta-monthly-cost") + error_message: str = Field(default="", alias="error-message") + matched_resources_count: int = Field(default=0, alias="matched-resources-count") + prior_monthly_cost: str = Field(default="", alias="prior-monthly-cost") + proposed_monthly_cost: str = Field(default="", alias="proposed-monthly-cost") + resources_count: int = Field(default=0, alias="resources-count") + status: CostEstimateStatus | None = Field(default=None, alias="status") + status_timestamps: CostEstimateStatusTimestamps | None = Field( + default=None, alias="status-timestamps" ) - unmatched_resources_count: int = Field(..., alias="unmatched-resources-count") + unmatched_resources_count: int = Field(default=0, alias="unmatched-resources-count") class CostEstimateStatus(str, Enum): From 2b3b0e7cb05f907379fb6e620a25a494fbd3b9d1 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Mon, 27 Apr 2026 12:15:47 +0530 Subject: [PATCH 5/6] update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d54d872..ad638af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ * `pytfe.__version__` added in src/pytfe/init.py via importlib.metadata.version("pytfe"). This will resolve to the version from pyproject.toml. +## Bug Fixes +* Run.read / Run.create fail with pydantic ValidationError when response has a `cost-estimate` and `comments` relationship. + # v0.1.4 ## Enhancements From 2f0ea2160951ed0231df772e8c4a5781f1beaab9 Mon Sep 17 00:00:00 2001 From: Sivaselvan I Date: Wed, 29 Apr 2026 17:22:53 +0530 Subject: [PATCH 6/6] Bugfix/workspace relations (#138) * fix(sshKey & stateVersion): Updated id as only mandatory option for sshkey and state_version models * fix(workspace): Updated workspace model with right relationship models and resource with additional relationship mapper * Updated changelog --- CHANGELOG.md | 2 ++ src/pytfe/models/ssh_key.py | 2 +- src/pytfe/models/state_version.py | 2 +- src/pytfe/models/workspace.py | 14 +++++++++---- src/pytfe/resources/workspaces.py | 34 ++++++++++++++++++++++++++++++- 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad638af3..55e5164c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ # v0.1.5 * `pytfe.__version__` added in src/pytfe/init.py via importlib.metadata.version("pytfe"). This will resolve to the version from pyproject.toml. +* Updated comments, sshkey, stateversion and cost-estimate models to have id as mandatory attribute by @isivaselvan [#137](https://github.com/hashicorp/python-tfe/pull/137) +* Updated workspace resource to include additional relationship models include AgentPool, Configuration-version, Run, Variables and State-version by @isivaselvan [#138](https://github.com/hashicorp/python-tfe/pull/138) ## Bug Fixes * Run.read / Run.create fail with pydantic ValidationError when response has a `cost-estimate` and `comments` relationship. diff --git a/src/pytfe/models/ssh_key.py b/src/pytfe/models/ssh_key.py index 4df3d0a0..52423fb8 100644 --- a/src/pytfe/models/ssh_key.py +++ b/src/pytfe/models/ssh_key.py @@ -13,7 +13,7 @@ class SSHKey(BaseModel): id: str = Field(..., description="The unique identifier for this SSH key") type: str = Field(default="ssh-keys", description="The type of this resource") - name: str = Field(..., description="A name to identify the SSH key") + name: str = Field(default="", description="A name to identify the SSH key") class SSHKeyCreateOptions(BaseModel): diff --git a/src/pytfe/models/state_version.py b/src/pytfe/models/state_version.py index 4d8607dd..dab42619 100644 --- a/src/pytfe/models/state_version.py +++ b/src/pytfe/models/state_version.py @@ -32,7 +32,7 @@ class StateVersion(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str = Field(..., alias="id") - created_at: datetime = Field(..., alias="created-at") + created_at: datetime | None = Field(None, alias="created-at") hosted_state_download_url: str | None = Field( None, alias="hosted-state-download-url" ) diff --git a/src/pytfe/models/workspace.py b/src/pytfe/models/workspace.py index d24ab35d..e0be77a7 100644 --- a/src/pytfe/models/workspace.py +++ b/src/pytfe/models/workspace.py @@ -23,9 +23,13 @@ from ..utils import has_tags_regex_defined, is_valid_workspace_name, valid_string from .agent import AgentPool from .common import EffectiveTagBinding, Tag, TagBinding +from .configuration_version import ConfigurationVersion from .data_retention_policy import DataRetentionPolicyChoice from .organization import ExecutionMode, Organization from .project import Project +from .ssh_key import SSHKey +from .state_version import StateVersion +from .variable import Variable if TYPE_CHECKING: from .run import Run @@ -168,15 +172,17 @@ class Workspace(BaseModel): # Relations agent_pool: AgentPool | None = None # AgentPool object current_run: Run | None = None # Run object - current_state_version: Any | None = None # StateVersion object + current_state_version: StateVersion | None = None # StateVersion object organization: Organization | None = None project: Project | None = None - ssh_key: Any | None = None # SSHKey object + ssh_key: SSHKey | None = None # SSHKey object outputs: list[WorkspaceOutputs] = Field(default_factory=list) tags: list[Tag] = Field(default_factory=list) - current_configuration_version: Any | None = None # ConfigurationVersion object + current_configuration_version: ConfigurationVersion | None = ( + None # ConfigurationVersion object + ) locked_by: LockedByChoice | None = None - variables: list[Any] = Field(default_factory=list) # Variable objects + variables: list[Variable] = Field(default_factory=list) # Variable objects tag_bindings: list[TagBinding] = Field(default_factory=list) effective_tag_bindings: list[EffectiveTagBinding] = Field(default_factory=list) diff --git a/src/pytfe/resources/workspaces.py b/src/pytfe/resources/workspaces.py index 2bd2d9b3..1a6ac6cb 100644 --- a/src/pytfe/resources/workspaces.py +++ b/src/pytfe/resources/workspaces.py @@ -7,6 +7,8 @@ from collections.abc import Iterator from typing import Any +from pytfe.models.ssh_key import SSHKey + from ..errors import ( InvalidOrgError, InvalidSSHKeyIDError, @@ -19,11 +21,13 @@ WorkspaceMinimumLimitError, WorkspaceRequiredError, ) +from ..models.agent import AgentPool from ..models.common import ( EffectiveTagBinding, Tag, TagBinding, ) +from ..models.configuration_version import ConfigurationVersion from ..models.data_retention_policy import ( DataRetentionPolicy, DataRetentionPolicyChoice, @@ -34,6 +38,9 @@ ) from ..models.organization import Organization from ..models.project import Project +from ..models.run import Run +from ..models.state_version import StateVersion +from ..models.variable import Variable from ..models.workspace import ( ExecutionMode, LockedByChoice, @@ -181,7 +188,32 @@ def _ws_from(d: dict[str, Any]) -> Workspace: {"id": relationships["project"]["data"].get("id")} ) if relationships.get("ssh-key", {}).get("data"): - attr["ssh_key"] = relationships["ssh-key"]["data"].get("id") + attr["ssh_key"] = SSHKey.model_validate( + {"id": relationships["ssh-key"]["data"].get("id")} + ) + if relationships.get("agent-pool", {}).get("data"): + attr["agent_pools"] = AgentPool.model_validate( + {"id": relationships["agent-pool"]["data"].get("id")} + ) + if relationships.get("current-run", {}).get("data"): + attr["current_run"] = Run.model_validate( + {"id": relationships["current-run"]["data"].get("id")} + ) + if relationships.get("current-configuration-version", {}).get("data"): + attr["current_configuration_version"] = ConfigurationVersion.model_validate( + {"id": relationships["current-configuration-version"]["data"].get("id")} + ) + if relationships.get("vars", {}).get("data"): + attr["variables"] = [ + Variable.model_validate({"id": item.get("id")}) + for item in relationships["vars"]["data"] + if item.get("id") + ] + if relationships.get("current-state-version", {}).get("data"): + attr["current_state_version"] = StateVersion.model_validate( + {"id": relationships["current-state-version"]["data"].get("id")} + ) + attr["outputs"] = outputs attr["locked_by"] = locked_by attr["data_retention_policy_choice"] = data_retention_policy_choice