Skip to content

Commit 57ec2a0

Browse files
feat: add client
1 parent c4f76d5 commit 57ec2a0

4 files changed

Lines changed: 142 additions & 42 deletions

File tree

‎tests/units/test_client.py‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1 @@
1-
"""
2-
Simple tests for the client.
3-
"""
41

5-
from tfe.client import TerraformEnterpriseClient
6-
7-
8-
def test_foo():
9-
"""Test foo returns foo."""
10-
client = TerraformEnterpriseClient("token")
11-
assert client.foo() == "foo"
12-
13-
14-
def test_bar():
15-
"""Test bar returns bar."""
16-
client = TerraformEnterpriseClient("token")
17-
assert client.bar() == "bar"

‎tfe/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
workspaces, runs, state files, and other TFE/TFC resources.
77
"""
88

9-
from tfe.client import TerraformEnterpriseClient
9+
from tfe.client import Client, TFEClientError
1010
from tfe.config import Config
1111

12-
__all__ = ["TerraformEnterpriseClient", "Config"]
12+
__all__ = ["Client", "TFEClientError", "Config"]

‎tfe/client.py‎

Lines changed: 131 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,140 @@
22
Main client class for Terraform Enterprise/Cloud API.
33
"""
44

5+
import logging
6+
from typing import cast
7+
from urllib.parse import urljoin, urlparse
58

6-
class TerraformEnterpriseClient:
7-
"""Simple client for Terraform Enterprise/Cloud API."""
9+
from tfe.config import Config
810

9-
def __init__(self, token: str, base_url: str = "https://app.terraform.io/api/v2/"):
10-
"""Initialize the client with API token and base URL."""
11-
self.token = token
12-
self.base_url = base_url.rstrip("/") + "/"
11+
logger = logging.getLogger(__name__)
1312

14-
def get_base_url(self) -> str:
15-
"""Get the base URL."""
16-
return self.base_url
1713

18-
def foo(self) -> str:
19-
"""Simple foo method for testing."""
20-
return "foo"
14+
class TFEClientError(Exception):
15+
"""Base exception for TFE client errors."""
2116

22-
def bar(self) -> str:
23-
"""Simple bar method for testing."""
24-
return "bar"
17+
pass
2518

