Skip to main content

tf-project

PyPI Python License

A thin, opinionated Terraform / OpenTofu wrapper for multi-environment projects. Provides a single tf-project (aka tfp) CLI built around named slots - each tfvars file binds to a slot, and switching between slots is a pointer write, not a re-terraform init.

If you maintain one tfvars per environment (dev.tfvars, staging.tfvars, prod.tfvars, …) and you're tired of re-initialising the backend every time you switch, this tool replaces that ritual with tfp use dev.

  • Slots, not paths. Banner-declared (or filename-derived) names refer to tfvars files. tfp use dev flips the active slot; tfp plan / tfp apply operate against it.
  • One .terraform/ per slot via TF_DATA_DIR. Two slots pointing at the same project keep independent provider/module/backend bindings on disk - swapping between them is instant when both are warm.
  • Shared provider cache via TF_PLUGIN_CACHE_DIR. Providers download once and link into every slot's .terraform/providers/.
  • Versioned, pydantic-validated state files. Every persisted JSON carries schema_version; unknown fields fail loudly (except in user-authored banners, where they are tolerated and logged).
  • Pluggable tfvars preprocessing - defaults to 1Password's op inject so you can keep op://... references in tfvars committed to git.

Install

pip install tf-project

The CLI is exposed as both tf-project and the shorter alias tfp.

Configure

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"               # scanned for slots; used by `tfp fmt`
tmp_dir          = "tmp"                  # slot dirs + plugin cache land here
state_key_prefix = "terraform/azure/"     # remote backend key prefix

# Optional. Defaults to `shutil.which("terraform")` at config-load time.
# terraform_binary = "bin/terraform-1.7.5"

# Optional. Defaults to `<tmp_dir>/plugin-cache`. Set to "" to disable.
# plugin_cache_dir = "tmp/plugin-cache"

# Optional. Static `-backend-config` k/v pairs applied to every slot init.
[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}"]

Unknown fields in tf_project.toml are rejected at load time.

Tfvars banner

Each tfvars file carries a one-line JSON banner that identifies its project and (optionally) names its slot:

# {"header": "terraform", "project": "demo"}
foo = "bar"

The slot name defaults to the tfvars filename stem (tfvars/dev.tfvars → slot dev). Override with a slot field:

# {"header": "terraform", "project": "demo", "slot": "production"}

Banner fields:

Field Purpose
header Must be "terraform". Identifies the comment line as a tf-project banner.
project Subdirectory under terraform_dir/ containing the source .tf files.
slot Slot name. Defaults to the tfvars filename stem. Must be unique across all tfvars in tfvars_dir.
state_key Full remote-state backend key. Overrides the default <state_key_prefix><tfvars-stem>.tfstate. Must be an absolute, normalised pure POSIX path (/shared/global.tfstate); relative keys, ./.., //, trailing /, and backslashes are rejected. The single leading slash is stripped before use (azurerm prepends the container, so a leading slash would double it). Use to share state across files.
env JSON object of string → string env vars passed to terraform when running against this slot.
backend_config JSON object of extra -backend-config k=v pairs. Wins over the config-level [tf_project.backend_config] table.

Unknown banner fields are tolerated (forward-compatible across tfp versions) but logged to stderr - so a typo doesn't disappear silently.

Usage

tfp use dev                     # switch to slot "dev" (no init if already warm)
tfp use tfvars/prod.tfvars      # path also accepted (useful with shell completion)
tfp init                        # re-bind the active slot's backend from its banner
                                # (run after editing a banner's state_key/backend_config)
tfp ls                          # list slots; active marked with `*`
tfp rm staging                  # remove a slot's on-disk directory

tfp plan                        # plan using the active slot
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 list                  # list resources in state
tfp state show aws_x.foo
tfp state mv  aws_x.a aws_x.b
tfp state rm  aws_x.foo aws_x.bar
tfp state pull                  # tfstate JSON to stdout
tfp state push backup.tfstate
tfp state replace-provider hashicorp/aws registry.acme.local/aws
tfp state identities            # 1.10+
tfp import aws_s3_bucket.foo my-bucket
                                # forwards the decrypted tfvars + saved env
