From f6a8f4b719bd00d98cf25602ed84d10aabb4316d Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Wed, 18 Feb 2026 14:30:30 +0530 Subject: [PATCH 1/3] iterator-pattern logic is added, examples file updated and tests are updated --- examples/run.py | 28 ++++++------ src/pytfe/models/__init__.py | 4 -- src/pytfe/models/run.py | 29 ------------ src/pytfe/resources/run.py | 69 ++++++++-------------------- tests/units/test_run.py | 87 +++++++++++++----------------------- 5 files changed, 65 insertions(+), 152 deletions(-) diff --git a/examples/run.py b/examples/run.py index d95b6e9..96010b0 100644 --- a/examples/run.py +++ b/examples/run.py @@ -67,7 +67,8 @@ def main(): ) try: - run_list = client.runs.list(args.workspace_id, options) + print("running inside run list") + run_list = list(client.runs.list(args.workspace_id, options)) except Exception as e: print(f"Error listing runs: {e}") if args.organization: @@ -75,23 +76,22 @@ def main(): else: return - if "run_list" in locals(): - print(f"Total runs: {run_list.total_count}") - print(f"Page {run_list.current_page} of {run_list.total_pages}") + if "run_list" in locals() and run_list: + print(f"Total runs fetched: {len(run_list)}") print() - for run in run_list.items: + for run in run_list: print(f"- {run.id} | status={run.status} | created={run.created_at}") print(f"message: {run.message}") print(f"has_changes: {run.has_changes} | is_destroy: {run.is_destroy}") - if not run_list.items: + if not run_list: print("No runs found.") else: # 2) Read the most recent run with details _print_header("Reading most recent run details") - latest_run = run_list.items[0] + latest_run = run_list[0] read_options = RunReadOptions( include=[ RunIncludeOpt.RUN_PLAN, @@ -188,10 +188,12 @@ def main(): status="applied,planned,errored", ) - org_runs = client.runs.list_for_organization(args.organization, org_options) - print(f"Found {len(org_runs.items)} runs across organization") + org_runs = list( + client.runs.list_for_organization(args.organization, org_options) + ) + print(f"Found {len(org_runs)} runs across organization") - for run in org_runs.items[:3]: # Show first 3 + for run in org_runs[:3]: # Show first 3 print(f"- {run.id} | status={run.status}") if run.workspace: print(f"workspace: {run.workspace.name}") @@ -204,15 +206,15 @@ def main(): _print_header("Run Actions Demo (Safe Mode)") # Get runs first if not already available - if "run_list" not in locals() or not run_list.items: + if "run_list" not in locals() or not run_list: try: options = RunListOptions(page_size=1) - run_list = client.runs.list(args.workspace_id, options) + run_list = list(client.runs.list(args.workspace_id, options)) except Exception as e: print(f"Error getting runs for actions demo: {e}") return - if not run_list.items: + if not run_list: print("No runs available for actions demo") return diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 8524e6b..7e2dc85 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -232,7 +232,6 @@ # Runs from .run import ( - OrganizationRunList, Run, RunActions, RunApplyOptions, @@ -241,7 +240,6 @@ RunDiscardOptions, RunForceCancelOptions, RunIncludeOpt, - RunList, RunListForOrganizationOptions, RunListOptions, RunOperation, @@ -552,9 +550,7 @@ "RunStatusTimestamps", "RunVariable", "RunVariableAttr", - "RunList", "RunListOptions", - "OrganizationRunList", "RunListForOrganizationOptions", "RunCreateOptions", "RunReadOptions", diff --git a/src/pytfe/models/run.py b/src/pytfe/models/run.py index 7ae158a..bfe89bc 100644 --- a/src/pytfe/models/run.py +++ b/src/pytfe/models/run.py @@ -202,19 +202,6 @@ class RunVariableAttr(BaseModel): value: str = Field(..., alias="value") -class RunList(BaseModel): - """RunList represents a list of runs.""" - - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - - items: list[Run] = Field(default_factory=list) - current_page: int | None = None - prev_page: int | None = None - next_page: int | None = None - total_pages: int | None = None - total_count: int | None = None - - class RunListOptions(BaseModel): page_number: int | None = Field(default=1, alias="page[number]") page_size: int | None = Field(default=20, alias="page[size]") @@ -229,20 +216,6 @@ class RunListOptions(BaseModel): include: list[RunIncludeOpt] | None = Field(default_factory=list, alias="include") -class OrganizationRunList(BaseModel): - """ - OrganizationRunList represents a list of runs across an organization. - It differs from the RunList in that it does not include a TotalCount of records in the pagination details - """ - - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - - items: list[Run] = Field(default_factory=list) - current_page: int | None = None - prev_page: int | None = None - next_page: int | None = None - - class RunListForOrganizationOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) @@ -319,5 +292,3 @@ class RunDiscardOptions(BaseModel): # Rebuild models to resolve forward references Run.model_rebuild() -RunList.model_rebuild() -OrganizationRunList.model_rebuild() diff --git a/src/pytfe/resources/run.py b/src/pytfe/resources/run.py index 49efdbc..4cb430c 100644 --- a/src/pytfe/resources/run.py +++ b/src/pytfe/resources/run.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterator from typing import Any from ..errors import ( @@ -10,14 +11,12 @@ TerraformVersionValidForPlanOnlyError, ) from ..models.run import ( - OrganizationRunList, Run, RunApplyOptions, RunCancelOptions, RunCreateOptions, RunDiscardOptions, RunForceCancelOptions, - RunList, RunListForOrganizationOptions, RunListOptions, RunReadOptions, @@ -27,63 +26,33 @@ class Runs(_Service): - def list(self, workspace_id: str, options: RunListOptions | None = None) -> RunList: + def list( + self, workspace_id: str, options: RunListOptions | None = None + ) -> Iterator[Run]: """List all the runs of the given workspace.""" if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() - params = ( - options.model_dump(by_alias=True, exclude_none=True) if options else None - ) - r = self.t.request( - "GET", - f"/api/v2/workspaces/{workspace_id}/runs", - params=params, - ) - jd = r.json() - items = [] - meta = jd.get("meta", {}) - pagination = meta.get("pagination", {}) - for d in jd.get("data", []): - attrs = d.get("attributes", {}) - attrs["id"] = d.get("id") - items.append(Run.model_validate(attrs)) - return RunList( - items=items, - current_page=pagination.get("current-page"), - total_pages=pagination.get("total-pages"), - prev_page=pagination.get("prev-page"), - next_page=pagination.get("next-page"), - total_count=pagination.get("total-count"), - ) + params = options.model_dump(by_alias=True) if options else {} + path = f"/api/v2/workspaces/{workspace_id}/runs" + for item in self._list(path, params=params): + attrs = item.get("attributes", {}) + attrs["id"] = item.get("id") + yield Run.model_validate(attrs) def list_for_organization( self, organization: str, options: RunListForOrganizationOptions | None = None - ) -> OrganizationRunList: + ) -> Iterator[Run]: """List all the runs of the given organization.""" if not valid_string_id(organization): raise InvalidOrgError() - params = ( - options.model_dump(by_alias=True, exclude_none=True) if options else None - ) - r = self.t.request( - "GET", - f"/api/v2/organizations/{organization}/runs", - params=params, - ) - jd = r.json() - items = [] - meta = jd.get("meta", {}) - pagination = meta.get("pagination", {}) - for d in jd.get("data", []): - attrs = d.get("attributes", {}) - attrs["id"] = d.get("id") - items.append(Run.model_validate(attrs)) - return OrganizationRunList( - items=items, - current_page=pagination.get("current-page"), - prev_page=pagination.get("prev-page"), - next_page=pagination.get("next-page"), - ) + path = f"/api/v2/organizations/{organization}/runs" + params = options.model_dump(by_alias=True, exclude_none=True) if options else {} + # meta = jd.get("meta", {}) + # pagination = meta.get("pagination", {}) + for item in self._list(path, params=params): + attrs = item.get("attributes", {}) + attrs["id"] = item.get("id") + yield Run.model_validate(attrs) def create(self, options: RunCreateOptions) -> Run: """Create a new run for the given workspace.""" diff --git a/tests/units/test_run.py b/tests/units/test_run.py index bce2d2a..45f53c6 100644 --- a/tests/units/test_run.py +++ b/tests/units/test_run.py @@ -11,7 +11,6 @@ TerraformVersionValidForPlanOnlyError, ) from pytfe.models.run import ( - OrganizationRunList, Run, RunApplyOptions, RunCancelOptions, @@ -19,7 +18,6 @@ RunDiscardOptions, RunForceCancelOptions, RunIncludeOpt, - RunList, RunListForOrganizationOptions, RunListOptions, RunReadOptions, @@ -93,28 +91,18 @@ def test_list_runs_success(self, runs_service): with patch.object(runs_service, "t") as mock_transport: mock_transport.request.return_value = mock_response - # Test with custom page_size - use a print statement to debug what's actually sent + # Test with custom page_size options = RunListOptions(page_number=1, page_size=5) - result = runs_service.list("ws-123", options) + result = list(runs_service.list("ws-123", options)) - # Check what was actually called - call_args = mock_transport.request.call_args - actual_params = call_args[1]["params"] - - # Verify the basic structure - assert call_args[0][0] == "GET" - assert call_args[0][1] == "/api/v2/workspaces/ws-123/runs" - assert actual_params["page[number]"] == 1 + # Verify request was made + assert mock_transport.request.called - # Verify result structure - assert isinstance(result, RunList) - assert len(result.items) == 2 - assert result.current_page == 1 - assert result.total_pages == 2 - assert result.total_count == 10 + # Verify result structure - now it's a list of Run objects + assert len(result) == 2 # Verify run objects - run1 = result.items[0] + run1 = result[0] assert run1.id == "run-123" assert run1.status == RunStatus.Run_Applied assert run1.source == RunSource.Run_Source_Configuration_Version @@ -122,7 +110,7 @@ def test_list_runs_success(self, runs_service): assert run1.has_changes is True assert run1.is_destroy is False - run2 = result.items[1] + run2 = result[1] assert run2.id == "run-456" assert run2.status == RunStatus.Run_Planned assert run2.source == RunSource.Run_Source_UI @@ -132,54 +120,41 @@ def test_list_runs_success(self, runs_service): def test_list_for_organization_success(self, runs_service): """Test successful list_for_organization operation.""" - mock_response_data = { - "data": [ - { - "id": "run-org-1", - "attributes": { - "status": "applied", - "source": "tfe-api", - "message": "Organization run", - "created-at": "2023-01-01T12:00:00Z", - "has-changes": True, - "is-destroy": False, - }, - } - ], - "meta": { - "pagination": { - "current-page": 1, - "prev-page": None, - "next-page": None, - } - }, - } - - mock_response = Mock() - mock_response.json.return_value = mock_response_data + mock_response_data = [ + { + "id": "run-org-1", + "attributes": { + "status": "applied", + "source": "tfe-api", + "message": "Organization run", + "created-at": "2023-01-01T12:00:00Z", + "has-changes": True, + "is-destroy": False, + }, + } + ] - with patch.object(runs_service, "t") as mock_transport: - mock_transport.request.return_value = mock_response + with patch.object(runs_service, "_list") as mock_list: + mock_list.return_value = mock_response_data options = RunListForOrganizationOptions(status="applied,planned") - result = runs_service.list_for_organization("test-org", options) + result = list(runs_service.list_for_organization("test-org", options)) - # Verify request was made correctly (account for defaults and aliases) + # Verify _list was called with correct path and params expected_params = { "page[number]": 1, "page[size]": 20, "filter[status]": "applied,planned", "include": [], } - mock_transport.request.assert_called_once_with( - "GET", "/api/v2/organizations/test-org/runs", params=expected_params + mock_list.assert_called_once_with( + "/api/v2/organizations/test-org/runs", params=expected_params ) - # Verify result structure - assert isinstance(result, OrganizationRunList) - assert len(result.items) == 1 - assert result.current_page == 1 - assert result.items[0].id == "run-org-1" + # Verify result structure - now returns list of Run objects + assert len(result) == 1 + assert result[0].id == "run-org-1" + assert result[0].status == RunStatus.Run_Applied def test_create_run_validation_errors(self, runs_service): """Test create method with validation errors.""" From 825fd8623f7124f0ad0160ad29171a1944a867db Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Mon, 23 Feb 2026 11:20:52 +0530 Subject: [PATCH 2/3] RunList models are added --- src/pytfe/models/__init__.py | 4 ++ src/pytfe/models/run.py | 29 ++++++++++++ tests/units/test_run.py | 91 +++++++++++++++++------------------- 3 files changed, 77 insertions(+), 47 deletions(-) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 7e2dc85..8524e6b 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -232,6 +232,7 @@ # Runs from .run import ( + OrganizationRunList, Run, RunActions, RunApplyOptions, @@ -240,6 +241,7 @@ RunDiscardOptions, RunForceCancelOptions, RunIncludeOpt, + RunList, RunListForOrganizationOptions, RunListOptions, RunOperation, @@ -550,7 +552,9 @@ "RunStatusTimestamps", "RunVariable", "RunVariableAttr", + "RunList", "RunListOptions", + "OrganizationRunList", "RunListForOrganizationOptions", "RunCreateOptions", "RunReadOptions", diff --git a/src/pytfe/models/run.py b/src/pytfe/models/run.py index bfe89bc..7ae158a 100644 --- a/src/pytfe/models/run.py +++ b/src/pytfe/models/run.py @@ -202,6 +202,19 @@ class RunVariableAttr(BaseModel): value: str = Field(..., alias="value") +class RunList(BaseModel): + """RunList represents a list of runs.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + items: list[Run] = Field(default_factory=list) + current_page: int | None = None + prev_page: int | None = None + next_page: int | None = None + total_pages: int | None = None + total_count: int | None = None + + class RunListOptions(BaseModel): page_number: int | None = Field(default=1, alias="page[number]") page_size: int | None = Field(default=20, alias="page[size]") @@ -216,6 +229,20 @@ class RunListOptions(BaseModel): include: list[RunIncludeOpt] | None = Field(default_factory=list, alias="include") +class OrganizationRunList(BaseModel): + """ + OrganizationRunList represents a list of runs across an organization. + It differs from the RunList in that it does not include a TotalCount of records in the pagination details + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + items: list[Run] = Field(default_factory=list) + current_page: int | None = None + prev_page: int | None = None + next_page: int | None = None + + class RunListForOrganizationOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) @@ -292,3 +319,5 @@ class RunDiscardOptions(BaseModel): # Rebuild models to resolve forward references Run.model_rebuild() +RunList.model_rebuild() +OrganizationRunList.model_rebuild() diff --git a/tests/units/test_run.py b/tests/units/test_run.py index 45f53c6..df50ce5 100644 --- a/tests/units/test_run.py +++ b/tests/units/test_run.py @@ -45,64 +45,59 @@ def runs_service(self, mock_transport): def test_list_runs_success(self, runs_service): """Test successful list operation.""" - mock_response_data = { - "data": [ - { - "id": "run-123", - "attributes": { - "status": "applied", - "source": "tfe-configuration-version", - "message": "Test run", - "created-at": "2023-01-01T12:00:00Z", - "has-changes": True, - "is-destroy": False, - "auto-apply": False, - "plan-only": False, - }, + mock_list_data = [ + { + "id": "run-123", + "attributes": { + "status": "applied", + "source": "tfe-configuration-version", + "message": "Test run", + "created-at": "2023-01-01T12:00:00Z", + "has-changes": True, + "is-destroy": False, + "auto-apply": False, + "plan-only": False, }, - { - "id": "run-456", - "attributes": { - "status": "planned", - "source": "tfe-ui", - "message": "Another test run", - "created-at": "2023-01-02T14:00:00Z", - "has-changes": False, - "is-destroy": True, - "auto-apply": True, - "plan-only": True, - }, + }, + { + "id": "run-456", + "attributes": { + "status": "planned", + "source": "tfe-ui", + "message": "Another test run", + "created-at": "2023-01-02T14:00:00Z", + "has-changes": False, + "is-destroy": True, + "auto-apply": True, + "plan-only": True, }, - ], - "meta": { - "pagination": { - "current-page": 1, - "total-pages": 2, - "prev-page": None, - "next-page": 2, - "total-count": 10, - } }, - } - - mock_response = Mock() - mock_response.json.return_value = mock_response_data + ] - with patch.object(runs_service, "t") as mock_transport: - mock_transport.request.return_value = mock_response + with patch.object(runs_service, "_list") as mock_list: + mock_list.return_value = mock_list_data - # Test with custom page_size + # Test with options options = RunListOptions(page_number=1, page_size=5) result = list(runs_service.list("ws-123", options)) - # Verify request was made - assert mock_transport.request.called - - # Verify result structure - now it's a list of Run objects + # Verify _list was called with correct path + assert mock_list.call_count == 1 + call_args = mock_list.call_args + assert call_args[0][0] == "/api/v2/workspaces/ws-123/runs" + + # Verify params structure includes pagination and options + params = call_args[1]["params"] + assert "page[number]" in params + assert "page[size]" in params + assert "include" in params + + # Verify result structure - iterator yields Run objects assert len(result) == 2 - # Verify run objects + # Verify run objects were created correctly from response data run1 = result[0] + assert isinstance(run1, Run) assert run1.id == "run-123" assert run1.status == RunStatus.Run_Applied assert run1.source == RunSource.Run_Source_Configuration_Version @@ -111,9 +106,11 @@ def test_list_runs_success(self, runs_service): assert run1.is_destroy is False run2 = result[1] + assert isinstance(run2, Run) assert run2.id == "run-456" assert run2.status == RunStatus.Run_Planned assert run2.source == RunSource.Run_Source_UI + assert run2.message == "Another test run" assert run2.has_changes is False assert run2.is_destroy is True From 2fea9e7d40a95213cebdfa7682367f7fad96b60e Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Mon, 23 Feb 2026 12:48:33 +0530 Subject: [PATCH 3/3] tests done --- tests/units/test_run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/units/test_run.py b/tests/units/test_run.py index df50ce5..c1a9bf6 100644 --- a/tests/units/test_run.py +++ b/tests/units/test_run.py @@ -85,7 +85,7 @@ def test_list_runs_success(self, runs_service): assert mock_list.call_count == 1 call_args = mock_list.call_args assert call_args[0][0] == "/api/v2/workspaces/ws-123/runs" - + # Verify params structure includes pagination and options params = call_args[1]["params"] assert "page[number]" in params