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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.PHONY: help fmt fmt-check lint check test install dev-install type-check clean all venv activate

PYTHON := python3
SRC_DIR := tfe
SRC_DIR := src/tfe
TEST_DIR := tests
VENV := .venv
VENV_PYTHON := $(VENV)/bin/python
Expand Down
1 change: 1 addition & 0 deletions doc/.keep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
##
12 changes: 12 additions & 0 deletions examples/ws_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from tfe import TFEClient, TFEConfig


def main():
client = TFEClient(TFEConfig.from_env())
org = "tfe-xxxxx"
for ws in client.workspaces.list(org):
print("WS:", ws.name, ws.id)


if __name__ == "__main__":
main()
32 changes: 19 additions & 13 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
requires = ["hatchling>=1.21"]
build-backend = "hatchling.build"

[project]
name = "python-tfe"
version = "0.1.0"
description = "Python client library for Terraform Enterprise/Cloud API"
name = "tfe"
version = "0.1.0a1"
description = "Official Python SDK for HashiCorp Terraform Cloud / Terraform Enterprise (TFE) API v2"
readme = "README.md"
license = { text = "MPL-2.0" }
authors = [{ name = "HashiCorp" }]
requires-python = ">=3.10"
dependencies = ["requests>=2.25.0"]
requires-python = ">=3.9"
dependencies = [
"httpx>=0.27.0,<0.29.0",
"pydantic>=2.6,<3",
"typing-extensions>=4.8",
"anyio>=4.0",
"h2>=4.3.0"
]
classifiers = [
"License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
Expand All @@ -32,11 +41,8 @@ dev = [
"Repository" = "https://github.com/hashicorp/python-tfe"
"Bug Tracker" = "https://github.com/hashicorp/python-tfe/issues"

[tool.setuptools.packages.find]
where = ["tfe"]

[tool.setuptools.package-dir]
"" = "tfe"
[tool.hatch.build.targets.wheel]
packages = ["src/tfe"]

# Ruff configuration
[tool.ruff]
Expand Down Expand Up @@ -97,7 +103,7 @@ warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
disallow_untyped_decorators = false
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
Expand Down
5 changes: 5 additions & 0 deletions src/tfe/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from . import errors
from .client import TFEClient
from .config import TFEConfig

__all__ = ["TFEConfig", "TFEClient", "errors"]
188 changes: 188 additions & 0 deletions src/tfe/_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
from __future__ import annotations

import time
from collections.abc import Mapping
from typing import Any

import anyio
import httpx

from ._jsonapi import build_headers, parse_error_payload
from .errors import (
AuthError,
NotFound,
RateLimited,
ServerError,
TFEError,
)

_RETRY_STATUSES = {429, 502, 503, 504}


class HTTPTransport:
def __init__(
self,
address: str,
token: str,
*,
timeout: float,
verify_tls: bool,
user_agent_suffix: str | None,
max_retries: int,
backoff_base: float,
backoff_cap: float,
backoff_jitter: bool,
http2: bool,
proxies: dict | None,
ca_bundle: str | None,
):
self.base = address.rstrip("/")
self.headers = build_headers(user_agent_suffix)
if token:
self.headers["Authorization"] = f"Bearer {token}"
self.timeout = timeout
self.verify = verify_tls
self.max_retries = max_retries
self.backoff_base = backoff_base
self.backoff_cap = backoff_cap
self.backoff_jitter = backoff_jitter
self.http2 = http2
self.proxies = proxies
self.ca_bundle = ca_bundle
self._sync = httpx.Client(
http2=http2, timeout=timeout, verify=ca_bundle or verify_tls
) # proxies=proxies
self._async = httpx.AsyncClient(
http2=http2, timeout=timeout, verify=ca_bundle or verify_tls
) # proxies=proxies
Comment on lines +52 to +57
Copy link

Copilot AI Sep 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The commented out proxies=proxies suggests incomplete implementation. Either implement proxy support or remove the commented code and the proxies parameter if not needed.

Suggested change
self._sync = httpx.Client(
http2=http2, timeout=timeout, verify=ca_bundle or verify_tls
) # proxies=proxies
self._async = httpx.AsyncClient(
http2=http2, timeout=timeout, verify=ca_bundle or verify_tls
) # proxies=proxies
client_kwargs = {
"http2": http2,
"timeout": timeout,
"verify": ca_bundle or verify_tls,
}
if proxies is not None:
client_kwargs["proxies"] = proxies
self._sync = httpx.Client(**client_kwargs)
self._async = httpx.AsyncClient(**client_kwargs)

Copilot uses AI. Check for mistakes.

def request(
self,
method: str,
path: str,
*,
params: Mapping[str, Any] | None = None,
json_body: Mapping[str, Any] | None = None,
headers: dict[str, str] | None = None,
allow_redirects: bool = True,
) -> httpx.Response:
url = f"{self.base}{path}"
hdrs = dict(self.headers)
if headers:
hdrs.update(headers)
attempt = 0
while True:
try:
resp = self._sync.request(
method,
url,
params=params,
json=json_body,
headers=hdrs,
follow_redirects=allow_redirects,
)
except httpx.HTTPError as e:
if attempt >= self.max_retries:
raise ServerError(str(e)) from e
self._sleep(attempt, None)
attempt += 1
continue
if resp.status_code in _RETRY_STATUSES and attempt < self.max_retries:
retry_after = _parse_retry_after(resp)
self._sleep(attempt, retry_after)
attempt += 1
continue
self._raise_if_error(resp)
return resp

async def arequest(
self,
method: str,
path: str,
*,
params: Mapping[str, Any] | None = None,
json_body: Mapping[str, Any] | None = None,
headers: dict[str, str] | None = None,
allow_redirects: bool = True,
) -> httpx.Response:
url = f"{self.base}{path}"
hdrs = dict(self.headers)
hdrs.update(headers or {})
attempt = 0
while True:
try:
resp = await self._async.request(
method,
url,
params=params,
json=json_body,
headers=hdrs,
follow_redirects=allow_redirects,
)
except httpx.HTTPError as e:
if attempt >= self.max_retries:
raise ServerError(str(e)) from e
await self._asleep(attempt, None)
attempt += 1
continue
if resp.status_code in _RETRY_STATUSES and attempt < self.max_retries:
retry_after = _parse_retry_after(resp)
await self._asleep(attempt, retry_after)
attempt += 1
continue
self._raise_if_error(resp)
return resp

def _sleep(self, attempt: int, retry_after: float | None) -> None:
if retry_after is not None:
time.sleep(retry_after)
return
delay = min(self.backoff_cap, self.backoff_base * (2**attempt))
time.sleep(delay)

async def _asleep(self, attempt: int, retry_after: float | None) -> None:
if retry_after is not None:
await anyio.sleep(retry_after)
return
delay = min(self.backoff_cap, self.backoff_base * (2**attempt))
await anyio.sleep(delay)

def _raise_if_error(self, resp: httpx.Response) -> None:
status = resp.status_code

if 200 <= status < 300:
return
try:
payload: Any = resp.json()
except Exception:
payload = {}
errors = parse_error_payload(payload)
msg: str = f"HTTP {status}"
if errors:
maybe_detail = errors[0].get("detail")
maybe_title = errors[0].get("title")
if isinstance(maybe_detail, str) and maybe_detail:
msg = maybe_detail
elif isinstance(maybe_title, str) and maybe_title:
msg = maybe_title

if status in (401, 403):
raise AuthError(msg, status=status, errors=errors)
if status == 404:
raise NotFound(msg, status=status, errors=errors)
if status == 429:
ra = _parse_retry_after(resp)
raise RateLimited(msg, status=status, errors=errors, retry_after=ra)
if status >= 500:
raise ServerError(msg, status=status, errors=errors)
raise TFEError(msg, status=status, errors=errors)


def _parse_retry_after(resp: httpx.Response) -> float | None:
ra = resp.headers.get("Retry-After")
if not ra:
return None
try:
return float(ra)
except Exception:
return None
23 changes: 23 additions & 0 deletions src/tfe/_jsonapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from __future__ import annotations

from typing import Any


def build_headers(user_agent_suffix: str | None = None) -> dict[str, str]:
ua = "python-tfe/0.1"
if user_agent_suffix:
ua = f"{ua} {user_agent_suffix}"
return {
"Accept": "application/vnd.api+json",
"Content-Type": "application/vnd.api+json",
"User-Agent": ua,
}


def parse_error_payload(payload: dict[str, Any]) -> list[dict]:
errs = payload.get("errors")
if isinstance(errs, list):
return errs
if "message" in payload:
return [{"detail": payload.get("message")}]
return []
27 changes: 27 additions & 0 deletions src/tfe/aclient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""
Async TFE Client: This client should not be used for now.
"""

from __future__ import annotations

from ._http import HTTPTransport
from .config import TFEConfig


class AsyncTFEClient:
def __init__(self, config: TFEConfig | None = None):
cfg = config or TFEConfig.from_env()
self._transport = HTTPTransport(
cfg.address,
cfg.token,
timeout=cfg.timeout,
verify_tls=cfg.verify_tls,
user_agent_suffix=cfg.user_agent_suffix,
max_retries=cfg.max_retries,
backoff_base=cfg.backoff_base,
backoff_cap=cfg.backoff_cap,
backoff_jitter=cfg.backoff_jitter,
http2=cfg.http2,
proxies=cfg.proxies,
ca_bundle=cfg.ca_bundle,
)
32 changes: 32 additions & 0 deletions src/tfe/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from __future__ import annotations

from ._http import HTTPTransport
from .config import TFEConfig
from .resources.organizations import Organizations
from .resources.projects import Projects
from .resources.workspaces import Workspaces


class TFEClient:
def __init__(self, config: TFEConfig | None = None):
cfg = config or TFEConfig.from_env()
self._transport = HTTPTransport(
cfg.address,
cfg.token,
timeout=cfg.timeout,
verify_tls=cfg.verify_tls,
user_agent_suffix=cfg.user_agent_suffix,
max_retries=cfg.max_retries,
backoff_base=cfg.backoff_base,
backoff_cap=cfg.backoff_cap,
backoff_jitter=cfg.backoff_jitter,
http2=cfg.http2,
proxies=cfg.proxies,
ca_bundle=cfg.ca_bundle,
)
self.organizations = Organizations(self._transport)
self.projects = Projects(self._transport)
self.workspaces = Workspaces(self._transport)

def close(self) -> None:
pass
30 changes: 30 additions & 0 deletions src/tfe/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from __future__ import annotations

import os

from pydantic import BaseModel, Field


class TFEConfig(BaseModel):
address: str = Field(
default_factory=lambda: os.getenv("TFE_ADDRESS", "https://app.terraform.io")
)
token: str = Field(default_factory=lambda: os.getenv("TFE_TOKEN", ""))
timeout: float = float(os.getenv("TFE_TIMEOUT", "30"))
verify_tls: bool = os.getenv("TFE_VERIFY_TLS", "true").lower() not in (
"0",
"false",
"no",
)
user_agent_suffix: str | None = None
max_retries: int = int(os.getenv("TFE_MAX_RETRIES", "5"))
backoff_base: float = 0.5
backoff_cap: float = 8.0
backoff_jitter: bool = True
http2: bool = True
proxies: dict[str, str] | None = None
ca_bundle: str | None = os.getenv("SSL_CERT_FILE", None)

@classmethod
def from_env(cls) -> TFEConfig:
return cls()
Loading
Loading