dbt-platform-helper 12.5.0__py3-none-any.whl → 12.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of dbt-platform-helper might be problematic. Click here for more details.

Files changed (40) hide show
  1. dbt_platform_helper/COMMANDS.md +39 -38
  2. dbt_platform_helper/commands/codebase.py +5 -8
  3. dbt_platform_helper/commands/conduit.py +2 -2
  4. dbt_platform_helper/commands/config.py +1 -1
  5. dbt_platform_helper/commands/copilot.py +4 -2
  6. dbt_platform_helper/commands/environment.py +40 -24
  7. dbt_platform_helper/commands/pipeline.py +6 -171
  8. dbt_platform_helper/constants.py +1 -0
  9. dbt_platform_helper/domain/codebase.py +20 -23
  10. dbt_platform_helper/domain/conduit.py +10 -12
  11. dbt_platform_helper/domain/config_validator.py +40 -7
  12. dbt_platform_helper/domain/copilot_environment.py +135 -131
  13. dbt_platform_helper/domain/database_copy.py +45 -42
  14. dbt_platform_helper/domain/maintenance_page.py +220 -183
  15. dbt_platform_helper/domain/pipelines.py +212 -0
  16. dbt_platform_helper/domain/terraform_environment.py +68 -35
  17. dbt_platform_helper/domain/test_platform_terraform_manifest_generator.py +100 -0
  18. dbt_platform_helper/providers/cache.py +1 -2
  19. dbt_platform_helper/providers/cloudformation.py +12 -1
  20. dbt_platform_helper/providers/config.py +21 -13
  21. dbt_platform_helper/providers/copilot.py +2 -0
  22. dbt_platform_helper/providers/files.py +26 -0
  23. dbt_platform_helper/providers/io.py +31 -0
  24. dbt_platform_helper/providers/load_balancers.py +29 -3
  25. dbt_platform_helper/providers/platform_config_schema.py +10 -7
  26. dbt_platform_helper/providers/vpc.py +106 -0
  27. dbt_platform_helper/providers/yaml_file.py +3 -14
  28. dbt_platform_helper/templates/COMMANDS.md.jinja +5 -3
  29. dbt_platform_helper/templates/pipelines/codebase/overrides/package-lock.json +819 -623
  30. dbt_platform_helper/utils/application.py +32 -34
  31. dbt_platform_helper/utils/aws.py +0 -50
  32. dbt_platform_helper/utils/files.py +8 -23
  33. dbt_platform_helper/utils/messages.py +2 -3
  34. dbt_platform_helper/utils/platform_config.py +0 -7
  35. dbt_platform_helper/utils/versioning.py +12 -0
  36. {dbt_platform_helper-12.5.0.dist-info → dbt_platform_helper-12.6.0.dist-info}/METADATA +2 -2
  37. {dbt_platform_helper-12.5.0.dist-info → dbt_platform_helper-12.6.0.dist-info}/RECORD +40 -35
  38. {dbt_platform_helper-12.5.0.dist-info → dbt_platform_helper-12.6.0.dist-info}/WHEEL +1 -1
  39. {dbt_platform_helper-12.5.0.dist-info → dbt_platform_helper-12.6.0.dist-info}/LICENSE +0 -0
  40. {dbt_platform_helper-12.5.0.dist-info → dbt_platform_helper-12.6.0.dist-info}/entry_points.txt +0 -0
@@ -1,12 +1,10 @@
1
1
  import subprocess
2
- from collections.abc import Callable
3
-
4
- import click
5
2
 
6
3
  from dbt_platform_helper.providers.cloudformation import CloudFormation
7
4
  from dbt_platform_helper.providers.copilot import connect_to_addon_client_task
8
5
  from dbt_platform_helper.providers.copilot import create_addon_client_task
9
6
  from dbt_platform_helper.providers.ecs import ECS
7
+ from dbt_platform_helper.providers.io import ClickIOProvider
10
8
  from dbt_platform_helper.providers.secrets import Secrets
11
9
  from dbt_platform_helper.utils.application import Application
12
10
 
@@ -18,7 +16,7 @@ class Conduit:
18
16
  secrets_provider: Secrets,
19
17
  cloudformation_provider: CloudFormation,
20
18
  ecs_provider: ECS,
