diagram-to-iac 1.0.1__py3-none-any.whl → 1.0.3__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.
@@ -42,6 +42,7 @@ from diagram_to_iac.tools.llm_utils.router import get_llm, LLMRouter
42
42
  from diagram_to_iac.core.agent_base import AgentBase
43
43
  from diagram_to_iac.core.memory import create_memory, LangGraphMemoryAdapter
44
44
  from diagram_to_iac.services.observability import log_event
45
+ from diagram_to_iac.core.config_loader import get_config, get_config_value
45
46
  from .parser import classify_terraform_error
46
47
 
47
48
 
@@ -125,36 +126,54 @@ class TerraformAgent(AgentBase):
125
126
  # Store memory type for tool initialization
126
127
  self.memory_type = memory_type
127
128
 
128
- # Load configuration
129
+ # Load configuration using centralized system
129
130
  if config_path is None:
130
131
  base_dir = os.path.dirname(os.path.abspath(__file__))
131
132
  config_path = os.path.join(base_dir, "config.yaml")
132
133
  self.logger.debug(f"Default config path set to: {config_path}")
133
134
 
134
135
  try:
135
- with open(config_path, "r") as f:
136
- self.config = yaml.safe_load(f)
137
- if self.config is None:
136
+ # Use centralized configuration loading with hierarchical merging
137
+ base_config = get_config()
138
+
139
+ # Load agent-specific config if provided
140
+ agent_config = {}
141
+ if config_path and os.path.exists(config_path):
142
+ with open(config_path, 'r') as f:
143
+ agent_config = yaml.safe_load(f) or {}
144
+
145
+ # Deep merge base config with agent-specific overrides
146
+ self.config = self._deep_merge(base_config, agent_config)
147
+ self.logger.info(f"Configuration loaded successfully from centralized system")
148
+ if "terraform_token_env" not in self.config:
149
+ self.config["terraform_token_env"] = "TFE_TOKEN"
150
+ except Exception as e:
151
+ self.logger.warning(f"Failed to load configuration via centralized system: {e}. Using fallback.")
152
+ # Fallback to direct YAML loading for backward compatibility
153
+ try:
154
+ with open(config_path, "r") as f:
155
+ self.config = yaml.safe_load(f)
156
+ if self.config is None:
157
+ self.logger.warning(
158
+ f"Configuration file at {config_path} is empty. Using defaults."
159
+ )
160
+ self._set_default_config()
161
+ else:
162
+ self.logger.info(
163
+ f"Configuration loaded successfully from {config_path}"
164
+ )
165
+ if "terraform_token_env" not in self.config:
166
+ self.config["terraform_token_env"] = "TFE_TOKEN"
167
+ except FileNotFoundError:
138
168
  self.logger.warning(
139
- f"Configuration file at {config_path} is empty. Using defaults."
169
+ f"Configuration file not found at {config_path}. Using defaults."
140
170
  )
141
171
  self._set_default_config()