tfp status                      # one-line summary of the active slot
tfp last                        # last terraform invocation for the active slot

Choosing the active slot

Three precedence levels - the most specific wins:

Source Wins over Lifetime
-s/--slot <name> everything single invocation only (does NOT save)
TFP_SLOT=<name> saved file current shell
tmp/active file - persistent until next tfp use
tfp -s prod plan                # one-off, doesn't touch the saved active
TFP_SLOT=prod tfp plan          # current shell only
tfp use prod                    # writes tmp/active (affects all shells without TFP_SLOT)

Global flags

  • --verbose - echo the terraform argv to stderr before exec.
  • --dry-run - print the argv and skip execution.
  • -s/--slot <name> - operate against a specific slot for one invocation.
tfp --dry-run plan -t module.foo
tfp -s prod --verbose apply

Plugin cache and the lock file

tfp enables Terraform's shared plugin cache by default at <tmp_dir>/plugin-cache/. Providers download once and link into every slot's .terraform/providers/, so the second slot's first init is seconds.

The trade-off: TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE=1 is set so Terraform tolerates the cache's missing per-platform hashes. To keep .terraform.lock.hcl portable across machines, every tfp use that runs terraform init chases it with terraform providers lock for linux_amd64 + linux_arm64 (the common CI / runtime targets) plus the host's own platform - so on an Apple Silicon Mac you get three sets of hashes (the two Linux ones + darwin_arm64), on Linux you just get the two Linux ones, etc.

Teams that span multiple host platforms can extend the auto-lock set via tf_project.toml so everyone's hashes land in the same lockfile regardless of who ran tfp use last:

[tf_project]
# linux_amd64, linux_arm64, and the host's own platform are always included;
# anything listed here is added on top.
extra_lock_platforms = ["windows_amd64", "darwin_amd64"]

To re-lock against a custom platform set (e.g. add windows_amd64):

tfp self providers lock -p linux_amd64 -p windows_amd64

Disable the cache by setting plugin_cache_dir = "" in the config.

Recovering from a stuck Azure / S3 tfstate lock

When terraform is killed mid-operation, the remote backend can leave a stale lock 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 lock (prompts for confirmation; -y to skip)
tfp force-unlock <LOCK_ID>      # backend-agnostic, via terraform passthrough

tfp self lock auto-detects the backend from the active slot's saved state:

  • azurerm - storage_account_name + container_name present. Shells out to az storage blob.
  • s3 - bucket + dynamodb_table present. Shells out to aws dynamodb.

Both need the respective CLI installed and authenticated.

tfp self lock break is polite by default: it discovers the lock ID and runs terraform force-unlock <ID> first, falling back to a backend-level lease break only if the polite path fails. Pass --blunt to skip terraform entirely.

Auto-snapshot before state-mutating ops

tfp state rm, tfp state mv, tfp state replace-provider, and tfp import each run terraform state pull first and save the result to <tmp_dir>/snapshots/<slot>/<ts>-pre-<op>.tfstate before the mutating command runs. If the pull fails (auth blip, network) the whole op aborts - no point destroying state you can't roll back to. Recovery:

tfp self snapshot list
tfp self snapshot restore               # newest, with confirmation
tfp self snapshot restore <name> -y     # specific snapshot, no prompt

Restoration uses terraform state push -force, which overwrites the remote state regardless of serial - by definition you're undoing a change, so the serial check is exactly what you want to skip.

The retention budget is snapshot_retention in tf_project.toml (default 200 per slot, pruned newest-first on every write). Set it to 0 to disable auto-snapshotting entirely; tfp self snapshot create still works either way.

Apply safety

tfp plan records a SHA-256 of the decrypted tfvars + every .tf under source_root in slots/<slot>/tfplan.meta.json, alongside the active banner's project, state_key, backend_config, and env. tfp apply refuses to run if any of those drift between plan and apply. 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 env from the active slot's saved state:

