From bebb6e1a683b3cb5eade86fcf3296fd801bcbee7 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Thu, 12 Mar 2026 11:34:43 +0530 Subject: [PATCH 1/6] Added User resource and integrate with client --- src/pytfe/client.py | 2 ++ src/pytfe/models/user.py | 1 + src/pytfe/resources/user.py | 14 ++++++++++++++ 3 files changed, 17 insertions(+) create mode 100644 src/pytfe/resources/user.py diff --git a/src/pytfe/client.py b/src/pytfe/client.py index d1c8337..b48e110 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -33,6 +33,7 @@ from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions from .resources.variable import Variables +from .resources.user import Users from .resources.variable_sets import VariableSets, VariableSetVariables from .resources.workspace_resources import WorkspaceResourcesService from .resources.workspaces import Workspaces @@ -69,6 +70,7 @@ def __init__(self, config: TFEConfig | None = None): self.plans = Plans(self._transport) self.organizations = Organizations(self._transport) self.organization_memberships = OrganizationMemberships(self._transport) + self.users = Users(self._transport) self.projects = Projects(self._transport) self.variables = Variables(self._transport) self.variable_sets = VariableSets(self._transport) diff --git a/src/pytfe/models/user.py b/src/pytfe/models/user.py index 26b902e..c2cbf01 100644 --- a/src/pytfe/models/user.py +++ b/src/pytfe/models/user.py @@ -7,6 +7,7 @@ class User(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str = Field(..., alias="id") + auth_method: str = Field(default="", alias="auth-method") avatar_url: str = Field(default="", alias="avatar-url") email: str = Field(default="", alias="email") is_service_account: bool = Field(default=False, alias="is-service-account") diff --git a/src/pytfe/resources/user.py b/src/pytfe/resources/user.py new file mode 100644 index 0000000..bc83f62 --- /dev/null +++ b/src/pytfe/resources/user.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from ..models.user import User +from ._base import _Service + + +class Users(_Service): + def read(self, user_id: str) -> User: + r = self.t.request("GET", f"/api/v2/users/{user_id}") + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + user_data = dict(attr) + user_data["id"] = d.get("id") + return User(**user_data) From 9d569adc01a1bbb198f28576a36f5f1481cc8cb9 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Mon, 16 Mar 2026 12:07:07 +0530 Subject: [PATCH 2/6] Add Users.read endpoint implementation and unit tests --- src/pytfe/resources/user.py | 4 ++ tests/units/test_user.py | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 tests/units/test_user.py diff --git a/src/pytfe/resources/user.py b/src/pytfe/resources/user.py index bc83f62..0901ef6 100644 --- a/src/pytfe/resources/user.py +++ b/src/pytfe/resources/user.py @@ -1,11 +1,15 @@ from __future__ import annotations from ..models.user import User +from ..utils import valid_string_id from ._base import _Service class Users(_Service): def read(self, user_id: str) -> User: + if not valid_string_id(user_id): + raise ValueError("invalid user id") + r = self.t.request("GET", f"/api/v2/users/{user_id}") d = r.json()["data"] attr = d.get("attributes", {}) or {} diff --git a/tests/units/test_user.py b/tests/units/test_user.py new file mode 100644 index 0000000..c04032a --- /dev/null +++ b/tests/units/test_user.py @@ -0,0 +1,74 @@ +"""Unit tests for the Users resource.""" + +from unittest.mock import Mock + +import pytest + +from pytfe.models.user import User +from pytfe.resources.user import Users + + +class TestUsers: + """Test suite for user resource operations.""" + + @pytest.fixture + def mock_transport(self): + """Mock HTTP transport.""" + return Mock() + + @pytest.fixture + def users_service(self, mock_transport): + """Create users service with mocked transport.""" + return Users(mock_transport) + + @pytest.fixture + def sample_user_response(self): + """Sample JSON:API response for a user.""" + return { + "data": { + "id": "user-MA4GL63FmYRpSFxa", + "type": "users", + "attributes": { + "username": "admin", + "email": "admin@example.com", + "is-service-account": False, + "auth-method": "hcp_sso", + "avatar-url": "https://example.com/avatar.png", + "v2-only": True, + "permissions": { + "can-create-organizations": False, + "can-change-email": True, + "can-change-username": True, + }, + }, + } + } + + def test_read_user(self, users_service, mock_transport, sample_user_response): + """Test reading a specific user by ID.""" + mock_transport.request.return_value.json.return_value = sample_user_response + + user_id = "user-MA4GL63FmYRpSFxa" + user = users_service.read(user_id) + + mock_transport.request.assert_called_once_with( + "GET", f"/api/v2/users/{user_id}" + ) + assert isinstance(user, User) + assert user.id == user_id + assert user.username == "admin" + assert user.email == "admin@example.com" + assert user.is_service_account is False + assert user.auth_method == "hcp_sso" + assert user.avatar_url == "https://example.com/avatar.png" + assert user.v2_only is True + assert user.permissions == { + "can-create-organizations": False, + "can-change-email": True, + "can-change-username": True, + } + + def test_read_user_invalid_id(self, users_service): + """Test reading a user with an invalid user ID.""" + with pytest.raises(ValueError, match="invalid user id"): + users_service.read("") From 6b77c285feef8ba2668a4916109d6430153a922a Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Mon, 16 Mar 2026 12:31:40 +0530 Subject: [PATCH 3/6] Apply ruff formatting fixes --- src/pytfe/client.py | 2 +- tests/units/test_user.py | 112 +++++++++++++++++++-------------------- 2 files changed, 57 insertions(+), 57 deletions(-) diff --git a/src/pytfe/client.py b/src/pytfe/client.py index b48e110..df04eaf 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -32,8 +32,8 @@ from .resources.ssh_keys import SSHKeys from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions -from .resources.variable import Variables from .resources.user import Users +from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables from .resources.workspace_resources import WorkspaceResourcesService from .resources.workspaces import Workspaces diff --git a/tests/units/test_user.py b/tests/units/test_user.py index c04032a..6df2aa4 100644 --- a/tests/units/test_user.py +++ b/tests/units/test_user.py @@ -9,66 +9,66 @@ class TestUsers: - """Test suite for user resource operations.""" + """Test suite for user resource operations.""" - @pytest.fixture - def mock_transport(self): - """Mock HTTP transport.""" - return Mock() + @pytest.fixture + def mock_transport(self): + """Mock HTTP transport.""" + return Mock() - @pytest.fixture - def users_service(self, mock_transport): - """Create users service with mocked transport.""" - return Users(mock_transport) + @pytest.fixture + def users_service(self, mock_transport): + """Create users service with mocked transport.""" + return Users(mock_transport) - @pytest.fixture - def sample_user_response(self): - """Sample JSON:API response for a user.""" - return { - "data": { - "id": "user-MA4GL63FmYRpSFxa", - "type": "users", - "attributes": { - "username": "admin", - "email": "admin@example.com", - "is-service-account": False, - "auth-method": "hcp_sso", - "avatar-url": "https://example.com/avatar.png", - "v2-only": True, - "permissions": { - "can-create-organizations": False, - "can-change-email": True, - "can-change-username": True, - }, - }, - } - } + @pytest.fixture + def sample_user_response(self): + """Sample JSON:API response for a user.""" + return { + "data": { + "id": "user-MA4GL63FmYRpSFxa", + "type": "users", + "attributes": { + "username": "admin", + "email": "admin@example.com", + "is-service-account": False, + "auth-method": "hcp_sso", + "avatar-url": "https://example.com/avatar.png", + "v2-only": True, + "permissions": { + "can-create-organizations": False, + "can-change-email": True, + "can-change-username": True, + }, + }, + } + } - def test_read_user(self, users_service, mock_transport, sample_user_response): - """Test reading a specific user by ID.""" - mock_transport.request.return_value.json.return_value = sample_user_response + def test_read_user(self, users_service, mock_transport, sample_user_response): + """Test reading a specific user by ID.""" + mock_transport.request.return_value.json.return_value = sample_user_response - user_id = "user-MA4GL63FmYRpSFxa" - user = users_service.read(user_id) + user_id = "user-MA4GL63FmYRpSFxa" + user = users_service.read(user_id) - mock_transport.request.assert_called_once_with( - "GET", f"/api/v2/users/{user_id}" - ) - assert isinstance(user, User) - assert user.id == user_id - assert user.username == "admin" - assert user.email == "admin@example.com" - assert user.is_service_account is False - assert user.auth_method == "hcp_sso" - assert user.avatar_url == "https://example.com/avatar.png" - assert user.v2_only is True - assert user.permissions == { - "can-create-organizations": False, - "can-change-email": True, - "can-change-username": True, - } + mock_transport.request.assert_called_once_with( + "GET", f"/api/v2/users/{user_id}" + ) + assert isinstance(user, User) + assert user.id == user_id + assert user.username == "admin" + assert user.email == "admin@example.com" + assert user.is_service_account is False + assert user.auth_method == "hcp_sso" + assert user.avatar_url == "https://example.com/avatar.png" + assert user.v2_only is True + assert user.permissions == { + "can-create-organizations": False, + "can-change-email": True, + "can-change-username": True, + } - def test_read_user_invalid_id(self, users_service): - """Test reading a user with an invalid user ID.""" - with pytest.raises(ValueError, match="invalid user id"): - users_service.read("") + def test_read_user_invalid_id(self, users_service): + """Test reading a user with an invalid user ID.""" + with pytest.raises(ValueError, match="invalid user id"): + users_service.read("") From 0602fd7effe305570d4168641472e7820d7f7129 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Wed, 18 Mar 2026 15:51:06 +0530 Subject: [PATCH 4/6] Add example demonstrating Users.read API usage --- examples/user.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 examples/user.py diff --git a/examples/user.py b/examples/user.py new file mode 100644 index 0000000..fce7229 --- /dev/null +++ b/examples/user.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Example usage of the Users API. + +This example demonstrates how to read a user by ID using the Python TFE SDK. +""" + +import os +import sys + +# Add the src directory to the Python path so we can import the local package. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from pytfe import TFEClient, TFEConfig + + +def main() -> None: + """Read and print user details from Terraform Cloud.""" + user_id = os.getenv("TFE_USER_ID") + if not user_id: + print("TFE_USER_ID is not set. Please export TFE_USER_ID and retry.") + return + + try: + client = TFEClient(TFEConfig.from_env()) + user = client.users.read(user_id) + + print("=== Terraform Cloud User ===") + print(f"User ID: {user.id}") + print(f"Username: {user.username}") + print(f"Email: {user.email}") + print(f"Auth Method: {user.auth_method}") + except Exception as e: + print(f"Error reading user '{user_id}': {e}") + + +if __name__ == "__main__": + main() From 14a42bd127b1f8478b2f776112de8c27572c55fa Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Mon, 23 Mar 2026 12:48:33 +0530 Subject: [PATCH 5/6] Add current user support and update Users example --- examples/user.py | 19 +++++++++++----- src/pytfe/models/user.py | 7 ++++++ src/pytfe/resources/user.py | 24 +++++++++++++++++++- tests/units/test_user.py | 44 ++++++++++++++++++++++++++++++++++++- 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/examples/user.py b/examples/user.py index fce7229..f187e55 100644 --- a/examples/user.py +++ b/examples/user.py @@ -16,21 +16,30 @@ def main() -> None: """Read and print user details from Terraform Cloud.""" user_id = os.getenv("TFE_USER_ID") - if not user_id: - print("TFE_USER_ID is not set. Please export TFE_USER_ID and retry.") - return try: client = TFEClient(TFEConfig.from_env()) + + current_user = client.users.read_current() + print("=== Current Terraform Cloud User ===") + print(f"User ID: {current_user.id}") + print(f"Username: {current_user.username}") + print(f"Email: {current_user.email}") + print(f"Auth Method: {current_user.auth_method}") + + if not user_id: + print("\nTFE_USER_ID not set. Skipping client.users.read(user_id).") + return + user = client.users.read(user_id) - print("=== Terraform Cloud User ===") + print("\n=== Terraform Cloud User By ID ===") print(f"User ID: {user.id}") print(f"Username: {user.username}") print(f"Email: {user.email}") print(f"Auth Method: {user.auth_method}") except Exception as e: - print(f"Error reading user '{user_id}': {e}") + print(f"Error running user example: {e}") if __name__ == "__main__": diff --git a/src/pytfe/models/user.py b/src/pytfe/models/user.py index c2cbf01..851f2f9 100644 --- a/src/pytfe/models/user.py +++ b/src/pytfe/models/user.py @@ -22,3 +22,10 @@ class User(BaseModel): # Relations # authentication_tokens: AuthenticationTokens = Field(..., alias="authentication-tokens") + + +class UserUpdateCurrentOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + username: str | None = Field(default=None, alias="username") + email: str | None = Field(default=None, alias="email") diff --git a/src/pytfe/resources/user.py b/src/pytfe/resources/user.py index 0901ef6..ab5a7e3 100644 --- a/src/pytfe/resources/user.py +++ b/src/pytfe/resources/user.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ..models.user import User +from ..models.user import User, UserUpdateCurrentOptions from ..utils import valid_string_id from ._base import _Service @@ -16,3 +16,25 @@ def read(self, user_id: str) -> User: user_data = dict(attr) user_data["id"] = d.get("id") return User(**user_data) + + def read_current(self) -> User: + r = self.t.request("GET", "/api/v2/account/details") + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + user_data = dict(attr) + user_data["id"] = d.get("id") + return User(**user_data) + + def update_current(self, options: UserUpdateCurrentOptions) -> User: + body = { + "data": { + "type": "users", + "attributes": options.model_dump(exclude_none=True), + } + } + r = self.t.request("PATCH", "/api/v2/account/update", json_body=body) + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + user_data = dict(attr) + user_data["id"] = d.get("id") + return User(**user_data) diff --git a/tests/units/test_user.py b/tests/units/test_user.py index 6df2aa4..4cea780 100644 --- a/tests/units/test_user.py +++ b/tests/units/test_user.py @@ -4,7 +4,7 @@ import pytest -from pytfe.models.user import User +from pytfe.models.user import User, UserUpdateCurrentOptions from pytfe.resources.user import Users @@ -72,3 +72,45 @@ def test_read_user_invalid_id(self, users_service): """Test reading a user with an invalid user ID.""" with pytest.raises(ValueError, match="invalid user id"): users_service.read("") + + def test_read_current_user( + self, users_service, mock_transport, sample_user_response + ): + """Test reading the currently authenticated user.""" + mock_transport.request.return_value.json.return_value = sample_user_response + + user = users_service.read_current() + + mock_transport.request.assert_called_once_with("GET", "/api/v2/account/details") + assert isinstance(user, User) + assert user.id == "user-MA4GL63FmYRpSFxa" + assert user.username == "admin" + assert user.email == "admin@example.com" + + def test_update_current_user( + self, users_service, mock_transport, sample_user_response + ): + """Test updating the currently authenticated user.""" + mock_transport.request.return_value.json.return_value = sample_user_response + options = UserUpdateCurrentOptions( + username="new-admin", + email="new-admin@example.com", + ) + + user = users_service.update_current(options) + + mock_transport.request.assert_called_once_with( + "PATCH", + "/api/v2/account/update", + json_body={ + "data": { + "type": "users", + "attributes": { + "username": "new-admin", + "email": "new-admin@example.com", + }, + } + }, + ) + assert isinstance(user, User) + assert user.id == "user-MA4GL63FmYRpSFxa" From e7ed5b048097b8ef7e6552f426fe9db03f6438f2 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Mon, 23 Mar 2026 15:55:16 +0530 Subject: [PATCH 6/6] Add tests for TwoFactor parsing and nullable booleans and update example/model --- examples/user.py | 8 ++--- src/pytfe/models/user.py | 40 +++++++++++++++++++------ tests/units/test_user.py | 63 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 92 insertions(+), 19 deletions(-) diff --git a/examples/user.py b/examples/user.py index f187e55..4052167 100644 --- a/examples/user.py +++ b/examples/user.py @@ -24,8 +24,8 @@ def main() -> None: print("=== Current Terraform Cloud User ===") print(f"User ID: {current_user.id}") print(f"Username: {current_user.username}") - print(f"Email: {current_user.email}") - print(f"Auth Method: {current_user.auth_method}") + print(f"Email: {current_user.email or 'N/A'}") + print(f"Auth Method: {current_user.auth_method or 'N/A'}") if not user_id: print("\nTFE_USER_ID not set. Skipping client.users.read(user_id).") @@ -36,8 +36,8 @@ def main() -> None: print("\n=== Terraform Cloud User By ID ===") print(f"User ID: {user.id}") print(f"Username: {user.username}") - print(f"Email: {user.email}") - print(f"Auth Method: {user.auth_method}") + print(f"Email: {user.email or 'N/A'}") + print(f"Auth Method: {user.auth_method or 'N/A'}") except Exception as e: print(f"Error running user example: {e}") diff --git a/src/pytfe/models/user.py b/src/pytfe/models/user.py index 851f2f9..bf8a019 100644 --- a/src/pytfe/models/user.py +++ b/src/pytfe/models/user.py @@ -3,22 +3,44 @@ from pydantic import BaseModel, ConfigDict, Field +class TwoFactor(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + enabled: bool = Field(default=False, alias="enabled") + verified: bool = Field(default=False, alias="verified") + + +class UserPermissions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + can_create_organizations: bool = Field( + default=False, alias="can-create-organizations" + ) + can_change_email: bool = Field(default=False, alias="can-change-email") + can_change_username: bool = Field(default=False, alias="can-change-username") + can_manage_user_tokens: bool = Field(default=False, alias="can-manage-user-tokens") + can_view_2fa_settings: bool = Field(default=False, alias="can-view2fa-settings") + can_manage_hcp_account: bool = Field(default=False, alias="can-manage-hcp-account") + + class User(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str = Field(..., alias="id") - auth_method: str = Field(default="", alias="auth-method") - avatar_url: str = Field(default="", alias="avatar-url") - email: str = Field(default="", alias="email") + auth_method: str | None = Field(default=None, alias="auth-method") + avatar_url: str | None = Field(default=None, alias="avatar-url") + email: str | None = Field(default=None, alias="email") is_service_account: bool = Field(default=False, alias="is-service-account") - two_factor: dict = Field(default_factory=dict, alias="two-factor") - unconfirmed_email: str = Field(default="", alias="unconfirmed-email") + two_factor: TwoFactor | None = Field(default=None, alias="two-factor") + unconfirmed_email: str | None = Field(default=None, alias="unconfirmed-email") username: str = Field(default="", alias="username") v2_only: bool = Field(default=False, alias="v2-only") - is_site_admin: bool = Field(default=False, alias="is-site-admin") # Deprecated - is_admin: bool = Field(default=False, alias="is-admin") - is_sso_login: bool = Field(default=False, alias="is-sso-login") - permissions: dict = Field(default_factory=dict, alias="permissions") + is_site_admin: bool | None = Field( + default=None, alias="is-site-admin" + ) # Deprecated + is_admin: bool | None = Field(default=None, alias="is-admin") + is_sso_login: bool | None = Field(default=None, alias="is-sso-login") + permissions: UserPermissions | None = Field(default=None, alias="permissions") # Relations # authentication_tokens: AuthenticationTokens = Field(..., alias="authentication-tokens") diff --git a/tests/units/test_user.py b/tests/units/test_user.py index 4cea780..2be9565 100644 --- a/tests/units/test_user.py +++ b/tests/units/test_user.py @@ -1,10 +1,11 @@ """Unit tests for the Users resource.""" +import copy from unittest.mock import Mock import pytest -from pytfe.models.user import User, UserUpdateCurrentOptions +from pytfe.models.user import User, UserPermissions, UserUpdateCurrentOptions from pytfe.resources.user import Users @@ -62,17 +63,67 @@ def test_read_user(self, users_service, mock_transport, sample_user_response): assert user.auth_method == "hcp_sso" assert user.avatar_url == "https://example.com/avatar.png" assert user.v2_only is True - assert user.permissions == { - "can-create-organizations": False, - "can-change-email": True, - "can-change-username": True, - } + assert isinstance(user.permissions, UserPermissions) + assert user.permissions is not None + assert user.permissions.can_create_organizations is False + assert user.permissions.can_change_email is True + assert user.permissions.can_change_username is True + assert user.permissions.can_manage_user_tokens is False + assert user.permissions.can_view_2fa_settings is False + assert user.permissions.can_manage_hcp_account is False def test_read_user_invalid_id(self, users_service): """Test reading a user with an invalid user ID.""" with pytest.raises(ValueError, match="invalid user id"): users_service.read("") + def test_read_user_with_null_unconfirmed_email( + self, users_service, mock_transport, sample_user_response + ): + """Test reading a user when unconfirmed-email is null.""" + sample_user_response["data"]["attributes"]["unconfirmed-email"] = None + mock_transport.request.return_value.json.return_value = sample_user_response + + user = users_service.read("user-MA4GL63FmYRpSFxa") + + assert isinstance(user, User) + assert user.unconfirmed_email is None + + def test_read_user_two_factor_parsing( + self, users_service, mock_transport, sample_user_response + ): + """Test reading a user with two-factor data.""" + modified_response = copy.deepcopy(sample_user_response) + modified_response["data"]["attributes"]["two-factor"] = { + "enabled": True, + "verified": False, + } + mock_transport.request.return_value.json.return_value = modified_response + + user_id = "user-MA4GL63FmYRpSFxa" + user = users_service.read(user_id) + + assert user.two_factor is not None + assert user.two_factor.enabled is True + assert user.two_factor.verified is False + + def test_read_user_nullable_bools( + self, users_service, mock_transport, sample_user_response + ): + """Test reading a user when pointer-style boolean fields are null.""" + modified_response = copy.deepcopy(sample_user_response) + modified_response["data"]["attributes"]["is-site-admin"] = None + modified_response["data"]["attributes"]["is-admin"] = None + modified_response["data"]["attributes"]["is-sso-login"] = None + mock_transport.request.return_value.json.return_value = modified_response + + user_id = "user-MA4GL63FmYRpSFxa" + user = users_service.read(user_id) + + assert user.is_site_admin is None + assert user.is_admin is None + assert user.is_sso_login is None + def test_read_current_user( self, users_service, mock_transport, sample_user_response ):