142
- else:
143
- self.logger.info(
144
- f"Configuration loaded successfully from {config_path}"
172
+ except yaml.YAMLError as e:
173
+ self.logger.error(
174
+ f"Error parsing YAML configuration: {e}. Using defaults.", exc_info=True
145
175
  )
146
- if "terraform_token_env" not in self.config:
147
- self.config["terraform_token_env"] = "TFE_TOKEN"
148
- except FileNotFoundError:
149
- self.logger.warning(
150
- f"Configuration file not found at {config_path}. Using defaults."
151
- )
152
- self._set_default_config()
153
- except yaml.YAMLError as e:
154
- self.logger.error(
155
- f"Error parsing YAML configuration: {e}. Using defaults.", exc_info=True
156
- )
157
- self._set_default_config()
176
+ self._set_default_config()
158
177
 
159
178
  # Initialize enhanced LLM router
160
179
  self.llm_router = LLMRouter()
@@ -178,20 +197,49 @@ class TerraformAgent(AgentBase):
178
197
  self.logger.info("TerraformAgent initialized successfully")
179
198
 
180
199
  def _set_default_config(self):
181
- """Set default configuration values."""
200
+ """Set default configuration values using centralized system."""
182
201
  self.logger.info("Setting default configuration for TerraformAgent")
183
202
  self.config = {
184
- "llm": {"model_name": "gpt-4o-mini", "temperature": 0.1},
203
+ "llm": {
204
+ "model_name": get_config_value("ai.default_model", "gpt-4o-mini"),
205
+ "temperature": get_config_value("ai.default_temperature", 0.1)
206
+ },
185
207
  "routing_keys": {
186
- "terraform_init": "ROUTE_TO_TF_INIT",
187
- "terraform_plan": "ROUTE_TO_TF_PLAN",
188
- "terraform_apply": "ROUTE_TO_TF_APPLY",
189
- "open_issue": "ROUTE_TO_OPEN_ISSUE",
190
- "end": "ROUTE_TO_END",
208
+ "terraform_init": get_config_value("routing.tokens.terraform_init", "ROUTE_TO_TF_INIT"),
209
+ "terraform_plan": get_config_value("routing.tokens.terraform_plan", "ROUTE_TO_TF_PLAN"),
210
+ "terraform_apply": get_config_value("routing.tokens.terraform_apply", "ROUTE_TO_TF_APPLY"),
211
+ "open_issue": get_config_value("routing.tokens.open_issue", "ROUTE_TO_OPEN_ISSUE"),
212
+ "end": get_config_value("routing.tokens.end", "ROUTE_TO_END"),
191
213
  },
192
214
  "terraform_token_env": "TFE_TOKEN",
215
+ "tools": {
216
+ "terraform": {
217
+ "timeout": get_config_value("network.terraform_timeout", 300),
218
+ "default_auto_approve": get_config_value("tools.terraform.default_auto_approve", True),
219
+ "default_plan_file": get_config_value("tools.terraform.default_plan_file", "plan.tfplan")
220
+ }
221
+ }
193
222
  }
194
223
 
224
+ def _deep_merge(self, base: dict, overlay: dict) -> dict:
225
+ """
226
+ Deep merge two dictionaries, with overlay taking precedence.
227
+
228
+ Args:
229
+ base: Base dictionary
230
+ overlay: Dictionary to overlay on base
231
+
232
+ Returns:
233
+ Merged dictionary
234
+ """
235
+ result = base.copy()
236
+ for key, value in overlay.items():
237
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
238
+ result[key] = self._deep_merge(result[key], value)
239
+ else:
240
+ result[key] = value
241
+ return result
242
+
195
243
  def _initialize_tools(self):
196
244
  """Initialize the Terraform tools following the established pattern."""
197
245
  try:
@@ -0,0 +1,281 @@
1
+ # src/diagram_to_iac/core/config_loader.py
2
+ """
3
+ Central configuration loader for diagram-to-iac project.
4
+ Handles loading and merging configuration from multiple sources with environment variable overrides.
5
+ """
6
+
7
+ import os
8
+ import yaml
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import Dict, Any, Optional, Union
12
+ from functools import lru_cache
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ class ConfigLoader:
17
+ """
18
+ Central configuration management for the diagram-to-iac project.
19
+ Loads configuration from YAML files and provides environment variable override capability.
20
+ """
21
+
22
+ def __init__(self, app_config_path: Optional[str] = None, system_config_path: Optional[str] = None):
23
+ """
24
+ Initialize ConfigLoader with optional custom config paths.
25
+
26
+ Args:
27
+ app_config_path: Path to application config file (default: src/diagram_to_iac/config.yaml)
28
+ system_config_path: Path to system config file (default: config/system.yaml)
29
+ """
30
+ self.logger = logging.getLogger(self.__class__.__name__)
31
+
32
+ # Set default paths
33
+ self.base_path = Path(__file__).parent.parent.parent.parent # diagram-to-iac root
34
+ self.app_config_path = Path(app_config_path) if app_config_path else self.base_path / "src" / "diagram_to_iac" / "config.yaml"
35
+ self.system_config_path = Path(system_config_path) if system_config_path else self.base_path / "config" / "system.yaml"
36
+
37
+ # Cache for loaded configs
38
+ self._app_config = None
39
+ self._system_config = None
40
+ self._merged_config = None
41
+
42
+ @lru_cache(maxsize=1)
43
+ def get_config(self) -> Dict[str, Any]:
44
+ """
45
+ Get the complete merged configuration with environment variable overrides.
46
+
47
+ Returns:
48
+ Merged configuration dictionary
49
+ """
50
+ if self._merged_config is None:
51
+ self._merged_config = self._load_and_merge_configs()
52
+ return self._merged_config
53
+
54
+ def _load_and_merge_configs(self) -> Dict[str, Any]:
55
+ """
56
+ Load and merge all configuration sources.
57
+
58
+ Returns:
59
+ Merged configuration dictionary
60
+ """
61
+ # Load base configs
62
+ app_config = self._load_app_config()
63
+ system_config = self._load_system_config()
64
+
65
+ # Start with system config as base, overlay app config
66
+ merged = self._deep_merge(system_config, app_config)
67
+
68
+ # Apply environment variable overrides
69
+ merged = self._apply_env_overrides(merged)
70
+
71
+ self.logger.debug("Configuration loaded and merged successfully")
72
+ return merged
73
+
74
+ def _load_app_config(self) -> Dict[str, Any]:
75
+ """Load application configuration from YAML file."""
76
+ if self._app_config is None:
77
+ try:
78
+ if self.app_config_path.exists():
79
+ with open(self.app_config_path, 'r') as f:
80
+ self._app_config = yaml.safe_load(f) or {}
81
+ self.logger.debug(f"Loaded app config from {self.app_config_path}")
82
+ else:
83
+ self.logger.warning(f"App config file not found: {self.app_config_path}")
84
+ self._app_config = {}
85
+ except Exception as e:
86
+ self.logger.error(f"Failed to load app config: {e}")
87
+ self._app_config = {}
88
+ return self._app_config
89
+
90
+ def _load_system_config(self) -> Dict[str, Any]:
91
+ """Load system configuration from YAML file."""
92
+ if self._system_config is None:
93
+ try:
94
+ if self.system_config_path.exists():
95
+ with open(self.system_config_path, 'r') as f:
96
+ self._system_config = yaml.safe_load(f) or {}
97
+ self.logger.debug(f"Loaded system config from {self.system_config_path}")
98
+ else:
99
+ self.logger.warning(f"System config file not found: {self.system_config_path}")
100
+ self._system_config = {}
101
+ except Exception as e:
102
+ self.logger.error(f"Failed to load system config: {e}")
103
+ self._system_config = {}
104
+ return self._system_config
105
+
106
+ def _deep_merge(self, base: Dict[str, Any], overlay: Dict[str, Any]) -> Dict[str, Any]:
107
+ """
108
+ Deep merge two dictionaries, with overlay taking precedence.
109
+
110
+ Args:
111
+ base: Base dictionary
112
+ overlay: Dictionary to overlay on base
113
+
114
+ Returns:
115
+ Merged dictionary
116
+ """
117
+ result = base.copy()
118
+
119
+ for key, value in overlay.items():
120
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
121
+ result[key] = self._deep_merge(result[key], value)
122
+ else:
123
+ result[key] = value
124
+
125
+ return result
126
+
127
+ def _apply_env_overrides(self, config: Dict[str, Any]) -> Dict[str, Any]:
128
+ """
129
+ Apply environment variable overrides to configuration.
130
+ Environment variables should be in the format: DIAGRAM_TO_IAC_<SECTION>_<KEY>
131
+
132
+ Args:
133
+ config: Base configuration dictionary
134
+
135
+ Returns:
136
+ Configuration with environment overrides applied
137
+ """
138
+ result = config.copy()
139
+ env_prefix = "DIAGRAM_TO_IAC_"
140
+
141
+ # Get list of allowed overrides from config
142
+ allowed_overrides = config.get("environment_overrides", {}).get("allowed_overrides", [])
143
+
144
+ for env_var, env_value in os.environ.items():
145
+ if not env_var.startswith(env_prefix):
146
+ continue
147
+
148
+ # Parse environment variable name
149
+ # e.g., DIAGRAM_TO_IAC_NETWORK_API_TIMEOUT -> network.api_timeout
150
+ var_path = env_var[len(env_prefix):].lower().replace('_', '.')
151
+
152
+ # Check if override is allowed
153
+ if var_path not in allowed_overrides:
154
+ self.logger.debug(f"Environment override not allowed: {var_path}")
155
+ continue
156
+
157
+ # Convert string value to appropriate type
158
+ converted_value = self._convert_env_value(env_value)
159
+
160
+ # Apply override to config
161
+ self._set_nested_value(result, var_path, converted_value)
162
+ self.logger.debug(f"Applied environment override: {var_path} = {converted_value}")
163
+
164
+ return result
165
+
166
+ def _convert_env_value(self, value: str) -> Union[str, int, float, bool]:
167
+ """
168
+ Convert environment variable string value to appropriate type.
169
+
170
+ Args:
171
+ value: String value from environment variable
172
+
173
+ Returns:
174
+ Converted value
175
+ """
176
+ # Handle boolean values
177
+ if value.lower() in ("true", "yes", "1", "on"):
178
+ return True
179
+ elif value.lower() in ("false", "no", "0", "off"):
180
+ return False
181
+
182
+ # Handle numeric values
183
+ try:
184
+ if '.' in value:
185
+ return float(value)
186
+ else:
187
+ return int(value)
188
+ except ValueError:
189
+ pass
190
+
191
+ # Return as string
192
+ return value
193
+
194
+ def _set_nested_value(self, config: Dict[str, Any], path: str, value: Any) -> None:
195
+ """
196
+ Set a nested value in configuration using dot notation.
197
+
198
+ Args:
199
+ config: Configuration dictionary to modify
200
+ path: Dot-separated path (e.g., "network.api_timeout")
201
+ value: Value to set
202
+ """
203
+ keys = path.split('.')
204
+ current = config
205
+
206
+ # Navigate to the parent of the target key
207
+ for key in keys[:-1]:
208
+ if key not in current:
209
+ current[key] = {}
210
+ current = current[key]
211
+
212
+ # Set the final value
213
+ current[keys[-1]] = value
214
+
215
+ def get_section(self, section: str) -> Dict[str, Any]:
216
+ """
217
+ Get a specific configuration section.
218
+
219
+ Args:
220
+ section: Section name (e.g., "network", "ai", "routing")
221
+
222
+ Returns:
223
+ Configuration section dictionary
224
+ """
225
+ return self.get_config().get(section, {})
226
+
227
+ def get_value(self, path: str, default: Any = None) -> Any:
228
+ """
229
+ Get a configuration value using dot notation.
230
+
231
+ Args:
232
+ path: Dot-separated path (e.g., "network.api_timeout")
233
+ default: Default value if path not found
234
+
235
+ Returns:
236
+ Configuration value or default
237
+ """
238
+ keys = path.split('.')
239
+ current = self.get_config()
240
+
241
+ try:
242
+ for key in keys:
243
+ current = current[key]
244
+ return current
245
+ except (KeyError, TypeError):
246
+ return default
247
+
248
+ def reload(self) -> None:
249
+ """Reload configuration from files (clears cache)."""
250
+ self._app_config = None
251
+ self._system_config = None
252
+ self._merged_config = None
253
+ self.get_config.cache_clear()
254
+ self.logger.debug("Configuration cache cleared, will reload on next access")
255
+
256
+
257
+ # Global configuration loader instance
258
+ _config_loader = None
259
+
260
+ def get_config_loader() -> ConfigLoader:
261
+ """Get the global configuration loader instance."""
262
+ global _config_loader
263
+ if _config_loader is None:
264
+ _config_loader = ConfigLoader()
265
+ return _config_loader
266
+
267
+ def get_config() -> Dict[str, Any]:
268
+ """Get the complete merged configuration."""
269
+ return get_config_loader().get_config()
270
+
271
+ def get_config_section(section: str) -> Dict[str, Any]:
272
+ """Get a specific configuration section."""
273
+ return get_config_loader().get_section(section)
274
+
275
+ def get_config_value(path: str, default: Any = None) -> Any:
276
+ """Get a configuration value using dot notation."""
277
+ return get_config_loader().get_value(path, default)
278
+
279
+ def reload_config() -> None:
280
+ """Reload configuration from files."""
281
+ get_config_loader().reload()
@@ -2,7 +2,7 @@ from typing import Any, Dict, List, Optional
2
2
  import json
3
3
  import os
4
4
  from pathlib import Path
5
- from subprocess import check_output, CalledProcessError
5
+ from subprocess import check_output, CalledProcessError, TimeoutExpired
6
6
  # Intended to be a thin wrapper over LangGraph's StateGraph node store.
7
7
  # For now, a simple dictionary-based state.
8
8
 
@@ -126,7 +126,12 @@ def save_agent_state(state: Dict[str, Any]) -> None:
126
126
  def current_git_sha() -> Optional[str]:
127
127
  """Return the current git commit SHA, or ``None`` if unavailable."""
128
128
  try:
129
- return check_output(["git", "rev-parse", "HEAD"], text=True).strip()
129
+ return check_output(
130
+ ["git", "rev-parse", "HEAD"],
131
+ text=True,
132
+ timeout=5,
133
+ env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}
134
+ ).strip()
130
135
  except Exception:
