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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ htmlcov
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.egg-info

# Visual Studio Code
.vscode/
Expand Down
36 changes: 36 additions & 0 deletions .hooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash

set -euo pipefail

# Call block to block the commit with a message.
block() {
echo "$@"
echo "Commit blocked - see errors above."
exit 1
}

# Add all check functions to this space separated list.
# They are executed in this order (see end of file).
CHECKS="fmt lint"

# Run fmt against changed files compared to origin/main
fmt() {
echo "==> Running fmt on all files"
make fmt || block "Formatting failed"

# Re-add any files that were changed by the fixers
git add -u
}

lint() {
echo "==> Running lint on all files"
make lint || block "Linting failed"

# Re-add any files that were changed by the fixers
git add -u
}

for CHECK in $CHECKS; do
# Force each check into a subshell to avoid crosstalk.
( $CHECK ) || exit $?
done
4 changes: 2 additions & 2 deletions 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 := src
SRC_DIR := tfe
TEST_DIR := tests
VENV := .venv
VENV_PYTHON := $(VENV)/bin/python
Expand Down Expand Up @@ -59,7 +59,7 @@ type-check:
$(VENV_PYTHON) -m mypy $(SRC_DIR)

test:
$(VENV_PYTHON) -m pytest
$(VENV_PYTHON) -m pytest -v

clean:
find . -type f -name "*.pyc" -delete
Expand Down
12 changes: 9 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,22 @@ requires-python = ">=3.10"
dependencies = ["requests>=2.25.0"]

[project.optional-dependencies]
dev = ["pytest>=7.0.0", "mypy>=1.17.1", "ruff>=0.12.10"]
dev = [
"pytest>=7.0.0",
"pytest-mock>=3.14.1",
"mypy>=1.17.1",
"ruff>=0.12.10",
"types-requests>=2.32.4.20250809",
]

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

[tool.setuptools.packages.find]
where = ["src"]
where = ["tfe"]
Comment thread
taru-garg-2000 marked this conversation as resolved.

[tool.setuptools.package-dir]
"" = "src"
"" = "tfe"

Comment thread
taru-garg-2000 marked this conversation as resolved.
Comment thread
taru-garg-2000 marked this conversation as resolved.
# Ruff configuration
[tool.ruff]
Expand Down
28 changes: 0 additions & 28 deletions src/python_tfe/client.py

This file was deleted.

17 changes: 0 additions & 17 deletions tests/test_client.py

This file was deleted.

104 changes: 104 additions & 0 deletions tests/units/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from unittest.mock import Mock, patch

import pytest

from tfe import client, config


@pytest.fixture
def test_config():
return config.Config(address="https://app.terraform.io", token="test-token")


@pytest.fixture
def mock_response():
response = Mock()
response.headers = {
"TFP-API-Version": "2.5.0",
"X-TFE-Version": "v202308-1",
"TFP-AppName": "HCP Terraform",
}
response.raise_for_status.return_value = None
return response


class TestClient:
@patch("requests.Session.get")
def test_client_initialization(self, mock_get, test_config, mock_response):
"""Test basic client setup works."""
mock_get.return_value = mock_response

client_instance = client.Client(config=test_config)

assert client_instance.config.address == "https://app.terraform.io"
assert client_instance.config.token == "test-token"
assert client_instance.base_url == "https://app.terraform.io/api/v2/"
assert (
client_instance.registry_base_url
== "https://app.terraform.io/api/registry/"
)

@patch("requests.Session.get")
def test_url_normalization(self, mock_get, mock_response):
"""Test that paths get normalized with trailing slashes."""
mock_get.return_value = mock_response

cfg = config.Config(
address="https://example.com",
token="test",
base_path="/custom/api", # no trailing slash
registry_base_path="/registry", # no trailing slash
)

client_instance = client.Client(config=cfg)

assert client_instance.base_url == "https://example.com/custom/api/"
assert client_instance.registry_base_url == "https://example.com/registry/"

@patch("requests.Session.get")
def test_api_metadata_extraction(self, mock_get, test_config, mock_response):
"""Test that API metadata gets extracted from response headers."""
mock_get.return_value = mock_response

client_instance = client.Client(config=test_config)

assert client_instance.remote_api_version == "2.5.0"
assert client_instance.remote_tfe_version == "v202308-1"
assert client_instance.app_name == "HCP Terraform"

@patch("requests.Session.get")
def test_cloud_vs_enterprise_detection(self, mock_get, test_config):
"""Test detection between cloud and enterprise instances."""
# Test HCP Terraform (cloud)
cloud_response = Mock()
cloud_response.headers = {"TFP-AppName": "HCP Terraform"}
cloud_response.raise_for_status.return_value = None
mock_get.return_value = cloud_response

cloud_client = client.Client(config=test_config)
assert cloud_client.is_cloud() is True
assert cloud_client.is_enterprise() is False

