From 1cf91a5b567b398a1dae80ad37233d1c94217b0d Mon Sep 17 00:00:00 2001 From: Richard Maynard Date: Tue, 18 Jun 2024 02:28:44 -0500 Subject: [PATCH] Move the root options to a Pydantic Model The long term is goal is to move all CLI configurations to models. Currently attributes are slapped onto one big inherited object and make it difficult to determine where something is coming from, and there is significant coupling. Moving the options into a base model allows for all validation and conditional logic to be handled within the model, and then the model can be passed to places where the configuration may be needed. This will lessen the overuse of passing around big sets of kwargs and taking out what is needed, and will ensure that the CLI options are seamlessly available in all the different areas they are needed. This starts with just the RootCommand, which takes the "global" or top level options. --- .isort.cfg | 2 +- tests/backends/test_gcs.py | 20 +- tests/commands/test_root.py | 65 +++-- tests/conftest.py | 319 ++++++++++++--------- tests/providers/test_google.py | 8 +- tests/providers/test_google_beta.py | 8 +- tests/test_cli.py | 55 ---- tests/types/test_cli_options.py | 70 +++++ tests/util/test_util_cli.py | 79 +++++ tests/util/test_util_terraform.py | 8 +- tfworker/cli.py | 170 +---------- tfworker/commands/base.py | 3 +- tfworker/commands/root.py | 29 +- tfworker/definitions.py | 4 +- tfworker/providers/providers_collection.py | 1 - tfworker/types/__init__.py | 1 + tfworker/types/cli_options.py | 163 +++++++++++ tfworker/util/cli.py | 59 ++++ tfworker/util/terraform.py | 2 + tfworker/util/terraform_helpers.py | 3 - 20 files changed, 646 insertions(+), 423 deletions(-) create mode 100644 tests/types/test_cli_options.py create mode 100644 tests/util/test_util_cli.py create mode 100644 tfworker/types/cli_options.py create mode 100644 tfworker/util/cli.py diff --git a/.isort.cfg b/.isort.cfg index 4541644..2fbb95e 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,3 +1,3 @@ [settings] -known_third_party = atlassian,boto3,botocore,click,deepdiff,google,hcl2,jinja2,mergedeep,moto,pydantic,pytest,yaml +known_third_party = atlassian,boto3,botocore,click,deepdiff,google,hcl2,jinja2,lark,mergedeep,moto,pydantic,pytest,yaml profile = black diff --git a/tests/backends/test_gcs.py b/tests/backends/test_gcs.py index 994d3ac..1aa5df1 100644 --- a/tests/backends/test_gcs.py +++ b/tests/backends/test_gcs.py @@ -171,25 +171,25 @@ def test_parse_gcs_items(self, gbasec, prefix, inval, outval, expected_raise): assert gbasec.backend._parse_gcs_items(inval) == outval -def test_google_hcl(gbasec): +def test_google_hcl(gbasec, gcp_creds_file): render = gbasec.backend.hcl("test") - expected_render = """ backend "gcs" { + expected_render = f""" backend "gcs" {{ bucket = "test_gcp_bucket" prefix = "terraform/test-0002/test" - credentials = "/home/test/test-creds.json" - }""" + credentials = "{gcp_creds_file}" + }}""" assert render == expected_render -def test_google_data_hcl(gbasec): - expected_render = """data "terraform_remote_state" "test" { +def test_google_data_hcl(gbasec, gcp_creds_file): + expected_render = f"""data "terraform_remote_state" "test" {{ backend = "gcs" - config = { + config = {{ bucket = "test_gcp_bucket" prefix = "terraform/test-0002/test" - credentials = "/home/test/test-creds.json" - } -}""" + credentials = "{gcp_creds_file}" + }} +}}""" render = [] render.append(gbasec.backend.data_hcl(["test", "test"])) render.append(gbasec.backend.data_hcl(["test"])) diff --git a/tests/commands/test_root.py b/tests/commands/test_root.py index 3387821..a9db5a4 100644 --- a/tests/commands/test_root.py +++ b/tests/commands/test_root.py @@ -16,12 +16,15 @@ import os import platform from tempfile import TemporaryDirectory +from unittest.mock import patch import pytest from deepdiff import DeepDiff import tfworker.commands.root from tfworker.commands.root import ordered_config_load +from tfworker.constants import DEFAULT_CONFIG +from tfworker.types import CLIOptionsRoot class TestMain: @@ -36,26 +39,24 @@ def test_rc_add_args(self, rootc): assert rc.args.a == 1 assert rc.args.b == "two" - def test_rc_init(self, rootc): - rc = tfworker.commands.root.RootCommand(args={"a": 1, "b": "two"}) - assert rc.args.a == 1 - assert rc.args.b == "two" - def test_rc_init_clean(self, rootc): # by default clean should be true - rc = tfworker.commands.root.RootCommand() + rc = tfworker.commands.root.RootCommand(CLIOptionsRoot()) assert rc.clean is True # if working_dir is passed, clean should be false - rc = tfworker.commands.root.RootCommand(args={"working_dir": "/tmp"}) + random_working_dir = TemporaryDirectory() + rc = tfworker.commands.root.RootCommand( + CLIOptionsRoot(working_dir=random_working_dir.name) + ) assert rc.clean is False if platform.system() == "Darwin": - assert str(rc.temp_dir) == "/private/tmp" + assert str(rc.temp_dir) == f"/private{random_working_dir.name}" else: - assert str(rc.temp_dir) == "/tmp" + assert str(rc.temp_dir) == random_working_dir.name # if clean is passed, it should be set to the value passed - rc = tfworker.commands.root.RootCommand(args={"clean": False}) + rc = tfworker.commands.root.RootCommand(CLIOptionsRoot(clean=False)) assert rc.temp_dir is not None assert rc.clean is False @@ -64,7 +65,7 @@ def test_rc_init_clean(self, rootc): tmpdir = TemporaryDirectory() assert os.path.exists(tmpdir.name) is True rc = tfworker.commands.root.RootCommand( - args={"clean": True, "working_dir": tmpdir.name} + CLIOptionsRoot(clean=True, working_dir=tmpdir.name) ) assert rc.clean is True if platform.system() == "Darwin": @@ -94,12 +95,18 @@ def test_config_loader(self, rootc, capfd): for k, v in expected_tf_vars.items(): assert terraform_config["terraform_vars"][k] == v - # a root command with no config should return None - emptyrc = tfworker.commands.root.RootCommand() - assert emptyrc.load_config() is None + # a root command with no config should attempt to load the default, but fail, and exit + with patch("os.path.exists") as mock_exists: + mock_exists.return_value = False + with pytest.raises(SystemExit) as e: + emptyrc = tfworker.commands.root.RootCommand(CLIOptionsRoot()) + assert emptyrc.load_config() is None + mock_exists.assert_called_with(DEFAULT_CONFIG) # an invalid path should raise an error - invalidrc = tfworker.commands.root.RootCommand({"config_file": "/tmp/invalid"}) + invalidrc = tfworker.commands.root.RootCommand( + CLIOptionsRoot(config_file="/tmp/invalid") + ) with pytest.raises(SystemExit) as e: invalidrc.load_config() assert e.value.code == 1 @@ -108,14 +115,16 @@ def test_config_loader(self, rootc, capfd): # a j2 template with invalid substitutions should raise an error invalidrc = tfworker.commands.root.RootCommand( - { - "config_file": os.path.join( - os.path.dirname(__file__), - "..", - "fixtures", - "test_config_invalid_j2.yaml", - ) - } + CLIOptionsRoot( + **{ + "config_file": os.path.join( + os.path.dirname(__file__), + "..", + "fixtures", + "test_config_invalid_j2.yaml", + ) + } + ) ) with pytest.raises(SystemExit) as e: invalidrc.load_config() @@ -123,11 +132,11 @@ def test_config_loader(self, rootc, capfd): out, err = capfd.readouterr() assert "invalid template" in out - def test_pullup_keys_edge(self): - rc = tfworker.commands.root.RootCommand() - assert rc.load_config() is None - assert rc._pullup_keys() is None - assert rc.providers_odict is None + # def test_pullup_keys_edge(self): + # rc = tfworker.commands.root.RootCommand() + # assert rc.load_config() is None + # assert rc._pullup_keys() is None + # assert rc.providers_odict is None def test_get_config_var_dict(self): config_vars = ["foo=bar", "this=that", "one=two"] diff --git a/tests/conftest.py b/tests/conftest.py index f390aba..d07ec51 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,7 @@ import tfworker.commands.base import tfworker.commands.root import tfworker.providers +import tfworker.types as tf_types @pytest.fixture @@ -42,6 +43,22 @@ def aws_secret_access_key(): ) +@pytest.fixture() +def gcp_creds_file(tmp_path): + creds_file = tmp_path / "creds.json" + creds_file.write_text( + """ + { + "type": "service_account", + "project_id": "test_project", + "private_key_id": "test_key_id", + "private_key": "test_key", + "client_email": " + }""" + ) + return creds_file + + @pytest.fixture def aws_role_arn(aws_account_id, aws_role_name): return f"arn:aws:iam:{aws_account_id}:role/{aws_role_name}" @@ -108,195 +125,217 @@ def backend_session(self): @pytest.fixture() -def grootc(): +def grootc(gcp_creds_file): result = tfworker.commands.root.RootCommand( - args={ - "backend": "gcs", - "backend_region": "us-central1", - "backend_bucket": "test_gcp_bucket", - "backend_prefix": "terraform/test-0002", - "backend_use_all_remotes": False, - "config_file": os.path.join( - os.path.dirname(__file__), "fixtures", "gcp_test_config.yaml" - ), - "gcp_creds_path": "/home/test/test-creds.json", - "gcp_project": "test_project", - "gcp_region": "us-west-2b", - "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), - "create_backend_bucket": True, - } + tf_types.CLIOptionsRoot( + **{ + "backend": "gcs", + "backend_region": "us-central1", + "backend_bucket": "test_gcp_bucket", + "backend_prefix": "terraform/test-0002", + "backend_use_all_remotes": False, + "config_file": os.path.join( + os.path.dirname(__file__), "fixtures", "gcp_test_config.yaml" + ), + "gcp_creds_path": str(gcp_creds_file), + "gcp_project": "test_project", + "gcp_region": "us-west-2b", + "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), + "create_backend_bucket": True, + } + ) ) return result @pytest.fixture() -def grootc_no_create_backend_bucket(): +def grootc_no_create_backend_bucket(gcp_creds_file): result = tfworker.commands.root.RootCommand( - args={ - "backend": "gcs", - "backend_region": "us-central1", - "backend_bucket": "test_gcp_bucket", - "backend_prefix": "terraform/test-0002", - "backend_use_all_remotes": False, - "config_file": os.path.join( - os.path.dirname(__file__), "fixtures", "gcp_test_config.yaml" - ), - "gcp_creds_path": "/home/test/test-creds.json", - "gcp_project": "test_project", - "gcp_region": "us-west-2b", - "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), - "create_backend_bucket": False, - } + tf_types.CLIOptionsRoot( + **{ + "backend": "gcs", + "backend_region": "us-central1", + "backend_bucket": "test_gcp_bucket", + "backend_prefix": "terraform/test-0002", + "backend_use_all_remotes": False, + "config_file": os.path.join( + os.path.dirname(__file__), "fixtures", "gcp_test_config.yaml" + ), + "gcp_creds_path": str(gcp_creds_file), + "gcp_project": "test_project", + "gcp_region": "us-west-2b", + "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), + "create_backend_bucket": False, + } + ) ) return result @pytest.fixture(scope="function") @mock.patch("tfworker.authenticators.aws.AWSAuthenticator", new=MockAWSAuth) -def rootc(s3_client, dynamodb_client, sts_client, create_backend_bucket=True): +def rootc( + s3_client, dynamodb_client, sts_client, gcp_creds_file, create_backend_bucket=True +): result = tfworker.commands.root.RootCommand( - args={ - "aws_access_key_id": "1234567890", - "aws_secret_access_key": "1234567890", - "aws_region": "us-west-2", - "backend": "s3", - "backend_region": "us-west-2", - "backend_bucket": "test_bucket", - "backend_prefix": "terraform/test-0001", - "backend_use_all_remotes": False, - "config_file": os.path.join( - os.path.dirname(__file__), "fixtures", "test_config.yaml" - ), - "gcp_creds_path": "/home/test/test-creds.json", - "gcp_project": "test_project", - "gcp_region": "us-west-2b", - "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), - "create_backend_bucket": create_backend_bucket, - } + tf_types.CLIOptionsRoot( + **{ + "aws_access_key_id": "1234567890", + "aws_secret_access_key": "1234567890", + "aws_region": "us-west-2", + "backend": "s3", + "backend_region": "us-west-2", + "backend_bucket": "test_bucket", + "backend_prefix": "terraform/test-0001", + "backend_use_all_remotes": False, + "config_file": os.path.join( + os.path.dirname(__file__), "fixtures", "test_config.yaml" + ), + "gcp_creds_path": str(gcp_creds_file), + "gcp_project": "test_project", + "gcp_region": "us-west-2b", + "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), + "create_backend_bucket": create_backend_bucket, + } + ) ) return result @pytest.fixture(scope="function") @mock.patch("tfworker.authenticators.aws.AWSAuthenticator", new=MockAWSAuth) -def rootc_no_create_backend_bucket(s3_client, dynamodb_client, sts_client): +def rootc_no_create_backend_bucket( + s3_client, dynamodb_client, gcp_creds_file, sts_client +): result = tfworker.commands.root.RootCommand( - args={ - "aws_access_key_id": "1234567890", - "aws_secret_access_key": "1234567890", - "aws_region": "us-west-2", - "backend": "s3", - "backend_region": "us-west-2", - "backend_bucket": "test_bucket", - "backend_prefix": "terraform/test-0001", - "backend_use_all_remotes": False, - "config_file": os.path.join( - os.path.dirname(__file__), "fixtures", "test_config.yaml" - ), - "gcp_creds_path": "/home/test/test-creds.json", - "gcp_project": "test_project", - "gcp_region": "us-west-2b", - "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), - "create_backend_bucket": False, - } + tf_types.CLIOptionsRoot( + **{ + "aws_access_key_id": "1234567890", + "aws_secret_access_key": "1234567890", + "aws_region": "us-west-2", + "backend": "s3", + "backend_region": "us-west-2", + "backend_bucket": "test_bucket", + "backend_prefix": "terraform/test-0001", + "backend_use_all_remotes": False, + "config_file": os.path.join( + os.path.dirname(__file__), "fixtures", "test_config.yaml" + ), + "gcp_creds_path": str(gcp_creds_file), + "gcp_project": "test_project", + "gcp_region": "us-west-2b", + "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), + "create_backend_bucket": False, + } + ) ) return result @pytest.fixture(scope="function") @mock.patch("tfworker.authenticators.aws.AWSAuthenticator", new=MockAWSAuth) -def json_base_rootc(s3_client, dynamodb_client, sts_client): +def json_base_rootc(s3_client, dynamodb_client, gcp_creds_file, sts_client): result = tfworker.commands.root.RootCommand( - args={ - "aws_access_key_id": "1234567890", - "aws_secret_access_key": "1234567890", - "aws_region": "us-west-2", - "backend": "s3", - "backend_region": "us-west-2", - "backend_bucket": "test_bucket", - "backend_prefix": "terraform/test-0001", - "backend_use_all_remotes": False, - "config_file": os.path.join( - os.path.dirname(__file__), "fixtures", "base_config_test.json" - ), - "gcp_creds_path": "/home/test/test-creds.json", - "gcp_project": "test_project", - "gcp_region": "us-west-2b", - "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), - } + tf_types.CLIOptionsRoot( + **{ + "aws_access_key_id": "1234567890", + "aws_secret_access_key": "1234567890", + "aws_region": "us-west-2", + "backend": "s3", + "backend_region": "us-west-2", + "backend_bucket": "test_bucket", + "backend_prefix": "terraform/test-0001", + "backend_use_all_remotes": False, + "config_file": os.path.join( + os.path.dirname(__file__), "fixtures", "base_config_test.json" + ), + "gcp_creds_path": str(gcp_creds_file), + "gcp_project": "test_project", + "gcp_region": "us-west-2b", + "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), + } + ) ) return result @pytest.fixture(scope="function") @mock.patch("tfworker.authenticators.aws.AWSAuthenticator", new=MockAWSAuth) -def yaml_base_rootc(s3_client, dynamodb_client, sts_client): +def yaml_base_rootc(s3_client, dynamodb_client, gcp_creds_file, sts_client): result = tfworker.commands.root.RootCommand( - args={ - "aws_access_key_id": "1234567890", - "aws_secret_access_key": "1234567890", - "aws_region": "us-west-2", - "backend": "s3", - "backend_region": "us-west-2", - "backend_bucket": "test_bucket", - "backend_prefix": "terraform/test-0001", - "backend_use_all_remotes": False, - "config_file": os.path.join( - os.path.dirname(__file__), "fixtures", "base_config_test.yaml" - ), - "gcp_creds_path": "/home/test/test-creds.json", - "gcp_project": "test_project", - "gcp_region": "us-west-2b", - "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), - } + tf_types.CLIOptionsRoot( + **{ + "aws_access_key_id": "1234567890", + "aws_secret_access_key": "1234567890", + "aws_region": "us-west-2", + "backend": "s3", + "backend_region": "us-west-2", + "backend_bucket": "test_bucket", + "backend_prefix": "terraform/test-0001", + "backend_use_all_remotes": False, + "config_file": os.path.join( + os.path.dirname(__file__), "fixtures", "base_config_test.yaml" + ), + "gcp_creds_path": str(gcp_creds_file), + "gcp_project": "test_project", + "gcp_region": "us-west-2b", + "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), + } + ) ) return result @pytest.fixture(scope="function") @mock.patch("tfworker.authenticators.aws.AWSAuthenticator", new=MockAWSAuth) -def hcl_base_rootc(s3_client, dynamodb_client, sts_client): +def hcl_base_rootc(s3_client, dynamodb_client, gcp_creds_file, sts_client): result = tfworker.commands.root.RootCommand( - args={ - "aws_access_key_id": "1234567890", - "aws_secret_access_key": "1234567890", - "aws_region": "us-west-2", - "backend": "s3", - "backend_region": "us-west-2", - "backend_bucket": "test_bucket", - "backend_prefix": "terraform/test-0001", - "backend_use_all_remotes": False, - "config_file": os.path.join( - os.path.dirname(__file__), "fixtures", "base_config_test.hcl" - ), - "gcp_creds_path": "/home/test/test-creds.json", - "gcp_project": "test_project", - "gcp_region": "us-west-2b", - "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), - } + tf_types.CLIOptionsRoot( + **{ + "aws_access_key_id": "1234567890", + "aws_secret_access_key": "1234567890", + "aws_region": "us-west-2", + "backend": "s3", + "backend_region": "us-west-2", + "backend_bucket": "test_bucket", + "backend_prefix": "terraform/test-0001", + "backend_use_all_remotes": False, + "config_file": os.path.join( + os.path.dirname(__file__), "fixtures", "base_config_test.hcl" + ), + "gcp_creds_path": str(gcp_creds_file), + "gcp_project": "test_project", + "gcp_region": "us-west-2b", + "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), + } + ) ) return result @pytest.fixture(scope="function") @mock.patch("tfworker.authenticators.aws.AWSAuthenticator", new=MockAWSAuth) -def rootc_options(s3_client, dynamodb_client, sts_client): +def rootc_options(s3_client, dynamodb_client, gcp_creds_file, sts_client): result = tfworker.commands.root.RootCommand( - args={ - "aws_region": "us-east-2", - "backend": "gcs", - "backend_region": "us-west-2", - "backend_bucket": "test_bucket", - "backend_prefix": "terraform/test-0001", - "backend_use_all_remotes": False, - "config_file": os.path.join( - os.path.dirname(__file__), "fixtures", "test_config_with_options.yaml" - ), - "gcp_creds_path": "/home/test/test-creds.json", - "gcp_project": "test_project", - "gcp_region": "us-west-2b", - "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), - } + tf_types.CLIOptionsRoot( + **{ + "aws_region": "us-east-2", + "backend": "gcs", + "backend_region": "us-west-2", + "backend_bucket": "test_bucket", + "backend_prefix": "terraform/test-0001", + "backend_use_all_remotes": False, + "config_file": os.path.join( + os.path.dirname(__file__), + "fixtures", + "test_config_with_options.yaml", + ), + "gcp_creds_path": str(gcp_creds_file), + "gcp_project": "test_project", + "gcp_region": "us-west-2b", + "repository_path": os.path.join(os.path.dirname(__file__), "fixtures"), + } + ) ) return result diff --git a/tests/providers/test_google.py b/tests/providers/test_google.py index 68cd32b..4f4cca0 100644 --- a/tests/providers/test_google.py +++ b/tests/providers/test_google.py @@ -13,11 +13,11 @@ # limitations under the License. -def test_google_hcl(basec): +def test_google_hcl(basec, gcp_creds_file): render = basec.providers["google"].hcl() - expected_render = """provider "google" { + expected_render = f"""provider "google" {{ region = "us-west-2" - credentials = file("/home/test/test-creds.json") -}""" + credentials = file("{gcp_creds_file}") +}}""" assert render == expected_render diff --git a/tests/providers/test_google_beta.py b/tests/providers/test_google_beta.py index 0367371..7f2806f 100644 --- a/tests/providers/test_google_beta.py +++ b/tests/providers/test_google_beta.py @@ -1,8 +1,8 @@ -def test_google_hcl(basec): +def test_google_hcl(basec, gcp_creds_file): render = basec.providers["google-beta"].hcl() - expected_render = """provider "google-beta" { + expected_render = f"""provider "google-beta" {{ region = "us-west-2" - credentials = file("/home/test/test-creds.json") -}""" + credentials = file("{gcp_creds_file}") +}}""" assert render == expected_render diff --git a/tests/test_cli.py b/tests/test_cli.py index 7a20f8c..bb4fba2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,8 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os -import tempfile from unittest.mock import patch import pytest @@ -47,22 +45,6 @@ def test_validate_deployment_invalid_length(self, capfd): assert e.value.code == 1 assert "32 characters" in out - def test_validate_gcp_creds_path(self): - """ensure valid creds paths are returned""" - with tempfile.NamedTemporaryFile(mode="w+") as tmpf: - assert ( - tfworker.cli.validate_gcp_creds_path(None, None, tmpf.name) == tmpf.name - ) - - def test_validate_gcp_creds_path_invalid(self, capfd): - """ensure invalid creds paths fail""" - with pytest.raises(SystemExit) as e: - tfworker.cli.validate_gcp_creds_path(None, None, "test") - out, err = capfd.readouterr() - assert e.type == SystemExit - assert e.value.code == 1 - assert "not resolve GCP credentials" in out - def test_validate_host(self): """only linux and darwin are supported, and require 64 bit platforms""" with patch("tfworker.cli.get_platform", return_value=("linux", "amd64")): @@ -92,43 +74,6 @@ def test_validate_host_invalid_os(self, capfd): assert e.value.code == 1 assert "not supported" in out - def test_validate_working_dir(self): - """ensure valid working dirs are returned""" - assert tfworker.cli.validate_working_dir(None) is None - - with tempfile.TemporaryDirectory() as tmpd: - assert tfworker.cli.validate_working_dir(tmpd) is None - - def test_validate_working_dir_is_file(self, capfd): - """ensure files fail""" - with tempfile.NamedTemporaryFile(mode="w+") as tmpf: - with pytest.raises(SystemExit) as e: - tfworker.cli.validate_working_dir(tmpf.name) - out, err = capfd.readouterr() - assert e.type == SystemExit - assert e.value.code == 1 - assert "not a directory" in out - - def test_validate_working_dir_does_not_exist(self, capfd): - """ensure non existent dirs fail""" - with pytest.raises(SystemExit) as e: - tfworker.cli.validate_working_dir("test") - out, err = capfd.readouterr() - assert e.type == SystemExit - assert e.value.code == 1 - assert "does not exist" in out - - def test_validate_working_dir_not_empty(self, capfd): - """ensure non empty dirs fail""" - with tempfile.TemporaryDirectory() as tmpd: - with open(os.path.join(tmpd, "test"), "w+"): - with pytest.raises(SystemExit) as e: - tfworker.cli.validate_working_dir(tmpd) - out, err = capfd.readouterr() - assert e.type == SystemExit - assert e.value.code == 1 - assert "must be empty" in out - def test_cli_no_params(self): """ensure cli returns usage with no params""" from tfworker.cli import cli diff --git a/tests/types/test_cli_options.py b/tests/types/test_cli_options.py new file mode 100644 index 0000000..2678198 --- /dev/null +++ b/tests/types/test_cli_options.py @@ -0,0 +1,70 @@ +from pathlib import Path +from tempfile import NamedTemporaryFile, TemporaryDirectory + +import pytest + +from tfworker.types import CLIOptionsRoot + + +def test_working_dir_validator_with_valid_directory(): + with TemporaryDirectory() as temp_dir: + options = CLIOptionsRoot(working_dir=temp_dir) + assert options.working_dir == temp_dir + + +def test_working_dir_validator_with_non_existent_directory(): + with pytest.raises(ValueError, match=r"Working path .* does not exist!"): + CLIOptionsRoot(working_dir="/non/existent/path") + + +def test_working_dir_validator_with_file_instead_of_directory(): + with TemporaryDirectory() as temp_dir: + file_path = Path(temp_dir) / "file.txt" + file_path.touch() + with pytest.raises(ValueError, match=r"Working path .* is not a directory!"): + CLIOptionsRoot(working_dir=str(file_path)) + + +def test_working_dir_validator_with_non_empty_directory(): + with TemporaryDirectory() as temp_dir: + (Path(temp_dir) / "file.txt").touch() + with pytest.raises(ValueError, match=r"Working path .* must be empty!"): + CLIOptionsRoot(working_dir=temp_dir) + + +def test_clean_validator_with_working_dir_set_and_clean_not_set(): + with TemporaryDirectory() as temp_dir: + options = CLIOptionsRoot(working_dir=temp_dir) + assert options.clean is False + + +def test_clean_validator_with_working_dir_not_set_and_clean_not_set(): + options = CLIOptionsRoot() + assert options.clean is True + + +def test_clean_validator_with_working_dir_set_and_clean_set_to_true(): + with TemporaryDirectory() as temp_dir: + options = CLIOptionsRoot(working_dir=temp_dir, clean=True) + assert options.clean is True + + +def test_clean_validator_with_working_dir_not_set_and_clean_set_to_false(): + options = CLIOptionsRoot(clean=False) + assert options.clean is False + + +def test_validate_gcp_creds_path(): + # Test with a non-existing file + with pytest.raises(ValueError, match=r"Path .* is not a file!"): + CLIOptionsRoot(gcp_creds_path="non_existing_file.json") + + # Test with a directory + with pytest.raises(ValueError, match=r"Path .* is not a file!"): + CLIOptionsRoot(gcp_creds_path=".") + + # Test with a valid file + # Create a temporary file for the test + with NamedTemporaryFile() as temp_file: + # The validator should not raise any exception for a valid file + CLIOptionsRoot(gcp_creds_path=temp_file.name) diff --git a/tests/util/test_util_cli.py b/tests/util/test_util_cli.py new file mode 100644 index 0000000..3979411 --- /dev/null +++ b/tests/util/test_util_cli.py @@ -0,0 +1,79 @@ +from typing import List, Optional + +import click +import pytest +from pydantic import BaseModel, Field + +from tfworker.util.cli import pydantic_to_click + + +class ATestModel(BaseModel): + str_field: str + optional_str_field: Optional[str] = None + int_field: int + optional_int_field: Optional[int] = None + float_field: float + optional_float_field: Optional[float] = None + bool_field: bool + optional_bool_field: Optional[bool] = None + list_str_field: List[str] = Field(required=True) + optional_list_str_field: Optional[List[str]] = [] + + +@click.command() +@pydantic_to_click(ATestModel) +def a_command(): + pass + + +def test_pydantic_to_click(): + command = a_command + + # Check that the command is a Click command + assert isinstance(command, click.Command) + + # Check that the command has the correct options + options = {option.name: option for option in command.params} + assert set(options.keys()) == set(ATestModel.__annotations__.keys()) + + # Check the types of the options + assert isinstance(options["str_field"].type, click.types.StringParamType) + assert isinstance(options["optional_str_field"].type, click.types.StringParamType) + assert isinstance(options["int_field"].type, click.types.IntParamType) + assert isinstance(options["optional_int_field"].type, click.types.IntParamType) + assert isinstance(options["float_field"].type, click.types.FloatParamType) + assert isinstance(options["optional_float_field"].type, click.types.FloatParamType) + assert isinstance(options["bool_field"].type, click.types.BoolParamType) + assert isinstance(options["optional_bool_field"].type, click.types.BoolParamType) + assert isinstance(options["list_str_field"].type, click.types.StringParamType) + assert isinstance( + options["optional_list_str_field"].type, click.types.StringParamType + ) + + # Check the 'multiple' attribute of the options + assert options["list_str_field"].multiple is True + assert options["optional_list_str_field"].multiple is True + + # Check the 'required' attribute of the options + assert options["str_field"].required is True + assert options["optional_str_field"].required is False + assert options["int_field"].required is True + assert options["optional_int_field"].required is False + assert options["float_field"].required is True + assert options["optional_float_field"].required is False + assert options["bool_field"].required is True + assert options["optional_bool_field"].required is False + assert options["list_str_field"].required is True + assert options["optional_list_str_field"].required is False + + +class UnsupportedModel(BaseModel): + unsupported_field: dict + + +def test_unsupported_type(): + with pytest.raises(ValueError, match=r"Unsupported type "): + + @pydantic_to_click(UnsupportedModel) + def a_command(): + pass diff --git a/tests/util/test_util_terraform.py b/tests/util/test_util_terraform.py index 9729ca4..be627e3 100644 --- a/tests/util/test_util_terraform.py +++ b/tests/util/test_util_terraform.py @@ -177,7 +177,7 @@ def test_get_tf_version( def mock_mirror_setup(): mock_mirror_settings = { "providers": MagicMock(), - 'terraform_bin': "/path/to/terraform", + "terraform_bin": "/path/to/terraform", "working_dir": "/working/dir", "cache_dir": "/cache/dir", "temp_dir": "/temp/dir", @@ -208,7 +208,7 @@ def test_mirror_providers(mock_mirror_setup): result = mirror_providers( providers=mock_mirror_settings["providers"], - terraform_bin=mock_mirror_settings['terraform_bin'], + terraform_bin=mock_mirror_settings["terraform_bin"], working_dir=mock_mirror_settings["working_dir"], cache_dir=mock_mirror_settings["cache_dir"], ) @@ -243,7 +243,7 @@ def test_mirror_providers_tf_error(mock_mirror_setup): with pytest.raises(SystemExit): mirror_providers( providers=mock_mirror_settings["providers"], - terraform_bin=mock_mirror_settings['terraform_bin'], + terraform_bin=mock_mirror_settings["terraform_bin"], working_dir=mock_mirror_settings["working_dir"], cache_dir=mock_mirror_settings["cache_dir"], ) @@ -273,7 +273,7 @@ def test_mirror_providers_all_in_cache(mock_mirror_setup): mirror_providers( providers=mock_mirror_settings["providers"], - terraform_bin=mock_mirror_settings['terraform_bin'], + terraform_bin=mock_mirror_settings["terraform_bin"], working_dir=mock_mirror_settings["working_dir"], cache_dir=mock_mirror_settings["cache_dir"], ) diff --git a/tfworker/cli.py b/tfworker/cli.py index 77d0424..b93862a 100644 --- a/tfworker/cli.py +++ b/tfworker/cli.py @@ -14,13 +14,12 @@ # limitations under the License. -import os import sys -from pathlib import Path import click +from pydantic import ValidationError -from tfworker import constants as const +import tfworker.types as tf_types from tfworker.commands import ( CleanCommand, EnvCommand, @@ -28,6 +27,7 @@ TerraformCommand, VersionCommand, ) +from tfworker.util.cli import pydantic_to_click from tfworker.util.system import get_platform @@ -42,16 +42,6 @@ def validate_deployment(ctx, deployment, name): return name -def validate_gcp_creds_path(ctx, path, value): - if value: - if not os.path.isabs(value): - value = os.path.abspath(value) - if os.path.isfile(value): - return value - click.secho(f"Could not resolve GCP credentials path: {value}", fg="red") - raise SystemExit(1) - - def validate_host(): """Ensure that the script is being run on a supported platform.""" supported_opsys = ["darwin", "linux"] @@ -76,22 +66,6 @@ def validate_host(): return True -def validate_working_dir(fpath): - # if fpath is none, then a custom working directory was not defined, so this validates - if fpath is None: - return - with Path(fpath) as wpath: - if not wpath.exists(): - click.secho(f"Working path {fpath} does not exist!", fg="red") - raise SystemExit(1) - if not wpath.is_dir(): - click.secho(f"Working path {fpath} is not a directory!", fg="red") - raise SystemExit(1) - if any(wpath.iterdir()): - click.secho(f"Working path {fpath} must be empty!", fg="red") - raise SystemExit(1) - - class CSVType(click.types.StringParamType): name = "csv" envvar_list_splitter = "," @@ -101,137 +75,17 @@ def __repr__(self): @click.group() -@click.option( - "--aws-access-key-id", - envvar="AWS_ACCESS_KEY_ID", - help="AWS Access key", -) -@click.option( - "--aws-secret-access-key", - envvar="AWS_SECRET_ACCESS_KEY", - help="AWS access key secret", -) -@click.option( - "--aws-session-token", - envvar="AWS_SESSION_TOKEN", - help="AWS access key token", -) -@click.option( - "--aws-role-arn", - envvar="AWS_ROLE_ARN", - help="If provided, credentials will be used to assume this role (complete ARN)", -) -@click.option( - "--aws-external-id", - envvar="AWS_EXTERNAL_ID", - help="If provided, will be used to assume the role specified by --aws-role-arn", -) -@click.option( - "--aws-region", - envvar="AWS_DEFAULT_REGION", - default=const.DEFAULT_AWS_REGION, - help="AWS Region to build in", -) -@click.option( - "--aws-profile", - envvar="AWS_PROFILE", - help="The AWS/Boto3 profile to use", -) -@click.option( - "--gcp-region", - envvar="GCP_REGION", - default=const.DEFAULT_GCP_REGION, - help="Region to build in", -) -@click.option( - "--gcp-creds-path", - envvar="GCP_CREDS_PATH", - help=( - "Relative path to the credentials JSON file for the service account to be used." - ), - callback=validate_gcp_creds_path, -) -@click.option( - "--gcp-project", - envvar="GCP_PROJECT", - help="GCP project name to which work will be applied", -) -@click.option( - "--config-file", - default=const.DEFAULT_CONFIG, - envvar="WORKER_CONFIG_FILE", - required=True, -) -@click.option( - "--repository-path", - default=const.DEFAULT_REPOSITORY_PATH, - envvar="WORKER_REPOSITORY_PATH", - required=True, - help="The path to the terraform module repository", -) -@click.option( - "--backend", - type=click.Choice(["s3", "gcs"]), - envvar="WORKER_BACKEND", - help="State/locking provider. One of: s3, gcs", -) -@click.option( - "--backend-bucket", - envvar="WORKER_BACKEND_BUCKET", - help="Bucket (must exist) where all terraform states are stored", -) -@click.option( - "--backend-prefix", - default=const.DEFAULT_BACKEND_PREFIX, - envvar="WORKER_BACKEND_PREFIX", - help=f"Prefix to use in backend storage bucket for all terraform states (DEFAULT: {const.DEFAULT_BACKEND_PREFIX})", -) -@click.option( - "--backend-region", - default=const.DEFAULT_AWS_REGION, - help="Region where terraform rootc/lock bucket exists", -) -@click.option( - "--backend-use-all-remotes/--no-backend-use-all-remotes", - default=True, - envvar="WORKER_BACKEND_USE_ALL_REMOTES", - help="Generate remote data sources based on all definition paths present in the backend", -) -@click.option( - "--create-backend-bucket/--no-create-backend-bucket", - default=True, - help="Create the backend bucket if it does not exist", -) -@click.option( - "--config-var", - multiple=True, - default=[], - help='key=value to be supplied as jinja variables in config_file under "var" dictionary, can be specified multiple times', -) -@click.option( - "--working-dir", - envvar="WORKER_WORKING_DIR", - default=None, - help="Specify the path to use instead of a temporary directory, must exist, be empty, and be writeable, --clean applies to this directory as well", -) -@click.option( - "--clean/--no-clean", - default=None, - envvar="WORKER_CLEAN", - help="clean up the temporary directory created by the worker after execution", -) -@click.option( - "--backend-plans/--no-backend-plans", - default=False, - envvar="WORKER_BACKEND_PLANS", - help="store plans in the backend", -) +@pydantic_to_click(tf_types.CLIOptionsRoot) @click.pass_context -def cli(context, **kwargs): +def cli(ctx, **kwargs): """CLI for the worker utility.""" - validate_host() - validate_working_dir(kwargs.get("working_dir", None)) - context.obj = RootCommand(args=kwargs) + try: + options = tf_types.CLIOptionsRoot(**kwargs) + validate_host() + ctx.obj = RootCommand(options) + except ValidationError as e: + click.echo(f"Error in options: {e}") + ctx.exit(1) @cli.command() diff --git a/tfworker/commands/base.py b/tfworker/commands/base.py index a8c25a1..0201003 100644 --- a/tfworker/commands/base.py +++ b/tfworker/commands/base.py @@ -12,9 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -import click import pathlib +import click + from tfworker.authenticators import AuthenticatorsCollection from tfworker.backends import BackendError, select_backend from tfworker.definitions import DefinitionsCollection diff --git a/tfworker/commands/root.py b/tfworker/commands/root.py index 44e9d01..7264d66 100644 --- a/tfworker/commands/root.py +++ b/tfworker/commands/root.py @@ -25,23 +25,24 @@ import yaml from jinja2.runtime import StrictUndefined +from tfworker.types import CLIOptionsRoot + class RootCommand: - def __init__(self, args={}): + def __init__(self, options: CLIOptionsRoot): """ Initialize the RootCommand with the given arguments. Args: args (dict, optional): A dictionary of arguments to initialize the RootCommand with. Defaults to {}. """ - self.working_dir = args.get("working_dir", None) - # the default behavior of --clean/--no-clean varies depending on if working-dir is passed - defaultClean = False if (self.working_dir is not None) else True - if args.get("clean", None) is None: - self.clean = defaultClean - else: - self.clean = args.get("clean") + # To avoid refactoring everything all at once, take items from CLIOptionsRoot and assign them to self + # This is a temporary measure to allow for a gradual transition to the new CLIOptionsRoot class + self.working_dir = options.working_dir + self.clean = options.clean + self.config_file = options.config_file + self.tf = None if self.working_dir is not None: self.temp_dir = pathlib.Path(self.working_dir).resolve() @@ -49,17 +50,19 @@ def __init__(self, args={}): self.temp_dir = tempfile.mkdtemp() self.args = self.StateArgs() - self.config_file = args.get("config_file") - - # Config accessors - self.tf = None - self.add_args(args) + self.add_args(options.dict()) def __del__(self): """ Cleanup the temporary directory after execution. """ + # Temporary for refactoring + if not hasattr(self, "clean"): + if hasattr(self, "temp_dir"): + print(f"self.temp_dir: {self.temp_dir} may be abandoned!!!") + return + if self.clean: # the affect of remove_top being true is removing the top level directory, for a temporary # directory this is desirable however when a working-dir is specified it's likely a volume diff --git a/tfworker/definitions.py b/tfworker/definitions.py index 5b5fff8..ed88aad 100644 --- a/tfworker/definitions.py +++ b/tfworker/definitions.py @@ -219,7 +219,9 @@ def _prep_terraform_lockfile(self): ) if result is not None: - with open(f"{self._target}/{TF_PROVIDER_DEFAULT_LOCKFILE}", "w") as lockfile: + with open( + f"{self._target}/{TF_PROVIDER_DEFAULT_LOCKFILE}", "w" + ) as lockfile: lockfile.write(result) @staticmethod diff --git a/tfworker/providers/providers_collection.py b/tfworker/providers/providers_collection.py index 9cd16ac..24d7d9e 100644 --- a/tfworker/providers/providers_collection.py +++ b/tfworker/providers/providers_collection.py @@ -14,7 +14,6 @@ import collections import copy -import click from typing import List from tfworker.providers.generic import GenericProvider diff --git a/tfworker/types/__init__.py b/tfworker/types/__init__.py index 34a70b4..8dc00ab 100644 --- a/tfworker/types/__init__.py +++ b/tfworker/types/__init__.py @@ -1,3 +1,4 @@ +from tfworker.types.cli_options import CLIOptionsRoot # noqa: F401 from tfworker.types.json import JSONType # noqa: F401 from tfworker.types.provider import ProviderConfig, Requirements # noqa: F401 from tfworker.types.terraform import ( # noqa: F401 diff --git a/tfworker/types/cli_options.py b/tfworker/types/cli_options.py new file mode 100644 index 0000000..4152458 --- /dev/null +++ b/tfworker/types/cli_options.py @@ -0,0 +1,163 @@ +import os +from pathlib import Path +from typing import List, Optional, Union + +from pydantic import BaseModel, Field, field_validator, root_validator + +from tfworker import constants as const + + +class CLIOptionsRoot(BaseModel): + aws_access_key_id: Optional[str] = Field( + None, env="AWS_ACCESS_KEY_ID", description="AWS Access key" + ) + aws_secret_access_key: Optional[str] = Field( + None, env="AWS_SECRET_ACCESS_KEY", description="AWS access key secret" + ) + aws_session_token: Optional[str] = Field( + None, env="AWS_SESSION_TOKEN", description="AWS access key token" + ) + aws_role_arn: Optional[str] = Field( + None, + env="AWS_ROLE_ARN", + description="If provided, credentials will be used to assume this role (complete ARN)", + ) + aws_external_id: Optional[str] = Field( + None, + env="AWS_EXTERNAL_ID", + description="If provided, will be used to assume the role specified by --aws-role-arn", + ) + aws_region: str = Field( + const.DEFAULT_AWS_REGION, + env="AWS_DEFAULT_REGION", + description="AWS Region to build in", + ) + aws_profile: Optional[str] = Field( + None, env="AWS_PROFILE", description="The AWS/Boto3 profile to use" + ) + gcp_region: str = Field( + const.DEFAULT_GCP_REGION, env="GCP_REGION", description="Region to build in" + ) + gcp_creds_path: Optional[str] = Field( + None, + env="GCP_CREDS_PATH", + description="Relative path to the credentials JSON file for the service account to be used.", + ) + gcp_project: Optional[str] = Field( + None, + env="GCP_PROJECT", + description="GCP project name to which work will be applied", + ) + config_file: str = Field( + const.DEFAULT_CONFIG, + env="WORKER_CONFIG_FILE", + description="Path to the configuration file", + required=True, + ) + repository_path: str = Field( + const.DEFAULT_REPOSITORY_PATH, + env="WORKER_REPOSITORY_PATH", + description="The path to the terraform module repository", + required=True, + ) + backend: Optional[str] = Field( + None, + env="WORKER_BACKEND", + description="State/locking provider. One of: s3, gcs", + ) + backend_bucket: Optional[str] = Field( + None, + env="WORKER_BACKEND_BUCKET", + description="Bucket (must exist) where all terraform states are stored", + ) + backend_prefix: str = Field( + const.DEFAULT_BACKEND_PREFIX, + env="WORKER_BACKEND_PREFIX", + description="Prefix to use in backend storage bucket for all terraform states", + ) + backend_region: str = Field( + const.DEFAULT_AWS_REGION, + description="Region where terraform root/lock bucket exists", + ) + backend_use_all_remotes: bool = Field( + True, + env="WORKER_BACKEND_USE_ALL_REMOTES", + description="Generate remote data sources based on all definition paths present in the backend", + ) + create_backend_bucket: bool = Field( + True, description="Create the backend bucket if it does not exist" + ) + config_var: Optional[List[str]] = Field( + [], + description='key=value to be supplied as jinja variables in config_file under "var" dictionary, can be specified multiple times', + ) + working_dir: Optional[str] = Field( + None, + env="WORKER_WORKING_DIR", + description="Specify the path to use instead of a temporary directory, must exist, be empty, and be writeable, --clean applies to this directory as well", + ) + clean: Optional[bool] = Field( + None, + env="WORKER_CLEAN", + description="Clean up the temporary directory created by the worker after execution", + ) + backend_plans: bool = Field( + False, env="WORKER_BACKEND_PLANS", description="Store plans in the backend" + ) + + @root_validator(pre=True) + def set_default_clean(cls, values): + if values.get("working_dir") is not None: + if "clean" not in values or values["clean"] is None: + values["clean"] = False + else: + if "clean" not in values or values["clean"] is None: + values["clean"] = True + return values + + @field_validator("working_dir") + @classmethod + def validate_working_dir(cls, fpath: Union[str, None]) -> Union[str, None]: + """Validate the working directory path. + + Args: + fpath: Path to the working directory. + + Returns: + Path to the working directory. + + Raises: + ValueError: If the path does not exist, is not a directory, or is not empty. + """ + if fpath is None: + return + with Path(fpath) as wpath: + if not wpath.exists(): + raise ValueError(f"Working path {fpath} does not exist!") + if not wpath.is_dir(): + raise ValueError(f"Working path {fpath} is not a directory!") + if any(wpath.iterdir()): + raise ValueError(f"Working path {fpath} must be empty!") + return fpath + + @field_validator("gcp_creds_path") + @classmethod + def validate_gcp_creds_path(cls, fpath: Union[str, None]) -> Union[str, None]: + """Validate the GCP credentials path. + + Args: + fpath: Path to the GCP credentials file. + + Returns: + Fully resolved path to the GCP credentials file. + + Raises: + ValueError: If the path does not exist or is not a file. + """ + if fpath is None: + return + if not os.path.isabs(fpath): + fpath = os.path.abspath(fpath) + if os.path.isfile(fpath): + return fpath + raise ValueError(f"Path {fpath} is not a file!") diff --git a/tfworker/util/cli.py b/tfworker/util/cli.py new file mode 100644 index 0000000..0b197e4 --- /dev/null +++ b/tfworker/util/cli.py @@ -0,0 +1,59 @@ +import typing as t + +import click +from pydantic import BaseModel +from pydantic.fields import PydanticUndefined + + +def pydantic_to_click(pydantic_model: t.Type[BaseModel]) -> click.Command: + """Convert a Pydantic model to a Click command. + + Args: + pydantic_model: Pydantic model to convert. + + Returns: + Click command. + """ + + def decorator(func): + model_types = t.get_type_hints(pydantic_model) + for fname, fdata in pydantic_model.model_fields.items(): + description = fdata.description or "" + default = fdata.default + multiple = False + + if model_types[fname] in [str, t.Optional[str]]: + option_type = click.STRING + elif model_types[fname] in [int, t.Optional[int]]: + option_type = click.INT + elif model_types[fname] in [float, t.Optional[float]]: + option_type = click.FLOAT + elif model_types[fname] in [bool, t.Optional[bool]]: + option_type = click.BOOL + elif model_types[fname] in [t.List[str], t.Optional[t.List[str]]]: + option_type = click.STRING + multiple = True + if default is PydanticUndefined: + default = [] + else: + raise ValueError(f"Unsupported type {model_types[fname]}") + + c_option_args = [f"--{fname.replace('_', '-')}"] + c_option_kwargs = { + "help": description, + "default": default, + "type": option_type, + "required": fdata.is_required(), + "multiple": multiple, + } + + if option_type == click.BOOL: + c_option_args = [ + f"--{fname.replace('_', '-')}/--no-{fname.replace('_', '-')}" + ] + del c_option_kwargs["type"] + + func = click.option(*c_option_args, **c_option_kwargs)(func) + return func + + return decorator diff --git a/tfworker/util/terraform.py b/tfworker/util/terraform.py index 2b56a0d..11bb8f1 100644 --- a/tfworker/util/terraform.py +++ b/tfworker/util/terraform.py @@ -196,6 +196,8 @@ def get_provider_gid_from_source(source: str) -> ProviderGID: return ProviderGID(hostname=hostname, namespace=namespace, type=ptype) + +@lru_cache def find_required_providers( search_dir: str, ) -> Union[None, Dict[str, [Dict[str, str]]]]: diff --git a/tfworker/util/terraform_helpers.py b/tfworker/util/terraform_helpers.py index 807ae43..8d43be7 100644 --- a/tfworker/util/terraform_helpers.py +++ b/tfworker/util/terraform_helpers.py @@ -1,13 +1,11 @@ import json import os import pathlib -from functools import lru_cache from tempfile import TemporaryDirectory from typing import Dict, List, Union import click import hcl2 - from lark.exceptions import UnexpectedToken from tfworker.providers.providers_collection import ProvidersCollection @@ -168,7 +166,6 @@ def _parse_required_providers(content: dict) -> Union[None, Dict[str, Dict[str, return providers -@lru_cache def _find_required_providers(search_dir: str) -> Dict[str, [Dict[str, str]]]: providers = {} for root, _, files in os.walk(search_dir, followlinks=True):