terraformgraph 1.0.2__py3-none-any.whl → 1.0.4__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.
- terraformgraph/__init__.py +1 -1
- terraformgraph/__main__.py +1 -1
- terraformgraph/aggregator.py +941 -300
- terraformgraph/config/aggregation_rules.yaml +276 -1
- terraformgraph/config_loader.py +9 -8
- terraformgraph/icons.py +504 -521
- terraformgraph/layout.py +580 -116
- terraformgraph/main.py +251 -48
- terraformgraph/parser.py +328 -86
- terraformgraph/renderer.py +1887 -170
- terraformgraph/terraform_tools.py +355 -0
- terraformgraph/variable_resolver.py +180 -0
- terraformgraph-1.0.4.dist-info/METADATA +386 -0
- terraformgraph-1.0.4.dist-info/RECORD +19 -0
- {terraformgraph-1.0.2.dist-info → terraformgraph-1.0.4.dist-info}/licenses/LICENSE +1 -1
- terraformgraph-1.0.2.dist-info/METADATA +0 -163
- terraformgraph-1.0.2.dist-info/RECORD +0 -17
- {terraformgraph-1.0.2.dist-info → terraformgraph-1.0.4.dist-info}/WHEEL +0 -0
- {terraformgraph-1.0.2.dist-info → terraformgraph-1.0.4.dist-info}/entry_points.txt +0 -0
- {terraformgraph-1.0.2.dist-info → terraformgraph-1.0.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terraform CLI Tools Integration
|
|
3
|
+
|
|
4
|
+
Provides functionality to run terraform commands and parse their output
|
|
5
|
+
to enhance the visualization with accurate state information.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class TerraformStateResource:
|
|
22
|
+
"""A resource from terraform state/plan JSON output."""
|
|
23
|
+
|
|
24
|
+
address: str # e.g., "aws_subnet.public[0]" or "aws_subnet.public[\"key\"]"
|
|
25
|
+
resource_type: str
|
|
26
|
+
name: str
|
|
27
|
+
index: Optional[Any] # Can be int (count) or str (for_each)
|
|
28
|
+
values: Dict[str, Any]
|
|
29
|
+
module_path: str = ""
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def base_address(self) -> str:
|
|
33
|
+
"""Address without index, e.g., 'aws_subnet.public' from 'aws_subnet.public[0]' or 'aws_subnet.public[\"key\"]'."""
|
|
34
|
+
# Remove numeric index [0] or string index ["key"]
|
|
35
|
+
address = re.sub(r"\[\d+\]$", "", self.address)
|
|
36
|
+
address = re.sub(r'\["[^"]+"\]$', "", address)
|
|
37
|
+
return address
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def full_id(self) -> str:
|
|
41
|
+
"""Full ID matching parser's format."""
|
|
42
|
+
if self.module_path:
|
|
43
|
+
return f"{self.module_path}.{self.resource_type}.{self.name}"
|
|
44
|
+
return f"{self.resource_type}.{self.name}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class TerraformStateResult:
|
|
49
|
+
"""Result from parsing terraform show/plan JSON output."""
|
|
50
|
+
|
|
51
|
+
resources: List[TerraformStateResource] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TerraformToolsRunner:
|
|
55
|
+
"""Executes terraform commands and parses their output."""
|
|
56
|
+
|
|
57
|
+
TIMEOUT_SHOW = 120 # seconds
|
|
58
|
+
|
|
59
|
+
def __init__(self, terraform_dir: Path, terraform_bin: str = "terraform"):
|
|
60
|
+
self.terraform_dir = Path(terraform_dir)
|
|
61
|
+
self.terraform_bin = terraform_bin
|
|
62
|
+
|
|
63
|
+
def check_terraform_available(self) -> bool:
|
|
64
|
+
"""Check if terraform CLI is available in PATH."""
|
|
65
|
+
return shutil.which(self.terraform_bin) is not None
|
|
66
|
+
|
|
67
|
+
def check_initialized(self) -> bool:
|
|
68
|
+
"""Check if terraform init has been run in the directory."""
|
|
69
|
+
terraform_dir = self.terraform_dir / ".terraform"
|
|
70
|
+
return terraform_dir.exists() and terraform_dir.is_dir()
|
|
71
|
+
|
|
72
|
+
def run_show_json(self, state_file: Optional[Path] = None) -> Optional[TerraformStateResult]:
|
|
73
|
+
"""Run terraform show -json and parse the state output.
|
|
74
|
+
|
|
75
|
+
If state_file is provided, reads from that file directly.
|
|
76
|
+
Otherwise, tries to read from local JSON files (plan.json, state.json, terraform.tfstate.json),
|
|
77
|
+
then falls back to running terraform show -json.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
state_file: Optional path to a specific JSON state/plan file.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
TerraformStateResult with resources, or None if failed.
|
|
84
|
+
"""
|
|
85
|
+
# If a specific state file is provided, use it directly
|
|
86
|
+
if state_file is not None:
|
|
87
|
+
if state_file.exists():
|
|
88
|
+
try:
|
|
89
|
+
with open(state_file, "r", encoding="utf-8") as f:
|
|
90
|
+
json_data = json.load(f)
|
|
91
|
+
result = parse_state_json(json_data)
|
|
92
|
+
if result and result.resources:
|
|
93
|
+
logger.info(
|
|
94
|
+
"Loaded state from %s: %d resources", state_file, len(result.resources)
|
|
95
|
+
)
|
|
96
|
+
return result
|
|
97
|
+
else:
|
|
98
|
+
logger.warning("No resources found in %s", state_file)
|
|
99
|
+
return None
|
|
100
|
+
except OSError as e:
|
|
101
|
+
logger.warning("Could not read %s: %s", state_file, e)
|
|
102
|
+
return None
|
|
103
|
+
except json.JSONDecodeError as e:
|
|
104
|
+
logger.warning("Could not parse JSON in %s: %s", state_file, e)
|
|
105
|
+
return None
|
|
106
|
+
else:
|
|
107
|
+
logger.warning("State file not found: %s", state_file)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
# Try to read from local JSON files in terraform dir
|
|
111
|
+
json_files = [
|
|
112
|
+
self.terraform_dir / "plan.json",
|
|
113
|
+
self.terraform_dir / "state.json",
|
|
114
|
+
self.terraform_dir / "terraform.tfstate.json",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
for json_file in json_files:
|
|
118
|
+
if json_file.exists():
|
|
119
|
+
try:
|
|
120
|
+
with open(json_file, "r", encoding="utf-8") as f:
|
|
121
|
+
json_data = json.load(f)
|
|
122
|
+
|
|
123
|
+
result = parse_state_json(json_data)
|
|
124
|
+
if result and result.resources:
|
|
125
|
+
logger.info(
|
|
126
|
+
"Loaded state from %s: %d resources",
|
|
127
|
+
json_file.name,
|
|
128
|
+
len(result.resources),
|
|
129
|
+
)
|
|
130
|
+
return result
|
|
131
|
+
except OSError as e:
|
|
132
|
+
logger.debug("Could not read %s: %s", json_file.name, e)
|
|
133
|
+
except json.JSONDecodeError as e:
|
|
134
|
+
logger.debug("Could not parse JSON in %s: %s", json_file.name, e)
|
|
135
|
+
|
|
136
|
+
# Fall back to running terraform show -json
|
|
137
|
+
if not self.check_terraform_available():
|
|
138
|
+
logger.warning("Terraform CLI not found in PATH")
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
if not self.check_initialized():
|
|
142
|
+
logger.warning(
|
|
143
|
+
"Terraform not initialized in %s. Run 'terraform init' first.", self.terraform_dir
|
|
144
|
+
)
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
result = subprocess.run(
|
|
149
|
+
[self.terraform_bin, "show", "-json"],
|
|
150
|
+
cwd=self.terraform_dir,
|
|
151
|
+
capture_output=True,
|
|
152
|
+
text=True,
|
|
153
|
+
timeout=self.TIMEOUT_SHOW,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if result.returncode != 0:
|
|
157
|
+
logger.warning("terraform show -json failed: %s", result.stderr)
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
if not result.stdout.strip():
|
|
161
|
+
logger.info("No terraform state found")
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
json_data = json.loads(result.stdout)
|
|
166
|
+
except json.JSONDecodeError as e:
|
|
167
|
+
logger.warning("Failed to parse terraform show output: %s", e)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
return parse_state_json(json_data)
|
|
171
|
+
|
|
172
|
+
except subprocess.TimeoutExpired:
|
|
173
|
+
logger.warning("terraform show timed out after %ds", self.TIMEOUT_SHOW)
|
|
174
|
+
return None
|
|
175
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
176
|
+
logger.warning("Error running terraform show: %s", e)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def parse_state_json(json_data: Any) -> TerraformStateResult:
|
|
181
|
+
"""Parse terraform show -json or terraform plan -json output.
|
|
182
|
+
|
|
183
|
+
Supports multiple JSON structures:
|
|
184
|
+
|
|
185
|
+
1. terraform show -json (state):
|
|
186
|
+
{
|
|
187
|
+
"values": {
|
|
188
|
+
"root_module": {
|
|
189
|
+
"resources": [...],
|
|
190
|
+
"child_modules": [...]
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
2. terraform plan -json:
|
|
196
|
+
{
|
|
197
|
+
"planned_values": {
|
|
198
|
+
"root_module": {
|
|
199
|
+
"resources": [...],
|
|
200
|
+
"child_modules": [...]
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
"prior_state": {
|
|
204
|
+
"values": {
|
|
205
|
+
"root_module": {...}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
json_data: Parsed JSON from terraform show/plan -json
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
TerraformStateResult with parsed resources (empty if input is invalid)
|
|
215
|
+
"""
|
|
216
|
+
result = TerraformStateResult()
|
|
217
|
+
|
|
218
|
+
# Validate input type
|
|
219
|
+
if not isinstance(json_data, dict):
|
|
220
|
+
logger.warning("parse_state_json received non-dict input: %s", type(json_data).__name__)
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
# Try different JSON structures in order of preference
|
|
224
|
+
root_module = None
|
|
225
|
+
|
|
226
|
+
# 1. Try "values" (terraform show -json format)
|
|
227
|
+
values = json_data.get("values")
|
|
228
|
+
if values:
|
|
229
|
+
root_module = values.get("root_module")
|
|
230
|
+
if root_module:
|
|
231
|
+
logger.debug("Using 'values.root_module' structure (terraform show format)")
|
|
232
|
+
|
|
233
|
+
# 2. Try "planned_values" (terraform plan -json format)
|
|
234
|
+
if not root_module:
|
|
235
|
+
planned_values = json_data.get("planned_values")
|
|
236
|
+
if planned_values:
|
|
237
|
+
root_module = planned_values.get("root_module")
|
|
238
|
+
if root_module:
|
|
239
|
+
logger.debug("Using 'planned_values.root_module' structure (terraform plan format)")
|
|
240
|
+
|
|
241
|
+
# 3. Try "prior_state.values" (terraform plan -json format, existing state)
|
|
242
|
+
if not root_module:
|
|
243
|
+
prior_state = json_data.get("prior_state")
|
|
244
|
+
if prior_state:
|
|
245
|
+
prior_values = prior_state.get("values")
|
|
246
|
+
if prior_values:
|
|
247
|
+
root_module = prior_values.get("root_module")
|
|
248
|
+
if root_module:
|
|
249
|
+
logger.debug("Using 'prior_state.values.root_module' structure")
|
|
250
|
+
|
|
251
|
+
if not root_module:
|
|
252
|
+
logger.debug("No root_module found in terraform JSON")
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
# Parse root module resources
|
|
256
|
+
_parse_module_resources(root_module, result, module_path="")
|
|
257
|
+
|
|
258
|
+
# Parse child modules recursively
|
|
259
|
+
for child_module in root_module.get("child_modules", []):
|
|
260
|
+
_parse_child_module(child_module, result)
|
|
261
|
+
|
|
262
|
+
logger.debug("Parsed terraform state: %d resources", len(result.resources))
|
|
263
|
+
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _parse_module_resources(
|
|
268
|
+
module_data: dict, result: TerraformStateResult, module_path: str
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Parse resources from a module in state JSON."""
|
|
271
|
+
for res in module_data.get("resources", []):
|
|
272
|
+
address = res.get("address", "")
|
|
273
|
+
resource_type = res.get("type", "")
|
|
274
|
+
name = res.get("name", "")
|
|
275
|
+
index = res.get("index") # Can be int (count) or str (for_each)
|
|
276
|
+
values = res.get("values", {})
|
|
277
|
+
|
|
278
|
+
# Ensure values is a dict
|
|
279
|
+
if not isinstance(values, dict):
|
|
280
|
+
values = {}
|
|
281
|
+
|
|
282
|
+
if resource_type and name:
|
|
283
|
+
state_resource = TerraformStateResource(
|
|
284
|
+
address=address,
|
|
285
|
+
resource_type=resource_type,
|
|
286
|
+
name=name,
|
|
287
|
+
index=index, # Keep original type (int or str)
|
|
288
|
+
values=values,
|
|
289
|
+
module_path=module_path,
|
|
290
|
+
)
|
|
291
|
+
result.resources.append(state_resource)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _parse_child_module(module_data: dict, result: TerraformStateResult) -> None:
|
|
295
|
+
"""Recursively parse a child module from state JSON."""
|
|
296
|
+
address = module_data.get("address", "")
|
|
297
|
+
|
|
298
|
+
# Extract module path from address (e.g., "module.vpc" -> "vpc")
|
|
299
|
+
module_path = ""
|
|
300
|
+
if address.startswith("module."):
|
|
301
|
+
# Handle nested modules: "module.vpc.module.subnets" -> "vpc.subnets"
|
|
302
|
+
parts = address.split(".")
|
|
303
|
+
module_parts = []
|
|
304
|
+
for i, part in enumerate(parts):
|
|
305
|
+
if part != "module" and (i == 0 or parts[i - 1] == "module"):
|
|
306
|
+
module_parts.append(part)
|
|
307
|
+
module_path = ".".join(module_parts)
|
|
308
|
+
|
|
309
|
+
_parse_module_resources(module_data, result, module_path)
|
|
310
|
+
|
|
311
|
+
# Recurse into nested child modules
|
|
312
|
+
for child in module_data.get("child_modules", []):
|
|
313
|
+
_parse_child_module(child, result)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def map_state_to_resource_id(state_address: str) -> str:
|
|
317
|
+
"""Convert terraform state address to parser resource full_id format.
|
|
318
|
+
|
|
319
|
+
Examples:
|
|
320
|
+
"aws_vpc.main" -> "aws_vpc.main"
|
|
321
|
+
"aws_subnet.public[0]" -> "aws_subnet.public"
|
|
322
|
+
"module.vpc.aws_subnet.public[0]" -> "vpc.aws_subnet.public"
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
state_address: Resource address from terraform state
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Resource ID matching parser's full_id format
|
|
329
|
+
"""
|
|
330
|
+
# Remove index brackets
|
|
331
|
+
address = re.sub(r"\[\d+\]", "", state_address)
|
|
332
|
+
address = re.sub(r'\["[^"]+"\]', "", address)
|
|
333
|
+
|
|
334
|
+
# Handle module prefix
|
|
335
|
+
if address.startswith("module."):
|
|
336
|
+
parts = address.split(".")
|
|
337
|
+
# module.vpc.aws_subnet.public -> vpc.aws_subnet.public
|
|
338
|
+
module_parts = []
|
|
339
|
+
resource_parts = []
|
|
340
|
+
|
|
341
|
+
i = 0
|
|
342
|
+
while i < len(parts):
|
|
343
|
+
if parts[i] == "module" and i + 1 < len(parts):
|
|
344
|
+
module_parts.append(parts[i + 1])
|
|
345
|
+
i += 2
|
|
346
|
+
else:
|
|
347
|
+
resource_parts = parts[i:]
|
|
348
|
+
break
|
|
349
|
+
|
|
350
|
+
if module_parts and resource_parts:
|
|
351
|
+
module_path = ".".join(module_parts)
|
|
352
|
+
resource_part = ".".join(resource_parts)
|
|
353
|
+
return f"{module_path}.{resource_part}"
|
|
354
|
+
|
|
355
|
+
return address
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Variable Resolver Module
|
|
3
|
+
|
|
4
|
+
Parses and resolves Terraform variables, locals, and interpolations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, Optional, Union
|
|
11
|
+
|
|
12
|
+
import hcl2
|
|
13
|
+
from lark.exceptions import UnexpectedInput, UnexpectedToken
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class VariableResolver:
|
|
19
|
+
"""Resolves Terraform variables and locals from tfvars and .tf files."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, directory: Union[str, Path]):
|
|
22
|
+
"""Initialize the resolver by parsing files in the given directory.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
directory: Path to directory containing Terraform files
|
|
26
|
+
"""
|
|
27
|
+
self.directory = Path(directory)
|
|
28
|
+
self._variables: Dict[str, Any] = {}
|
|
29
|
+
self._locals: Dict[str, Any] = {}
|
|
30
|
+
|
|
31
|
+
# Parse files in order of precedence
|
|
32
|
+
self._parse_variable_defaults()
|
|
33
|
+
self._parse_tfvars()
|
|
34
|
+
self._parse_locals()
|
|
35
|
+
|
|
36
|
+
def _parse_tfvars(self) -> None:
|
|
37
|
+
"""Parse .tfvars and .auto.tfvars files for variable values.
|
|
38
|
+
|
|
39
|
+
Files are parsed in alphabetical order, with later files overriding earlier ones.
|
|
40
|
+
terraform.tfvars is parsed last to give it highest precedence.
|
|
41
|
+
"""
|
|
42
|
+
tfvars_files = []
|
|
43
|
+
|
|
44
|
+
# Collect .auto.tfvars files
|
|
45
|
+
tfvars_files.extend(sorted(self.directory.glob("*.auto.tfvars")))
|
|
46
|
+
|
|
47
|
+
# Add terraform.tfvars last (highest precedence)
|
|
48
|
+
terraform_tfvars = self.directory / "terraform.tfvars"
|
|
49
|
+
if terraform_tfvars.exists():
|
|
50
|
+
tfvars_files.append(terraform_tfvars)
|
|
51
|
+
|
|
52
|
+
for tfvars_file in tfvars_files:
|
|
53
|
+
try:
|
|
54
|
+
with open(tfvars_file, "r", encoding="utf-8") as f:
|
|
55
|
+
content = hcl2.load(f)
|
|
56
|
+
for key, value in content.items():
|
|
57
|
+
self._variables[key] = value
|
|
58
|
+
except OSError as e:
|
|
59
|
+
logger.warning("Could not read tfvars file %s: %s", tfvars_file, e)
|
|
60
|
+
except (UnexpectedInput, UnexpectedToken) as e:
|
|
61
|
+
logger.warning("Could not parse tfvars file %s: %s", tfvars_file, e)
|
|
62
|
+
|
|
63
|
+
def _parse_locals(self) -> None:
|
|
64
|
+
"""Parse locals blocks from all .tf files."""
|
|
65
|
+
for tf_file in self.directory.glob("*.tf"):
|
|
66
|
+
try:
|
|
67
|
+
with open(tf_file, "r", encoding="utf-8") as f:
|
|
68
|
+
content = hcl2.load(f)
|
|
69
|
+
|
|
70
|
+
for locals_block in content.get("locals", []):
|
|
71
|
+
if isinstance(locals_block, dict):
|
|
72
|
+
for key, value in locals_block.items():
|
|
73
|
+
self._locals[key] = value
|
|
74
|
+
except OSError as e:
|
|
75
|
+
logger.warning("Could not read file %s: %s", tf_file, e)
|
|
76
|
+
except (UnexpectedInput, UnexpectedToken) as e:
|
|
77
|
+
logger.warning("Could not parse locals from %s: %s", tf_file, e)
|
|
78
|
+
|
|
79
|
+
def _parse_variable_defaults(self) -> None:
|
|
80
|
+
"""Parse variable blocks for default values from all .tf files."""
|
|
81
|
+
for tf_file in self.directory.glob("*.tf"):
|
|
82
|
+
try:
|
|
83
|
+
with open(tf_file, "r", encoding="utf-8") as f:
|
|
84
|
+
content = hcl2.load(f)
|
|
85
|
+
|
|
86
|
+
for variable_block in content.get("variable", []):
|
|
87
|
+
if isinstance(variable_block, dict):
|
|
88
|
+
for var_name, var_config in variable_block.items():
|
|
89
|
+
if isinstance(var_config, dict):
|
|
90
|
+
default = var_config.get("default")
|
|
91
|
+
if default is not None:
|
|
92
|
+
self._variables[var_name] = default
|
|
93
|
+
elif isinstance(var_config, list) and var_config:
|
|
94
|
+
# HCL2 sometimes returns list of configs
|
|
95
|
+
config = var_config[0]
|
|
96
|
+
if isinstance(config, dict):
|
|
97
|
+
default = config.get("default")
|
|
98
|
+
if default is not None:
|
|
99
|
+
self._variables[var_name] = default
|
|
100
|
+
except OSError as e:
|
|
101
|
+
logger.warning("Could not read file %s: %s", tf_file, e)
|
|
102
|
+
except (UnexpectedInput, UnexpectedToken) as e:
|
|
103
|
+
logger.warning("Could not parse variables from %s: %s", tf_file, e)
|
|
104
|
+
|
|
105
|
+
def get_variable(self, name: str) -> Optional[Any]:
|
|
106
|
+
"""Get a variable value by name.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
name: The variable name (without 'var.' prefix)
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The variable value, or None if not found
|
|
113
|
+
"""
|
|
114
|
+
return self._variables.get(name)
|
|
115
|
+
|
|
116
|
+
def get_local(self, name: str) -> Optional[Any]:
|
|
117
|
+
"""Get a local value by name.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
name: The local name (without 'local.' prefix)
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
The local value, or None if not found
|
|
124
|
+
"""
|
|
125
|
+
return self._locals.get(name)
|
|
126
|
+
|
|
127
|
+
def resolve(self, value: Any) -> Any:
|
|
128
|
+
"""Resolve interpolations in a value.
|
|
129
|
+
|
|
130
|
+
Handles ${var.name} and ${local.name} interpolations.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
value: The value to resolve (string or other type)
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The resolved value with interpolations replaced,
|
|
137
|
+
or the original value if no interpolations or resolution failed
|
|
138
|
+
"""
|
|
139
|
+
if value is None:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
if not isinstance(value, str):
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
# Pattern to match ${var.name} or ${local.name}
|
|
146
|
+
pattern = r"\$\{(var|local)\.(\w+)\}"
|
|
147
|
+
|
|
148
|
+
def replace_interpolation(match: re.Match) -> str:
|
|
149
|
+
ref_type = match.group(1)
|
|
150
|
+
ref_name = match.group(2)
|
|
151
|
+
|
|
152
|
+
if ref_type == "var":
|
|
153
|
+
resolved = self.get_variable(ref_name)
|
|
154
|
+
else: # local
|
|
155
|
+
resolved = self.get_local(ref_name)
|
|
156
|
+
|
|
157
|
+
if resolved is not None:
|
|
158
|
+
return str(resolved)
|
|
159
|
+
else:
|
|
160
|
+
# Keep original if not resolvable
|
|
161
|
+
return match.group(0)
|
|
162
|
+
|
|
163
|
+
return re.sub(pattern, replace_interpolation, value)
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def truncate_name(name: str, max_length: int = 25) -> str:
|
|
167
|
+
"""Truncate a name to a maximum length with ellipsis.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
name: The name to truncate
|
|
171
|
+
max_length: Maximum length (default 25)
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The truncated name with '...' suffix if it exceeds max_length
|
|
175
|
+
"""
|
|
176
|
+
if len(name) <= max_length:
|
|
177
|
+
return name
|
|
178
|
+
|
|
179
|
+
# Leave room for '...' suffix
|
|
180
|
+
return name[: max_length - 3] + "..."
|