fast-clean-architecture 1.0.0__py3-none-any.whl → 1.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.
Files changed (27) hide show
  1. fast_clean_architecture/__init__.py +3 -4
  2. fast_clean_architecture/analytics.py +260 -0
  3. fast_clean_architecture/cli.py +555 -43
  4. fast_clean_architecture/config.py +47 -23
  5. fast_clean_architecture/error_tracking.py +201 -0
  6. fast_clean_architecture/exceptions.py +432 -12
  7. fast_clean_architecture/generators/__init__.py +11 -1
  8. fast_clean_architecture/generators/component_generator.py +407 -103
  9. fast_clean_architecture/generators/config_updater.py +186 -38
  10. fast_clean_architecture/generators/generator_factory.py +223 -0
  11. fast_clean_architecture/generators/package_generator.py +9 -7
  12. fast_clean_architecture/generators/template_validator.py +109 -9
  13. fast_clean_architecture/generators/validation_config.py +5 -3
  14. fast_clean_architecture/generators/validation_metrics.py +10 -6
  15. fast_clean_architecture/health.py +169 -0
  16. fast_clean_architecture/logging_config.py +52 -0
  17. fast_clean_architecture/metrics.py +108 -0
  18. fast_clean_architecture/protocols.py +406 -0
  19. fast_clean_architecture/templates/external.py.j2 +109 -32
  20. fast_clean_architecture/utils.py +50 -31
  21. fast_clean_architecture/validation.py +302 -0
  22. {fast_clean_architecture-1.0.0.dist-info → fast_clean_architecture-1.1.0.dist-info}/METADATA +31 -21
  23. fast_clean_architecture-1.1.0.dist-info/RECORD +38 -0
  24. fast_clean_architecture-1.0.0.dist-info/RECORD +0 -30
  25. {fast_clean_architecture-1.0.0.dist-info → fast_clean_architecture-1.1.0.dist-info}/WHEEL +0 -0
  26. {fast_clean_architecture-1.0.0.dist-info → fast_clean_architecture-1.1.0.dist-info}/entry_points.txt +0 -0
  27. {fast_clean_architecture-1.0.0.dist-info → fast_clean_architecture-1.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,20 +1,21 @@
1
- """Configuration updater for managing fca-config.yaml updates."""
1
+ """Configuration updater for managing fca_config.yaml updates."""
2
2
 
3
3
  import shutil
4
4
  from pathlib import Path
5
- from typing import Optional, Dict, Any
5
+ from typing import Any, Dict, List, Optional, Union
6
6
 
7
7
  from rich.console import Console
8
8
 
9
9
  from ..config import Config
10
+ from ..exceptions import ConfigurationError, Result, SecurityError, ValidationError
10
11
  from ..utils import generate_timestamp
11
- from ..exceptions import ConfigurationError
12
+ from ..validation import Validator
12
13
 
13
14
 
14
15
  class ConfigUpdater:
15
- """Handles updates to the fca-config.yaml file with proper timestamp management."""
16
+ """Handles updates to the fca_config.yaml file with proper timestamp management."""
16
17
 
17
- def __init__(self, config_path: Path, console: Console = None):
18
+ def __init__(self, config_path: Path, console: Optional[Console] = None):
18
19
  self.config_path = config_path
19
20
  self.console = console or Console()
20
21
  self._config: Optional[Config] = None
@@ -28,27 +29,95 @@ class ConfigUpdater:
28
29
 
29
30
  def _load_config(self) -> Config:
30
31
  """Load configuration from file or create default."""
31
- if self.config_path.exists():
32
- return Config.load_from_file(self.config_path)
33
- else:
34
- # Create default config if it doesn't exist
35
- config = Config.create_default()
36
- config.save_to_file(self.config_path)
37
- return config
32
+ result = self._load_config_safe()
33
+ return result.unwrap()
34
+
35
+ def _load_config_safe(self) -> Result[Config, ConfigurationError]:
36
+ """Safely load configuration with Result pattern."""
37
+ try:
38
+ if self.config_path.exists():
39
+ config = Config.load_from_file(self.config_path)
40
+ return Result.success(config)
41
+ else:
42
+ # Create default config if it doesn't exist
43
+ config = Config.create_default()
44
+ config.save_to_file(self.config_path)
45
+ return Result.success(config)
46
+ except Exception as e:
47
+ return Result.failure(
48
+ ConfigurationError(
49
+ f"Failed to load configuration from {self.config_path}",
50
+ context={"path": str(self.config_path), "error": str(e)},
51
+ )
52
+ )
38
53
 
39
54
  def backup_config(self) -> Path:
40
55
  """Create a backup of the current configuration."""
41
- if not self.config_path.exists():
42
- raise ConfigurationError(
43
- f"Configuration file does not exist: {self.config_path}"
56
+ result = self._backup_config_safe()
57
+ return result.unwrap()
58
+
59
+ def _backup_config_safe(self) -> Result[Path, ConfigurationError]:
60
+ """Safely create a backup with Result pattern."""
61
+ try:
62
+ if not self.config_path.exists():
63
+ return Result.failure(
64
+ ConfigurationError(
65
+ f"Configuration file does not exist: {self.config_path}",
66
+ context={"path": str(self.config_path)},
67
+ )
68
+ )
69
+
70
+ timestamp = generate_timestamp().replace(":", "-").replace(".", "-")
71
+ # Create backup directory if it doesn't exist
72
+ backup_dir = self.config_path.parent / "fca_config_backups"
73
+ backup_dir.mkdir(exist_ok=True)
74
+ backup_path = (
75
+ backup_dir / f"{self.config_path.stem}.backup.{timestamp}.yaml"
44
76
  )
45
77
 
46
- timestamp = generate_timestamp().replace(":", "-").replace(".", "-")
47
- backup_path = self.config_path.with_suffix(f".backup.{timestamp}.yaml")
78
+ shutil.copy2(self.config_path, backup_path)
79
+ self.console.print(f"📋 Config backed up to: {backup_path}")
80
+
81
+ # Clean up old backups (keep only last 5)
82
+ self._cleanup_old_backups()
48
83
 
49
- shutil.copy2(self.config_path, backup_path)
50
- self.console.print(f"📋 Config backed up to: {backup_path}")
51
- return backup_path
84
+ return Result.success(backup_path)
85
+ except Exception as e:
86
+ return Result.failure(
87
+ ConfigurationError(
88
+ f"Failed to create backup: {e}",
89
+ context={"source": str(self.config_path), "error": str(e)},
90
+ )
91
+ )
92
+
93
+ def _cleanup_old_backups(self, keep_count: int = 5) -> None:
94
+ """Clean up old backup files created by ConfigUpdater, keeping only the most recent ones."""
95
+ try:
96
+ backup_dir = self.config_path.parent / "fca_config_backups"
97
+ if not backup_dir.exists():
98
+ return
99
+
100
+ # Pattern for ConfigUpdater backups: filename.backup.timestamp.yaml
101
+ backup_pattern = f"{self.config_path.stem}.backup.*.yaml"
102
+ backup_files = list(backup_dir.glob(backup_pattern))
103
+
104
+ if len(backup_files) > keep_count:
105
+ # Sort by modification time (newest first)
106
+ backup_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
107
+
108
+ # Remove old backups
109
+ for old_backup in backup_files[keep_count:]:
110
+ try:
111
+ old_backup.unlink()
112
+ self.console.print(
113
+ f"🗑️ Removed old backup: {old_backup.name}", style="dim"
114
+ )
115
+ except OSError:
116
+ # Ignore errors when cleaning up old backups
117
+ pass
118
+ except OSError:
119
+ # Don't fail the main operation if backup cleanup fails
120
+ pass
52
121
 
53
122
  def add_system(
54
123
  self,
@@ -56,17 +125,33 @@ class ConfigUpdater:
56
125
  description: Optional[str] = None,
57
126
  backup: bool = True,
58
127
  ) -> None:
59
- """Add a new system to the configuration."""
128
+ """Add a new system to the configuration with validation."""
129
+ # Validate system name
130
+ name_result = Validator.validate_system_name(system_name)
131
+ if name_result.is_failure:
132
+ name_result.unwrap() # This will raise the error
133
+
134
+ # Validate description if provided
135
+ if description:
136
+ desc_result = Validator.validate_description(description)
137
+ if desc_result.is_failure:
138
+ desc_result.unwrap() # This will raise the error
139
+
60
140
  if backup and self.config_path.exists():
61
141
  self.backup_config()
62
142
 
63
143
  # Add system to config
64
- self.config.add_system(system_name, description)
144
+ validated_name = name_result.value
145
+ if validated_name is None:
146
+ raise ValidationError(
147
+ "Validated name should not be None after successful validation"
148
+ )
149
+ self.config.add_system(validated_name, description or "")
65
150
 
66
151
  # Save updated config
67
152
  self._save_config_atomically()
68
153
 
69
- self.console.print(f"✅ Added system: {system_name}")
154
+ self.console.print(f"✅ Added system: {name_result.value}")
70
155
 
71
156
  def add_module(
72
157
  self,
@@ -75,17 +160,45 @@ class ConfigUpdater:
75
160
  description: Optional[str] = None,
76
161
  backup: bool = True,
77
162
  ) -> None:
78
- """Add a new module to a system."""
163
+ """Add a new module to a system with validation."""
164
+ # Validate system name
165
+ system_result = Validator.validate_system_name(system_name)
166
+ if system_result.is_failure:
167
+ system_result.unwrap() # This will raise the error
168
+
169
+ # Validate module name
170
+ module_result = Validator.validate_module_name(module_name)
171
+ if module_result.is_failure:
172
+ module_result.unwrap() # This will raise the error
173
+
174
+ # Validate description if provided
175
+ if description:
176
+ desc_result = Validator.validate_description(description)
177
+ if desc_result.is_failure:
178
+ desc_result.unwrap() # This will raise the error
179
+
79
180
  if backup and self.config_path.exists():
80
181
  self.backup_config()
81
182
 
82
183
  # Add module to config
83
- self.config.add_module(system_name, module_name, description)
184
+ validated_system = system_result.value
185
+ validated_module = module_result.value
186
+ if validated_system is None:
187
+ raise ValidationError(
188
+ "Validated system should not be None after successful validation"
189
+ )
190
+ if validated_module is None:
191
+ raise ValidationError(
192
+ "Validated module should not be None after successful validation"
193
+ )
194
+ self.config.add_module(validated_system, validated_module, description or "")
84
195
 
85
196
  # Save updated config
86
197
  self._save_config_atomically()
87
198
 
88
- self.console.print(f"✅ Added module: {module_name} to system: {system_name}")
199
+ self.console.print(
200
+ f"✅ Added module: {module_result.value} to system: {system_result.value}"
201
+ )
89
202
 
90
203
  def add_component(
91
204
  self,
@@ -97,18 +210,39 @@ class ConfigUpdater:
97
210
  file_path: Optional[Path] = None,
98
211
  backup: bool = True,
99
212
  ) -> None:
100
- """Add a new component to a module."""
101
- if backup and self.config_path.exists():
102
- self.backup_config()
103
-
104
- # Add component to config
105
- self.config.add_component(
213
+ """Add a new component to a module with comprehensive validation."""
214
+ # Validate all inputs
215
+ validation_result = Validator.validate_component_creation(
106
216
  system_name=system_name,
107
217
  module_name=module_name,
108
218
  layer=layer,
109
219
  component_type=component_type,
110
220
  component_name=component_name,
111
- file_path=str(file_path) if file_path else None,
221
+ file_path=file_path,
222
+ )
223
+
224
+ if validation_result.is_failure:
225
+ validation_result.unwrap() # This will raise the error
226
+
227
+ if backup and self.config_path.exists():
228
+ self.backup_config()
229
+
230
+ # Add component to config using validated data
231
+ validated_data = validation_result.value
232
+ if validated_data is None:
233
+ raise ConfigurationError("Validation result is None")
234
+
235
+ self.config.add_component(
236
+ system_name=validated_data["system_name"],
237
+ module_name=validated_data["module_name"],
238
+ layer=validated_data["layer"],
239
+ component_type=validated_data["component_type"],
240
+ component_name=validated_data["component_name"],
241
+ file_path=(
242
+ str(validated_data["file_path"])
243
+ if "file_path" in validated_data and validated_data["file_path"]
244
+ else None
245
+ ),
112
246
  )
113
247
 
114
248
  # Save updated config
@@ -266,7 +400,7 @@ class ConfigUpdater:
266
400
 
267
401
  def get_config_summary(self) -> Dict[str, Any]:
268
402
  """Get a summary of the current configuration."""
269
- summary = {
403
+ summary: Dict[str, Any] = {
270
404
  "project": {
271
405
  "name": self.config.project.name,
272
406
  "version": self.config.project.version,
@@ -288,6 +422,12 @@ class ConfigUpdater:
288
422
 
289
423
  def _save_config_atomically(self) -> None:
290
424
  """Save configuration atomically to prevent corruption."""
425
+ result = self._save_config_atomically_safe()
426
+ result.unwrap()
427
+
428
+ def _save_config_atomically_safe(self) -> Result[None, ConfigurationError]:
429
+ """Safely save configuration with Result pattern."""
430
+ temp_path: Optional[Path] = None
291
431
  try:
292
432
  # Write to temporary file first
293
433
  temp_path = self.config_path.with_suffix(".tmp")
@@ -295,13 +435,21 @@ class ConfigUpdater:
295
435
 
296
436
  # Atomic move
297
437
  temp_path.replace(self.config_path)
438
+ return Result.success(None)
298
439
 
299
440
  except Exception as e:
300
441
  # Clean up temp file if it exists
301
- temp_path = self.config_path.with_suffix(".tmp")
302
- if temp_path.exists():
303
- temp_path.unlink()
304
- raise ConfigurationError(f"Failed to save configuration: {e}")
442
+ if temp_path and temp_path.exists():
443
+ try:
444
+ temp_path.unlink()
445
+ except OSError:
446
+ pass # Ignore cleanup errors
447
+ return Result.failure(
448
+ ConfigurationError(
449
+ f"Failed to save configuration: {e}",
450
+ context={"config_path": str(self.config_path), "error": str(e)},
451
+ )
452
+ )
305
453
 
306
454
  def reload_config(self) -> None:
307
455
  """Reload configuration from file."""
@@ -0,0 +1,223 @@
1
+ """Factory pattern implementation for generators.
2
+
3
+ This module provides a factory for creating different types of generators
4
+ with proper dependency injection and type safety.
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Generic, Optional, Protocol, Type, TypeVar, Union
10
+
11
+ from rich.console import Console
12
+
13
+ from ..config import Config
14
+ from ..exceptions import ComponentError
15
+ from ..protocols import (
16
+ ComponentGeneratorProtocol,
17
+ SecurePathHandler,
18
+ TemplateValidatorProtocol,
19
+ )
20
+ from .component_generator import ComponentGenerator
21
+ from .config_updater import ConfigUpdater
22
+ from .package_generator import PackageGenerator
23
+ from .template_validator import SimpleTemplateValidator
24
+ from .validation_config import ValidationConfig
25
+
26
+ # Type variables for enhanced type safety
27
+ T = TypeVar("T")
28
+ GeneratorType = TypeVar("GeneratorType")
29
+
30
+
31
+ class GeneratorProtocol(Protocol):
32
+ """Base protocol for all generators."""
33
+
34
+ pass
35
+
36
+
37
+ # ComponentGeneratorProtocol is imported from protocols module
38
+
39
+
40
+ class GeneratorFactoryProtocol(Protocol):
41
+ """Protocol for generator factories."""
42
+
43
+ def create_generator(self, generator_type: str, **kwargs: Any) -> GeneratorProtocol:
44
+ """Create a generator of the specified type.
45
+
46
+ Args:
47
+ generator_type: Type of generator to create
48
+ **kwargs: Additional arguments for generator creation
49
+
50
+ Returns:
51
+ Generator instance
52
+
53
+ Raises:
54
+ ValueError: If generator type is not supported
55
+ """
56
+ ...
57
+
58
+
59
+ class DependencyContainer:
60
+ """Container for managing dependencies and their lifecycle."""
61
+
62
+ def __init__(self, config: Config, console: Optional[Console] = None):
63
+ self.config = config
64
+ self.console = console or Console()
65
+ self._template_validator: Optional[TemplateValidatorProtocol] = None
66
+ self._path_handler: Optional[SecurePathHandler[Union[str, Path]]] = None
67
+
68
+ @property
69
+ def template_validator(self) -> TemplateValidatorProtocol:
70
+ """Get or create template validator instance."""
71
+ if self._template_validator is None:
72
+ # Create template environment for validator
73
+ import jinja2
74
+ from jinja2.sandbox import SandboxedEnvironment
75
+
76
+ from ..templates import TEMPLATES_DIR
77
+
78
+ template_env = SandboxedEnvironment(
79
+ loader=jinja2.FileSystemLoader(TEMPLATES_DIR),
80
+ trim_blocks=True,
81
+ lstrip_blocks=True,
82
+ )
83
+
84
+ validation_config = ValidationConfig(
85
+ sandbox_mode=True,
86
+ max_template_size_bytes=64 * 1024, # 64KB limit
87
+ render_timeout_seconds=10,
88
+ max_variable_nesting_depth=10,
89
+ )
90
+
91
+ self._template_validator = SimpleTemplateValidator(
92
+ template_env, validation_config
93
+ )
94
+ return self._template_validator
95
+
96
+ @property
97
+ def path_handler(self) -> SecurePathHandler[Union[str, Path]]:
98
+ """Get or create path handler instance."""
99
+ if self._path_handler is None:
100
+ self._path_handler = SecurePathHandler[Union[str, Path]](
101
+ max_path_length=4096,
102
+ allowed_extensions=[".py", ".j2", ".yaml", ".yml", ".json"],
103
+ )
104
+ return self._path_handler
105
+
106
+
107
+ class GeneratorFactory(GeneratorFactoryProtocol):
108
+ """Factory for creating generators with dependency injection.
109
+
110
+ This factory implements the factory pattern and provides dependency
111
+ injection for all generator types, ensuring loose coupling and
112
+ better testability.
113
+ """
114
+
115
+ def __init__(self, dependency_container: DependencyContainer):
116
+ """Initialize factory with dependency container.
117
+
118
+ Args:
119
+ dependency_container: Container with all required dependencies
120
+ """
121
+ self.dependencies = dependency_container
122
+
123
+ # Registry of available generators
124
+ self._generators: Dict[str, Type[GeneratorProtocol]] = {
125
+ "component": ComponentGenerator,
126
+ "package": PackageGenerator,
127
+ "config": ConfigUpdater,
128
+ }
129
+
130
+ def create_generator(self, generator_type: str, **kwargs: Any) -> GeneratorProtocol:
131
+ """Create a generator of the specified type with dependency injection.
132
+
133
+ Args:
134
+ generator_type: Type of generator ('component', 'package', 'config')
135
+ **kwargs: Additional arguments for specific generator types
136
+
137
+ Returns:
138
+ Generator instance with injected dependencies
139
+
140
+ Raises:
141
+ ValueError: If generator type is not supported
142
+ """
143
+ if generator_type not in self._generators:
144
+ available_types = ", ".join(self._generators.keys())
145
+ raise ValueError(
146
+ f"Unsupported generator type: {generator_type}. "
147
+ f"Available types: {available_types}"
148
+ )
149
+
150
+ generator_class = self._generators[generator_type]
151
+
152
+ # Create generator with appropriate dependencies
153
+ if generator_type == "component":
154
+ return self._create_component_generator(**kwargs)
155
+ elif generator_type == "package":
156
+ return self._create_package_generator(**kwargs)
157
+ elif generator_type == "config":
158
+ return self._create_config_updater(**kwargs)
159
+ else:
160
+ # This should never happen due to the check above, but included for completeness
161
+ raise ValueError(f"Unknown generator type: {generator_type}")
162
+
163
+ def _create_component_generator(self, **kwargs: Any) -> ComponentGenerator:
164
+ """Create ComponentGenerator with injected dependencies."""
165
+ return ComponentGenerator(
166
+ config=self.dependencies.config,
167
+ template_validator=self.dependencies.template_validator,
168
+ path_handler=self.dependencies.path_handler,
169
+ console=self.dependencies.console,
170
+ **kwargs,
171
+ )
172
+
173
+ def _create_package_generator(self, **kwargs: Any) -> PackageGenerator:
174
+ """Create PackageGenerator with injected dependencies."""
175
+ return PackageGenerator(console=self.dependencies.console, **kwargs)
176
+
177
+ def _create_config_updater(
178
+ self, config_path: Optional[Path] = None, **kwargs: Any
179
+ ) -> ConfigUpdater:
180
+ """Create ConfigUpdater with injected dependencies."""
181
+ if config_path is None:
182
+ # Use default config path from dependencies
183
+ config_path = Path("fca_config.yaml")
184
+
185
+ return ConfigUpdater(
186
+ config_path=config_path, console=self.dependencies.console, **kwargs
187
+ )
188
+
189
+ def register_generator(
190
+ self, generator_type: str, generator_class: Type[GeneratorProtocol]
191
+ ) -> None:
192
+ """Register a new generator type.
193
+
194
+ Args:
195
+ generator_type: Name of the generator type
196
+ generator_class: Class implementing the generator
197
+ """
198
+ self._generators[generator_type] = generator_class
199
+
200
+ def get_available_types(self) -> list[str]:
201
+ """Get list of available generator types.
202
+
203
+ Returns:
204
+ List of available generator type names
205
+ """
206
+ return list(self._generators.keys())
207
+
208
+
209
+ # Convenience function for creating a factory with default dependencies
210
+ def create_generator_factory(
211
+ config: Config, console: Optional[Console] = None
212
+ ) -> GeneratorFactory:
213
+ """Create a generator factory with default dependencies.
214
+
215
+ Args:
216
+ config: Configuration object
217
+ console: Optional console for output
218
+
219
+ Returns:
220
+ Configured generator factory
221
+ """
222
+ dependency_container = DependencyContainer(config, console)
223
+ return GeneratorFactory(dependency_container)
@@ -1,14 +1,14 @@
1
1
  """Package generator for creating directory structures and __init__.py files."""
2
2
 
3
3
  from pathlib import Path
4
- from typing import List, Dict, Optional
4
+ from typing import Any, Dict, List, Optional
5
5
 
6
6
  import jinja2
7
7
  from rich.console import Console
8
8
 
9
+ from ..exceptions import TemplateError
9
10
  from ..templates import TEMPLATES_DIR
10
11
  from ..utils import ensure_directory
11
- from ..exceptions import TemplateError
12
12
 
13
13
 
14
14
  class PackageGenerator:
@@ -20,6 +20,7 @@ class PackageGenerator:
20
20
  loader=jinja2.FileSystemLoader(TEMPLATES_DIR),
21
21
  trim_blocks=True,
22
22
  lstrip_blocks=True,
23
+ autoescape=True,
23
24
  )
24
25
 
25
26
  def create_system_structure(
@@ -85,7 +86,7 @@ class PackageGenerator:
85
86
  )
86
87
 
87
88
  # Create layer directories
88
- layers = {
89
+ layers: Dict[str, Dict[str, List[Any]]] = {
89
90
  "domain": {
90
91
  "entities": [],
91
92
  "repositories": [],
@@ -100,7 +101,6 @@ class PackageGenerator:
100
101
  "models": [],
101
102
  "repositories": [],
102
103
  "external": [],
103
- "internal": [],
104
104
  },
105
105
  "presentation": {
106
106
  "api": [],
@@ -134,9 +134,11 @@ class PackageGenerator:
134
134
  components=[],
135
135
  )
136
136
 
137
- # Create module.py file
137
+ # Create module API file
138
138
  module_content = f'"""\nModule registration for {module_name}.\n"""\n\n# Module configuration and dependencies\n'
139
- (module_path / "module.py").write_text(module_content, encoding="utf-8")
139
+ (module_path / f"{module_name}_module_api.py").write_text(
140
+ module_content, encoding="utf-8"
141
+ )
140
142
 
141
143
  self.console.print(f"✅ Created module structure: {module_path}")
142
144
 
@@ -160,7 +162,7 @@ class PackageGenerator:
160
162
 
161
163
  self.console.print(f"📝 Updated {init_path}")
162
164
 
163
- def _create_init_file(self, file_path: Path, **template_vars) -> None:
165
+ def _create_init_file(self, file_path: Path, **template_vars: Any) -> None:
164
166
  """Create __init__.py file from template."""
165
167
  try:
166
168
  template = self.template_env.get_template("__init__.py.j2")