Custom Terraform project wrapper used to provision infrastructure.
Project description
tf-project
A thin, opinionated Terraform-project wrapper. Provides a single tf-project
(aka tfp) CLI that wraps terraform init / plan / apply / refresh / destroy / fmt / output / state mv with:
- Per-tfvars remote-state backend keys (one state per tfvars file).
- Pluggable tfvars preprocessing — defaults to 1Password's
op injectso you can keepop://...references in tfvars committed to git. - A small JSON state file (
tmp/my_terraform_state.json) capturing which tfvars was last init'd, so subsequentplan/applyneed no arguments.
Install
pip install tf-project
The CLI is exposed as both tf-project and the shorter alias tfp.
Configure
The fastest way to get a config is:
cd path/to/your-terraform-repo
tfp self init
This drops a tf_project.toml at the repo root, or — if a pyproject.toml
is already present — appends a [tool.tf_project] section to it. It refuses
to overwrite an existing config.
You can also write the file yourself:
[tf_project]
terraform_dir = "terraform" # where your <project>/ subdirs live
tfvars_dir = "tfvars" # used by `tfp fmt`
tmp_dir = "tmp" # state file + tfplan land here
state_key_prefix = "terraform/azure/" # remote backend key prefix
# Optional. Defaults to `shutil.which("terraform")` at config-load time.
# A value with a path separator is resolved relative to the project root;
# a bare name (e.g. `"tofu"`) is left for `subprocess` to PATH-resolve.
# terraform_binary = "bin/terraform-1.7.5"
# Optional. Static `-backend-config` k/v pairs applied to every `tfp init`.
# Banner-level `backend_config` overrides individual keys.
[tf_project.backend_config]
# resource_group_name = "tfstate-rg"
# storage_account_name = "tfstate0001"
# container_name = "tfstate"
# Optional. Defaults to `op inject`. Set `command = []` to disable.
[tf_project.secrets]
command = ["op", "inject", "--in-file", "{in}", "--out-file", "{out}"]
Alternatively, place the same fields under [tool.tf_project] in your
pyproject.toml. tf_project.toml wins if both are present. The package
walks up from cwd to find either file.
Each tfvars file should carry a one-line JSON banner identifying its project:
# {"header": "terraform", "project": "demo"}
foo = "bar"
tfp init <tfvars> reads that banner to pick the terraform_dir/<project>/
subdirectory to operate on, and persists a state record so subsequent
commands take no arguments.
The banner also accepts these optional fields:
| Field | Purpose |
|---|---|
state_key |
Full remote-state backend key. Overrides the default <state_key_prefix><tfvars-stem>.tfstate. Use to share state across files. |
env |
JSON object of string → string env vars. Merged into the saved state on top of any previously-captured environment. |
backend_config |
JSON object of extra -backend-config k=v pairs (e.g. resource_group_name, storage_account_name). Wins over the config-level [tf_project.backend_config] table. |
# {"header":"terraform","project":"core","state_key":"shared/core.tfstate","env":{"ARM_SUBSCRIPTION_ID":"…"}}
foo = "bar"
Usage
tfp init tfvars/dev.tfvars # init backend for this tfvars
tfp plan # plan using last init'd tfvars
tfp plan -t module.foo.bar # targeted plan (repeatable)
tfp plan -r module.foo.bar # force-replace (repeatable)
tfp apply # apply the saved plan
tfp refresh # apply directly (no saved plan)
tfp destroy -t module.foo.bar # targeted destroy
tfp fmt # terraform fmt -recursive over terraform/ + tfvars/
tfp output # terraform output -json
tfp state-mv aws_x.a aws_x.b # terraform state mv
tfp status # one-line summary of the current init
tfp last # last terraform invocation (argv + exit code)
Global flags
--verbose— echo the terraform argv to stderr before exec.--dry-run— print the argv and skip execution. Combine with any subcommand (wrapped or passthrough) to preview what would be invoked.
tfp --dry-run plan -t module.foo
tfp --verbose apply
Recovering from a stuck Azure tfstate lock
When terraform is killed mid-operation (Ctrl-C, OOM, network drop), the azurerm backend leaves an infinite blob lease on the tfstate. Subsequent runs fail with "state locked".
tfp self lock status # lease + lock metadata (exits 2 if locked)
tfp self lock break # break the blob lease (prompts for confirmation; -y to skip)
tfp force-unlock <LOCK_ID> # backend-agnostic, via terraform passthrough
tfp self lock status reads the lock ID directly from the blob's
terraformlockid metadata (which the azurerm backend writes as
base64-encoded JSON), so you don't have to provoke a failed terraform run
to discover it:
locked = True
lease_state = leased
lease_duration = infinite
lock_id = 12345678-90ab-cdef-1234-567890abcdef
lock_who = user@host
lock_operation = OperationTypePlan
lock_created = 2026-05-14T12:00:00Z
To release via terraform: tfp force-unlock 12345678-90ab-cdef-1234-567890abcdef
tfp self lock auto-detects the backend from the saved init state's
backend_config:
- azurerm —
storage_account_name+container_namepresent. Shells out toaz storage blob. - s3 —
bucket+dynamodb_tablepresent. Shells out toaws dynamodb(uses the DynamoDB-locking variant of the S3 backend).
Both need the respective CLI installed and authenticated, and they read
the relevant fields from the state saved by tfp init.
Two options once you have the ID:
tfp force-unlock <ID>ortfp self lock break(default) — the polite version: terraform releases the lease and deletes the lock metadata. Works for any backend.self lock breakdiscovers the ID itself;force-unlocktakes it as an argument.tfp self lock break --blunt— skips terraform and releases the lock at the backend level (azurerm: break the blob lease; s3: delete the DynamoDB lock item). Use when terraform itself can't reach the backend.
Apply safety
tfp plan records a SHA-256 of the decrypted tfvars alongside the saved
tfplan (<tfplan>.meta.json). tfp apply refuses to run if the tfvars
content changed since the plan was generated. Pass --force to override.
Passthrough to terraform
Any subcommand not in the wrapped list above is forwarded to terraform
verbatim, prefixed with -chdir=<source_root> and the environment from the
last tfp init. So the full Terraform CLI surface is reachable through tfp:
tfp validate # terraform -chdir=... validate
tfp validate -json # flags pass straight through
tfp workspace list # terraform -chdir=... workspace list
tfp taint module.foo.bar # terraform -chdir=... taint module.foo.bar
tfp providers schema -json # terraform -chdir=... providers schema -json
tfp version # works without init (no -chdir prepended)
The wrapped subcommands also accept extra terraform flags, which are appended to the underlying invocation:
tfp plan -t module.foo -- -detailed-exitcode -compact-warnings
tfp apply -- -parallelism=20
(The -- is optional — anything Typer doesn't recognise is forwarded — but
including it is the most readable way to signal "everything after this is raw
terraform flags".)
Self-management
A group of tfp self ... commands manages the tool itself, not Terraform:
tfp self init # bootstrap tf_project.toml or [tool.tf_project]
tfp self config print # show effective config (--json for JSON)
tfp self config path # show which file the config came from
tfp self state show # pretty-print the saved init state
tfp self state clear # delete the saved state file
tfp self doctor # sanity-check the environment (PATH, dirs, ...)
tfp self banner check <tfvars> # validate a tfvars banner; print resolved fields
tfp self snapshot # `terraform state pull` to <tmp_dir>/snapshot-<ts>.tfstate
tfp self trace <subcommand> # print the argv tfp would build; no exec, no op inject
tfp self lock status # remote-state lock state (azurerm or s3+dynamodb)
tfp self lock break # polite-by-default release; --blunt skips terraform
tfp force-unlock <LOCK_ID> # backend-agnostic, via terraform passthrough
Development
pdm install
pdm run ruff check src tests
pdm run pyright src
pdm run pytest # unit tests
pdm run pytest -m integration # smoke tests (need `terraform` on PATH)
Release
- Merge to
main. CI runs; on success the Release workflow triggers. Releasebuilds the wheel, publishes to TestPyPI then PyPI via OIDC trusted publishing, then opens apdm bump patchcommit to prepare the next version.- PyPI trusted-publishing setup is a one-time step on
pypi.org → Manage project → Publishing, tied to this repo and thereleaseGitHub Environment.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tf_project-0.1.2.tar.gz.
File metadata
- Download URL: tf_project-0.1.2.tar.gz
- Upload date:
- Size: 31.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: pdm/2.26.9 CPython/3.13.13 Linux/6.17.0-1010-azure
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4f91d1ec20f7f0411000fc2182fd6b40407ec7fee8344954f01a2e80b51ae453
|
|
| MD5 |
ed973a836a4e9358a87133c2f9f3c8a7
|
|
| BLAKE2b-256 |
91e2d8bc310b08964d1d70b04df9ed509b6edf9265aa621ece589dcad29cdb1d
|
File details
Details for the file tf_project-0.1.2-py3-none-any.whl.
File metadata
- Download URL: tf_project-0.1.2-py3-none-any.whl
- Upload date:
- Size: 30.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: pdm/2.26.9 CPython/3.13.13 Linux/6.17.0-1010-azure
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5fc68bfab884bccfbcddf7ad687a79e428e2438408b64dd8ca25bbc1947e9c43
|
|
| MD5 |
97ef768a08e1c3c03a0fd9a6aadb9316
|
|
| BLAKE2b-256 |
f57aefaa94663f4727b43fee96804441236303cd3c26cb632ff0188b66881e0a
|