Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.

Commit a9a6799

Browse files
CloudFormation: Store resource scaffolding in 'generated' directory. (#13534)
1 parent 6269d34 commit a9a6799

11 files changed

Lines changed: 394 additions & 78 deletions

File tree

localstack-core/localstack/services/cloudformation/provider_utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,8 @@ def resource_tags_to_remove_or_update(
297297

298298
# LocalStack specific utilities
299299
def get_schema_path(file_path: Path) -> dict:
300-
file_name_base = file_path.name.removesuffix(".py").removesuffix(".py.enc")
300+
file_name_base = (
301+
file_path.name.removesuffix("_base.py").removesuffix(".py").removesuffix(".py.enc")
302+
)
301303
with Path(file_path).parent.joinpath(f"{file_name_base}.schema.json").open() as fd:
302304
return json.load(fd)

localstack-core/localstack/services/cloudformation/scaffolding/__main__.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import json
44
import os
5+
import subprocess
6+
import sys
57
import zipfile
68
from collections.abc import Generator
79
from dataclasses import dataclass
@@ -280,6 +282,7 @@ class FileType(Enum):
280282
# service code
281283
plugin = auto()
282284
provider = auto()
285+
provider_base = auto()
283286

284287
# test files
285288
integration_test = auto()
@@ -325,6 +328,7 @@ def render(
325328
template_mapping = {
326329
FileType.plugin: "plugin_template.py.j2",
327330
FileType.provider: "provider_template.py.j2",
331+
FileType.provider_base: "provider_base_template.py.j2",
328332
FileType.getatt_test: "test_getatt_template.py.j2",
329333
FileType.integration_test: "test_integration_template.py.j2",
330334
# FileType.cloudcontrol_test: "test_cloudcontrol_template.py.j2",
@@ -351,7 +355,7 @@ def render(
351355
resource_name, FileType.attribute_template, tests_output_path, pro=self.pro
352356
)
353357
)
354-
case FileType.provider:
358+
case FileType.provider | FileType.provider_base:
355359
property_ir = generate_ir_for_type(
356360
[self.schema],
357361
resource_name.full_name,
@@ -377,6 +381,9 @@ def render(
377381
kwargs["list_permissions"] = (
378382
self.schema.get("handlers", {}).get("list", {}).get("permissions")
379383
)
384+
kwargs["service"] = resource_name.python_compatible_service_name.lower()
385+
kwargs["lower_resource"] = resource_name.resource.lower()
386+
kwargs["pro"] = self.pro
380387
case FileType.plugin:
381388
kwargs["service"] = resource_name.python_compatible_service_name.lower()
382389
kwargs["lower_resource"] = resource_name.resource.lower()
@@ -588,18 +595,28 @@ def __init__(
588595
"resource_providers",
589596
f"{self.resource_name.namespace.lower()}_{self.resource_name.service.lower()}_{self.resource_name.resource.lower()}.py",
590597
),
598+
FileType.provider_base: root_dir(self.pro).joinpath(
599+
*base_path,
600+
"services",
601+
self.resource_name.python_compatible_service_name.lower(),
602+
"resource_providers",
603+
"generated",
604+
f"{self.resource_name.namespace.lower()}_{self.resource_name.service.lower()}_{self.resource_name.resource.lower()}_base.py",
605+
),
591606
FileType.plugin: root_dir(self.pro).joinpath(
592607
*base_path,
593608
"services",
594609
self.resource_name.python_compatible_service_name.lower(),
595610
"resource_providers",
611+
"generated",
596612
f"{self.resource_name.namespace.lower()}_{self.resource_name.service.lower()}_{self.resource_name.resource.lower()}_plugin.py",
597613
),
598614
FileType.schema: root_dir(self.pro).joinpath(
599615
*base_path,
600616
"services",
601617
self.resource_name.python_compatible_service_name.lower(),
602618
"resource_providers",
619+
"generated",
603620
f"aws_{self.resource_name.service.lower()}_{self.resource_name.resource.lower()}.schema.json",
604621
),
605622
FileType.integration_test: tests_root_dir(self.pro).joinpath(
@@ -656,6 +673,10 @@ def write(self, file_type: FileType, contents: str):
656673
self.ensure_python_init_files(destination_path)
657674
self.write_text(contents, file_destination)
658675
self.console.print(f"Written provider to {file_destination}")
676+
case FileType.provider_base:
677+
self.ensure_python_init_files(destination_path)
678+
self.write_text(contents, file_destination)
679+
self.console.print(f"Written provider base to {file_destination}")
659680
case FileType.plugin:
660681
self.ensure_python_init_files(destination_path)
661682
self.write_text(contents, file_destination)
@@ -707,13 +728,25 @@ def confirm_overwrite(self, destination_file: Path) -> bool:
707728
708729
:return True if file should be (over-)written, False otherwise
709730
"""
710-
return self.overwrite or click.confirm("Destination files already exist, overwrite?")
731+
return self.overwrite or click.confirm(
732+
f"Destination file {destination_file} already exists, overwrite?"
733+
)
711734

712735
@staticmethod
713736
def write_text(contents: str, destination: Path):
714737
with destination.open("wt") as outfile:
715738
print(contents, file=outfile)
716739

740+
# for Python files, use ruff to clean up formatting errors introduced by scaffolding
741+
if destination.suffix == ".py":
742+
command = [sys.executable, "-m", "ruff", "format", destination]
743+
try:
744+
subprocess.run(command, check=True, capture_output=True, text=True)
745+
except subprocess.CalledProcessError as e:
746+
print(
747+
f"Ruff fix command failed (exit code {e.returncode}):\n{e.stdout}\n{e.stderr}"
748+
)
749+
717750
@staticmethod
718751
def ensure_python_init_files(path: Path):
719752
"""
@@ -777,6 +810,9 @@ def print(self):
777810
case FileType.provider:
778811
self.printer.print("\n[underline]Provider template[/underline]\n")
779812
self.printer.print(Syntax(self.contents, "python"))
813+
case FileType.provider_base:
814+
self.printer.print("\n[underline]Provider base template[/underline]\n")
815+
self.printer.print(Syntax(self.contents, "python"))
780816
case FileType.plugin:
781817
self.printer.print("\n[underline]Plugin[/underline]\n")
782818
self.printer.print(Syntax(self.contents, "python"))

localstack-core/localstack/services/cloudformation/scaffolding/templates/plugin_template.py.j2

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
#
2+
# AUTOGENERATED FILE - DO NOT EDIT
3+
#
14
from typing import Optional, Type
25

36
from localstack.services.cloudformation.resource_provider import ResourceProvider
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# LocalStack Resource Provider Base Class Scaffolding {{ scaffolding_version }}
2+
#
3+
# AUTOGENERATED FILE - DO NOT EDIT
4+
#
5+
6+
from __future__ import annotations
7+
8+
from abc import ABC, abstractmethod
9+
from pathlib import Path
10+
from typing import Optional, TypedDict
11+
12+
import localstack.services.cloudformation.provider_utils as util
13+
from localstack.services.cloudformation.resource_provider import (
14+
ProgressEvent,
15+
ResourceProvider,
16+
ResourceRequest,
17+
)
18+
19+
{{ provider_properties }}
20+
21+
REPEATED_INVOCATION = "repeated_invocation"
22+
23+
class {{ resource }}ProviderBase(ResourceProvider[{{ resource }}Properties], ABC):
24+
25+
TYPE = "{{ name }}" # Autogenerated. Don't change
26+
SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change
27+
28+
@abstractmethod
29+
def create(
30+
self,
31+
request: ResourceRequest[{{ resource }}Properties],
32+
) -> ProgressEvent[{{ resource }}Properties]:
33+
"""
34+
Create a new resource.
35+
36+
{% if primary_identifier -%}
37+
Primary identifier fields:
38+
{%- for property in primary_identifier %}
39+
- {{ property }}
40+
{%- endfor %}
41+
{%- endif %}
42+
43+
{% if required_properties -%}
44+
Required properties:
45+
{%- for property in required_properties %}
46+
- {{ property }}
47+
{%- endfor %}
48+
{%- endif %}
49+
50+
{% if create_only_properties -%}
51+
Create-only properties:
52+
{%- for property in create_only_properties %}
53+
- {{ property }}
54+
{%- endfor %}
55+
{%- endif %}
56+
57+
{% if read_only_properties -%}
58+
Read-only properties:
59+
{%- for property in read_only_properties %}
60+
- {{ property }}
61+
{%- endfor %}
62+
{%- endif %}
63+
64+
{% if create_permissions -%}
65+
IAM permissions required:
66+
{%- for permission in create_permissions %}
67+
- {{ permission }}
68+
{%- endfor -%}
69+
{%- endif %}
70+
71+
"""
72+
raise NotImplementedError
73+
74+
@abstractmethod
75+
def read(
76+
self,
77+
request: ResourceRequest[{{ resource }}Properties],
78+
) -> ProgressEvent[{{ resource }}Properties]:
79+
"""
80+
Fetch resource information
81+
82+
{% if read_permissions -%}
83+
IAM permissions required:
84+
{%- for permission in read_permissions %}
85+
- {{ permission }}
86+
{%- endfor %}
87+
{%- endif %}
88+
"""
89+
raise NotImplementedError
90+
91+
@abstractmethod
92+
def delete(
93+
self,
94+
request: ResourceRequest[{{ resource }}Properties],
95+
) -> ProgressEvent[{{ resource }}Properties]:
96+
"""
97+
Delete a resource
98+
99+
{% if delete_permissions -%}
100+
IAM permissions required:
101+
{%- for permission in delete_permissions %}
102+
- {{ permission }}
103+
{%- endfor %}
104+
{%- endif %}
105+
"""
106+
raise NotImplementedError
107+
108+
@abstractmethod
109+
def update(
110+
self,
111+
request: ResourceRequest[{{ resource }}Properties],
112+
) -> ProgressEvent[{{ resource }}Properties]:
113+
"""
114+
Update a resource
115+
116+
{% if update_permissions -%}
117+
IAM permissions required:
118+
{%- for permission in update_permissions %}
119+
- {{ permission }}
120+
{%- endfor %}
121+
{%- endif %}
122+
"""
123+
raise NotImplementedError
124+
125+
@abstractmethod
126+
def list(
127+
self,
128+
request: ResourceRequest[{{ resource }}Properties],
129+
) -> ProgressEvent[{{ resource }}Properties]:
130+
"""
131+
List available resources of this type
132+
{% if list_permissions -%}
133+
IAM permissions required:
134+
{%- for permission in list_permissions %}
135+
- {{ permission }}
136+
{%- endfor %}
137+
{%- endif %}
138+
"""
139+
raise NotImplementedError

localstack-core/localstack/services/cloudformation/scaffolding/templates/provider_template.py.j2

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,22 @@
11
# LocalStack Resource Provider Scaffolding {{ scaffolding_version }}
2-
from __future__ import annotations
32

4-
from pathlib import Path
5-
from typing import Optional, TypedDict
6-
7-
import localstack.services.cloudformation.provider_utils as util
83
from localstack.services.cloudformation.resource_provider import (
94
OperationStatus,
105
ProgressEvent,
11-
ResourceProvider,
126
ResourceRequest,
137
)
8+
{%- if pro %}
9+
{%- set root_module = "localstack.pro.core" %}
10+
{%- else %}
11+
{%- set root_module = "localstack" %}
12+
{%- endif %}
13+
from {{ root_module }}.services.{{ service }}.resource_providers.generated.aws_{{ service }}_{{ lower_resource }}_base import (
14+
{{ resource }}ProviderBase,
15+
{{ resource }}Properties,
16+
REPEATED_INVOCATION
17+
)
1418

15-
{{ provider_properties }}
16-
17-
18-
REPEATED_INVOCATION = "repeated_invocation"
19-
20-
class {{ resource }}Provider(ResourceProvider[{{ resource }}Properties]):
21-
22-
TYPE = "{{ name }}" # Autogenerated. Don't change
23-
SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change
19+
class {{ resource }}Provider({{ resource }}ProviderBase):
2420

2521
def create(
2622
self,
@@ -63,7 +59,6 @@ class {{ resource }}Provider(ResourceProvider[{{ resource }}Properties]):
6359
- {{ permission }}
6460
{%- endfor -%}
6561
{%- endif %}
66-
6762
"""
6863
model = request.desired_state
6964

@@ -136,3 +131,18 @@ class {{ resource }}Provider(ResourceProvider[{{ resource }}Properties]):
136131
{%- endif %}
137132
"""
138133
raise NotImplementedError
134+
135+
def list(
136+
self,
137+
request: ResourceRequest[{{ resource }}Properties],
138+
) -> ProgressEvent[{{ resource }}Properties]:
139+
"""
140+
List available resources of this type
141+
{% if list_permissions -%}
142+
IAM permissions required:
143+
{%- for permission in list_permissions %}
144+
- {{ permission }}
145+
{%- endfor %}
146+
{%- endif %}
147+
"""
148+
raise NotImplementedError

0 commit comments

Comments
 (0)