131
136
  return None
132
137
 
@@ -7,6 +7,14 @@ import google.generativeai as genai
7
7
  import googleapiclient.discovery
8
8
  from concurrent.futures import ThreadPoolExecutor, TimeoutError
9
9
 
10
+ # Import centralized configuration
11
+ try:
12
+ from diagram_to_iac.core.config_loader import get_config_value
13
+ except ImportError:
14
+ # Fallback if config system not available
15
+ def get_config_value(path: str, default=None):
16
+ return default
17
+
10
18
  def test_openai_api():
11
19
  try:
12
20
  if not os.environ.get("OPENAI_API_KEY"):
@@ -14,7 +22,10 @@ def test_openai_api():
14
22
  return False
15
23
  client = OpenAI()
16
24
 
17
- # Run the API call with a 10-second timeout
25
+ # Get timeout from configuration
26
+ api_timeout = get_config_value("network.api_timeout", 10)
27
+
28
+ # Run the API call with configured timeout
18
29
  with ThreadPoolExecutor(max_workers=1) as executor:
19
30
  future = executor.submit(
20
31
  client.chat.completions.create,
@@ -23,9 +34,9 @@ def test_openai_api():
23
34
  max_tokens=10
24
35
  )
25
36
  try:
26
- response = future.result(timeout=10)
37
+ response = future.result(timeout=api_timeout)
27
38
  except TimeoutError:
28
- print("❌ OpenAI API error: request timed out.")
39
+ print(f"❌ OpenAI API error: request timed out after {api_timeout}s.")
29
40
  return False
30
41
  return True
31
42
  except Exception as e:
@@ -41,13 +52,16 @@ def test_gemini_api():
41
52
  genai.configure(api_key=google_api_key)
42
53
  model = genai.GenerativeModel('gemini-2.0-flash') # Corrected model name
43
54
 
44
- # Run the API call with a 10-second timeout
55
+ # Get timeout from configuration
56
+ api_timeout = get_config_value("network.api_timeout", 10)
57
+
58
+ # Run the API call with configured timeout
45
59
  with ThreadPoolExecutor(max_workers=1) as executor:
46
60
  future = executor.submit(model.generate_content, "Hello, are you working?")
47
61
  try:
48
- response = future.result(timeout=10)
62
+ response = future.result(timeout=api_timeout)
49
63
  except TimeoutError:
50
- print("❌ Gemini API error: request timed out.")
64
+ print(f"❌ Gemini API error: request timed out after {api_timeout}s.")
51
65
  return False
52
66
  return True
53
67
  except Exception as e:
@@ -61,7 +75,10 @@ def test_anthropic_api():
61
75
  return False
62
76
  client = Anthropic()