tfp validate                          # terraform -chdir=... validate
tfp workspace list                    # per-slot workspace state via TF_DATA_DIR
tfp providers schema -json
tfp version                           # works without an active slot

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

Self-management

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                 # active slot's saved state as JSON
tfp self state show --all           # every slot's state
tfp self state clear                # delete the active slot's saved state
tfp self state clear --all          # nuke every slot's saved state
tfp self doctor                     # sanity-check the environment (PATH, dirs, slot uniqueness, orphans)
tfp self banner check <tfvars>      # validate a banner; print resolved slot + fields
tfp self snapshot create            # `terraform state pull` to <tmp_dir>/snapshots/<slot>/<ts>-manual.tfstate
tfp self snapshot list              # list snapshots for the active slot, newest first
tfp self snapshot restore [<name>]  # `terraform state push -force` the named snapshot (default: newest)
tfp self trace <subcommand>         # print the argv tfp would build; no exec
tfp self lock status                # remote-state lock state (azurerm or s3+dynamodb)
tfp self lock break                 # polite-by-default release; --blunt skips terraform
tfp self providers lock             # cross-platform `terraform providers lock`
tfp force-unlock <LOCK_ID>          # backend-agnostic, via terraform passthrough

Migrating from a pre-slot install

The first time you run any tfp command after upgrading, tf-project detects the legacy tmp/my_terraform_state.json and promotes it into the new layout under tmp/slots/<slot>/, sets tmp/active, and leaves a one-line tmp/MIGRATED-<UTC-timestamp>.txt breadcrumb. The first command afterwards will repopulate the slot's .terraform/ from the plugin cache.

If the legacy state references a tfvars that has since moved or been deleted, migration logs a warning, deletes the legacy file, and asks you to run tfp use <slot> to start fresh.

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

Releases are tag-driven. The wheel's version comes from the git tag, not a code edit - src/tf_project/__version__.py exists but reads the installed distribution's metadata at runtime; there is nothing in code to bump.

  1. Move the ## [Unreleased] block in CHANGELOG.md under a new ## [X.Y.Z] - YYYY-MM-DD heading. Commit to main.
  2. Tag the commit: git tag vX.Y.Z && git push origin vX.Y.Z.
  3. The Release workflow fires on the tag push:
    • Re-runs CI against the tagged commit.
    • Builds sdist + wheel with the version pinned from the tag.
    • Signs both with Sigstore (keyless, transparency-logged).
    • Publishes to TestPyPI and then PyPI via OIDC trusted publishing.
    • Creates a GitHub Release with the extracted changelog section and the signed artifacts (.whl, .tar.gz, .sigstore) attached.

Tags carrying rc / a / b / dev suffixes (e.g. v1.0.0rc1) are auto-marked as pre-releases on GitHub.

PyPI trusted-publishing is a one-time setup on pypi.org → Manage project → Publishing, tied to this repo and the release GitHub Environment.

Metadata

Release files for tf-project 0.2.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tf-project 0.2.3
File Size Uploaded
tf_project-0.2.3.tar.gz 59.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tf-project 0.2.3
File Interpreter ABI Platform
tf_project-0.2.3-py3-none-any.whl Python 3 none any Details

Total release size: 115.8 kB

Release files / tf_project-0.2.3.tar.gz

Download URL tf_project-0.2.3.tar.gz
Size 59.8 kB
Tags Source
SHA-256 checksum
How to use checksums
64ef0c141c1317ae051bc1235c0441c5cf9179eb076025adc9a031d283a24608
BLAKE2b-256 checksum
How to use checksums
bfe99d7c98b2d87837520e5356263ba55a1c0a21c57d04a290b9ddc99b27a079
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 26, 2026.

Transparency log

Release files / tf_project-0.2.3-py3-none-any.whl

Download URL tf_project-0.2.3-py3-none-any.whl
Size 56.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7a37bc8236146a34844c0713c3d3c9a5f87b74af2907e414f92bf661d3658c96
BLAKE2b-256 checksum
How to use checksums
9a3aef3ead189b941921e43f0a3847752562aeae3a9be04fa8dedc2cc33ad179
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 26, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.3 This release

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page