Skip to content

Commit 901eee8

Browse files
Initial Client and Config class for tfe (#5)
* feat: config class for tfe * feat: add client * fix: cleanup types * tests: improve tests for client and config * tests: make lint happy * chore: add git hooks for pre-commit formatting * fix: add shebang to precommit hook
1 parent e8c9e7f commit 901eee8

11 files changed

Lines changed: 428 additions & 52 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ htmlcov
1313
.pytest_cache/
1414
.mypy_cache/
1515
.ruff_cache/
16+
*.egg-info
1617

1718
# Visual Studio Code
1819
.vscode/

‎.hooks/pre-commit‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
# Call block to block the commit with a message.
6+
block() {
7+
echo "$@"
8+
echo "Commit blocked - see errors above."
9+
exit 1
10+
}
11+
12+
# Add all check functions to this space separated list.
13+
# They are executed in this order (see end of file).
14+
CHECKS="fmt lint"
15+
16+
# Run fmt against changed files compared to origin/main
17+
fmt() {
18+
echo "==> Running fmt on all files"
19+
make fmt || block "Formatting failed"
20+
21+
# Re-add any files that were changed by the fixers
22+
git add -u
23+
}
24+
25+
lint() {
26+
echo "==> Running lint on all files"
27+
make lint || block "Linting failed"
28+
29+
# Re-add any files that were changed by the fixers
30+
git add -u
31+
}
32+
33+
for CHECK in $CHECKS; do
34+
# Force each check into a subshell to avoid crosstalk.
35+
( $CHECK ) || exit $?
36+
done

‎Makefile‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
.PHONY: help fmt fmt-check lint check test install dev-install type-check clean all venv activate
22

33
PYTHON := python3
4-
SRC_DIR := src
4+
SRC_DIR := tfe
55
TEST_DIR := tests
66
VENV := .venv
77
VENV_PYTHON := $(VENV)/bin/python
@@ -59,7 +59,7 @@ type-check:
5959
$(VENV_PYTHON) -m mypy $(SRC_DIR)
6060

6161
test:
62-
$(VENV_PYTHON) -m pytest
62+
$(VENV_PYTHON) -m pytest -v
6363

6464
clean:
6565
find . -type f -name "*.pyc" -delete

‎pyproject.toml‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,22 @@ requires-python = ">=3.10"
1313
dependencies = ["requests>=2.25.0"]
1414

1515
[project.optional-dependencies]
16-
dev = ["pytest>=7.0.0", "mypy>=1.17.1", "ruff>=0.12.10"]
16+
dev = [
17+
"pytest>=7.0.0",
18+
"pytest-mock>=3.14.1",
19+
"mypy>=1.17.1",
20+
"ruff>=0.12.10",
21+
"types-requests>=2.32.4.20250809",
22+
]
1723

1824
[project.urls]
1925
Repository = "https://github.com/hashicorp/python-tfe"
2026

2127
[tool.setuptools.packages.find]
22-
where = ["src"]
28+
where = ["tfe"]
2329

2430
[tool.setuptools.package-dir]
25-
"" = "src"
31+
"" = "tfe"
2632

2733
# Ruff configuration
2834
[tool.ruff]

‎src/python_tfe/client.py‎

Lines changed: 0 additions & 28 deletions
This file was deleted.

‎tests/test_client.py‎

Lines changed: 0 additions & 17 deletions
This file was deleted.

‎tests/units/test_client.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from unittest.mock import Mock, patch
2+
3+
import pytest
4+
5+
from tfe import client, config
6+
7+
8+
@pytest.fixture
9+
def test_config():
10+
return config.Config(address="https://app.terraform.io", token="test-token")
11+
12+
13+
@pytest.fixture
14+
def mock_response():
15+
response = Mock()
16+
response.headers = {
17+
"TFP-API-Version": "2.5.0",
18+
"X-TFE-Version": "v202308-1",
19+
"TFP-AppName": "HCP Terraform",
20+
}
21+
response.raise_for_status.return_value = None
22+
return response
23+
24+
25+
class TestClient:
26+
@patch("requests.Session.get")
27+
def test_client_initialization(self, mock_get, test_config, mock_response):
28+
"""Test basic client setup works."""
29+
mock_get.return_value = mock_response
30+
31+
client_instance = client.Client(config=test_config)
32+
33+
assert client_instance.config.address == "https://app.terraform.io"
34+
assert client_instance.config.token == "test-token"
35+
assert client_instance.base_url == "https://app.terraform.io/api/v2/"
36+
assert (
37+
client_instance.registry_base_url
38+
== "https://app.terraform.io/api/registry/"
39+
)
40+
41+
@patch("requests.Session.get")
42+
def test_url_normalization(self, mock_get, mock_response):
43+
"""Test that paths get normalized with trailing slashes."""
44+
mock_get.return_value = mock_response
45+
46+
cfg = config.Config(
47+
address="https://example.com",
48+
token="test",
49+
base_path="/custom/api", # no trailing slash
50+
registry_base_path="/registry", # no trailing slash
51+
)
52+
53+
client_instance = client.Client(config=cfg)
54+
55+
assert client_instance.base_url == "https://example.com/custom/api/"
56+
assert client_instance.registry_base_url == "https://example.com/registry/"
57+
58+
@patch("requests.Session.get")
59+
def test_api_metadata_extraction(self, mock_get, test_config, mock_response):
60+
"""Test that API metadata gets extracted from response headers."""
61+
mock_get.return_value = mock_response
62+
63+
client_instance = client.Client(config=test_config)
64+
65+
assert client_instance.remote_api_version == "2.5.0"
66+
assert client_instance.remote_tfe_version == "v202308-1"
67+
assert client_instance.app_name == "HCP Terraform"
68+
69+
@patch("requests.Session.get")
70+
def test_cloud_vs_enterprise_detection(self, mock_get, test_config):
71+
"""Test detection between cloud and enterprise instances."""
72+
# Test HCP Terraform (cloud)
73+
cloud_response = Mock()
74+
cloud_response.headers = {"TFP-AppName": "HCP Terraform"}
75+
cloud_response.raise_for_status.return_value = None
76+
mock_get.return_value = cloud_response
77+
78+
cloud_client = client.Client(config=test_config)
79+
assert cloud_client.is_cloud() is True
80+
assert cloud_client.is_enterprise() is False
81+
82+
# Test Terraform Enterprise
83+
enterprise_response = Mock()
84+
enterprise_response.headers = {"TFP-AppName": "Terraform Enterprise"}
85+
enterprise_response.raise_for_status.return_value = None
86+
mock_get.return_value = enterprise_response
87+
88+
enterprise_client = client.Client(config=test_config)
89+
assert enterprise_client.is_cloud() is False
90+
assert enterprise_client.is_enterprise() is True
91+
92+
@patch("requests.Session.get")
93+
def test_fake_api_version_for_testing(self, mock_get, test_config, mock_response):
94+
"""Test the fake API version setter for testing scenarios."""
95+
mock_get.return_value = mock_response
96+
97+
client_instance = client.Client(config=test_config)
98+
99+
# Original version from mock
100+
assert client_instance.remote_api_version == "2.5.0"
101+
102+
# Set fake version
103+
client_instance.set_fake_remote_api_version("3.0.0")
104+
assert client_instance.remote_api_version == "3.0.0"

‎tests/units/test_config.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import pytest
2+
import requests
3+
4+
from tfe import config
5+
6+
7+
@pytest.fixture(autouse=True)
8+
def reset_environment(monkeypatch):
9+
"""Reset environment variables before each test."""
10+
monkeypatch.delenv("TFE_ADDRESS", raising=False)
11+
monkeypatch.delenv("TFE_TOKEN", raising=False)
12+
monkeypatch.delenv("TFE_HOST", raising=False)
13+
monkeypatch.setenv("TFE_TOKEN", "abc123")
14+
yield
15+
16+
17+
@pytest.fixture
18+
def cfg():
19+
"""Provide a fresh Config instance with clean environment."""
20+
return config.Config()
21+
22+
23+
@pytest.fixture
24+
def test_session():
25+
"""Provide a clean requests session without default headers."""
26+
session = requests.Session()
27+
session.headers["User-Agent"] = "test"
28+
session.headers["Authorization"] = "Bearer test"
29+
return session
30+
31+
32+
class TestConfig:
33+
def test_default_config(self, cfg):
34+
"""Test that default configuration values are set correctly."""
35+
assert cfg.address == config.DEFAULT_ADDRESS
36+
assert cfg.base_path == config.DEFAULT_BASE_PATH
37+
assert cfg.registry_base_path == config.DEFAULT_REGISTRY_PATH
38+
assert isinstance(cfg.http_client, requests.Session)
39+
assert "User-Agent" in cfg.http_client.headers
40+
assert cfg.retry_log_hook is None
41+
assert cfg.retry_server_errors is False
42+
43+
def test_env_address_and_token(self, monkeypatch):
44+
"""Test that environment variables TFE_ADDRESS and TFE_TOKEN are read correctly."""
45+
monkeypatch.setenv("TFE_ADDRESS", "https://custom.tfe")
46+
cfg = config.Config()
47+
assert cfg.address == "https://custom.tfe"
48+
assert cfg.token == "abc123"
49+
50+
def test_env_host_fallback(self, monkeypatch):
51+
"""Test that TFE_HOST is used as fallback when TFE_ADDRESS is not set."""
52+
monkeypatch.setenv("TFE_HOST", "host.tfe")
53+
cfg = config.Config()
54+
assert cfg.address == "https://host.tfe"
55+
56+
def test_explicit_address_override(self):
57+
"""Test that explicitly passed address overrides environment variables."""
58+
cfg = config.Config(address="https://explicit.tfe")
59+
assert cfg.address == "https://explicit.tfe"
60+
61+
def test_headers_update(self):
62+
"""Test that custom headers are properly merged with default headers."""
63+
custom_headers = {"Authorization": "Bearer testtoken", "X-Test": "yes"}
64+
cfg = config.Config(headers=custom_headers)
65+
assert "Authorization" in cfg.http_client.headers
66+
assert cfg.http_client.headers["Authorization"] == "Bearer testtoken"
67+
assert "X-Test" in cfg.http_client.headers
68+
assert cfg.http_client.headers["X-Test"] == "yes"
69+
assert "User-Agent" in cfg.http_client.headers
70+
71+
def test_retry_log_hook_and_server_errors(self):
72+
"""Test that retry configuration is properly set."""
73+
74+
def dummy_hook(retries, response):
75+
pass
76+
77+
cfg = config.Config(retry_log_hook=dummy_hook, retry_server_errors=True)
78+
assert cfg.retry_log_hook == dummy_hook
79+
assert cfg.retry_server_errors is True
80+
81+
def test_custom_session(self, test_session):
82+
"""Test that User-Agent is set when session has no default User-Agent."""
83+
cfg = config.Config(http_client=test_session)
84+
assert "User-Agent" in cfg.http_client.headers
85+
assert cfg.http_client.headers["User-Agent"] == "test"
86+
assert cfg.http_client.headers["Authorization"] == "Bearer test"
87+
88+
def test_validate_config(self, monkeypatch):
89+
"""Test that configuration validation works as expected."""
90+
with pytest.raises(ValueError, match="API token is required") as _:
91+
monkeypatch.setenv("TFE_TOKEN", "")
92+
_ = config.Config(token="")
93+
94+
with pytest.raises(ValueError, match="Address must include protocol") as _:
95+
monkeypatch.setenv("TFE_TOKEN", "test-token")
96+
monkeypatch.setenv("TFE_ADDRESS", "test.foo.bar")
97+
_ = config.Config()
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
workspaces, runs, state files, and other TFE/TFC resources.
77
"""
88

9-
from .client import TerraformEnterpriseClient
9+
from tfe.client import Client, TFEClientError
10+
from tfe.config import Config
1011

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

0 commit comments

Comments
 (0)