├── .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 | [![Add new versions](https://github.com/AleksaC/terraform-py/actions/workflows/add-new-versions.yml/badge.svg)](https://github.com/AleksaC/terraform-py/actions/workflows/add-new-versions.yml) 4 | [![Run tests](https://github.com/AleksaC/terraform-py/actions/workflows/tests.yml/badge.svg)](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 | - Twitter followers 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 | [![Add new versions](https://github.com/AleksaC/terraform-py/actions/workflows/add-new-versions.yml/badge.svg)](https://github.com/AleksaC/terraform-py/actions/workflows/add-new-versions.yml) 4 | [![Run tests](https://github.com/AleksaC/terraform-py/actions/workflows/tests.yml/badge.svg)](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 | - Twitter followers 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 | --------------------------------------------------------------------------------