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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ src/pytfe/
config.py # TFEConfig — auth, timeout, retry, proxy settings
_http.py # HTTPTransport — request, retry, redirects, auth
_jsonapi.py # JSON:API envelope helpers
_logging.py. # Logging primitives for the pytfe SDK
errors.py # Typed exception hierarchy (TFEError + ~80 subclasses)
utils.py # Validation + small helpers
models/ # Pydantic v2 models, one file per resource
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
## Bug Fixes
* Fixed task result relationships to map into typed SDK models instead of raw JSON by @TanyaSingh369-svg [#156](https://github.com/hashicorp/python-tfe/pull/156)
* Fixed task stage relationship mapping in the task result resource by @TanyaSingh369-svg [#156](https://github.com/hashicorp/python-tfe/pull/156)
* Updated variable set models to support ``global_`` inputs. Since ``global`` is a Python reserved word, callers previously had to use ``model_validate`` as a workaround; existing ``global`` alias usage continues to work unchanged.


# v0.1.5
Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,19 @@ For full details — environment variables, redaction guarantees, and how to add

## Documentation

- API reference and guides (SDK): **coming soon**
- Terraform Enterprise API: https://developer.hashicorp.com/terraform/enterprise/api-docs
- Internal reference: [`docs/ITERATORS.md`](./docs/ITERATORS.md), [`docs/MODELS.md`](./docs/MODELS.md), [`docs/RESOURCE.md`](./docs/RESOURCE.md), [`docs/LOGGING.md`](./docs/LOGGING.md)
Start with [Getting started](./docs/getting-started.md), then use the
[API index](./docs/api/index.md) to find resource-specific guides, examples,
and upstream HCP Terraform API docs.

| Need | Start here |
|---|---|
| Configure the SDK | [Authentication](./docs/authentication.md), [Pagination](./docs/pagination.md), [Logging](./docs/LOGGING.md) |
| API guides | [API index](./docs/api/index.md), [Workspaces](./docs/api/workspaces.md), [Runs/plans/applies](./docs/api/runs-plans-applies.md), [State versions](./docs/api/state-versions.md) |
| Scenario guides | [API-driven run](./docs/scenarios/api-driven-run.md), [State management](./docs/scenarios/state-management.md), [Migrate workspaces and state](./docs/scenarios/migrate-workspaces-and-state.md), [Team access onboarding](./docs/scenarios/team-access-onboarding.md) |
| Operations guides | [Troubleshooting](./docs/troubleshooting.md), [Errors](./docs/errors.md), [Terraform Enterprise](./docs/terraform-enterprise.md) |
| Contribute to the SDK | [CONTRIBUTING](./docs/CONTRIBUTING.md), [ITERATORS](./docs/ITERATORS.md), [MODELS](./docs/MODELS.md), [RESOURCE](./docs/RESOURCE.md) |

Upstream API reference: https://developer.hashicorp.com/terraform/cloud-docs/api-docs

## Examples

Expand Down
18 changes: 12 additions & 6 deletions docs/ITERATORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,21 @@ with pytest.raises(InvalidOrgError):
If you genuinely need eager validation (raised from the call expression itself, not the first `for` loop), use the wrapper pattern:

```python
def list(self, organization: str, ...) -> Iterator[Workspace]:
def list(
self,
organization: str,
options: WorkspaceListOptions | None = None,
) -> Iterator[Workspace]:
if not valid_string_id(organization):
raise InvalidOrgError() # eager

params = ...
path = ...
params = options.model_dump(by_alias=True, exclude_none=True, mode="json") if options else {}
path = f"/api/v2/organizations/{organization}/workspaces"

def _gen() -> Iterator[Workspace]:
for item in self._list(path, params=params):
yield self._workspace_from(item)

return _gen()
```

Expand Down Expand Up @@ -131,15 +137,15 @@ Do **not** reach for `iter(list)` just because the endpoint is non-paginated. Us

```python
# ❌ Returns Iterable instead of Iterator — looks similar, isn't.
def list(...) -> Iterable[Workspace]: ...
def list(self) -> Iterable[Workspace]: ...

# ❌ Returns Pager / LazyList / custom wrapper.
def list(...) -> WorkspaceList: ...
def list(self) -> WorkspaceList: ...

# ❌ Returns concrete list. The type is a public contract; consumers will
# rely on len(), indexing, and isinstance(result, list). See "Known
# exceptions" below for the one method where this is documented.
def list_widgets(...) -> list[Widget]: ...
def list_widgets(self) -> list[Widget]: ...
```

## Known exceptions (and why)
Expand Down
54 changes: 22 additions & 32 deletions docs/RESOURCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ from ._base import _Service
class Widgets(_Service):
"""Service for managing widgets."""

def list(...) -> Iterator[Widget]: ...
def read(...) -> Widget: ...
def create(...) -> Widget: ...
def update(...) -> Widget: ...
def delete(...) -> None: ...
def list(self) -> Iterator[Widget]: ...
def read(self, widget_id: str) -> Widget: ...
def create(self, organization: str, options: WidgetCreateOptions) -> Widget: ...
def update(self, widget_id: str, options: WidgetUpdateOptions) -> Widget: ...
def delete(self, widget_id: str) -> None: ...

def _widget_from(self, data: dict[str, Any]) -> Widget: ...
```
Expand Down Expand Up @@ -188,32 +188,23 @@ If the model has relationships, pull them from `data["relationships"]` and eithe

1. **Embed an id-stub** using `Model.model_construct(id=...)` — use this when the model defines the relation as `OtherModel | None`. `model_construct` skips validation, which is correct for partial `{id, type}` data:

```python
relationships = data.get("relationships", {})
run_data = relationships.get("run", {}).get("data")
if run_data:
attributes["run"] = Run.model_construct(id=run_data["id"])
```
```python
relationships = data.get("relationships", {})
run_data = relationships.get("run", {}).get("data")
if run_data:
attributes["run"] = Run.model_construct(id=run_data["id"])
```

2. **Flatten to `*_id`** when the model exposes a flat `team_id: str | None` field:

```python
team_data = (relationships.get("team") or {}).get("data") or {}
if team_data.get("id"):
attributes["team-id"] = team_data["id"]
```
```python
team_data = (relationships.get("team") or {}).get("data") or {}
if team_data.get("id"):
attributes["team-id"] = team_data["id"]
```

Always defensively coalesce with `or {}` — relationships may be missing from sparse responses.

## Presigned URLs and redirects

The TFE bearer token must not be forwarded to Archivist, S3, or other presigned blob hosts. Signed upload/download URLs already carry their own credentials.

- Direct signed URL: `self.t.request("GET", url, include_auth=False)`
- API endpoint that returns a redirect: call the API path with `allow_redirects=False`, read the `Location` header, then fetch that URL with `include_auth=False`
- Add a unit test that asserts the blob URL call uses `include_auth=False`

This applies to state upload/download, plan JSON output/schema, apply errored state, and any future blob-backed endpoint.

## Pagination — use `self._list`, don't roll your own

Expand Down Expand Up @@ -249,12 +240,12 @@ You usually don't need to catch these — let them propagate to the caller. Catc
- You want to translate to a more specific error (`except TFEError as e: if "rate-limit" in str(e): raise ...`)
- The "error" is actually an expected outcome — like a `NotFound` meaning "no current assessment yet":

```python
try:
r = self.t.request("GET", f"/api/v2/workspaces/{ws_id}/current-assessment-result")
except NotFound:
return None
```
```python
try:
r = self.t.request("GET", f"/api/v2/workspaces/{ws_id}/current-assessment-result")
except NotFound:
return None
```

## Wiring into the client

Expand Down Expand Up @@ -460,7 +451,6 @@ raise InvalidWidgetIDError() # preferred for new APIs
- [ ] `list*` returns `Iterator[X]` via `self._list(...)` (see [ITERATORS.md](ITERATORS.md))
- [ ] Response parsing helper `_widget_from(data)` translates JSON:API → Pydantic
- [ ] Non-standard response shapes (`204`, `null`, bare resources, raw bytes, redirects) are verified against docs/go-tfe/spec and covered by tests
- [ ] Presigned upload/download/blob URLs are fetched with `include_auth=False`
- [ ] Classes with `def list(...)` avoid later bare `list[...]` annotations
- [ ] Models added per [MODELS.md](MODELS.md), wired in `models/__init__.py`
- [ ] Resource wired into `client.py` (import + `self.widgets = Widgets(...)`)
Expand Down
19 changes: 12 additions & 7 deletions docs/TESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ def test_create_workspace(self, client):
client._transport.request = MagicMock(return_value=mock_response)

# Execute the operation
options = WorkspaceCreateOptions(name="new-workspace", organization="test-org")
workspace = client.workspaces.create(options)
options = WorkspaceCreateOptions(name="new-workspace")
workspace = client.workspaces.create("test-org", options)

# Assertions
assert workspace.id == "ws-new"
Expand All @@ -156,15 +156,20 @@ Always test validation and error handling:

```python
def test_create_workspace_invalid_org(self, client):
"""Test creating workspace with invalid organization."""
"""Test creating workspace with an empty organization name."""
options = WorkspaceCreateOptions(name="test")
with pytest.raises(InvalidOrgError):
options = WorkspaceCreateOptions(name="test", organization="")
client.workspaces.create(options)
client.workspaces.create("", options)

def test_read_workspace_invalid_id(self, client):
"""Test reading workspace with invalid ID."""
"""Test read_by_id with an empty workspace ID."""
with pytest.raises(InvalidWorkspaceIDError):
client.workspaces.read(workspace_id="")
client.workspaces.read_by_id("")

def test_read_workspace_invalid_name(self, client):
"""Test read with an empty workspace name."""
with pytest.raises(InvalidWorkspaceValueError):
client.workspaces.read("", organization="valid-org")
```

### 4. Test Pagination
Expand Down
Loading
Loading