21
- echo: Callable[[str], str] = click.secho,
19
+ io: ClickIOProvider = ClickIOProvider(),
22
20
  subprocess: subprocess = subprocess,
23
21
  connect_to_addon_client_task=connect_to_addon_client_task,
24
22
  create_addon_client_task=create_addon_client_task,
@@ -29,7 +27,7 @@ class Conduit:
29
27
  self.cloudformation_provider = cloudformation_provider
30
28
  self.ecs_provider = ecs_provider
31
29
  self.subprocess = subprocess
32
- self.echo = echo
30
+ self.io = io
33
31
  self.connect_to_addon_client_task = connect_to_addon_client_task
34
32
  self.create_addon_client_task = create_addon_client_task
35
33
 
@@ -39,10 +37,10 @@ class Conduit:
39
37
  addon_name, access
40
38
  )
41
39
 
42
- self.echo(f"Checking if a conduit task is already running for {addon_type}")
40
+ self.io.info(f"Checking if a conduit task is already running for {addon_type}")
43
41
  task_arns = self.ecs_provider.get_ecs_task_arns(cluster_arn, task_name)
44
42
  if not task_arns:
45
- self.echo("Creating conduit task")
43
+ self.io.info("Creating conduit task")
46
44
  self.create_addon_client_task(
47
45
  clients["iam"],
48
46
  clients["ssm"],
@@ -55,7 +53,7 @@ class Conduit:
55
53
  access,
56
54
  )
57
55
 
58
- self.echo("Updating conduit task")
56
+ self.io.info("Updating conduit task")
59
57
  self._update_stack_resources(
60
58
  self.application.name,
61
59
  env,
@@ -69,13 +67,13 @@ class Conduit:
69
67
  task_arns = self.ecs_provider.get_ecs_task_arns(cluster_arn, task_name)
70
68
 
71
69
  else:
72
- self.echo("Conduit task already running")
70
+ self.io.info("Conduit task already running")
73
71
 
74
- self.echo(f"Checking if exec is available for conduit task...")
72
+ self.io.info(f"Checking if exec is available for conduit task...")
75
73
 
76
74
  self.ecs_provider.ecs_exec_is_available(cluster_arn, task_arns)
77
75
 
78
- self.echo("Connecting to conduit task")
76
+ self.io.info("Connecting to conduit task")
79
77
  self.connect_to_addon_client_task(
80
78
  clients["ecs"], self.subprocess, self.application.name, env, cluster_arn, task_name
81
79
  )
@@ -115,7 +113,7 @@ class Conduit:
115
113
  parameter_name,
116
114
  access,
117
115
  )
118
- self.echo("Waiting for conduit task update to complete...")
116
+ self.io.info("Waiting for conduit task update to complete...")
119
117
  self.cloudformation_provider.wait_for_cloudformation_to_reach_status(
120
118
  "stack_update_complete", stack_name
121
119
  )
@@ -1,11 +1,11 @@
1
1
  from typing import Callable
2
2
 
3
3
  import boto3
4
- import click
5
4
 
6
5
  from dbt_platform_helper.constants import CODEBASE_PIPELINES_KEY
7
6
  from dbt_platform_helper.constants import ENVIRONMENTS_KEY
8
7
  from dbt_platform_helper.constants import PLATFORM_CONFIG_FILE
8
+ from dbt_platform_helper.providers.io import ClickIOProvider
9
9
  from dbt_platform_helper.providers.opensearch import OpensearchProvider
10
10
  from dbt_platform_helper.providers.redis import RedisProvider
11
11
  from dbt_platform_helper.utils.messages import abort_with_error
@@ -13,7 +13,9 @@ from dbt_platform_helper.utils.messages import abort_with_error
13
13
 
14
14
  class ConfigValidator:
15
15
 
16
- def __init__(self, validations: Callable[[dict], None] = None):
16
+ def __init__(
17
+ self, validations: Callable[[dict], None] = None, io: ClickIOProvider = ClickIOProvider()
18
+ ):
17
19
  self.validations = validations or [
18
20
  self.validate_supported_redis_versions,
19
21
  self.validate_supported_opensearch_versions,
@@ -21,7 +23,9 @@ class ConfigValidator:
21
23
  self.validate_codebase_pipelines,
22
24
  self.validate_environment_pipelines_triggers,
23
25
  self.validate_database_copy_section,
26
+ self.validate_database_migration_input_sources,
24
27
  ]
