diff --git a/README.md b/README.md index 264f38a..88877fb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,10 @@ # Terrapyne Python wrapper around terraform and similar + +```python +import terrapyne +tf = terrapyne.Terraform() +tf.apply() +outputs = tf.output() +``` diff --git a/poetry.lock b/poetry.lock index b57411d..35b0901 100644 --- a/poetry.lock +++ b/poetry.lock @@ -19,6 +19,27 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, + {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + [[package]] name = "black" version = "24.8.0" @@ -63,6 +84,44 @@ d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "boto3" +version = "1.35.34" +description = "The AWS SDK for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "boto3-1.35.34-py3-none-any.whl", hash = "sha256:291e7b97a34967ed93297e6171f1bebb8529e64633dd48426760e3fdef1cdea8"}, + {file = "boto3-1.35.34.tar.gz", hash = "sha256:57e6ee8504e7929bc094bb2afc879943906064179a1e88c23b4812e2c6f61532"}, +] + +[package.dependencies] +botocore = ">=1.35.34,<1.36.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.10.0,<0.11.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.35.34" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">=3.8" +files = [ + {file = "botocore-1.35.34-py3-none-any.whl", hash = "sha256:ccb0fe397b11b81c9abc0c87029d17298e17bf658d8db5c0c5a551a12a207e7a"}, + {file = "botocore-1.35.34.tar.gz", hash = "sha256:789b6501a3bb4a9591c1fe10da200cc315c1fa5df5ada19c720d8ef06439b3e3"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} + +[package.extras] +crt = ["awscrt (==0.22.0)"] + [[package]] name = "cachetools" version = "5.5.0" @@ -343,6 +402,17 @@ files = [ {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] +[[package]] +name = "et-xmlfile" +version = "1.1.0" +description = "An implementation of lxml.xmlfile for the standard library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "et_xmlfile-1.1.0-py3-none-any.whl", hash = "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada"}, + {file = "et_xmlfile-1.1.0.tar.gz", hash = "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c"}, +] + [[package]] name = "execnet" version = "2.1.1" @@ -431,6 +501,20 @@ files = [ {file = "fpyutils-4.0.1.tar.gz", hash = "sha256:5ee8448b09863d5905ad22cf5f6c8af79d3b314617ac8fbded48eb2a414988e6"}, ] +[[package]] +name = "ftfy" +version = "6.2.3" +description = "Fixes mojibake and other problems with Unicode, after the fact" +optional = false +python-versions = "<4,>=3.8.1" +files = [ + {file = "ftfy-6.2.3-py3-none-any.whl", hash = "sha256:f15761b023f3061a66207d33f0c0149ad40a8319fd16da91796363e2c049fdf8"}, + {file = "ftfy-6.2.3.tar.gz", hash = "sha256:79b505988f29d577a58a9069afe75553a02a46e42de6091c0660cdc67812badc"}, +] + +[package.dependencies] +wcwidth = ">=0.2.12,<0.3.0" + [[package]] name = "furl" version = "2.1.3" @@ -502,6 +586,17 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "jmespath" +version = "1.0.1" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.7" +files = [ + {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, + {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, +] + [[package]] name = "jsonschema" version = "4.23.0" @@ -537,6 +632,16 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "mailchecker" +version = "6.0.11" +description = "Cross-language email validation. Backed by a database of thousands throwable email providers." +optional = false +python-versions = "*" +files = [ + {file = "mailchecker-6.0.11.tar.gz", hash = "sha256:bf2490e26a3a9ac385760838e3fcc7321a6be1980fdad5746d07b63a06479aa2"}, +] + [[package]] name = "markupsafe" version = "2.1.5" @@ -669,6 +774,20 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +description = "A Python library to read/write Excel 2010 xlsx/xlsm files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2"}, + {file = "openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050"}, +] + +[package.dependencies] +et-xmlfile = "*" + [[package]] name = "orderedmultidict" version = "1.0.1" @@ -705,6 +824,17 @@ files = [ {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] +[[package]] +name = "phonenumbers" +version = "8.13.47" +description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers." +optional = false +python-versions = "*" +files = [ + {file = "phonenumbers-8.13.47-py2.py3-none-any.whl", hash = "sha256:5d3c0142ef7055ca5551884352e3b6b93bfe002a0bc95b8eaba39b0e2184541b"}, + {file = "phonenumbers-8.13.47.tar.gz", hash = "sha256:53c5e7c6d431cafe4efdd44956078404ae9bc8b0eacc47be3105d3ccc88aaffa"}, +] + [[package]] name = "platformdirs" version = "4.3.6" @@ -898,6 +1028,58 @@ psutil = ["psutil (>=3.0)"] setproctitle = ["setproctitle"] testing = ["filelock"] +[[package]] +name = "python-benedict" +version = "0.33.2" +description = "python-benedict is a dict subclass with keylist/keypath/keyattr support, normalized I/O operations (base64, csv, ini, json, pickle, plist, query-string, toml, xls, xml, yaml) and many utilities... for humans, obviously." +optional = false +python-versions = "*" +files = [ + {file = "python-benedict-0.33.2.tar.gz", hash = "sha256:662de43bffb4e127da2056447f8ddd7f6f5c89b72dd66d289cf9abd1cc2720c8"}, + {file = "python_benedict-0.33.2-py3-none-any.whl", hash = "sha256:50a69b601b34d4ad7b67fe94e3266ec05046bc547a4132fe43fd8fbd41aeefaa"}, +] + +[package.dependencies] +beautifulsoup4 = {version = ">=4.12.0,<5.0.0", optional = true, markers = "extra == \"html\""} +boto3 = {version = ">=1.24.89,<2.0.0", optional = true, markers = "extra == \"s3\""} +ftfy = {version = ">=6.0.0,<7.0.0", optional = true, markers = "extra == \"parse\""} +mailchecker = {version = ">=4.1.0,<7.0.0", optional = true, markers = "extra == \"parse\""} +openpyxl = {version = ">=3.0.0,<4.0.0", optional = true, markers = "extra == \"xls\""} +phonenumbers = {version = ">=8.12.0,<9.0.0", optional = true, markers = "extra == \"parse\""} +python-dateutil = {version = ">=2.8.0,<3.0.0", optional = true, markers = "extra == \"parse\""} +python-fsutil = ">=0.9.3,<1.0.0" +python-slugify = ">=7.0.0,<9.0.0" +pyyaml = {version = ">=6.0,<7.0", optional = true, markers = "extra == \"yaml\""} +requests = ">=2.26.0,<3.0.0" +toml = {version = ">=0.10.2,<1.0.0", optional = true, markers = "extra == \"toml\""} +xlrd = {version = ">=2.0.0,<3.0.0", optional = true, markers = "extra == \"xls\""} +xmltodict = {version = ">=0.12.0,<1.0.0", optional = true, markers = "extra == \"xml\""} + +[package.extras] +all = ["python-benedict[io,parse,s3]"] +html = ["beautifulsoup4 (>=4.12.0,<5.0.0)", "python-benedict[xml]"] +io = ["python-benedict[html,toml,xls,xml,yaml]"] +parse = ["ftfy (>=6.0.0,<7.0.0)", "mailchecker (>=4.1.0,<7.0.0)", "phonenumbers (>=8.12.0,<9.0.0)", "python-dateutil (>=2.8.0,<3.0.0)"] +s3 = ["boto3 (>=1.24.89,<2.0.0)"] +toml = ["toml (>=0.10.2,<1.0.0)"] +xls = ["openpyxl (>=3.0.0,<4.0.0)", "xlrd (>=2.0.0,<3.0.0)"] +xml = ["xmltodict (>=0.12.0,<1.0.0)"] +yaml = ["pyyaml (>=6.0,<7.0)"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "python-decouple" version = "3.8" @@ -909,6 +1091,34 @@ files = [ {file = "python_decouple-3.8-py3-none-any.whl", hash = "sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66"}, ] +[[package]] +name = "python-fsutil" +version = "0.14.1" +description = "high-level file-system operations for lazy devs." +optional = false +python-versions = "*" +files = [ + {file = "python-fsutil-0.14.1.tar.gz", hash = "sha256:8fb204fa8059f37bdeee8a1dc0fff010170202ea47c4225ee71bb3c26f3997be"}, + {file = "python_fsutil-0.14.1-py3-none-any.whl", hash = "sha256:0d45e623f0f4403f674bdd8ae7aa7d24a4b3132ea45c65416bd2865e6b20b035"}, +] + +[[package]] +name = "python-slugify" +version = "8.0.4" +description = "A Python slugify application that also handles Unicode" +optional = false +python-versions = ">=3.7" +files = [ + {file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"}, + {file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"}, +] + +[package.dependencies] +text-unidecode = ">=1.3" + +[package.extras] +unidecode = ["Unidecode (>=1.1.1)"] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1136,6 +1346,23 @@ files = [ {file = "rpds_py-0.20.0.tar.gz", hash = "sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121"}, ] +[[package]] +name = "s3transfer" +version = "0.10.2" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">=3.8" +files = [ + {file = "s3transfer-0.10.2-py3-none-any.whl", hash = "sha256:eca1c20de70a39daee580aef4986996620f365c4e0fda6a86100231d62f1bf69"}, + {file = "s3transfer-0.10.2.tar.gz", hash = "sha256:0711534e9356d3cc692fdde846b4a1e4b0cb6519971860796e6bc4c7aea00ef6"}, +] + +[package.dependencies] +botocore = ">=1.33.2,<2.0a.0" + +[package.extras] +crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] + [[package]] name = "six" version = "1.16.0" @@ -1158,6 +1385,28 @@ files = [ {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, ] +[[package]] +name = "soupsieve" +version = "2.6" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.8" +files = [ + {file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"}, + {file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"}, +] + +[[package]] +name = "text-unidecode" +version = "1.3" +description = "The most basic Text::Unidecode port" +optional = false +python-versions = "*" +files = [ + {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, + {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, +] + [[package]] name = "toml" version = "0.10.2" @@ -1232,6 +1481,33 @@ platformdirs = ">=3.9.1,<5" docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + +[[package]] +name = "xlrd" +version = "2.0.1" +description = "Library for developers to extract data from Microsoft Excel (tm) .xls spreadsheet files" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +files = [ + {file = "xlrd-2.0.1-py2.py3-none-any.whl", hash = "sha256:6a33ee89877bd9abc1158129f6e94be74e2679636b8a205b43b85206c3f0bbdd"}, + {file = "xlrd-2.0.1.tar.gz", hash = "sha256:f72f148f54442c6b056bf931dbc34f986fd0c3b0b6b5a58d013c9aef274d0c88"}, +] + +[package.extras] +build = ["twine", "wheel"] +docs = ["sphinx"] +test = ["pytest", "pytest-cov"] + [[package]] name = "xmltodict" version = "0.13.0" @@ -1246,4 +1522,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11.1,<4.0" -content-hash = "cbe898185a47d8da4623f3b5be3801c5b2cc7fd812ee8409f1903a2d0a0e054d" +content-hash = "7ee22dc87e66812da82061c44647f257fa6791ce781c8dcee80a7053b05f1f27" diff --git a/pyproject.toml b/pyproject.toml index 81d5667..58fa500 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ colorlog = ">=5.0.1,<7.0.0" jinja2 = "^3.0.1" pretty_traceback = "*" python-decouple = "^3.8" +python-benedict = {extras = ["all"], version = "^0.33.2"} [tool.poetry.group.test.dependencies] black = "*" diff --git a/src/terrapyne/terrapyne.py b/src/terrapyne/terrapyne.py index 325e8e9..5d4aa45 100644 --- a/src/terrapyne/terrapyne.py +++ b/src/terrapyne/terrapyne.py @@ -2,27 +2,40 @@ # -*- coding: utf-8 -*- -from textwrap import dedent from functools import cached_property from pathlib import Path from shutil import which from subprocess import Popen, PIPE -from typing import Tuple, Any +from textwrap import dedent +from typing import Tuple, Any, TypeAlias, Union +import json import logging as log +from benedict import benedict import os import re -import json class TerraformException(Exception): pass +NullableDict: TypeAlias = Union[dict[Any, Any], None] +NullableList: TypeAlias = Union[list, None] +NullableStr: TypeAlias = Union[str, None] + + class Terraform: - def __init__(self, required_version=None, environment_variables={}): + def __init__( + self, + required_version: NullableStr = None, + tfvars: NullableDict = None, + envvars: NullableDict = None, + ): self.executable = which("terraform") or next(Path("~/.local/bin").expanduser().glob("terraform")) - self.environment_variables = { + self.tfvars = self.benedict(tfvars or {}) + + self.envvars = { "TF_IN_AUTOMATION": "1", "TF_INPUT": "0", "NO_COLOR": "1", @@ -32,13 +45,13 @@ def __init__(self, required_version=None, environment_variables={}): "TF_CLI_ARGS_plan": "-input=false -no-color", "TF_CLI_ARGS_apply": "-input=false -no-color -auto-approve", "TF_CLI_ARGS_destroy": "-input=false -no-color -auto-approve", - } | self.generate_environment_variables(environment_variables) + } | self.generate_envvars(envvars or {}) + # "TF_LOG": "trace", # "TF_LOG_PATH": "./terraform.log", # "TF_PLUGIN_CACHE_DIR": "./tf-cache", self.tfplan_name = "current.tfplan" # Name by project - log.debug(f"terraform executable: {self.executable}") if required_version is not None: if self.version != required_version: raise TerraformException(f"required version of terraform check failed: {self.version} != {required_version}") @@ -49,59 +62,73 @@ def version(self): cmd=["version"], ) result = stdout.replace("\n", " ").replace("Terraform ", "").replace(" on ", " ") - log.debug(f"version string: {result}") version, self.platform = result.split(" ")[0:2] - log.debug(f"version string: {version}, platform: {self.platform}") return re.sub("^v", "", version) - def init(self, args=None) -> Tuple[str, str, int]: + def init(self, args: NullableList = None) -> Tuple[str, str, int]: return self.exec( cmd=["init", *(args or [])], ) - def validate(self, args=None) -> Tuple[str, str, int]: + def validate(self, args: NullableList = None) -> Tuple[str, str, int]: return self.exec( cmd=["validate", *(args or [])], ignore_exit_code=True, ) - def plan(self, args=None, environment_variables={}) -> Tuple[str, str, int]: + def plan( + self, + args: NullableList = None, + tfvars: NullableDict = None, + envvars: NullableDict = None, + ) -> Tuple[str, str, int]: return self.exec( cmd=["plan", *(args or [])], - environment_variables=self.generate_environment_variables(environment_variables), + tfvars=self.benedict(tfvars or {}), + envvars=self.generate_envvars(envvars or {}), ) - def apply(self, args=None, environment_variables={}) -> Tuple[str, str, int]: + def apply( + self, + args: NullableList = None, + tfvars: NullableDict = None, + envvars: NullableDict = None, + ) -> Tuple[str, str, int]: if not Path(self.tfplan_name).exists(): self.init(args=["-backend=false"]) self.plan( args=[f"-out={self.tfplan_name}"], - environment_variables=self.generate_environment_variables(environment_variables), + envvars=self.generate_envvars(envvars or {}), ) return self.exec( cmd=["apply", *(args or [])], - environment_variables=self.generate_environment_variables(environment_variables), + tfvars=self.benedict(tfvars or {}), + envvars=self.generate_envvars(envvars or {}), ) - def output(self, args=None) -> Tuple[Any, str, int]: - o, e, c = self.exec( + def output(self, args: NullableList = None) -> benedict: + o, _, _ = self.exec( cmd=["output", *(args or ["-json"])], ) - return json.loads(o), e, c + return benedict(json.loads(o), keypath_separator="¬") - def state(self, args=None) -> Tuple[str, str, int]: + def state(self, args: NullableList = None) -> Tuple[str, str, int]: return self.exec( cmd=["state", *(args or [""])], ) - def dump(self, args=None) -> Tuple[dict[Any, Any], str, int]: + def dump(self, args: NullableList = None) -> Tuple[dict[Any, Any], str, int]: o, e, c = self.exec( cmd=["state", "pull", *(args or [""])], ) return json.loads(o), e, c - def destroy(self, args=None) -> Tuple[str, str, int]: + def tfstate(self) -> benedict: + o, _, _ = self.dump() + return benedict(o, keypath_separator="¬") + + def destroy(self, args: NullableList = None) -> Tuple[str, str, int]: return self.exec(cmd=["destroy", *(args or [])]) def fmt(self) -> Tuple[str, str, int]: @@ -109,15 +136,13 @@ def fmt(self) -> Tuple[str, str, int]: cmd=["fmt", "-recursive"], ) - def get_resources(self) -> dict[Any, Any]: - o, _, _ = self.dump() - return o["resources"] + def get_resources(self) -> benedict: + return self.tfstate().resources - def get_outputs(self) -> dict[Any, Any]: - o, _, _ = self.dump() - return o["outputs"] + def get_outputs(self) -> benedict: + return self.tfstate().outputs - def generate_environment_variables(self, in_vars) -> dict[str, str]: + def generate_envvars(self, in_vars) -> dict[str, str]: updated = {} for key in in_vars: newkey = key @@ -126,6 +151,9 @@ def generate_environment_variables(self, in_vars) -> dict[str, str]: updated[f"TF_VAR_{newkey}"] = in_vars[key] return updated + def benedict(self, d: dict) -> benedict: + return benedict(d, keypath_separator="¬") + def make_layout(self) -> None: for tf_file in [ "main.tf", @@ -164,11 +192,16 @@ def exec( input="", expect_exit_code=0, ignore_exit_code=False, - environment_variables={}, + envvars: NullableDict = None, + tfvars: NullableDict = None, ) -> Tuple[str, str, int]: cmd.insert(0, self.executable) log.debug(f"terraform.exec({cmd}) with {self.executable}") + tfvars = self.benedict(self.tfvars | (tfvars or {})) + with open("terrapyne.auto.tfvars.json", "w") as f: + f.write(json.dumps(tfvars)) + process_env_vars = {} for key in os.environ: if key.startswith("TF_VAR_"): @@ -179,7 +212,7 @@ def exec( stdout=PIPE, stdin=PIPE, stderr=PIPE, - env=(self.environment_variables | process_env_vars | environment_variables), + env=self.benedict(self.envvars | process_env_vars | (envvars or {})), ) stdout, stderr = p.communicate(input=input.encode()) diff --git a/tests/test_version.py b/tests/test_version.py index addecf0..961c20a 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -5,16 +5,13 @@ """ """ from pathlib import Path -import shutil import sys import os import tempfile -import platform -import json from decouple import config from unittest import mock -sys.path.append(Path(__file__ + "/../src").resolve()) +sys.path.append(Path(f"{__file__}/../src").resolve()) import terrapyne import terrapyne.logging @@ -28,53 +25,44 @@ class TestImport: - def test_terrapyne_import(self): + def test_terrapyne_import(self) -> None: with terrapyne.logging.cli_log_config(verbose=VERBOSITY, logger=log.root): assert terraform assert terraform.version assert terraform.executable - def test_terrapyne_required_version(self, tf_required_version): + def test_terrapyne_required_version(self, tf_required_version) -> None: with terrapyne.logging.cli_log_config(verbose=VERBOSITY, logger=log.root): terraform = terrapyne.Terraform(required_version=tf_required_version) assert terraform.version assert len(terraform.platform.split("_")) == 2 - def test_terrapyne_blank_layout(self): + def test_terrapyne_blank_layout(self) -> None: with terrapyne.logging.cli_log_config(verbose=VERBOSITY, logger=log.root): with tempfile.TemporaryDirectory() as tmpdir: os.chdir(tmpdir) terraform.make_layout() - # Path(terraform.environment_variables.get("TF_PLUGIN_CACHE_DIR", "tf-cache")).mkdir() - - shutil.copy("terraform.tf", "/tmp/terraform-1.tf") - - _fmt_out = terraform.fmt() - - shutil.copy("terraform.tf", "/tmp/terraform-2.tf") - - _init_out = terraform.init() - - _validate_out = terraform.validate() - - _plan_out = terraform.plan() + # Path(terraform.envvars.get("TF_PLUGIN_CACHE_DIR", "tf-cache")).mkdir() + _ = terraform.fmt() + _ = terraform.init() + _ = terraform.validate() + _ = terraform.plan() _apply_out = terraform.apply() - - _destroy_out = terraform.destroy() + _ = terraform.destroy() assert "0 added" in _apply_out[0] assert "0 changed" in _apply_out[0] assert "0 destroyed" in _apply_out[0] - def test_terrapyne_minimal_layout(self): + def test_terrapyne_minimal_layout(self) -> None: with terrapyne.logging.cli_log_config(verbose=VERBOSITY, logger=log.root): with tempfile.TemporaryDirectory() as tmpdir: os.chdir(tmpdir) terraform.make_layout() - # Path(terraform.environment_variables.get("TF_PLUGIN_CACHE_DIR", "tf-cache")).mkdir() + # Path(terraform.envvars.get("TF_PLUGIN_CACHE_DIR", "tf-cache")).mkdir() - _init_out = terraform.init() + _ = terraform.init() with open("outputs.tf", "a") as f: f.write( @@ -102,14 +90,16 @@ def test_terrapyne_minimal_layout(self): assert "0 destroyed" in _apply_out[0] _output_out = terraform.output() - assert _output_out[0]["foo"]["value"] == "bar" + assert _output_out.foo.value == "bar" _outputs_out = terraform.get_outputs() - assert _outputs_out["example"]["value"]["content"] == "foo!" + # assert _outputs_out["example"]["value"]["content"] == "foo!" + assert _outputs_out + assert _outputs_out.example.value.content == "foo!" _resources_out = terraform.get_resources() - assert _resources_out[0]["instances"][0]["attributes"]["content"] == "foo!" - assert _resources_out[0]["instances"][0]["attributes"]["filename"] == "./foo.bar" + assert _resources_out[0].instances[0].attributes.content == "foo!" + assert _resources_out[0].instances[0].attributes.filename == "./foo.bar" Path("./foo.bar").unlink() _apply_out = terraform.apply() @@ -119,10 +109,10 @@ def test_terrapyne_minimal_layout(self): _destroy_out = terraform.destroy() assert "1 destroyed" in _destroy_out[0] - def test_terrapyne_env_vars(self, capsys): + def test_terrapyne_env_vars(self) -> None: with terrapyne.logging.cli_log_config(verbose=VERBOSITY, logger=log.root): envvars = {"TF_LOG": "trace", "TF_LOG_PATH": "tf-log.log", "foo": "nbar"} - terraform = terrapyne.Terraform(environment_variables=envvars) + terraform = terrapyne.Terraform(envvars=envvars) with tempfile.TemporaryDirectory() as tmpdir: os.chdir(tmpdir) terraform.make_layout() @@ -130,35 +120,69 @@ def test_terrapyne_env_vars(self, capsys): with open("outputs.tf", "a") as f: f.write( """ - variable "foo" { - type = string - } - output "foo" { - value = var.foo - } + variable "foo" { type = string } + output "foo" { value = var.foo } """ ) # default env vars - _apply_out = terraform.apply() + _ = terraform.apply() _output_out = terraform.output() - assert _output_out[0]["foo"]["value"] == "nbar" + assert _output_out.foo.value == envvars["foo"] # per apply env vars - _apply_out = terraform.apply(environment_variables={"foo": "env_bar"}) + localvars = {"foo": "env_bar"} + _ = terraform.apply(envvars=localvars) _output_out = terraform.output() - assert _output_out[0]["foo"]["value"] == "env_bar" + assert _output_out.foo.value == localvars["foo"] # external env vars - with mock.patch.dict('os.environ', {'TF_VAR_foo': 'external_bar'}, clear=True): - _apply_out = terraform.apply() + localvars = {"TF_VAR_foo": "external_bar"} + with mock.patch.dict("os.environ", localvars, clear=True): + _ = terraform.apply() _output_out = terraform.output() - assert _output_out[0]["foo"]["value"] == "external_bar" + assert _output_out.foo.value == localvars["TF_VAR_foo"] - # different env vars per apply - _apply_out = terraform.apply() + # different env vars per apply for the same workspace + # env vars must not be persisted across runs + _ = terraform.apply() + _output_out = terraform.output() + assert _output_out.foo.value == envvars["foo"] + + def test_terrapyne_tf_tfvars(self) -> None: + with terrapyne.logging.cli_log_config(verbose=VERBOSITY, logger=log.root): + tfvars = { + "foo": "tfvars_bar", + "bar": True, + "baz": [1, 2], + "moo": {"foo": "bar", "baz": "moo"}, + } + terraform = terrapyne.Terraform(tfvars=tfvars) + with tempfile.TemporaryDirectory() as tmpdir: + os.chdir(tmpdir) + + with open("outputs.tf", "a") as f: + f.write( + """ + variable "foo" { } + output "foo" { value = var.foo } + variable "bar" { } + output "bar" { value = var.bar } + variable "baz" { } + output "baz" { value = var.baz } + variable "moo" { } + output "moo" { value = var.moo } + """ + ) + + # round-trip tests + _ = terraform.apply() _output_out = terraform.output() - assert _output_out[0]["foo"]["value"] == "nbar" + assert _output_out.foo.value == tfvars["foo"] + assert _output_out.bar.value is tfvars["bar"] + assert sorted(_output_out.baz.value) == sorted(tfvars["baz"]) + assert _output_out.moo.value.foo == tfvars["moo"]["foo"] + assert _output_out.moo.value.baz == tfvars["moo"]["baz"] if __name__ == "__main__":