If you are an AI coding agent (Claude Code, Codex, Cursor, GitHub Copilot Workspace, etc.) about to make changes to this repository, read this file first. It will save you from generating code that diverges from the codebase's conventions.
If you are a human contributor, the same conventions apply to you — but the more comprehensive docs/CONTRIBUTING.md and the deep-dive references linked below are written for you specifically.
pytfe is the official Python SDK for the HCP Terraform and Terraform Enterprise V2 API. It wraps roughly 50 resource services (workspaces, runs, policies, teams, agents, …) and is consumed by downstream projects. Source layout:
src/pytfe/
client.py # TFEClient — composition root, wires every resource
config.py # TFEConfig — auth, timeout, retry, proxy settings
_http.py # HTTPTransport — request, retry, redirects, auth
_jsonapi.py # JSON:API helpers: headers, error payloads, and the
# shared relationship/included parser (parse_relationships)
_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
resources/ # Service classes, one file per resource
tests/units/ # Pytest unit tests with mocked transport, one file per resource
examples/ # Runnable CLI demos, one file per resource (or extended)
docs/ # Internal reference (see below)
These three documents define the patterns this codebase already uses. Generating code without consulting them will produce inconsistent output:
| Topic | Doc |
|---|---|
list_* methods, pagination, iterator vs list, the _list helper |
docs/ITERATORS.md |
Pydantic model conventions: ConfigDict, aliases, validators, relationships, exporting |
docs/MODELS.md |
| Resource service patterns: method shape, JSON:API envelopes, client wiring, examples | docs/RESOURCE.md |
| Logging: namespace, redaction, env-var setup, debug round-trip traces | docs/LOGGING.md |
Each doc ends with a checklist. Use those checklists; they encode the rules a reviewer will look for.
Before adding a new resource, endpoint, enum, or non-obvious response parser, verify the wire contract against primary sources:
- Official HCP Terraform API docs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs
- go-tfe implementation: https://github.com/hashicorp/go-tfe
- OpenAPI specs or live probes when the public docs/go-tfe are missing, beta, or ambiguous
Use the official docs and go-tfe as the first sources of truth. OpenAPI and live probes are supporting evidence, especially for endpoints that are newly released or not fully documented yet. When behavior is surprising, note the source you checked in the PR description, test name, example header, or a short code comment.
A handful of conventions are pervasive enough that you'll regret breaking them. In rough order of "how loudly it breaks at review time":
-
list_*methods returnIterator[X]. Notlist[X], notIterable[X], not a customPager. Usefor item in self._list(path): yield ...inside the method body. (ITERATORS.md) -
JSON:API attribute names go through
Field(alias="..."). The API sendscreated-at, Python usescreated_at. Pair withmodel_config = ConfigDict(populate_by_name=True, validate_by_name=True)on the model. (MODELS.md)
2b. Top-level response/resource models inherit TFEModel, not BaseModel. It's config-light (you keep your own model_config) and adds the lossless .relationships/.included/.related()/.has_* accessors. Wire the resource's parser to call attach_jsonapi(model, data, included) so they're populated. Options/sub-object/enum models stay on BaseModel. (MODELS.md — TFEModel vs BaseModel)
-
model_dump(by_alias=True, exclude_none=True)for write payloads. Withoutby_alias=Trueyou'll send snake_case to the API and it will silently drop the fields. Addmode="json"if the options contain enums. -
For new public APIs, prefer typed
TFEErrorsubclasses. The error hierarchy inerrors.pyis part of the public API, and downstream consumers oftenexcept TFEError:once. Existing methods still expose manyValueErrorpaths; do not change those established exceptions unless the breaking-change impact is explicitly accepted. -
Validate IDs at the top of every method. Use
valid_string_idfromutils.py. New methods should prefer typedInvalid<Thing>IDErrorerrors; existing resources may already useValueErrorand should keep that public behavior unless a breaking change is intentional. -
Wire every new resource into
client.py. A resource not added toTFEClient.__init__is unreachable. Same for new models inmodels/__init__.py. -
Use the standard verb names:
list,read,create,update,delete. Plusadd_*/remove_*for relationship modifications. Argument order is always identifiers first, options last.
These are mistakes a competent Python developer would make if they hadn't read the conventions. Avoid them:
- Don't catch
httpxerrors directly. The transport already translates them intoTFEErrorsubclasses. Catchinghttpx.HTTPErrorin a resource means the typed error never propagates. - Always send the bearer token, even to absolute URLs returned by the API. Endpoints like
hosted_state_download_url,hosted_state_upload_url, planjson-output, and applyerrored-stateredirect toarchivist.terraform.io— which is HashiCorp infrastructure that requires the bearer. go-tfe does the same (seestate_version.go::Download+tfe.go::NewRequest). Stripping the bearer breaks downstream consumers (notably the Ansible collection's statefile + dynamic-inventory flows).HTTPTransport.requestacceptsinclude_auth=Falseonly as an opt-out for the hypothetical case of calling a genuinely non-HashiCorp host; do not use it for Archivist URLs. - Don't write a custom page loop.
self._list(path, params=...)handles pagination + non-paginated endpoints transparently. Rolling your own loop will diverge from the rest of the codebase. - Disable pagination for endpoints that ignore page params. A few endpoints return the whole collection on every request and ignore
page[number]/page[size](workspace/varsand/all-vars). Callself._list(path, params=..., paginated=False)for those — otherwise they infinite-loop once the collection reaches the page size (the #181 bug). See ITERATORS.md. - Don't reuse generators. Iterators returned by
list_*are single-use. If you need to traverse twice,materialized = list(client.foo.list_bars(...))first. - Don't add features beyond what was asked. This codebase is approaching v1.0.0. Adding "while I'm here" refactors or speculative abstractions slows reviews and risks breaking the Ansible collection.
- Don't assume every successful response is
{"data": ...}. Check the docs/go-tfe/spec for each endpoint: some return a JSON:API envelope, some return a bare resource object,204 No Content,null, raw bytes, or a redirect to a blob URL. Add tests for non-standard shapes. - Don't hand-roll relationship parsing, and don't let response models drop unknown fields. Response models (the ones you parse from API payloads) set
extra="allow"so new server fields survive inmodel_extrainstead of being silently dropped. Parse therelationshipsblock with the sharedparse_relationshipshelper frompytfe._jsonapi(a declarative{wire_relation: Model}map + optionalincludedhydration), not a bespoke if-ladder.resources/workspaces.pyandresources/run.pyare the reference parsers; details in MODELS.md and RESOURCE.md. - Don't use bare
list[...]annotations inside a resource class after definingdef list(...). In class scope, mypy can resolvelistto the method instead of the builtin. Usebuiltins.list[...],Sequence[...], or another unshadowed type. - Don't
print()or use ad-hoclogging.getLogger(__name__)calls in library code. The SDK has a structured logging framework — usepytfe._logging.transport_loggerfor HTTP traffic, orpytfe._logging.logger(thepytferoot) for higher-level events. Everything from that namespace is silent by default (NullHandler) and respects the user'ssetup_logging()or stdlib configuration. See LOGGING.md for redaction rules — bearer tokens andtoken/secret/passwordkeys are auto-redacted byRoundTrip, but only inside that formatter. Neverlog.info(token)directly.
| Consumer | What they depend on |
|---|---|
hashicorp/terraform-ansible-collection |
The pytfe public API — resource methods, model fields, exception classes. Any signature change here is a breaking change. In particular, client.projects.list_tag_bindings is consumed with an isinstance(response, list) check; it intentionally still returns list[TagBinding] (see ITERATORS.md — Known exceptions). |
| Downstream user code generally | Method signatures, return types, model fields, and exception types. New errors should subclass an existing parent so except TFEError: continues to work, but existing ValueError behavior should not be changed casually. |
When in doubt about whether a change is breaking: check gh search code '<symbol>' --owner hashicorp to see if the Ansible repo uses it.
This is the workflow that produces low-friction reviews. Follow it.
- Understand the scope first. If the task is "add resource X", read
docs/RESOURCE.mdend-to-end. If it's "fix bug in Y", readY's current implementation and tests before touching anything. - Check official API docs and go-tfe for the canonical API shape.
pytfemirrors the HCP Terraform API and often follows go-tfe's surface. URL paths, method names, payload shapes, response shapes, enum values, and redirect behavior should be verified against https://developer.hashicorp.com/terraform/cloud-docs/api-docs and https://github.com/hashicorp/go-tfe before designing anything. - Add models first (
src/pytfe/models/<resource>.py), then the resource (src/pytfe/resources/<resource>.py), then wire both into the respective__init__.py/client.py. - Write tests. Mock
HTTPTransport. One test per method, plus an invalid-id case for every public method. Seetests/units/test_comment.pyas a small reference. - Run
make testandmake lint. Both must pass.pytest tests/units/runs the suite directly; it should be < 2 seconds. - Add or extend an example. Real engineers will copy-paste it; make it work end-to-end. Use env vars (
TFE_TOKEN,TFE_ORG) for auth, never hard-code credentials. - If the change is non-trivial, verify live. The repo doesn't run integration tests in CI, so the only way to catch a wrong URL or a typo in an attribute alias is to run the example against a real organization.
- Never put a token, password, or other credential in any file. Use environment variables. The user will rotate them after; you don't need to know them.
- Never use
git push --forceorgit reset --hardwithout explicit instruction. Same for--no-verify, force-push tomain, or rebasing public commits. - Never commit
.env,credentials.json,*.tfstate, or anything with secrets. Match against the existing.gitignoreif unsure. - Never bypass pre-commit hooks. If a hook fails, fix the underlying issue.
- Never run an example that creates real resources against production without explicit user confirmation. Sandbox orgs are safe; user's actual workspace is not.
The codebase uses ruff for both formatting and linting and mypy for type checking. Type hints are required on every public method's signature. Public resource methods carry a Google-style docstring — a one-line summary plus Args:, Returns:, Raises:, and Example: sections as applicable (omit Args: for a no-argument method; omit Raises: when the method raises nothing, e.g. a pure local computation) — written for consumers and the AI coding assistants that read them via the language server. Accuracy comes first: under Raises: list only the exceptions the method body itself raises (not those raised by an options model's validators); encode return gotchas in Returns: (single-use Iterator, raw bytes for blob downloads that follow a redirect, None for 204s); and make every Example: a real, runnable call using the correct client.<attr> name and a real *Options class. Internal/private helpers stay terse (one line, or none when obvious).
Comments are minimal by design. A comment should explain why something non-obvious is true, not what the code does. The names and types should be enough to convey "what".
# ❌ Don't
# Increment the counter by 1
counter += 1
# ✅ Do (only when the why is non-obvious)
# Run task stages are wire values, not Python names. If the API/go-tfe says
# "pre-plan", keep the hyphen; do not "normalize" it to snake_case.
stage_value = raw_valueA reasonable PR includes:
- Code (resource + models)
- Tests covering every public method
- An updated or new example
- A short
CHANGELOG.mdentry under# v<next-minor>.0 (Unreleased)describing the user-visible change
Open the PR with a description that explains why the change is needed, links to any HCP Terraform API docs or go-tfe code referenced, and notes any behavior changes a downstream consumer might see.
The reviewer's checklist will be the union of the checklists in docs/ITERATORS.md, docs/MODELS.md, and docs/RESOURCE.md. Pre-running them yourself is the fastest way to a merge.