28
+ self.io = io
25
29
 
26
30
  def run_validations(self, config: dict):
27
31
  for validation in self.validations:
@@ -48,9 +52,8 @@ class ConfigValidator:
48
52
  environments = extension.get("environments", {})
49
53
 
50
54
  if not isinstance(environments, dict):
51
- click.secho(
52
- f"Error: {extension_type} extension definition is invalid type, expected dictionary",
53
- fg="red",
55
+ self.io.error(
56
+ f"{extension_type} extension definition is invalid type, expected dictionary",
54
57
  )
55
58
  continue
56
59
  for environment, env_config in environments.items():
@@ -64,9 +67,8 @@ class ConfigValidator:
64
67
  )
65
68
 
66
69
  for version_failure in extensions_with_invalid_version:
67
- click.secho(
70
+ self.io.error(
68
71
  f"{extension_type} version for environment {version_failure['environment']} is not in the list of supported {extension_type} versions: {supported_extension_versions}. Provided Version: {version_failure['version']}",
69
- fg="red",
70
72
  )
71
73
 
72
74
  def validate_supported_redis_versions(self, config):
@@ -240,3 +242,34 @@ class ConfigValidator:
240
242
 
241
243
  if errors:
242
244
  abort_with_error("\n".join(errors))
245
+
246
+ def validate_database_migration_input_sources(self, config):
247
+ extensions = config.get("extensions", {})
248
+ if not extensions:
249
+ return
250
+
251
+ s3_extensions = {
252
+ key: ext for key, ext in extensions.items() if ext.get("type", None) == "s3"
253
+ }
254
+
255
+ if not s3_extensions:
256
+ return
257
+
258
+ errors = []
259
+
260
+ for extension_name, extension in s3_extensions.items():
261
+ for env, env_config in extension.get("environments", {}).items():
262
+ if "data_migration" not in env_config:
263
+ continue
264
+ data_migration = env_config.get("data_migration", {})
265
+ if "import" in data_migration and "import_sources" in data_migration:
266
+ errors.append(
267
+ f"Error in '{extension_name}.environments.{env}.data_migration': only the 'import_sources' property is required - 'import' is deprecated."
268
+ )
269
+ if "import" not in data_migration and "import_sources" not in data_migration:
270
+ errors.append(
271
+ f"Error in '{extension_name}.environments.{env}.data_migration': 'import_sources' property is missing."
272
+ )
273
+
274
+ if errors:
275
+ abort_with_error("\n".join(errors))
@@ -1,162 +1,168 @@
1
1
  from collections import defaultdict
2
2
  from pathlib import Path
3
+ from typing import Callable
3
4
 
4
- import boto3
5
5
  import click
6
-
7
- from dbt_platform_helper.platform_exception import PlatformException
8
- from dbt_platform_helper.providers.load_balancers import find_https_listener
9
- from dbt_platform_helper.utils.aws import get_aws_session_or_abort
10
- from dbt_platform_helper.utils.files import mkfile
6
+ from boto3 import Session
7
+
8
+ from dbt_platform_helper.domain.terraform_environment import (
9
+ EnvironmentNotFoundException,
10
+ )
11
+ from dbt_platform_helper.providers.cloudformation import CloudFormation
12
+ from dbt_platform_helper.providers.config import ConfigProvider
13
+ from dbt_platform_helper.providers.files import FileProvider
14
+ from dbt_platform_helper.providers.load_balancers import (
15
+ get_https_certificate_for_application,
16
+ )
17
+ from dbt_platform_helper.providers.vpc import Vpc
18
+ from dbt_platform_helper.providers.vpc import VpcNotFoundForNameException
19
+ from dbt_platform_helper.providers.vpc import VpcProvider
11
20
  from dbt_platform_helper.utils.template import S3_CROSS_ACCOUNT_POLICY
12
21
  from dbt_platform_helper.utils.template import camel_case
13
22
  from dbt_platform_helper.utils.template import setup_templates
14
23
 
15
24
 
16
- # TODO - move helper functions into suitable provider classes
17
- def get_subnet_ids(session, vpc_id, environment_name):
18
- subnets = session.client("ec2").describe_subnets(
19
- Filters=[{"Name": "vpc-id", "Values": [vpc_id]}]
20
- )["Subnets"]
21
-
22
- if not subnets:
23
- click.secho(f"No subnets found for VPC with id: {vpc_id}.", fg="red")
24
- raise click.Abort
25
-
26
- public_tag = {"Key": "subnet_type", "Value": "public"}
27
- public_subnets = [subnet["SubnetId"] for subnet in subnets if public_tag in subnet["Tags"]]
28
- private_tag = {"Key": "subnet_type", "Value": "private"}
29
- private_subnets = [subnet["SubnetId"] for subnet in subnets if private_tag in subnet["Tags"]]
30
-
31
- # This call and the method declaration can be removed when we stop using AWS Copilot to deploy the services
32
- public_subnets, private_subnets = _match_subnet_id_order_to_cloudformation_exports(
33
- session,
34
- environment_name,
35
- public_subnets,
36
- private_subnets,
37
- )
38
-
39
- return public_subnets, private_subnets
40
-
41
-
42
- def _match_subnet_id_order_to_cloudformation_exports(
43
- session, environment_name, public_subnets, private_subnets
44
- ):
45
- public_subnet_exports = []
46
- private_subnet_exports = []
47
- for page in session.client("cloudformation").get_paginator("list_exports").paginate():
48
- for export in page["Exports"]:
49
- if f"-{environment_name}-" in export["Name"]:
50
- if export["Name"].endswith("-PublicSubnets"):
51
- public_subnet_exports = export["Value"].split(",")
52
- if export["Name"].endswith("-PrivateSubnets"):
53
- private_subnet_exports = export["Value"].split(",")
54
-
55
- # If the elements match, regardless of order, use the list from the CloudFormation exports
56
- if set(public_subnets) == set(public_subnet_exports):
57
- public_subnets = public_subnet_exports
58
- if set(private_subnets) == set(private_subnet_exports):
59
- private_subnets = private_subnet_exports
60
-
61
- return public_subnets, private_subnets
62
-
63
-
64
- def get_cert_arn(session, application, env_name):
65
- try:
66
- arn = find_https_certificate(session, application, env_name)
67
- except:
68
- click.secho(
69
- f"No certificate found with domain name matching environment {env_name}.", fg="red"
25
+ class CopilotEnvironment:
26
+ def __init__(
27
+ self,
28
+ config_provider: ConfigProvider,
29
+ vpc_provider: VpcProvider = None,
30
+ cloudformation_provider: CloudFormation = None,
31
+ session: Session = None, # TODO - this is a temporary fix, will fall away once the Loadbalancer provider is in place.
32
+ copilot_templating=None,
33
+ echo: Callable[[str], str] = click.secho,
34
+ ):
35
+ self.config_provider = config_provider
36
+ self.vpc_provider = vpc_provider
37
+ self.copilot_templating = copilot_templating or CopilotTemplating(
38
+ file_provider=FileProvider(),
70
39
  )
71
- raise click.Abort
40
+ self.echo = echo
41
+ self.session = session
42
+ self.cloudformation_provider = cloudformation_provider
72
43
 
73
- return arn
44
+ def generate(self, environment_name: str) -> None:
74
45
 
46
+ platform_config = self.config_provider.get_enriched_config()
75
47
 
76
- def get_vpc_id(session, env_name, vpc_name=None):
77
- if not vpc_name:
78
- vpc_name = f"{session.profile_name}-{env_name}"
48
+ if environment_name not in platform_config.get("environments").keys():
49
+ raise EnvironmentNotFoundException(
50
+ f"Error: cannot generate copilot manifests for environment {environment_name}. It does not exist in your configuration"
51
+ )
79
52
 
80
- filters = [{"Name": "tag:Name", "Values": [vpc_name]}]
81
- vpcs = session.client("ec2").describe_vpcs(Filters=filters)["Vpcs"]
53
+ env_config = platform_config["environments"][environment_name]
54
+ profile_for_environment = env_config.get("accounts", {}).get("deploy", {}).get("name")
55
+
56
+ self.echo(f"Using {profile_for_environment} for this AWS session")
82
57
 
83
- if not vpcs:
84
- filters[0]["Values"] = [session.profile_name]
85
- vpcs = session.client("ec2").describe_vpcs(Filters=filters)["Vpcs"]
58
+ app_name = platform_config["application"]
86
59
 
87
- if not vpcs:
88
- click.secho(
89
- f"No VPC found with name {vpc_name} in AWS account {session.profile_name}.", fg="red"
60
+ certificate_arn = get_https_certificate_for_application(
61
+ self.session, app_name, environment_name
90
62
  )
91
- raise click.Abort
92
-
93
- return vpcs[0]["VpcId"]
94
-
95
-
96
- def _generate_copilot_environment_manifests(
97
- environment_name, application_name, env_config, session
98
- ):
99
- env_template = setup_templates().get_template("env/manifest.yml")
100
- vpc_name = env_config.get("vpc", None)
101
- vpc_id = get_vpc_id(session, environment_name, vpc_name)
102
- pub_subnet_ids, priv_subnet_ids = get_subnet_ids(session, vpc_id, environment_name)
103
- cert_arn = get_cert_arn(session, application_name, environment_name)
104
- contents = env_template.render(
105
- {
106
- "name": environment_name,
107
- "vpc_id": vpc_id,
108
- "pub_subnet_ids": pub_subnet_ids,
109
- "priv_subnet_ids": priv_subnet_ids,
110
- "certificate_arn": cert_arn,
111
- }
112
- )
113
- click.echo(
114
- mkfile(
115
- ".", f"copilot/environments/{environment_name}/manifest.yml", contents, overwrite=True
63
+
64
+ vpc = self._get_environment_vpc(
65
+ self.session, app_name, environment_name, env_config.get("vpc", None)
116
66
  )
117
- )
118
67
 
68
+ copilot_environment_manifest = self.copilot_templating.generate_copilot_environment_manifest(
69
+ environment_name=environment_name,
70
+ # We need to correct the subnet id order before adding it to the template. See pydoc on below method for details.
71
+ vpc=self._match_subnet_id_order_to_cloudformation_exports(environment_name, vpc),
72
+ cert_arn=certificate_arn,
73
+ )
119
74
 
120
- def find_https_certificate(session: boto3.Session, app: str, env: str) -> str:
121
- listener_arn = find_https_listener(session, app, env)
122
- cert_client = session.client("elbv2")
123
- certificates = cert_client.describe_listener_certificates(ListenerArn=listener_arn)[
124
- "Certificates"
125
- ]
75
+ self.echo(
76
+ self.copilot_templating.write_environment_manifest(
77
+ environment_name, copilot_environment_manifest
78
+ )
79
+ )
126
80
 
127
- try:
128
- certificate_arn = next(c["CertificateArn"] for c in certificates if c["IsDefault"])
129
- except StopIteration:
130
- raise CertificateNotFoundException()
81
+ # TODO: There should always be a vpc_name as defaults have been applied to the config. This function can
82
+ # probably fall away. We shouldn't need to check 3 different names (vpc_name, session.profile_name, {session.profile_name}-{env_name})
83
+ # To be checked.
84
+ def _get_environment_vpc(self, session: Session, app_name, env_name: str, vpc_name: str) -> Vpc:
131
85
 
132
- return certificate_arn
86
+ if not vpc_name:
87
+ vpc_name = f"{session.profile_name}-{env_name}"
133
88
 
89
+ try:
90
+ vpc = self.vpc_provider.get_vpc(app_name, env_name, vpc_name)
91
+ except VpcNotFoundForNameException:
92
+ vpc = self.vpc_provider.get_vpc(app_name, env_name, session.profile_name)
134
93
 
135
- class CertificateNotFoundException(PlatformException):
136
- pass
94
+ if not vpc:
95
+ raise VpcNotFoundForNameException
137
96
 
97
+ return vpc
138
98
 
139
- class CopilotEnvironment:
140
- def __init__(self, config_provider):
141
- self.config_provider = config_provider
99
+ def _match_subnet_id_order_to_cloudformation_exports(
100
+ self, environment_name: str, vpc: Vpc
101
+ ) -> Vpc:
102
+ """
103
+ Addresses an issue identified in DBTP-1524 'If the order of the subnets
104
+ in the environment manifest has changed, copilot env deploy tries to do
105
+ destructive changes.'.
142
106
 
143
- def generate(self, environment_name):
144
- config = self.config_provider.load_and_validate_platform_config()
145
- enriched_config = self.config_provider.apply_environment_defaults(config)
107
+ Takes a Vpc object which has a private and public subnets attribute and
108
+ sorts them to match the order within cfn exports.
109
+ """
146
110
 
147
- env_config = enriched_config["environments"][environment_name]
148
- profile_for_environment = env_config.get("accounts", {}).get("deploy", {}).get("name")
149
- click.secho(f"Using {profile_for_environment} for this AWS session")
150
- session = get_aws_session_or_abort(profile_for_environment)
151
-
152
- _generate_copilot_environment_manifests(
153
- environment_name, enriched_config["application"], env_config, session
111
+ exports = self.cloudformation_provider.get_cloudformation_exports_for_environment(
112
+ environment_name
154
113
  )
155
114
 
115
+ public_subnet_exports = []
116
+ private_subnet_exports = []
117
+
118
+ for export in exports:
119
+ if export["Name"].endswith("-PublicSubnets"):
120
+ public_subnet_exports = export["Value"].split(",")
121
+ elif export["Name"].endswith("-PrivateSubnets"):
122
+ private_subnet_exports = export["Value"].split(",")
123
+
124
+ # If the elements match, regardless of order, use the list from the CloudFormation exports
125
+ if set(vpc.public_subnets) == set(public_subnet_exports):
126
+ vpc.public_subnets = public_subnet_exports
127
+ if set(vpc.private_subnets) == set(private_subnet_exports):
128
+ vpc.private_subnets = private_subnet_exports
129
+
130
+ return vpc
131
+
156
132
 
157
133
  class CopilotTemplating:
158
- def __init__(self, mkfile_fn=mkfile):
159
- self.mkfile_fn = mkfile_fn
134
+ def __init__(
135
+ self,
136
+ file_provider: FileProvider = None,
137
+ # TODO file_provider can be moved up a layer. File writing can be the responsibility of CopilotEnvironment generate
138
+ # Or we align with PlatformTerraformManifestGenerator and rename from Templating to reflect the file writing responsibility
139
+ ):
140
+ self.file_provider = file_provider
141
+ self.templates = setup_templates()
142
+
143
+ def generate_copilot_environment_manifest(
144
+ self, environment_name: str, vpc: Vpc, cert_arn: str
145
+ ) -> str:
146
+ env_template = self.templates.get_template("env/manifest.yml")
147
+
148
+ return env_template.render(
149
+ {
150
+ "name": environment_name,
151
+ "vpc_id": vpc.id,
152
+ "pub_subnet_ids": vpc.public_subnets,
153
+ "priv_subnet_ids": vpc.private_subnets,
154
+ "certificate_arn": cert_arn,
155
+ }
156
+ )
157
+
158
+ def write_environment_manifest(self, environment_name: str, manifest_contents: str) -> str:
159
+
160
+ return self.file_provider.mkfile(
161
+ ".",
162
+ f"copilot/environments/{environment_name}/manifest.yml",
163
+ manifest_contents,
164
+ overwrite=True,
165
+ )
160
166
 
161
167
  def generate_cross_account_s3_policies(self, environments: dict, extensions):
162
168
  resource_blocks = defaultdict(list)
@@ -190,15 +196,13 @@ class CopilotTemplating:
190
196
  click.echo("\n>>> No cross-environment S3 policies to create.\n")
191
197
  return
192
198
 
193
- templates = setup_templates()
194
-
195
199
  for service in sorted(resource_blocks.keys()):
196
200
  resources = resource_blocks[service]
197
201
  click.echo(f"\n>>> Creating S3 cross account policies for {service}.\n")
198
- template = templates.get_template(S3_CROSS_ACCOUNT_POLICY)
202
+ template = self.templates.get_template(S3_CROSS_ACCOUNT_POLICY)
199
203
  file_content = template.render({"resources": resources})
200
204
  output_dir = Path(".").absolute()
201
205
  file_path = f"copilot/{service}/addons/s3-cross-account-policy.yml"
202
206
 
203
- self.mkfile_fn(output_dir, file_path, file_content, True)
207
+ self.file_provider.mkfile(output_dir, file_path, file_content, True)
204
208
  click.echo(f"File {file_path} created")