# Test Terraform Enterprise
enterprise_response = Mock()
enterprise_response.headers = {"TFP-AppName": "Terraform Enterprise"}
enterprise_response.raise_for_status.return_value = None
mock_get.return_value = enterprise_response

enterprise_client = client.Client(config=test_config)
assert enterprise_client.is_cloud() is False
assert enterprise_client.is_enterprise() is True

@patch("requests.Session.get")
def test_fake_api_version_for_testing(self, mock_get, test_config, mock_response):
"""Test the fake API version setter for testing scenarios."""
mock_get.return_value = mock_response

client_instance = client.Client(config=test_config)

# Original version from mock
assert client_instance.remote_api_version == "2.5.0"

# Set fake version
client_instance.set_fake_remote_api_version("3.0.0")
assert client_instance.remote_api_version == "3.0.0"
97 changes: 97 additions & 0 deletions tests/units/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import pytest
import requests

from tfe import config


@pytest.fixture(autouse=True)
def reset_environment(monkeypatch):
"""Reset environment variables before each test."""
monkeypatch.delenv("TFE_ADDRESS", raising=False)
monkeypatch.delenv("TFE_TOKEN", raising=False)
monkeypatch.delenv("TFE_HOST", raising=False)
monkeypatch.setenv("TFE_TOKEN", "abc123")
yield


@pytest.fixture
def cfg():
"""Provide a fresh Config instance with clean environment."""
return config.Config()


@pytest.fixture
def test_session():
"""Provide a clean requests session without default headers."""
session = requests.Session()
session.headers["User-Agent"] = "test"
session.headers["Authorization"] = "Bearer test"
return session


class TestConfig:
def test_default_config(self, cfg):
"""Test that default configuration values are set correctly."""
assert cfg.address == config.DEFAULT_ADDRESS
assert cfg.base_path == config.DEFAULT_BASE_PATH
assert cfg.registry_base_path == config.DEFAULT_REGISTRY_PATH
assert isinstance(cfg.http_client, requests.Session)
assert "User-Agent" in cfg.http_client.headers
assert cfg.retry_log_hook is None
assert cfg.retry_server_errors is False

def test_env_address_and_token(self, monkeypatch):
"""Test that environment variables TFE_ADDRESS and TFE_TOKEN are read correctly."""
monkeypatch.setenv("TFE_ADDRESS", "https://custom.tfe")
cfg = config.Config()
assert cfg.address == "https://custom.tfe"
assert cfg.token == "abc123"

def test_env_host_fallback(self, monkeypatch):
"""Test that TFE_HOST is used as fallback when TFE_ADDRESS is not set."""
monkeypatch.setenv("TFE_HOST", "host.tfe")
cfg = config.Config()
assert cfg.address == "https://host.tfe"

def test_explicit_address_override(self):
"""Test that explicitly passed address overrides environment variables."""
cfg = config.Config(address="https://explicit.tfe")
assert cfg.address == "https://explicit.tfe"

def test_headers_update(self):
"""Test that custom headers are properly merged with default headers."""
custom_headers = {"Authorization": "Bearer testtoken", "X-Test": "yes"}
cfg = config.Config(headers=custom_headers)
assert "Authorization" in cfg.http_client.headers
assert cfg.http_client.headers["Authorization"] == "Bearer testtoken"
assert "X-Test" in cfg.http_client.headers
assert cfg.http_client.headers["X-Test"] == "yes"
assert "User-Agent" in cfg.http_client.headers

def test_retry_log_hook_and_server_errors(self):
"""Test that retry configuration is properly set."""

def dummy_hook(retries, response):
pass

cfg = config.Config(retry_log_hook=dummy_hook, retry_server_errors=True)
assert cfg.retry_log_hook == dummy_hook
assert cfg.retry_server_errors is True

def test_custom_session(self, test_session):
"""Test that User-Agent is set when session has no default User-Agent."""
cfg = config.Config(http_client=test_session)
assert "User-Agent" in cfg.http_client.headers
assert cfg.http_client.headers["User-Agent"] == "test"
assert cfg.http_client.headers["Authorization"] == "Bearer test"

def test_validate_config(self, monkeypatch):
"""Test that configuration validation works as expected."""
with pytest.raises(ValueError, match="API token is required") as _:
monkeypatch.setenv("TFE_TOKEN", "")
_ = config.Config(token="")

with pytest.raises(ValueError, match="Address must include protocol") as _:
monkeypatch.setenv("TFE_TOKEN", "test-token")
monkeypatch.setenv("TFE_ADDRESS", "test.foo.bar")
_ = config.Config()
5 changes: 3 additions & 2 deletions src/python_tfe/__init__.py → tfe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
workspaces, runs, state files, and other TFE/TFC resources.
"""

from .client import TerraformEnterpriseClient
from tfe.client import Client, TFEClientError
from tfe.config import Config

__all__ = ["TerraformEnterpriseClient"]
__all__ = ["Client", "TFEClientError", "Config"]
Loading
Loading