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
117 changes: 117 additions & 0 deletions examples/state_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
from __future__ import annotations

import argparse
import os
from pathlib import Path

from tfe import TFEClient, TFEConfig
from tfe.errors import ErrStateVersionUploadNotSupported
from tfe.models.state_version import (
StateVersionCreateOptions,
StateVersionCurrentOptions,
StateVersionListOptions,
)
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()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 13 additions & 1 deletion src/tfe/_http.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import re
import time
from collections.abc import Mapping
from typing import Any
from urllib.parse import urljoin

import anyio
import httpx
Expand All @@ -18,6 +20,8 @@

_RETRY_STATUSES = {429, 502, 503, 504}

ABSOLUTE_URL_RE = re.compile(r"^https?://", re.I)


class HTTPTransport:
def __init__(
Expand Down Expand Up @@ -56,6 +60,12 @@ def __init__(
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,
method: str,
Expand All @@ -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(
Expand All @@ -92,6 +103,7 @@ def request(
self._sleep(attempt, retry_after)
attempt += 1
continue
# print(resp)
self._raise_if_error(resp)
return resp

Expand Down
5 changes: 5 additions & 0 deletions src/tfe/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
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.variable import Variables
from .resources.workspaces import Workspaces

Expand Down Expand Up @@ -32,5 +34,8 @@ def __init__(self, config: TFEConfig | None = None):
self.workspaces = Workspaces(self._transport)
self.registry_modules = RegistryModules(self._transport)

self.state_versions = StateVersions(self._transport)
self.state_version_outputs = StateVersionOutputs(self._transport)

def close(self) -> None:
pass
3 changes: 3 additions & 0 deletions src/tfe/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ class InvalidValues(TFEError): ...
class RequiredFieldMissing(TFEError): ...


class ErrStateVersionUploadNotSupported(TFEError): ...


# Error message constants
ERR_INVALID_NAME = "invalid value for name"
ERR_REQUIRED_NAME = "name is required"
Expand Down
93 changes: 93 additions & 0 deletions src/tfe/models/state_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from __future__ import annotations

from datetime import datetime
from enum import Enum

from pydantic import BaseModel, ConfigDict, Field

# ---- 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: 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: 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)

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: list[StateVersionIncludeOpt] | None = None


class StateVersionReadOptions(BaseModel):
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)

include: list[StateVersionIncludeOpt] | None = None


class StateVersionListOptions(BaseModel):
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)

# Standard pagination + filters
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: int | None = None
total_pages: int | None = None
total_count: int | None = None
32 changes: 32 additions & 0 deletions src/tfe/models/state_version_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from __future__ import annotations

from typing import Any

from pydantic import BaseModel, ConfigDict, Field


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: Any | None = Field(None, alias="detailed-type")


class StateVersionOutputsListOptions(BaseModel):
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)

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: int | None = None
total_pages: int | None = None
total_count: int | None = None
Loading
Loading