├── .coveragerc
├── .editorconfig
├── .github
└── workflows
│ ├── add-new-versions.yml
│ └── tests.yml
├── .gitignore
├── .isort.cfg
├── .pre-commit-config.yaml
├── .pre-commit-hooks.yaml
├── LICENSE
├── README.md
├── add-new-versions.py
├── requirements-dev.txt
├── requirements.txt
├── setup.cfg
├── setup.py
├── templates
├── README.md.j2
└── setup.py.j2
├── terraform_py
├── __init__.py
├── __main__.py
└── _main.py
├── testing
├── invalid
│ ├── .terraform.lock.hcl
│ ├── non-existent-resource.tf
│ ├── providers.tf
│ └── versions.tf
├── malformatted
│ ├── .terraform.lock.hcl
│ ├── providers.tf
│ └── versions.tf
├── nested
│ ├── invalid
│ │ ├── .terraform.lock.hcl
│ │ ├── non-existent-resource.tf
│ │ ├── providers.tf
│ │ └── versions.tf
│ └── malformatted
│ │ ├── .terraform.lock.hcl
│ │ ├── providers.tf
│ │ └── versions.tf
└── valid
│ ├── .terraform.lock.hcl
│ ├── providers.tf
│ └── versions.tf
├── tests
└── test_main.py
└── tox.ini
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | branch = true
3 | source = terraform_py
4 |
5 | omit =
6 | */__main__.py
7 | setup.py
8 | tests/*
9 | */venv/*
10 |
11 | [report]
12 | fail_under = 100
13 | show_missing = true
14 |
15 | exclude_lines =
16 | pragma: no cover
17 | raise AssertionError
18 | raise NotImplementedError
19 | if __name__ == "__main__":
20 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | indent_size = 4
7 | indent_style = space
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.{yml,yaml}]
12 | indent_size = 2
13 |
--------------------------------------------------------------------------------
/.github/workflows/add-new-versions.yml:
--------------------------------------------------------------------------------
1 | name: Add new versions
2 |
3 | on:
4 | schedule:
5 | - cron: "0 12 * * *"
6 | workflow_dispatch:
7 |
8 | jobs:
9 | add_new_versions:
10 | runs-on: ubuntu-latest
11 | env:
12 | GH_TOKEN: ${{ secrets.GH_TOKEN }}
13 |
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 | with:
18 | token: ${{ env.GH_TOKEN }}
19 |
20 | - name: Set up python 3.9
21 | uses: actions/setup-python@v5
22 | with:
23 | python-version: "3.12"
24 |
25 | - name: Install dependencies
26 | run: pip install -r requirements.txt
27 |
28 | - name: Configure git
29 | run: |
30 | git config --global user.name 'Github Actions'
31 | git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
32 |
33 | - name: Add new versions
34 | run: ./add-new-versions.py --push
35 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Run tests
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 | workflow_dispatch:
9 |
10 | defaults:
11 | run:
12 | shell: bash
13 |
14 | jobs:
15 | test:
16 | strategy:
17 | matrix:
18 | version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
19 | os: [ubuntu-latest, windows-latest, macos-13, macos-latest]
20 | fail-fast: false
21 |
22 | runs-on: ${{ matrix.os }}
23 |
24 | steps:
25 | - name: Checkout
26 | uses: actions/checkout@v4
27 |
28 | - name: Set up python ${{ matrix.version }}
29 | uses: actions/setup-python@v5
30 | with:
31 | python-version: ${{ matrix.version }}
32 |
33 | - name: Install dependencies
34 | run: pip install tox
35 |
36 | - name: Run tests for python ${{ matrix.version }}
37 | run: tox -e py$(tr -d '.' <<< '${{ matrix.version }}')
38 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 |
3 | .idea
4 | .vscode
5 |
6 | *.pyc
7 | __pycache__
8 |
9 | *.egg-info
10 | build/
11 |
12 | venv
13 |
14 | .tox
15 |
16 | .mypy_cache
17 |
18 | .coverage
19 |
20 | .env
21 |
22 | .terraform
23 | *.tfstate
24 | *.tfstate.*
25 |
--------------------------------------------------------------------------------
/.isort.cfg:
--------------------------------------------------------------------------------
1 | [settings]
2 | line_length = 88
3 | lines_after_imports = 2
4 | force_single_line = true
5 | ensure_newline_before_comments = true
6 | known_typing = typing
7 | sections = FUTURE, STDLIB, TYPING, THIRDPARTY, FIRSTPARTY, LOCALFOLDER
8 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v4.4.0
4 | hooks:
5 | - id: trailing-whitespace
6 | - id: end-of-file-fixer
7 | - id: check-yaml
8 |
9 | - repo: https://github.com/psf/black
10 | rev: 23.3.0
11 | hooks:
12 | - id: black
13 | types_or: [text]
14 | exclude: setup.py$
15 | files: ^.*\.py(\.j2)?$
16 |
17 | - repo: https://github.com/timothycrosley/isort
18 | rev: 5.12.0
19 | hooks:
20 | - id: isort
21 | types_or: [text]
22 | exclude: setup.py$
23 | files: ^.*\.py(\.j2)?$
24 |
25 | - repo: https://github.com/asottile/setup-cfg-fmt
26 | rev: v2.3.0
27 | hooks:
28 | - id: setup-cfg-fmt
29 |
--------------------------------------------------------------------------------
/.pre-commit-hooks.yaml:
--------------------------------------------------------------------------------
1 | - id: tf-fmt
2 | name: terraform fmt
3 | description: Run terraform fmt
4 | require_serial: true
5 | entry: terraform-py fmt
6 | language: python
7 | types: [terraform]
8 |
9 | - id: tf-validate
10 | name: terraform validate
11 | description: Run terraform validate
12 | require_serial: true
13 | entry: terraform-py validate
14 | language: python
15 | types: [terraform]
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Aleksa Cukovic
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # terraform-py
2 |
3 | [](https://github.com/AleksaC/terraform-py/actions/workflows/add-new-versions.yml)
4 | [](https://github.com/AleksaC/terraform-py/actions/workflows/tests.yml)
5 |
6 | pip installable [terraform](https://github.com/hashicorp/terraform) binary with wrapper for pre-commit.
7 |
8 | The mechanism by which the terraform binary is downloaded is adapted from
9 | [shellcheck-py](https://github.com/shellcheck-py/shellcheck-py).
10 |
11 | ## Getting started
12 |
13 | ### Installation
14 |
15 | This package has been built to make it more convenient to run `terraform fmt`
16 | `terraform validate` as pre-commit hooks so it hasn't been published to PyPI.
17 | However you can install it using git:
18 |
19 | ```shell script
20 | pip install git+https://github.com/AleksaC/terraform-py.git@v1.12.1
21 | ```
22 |
23 | ### pre-commit hooks
24 |
25 | Since `terraform fmt` and `terraform validate` take directories as inputs they
26 | can't be used as pre-commit hooks directly. Hence there are wrappers for the
27 | two commands that take list of filenames as input and run the commands on the
28 | directories they are in. To use the hooks include the following config in your
29 | `.pre-commit-config.yaml` file:
30 |
31 | ```yaml
32 | repos:
33 | - repo: https://github.com/AleksaC/terraform-py
34 | rev: v1.12.1
35 | hooks:
36 | - id: tf-fmt
37 | - id: tf-validate
38 | ```
39 |
40 | ## Limitations
41 |
42 | This package mirrors all terraform releases currently available on github,
43 | however fmt and validate commands weren't available on the oldest versions
44 | and worked differently in the initial releases. This shouldn't be a problem
45 | since I don't expect versions so old to be used.
46 |
47 | Versions before `1.0.3` won't work on Macs with M1 chip since darwin arm builds
48 | weren't available for earlier versions. While x86 binaries would work
49 | I didn't want to support that edge case in the platform detection code as it
50 | won't be needed for the future releases.
51 |
52 | `terraform validate` itself isn't particularly fast. In addition to that
53 | `terraform init` needs to be performed before it, making it even slower.
54 | In projects with lots of modules this can get quite slow, so you may need
55 | to set up additional caching beside the one for pre-commit.
56 |
57 | ## Contact 🙋♂️
58 | - [Personal website](https://aleksac.me)
59 | -
60 | - aleksacukovic1@gmail.com
61 |
--------------------------------------------------------------------------------
/add-new-versions.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import argparse
4 | import base64
5 | import itertools
6 | import json
7 | import os
8 | import re
9 | import subprocess
10 | import sys
11 | from urllib.request import Request
12 | from urllib.request import urlopen
13 |
14 | from typing import Any
15 | from typing import NamedTuple
16 | from typing import Optional
17 |
18 | import jinja2
19 |
20 |
21 | VERSION_RE = re.compile("^v?(?P[0-9]+)\.(?P[0-9]+)\.(?P[0-9]+)$")
22 |
23 | OS = ("darwin", "linux", "windows")
24 | ARCH = ("amd64", "arm64")
25 |
26 | TEMPLATES_DIR = "templates"
27 | PACKAGE_DIR = "terraform_py"
28 |
29 | REPO = "hashicorp/terraform"
30 | MIRROR_REPO = "AleksaC/terraform-py"
31 |
32 |
33 | class Version(NamedTuple):
34 | major: int
35 | minor: int
36 | patch: int
37 |
38 | @classmethod
39 | def from_string(cls, version: str) -> "Version":
40 | if match := re.match(VERSION_RE, version):
41 | return cls(*map(int, match.groups()))
42 |
43 | raise ValueError("Invalid version", version)
44 |
45 | def __repr__(self):
46 | return f"{self.major}.{self.minor}.{self.patch}"
47 |
48 |
49 | class Template(NamedTuple):
50 | src: str
51 | dest: str
52 | vars: dict[str, Any]
53 |
54 |
55 | def _get(url: str, headers: Optional[dict[str, str]] = None) -> dict:
56 | if headers is None:
57 | headers = {}
58 |
59 | req = Request(url, headers=headers)
60 | resp = urlopen(req, timeout=30)
61 |
62 | return resp
63 |
64 |
65 | def get_json(url: str, headers: Optional[dict[str, str]] = None) -> dict:
66 | return json.loads(_get(url, headers).read())
67 |
68 |
69 | def get_text(url: str, headers: Optional[dict[str, str]] = None) -> str:
70 | return _get(url, headers).read().decode()
71 |
72 |
73 | def git(*args: str) -> None:
74 | subprocess.run(["git", *args], check=True)
75 |
76 |
77 | def get_versions(repo: str, *, from_releases: bool = True) -> list[str]:
78 | gh_token = os.environ["GH_TOKEN"]
79 | auth = base64.b64encode(f"AleksaC:{gh_token}".encode()).decode()
80 | base_url = "https://api.github.com/repos/{}/{}?per_page=100&page={}"
81 | headers = {
82 | "Accept": "application/vnd.github.v3+json",
83 | "Authorization": f"Basic {auth}",
84 | }
85 |
86 | releases = []
87 | page = 1
88 | while releases_page := get_json(
89 | base_url.format(repo, "releases" if from_releases else "tags", page),
90 | headers=headers,
91 | ):
92 | releases.extend(releases_page)
93 | page += 1
94 |
95 | if from_releases:
96 | return [
97 | release["tag_name"]
98 | for release in releases
99 | if not release["draft"] and not release["prerelease"]
100 | ]
101 |
102 | return [release["name"] for release in releases]
103 |
104 |
105 | def get_missing_versions(repo: str, mirror_repo: str) -> list[Version]:
106 | versions = get_versions(repo)
107 | mirrored = set(
108 | map(Version.from_string, get_versions(mirror_repo, from_releases=False))
109 | )
110 | missing = []
111 |
112 | for v in reversed(versions):
113 | version = Version.from_string(v)
114 | if version not in mirrored:
115 | missing.append(version)
116 |
117 | return missing
118 |
119 |
120 | def get_archives(version: Version) -> dict[str, tuple[str, str]]:
121 | checksum_url = (
122 | f"https://releases.hashicorp.com/terraform/"
123 | f"{version}/terraform_{version}_SHA256SUMS"
124 | )
125 | checksums = get_text(checksum_url).splitlines()
126 |
127 | versions = {
128 | f"terraform_{version}_{os}_{arch}.zip": (os, arch)
129 | for os, arch in itertools.product(OS, ARCH)
130 | }
131 |
132 | archives = {}
133 |
134 | for checksum in checksums:
135 | sha, archive = checksum.split()
136 | if archive in versions:
137 | os, arch = versions[archive]
138 | archives[f"{os}_{arch}"] = (archive, sha)
139 |
140 | return archives
141 |
142 |
143 | def render_templates(templates: list[Template]) -> None:
144 | for src, dest, vars in templates:
145 | with open(os.path.join(TEMPLATES_DIR, src)) as f:
146 | template_file = f.read()
147 |
148 | template = jinja2.Template(template_file, keep_trailing_newline=True)
149 |
150 | with open(dest, "w") as f:
151 | f.write(template.render(**vars))
152 |
153 |
154 | def tag_version(version: str) -> None:
155 | git("add", "-u")
156 | git("commit", "-m", f"Add version {version}")
157 | git("tag", version)
158 |
159 |
160 | def main(argv=None):
161 | parser = argparse.ArgumentParser()
162 | parser.add_argument("--push", default=False, action="store_true")
163 | args = parser.parse_args(argv)
164 |
165 | versions = get_missing_versions(REPO, MIRROR_REPO)
166 |
167 | for version in versions:
168 | print(f"Adding new version: v{version}")
169 |
170 | archives = get_archives(version)
171 |
172 | render_templates(
173 | [
174 | Template(
175 | src="setup.py.j2",
176 | dest="setup.py",
177 | vars={"tf_version": str(version), "archives": str(archives)},
178 | ),
179 | Template(
180 | src="README.md.j2",
181 | dest="README.md",
182 | vars={"tf_version": str(version)},
183 | ),
184 | ]
185 | )
186 |
187 | tag_version(f"v{version}")
188 |
189 | if args.push:
190 | git("push")
191 | git("push", "--tags")
192 |
193 | return 0
194 |
195 |
196 | if __name__ == "__main__":
197 | sys.exit(main())
198 |
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | pytest
2 | coverage
3 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | # dependencies for add-new-version.py, not the package itself
2 | Jinja2
3 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | name = terraform_py
3 | description = Making terraform pip installable
4 | long_description = file: README.md
5 | long_description_content_type = text/markdown
6 | url = https://github.com/AleksaC/terraform-py
7 | author = Aleksa Cukovic
8 | author_email = aleksacukovic1@gmail.com
9 | license = MIT
10 | license_files = LICENSE
11 | classifiers =
12 | License :: OSI Approved :: MIT License
13 | Programming Language :: Python :: 3
14 | Programming Language :: Python :: 3 :: Only
15 | Programming Language :: Python :: Implementation :: CPython
16 | Programming Language :: Python :: Implementation :: PyPy
17 |
18 | [options]
19 | packages = find:
20 | python_requires = >=3.8
21 |
22 | [options.packages.find]
23 | exclude =
24 | tests*
25 | testing*
26 |
27 | [options.entry_points]
28 | console_scripts =
29 | terraform-py = terraform_py._main:main
30 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import hashlib
4 | import http
5 | import io
6 | import os.path
7 | import platform
8 | import stat
9 | import tarfile
10 | import urllib.request
11 | import zipfile
12 | from distutils.command.build import build as orig_build
13 | from distutils.core import Command
14 |
15 | from setuptools import setup
16 | from setuptools.command.install import install as orig_install
17 |
18 |
19 | TERRAFORM_VERSION = "1.12.1"
20 | ARCHIVE_SHA256 = {'darwin_amd64': ('terraform_1.12.1_darwin_amd64.zip', 'bb5bc5c846a4b41b414a6598775a27e3fbb0405ef9b36a61789df5639a9860f5'), 'darwin_arm64': ('terraform_1.12.1_darwin_arm64.zip', '30dd56df622fc4d477f24abe7c19856c4c1c22284e20db6d7fa4c53bcfacfb20'), 'linux_amd64': ('terraform_1.12.1_linux_amd64.zip', 'dcaf8ba801660a431a6769ec44ba53b66c1ad44637512ef3961f7ffe4397ef7c'), 'linux_arm64': ('terraform_1.12.1_linux_arm64.zip', '70e8c1776646f2af83ccad6113b8bb4768e6f7dc65335ae11ffd095eca3b0d4c'), 'windows_amd64': ('terraform_1.12.1_windows_amd64.zip', '0db2cd75a49dc04c5b88dcd0173ff67607f4d914396cd195b6717869a415dea1')}
21 |
22 |
23 | def get_download_url() -> str:
24 | os, arch = platform.system().lower(), platform.machine().lower()
25 | if (
26 | os == "windows"
27 | or "x86" in arch
28 | or "amd" in arch
29 | or "i386" in arch
30 | or "i686" in arch
31 | ):
32 | arch = "amd"
33 | elif "arm" in arch or arch == "aarch64":
34 | arch = "arm"
35 |
36 | archive, sha256 = ARCHIVE_SHA256[f"{os}_{arch}64"]
37 | url = f"https://releases.hashicorp.com/terraform/" f"{TERRAFORM_VERSION}/{archive}"
38 |
39 | return url, sha256
40 |
41 |
42 | def download(url: str, sha256: str) -> bytes:
43 | with urllib.request.urlopen(url) as resp:
44 | code = resp.getcode()
45 | if code != http.HTTPStatus.OK:
46 | raise ValueError(f"HTTP failure. Code: {code}")
47 | data = resp.read()
48 |
49 | checksum = hashlib.sha256(data).hexdigest()
50 | if checksum != sha256:
51 | raise ValueError(f"sha256 mismatch, expected {sha256}, got {checksum}")
52 |
53 | return data
54 |
55 |
56 | def extract(url: str, data: bytes) -> bytes:
57 | with io.BytesIO(data) as bio:
58 | if ".tar." in url:
59 | with tarfile.open(fileobj=bio) as tarf:
60 | for info in tarf.getmembers():
61 | if info.isfile() and info.name.endswith("terraform"):
62 | return tarf.extractfile(info).read()
63 | elif url.endswith(".zip"):
64 | with zipfile.ZipFile(bio) as zipf:
65 | for info in zipf.infolist():
66 | if not info.is_dir() and (
67 | info.filename.endswith(".exe")
68 | or info.filename.endswith("terraform")
69 | ):
70 | return zipf.read(info.filename)
71 |
72 | raise AssertionError(f"unreachable {url}")
73 |
74 |
75 | def save_executable(data: bytes, base_dir: str):
76 | exe = "terraform" if platform.system() != "Windows" else "terraform.exe"
77 | output_path = os.path.join(base_dir, exe)
78 | os.makedirs(base_dir, exist_ok=True)
79 |
80 | with open(output_path, "wb") as fp:
81 | fp.write(data)
82 |
83 | # Mark as executable.
84 | # https://stackoverflow.com/a/14105527
85 | mode = os.stat(output_path).st_mode
86 | mode |= stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
87 | os.chmod(output_path, mode)
88 |
89 |
90 | class build(orig_build):
91 | sub_commands = orig_build.sub_commands + [("fetch_binaries", None)]
92 |
93 |
94 | class install(orig_install):
95 | sub_commands = orig_install.sub_commands + [("install_terraform", None)]
96 |
97 |
98 | class fetch_binaries(Command):
99 | build_temp = None
100 |
101 | def initialize_options(self):
102 | pass
103 |
104 | def finalize_options(self):
105 | self.set_undefined_options("build", ("build_temp", "build_temp"))
106 |
107 | def run(self):
108 | # save binary to self.build_temp
109 | url, sha256 = get_download_url()
110 | archive = download(url, sha256)
111 | data = extract(url, archive)
112 |
113 | save_executable(data, self.build_temp)
114 |
115 |
116 | class install_terraform(Command):
117 | description = "install the terraform executable"
118 | outfiles = ()
119 | build_dir = install_dir = None
120 |
121 | def initialize_options(self):
122 | pass
123 |
124 | def finalize_options(self):
125 | # this initializes attributes based on other commands' attributes
126 | self.set_undefined_options("build", ("build_temp", "build_dir"))
127 | self.set_undefined_options(
128 | "install",
129 | ("install_scripts", "install_dir"),
130 | )
131 |
132 | def run(self):
133 | self.outfiles = self.copy_tree(self.build_dir, self.install_dir)
134 |
135 | def get_outputs(self):
136 | return self.outfiles
137 |
138 |
139 | command_overrides = {
140 | "install": install,
141 | "install_terraform": install_terraform,
142 | "build": build,
143 | "fetch_binaries": fetch_binaries,
144 | }
145 |
146 |
147 | try:
148 | from wheel.bdist_wheel import bdist_wheel as orig_bdist_wheel
149 |
150 | class bdist_wheel(orig_bdist_wheel):
151 | def finalize_options(self):
152 | orig_bdist_wheel.finalize_options(self)
153 | # Mark us as not a pure python package
154 | self.root_is_pure = False
155 |
156 | def get_tag(self):
157 | _, _, plat = orig_bdist_wheel.get_tag(self)
158 | # We don't contain any python source, nor any python extensions
159 | return "py2.py3", "none", plat
160 |
161 | command_overrides["bdist_wheel"] = bdist_wheel
162 | except ImportError:
163 | pass
164 |
165 | setup(version=f"{TERRAFORM_VERSION}", cmdclass=command_overrides)
166 |
--------------------------------------------------------------------------------
/templates/README.md.j2:
--------------------------------------------------------------------------------
1 | # terraform-py
2 |
3 | [](https://github.com/AleksaC/terraform-py/actions/workflows/add-new-versions.yml)
4 | [](https://github.com/AleksaC/terraform-py/actions/workflows/tests.yml)
5 |
6 | pip installable [terraform](https://github.com/hashicorp/terraform) binary with wrapper for pre-commit.
7 |
8 | The mechanism by which the terraform binary is downloaded is adapted from
9 | [shellcheck-py](https://github.com/shellcheck-py/shellcheck-py).
10 |
11 | ## Getting started
12 |
13 | ### Installation
14 |
15 | This package has been built to make it more convenient to run `terraform fmt`
16 | `terraform validate` as pre-commit hooks so it hasn't been published to PyPI.
17 | However you can install it using git:
18 |
19 | ```shell script
20 | pip install git+https://github.com/AleksaC/terraform-py.git@v{{ tf_version }}
21 | ```
22 |
23 | ### pre-commit hooks
24 |
25 | Since `terraform fmt` and `terraform validate` take directories as inputs they
26 | can't be used as pre-commit hooks directly. Hence there are wrappers for the
27 | two commands that take list of filenames as input and run the commands on the
28 | directories they are in. To use the hooks include the following config in your
29 | `.pre-commit-config.yaml` file:
30 |
31 | ```yaml
32 | repos:
33 | - repo: https://github.com/AleksaC/terraform-py
34 | rev: v{{ tf_version }}
35 | hooks:
36 | - id: tf-fmt
37 | - id: tf-validate
38 | ```
39 |
40 | ## Limitations
41 |
42 | This package mirrors all terraform releases currently available on github,
43 | however fmt and validate commands weren't available on the oldest versions
44 | and worked differently in the initial releases. This shouldn't be a problem
45 | since I don't expect versions so old to be used.
46 |
47 | Versions before `1.0.3` won't work on Macs with M1 chip since darwin arm builds
48 | weren't available for earlier versions. While x86 binaries would work
49 | I didn't want to support that edge case in the platform detection code as it
50 | won't be needed for the future releases.
51 |
52 | `terraform validate` itself isn't particularly fast. In addition to that
53 | `terraform init` needs to be performed before it, making it even slower.
54 | In projects with lots of modules this can get quite slow, so you may need
55 | to set up additional caching beside the one for pre-commit.
56 |
57 | ## Contact 🙋♂️
58 | - [Personal website](https://aleksac.me)
59 | -
60 | - aleksacukovic1@gmail.com
61 |
--------------------------------------------------------------------------------
/templates/setup.py.j2:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import hashlib
4 | import http
5 | import io
6 | import os.path
7 | import platform
8 | import stat
9 | import tarfile
10 | import urllib.request
11 | import zipfile
12 | from distutils.command.build import build as orig_build
13 | from distutils.core import Command
14 |
15 | from setuptools import setup
16 | from setuptools.command.install import install as orig_install
17 |
18 |
19 | TERRAFORM_VERSION = "{{ tf_version }}"
20 | ARCHIVE_SHA256 = {{archives}}
21 |
22 |
23 | def get_download_url() -> str:
24 | os, arch = platform.system().lower(), platform.machine().lower()
25 | if (
26 | os == "windows"
27 | or "x86" in arch
28 | or "amd" in arch
29 | or "i386" in arch
30 | or "i686" in arch
31 | ):
32 | arch = "amd"
33 | elif "arm" in arch or arch == "aarch64":
34 | arch = "arm"
35 |
36 | archive, sha256 = ARCHIVE_SHA256[f"{os}_{arch}64"]
37 | url = f"https://releases.hashicorp.com/terraform/" f"{TERRAFORM_VERSION}/{archive}"
38 |
39 | return url, sha256
40 |
41 |
42 | def download(url: str, sha256: str) -> bytes:
43 | with urllib.request.urlopen(url) as resp:
44 | code = resp.getcode()
45 | if code != http.HTTPStatus.OK:
46 | raise ValueError(f"HTTP failure. Code: {code}")
47 | data = resp.read()
48 |
49 | checksum = hashlib.sha256(data).hexdigest()
50 | if checksum != sha256:
51 | raise ValueError(f"sha256 mismatch, expected {sha256}, got {checksum}")
52 |
53 | return data
54 |
55 |
56 | def extract(url: str, data: bytes) -> bytes:
57 | with io.BytesIO(data) as bio:
58 | if ".tar." in url:
59 | with tarfile.open(fileobj=bio) as tarf:
60 | for info in tarf.getmembers():
61 | if info.isfile() and info.name.endswith("terraform"):
62 | return tarf.extractfile(info).read()
63 | elif url.endswith(".zip"):
64 | with zipfile.ZipFile(bio) as zipf:
65 | for info in zipf.infolist():
66 | if not info.is_dir() and (
67 | info.filename.endswith(".exe")
68 | or info.filename.endswith("terraform")
69 | ):
70 | return zipf.read(info.filename)
71 |
72 | raise AssertionError(f"unreachable {url}")
73 |
74 |
75 | def save_executable(data: bytes, base_dir: str):
76 | exe = "terraform" if platform.system() != "Windows" else "terraform.exe"
77 | output_path = os.path.join(base_dir, exe)
78 | os.makedirs(base_dir, exist_ok=True)
79 |
80 | with open(output_path, "wb") as fp:
81 | fp.write(data)
82 |
83 | # Mark as executable.
84 | # https://stackoverflow.com/a/14105527
85 | mode = os.stat(output_path).st_mode
86 | mode |= stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
87 | os.chmod(output_path, mode)
88 |
89 |
90 | class build(orig_build):
91 | sub_commands = orig_build.sub_commands + [("fetch_binaries", None)]
92 |
93 |
94 | class install(orig_install):
95 | sub_commands = orig_install.sub_commands + [("install_terraform", None)]
96 |
97 |
98 | class fetch_binaries(Command):
99 | build_temp = None
100 |
101 | def initialize_options(self):
102 | pass
103 |
104 | def finalize_options(self):
105 | self.set_undefined_options("build", ("build_temp", "build_temp"))
106 |
107 | def run(self):
108 | # save binary to self.build_temp
109 | url, sha256 = get_download_url()
110 | archive = download(url, sha256)
111 | data = extract(url, archive)
112 |
113 | save_executable(data, self.build_temp)
114 |
115 |
116 | class install_terraform(Command):
117 | description = "install the terraform executable"
118 | outfiles = ()
119 | build_dir = install_dir = None
120 |
121 | def initialize_options(self):
122 | pass
123 |
124 | def finalize_options(self):
125 | # this initializes attributes based on other commands' attributes
126 | self.set_undefined_options("build", ("build_temp", "build_dir"))
127 | self.set_undefined_options(
128 | "install",
129 | ("install_scripts", "install_dir"),
130 | )
131 |
132 | def run(self):
133 | self.outfiles = self.copy_tree(self.build_dir, self.install_dir)
134 |
135 | def get_outputs(self):
136 | return self.outfiles
137 |
138 |
139 | command_overrides = {
140 | "install": install,
141 | "install_terraform": install_terraform,
142 | "build": build,
143 | "fetch_binaries": fetch_binaries,
144 | }
145 |
146 |
147 | try:
148 | from wheel.bdist_wheel import bdist_wheel as orig_bdist_wheel
149 |
150 | class bdist_wheel(orig_bdist_wheel):
151 | def finalize_options(self):
152 | orig_bdist_wheel.finalize_options(self)
153 | # Mark us as not a pure python package
154 | self.root_is_pure = False
155 |
156 | def get_tag(self):
157 | _, _, plat = orig_bdist_wheel.get_tag(self)
158 | # We don't contain any python source, nor any python extensions
159 | return "py2.py3", "none", plat
160 |
161 | command_overrides["bdist_wheel"] = bdist_wheel
162 | except ImportError:
163 | pass
164 |
165 | setup(version=f"{TERRAFORM_VERSION}", cmdclass=command_overrides)
166 |
--------------------------------------------------------------------------------
/terraform_py/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AleksaC/terraform-py/04cc389614dac16ca936aac92ffd7704ac794583/terraform_py/__init__.py
--------------------------------------------------------------------------------
/terraform_py/__main__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from terraform_py._main import main
4 |
5 |
6 | if __name__ == "__main__":
7 | raise SystemExit(main())
8 |
--------------------------------------------------------------------------------
/terraform_py/_main.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import argparse
4 | import os
5 | import subprocess
6 | import sys
7 |
8 | from typing import List
9 | from typing import Optional
10 | from typing import Set
11 |
12 |
13 | def get_dirs(filenames: List[str]) -> Set[str]:
14 | dirs = set(map(lambda filename: os.path.dirname(filename), filenames))
15 |
16 | if "" in dirs:
17 | dirs.remove("")
18 | dirs.add(".")
19 |
20 | return dirs
21 |
22 |
23 | def run_terraform_command(command: str, *args: str, **kwargs) -> int:
24 | global_options = kwargs.pop("global_options", [])
25 | options = kwargs.pop("options", [])
26 |
27 | res = subprocess.run(
28 | ["terraform", *global_options, command, *options, *args],
29 | stdout=subprocess.PIPE,
30 | stderr=subprocess.PIPE,
31 | text=True,
32 | **kwargs,
33 | )
34 |
35 | print(res.stdout, end="")
36 | print(res.stderr, file=sys.stderr, end="")
37 |
38 | return res.returncode
39 |
40 |
41 | def fmt(dir: str) -> int:
42 | return run_terraform_command("fmt", dir)
43 |
44 |
45 | def validate(dir: str) -> int:
46 | run_terraform_command("init", "-backend=false", cwd=dir)
47 | return run_terraform_command("validate", cwd=dir)
48 |
49 |
50 | def main(argv: Optional[List[str]] = None) -> int:
51 | parser = argparse.ArgumentParser()
52 | parser.add_argument("command", choices=["fmt", "validate"])
53 | parser.add_argument("filenames", nargs="*")
54 | args = parser.parse_args(argv)
55 |
56 | status_code = 0
57 | command = fmt if args.command == "fmt" else validate
58 | for dir in get_dirs(args.filenames):
59 | status_code |= command(dir)
60 |
61 | return status_code
62 |
--------------------------------------------------------------------------------
/testing/invalid/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/hashicorp/aws" {
5 | version = "4.3.0"
6 | constraints = "4.3.0"
7 | hashes = [
8 | "h1:+/UcPbNhOmn+DIsiGugJd3B8ruKgXfC2JxIX9PCCFJI=",
9 | "h1:+z4+nEWPJ7NGu4Zo2U/3ghgN3+M3tckgUiTZI0c8k9I=",
10 | "h1:AcT2MN3JsZ3LZCFIbogrrlhGKizAZ+uX1NRlk8eaDzo=",
11 | "h1:OePPETAA8BIGTCgGQ54F3oAZnOFH5lxyDdP0cyLXdU4=",
12 | "zh:087c67e5429f343a164221c05a83f152322f411e7394f8a39ed81a75982af1f2",
13 | "zh:2e852a1b107e5324524874e1cd98bcf3a69284b4fe04750aa373054177c54214",
14 | "zh:4b9a54b5895f945827832e6ddd16ff107301fedf47acbd83d17d4e18bbf10bb1",
15 | "zh:64dfc02bc85f5df2f51ff942fc78d72fcd0db17b0f53e1fae380e58adbd239b3",
16 | "zh:766f9aef619cfd23e924aee523791acccd30b6d8f1cc0ed1a7b5c953bf8c5392",
17 | "zh:90048d87ff3071a4356cf91916b46a7ec69ba55bcba5765b598d3fe545d4c6ca",
18 | "zh:c51f5b238af37c63e9033a12fd7fedc87c03eb966f5f5c7786eb6246e8bf3071",
19 | "zh:d0df94d3112a25de609dfb55c5e3b0d119dea519a2bdd8099e64a8d63f22b683",
20 | "zh:de166ecfeed70f570cea72ec094f00c2f997496b3226fa08518e7cd4a73884e1",
21 | "zh:e31c31d00f42ea2dbaab1ad4c245da5cfff63e28399b5a5795b5e6a826c6c8af",
22 | "zh:f93725afd8410194ede51d83505327aa1ae6a9b4280cf31db649c62c7dc203ae",
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/testing/invalid/non-existent-resource.tf:
--------------------------------------------------------------------------------
1 | resource "aws_s4_bucket" "test" {
2 | bucket = "my-tf-test-bucket"
3 | }
4 |
--------------------------------------------------------------------------------
/testing/invalid/providers.tf:
--------------------------------------------------------------------------------
1 | provider "aws" {
2 | region = "eu-central-1"
3 | }
4 |
--------------------------------------------------------------------------------
/testing/invalid/versions.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_version = ">= 0.13"
3 | required_providers {
4 | aws = {
5 | source = "hashicorp/aws"
6 | version = "4.3.0"
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/testing/malformatted/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/hashicorp/aws" {
5 | version = "4.3.0"
6 | constraints = "4.3.0"
7 | hashes = [
8 | "h1:+/UcPbNhOmn+DIsiGugJd3B8ruKgXfC2JxIX9PCCFJI=",
9 | "h1:+z4+nEWPJ7NGu4Zo2U/3ghgN3+M3tckgUiTZI0c8k9I=",
10 | "h1:AcT2MN3JsZ3LZCFIbogrrlhGKizAZ+uX1NRlk8eaDzo=",
11 | "h1:OePPETAA8BIGTCgGQ54F3oAZnOFH5lxyDdP0cyLXdU4=",
12 | "zh:087c67e5429f343a164221c05a83f152322f411e7394f8a39ed81a75982af1f2",
13 | "zh:2e852a1b107e5324524874e1cd98bcf3a69284b4fe04750aa373054177c54214",
14 | "zh:4b9a54b5895f945827832e6ddd16ff107301fedf47acbd83d17d4e18bbf10bb1",
15 | "zh:64dfc02bc85f5df2f51ff942fc78d72fcd0db17b0f53e1fae380e58adbd239b3",
16 | "zh:766f9aef619cfd23e924aee523791acccd30b6d8f1cc0ed1a7b5c953bf8c5392",
17 | "zh:90048d87ff3071a4356cf91916b46a7ec69ba55bcba5765b598d3fe545d4c6ca",
18 | "zh:c51f5b238af37c63e9033a12fd7fedc87c03eb966f5f5c7786eb6246e8bf3071",
19 | "zh:d0df94d3112a25de609dfb55c5e3b0d119dea519a2bdd8099e64a8d63f22b683",
20 | "zh:de166ecfeed70f570cea72ec094f00c2f997496b3226fa08518e7cd4a73884e1",
21 | "zh:e31c31d00f42ea2dbaab1ad4c245da5cfff63e28399b5a5795b5e6a826c6c8af",
22 | "zh:f93725afd8410194ede51d83505327aa1ae6a9b4280cf31db649c62c7dc203ae",
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/testing/malformatted/providers.tf:
--------------------------------------------------------------------------------
1 | provider "aws" {
2 | region = "eu-central-1"
3 | }
4 |
--------------------------------------------------------------------------------
/testing/malformatted/versions.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_version = ">= 0.13"
3 | required_providers {
4 | aws = {
5 | source = "hashicorp/aws"
6 | version = "4.3.0"
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/testing/nested/invalid/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/hashicorp/aws" {
5 | version = "4.3.0"
6 | constraints = "4.3.0"
7 | hashes = [
8 | "h1:+/UcPbNhOmn+DIsiGugJd3B8ruKgXfC2JxIX9PCCFJI=",
9 | "h1:+z4+nEWPJ7NGu4Zo2U/3ghgN3+M3tckgUiTZI0c8k9I=",
10 | "h1:AcT2MN3JsZ3LZCFIbogrrlhGKizAZ+uX1NRlk8eaDzo=",
11 | "h1:OePPETAA8BIGTCgGQ54F3oAZnOFH5lxyDdP0cyLXdU4=",
12 | "zh:087c67e5429f343a164221c05a83f152322f411e7394f8a39ed81a75982af1f2",
13 | "zh:2e852a1b107e5324524874e1cd98bcf3a69284b4fe04750aa373054177c54214",
14 | "zh:4b9a54b5895f945827832e6ddd16ff107301fedf47acbd83d17d4e18bbf10bb1",
15 | "zh:64dfc02bc85f5df2f51ff942fc78d72fcd0db17b0f53e1fae380e58adbd239b3",
16 | "zh:766f9aef619cfd23e924aee523791acccd30b6d8f1cc0ed1a7b5c953bf8c5392",
17 | "zh:90048d87ff3071a4356cf91916b46a7ec69ba55bcba5765b598d3fe545d4c6ca",
18 | "zh:c51f5b238af37c63e9033a12fd7fedc87c03eb966f5f5c7786eb6246e8bf3071",
19 | "zh:d0df94d3112a25de609dfb55c5e3b0d119dea519a2bdd8099e64a8d63f22b683",
20 | "zh:de166ecfeed70f570cea72ec094f00c2f997496b3226fa08518e7cd4a73884e1",
21 | "zh:e31c31d00f42ea2dbaab1ad4c245da5cfff63e28399b5a5795b5e6a826c6c8af",
22 | "zh:f93725afd8410194ede51d83505327aa1ae6a9b4280cf31db649c62c7dc203ae",
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/testing/nested/invalid/non-existent-resource.tf:
--------------------------------------------------------------------------------
1 | resource "aws_s4_bucket" "test" {
2 | bucket = "my-tf-test-bucket"
3 | }
4 |
--------------------------------------------------------------------------------
/testing/nested/invalid/providers.tf:
--------------------------------------------------------------------------------
1 | provider "aws" {
2 | region = "eu-central-1"
3 | }
4 |
--------------------------------------------------------------------------------
/testing/nested/invalid/versions.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_version = ">= 0.13"
3 | required_providers {
4 | aws = {
5 | source = "hashicorp/aws"
6 | version = "4.3.0"
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/testing/nested/malformatted/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/hashicorp/aws" {
5 | version = "4.3.0"
6 | constraints = "4.3.0"
7 | hashes = [
8 | "h1:+/UcPbNhOmn+DIsiGugJd3B8ruKgXfC2JxIX9PCCFJI=",
9 | "h1:+z4+nEWPJ7NGu4Zo2U/3ghgN3+M3tckgUiTZI0c8k9I=",
10 | "h1:AcT2MN3JsZ3LZCFIbogrrlhGKizAZ+uX1NRlk8eaDzo=",
11 | "h1:OePPETAA8BIGTCgGQ54F3oAZnOFH5lxyDdP0cyLXdU4=",
12 | "zh:087c67e5429f343a164221c05a83f152322f411e7394f8a39ed81a75982af1f2",
13 | "zh:2e852a1b107e5324524874e1cd98bcf3a69284b4fe04750aa373054177c54214",
14 | "zh:4b9a54b5895f945827832e6ddd16ff107301fedf47acbd83d17d4e18bbf10bb1",
15 | "zh:64dfc02bc85f5df2f51ff942fc78d72fcd0db17b0f53e1fae380e58adbd239b3",
16 | "zh:766f9aef619cfd23e924aee523791acccd30b6d8f1cc0ed1a7b5c953bf8c5392",
17 | "zh:90048d87ff3071a4356cf91916b46a7ec69ba55bcba5765b598d3fe545d4c6ca",
18 | "zh:c51f5b238af37c63e9033a12fd7fedc87c03eb966f5f5c7786eb6246e8bf3071",
19 | "zh:d0df94d3112a25de609dfb55c5e3b0d119dea519a2bdd8099e64a8d63f22b683",
20 | "zh:de166ecfeed70f570cea72ec094f00c2f997496b3226fa08518e7cd4a73884e1",
21 | "zh:e31c31d00f42ea2dbaab1ad4c245da5cfff63e28399b5a5795b5e6a826c6c8af",
22 | "zh:f93725afd8410194ede51d83505327aa1ae6a9b4280cf31db649c62c7dc203ae",
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/testing/nested/malformatted/providers.tf:
--------------------------------------------------------------------------------
1 | provider "aws" {
2 | region = "eu-central-1"
3 | }
4 |
--------------------------------------------------------------------------------
/testing/nested/malformatted/versions.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_version = ">= 0.13"
3 | required_providers {
4 | aws = {
5 | source = "hashicorp/aws"
6 | version = "4.3.0"
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/testing/valid/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/hashicorp/aws" {
5 | version = "4.3.0"
6 | constraints = "4.3.0"
7 | hashes = [
8 | "h1:+/UcPbNhOmn+DIsiGugJd3B8ruKgXfC2JxIX9PCCFJI=",
9 | "h1:+z4+nEWPJ7NGu4Zo2U/3ghgN3+M3tckgUiTZI0c8k9I=",
10 | "h1:AcT2MN3JsZ3LZCFIbogrrlhGKizAZ+uX1NRlk8eaDzo=",
11 | "h1:OePPETAA8BIGTCgGQ54F3oAZnOFH5lxyDdP0cyLXdU4=",
12 | "zh:087c67e5429f343a164221c05a83f152322f411e7394f8a39ed81a75982af1f2",
13 | "zh:2e852a1b107e5324524874e1cd98bcf3a69284b4fe04750aa373054177c54214",
14 | "zh:4b9a54b5895f945827832e6ddd16ff107301fedf47acbd83d17d4e18bbf10bb1",
15 | "zh:64dfc02bc85f5df2f51ff942fc78d72fcd0db17b0f53e1fae380e58adbd239b3",
16 | "zh:766f9aef619cfd23e924aee523791acccd30b6d8f1cc0ed1a7b5c953bf8c5392",
17 | "zh:90048d87ff3071a4356cf91916b46a7ec69ba55bcba5765b598d3fe545d4c6ca",
18 | "zh:c51f5b238af37c63e9033a12fd7fedc87c03eb966f5f5c7786eb6246e8bf3071",
19 | "zh:d0df94d3112a25de609dfb55c5e3b0d119dea519a2bdd8099e64a8d63f22b683",
20 | "zh:de166ecfeed70f570cea72ec094f00c2f997496b3226fa08518e7cd4a73884e1",
21 | "zh:e31c31d00f42ea2dbaab1ad4c245da5cfff63e28399b5a5795b5e6a826c6c8af",
22 | "zh:f93725afd8410194ede51d83505327aa1ae6a9b4280cf31db649c62c7dc203ae",
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/testing/valid/providers.tf:
--------------------------------------------------------------------------------
1 | provider "aws" {
2 | region = "eu-central-1"
3 | }
4 |
--------------------------------------------------------------------------------
/testing/valid/versions.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_version = ">= 0.13"
3 | required_providers {
4 | aws = {
5 | source = "hashicorp/aws"
6 | version = "4.3.0"
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/tests/test_main.py:
--------------------------------------------------------------------------------
1 | import os
2 | from glob import glob
3 |
4 | import pytest
5 |
6 | from terraform_py._main import fmt
7 | from terraform_py._main import get_dirs
8 | from terraform_py._main import main
9 | from terraform_py._main import run_terraform_command
10 | from terraform_py._main import validate
11 |
12 |
13 | @pytest.fixture
14 | def versions_tf(tmp_path):
15 | path = tmp_path / "versions.tf"
16 | file_contents = """\
17 | terraform {
18 | required_version = ">= 0.13"
19 | required_providers {
20 | aws = {
21 | source = "hashicorp/aws"
22 | version = "4.3.0"
23 | }
24 | }
25 | }
26 | """
27 | path.write_text(file_contents)
28 |
29 | return path
30 |
31 |
32 | def test_get_dirs():
33 | files = glob("testing/**/*.tf", recursive=True)
34 | dirs = get_dirs(files)
35 |
36 | expected_dirs = {
37 | os.path.join("testing", "valid"),
38 | os.path.join("testing", "invalid"),
39 | os.path.join("testing", "malformatted"),
40 | os.path.join("testing", "nested", "invalid"),
41 | os.path.join("testing", "nested", "malformatted"),
42 | }
43 |
44 | assert len(dirs) == len(expected_dirs) and all(dir in expected_dirs for dir in dirs)
45 |
46 |
47 | def test_get_dirs_root(monkeypatch):
48 | monkeypatch.chdir(os.path.join("testing", "valid"))
49 |
50 | files = glob("*.tf", recursive=True)
51 | dirs = get_dirs(files)
52 |
53 | assert dirs == {"."}
54 |
55 |
56 | def test_fmt_valid():
57 | return_code = run_terraform_command("fmt", "testing/valid", options=["-check"])
58 |
59 | assert return_code == 0
60 |
61 |
62 | def test_fmt_invalid():
63 | return_code = run_terraform_command(
64 | "fmt", "testing/malformatted", options=["-check"]
65 | )
66 |
67 | assert return_code != 0
68 |
69 |
70 | def test_fmt_invalid_with_sideefects(capsys, monkeypatch, versions_tf):
71 | dir = str(versions_tf.parent)
72 | monkeypatch.chdir(dir)
73 |
74 | assert fmt(dir) == 0
75 |
76 | out, _ = capsys.readouterr()
77 | assert out == "versions.tf\n"
78 |
79 |
80 | def test_validate_valid():
81 | return_code = validate("testing/valid")
82 |
83 | assert return_code == 0
84 |
85 |
86 | def test_validate_invalid():
87 | return_code = validate("testing/invalid")
88 |
89 | assert return_code != 0
90 |
91 |
92 | def test_fmt_invalid_nested():
93 | return_code = validate("testing/nested/invalid")
94 |
95 | assert return_code != 0
96 |
97 |
98 | def test_validate_invalid_nested():
99 | return_code = run_terraform_command(
100 | "fmt", "testing/nested/malformatted", options=["-check"]
101 | )
102 |
103 | assert return_code != 0
104 |
105 |
106 | def test_validate_cli():
107 | files = glob("testing/**/*.tf", recursive=True)
108 |
109 | assert main(["validate", *files]) != 0
110 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = py38,py39,py310,py311,py312
3 |
4 | [testenv]
5 | deps = -rrequirements-dev.txt
6 | commands =
7 | coverage erase
8 | coverage run -m pytest {posargs}
9 | coverage report
10 |
--------------------------------------------------------------------------------