63
77
 
64
- # Run the API call with a 10-second timeout
78
+ # Get timeout from configuration
79
+ api_timeout = get_config_value("network.api_timeout", 10)
80
+
81
+ # Run the API call with configured timeout
65
82
  with ThreadPoolExecutor(max_workers=1) as executor:
66
83
  future = executor.submit(
67
84
  client.messages.create,
@@ -70,9 +87,9 @@ def test_anthropic_api():
70
87
  messages=[{"role": "user", "content": "Hello, are you working?"}]
71
88
  )
72
89
  try:
73
- response = future.result(timeout=10)
90
+ response = future.result(timeout=api_timeout)
74
91
  except TimeoutError:
75
- print("❌ Anthropic API error: request timed out.")
92
+ print(f"❌ Anthropic API error: request timed out after {api_timeout}s.")
76
93
  return False
77
94
  return True
78
95
  except Exception as e:
@@ -92,7 +109,10 @@ def test_github_api():
92
109
  "Accept": "application/vnd.github.v3+json"
93
110
  }
94
111
 
95
- # Run the API call with a 10-second timeout
112
+ # Get timeout from configuration
113
+ github_timeout = get_config_value("network.github_timeout", 15)
114
+
115
+ # Run the API call with configured timeout
96
116
  with ThreadPoolExecutor(max_workers=1) as executor:
97
117
  future = executor.submit(
98
118
  requests.get,
@@ -100,9 +120,9 @@ def test_github_api():
100
120
  headers=headers
101
121
  )
102
122
  try:
103
- response = future.result(timeout=10)
123
+ response = future.result(timeout=github_timeout)
104
124
  except TimeoutError:
105
- print("❌ GitHub API error: request timed out.")
125
+ print(f"❌ GitHub API error: request timed out after {github_timeout}s.")
106
126
  return False
107
127
 
108
128
  if response.status_code == 200:
@@ -119,33 +139,53 @@ def test_github_api():
119
139
 
120
140
  def test_Terraform_API():
121
141
  try:
122
- if not os.environ.get("TFE_TOKEN"):
142
+ tfe_token = os.environ.get("TFE_TOKEN")
143
+ if not tfe_token:
123
144
  print("❌ Terraform API error: TFE_TOKEN environment variable not set.")
124
145
  return False
146
+
125
147
  headers = {
126
- "Authorization": f"Bearer {os.environ.get('TFE_TOKEN')}",
148
+ "Authorization": f"Bearer {tfe_token}",
127
149
  "Content-Type": "application/vnd.api+json"
128
150
  }
129
- # Run the API call with a 10-second timeout
151
+
152
+ # Get timeout from configuration
153
+ terraform_timeout = get_config_value("network.terraform_timeout", 30)
154
+
155
+ # Test account details endpoint (more reliable than organizations)
156
+ # Run the API call with configured timeout
130
157
  with ThreadPoolExecutor(max_workers=1) as executor:
131
158
  future = executor.submit(
132
159
  requests.get,
133
- "https://app.terraform.io/api/v2/organizations",
160
+ "https://app.terraform.io/api/v2/account/details",
134
161
  headers=headers
135
162
  )
136
163
  try:
137
- response = future.result(timeout=10)
164
+ response = future.result(timeout=terraform_timeout)
138
165
  except TimeoutError:
139
- print("❌ Terraform API error: request timed out.")
166
+ print(f"❌ Terraform API error: request timed out after {terraform_timeout}s.")
140
167
  return False
168
+
141
169
  if response.status_code == 200:
142
- org_data = response.json()
143
- # print(f"✅ Terraform API works! Organizations: {org_data}")
170
+ print("✅ Terraform API works! Account access verified.")
144
171
  return True
145
172
  else:
146
173
  print(f"❌ Terraform API error: Status code {response.status_code}")
147
174
  if hasattr(response, 'text'):
148
175
  print(f" Response body: {response.text}")
176
+
177
+ # Provide specific guidance for common error codes
178
+ if response.status_code == 401:
179
+ print(" This is an authentication error. Common causes:")
180
+ print(" - TFE_TOKEN is invalid or expired")
181
+ print(" - Token format is incorrect (should be a Terraform Cloud API token)")
182
+ print(" - Token doesn't have sufficient permissions")
183
+ print(" - Please verify your token at: https://app.terraform.io/app/settings/tokens")
184
+ elif response.status_code == 403:
185
+ print(" This is a permission error. The token is valid but lacks access rights.")
186
+ elif response.status_code == 404:
187
+ print(" The API endpoint was not found. Check if the URL is correct.")
188
+
149
189
  return False
150
190
  except requests.exceptions.ConnectionError as e:
151
191
  print(f"❌ Terraform API error: Connection failed - {str(e)}")
@@ -167,6 +207,7 @@ def test_Terraform_API():
167
207
  print(f"❌ Terraform API error: Unexpected error - {str(e)}")
168
208
  return False
169
209
 
210
+
170
211
  def test_all_apis():
171
212
  print("Hello from the test workflow!")
172
213