26-
def foobar(self) -> str:
27-
"""Combine foo and bar."""
28-
return f"{self.foo()}{self.bar()}"
19+
20+
class Client:
21+
"""
22+
Client is the Terraform Enterprise API client. It provides the basic
23+
functionality to interact with the Terraform API.
24+
"""
25+
26+
def __init__(self, config: Config | None = None) -> None:
27+
self.config = Config()
28+
29+
if config is not None:
30+
self._merge_config(config)
31+
32+
self._validate_config()
33+
self._setup_urls()
34+
35+
self._api_version = ""
36+
self._tfe_version = ""
37+
self._app_name = ""
38+
self._fetch_api_metadata()
39+
40+
def _merge_config(self, config: Config) -> None:
41+
"""Merge provided config with defaults, preserving non-empty values."""
42+
if config.address:
43+
self.config.address = config.address
44+
if config.base_path:
45+
self.config.base_path = config.base_path
46+
if config.registry_base_path:
47+
self.config.registry_base_path = config.registry_base_path
48+
if config.token:
49+
self.config.token = config.token
50+
if config.headers:
51+
if self.config.headers is None:
52+
self.config.headers = {}
53+
self.config.headers.update(config.headers)
54+
if config.http_client:
55+
self.config.http_client = config.http_client
56+
if config.retry_log_hook:
57+
self.config.retry_log_hook = config.retry_log_hook
58+
self.config.retry_server_errors = config.retry_server_errors
59+
60+
def _validate_config(self) -> None:
61+
"""Validate required configuration."""
62+
if not self.config.token:
63+
raise TFEClientError("API token is required")
64+
65+
if not self.config.address:
66+
raise TFEClientError("API address is required")
67+
68+
# Type narrowing: after validation, we know these are not None
69+
assert self.config.address is not None
70+
assert self.config.token is not None
71+
72+
def _setup_urls(self) -> None:
73+
"""Parse and setup base URLs."""
74+
try:
75+
# After validation, we know address is not None
76+
parsed_url = urlparse(self.config.address)
77+
if not parsed_url.scheme:
78+
raise ValueError("Address must include protocol (http/https)")
79+
80+
# Ensure base path ends with /
81+
base_path = self.config.base_path
82+
if not base_path.endswith("/"):
83+
base_path += "/"
84+
85+
registry_path = self.config.registry_base_path
86+
if not registry_path.endswith("/"):
87+
registry_path += "/"
88+
89+
self.base_url = urljoin(self.config.address, base_path)
90+
self.registry_base_url = urljoin(self.config.address, registry_path)
91+
92+
except Exception as e:
93+
raise TFEClientError(f"Invalid address '{self.config.address}': {e}") from e
94+
95+
def _fetch_api_metadata(self) -> None:
96+
"""Fetch API metadata from the server."""
97+
ping_url = urljoin(self.base_url, "ping")
98+
99+
# After validation, we know token is not None
100+
token = cast(str, self.config.token)
101+
headers = {
102+
"Accept": "application/vnd.api+json",
103+
"Authorization": f"Bearer {token}",
104+
}
105+
if self.config.headers:
106+
headers.update(self.config.headers)
107+
108+
response = self.config.http_client.get(ping_url, headers=headers)
109+
response.raise_for_status()
110+
111+
# Extract metadata from headers
112+
self._api_version = response.headers.get("TFP-API-Version", "")
113+
self._tfe_version = response.headers.get("X-TFE-Version", "")
114+
self._app_name = response.headers.get("TFP-AppName", "")
115+
116+
@property
117+
def remote_api_version(self) -> str:
118+
"""Return the server's declared API version string."""
119+
return self._api_version
120+
121+
@property
122+
def remote_tfe_version(self) -> str:
123+
"""Return the server's declared TFE version string."""
124+
return self._tfe_version
125+
126+
@property
127+
def app_name(self) -> str:
128+
"""Return the name of the instance."""
129+
return self._app_name
130+
131+
def is_cloud(self) -> bool:
132+
"""Return True if the client is configured against HCP Terraform."""
133+
return self._app_name == "HCP Terraform"
134+
135+
def is_enterprise(self) -> bool:
136+
"""Return True if the client is configured against Terraform Enterprise."""
137+
return not self.is_cloud()
138+
139+
def set_fake_remote_api_version(self, version: str) -> None:
140+
"""Set a fake API version for testing purposes."""
141+
self._api_version = version

‎tfe/config.py‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515
@dataclass
1616
class Config:
1717
# Address of the Terraform Enterprise API
18-
address: str | None = field(default_factory=lambda: os.getenv("TFE_ADDRESS"))
18+
address: str = ""
1919

2020
# Base path for which the API is served
21-
base_path: str | None = DEFAULT_BASE_PATH
21+
base_path: str = DEFAULT_BASE_PATH
2222

2323
# Base path for the Terraform Enterprise Registry API
24-
registry_base_path: str | None = DEFAULT_REGISTRY_PATH
24+
registry_base_path: str = DEFAULT_REGISTRY_PATH
2525

2626
# API token used to access the terraform enterprise API
2727
token: str | None = field(default_factory=lambda: os.getenv("TFE_TOKEN"))
@@ -40,10 +40,13 @@ class Config:
4040
retry_server_errors: bool = False
4141

4242
def __post_init__(self) -> None:
43+
tfe_address = os.getenv("TFE_ADDRESS", "")
44+
if tfe_address:
45+
self.address = tfe_address
46+
4347
if not self.address:
44-
host = os.getenv("TFE_HOST")
45-
if host:
46-
self.address = f"https://{host}"
48+
if os.getenv("TFE_HOST"):
49+
self.address = f"https://{os.getenv('TFE_HOST')}"
4750
else:
4851
self.address = DEFAULT_ADDRESS
4952

0 commit comments

Comments
 (0)