socialseed-e2e 0.1.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.
@@ -0,0 +1,604 @@
1
+ """
2
+ ApiConfigLoader - Centralized Configuration Management
3
+
4
+ This module provides a centralized way to load and manage API configuration
5
+ from the api.conf file. It supports environment variable substitution and
6
+ provides easy access to all service configurations.
7
+ """
8
+
9
+ # Try to import yaml, fallback to JSON if not available
10
+ import json
11
+ import os
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional, Union
16
+
17
+ HAS_YAML = False
18
+ try:
19
+ import yaml # type: ignore
20
+
21
+ HAS_YAML = True
22
+ except ImportError:
23
+ pass # YAML not available, will use JSON
24
+
25
+ # Import TemplateEngine for default config generation
26
+ from socialseed_e2e.utils import TemplateEngine
27
+
28
+
29
+ @dataclass
30
+ class ServiceEndpoint:
31
+ """Represents a single API endpoint configuration."""
32
+
33
+ name: str
34
+ path: str
35
+ method: str = "POST"
36
+ requires_auth: bool = False
37
+
38
+
39
+ @dataclass
40
+ class ServiceConfig:
41
+ """Complete configuration for a microservice."""
42
+
43
+ name: str
44
+ base_url: str
45
+ health_endpoint: str = "/actuator/health"
46
+ port: int = 8080
47
+ maven_module: str = ""
48
+ timeout: int = 30000
49
+ headers: Dict[str, str] = field(default_factory=dict)
50
+ auto_start: bool = True
51
+ required: bool = True
52
+ endpoints: Dict[str, str] = field(default_factory=dict)
53
+
54
+
55
+ @dataclass
56
+ class ApiGatewayConfig:
57
+ """API Gateway configuration."""
58
+
59
+ enabled: bool = False
60
+ url: str = ""
61
+ prefix: str = ""
62
+ auth_type: str = "none"
63
+ auth_token: Optional[str] = None
64
+ api_key_header: Optional[str] = None
65
+ api_key_value: Optional[str] = None
66
+
67
+
68
+ @dataclass
69
+ class DatabaseConfig:
70
+ """Database connection configuration."""
71
+
72
+ host: str = "localhost"
73
+ port: int = 5432
74
+ database: str = ""
75
+ username: str = ""
76
+ password: str = ""
77
+ enabled: bool = False
78
+
79
+
80
+ @dataclass
81
+ class TestDataConfig:
82
+ """Test data generation configuration."""
83
+
84
+ email_domain: str = "test.socialseed.com"
85
+ password: str = "StrongPass123!"
86
+ username_prefix: str = "testuser"
87
+ step_delay: int = 100
88
+ async_timeout: int = 10000
89
+ max_retries: int = 3
90
+ retry_backoff_ms: int = 1000
91
+
92
+
93
+ @dataclass
94
+ class SecurityConfig:
95
+ """Security and SSL configuration."""
96
+
97
+ verify_ssl: bool = True
98
+ ssl_cert: Optional[str] = None
99
+ ssl_key: Optional[str] = None
100
+ ssl_ca: Optional[str] = None
101
+ test_tokens: Dict[str, str] = field(default_factory=dict)
102
+
103
+
104
+ @dataclass
105
+ class ReportingConfig:
106
+ """Test reporting configuration."""
107
+
108
+ format: str = "console"
109
+ save_logs: bool = True
110
+ log_dir: str = "./logs"
111
+ include_payloads: bool = False
112
+ screenshot_on_failure: bool = False
113
+
114
+
115
+ @dataclass
116
+ class AppConfig:
117
+ """Main application configuration container."""
118
+
119
+ environment: str = "dev"
120
+ timeout: int = 30000
121
+ user_agent: str = "SocialSeed-E2E-Agent/2.0"
122
+ verification_level: str = "strict"
123
+ verbose: bool = True
124
+ project_name: str = "SocialSeed"
125
+ project_version: str = "0.0.0"
126
+ api_gateway: ApiGatewayConfig = field(default_factory=ApiGatewayConfig)
127
+ services: Dict[str, ServiceConfig] = field(default_factory=dict)
128
+ databases: Dict[str, DatabaseConfig] = field(default_factory=dict)
129
+ test_data: TestDataConfig = field(default_factory=TestDataConfig)
130
+ security: SecurityConfig = field(default_factory=SecurityConfig)
131
+ reporting: ReportingConfig = field(default_factory=ReportingConfig)
132
+
133
+
134
+ class ConfigError(Exception):
135
+ """Custom exception for configuration errors."""
136
+
137
+ pass
138
+
139
+
140
+ class ApiConfigLoader:
141
+ """
142
+ Loads and manages API configuration from api.conf file.
143
+
144
+ Supports:
145
+ - Environment variable substitution: ${VAR_NAME} or ${VAR_NAME:-default}
146
+ - YAML or JSON format
147
+ - Hot-reloading (reload config without restarting)
148
+ - Singleton pattern for global access
149
+
150
+ Usage:
151
+ # Load default configuration
152
+ config = ApiConfigLoader.load()
153
+
154
+ # Get specific service configuration
155
+ auth_config = config.services.get("auth")
156
+
157
+ # Check if using API Gateway
158
+ if config.api_gateway.enabled:
159
+ base_url = config.api_gateway.url
160
+ """
161
+
162
+ _instance: Optional[AppConfig] = None
163
+ _config_path: Optional[Path] = None
164
+
165
+ @classmethod
166
+ def load(cls, config_path: Optional[str] = None) -> AppConfig:
167
+ """
168
+ Load configuration from api.conf file.
169
+
170
+ Args:
171
+ config_path: Path to configuration file. If None, searches in:
172
+ 1. Environment variable E2E_CONFIG_PATH
173
+ 2. verify_services/api.conf (relative to project root)
174
+ 3. Current working directory ./api.conf
175
+
176
+ Returns:
177
+ AppConfig: Parsed configuration object
178
+
179
+ Raises:
180
+ FileNotFoundError: If configuration file not found
181
+ ValueError: If configuration file is invalid
182
+ """
183
+ if cls._instance is not None and config_path is None:
184
+ return cls._instance
185
+
186
+ # Determine config file path
187
+ if config_path is None:
188
+ config_path = cls._find_config_file()
189
+
190
+ config_file = Path(config_path)
191
+ if not config_file.exists():
192
+ raise FileNotFoundError(f"Configuration file not found: {config_path}")
193
+
194
+ # Read and parse file
195
+ content = config_file.read_text()
196
+
197
+ # Substitute environment variables
198
+ content = cls._substitute_env_vars(content)
199
+
200
+ # Parse YAML or JSON
201
+ if HAS_YAML:
202
+ import yaml as yaml_module
203
+
204
+ data = yaml_module.safe_load(content)
205
+ else:
206
+ import json as json_module
207
+
208
+ data = json_module.loads(content)
209
+
210
+ # Build configuration object
211
+ config = cls._parse_config(data)
212
+ cls._instance = config
213
+ cls._config_path = config_file
214
+
215
+ return config
216
+
217
+ @classmethod
218
+ def reload(cls) -> AppConfig:
219
+ """Reload configuration from file (useful for hot-reloading)."""
220
+ cls._instance = None
221
+ if cls._config_path is not None:
222
+ return cls.load(str(cls._config_path))
223
+ return cls.load()
224
+
225
+ @classmethod
226
+ def get_config_path(cls) -> Optional[Path]:
227
+ """Get the path of the currently loaded configuration file."""
228
+ return cls._config_path
229
+
230
+ @classmethod
231
+ def _find_config_file(cls) -> str:
232
+ """Find configuration file in common locations.
233
+
234
+ Search order (highest priority first):
235
+ 1. E2E_CONFIG_PATH environment variable
236
+ 2. ./e2e.conf (current directory)
237
+ 3. ./config/e2e.conf
238
+ 4. ./tests/e2e.conf
239
+ 5. ~/.config/socialseed-e2e/default.conf
240
+ 6. verify_services/api.conf (legacy, for backward compatibility)
241
+ 7. ./api.conf (legacy, for backward compatibility)
242
+
243
+ Returns:
244
+ str: Path to the configuration file
245
+
246
+ Raises:
247
+ FileNotFoundError: If no configuration file is found
248
+ """
249
+ # Priority 1: Environment variable
250
+ if env_path := os.getenv("E2E_CONFIG_PATH"):
251
+ if Path(env_path).exists():
252
+ return env_path
253
+ raise FileNotFoundError(
254
+ f"Configuration file specified in E2E_CONFIG_PATH not found: {env_path}"
255
+ )
256
+
257
+ # Priority 2-4: Search in current directory and subdirectories
258
+ search_paths = [
259
+ Path("e2e.conf"), # Current directory
260
+ Path("config") / "e2e.conf", # config subdirectory
261
+ Path("tests") / "e2e.conf", # tests subdirectory
262
+ ]
263
+
264
+ for path in search_paths:
265
+ if path.exists():
266
+ return str(path)
267
+
268
+ # Priority 5: Global config in home directory
269
+ home_config = Path.home() / ".config" / "socialseed-e2e" / "default.conf"
270
+ if home_config.exists():
271
+ return str(home_config)
272
+
273
+ # Priority 6-7: Legacy paths (for backward compatibility)
274
+ # Search up the directory tree for verify_services/api.conf
275
+ current_dir = Path.cwd()
276
+ for parent in [current_dir] + list(current_dir.parents):
277
+ verify_services_dir = parent / "verify_services"
278
+ if verify_services_dir.exists():
279
+ legacy_config = verify_services_dir / "api.conf"
280
+ if legacy_config.exists():
281
+ return str(legacy_config)
282
+
283
+ # Legacy: api.conf in current directory
284
+ if Path("api.conf").exists():
285
+ return "api.conf"
286
+
287
+ # Configuration not found
288
+ raise FileNotFoundError(
289
+ "Could not find configuration file. Searched in:\n"
290
+ " 1. E2E_CONFIG_PATH environment variable\n"
291
+ " 2. ./e2e.conf\n"
292
+ " 3. ./config/e2e.conf\n"
293
+ " 4. ./tests/e2e.conf\n"
294
+ " 5. ~/.config/socialseed-e2e/default.conf\n"
295
+ " 6. verify_services/api.conf (legacy)\n"
296
+ " 7. ./api.conf (legacy)\n\n"
297
+ "To create a default configuration, run:\n"
298
+ " e2e init\n\n"
299
+ "Or set the E2E_CONFIG_PATH environment variable:\n"
300
+ " export E2E_CONFIG_PATH=/path/to/your/e2e.conf"
301
+ )
302
+
303
+ @classmethod
304
+ def create_default_config(cls, path: Union[str, Path], overwrite: bool = False) -> str:
305
+ """Create a default e2e.conf configuration file.
306
+
307
+ Creates a default configuration file at the specified path using
308
+ the e2e.conf.template template. Parent directories are created
309
+ automatically if they don't exist.
310
+
311
+ Args:
312
+ path: Path where to create the configuration file
313
+ overwrite: If True, overwrite existing file
314
+
315
+ Returns:
316
+ str: Path to the created configuration file
317
+
318
+ Raises:
319
+ FileExistsError: If file exists and overwrite=False
320
+ FileNotFoundError: If template file doesn't exist
321
+ """
322
+ path = Path(path)
323
+ engine = TemplateEngine()
324
+
325
+ rendered_path = engine.render_to_file(
326
+ "e2e.conf",
327
+ variables={
328
+ "environment": "dev",
329
+ "timeout": "30000",
330
+ "user_agent": "SocialSeed-E2E-Agent/2.0",
331
+ "verbose": "true",
332
+ "services_config": "",
333
+ },
334
+ output_path=path,
335
+ overwrite=overwrite,
336
+ )
337
+
338
+ return str(rendered_path)
339
+
340
+ @classmethod
341
+ def _substitute_env_vars(cls, content: str) -> str:
342
+ """Substitute environment variables in configuration content."""
343
+ # Pattern: ${VAR_NAME} or ${VAR_NAME:-default_value}
344
+ pattern = r"\$\{(\w+)(?::-([^}]*))?\}"
345
+
346
+ def replace_var(match):
347
+ var_name = match.group(1)
348
+ default_value = match.group(2)
349
+
350
+ env_value = os.getenv(var_name)
351
+ if env_value is not None:
352
+ return env_value
353
+ elif default_value is not None:
354
+ return default_value
355
+ else:
356
+ # Keep original if no value found
357
+ return match.group(0)
358
+
359
+ return re.sub(pattern, replace_var, content)
360
+
361
+ @classmethod
362
+ def validate_config(cls, data: Dict[str, Any], strict: bool = False) -> None:
363
+ """Validate minimum configuration requirements.
364
+
365
+ Args:
366
+ data: Raw configuration dictionary
367
+ strict: If True, raises error for missing optional fields
368
+
369
+ Raises:
370
+ ConfigError: If configuration is invalid or missing required fields
371
+ """
372
+ errors = []
373
+ warnings = []
374
+
375
+ # Check for required 'general' section
376
+ if "general" not in data:
377
+ errors.append("Missing required 'general' section")
378
+ else:
379
+ general = data["general"]
380
+
381
+ # Validate environment
382
+ if "environment" in general:
383
+ valid_envs = ["dev", "development", "staging", "prod", "production", "test"]
384
+ if general["environment"] not in valid_envs:
385
+ warnings.append(
386
+ f"Unusual environment value: {general['environment']}. "
387
+ f"Recommended: {', '.join(valid_envs)}"
388
+ )
389
+
390
+ # Validate timeout is positive integer
391
+ if "timeout" in general:
392
+ try:
393
+ timeout = int(general["timeout"])
394
+ if timeout <= 0:
395
+ errors.append("general.timeout must be a positive integer")
396
+ elif timeout < 1000:
397
+ warnings.append(f"general.timeout is very short ({timeout}ms)")
398
+ except (ValueError, TypeError):
399
+ errors.append("general.timeout must be a valid integer")
400
+
401
+ # Check for services section (at least one service recommended)
402
+ services = data.get("services", {})
403
+ if not services:
404
+ warnings.append("No services defined. Add at least one service to test.")
405
+ else:
406
+ # Validate each service has required fields
407
+ for service_name, service_data in services.items():
408
+ if not isinstance(service_data, dict):
409
+ errors.append(f"Service '{service_name}' must be a dictionary")
410
+ continue
411
+
412
+ if "base_url" not in service_data:
413
+ errors.append(f"Service '{service_name}' missing required field: base_url")
414
+ elif not service_data.get("base_url"):
415
+ warnings.append(f"Service '{service_name}' has empty base_url")
416
+
417
+ # Validate port if provided
418
+ if "port" in service_data:
419
+ try:
420
+ port = int(service_data["port"])
421
+ if port < 1 or port > 65535:
422
+ errors.append(
423
+ f"Service '{service_name}' port must be between 1 and 65535"
424
+ )
425
+ except (ValueError, TypeError):
426
+ errors.append(f"Service '{service_name}' port must be a valid integer")
427
+
428
+ # Raise error if there are validation errors
429
+ if errors:
430
+ raise ConfigError("Configuration validation failed:\n - " + "\n - ".join(errors))
431
+
432
+ # Print warnings if strict mode
433
+ if strict and warnings:
434
+ import warnings as warnings_module
435
+
436
+ for warning in warnings:
437
+ warnings_module.warn(f"Config warning: {warning}")
438
+
439
+ @classmethod
440
+ def _parse_config(cls, data: Dict[str, Any]) -> AppConfig:
441
+ """Parse raw dictionary into AppConfig object."""
442
+ # Validate configuration before parsing
443
+ cls.validate_config(data)
444
+
445
+ # General configuration
446
+ general = data.get("general", {})
447
+ project = general.get("project", {})
448
+
449
+ config = AppConfig(
450
+ environment=general.get("environment", "dev"),
451
+ timeout=general.get("timeout", 30000),
452
+ user_agent=general.get("user_agent", "SocialSeed-E2E-Agent/2.0"),
453
+ verification_level=general.get("verification_level", "strict"),
454
+ verbose=general.get("verbose", True),
455
+ project_name=project.get("name", "SocialSeed"),
456
+ project_version=project.get("version", "0.0.0"),
457
+ )
458
+
459
+ # API Gateway
460
+ gateway_data = data.get("api_gateway", {})
461
+ auth_data = gateway_data.get("auth", {})
462
+ config.api_gateway = ApiGatewayConfig(
463
+ enabled=gateway_data.get("enabled", False),
464
+ url=gateway_data.get("url", ""),
465
+ prefix=gateway_data.get("prefix", ""),
466
+ auth_type=auth_data.get("type", "none"),
467
+ auth_token=auth_data.get("bearer_token"),
468
+ api_key_header=auth_data.get("api_key_header"),
469
+ api_key_value=auth_data.get("api_key_value"),
470
+ )
471
+
472
+ # Services
473
+ services_data = data.get("services", {})
474
+ for service_name, service_data in services_data.items():
475
+ config.services[service_name] = ServiceConfig(
476
+ name=service_data.get("name", service_name),
477
+ base_url=service_data.get("base_url", ""),
478
+ health_endpoint=service_data.get("health_endpoint", "/actuator/health"),
479
+ port=service_data.get("port", 8080),
480
+ maven_module=service_data.get("maven_module", f"services/{service_name}"),
481
+ timeout=service_data.get("timeout", config.timeout),
482
+ headers=service_data.get("headers", {}),
483
+ auto_start=service_data.get("auto_start", True),
484
+ required=service_data.get("required", True),
485
+ endpoints=service_data.get("endpoints", {}),
486
+ )
487
+
488
+ # Databases
489
+ databases_data = data.get("databases", {})
490
+ for db_name, db_data in databases_data.items():
491
+ config.databases[db_name] = DatabaseConfig(
492
+ host=db_data.get("host", "localhost"),
493
+ port=db_data.get("port", 5432),
494
+ database=db_data.get("database", ""),
495
+ username=db_data.get("username", ""),
496
+ password=db_data.get("password", ""),
497
+ enabled=db_data.get("enabled", False),
498
+ )
499
+
500
+ # Test data
501
+ test_data = data.get("test_data", {})
502
+ user_data = test_data.get("user", {})
503
+ timing_data = test_data.get("timing", {})
504
+ retries_data = test_data.get("retries", {})
505
+ config.test_data = TestDataConfig(
506
+ email_domain=user_data.get("email_domain", "test.socialseed.com"),
507
+ password=user_data.get("password", "StrongPass123!"),
508
+ username_prefix=user_data.get("username_prefix", "testuser"),
509
+ step_delay=timing_data.get("step_delay", 100),
510
+ async_timeout=timing_data.get("async_timeout", 10000),
511
+ max_retries=retries_data.get("max_attempts", 3),
512
+ retry_backoff_ms=retries_data.get("backoff_ms", 1000),
513
+ )
514
+
515
+ # Security
516
+ security_data = data.get("security", {})
517
+ config.security = SecurityConfig(
518
+ verify_ssl=security_data.get("verify_ssl", True),
519
+ ssl_cert=security_data.get("ssl_cert"),
520
+ ssl_key=security_data.get("ssl_key"),
521
+ ssl_ca=security_data.get("ssl_ca"),
522
+ test_tokens=security_data.get("test_tokens", {}),
523
+ )
524
+
525
+ # Reporting
526
+ reporting_data = data.get("reporting", {})
527
+ config.reporting = ReportingConfig(
528
+ format=reporting_data.get("format", "console"),
529
+ save_logs=reporting_data.get("save_logs", True),
530
+ log_dir=reporting_data.get("log_dir", "./logs"),
531
+ include_payloads=reporting_data.get("include_payloads", False),
532
+ screenshot_on_failure=reporting_data.get("screenshot_on_failure", False),
533
+ )
534
+
535
+ return config
536
+
537
+ @classmethod
538
+ def get_service_url(cls, service_name: str, use_gateway: Optional[bool] = None) -> str:
539
+ """
540
+ Get the effective URL for a service.
541
+
542
+ If API Gateway is enabled, returns gateway URL + service path.
543
+ Otherwise, returns the service's base_url.
544
+
545
+ Args:
546
+ service_name: Name of the service
547
+ use_gateway: Force gateway mode (None = use config setting)
548
+
549
+ Returns:
550
+ str: Full URL for the service
551
+ """
552
+ config = cls.load()
553
+ service = config.services.get(service_name)
554
+
555
+ if not service:
556
+ raise ValueError(f"Service '{service_name}' not found in configuration")
557
+
558
+ # Determine if we should use gateway
559
+ should_use_gateway = use_gateway if use_gateway is not None else config.api_gateway.enabled
560
+
561
+ if should_use_gateway and config.api_gateway.url:
562
+ gateway_url = config.api_gateway.url.rstrip("/")
563
+ prefix = config.api_gateway.prefix.rstrip("/")
564
+ return f"{gateway_url}{prefix}/{service_name}"
565
+
566
+ return service.base_url
567
+
568
+ @classmethod
569
+ def get_all_required_services(cls) -> List[str]:
570
+ """Get list of services marked as required."""
571
+ config = cls.load()
572
+ return [name for name, service in config.services.items() if service.required]
573
+
574
+ @classmethod
575
+ def get_auto_start_services(cls) -> List[str]:
576
+ """Get list of services configured for auto-start."""
577
+ config = cls.load()
578
+ return [name for name, service in config.services.items() if service.auto_start]
579
+
580
+ @classmethod
581
+ def get_service_by_maven_module(cls, maven_module: str) -> Optional[ServiceConfig]:
582
+ """Find service configuration by Maven module name."""
583
+ config = cls.load()
584
+ for service in config.services.values():
585
+ if service.maven_module == maven_module:
586
+ return service
587
+ return None
588
+
589
+
590
+ # Convenience functions for direct import
591
+ def get_config() -> AppConfig:
592
+ """Get the global configuration instance."""
593
+ return ApiConfigLoader.load()
594
+
595
+
596
+ def get_service_config(service_name: str) -> Optional[ServiceConfig]:
597
+ """Get configuration for a specific service."""
598
+ config = ApiConfigLoader.load()
599
+ return config.services.get(service_name)
600
+
601
+
602
+ def get_service_url(service_name: str) -> str:
603
+ """Get effective URL for a service (considers API Gateway)."""
604
+ return ApiConfigLoader.get_service_url(service_name)
@@ -0,0 +1,20 @@
1
+ from typing import Dict
2
+
3
+ DEFAULT_JSON_HEADERS: Dict[str, str] = {
4
+ "Content-Type": "application/json",
5
+ "Accept": "application/json, text/plain, */*",
6
+ }
7
+
8
+ DEFAULT_BROWSER_HEADERS: Dict[str, str] = {
9
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
10
+ "Accept-Language": "en-US,en;q=0.9",
11
+ "Connection": "keep-alive",
12
+ }
13
+
14
+
15
+ def get_combined_headers(custom_headers: Dict[str, str] = None) -> Dict[str, str]:
16
+ """Helper to combine default headers with custom ones."""
17
+ headers = {**DEFAULT_JSON_HEADERS, **DEFAULT_BROWSER_HEADERS}
18
+ if custom_headers:
19
+ headers.update(custom_headers)
20
+ return headers
@@ -0,0 +1,22 @@
1
+ from typing import Any, Protocol, runtime_checkable
2
+
3
+
4
+ @runtime_checkable
5
+ class IServicePage(Protocol):
6
+ """Protocol that every service-specific page object should implement."""
7
+
8
+ base_url: str
9
+
10
+ def setup(self) -> None:
11
+ ...
12
+
13
+ def teardown(self) -> None:
14
+ ...
15
+
16
+
17
+ @runtime_checkable
18
+ class ITestModule(Protocol):
19
+ """Protocol for test modules."""
20
+
21
+ def run(self, context: Any) -> None:
22
+ ...
@@ -0,0 +1,51 @@
1
+ import importlib.util
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Callable, List, Optional
5
+
6
+
7
+ class ModuleLoader:
8
+ """Handles discovery and dynamic loading of Python modules from file paths."""
9
+
10
+ @staticmethod
11
+ def load_runnable_from_file(file_path: Path, function_name: str = "run") -> Optional[Callable]:
12
+ """Loads a specific function from a python file."""
13
+ if not file_path.exists() or file_path.suffix != ".py":
14
+ return None
15
+
16
+ try:
17
+ # Create a unique module name based on path to avoid collisions
18
+ module_name = f"dynamic_mod_{file_path.stem}_{hash(str(file_path)) % 10000}"
19
+
20
+ spec = importlib.util.spec_from_file_location(module_name, str(file_path))
21
+ if spec and spec.loader:
22
+ module = importlib.util.module_from_spec(spec)
23
+ sys.modules[module_name] = module
24
+ spec.loader.exec_module(module)
25
+
26
+ func = getattr(module, function_name, None)
27
+ if callable(func):
28
+ return func
29
+ except Exception as e:
30
+ print(f"Error loading {file_path}: {e}")
31
+
32
+ return None
33
+
34
+ def discover_runnables(self, root_path: Path, pattern: str = "*.py") -> List[Callable]:
35
+ """Discovers all runnable modules in a directory matching a pattern."""
36
+ runnables = []
37
+ if not root_path.exists() or not root_path.is_dir():
38
+ return runnables
39
+
40
+ # Sort by filename to ensure predictable order
41
+ paths = sorted(list(root_path.glob(pattern)), key=lambda p: p.name)
42
+
43
+ for file_path in paths:
44
+ if file_path.name == "__init__.py":
45
+ continue
46
+
47
+ runnable = self.load_runnable_from_file(file_path)
48
+ if runnable:
49
+ runnables.append(runnable)
50
+
51
+ return runnables