From a5c2b8b6dba63923d6dcfcb86a957cdaae6f9ddb Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Tue, 16 Sep 2025 16:22:40 +0530 Subject: [PATCH 1/5] TF-30491 Provide feature for state version & state version output API specs --- examples/state_versions.py | 115 +++++++ pyproject.toml | 2 +- src/tfe/_http.py | 14 +- src/tfe/client.py | 6 +- src/tfe/errors.py | 2 + src/tfe/models/__init__.py | 0 src/tfe/models/state_version.py | 79 +++++ src/tfe/models/state_version_output.py | 32 ++ src/tfe/resources/state_version_outputs.py | 82 +++++ src/tfe/resources/state_versions.py | 330 +++++++++++++++++++++ src/tfe/utils.py | 4 + 11 files changed, 663 insertions(+), 3 deletions(-) create mode 100644 examples/state_versions.py create mode 100644 src/tfe/models/__init__.py create mode 100644 src/tfe/models/state_version.py create mode 100644 src/tfe/models/state_version_output.py create mode 100644 src/tfe/resources/state_version_outputs.py create mode 100644 src/tfe/resources/state_versions.py diff --git a/examples/state_versions.py b/examples/state_versions.py new file mode 100644 index 0000000..254571e --- /dev/null +++ b/examples/state_versions.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path + +from tfe import TFEClient, TFEConfig +from tfe.errors import ErrStateVersionUploadNotSupported +from tfe.models.state_version import ( + StateVersionListOptions, + StateVersionCreateOptions, + StateVersionCurrentOptions, +) +from tfe.models.state_version_output import StateVersionOutputsListOptions + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser( + description="State Versions demo for python-tfe SDK" + ) + parser.add_argument("--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io")) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--org", required=True, help="Organization name") + parser.add_argument("--workspace", required=True, help="Workspace name") + parser.add_argument("--workspace-id", required=True, help="Workspace ID") + parser.add_argument("--download", help="Path to save downloaded current state") + parser.add_argument("--upload", help="Path to a .tfstate (or JSON state) to upload") + parser.add_argument("--page", type=int, default=1) + parser.add_argument("--page-size", type=int, default=10) + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + options = StateVersionListOptions( + page_number=args.page, + page_size=args.page_size, + organization=args.org, + workspace=args.workspace, + ) + + sv_list = client.state_versions.list(options) + + print(f"Total state versions: {sv_list.total_count}") + print(f"Page {sv_list.current_page} of {sv_list.total_pages}") + print() + + for sv in sv_list.items: + print(f"- {sv.id} | status={sv.status} | created_at={sv.created_at}") + + + # 1) List all state versions across org and workspace filters + _print_header("Org-scoped listing via /api/v2/state-versions (first page)") + all_sv = client.state_versions.list( + StateVersionListOptions(organization=args.org,workspace=args.workspace,page_size=args.page_size) + ) + for sv in all_sv.items: + print(f"- {sv.id} | status={sv.status} | created_at={sv.created_at}") + + # 2) Read the current state version (with outputs included if you want) + _print_header("Reading current state version") + current = client.state_versions.read_current_with_options( + args.workspace_id, + StateVersionCurrentOptions(include=["outputs"]) + ) + print(f"Current SV: {current.id} status={current.status} durl={current.hosted_state_download_url}") + + # 3) (Optional) Download the current state (optional) + if args.download: + _print_header(f"Downloading current state to: {args.download}") + raw = client.state_versions.download(current.id) + Path(args.download).write_bytes(raw) + print(f"Wrote {len(raw)} bytes to {args.download}") + + # 4) List outputs for the current state version (paged) + _print_header("Listing outputs (current state version)") + outs = client.state_versions.list_outputs( + current.id, options=StateVersionOutputsListOptions(page_size=50) + ) + if not outs.items: + print("No outputs found.") + for o in outs.items: + # Sensitive outputs will have value = None + print(f"- {o.name}: sensitive={o.sensitive} type={o.type} value={o.value}") + + # 5) (Optional) Upload a new state file + if args.upload: + _print_header(f"Uploading new state from: {args.upload}") + payload = Path(args.upload).read_bytes() + try: + # If your server supports signed uploads, this will: + # a) create SV (to get upload URL) + # b) PUT bytes to the signed URL + # c) read back the SV to return a hydrated object + new_sv = client.state_versions.upload( + args.workspace_id, + raw_state=payload, + options=StateVersionCreateOptions(), + ) + print(f"Uploaded new SV: {new_sv.id} status={new_sv.status}") + except ErrStateVersionUploadNotSupported as e: + # Some older/self-hosted versions don’t support direct upload + print(f"Upload not supported on this server: {e}") + + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 76afdbc..1b11eeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "Official Python SDK for HashiCorp Terraform Cloud / Terraform Ent readme = "README.md" license = { text = "MPL-2.0" } authors = [{ name = "HashiCorp" }] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ "httpx>=0.27.0,<0.29.0", "pydantic>=2.6,<3", diff --git a/src/tfe/_http.py b/src/tfe/_http.py index b8a351f..20a9382 100644 --- a/src/tfe/_http.py +++ b/src/tfe/_http.py @@ -1,6 +1,8 @@ from __future__ import annotations import time +import re +from urllib.parse import urljoin from collections.abc import Mapping from typing import Any @@ -18,6 +20,8 @@ _RETRY_STATUSES = {429, 502, 503, 504} +ABSOLUTE_URL_RE = re.compile(r"^https?://", re.I) + class HTTPTransport: def __init__( @@ -55,6 +59,12 @@ def __init__( self._async = httpx.AsyncClient( http2=http2, timeout=timeout, verify=ca_bundle or verify_tls ) # proxies=proxies + + def _build_url(self, path: str) -> str: + # IMPORTANT: don't prefix absolute URLs (hosted_state, signed blobs, etc.) + if ABSOLUTE_URL_RE.match(path): + return path + return urljoin(self.base, path.lstrip("/")) def request( self, @@ -66,11 +76,12 @@ def request( headers: dict[str, str] | None = None, allow_redirects: bool = True, ) -> httpx.Response: - url = f"{self.base}{path}" + url = self._build_url(path) hdrs = dict(self.headers) if headers: hdrs.update(headers) attempt = 0 + print (method, url, params,json_body,hdrs) while True: try: resp = self._sync.request( @@ -92,6 +103,7 @@ def request( self._sleep(attempt, retry_after) attempt += 1 continue + print(resp) self._raise_if_error(resp) return resp diff --git a/src/tfe/client.py b/src/tfe/client.py index e4e2754..ae89e54 100644 --- a/src/tfe/client.py +++ b/src/tfe/client.py @@ -5,7 +5,8 @@ from .resources.organizations import Organizations from .resources.projects import Projects from .resources.workspaces import Workspaces - +from .resources.state_versions import StateVersions +from .resources.state_version_outputs import StateVersionOutputs class TFEClient: def __init__(self, config: TFEConfig | None = None): @@ -28,5 +29,8 @@ def __init__(self, config: TFEConfig | None = None): self.projects = Projects(self._transport) self.workspaces = Workspaces(self._transport) + self.state_versions = StateVersions(self._transport) + self.state_version_outputs = StateVersionOutputs(self._transport) + def close(self) -> None: pass diff --git a/src/tfe/errors.py b/src/tfe/errors.py index e524f32..bab9e26 100644 --- a/src/tfe/errors.py +++ b/src/tfe/errors.py @@ -51,6 +51,8 @@ class InvalidValues(TFEError): ... class RequiredFieldMissing(TFEError): ... +class ErrStateVersionUploadNotSupported(TFEError): ... + # Error message constants ERR_INVALID_NAME = "invalid value for name" diff --git a/src/tfe/models/__init__.py b/src/tfe/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tfe/models/state_version.py b/src/tfe/models/state_version.py new file mode 100644 index 0000000..51a6be8 --- /dev/null +++ b/src/tfe/models/state_version.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any, List, Optional + +from pydantic import BaseModel, Field, ConfigDict + + +# ---- Enums ---- + +class StateVersionStatus(str, Enum): + PENDING = "pending" + FINALIZED = "finalized" + DISCARDED = "discarded" + + +class StateVersionIncludeOpt(str, Enum): + CREATED_BY = "created_by" + RUN = "run" + RUN_CREATED_BY = "run.created_by" + RUN_CONFIGURATION_VERSION = "run.configuration_version" + OUTPUTS = "outputs" + + +# ---- DTOs ---- + +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") + hosted_state_download_url: Optional[str] = Field(None, alias="hosted-state-download-url") + hosted_state_upload_url: Optional[str] = Field(None, alias="hosted-state-upload-url") + status: Optional[StateVersionStatus] = Field(None, alias="status") + + # Optional/advanced fields (present on newer servers; keep loose) + resources_processed: Optional[bool] = Field(None, alias="resources-processed") + modules: Optional[dict] = None + providers: Optional[dict] = None + resources: Optional[List[dict]] = None + + +class StateVersionCreateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + # Mirrors go-tfe: optional hint payload + json_state_outputs: Optional[str] = Field(None, alias="json-state-outputs") + + +class StateVersionCurrentOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + include: Optional[List[StateVersionIncludeOpt]] = None + + +class StateVersionReadOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + include: Optional[List[StateVersionIncludeOpt]] = None + + +class StateVersionListOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + # Standard pagination + filters + page_number: Optional[int] = Field(None, alias="page[number]") + page_size: Optional[int] = Field(None, alias="page[size]") + organization: Optional[str] = Field(None, alias="filter[organization][name]") + workspace: Optional[str] = Field(None, alias="filter[workspace][name]") + + +class StateVersionList(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + items: List[StateVersion] = Field(default_factory=list) + current_page: Optional[int] = None + total_pages: Optional[int] = None + total_count: Optional[int] = None diff --git a/src/tfe/models/state_version_output.py b/src/tfe/models/state_version_output.py new file mode 100644 index 0000000..c1a0c30 --- /dev/null +++ b/src/tfe/models/state_version_output.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Any, List, Optional + +from pydantic import BaseModel, Field, ConfigDict + + +class StateVersionOutput(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + name: str + sensitive: bool + type: str + value: Any + detailed_type: Optional[Any] = Field(None, alias="detailed-type") + + +class StateVersionOutputsListOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_number: Optional[int] = Field(None, alias="page[number]") + page_size: Optional[int] = Field(None, alias="page[size]") + + +class StateVersionOutputsList(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + items: List[StateVersionOutput] = Field(default_factory=list) + current_page: Optional[int] = None + total_pages: Optional[int] = None + total_count: Optional[int] = None diff --git a/src/tfe/resources/state_version_outputs.py b/src/tfe/resources/state_version_outputs.py new file mode 100644 index 0000000..4f0e910 --- /dev/null +++ b/src/tfe/resources/state_version_outputs.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from typing import Any, Optional + +from ..utils import valid_string_id +from ._base import _Service + +from ..models.state_version_output import ( + StateVersionOutput, + StateVersionOutputsList, + StateVersionOutputsListOptions, +) + + +def _safe_str(v: Any, default: str = "") -> str: + return v if isinstance(v, str) else (str(v) if v is not None else default) + + +class StateVersionOutputs(_Service): + """ + HCPTF and TFE State Version Outputs service. + + Endpoints: + - GET /api/v2/state-version-outputs/:id + - GET /api/v2/workspaces/:workspace_id/current-state-version-outputs + """ + + def read(self, output_id: str) -> StateVersionOutput: + """Read a specific state version output by ID.""" + if not valid_string_id(output_id): + raise ValueError("invalid output id") + + r = self.t.request("GET", f"/api/v2/state-version-outputs/{output_id}") + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + + return StateVersionOutput( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ) + + def read_current( + self, workspace_id: str, options: Optional[StateVersionOutputsListOptions] = None + ) -> StateVersionOutputsList: + """ + Read outputs for the workspace's current state version. + Note: sensitive outputs are returned with null values by the API. + """ + if not valid_string_id(workspace_id): + raise ValueError("invalid workspace id") + + params: dict[str, Any] = {} + if options: + if options.page_number is not None: + params["page[number]"] = options.page_number + if options.page_size is not None: + params["page[size]"] = options.page_size + + r = self.t.request( + "GET", + f"/api/v2/workspaces/{workspace_id}/current-state-version-outputs", + params=params, + ) + data = r.json() + + items: list[StateVersionOutput] = [] + for item in data.get("data", []): + attr = item.get("attributes", {}) or {} + items.append( + StateVersionOutput( + id=_safe_str(item.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ) + ) + + meta = data.get("meta", {}).get("pagination", {}) or {} + return StateVersionOutputsList( + items=items, + current_page=meta.get("current-page"), + total_pages=meta.get("total-pages"), + total_count=meta.get("total-count"), + ) diff --git a/src/tfe/resources/state_versions.py b/src/tfe/resources/state_versions.py new file mode 100644 index 0000000..3ffae51 --- /dev/null +++ b/src/tfe/resources/state_versions.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any, Optional, Dict +from urllib.parse import urlencode + +from ..utils import valid_string_id +from ._base import _Service +from ..errors import ( + ErrStateVersionUploadNotSupported +) + +# Pydantic models for this feature +from ..models.state_version import ( + StateVersion, + StateVersionCreateOptions, + StateVersionCurrentOptions, + StateVersionList, + StateVersionListOptions, + StateVersionReadOptions, +) +from ..models.state_version_output import ( + StateVersionOutputsList, + StateVersionOutputsListOptions, + StateVersionOutput, +) + + +def _safe_str(v: Any, default: str = "") -> str: + return v if isinstance(v, str) else (str(v) if v is not None else default) + + +class StateVersions(_Service): + """ + TFE/TFC State Versions service. + + Endpoints covered (JSON:API): + - GET /api/v2/workspaces/:workspace_id/state-versions + - GET /api/v2/workspaces/:workspace_id/current-state-version + - GET /api/v2/state-versions/:id + - GET /api/v2/state-versions/:id/download + - GET /api/v2/state-versions/:id/outputs + - POST /api/v2/workspaces/:workspace_id/state-versions + - POST /api/v2/state-versions/:id/actions/soft_delete_backing_data (TFE only) + - POST /api/v2/state-versions/:id/actions/restore_backing_data (TFE only) + - POST /api/v2/state-versions/:id/actions/permanently_delete_backing_data (TFE only) + """ + + # ---------------------------- + # Listing & reading + # ---------------------------- + + @staticmethod + def _encode_query(params: Dict[str, Any]) -> str: + clean = {k: v for k, v in params.items() if v is not None} + if not clean: + return "" + return "?" + urlencode(clean, doseq=True) + + def list(self, options: Optional[StateVersionListOptions] = None) -> StateVersionList: + """ + GET /state-versions + Accepts filters for organization and workspace and standard pagination. + """ + params = options.model_dump(by_alias=True, exclude_none=True) if options else {} + path = f"/api/v2/state-versions{self._encode_query(params)}" + r = self.t.request("GET", path) + jd = r.json() + # Expecting JSON:API list. Normalize to models. + items = [] + meta = jd.get("meta", {}) + for d in jd.get("data", []): + attrs = d.get("attributes", {}) + attrs["id"] = d.get("id") + items.append(StateVersion.model_validate(attrs)) + return StateVersionList( + items=items, + current_page=meta.get("pagination", {}).get("current-page"), + total_pages=meta.get("pagination", {}).get("total-pages"), + total_count=meta.get("pagination", {}).get("total-count"), + ) + + + def read(self, state_version_id: str) -> StateVersion: + """Read a state version by ID.""" + if not valid_string_id(state_version_id): + raise ValueError("invalid state version id") + + r = self.t.request("GET", f"/api/v2/state-versions/{state_version_id}") + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + + return StateVersion( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ) + + def read_with_options( + self, state_version_id: str, options: StateVersionReadOptions + ) -> StateVersion: + """Read a state version with include options (?include=outputs,run,created_by,...).""" + if not valid_string_id(state_version_id): + raise ValueError("invalid state version id") + + params: dict[str, Any] = {} + if options and options.include: + params["include"] = ",".join(options.include) + + r = self.t.request( + "GET", f"/api/v2/state-versions/{state_version_id}", params=params + ) + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + + return StateVersion( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ) + + def read_current(self, workspace_id: str) -> StateVersion: + """Read the current state version for a workspace.""" + if not valid_string_id(workspace_id): + raise ValueError("invalid workspace id") + + r = self.t.request( + "GET", f"/api/v2/workspaces/{workspace_id}/current-state-version" + ) + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + + return StateVersion( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ) + + def read_current_with_options( + self, workspace_id: str, options: StateVersionCurrentOptions + ) -> StateVersion: + """Read the current state version with include options.""" + if not valid_string_id(workspace_id): + raise ValueError("invalid workspace id") + + params: dict[str, Any] = {} + if options and options.include: + params["include"] = ",".join(options.include) + + r = self.t.request( + "GET", + f"/api/v2/workspaces/{workspace_id}/current-state-version", + params=params, + ) + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + + return StateVersion( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ) + + # ---------------------------- + # Create / upload (signed URL) + # ---------------------------- + + def create( + self, workspace_id: str, options: Optional[StateVersionCreateOptions] = None + ) -> StateVersion: + """Create a state version record (server returns signed upload URL).""" + if not valid_string_id(workspace_id): + raise ValueError("invalid workspace id") + + body = { + "data": { + "type": "state-versions", + "attributes": (options.model_dump(by_alias=True, exclude_none=True) if options else {}), + } + } + r = self.t.request( + "POST", f"/api/v2/workspaces/{workspace_id}/state-versions", json_body=body + ) + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + return StateVersion( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ) + + def upload(self, workspace_id: str, *, raw_state: bytes | None = None, raw_json_state: bytes | None = None, + options: Optional[StateVersionCreateOptions] = None) -> StateVersion: + """ + Mirrors go-tfe Upload: + 1) POST to create (obtain upload URL) + 2) PUT the raw content to the object store (archivist) + """ + sv = self.create(workspace_id, options or StateVersionCreateOptions()) + upload_url = sv.hosted_state_upload_url + if not upload_url: + raise ErrStateVersionUploadNotSupported( + message="Server did not return an upload URL for state version", + method="PUT", path="(signed upload URL)" + ) + + # Choose the content + content = raw_json_state if raw_json_state is not None else raw_state + if content is None: + raise ErrStateVersionUploadNotSupported(message="No state content provided", method="PUT", path=upload_url) + + # Raw PUT to the object store + self.t.request( + "PUT", + upload_url, + json_body=None, + allow_redirects=True, + timeout=120, + headers={"Content-Type": "application/octet-stream"}, + raw_body=content, # transport should use raw bytes when provided + retry_non_idempotent=False, + ) + + # Read back the created SV to reflect any server-side fields + return self.read(sv.id) + + def download(self, state_version_id: str) -> bytes: + """ + Download the raw state file bytes for a specific state version. + + HCP Terraform returns a signed blob URL in the state-version attributes + called 'hosted-state-download-url'. We must fetch that URL directly. + """ + if not valid_string_id(state_version_id): + raise ValueError("invalid state version id") + + sv = self.read(state_version_id) + url = sv.hosted_state_download_url + if not url: + # Can happen if SV is missing, not finalized yet, or you lack permissions. + # Also happens on some older/self-hosted versions if backing data was GC’d. + from ..errors import NotFound + raise NotFound("download url not available for this state version") + + # Download the bytes from the signed Archivist URL (follow redirects). + # Avoid JSON:API headers here; Accept */* is fine. + resp = self.t.request("GET", url, allow_redirects=True, headers={"Accept": "application/json"}) + return resp.content + + def download_current(self, workspace_id: str) -> bytes: + """Download the current state for a workspace.""" + if not valid_string_id(workspace_id): + raise ValueError("invalid workspace id") + + sv = self.read_current(workspace_id) + url = sv.hosted_state_download_url + if not url: + from ..errors import NotFound + raise NotFound("download url not available for current state") + resp = self.t.request("GET", url, allow_redirects=True, headers={"Accept": "*/*"}) + return resp.content + + + + # ---------------------------- + # Outputs (via state version) + # ---------------------------- + + def list_outputs( + self, state_version_id: str, options: Optional[StateVersionOutputsListOptions] = None + ) -> StateVersionOutputsList: + """List outputs for a given state version (paged).""" + if not valid_string_id(state_version_id): + raise ValueError("invalid state version id") + + params: dict[str, Any] = {} + if options: + if options.page_number is not None: + params["page[number]"] = options.page_number + if options.page_size is not None: + params["page[size]"] = options.page_size + + r = self.t.request( + "GET", f"/api/v2/state-versions/{state_version_id}/outputs", params=params + ) + data = r.json() + + items: list[StateVersionOutput] = [] + for item in data.get("data", []): + attr = item.get("attributes", {}) or {} + items.append( + StateVersionOutput( + id=_safe_str(item.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ) + ) + + meta = data.get("meta", {}).get("pagination", {}) or {} + return StateVersionOutputsList( + items=items, + current_page=meta.get("current-page"), + total_pages=meta.get("total-pages"), + total_count=meta.get("total-count"), + ) + + # ---------------------------- + # TFE-only backing data actions + # ---------------------------- + + def soft_delete_backing_data(self, state_version_id: str) -> None: + if not valid_string_id(state_version_id): + raise ValueError("invalid state version id") + self.t.request( + "POST", + f"/api/v2/state-versions/{state_version_id}/actions/soft_delete_backing_data", + ) + return None + + def restore_backing_data(self, state_version_id: str) -> None: + if not valid_string_id(state_version_id): + raise ValueError("invalid state version id") + self.t.request( + "POST", + f"/api/v2/state-versions/{state_version_id}/actions/restore_backing_data", + ) + return None + + def permanently_delete_backing_data(self, state_version_id: str) -> None: + if not valid_string_id(state_version_id): + raise ValueError("invalid state version id") + self.t.request( + "POST", + f"/api/v2/state-versions/{state_version_id}/actions/permanently_delete_backing_data", + ) + return None diff --git a/src/tfe/utils.py b/src/tfe/utils.py index 303431a..506c78d 100644 --- a/src/tfe/utils.py +++ b/src/tfe/utils.py @@ -3,6 +3,7 @@ import re import time from collections.abc import Callable +from typing import Any _STRING_ID_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{2,}$") @@ -29,3 +30,6 @@ def valid_string(v: str | None) -> bool: def valid_string_id(v: str | None) -> bool: return v is not None and _STRING_ID_PATTERN.match(str(v)) is not None + +def _safe_str(v: Any, default: str = "") -> str: + return v if isinstance(v, str) else (str(v) if v is not None else default) From 1403214feee439098190c505aab7b391f026a10e Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Wed, 17 Sep 2025 13:55:38 +0530 Subject: [PATCH 2/5] Fixes for linters --- examples/state_versions.py | 28 ++--- src/tfe/_http.py | 8 +- src/tfe/client.py | 5 +- src/tfe/errors.py | 1 + src/tfe/models/state_version.py | 58 ++++++---- src/tfe/models/state_version_output.py | 18 +-- src/tfe/resources/state_version_outputs.py | 11 +- src/tfe/resources/state_versions.py | 124 ++++++++++----------- src/tfe/utils.py | 28 ++++- 9 files changed, 163 insertions(+), 118 deletions(-) diff --git a/examples/state_versions.py b/examples/state_versions.py index 254571e..581b0e8 100644 --- a/examples/state_versions.py +++ b/examples/state_versions.py @@ -1,16 +1,15 @@ from __future__ import annotations import argparse -import json import os from pathlib import Path from tfe import TFEClient, TFEConfig from tfe.errors import ErrStateVersionUploadNotSupported from tfe.models.state_version import ( - StateVersionListOptions, StateVersionCreateOptions, StateVersionCurrentOptions, + StateVersionListOptions, ) from tfe.models.state_version_output import StateVersionOutputsListOptions @@ -25,7 +24,9 @@ def main(): parser = argparse.ArgumentParser( description="State Versions demo for python-tfe SDK" ) - parser.add_argument("--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io")) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) parser.add_argument("--org", required=True, help="Organization name") parser.add_argument("--workspace", required=True, help="Workspace name") @@ -55,30 +56,32 @@ def main(): for sv in sv_list.items: print(f"- {sv.id} | status={sv.status} | created_at={sv.created_at}") - # 1) List all state versions across org and workspace filters _print_header("Org-scoped listing via /api/v2/state-versions (first page)") all_sv = client.state_versions.list( - StateVersionListOptions(organization=args.org,workspace=args.workspace,page_size=args.page_size) + StateVersionListOptions( + organization=args.org, workspace=args.workspace, page_size=args.page_size + ) ) for sv in all_sv.items: print(f"- {sv.id} | status={sv.status} | created_at={sv.created_at}") - + # 2) Read the current state version (with outputs included if you want) _print_header("Reading current state version") current = client.state_versions.read_current_with_options( - args.workspace_id, - StateVersionCurrentOptions(include=["outputs"]) + args.workspace_id, StateVersionCurrentOptions(include=["outputs"]) ) - print(f"Current SV: {current.id} status={current.status} durl={current.hosted_state_download_url}") - + print( + f"Current SV: {current.id} status={current.status} durl={current.hosted_state_download_url}" + ) + # 3) (Optional) Download the current state (optional) if args.download: _print_header(f"Downloading current state to: {args.download}") raw = client.state_versions.download(current.id) Path(args.download).write_bytes(raw) print(f"Wrote {len(raw)} bytes to {args.download}") - + # 4) List outputs for the current state version (paged) _print_header("Listing outputs (current state version)") outs = client.state_versions.list_outputs( @@ -89,7 +92,7 @@ def main(): for o in outs.items: # Sensitive outputs will have value = None print(f"- {o.name}: sensitive={o.sensitive} type={o.type} value={o.value}") - + # 5) (Optional) Upload a new state file if args.upload: _print_header(f"Uploading new state from: {args.upload}") @@ -110,6 +113,5 @@ def main(): print(f"Upload not supported on this server: {e}") - if __name__ == "__main__": main() diff --git a/src/tfe/_http.py b/src/tfe/_http.py index 20a9382..7d047ea 100644 --- a/src/tfe/_http.py +++ b/src/tfe/_http.py @@ -1,10 +1,10 @@ from __future__ import annotations -import time import re -from urllib.parse import urljoin +import time from collections.abc import Mapping from typing import Any +from urllib.parse import urljoin import anyio import httpx @@ -59,7 +59,7 @@ def __init__( self._async = httpx.AsyncClient( http2=http2, timeout=timeout, verify=ca_bundle or verify_tls ) # proxies=proxies - + def _build_url(self, path: str) -> str: # IMPORTANT: don't prefix absolute URLs (hosted_state, signed blobs, etc.) if ABSOLUTE_URL_RE.match(path): @@ -81,7 +81,7 @@ def request( if headers: hdrs.update(headers) attempt = 0 - print (method, url, params,json_body,hdrs) + print(method, url, params, json_body, hdrs) while True: try: resp = self._sync.request( diff --git a/src/tfe/client.py b/src/tfe/client.py index ae89e54..559063c 100644 --- a/src/tfe/client.py +++ b/src/tfe/client.py @@ -4,9 +4,10 @@ from .config import TFEConfig from .resources.organizations import Organizations from .resources.projects import Projects -from .resources.workspaces import Workspaces -from .resources.state_versions import StateVersions from .resources.state_version_outputs import StateVersionOutputs +from .resources.state_versions import StateVersions +from .resources.workspaces import Workspaces + class TFEClient: def __init__(self, config: TFEConfig | None = None): diff --git a/src/tfe/errors.py b/src/tfe/errors.py index bab9e26..cce706b 100644 --- a/src/tfe/errors.py +++ b/src/tfe/errors.py @@ -51,6 +51,7 @@ class InvalidValues(TFEError): ... class RequiredFieldMissing(TFEError): ... + class ErrStateVersionUploadNotSupported(TFEError): ... diff --git a/src/tfe/models/state_version.py b/src/tfe/models/state_version.py index 51a6be8..018620c 100644 --- a/src/tfe/models/state_version.py +++ b/src/tfe/models/state_version.py @@ -2,13 +2,12 @@ from datetime import datetime from enum import Enum -from typing import Any, List, Optional - -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, ConfigDict, Field # ---- Enums ---- + class StateVersionStatus(str, Enum): PENDING = "pending" FINALIZED = "finalized" @@ -25,55 +24,70 @@ class StateVersionIncludeOpt(str, Enum): # ---- DTOs ---- + 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") - hosted_state_download_url: Optional[str] = Field(None, alias="hosted-state-download-url") - hosted_state_upload_url: Optional[str] = Field(None, alias="hosted-state-upload-url") - status: Optional[StateVersionStatus] = Field(None, alias="status") + hosted_state_download_url: str | None = Field( + None, alias="hosted-state-download-url" + ) + hosted_state_upload_url: str | None = Field(None, alias="hosted-state-upload-url") + status: StateVersionStatus | None = Field(None, alias="status") # Optional/advanced fields (present on newer servers; keep loose) - resources_processed: Optional[bool] = Field(None, alias="resources-processed") - modules: Optional[dict] = None - providers: Optional[dict] = None - resources: Optional[List[dict]] = None + resources_processed: bool | None = Field(None, alias="resources-processed") + modules: dict | None = None + providers: dict | None = None + resources: list[dict] | None = None class StateVersionCreateOptions(BaseModel): + """ + Options for POST /workspaces/:id/state-versions. + If you omit inline content (recommended), you must include serial & md5. + """ + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - # Mirrors go-tfe: optional hint payload - json_state_outputs: Optional[str] = Field(None, alias="json-state-outputs") + serial: int + md5: str + lineage: str | None = None + terraform_version: str | None = Field(None, alias="terraform-version") + + # Optional one-shot create path (if you don't use signed upload) + state: str | None = None # base64-encoded tfstate + json_state: str | None = Field(None, alias="json-state") + json_state_outputs: str | None = Field(None, alias="json-state-outputs") class StateVersionCurrentOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - include: Optional[List[StateVersionIncludeOpt]] = None + include: list[StateVersionIncludeOpt] | None = None class StateVersionReadOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - include: Optional[List[StateVersionIncludeOpt]] = None + include: list[StateVersionIncludeOpt] | None = None class StateVersionListOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) # Standard pagination + filters - page_number: Optional[int] = Field(None, alias="page[number]") - page_size: Optional[int] = Field(None, alias="page[size]") - organization: Optional[str] = Field(None, alias="filter[organization][name]") - workspace: Optional[str] = Field(None, alias="filter[workspace][name]") + page_number: int | None = Field(None, alias="page[number]") + page_size: int | None = Field(None, alias="page[size]") + organization: str | None = Field(None, alias="filter[organization][name]") + workspace: str | None = Field(None, alias="filter[workspace][name]") class StateVersionList(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - items: List[StateVersion] = Field(default_factory=list) - current_page: Optional[int] = None - total_pages: Optional[int] = None - total_count: Optional[int] = None + items: list[StateVersion] = Field(default_factory=list) + current_page: int | None = None + total_pages: int | None = None + total_count: int | None = None diff --git a/src/tfe/models/state_version_output.py b/src/tfe/models/state_version_output.py index c1a0c30..1d69b27 100644 --- a/src/tfe/models/state_version_output.py +++ b/src/tfe/models/state_version_output.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import Any, List, Optional +from typing import Any -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, ConfigDict, Field class StateVersionOutput(BaseModel): @@ -13,20 +13,20 @@ class StateVersionOutput(BaseModel): sensitive: bool type: str value: Any - detailed_type: Optional[Any] = Field(None, alias="detailed-type") + detailed_type: Any | None = Field(None, alias="detailed-type") class StateVersionOutputsListOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - page_number: Optional[int] = Field(None, alias="page[number]") - page_size: Optional[int] = Field(None, alias="page[size]") + page_number: int | None = Field(None, alias="page[number]") + page_size: int | None = Field(None, alias="page[size]") class StateVersionOutputsList(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - items: List[StateVersionOutput] = Field(default_factory=list) - current_page: Optional[int] = None - total_pages: Optional[int] = None - total_count: Optional[int] = None + items: list[StateVersionOutput] = Field(default_factory=list) + current_page: int | None = None + total_pages: int | None = None + total_count: int | None = None diff --git a/src/tfe/resources/state_version_outputs.py b/src/tfe/resources/state_version_outputs.py index 4f0e910..205073c 100644 --- a/src/tfe/resources/state_version_outputs.py +++ b/src/tfe/resources/state_version_outputs.py @@ -1,15 +1,14 @@ from __future__ import annotations -from typing import Any, Optional - -from ..utils import valid_string_id -from ._base import _Service +from typing import Any from ..models.state_version_output import ( StateVersionOutput, StateVersionOutputsList, StateVersionOutputsListOptions, ) +from ..utils import valid_string_id +from ._base import _Service def _safe_str(v: Any, default: str = "") -> str: @@ -40,7 +39,9 @@ def read(self, output_id: str) -> StateVersionOutput: ) def read_current( - self, workspace_id: str, options: Optional[StateVersionOutputsListOptions] = None + self, + workspace_id: str, + options: StateVersionOutputsListOptions | None = None, ) -> StateVersionOutputsList: """ Read outputs for the workspace's current state version. diff --git a/src/tfe/resources/state_versions.py b/src/tfe/resources/state_versions.py index 3ffae51..29e1f5c 100644 --- a/src/tfe/resources/state_versions.py +++ b/src/tfe/resources/state_versions.py @@ -1,14 +1,9 @@ from __future__ import annotations -from collections.abc import Iterator -from typing import Any, Optional, Dict +from typing import Any from urllib.parse import urlencode -from ..utils import valid_string_id -from ._base import _Service -from ..errors import ( - ErrStateVersionUploadNotSupported -) +from ..errors import NotFound # Pydantic models for this feature from ..models.state_version import ( @@ -20,10 +15,12 @@ StateVersionReadOptions, ) from ..models.state_version_output import ( + StateVersionOutput, StateVersionOutputsList, StateVersionOutputsListOptions, - StateVersionOutput, ) +from ..utils import looks_like_workspace_id, valid_string_id +from ._base import _Service def _safe_str(v: Any, default: str = "") -> str: @@ -46,18 +43,33 @@ class StateVersions(_Service): - POST /api/v2/state-versions/:id/actions/permanently_delete_backing_data (TFE only) """ + def _resolve_workspace_id(self, workspace: str, organization: str | None) -> str: + """Accept a workspace ID (ws-xxxxxx) or resolve by name with organization.""" + if looks_like_workspace_id(workspace): + return workspace + if not organization: + raise ValueError("organization is required when workspace is a name") + r = self.t.request( + "GET", f"/api/v2/organizations/{organization}/workspaces/{workspace}" + ) + data = r.json().get("data") or {} + ws_id = _safe_str(data.get("id")) + if not ws_id: + raise NotFound(f"workspace '{workspace}' not found in org '{organization}'") + return ws_id + # ---------------------------- # Listing & reading # ---------------------------- @staticmethod - def _encode_query(params: Dict[str, Any]) -> str: + def _encode_query(params: dict[str, Any]) -> str: clean = {k: v for k, v in params.items() if v is not None} if not clean: return "" return "?" + urlencode(clean, doseq=True) - def list(self, options: Optional[StateVersionListOptions] = None) -> StateVersionList: + def list(self, options: StateVersionListOptions | None = None) -> StateVersionList: """ GET /state-versions Accepts filters for organization and workspace and standard pagination. @@ -80,7 +92,6 @@ def list(self, options: Optional[StateVersionListOptions] = None) -> StateVersio total_count=meta.get("pagination", {}).get("total-count"), ) - def read(self, state_version_id: str) -> StateVersion: """Read a state version by ID.""" if not valid_string_id(state_version_id): @@ -162,20 +173,25 @@ def read_current_with_options( # ---------------------------- def create( - self, workspace_id: str, options: Optional[StateVersionCreateOptions] = None + self, + workspace: str, + options: StateVersionCreateOptions, + *, + organization: str | None = None, ) -> StateVersion: - """Create a state version record (server returns signed upload URL).""" - if not valid_string_id(workspace_id): - raise ValueError("invalid workspace id") + """Create a state-version record (returns hosted upload URLs if content omitted).""" + ws_id = self._resolve_workspace_id(workspace, organization) + + attrs = options.model_dump(by_alias=True, exclude_none=True) + if not attrs: + # API requires attributes; at minimum serial & md5 + raise ValueError( + "state-version create requires attributes (at least serial & md5)" + ) - body = { - "data": { - "type": "state-versions", - "attributes": (options.model_dump(by_alias=True, exclude_none=True) if options else {}), - } - } + body = {"data": {"type": "state-versions", "attributes": attrs}} r = self.t.request( - "POST", f"/api/v2/workspaces/{workspace_id}/state-versions", json_body=body + "POST", f"/api/v2/workspaces/{ws_id}/state-versions", json_body=body ) d = r.json()["data"] attr = d.get("attributes", {}) or {} @@ -184,41 +200,19 @@ def create( **{k.replace("-", "_"): v for k, v in attr.items()}, ) - def upload(self, workspace_id: str, *, raw_state: bytes | None = None, raw_json_state: bytes | None = None, - options: Optional[StateVersionCreateOptions] = None) -> StateVersion: - """ - Mirrors go-tfe Upload: - 1) POST to create (obtain upload URL) - 2) PUT the raw content to the object store (archivist) - """ - sv = self.create(workspace_id, options or StateVersionCreateOptions()) - upload_url = sv.hosted_state_upload_url - if not upload_url: - raise ErrStateVersionUploadNotSupported( - message="Server did not return an upload URL for state version", - method="PUT", path="(signed upload URL)" - ) - - # Choose the content - content = raw_json_state if raw_json_state is not None else raw_state - if content is None: - raise ErrStateVersionUploadNotSupported(message="No state content provided", method="PUT", path=upload_url) - - # Raw PUT to the object store - self.t.request( - "PUT", - upload_url, - json_body=None, - allow_redirects=True, - timeout=120, - headers={"Content-Type": "application/octet-stream"}, - raw_body=content, # transport should use raw bytes when provided - retry_non_idempotent=False, - ) + """ + def upload( + self, + workspace: str, + *, + raw_state: bytes | None = None, + raw_json_state: bytes | None = None, + options: Optional[StateVersionCreateOptions] = None, + organization: Optional[str] = None, + ) -> StateVersion: + # TBD: Implements Upload State Functionality + """ - # Read back the created SV to reflect any server-side fields - return self.read(sv.id) - def download(self, state_version_id: str) -> bytes: """ Download the raw state file bytes for a specific state version. @@ -235,13 +229,16 @@ def download(self, state_version_id: str) -> bytes: # Can happen if SV is missing, not finalized yet, or you lack permissions. # Also happens on some older/self-hosted versions if backing data was GC’d. from ..errors import NotFound + raise NotFound("download url not available for this state version") # Download the bytes from the signed Archivist URL (follow redirects). # Avoid JSON:API headers here; Accept */* is fine. - resp = self.t.request("GET", url, allow_redirects=True, headers={"Accept": "application/json"}) + resp = self.t.request( + "GET", url, allow_redirects=True, headers={"Accept": "application/json"} + ) return resp.content - + def download_current(self, workspace_id: str) -> bytes: """Download the current state for a workspace.""" if not valid_string_id(workspace_id): @@ -251,18 +248,21 @@ def download_current(self, workspace_id: str) -> bytes: url = sv.hosted_state_download_url if not url: from ..errors import NotFound + raise NotFound("download url not available for current state") - resp = self.t.request("GET", url, allow_redirects=True, headers={"Accept": "*/*"}) + resp = self.t.request( + "GET", url, allow_redirects=True, headers={"Accept": "*/*"} + ) return resp.content - - # ---------------------------- # Outputs (via state version) # ---------------------------- def list_outputs( - self, state_version_id: str, options: Optional[StateVersionOutputsListOptions] = None + self, + state_version_id: str, + options: StateVersionOutputsListOptions | None = None, ) -> StateVersionOutputsList: """List outputs for a given state version (paged).""" if not valid_string_id(state_version_id): diff --git a/src/tfe/utils.py b/src/tfe/utils.py index 506c78d..77f0a85 100644 --- a/src/tfe/utils.py +++ b/src/tfe/utils.py @@ -2,10 +2,11 @@ import re import time -from collections.abc import Callable +from collections.abc import Callable, Mapping from typing import Any _STRING_ID_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{2,}$") +_WS_ID_RE = re.compile(r"^ws-[A-Za-z0-9]+$") def poll_until( @@ -31,5 +32,30 @@ def valid_string(v: str | None) -> bool: def valid_string_id(v: str | None) -> bool: return v is not None and _STRING_ID_PATTERN.match(str(v)) is not None + def _safe_str(v: Any, default: str = "") -> str: return v if isinstance(v, str) else (str(v) if v is not None else default) + + +def looks_like_workspace_id(value: Any) -> bool: + """True if value matches "ws-" pattern.""" + return isinstance(value, str) and bool(_WS_ID_RE.match(value)) + + +def encode_query(params: Mapping[str, Any] | None) -> str: + """ + Best-effort encoder for JSON:API-style query dicts into a query string. + Keeps keys like "page[number]" intact. Values that are lists/tuples are joined with commas. + """ + if not params: + return "" + parts: list[str] = [] + for k, v in params.items(): + if v is None: + continue + if isinstance(v, (list, tuple)): + sv = ",".join(str(x) for x in v) + else: + sv = str(v) + parts.append(f"{k}={sv}") + return ("?" + "&".join(parts)) if parts else "" From 3803d2f864d1da6f245bc0b4c82c2e17a428d0da Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Wed, 17 Sep 2025 13:57:24 +0530 Subject: [PATCH 3/5] comments the debugging print --- src/tfe/_http.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tfe/_http.py b/src/tfe/_http.py index 7d047ea..497a6c6 100644 --- a/src/tfe/_http.py +++ b/src/tfe/_http.py @@ -81,7 +81,7 @@ def request( if headers: hdrs.update(headers) attempt = 0 - print(method, url, params, json_body, hdrs) + # print(method, url, params, json_body, hdrs) while True: try: resp = self._sync.request( @@ -103,7 +103,7 @@ def request( self._sleep(attempt, retry_after) attempt += 1 continue - print(resp) + # print(resp) self._raise_if_error(resp) return resp From 8067a799511441a7c2aa3d2a8aeab1bfab6ee5d4 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Wed, 17 Sep 2025 14:10:16 +0530 Subject: [PATCH 4/5] Autoformat lint --- src/tfe/_http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tfe/_http.py b/src/tfe/_http.py index 497a6c6..e5b8d8b 100644 --- a/src/tfe/_http.py +++ b/src/tfe/_http.py @@ -103,7 +103,7 @@ def request( self._sleep(attempt, retry_after) attempt += 1 continue - # print(resp) + # print(resp) self._raise_if_error(resp) return resp From 47d91896842bded886d480fb03c9f0c647909d01 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Sep 2025 12:11:22 +0530 Subject: [PATCH 5/5] linter fixes --- src/tfe/client.py | 3 +-- src/tfe/utils.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/tfe/client.py b/src/tfe/client.py index b88de17..e7a2f92 100644 --- a/src/tfe/client.py +++ b/src/tfe/client.py @@ -2,12 +2,11 @@ from ._http import HTTPTransport from .config import TFEConfig - from .resources.organizations import Organizations from .resources.projects import Projects +from .resources.registry_module import RegistryModules from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions -from .resources.registry_module import RegistryModules from .resources.variable import Variables from .resources.workspaces import Workspaces diff --git a/src/tfe/utils.py b/src/tfe/utils.py index a3da3f1..e99eea5 100644 --- a/src/tfe/utils.py +++ b/src/tfe/utils.py @@ -80,6 +80,7 @@ def encode_query(params: Mapping[str, Any] | None) -> str: parts.append(f"{k}={sv}") return ("?" + "&".join(parts)) if parts else "" + def valid_version(v: str | None) -> bool: """Validate semantic version string.""" return v is not None and _VERSION_PATTERN.match(str(v)) is not None @@ -196,4 +197,3 @@ def validate_workspace_update_options(options: WorkspaceUpdateOptions) -> None: if options.file_triggers_enabled is not None and options.file_triggers_enabled: raise UnsupportedBothTagsRegexAndFileTriggersEnabledError() -