This commit is contained in:
xcad
2025-10-14 15:36:17 +02:00
parent cce6ab3083
commit a862bc157f
10 changed files with 132 additions and 34 deletions
+15 -3
View File
@@ -7,18 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.1.0] - 2025-10-14
### Added
- Support for required variables independent of section state (#1355)
- Variables can now be marked with `required: true` in template specs
- Required variables are always prompted, validated, and included in rendering
- Display shows yellow `(required)` indicator for required variables
- Required variables from disabled sections are still collected and available
### Changed
- Improved error handling and display output consistency
- Updated dependency PyYAML to v6.0.3 (Python 3.14 compatibility)
- Updated dependency rich to v14.2.0 (Python 3.14 compatibility)
### Fixed
- Repository fetch fails when library directory already exists
- Install script reliability improvements
- Absolute paths without leading slash treated as relative paths in generate command (#1357)
- Paths like `Users/xcad/Projects/test` are now correctly normalized to `/Users/xcad/Projects/test`
- Supports common Unix/Linux root directories: Users/, home/, usr/, opt/, var/, tmp/
- Repository fetch fails when library directory already exists (#1279)
## [0.0.4] - 2025-01-XX
Initial public release with core CLI functionality.
[unreleased]: https://github.com/christianlempa/boilerplates/compare/v0.0.4...HEAD
[unreleased]: https://github.com/christianlempa/boilerplates/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/christianlempa/boilerplates/compare/v0.0.4...v0.1.0
[0.0.4]: https://github.com/christianlempa/boilerplates/releases/tag/v0.0.4
+5
View File
@@ -91,6 +91,11 @@ def main(
ctx.ensure_object(dict)
ctx.obj['log_level'] = log_level
# Check for local config.yaml and show indicator
local_config = Path.cwd() / "config.yaml"
if local_config.exists() and local_config.is_file():
console.print(f"[dim]→ Using local config: config.yaml[/dim]")
# If no subcommand is provided, show help and friendly intro
if ctx.invoked_subcommand is None:
console.print(ctx.get_help())
+36 -14
View File
@@ -303,8 +303,9 @@ class VariableCollection:
"""Get variable values only from sections with satisfied dependencies.
This respects both toggle states and section dependencies, ensuring that:
- Variables from disabled sections (toggle=false) are excluded
- Variables from disabled sections (toggle=false) are excluded EXCEPT required variables
- Variables from sections with unsatisfied dependencies are excluded
- Required variables are always included if their section dependencies are satisfied
Returns:
Dictionary of variable names to values for satisfied sections only
@@ -312,19 +313,25 @@ class VariableCollection:
satisfied_values = {}
for section_key, section in self._sections.items():
# Skip sections with unsatisfied dependencies
# Skip sections with unsatisfied dependencies (even required variables need satisfied deps)
if not self.is_section_satisfied(section_key):
logger.debug(f"Excluding variables from section '{section_key}' - dependencies not satisfied")
continue
# Skip disabled sections (toggle check)
if not section.is_enabled():
logger.debug(f"Excluding variables from section '{section_key}' - section is disabled")
continue
# Check if section is enabled
is_enabled = section.is_enabled()
# Include all variables from this satisfied section
for var_name, variable in section.variables.items():
satisfied_values[var_name] = variable.convert(variable.value)
if is_enabled:
# Include all variables from enabled section
for var_name, variable in section.variables.items():
satisfied_values[var_name] = variable.convert(variable.value)
else:
# Section is disabled - only include required variables
logger.debug(f"Section '{section_key}' is disabled - including only required variables")
for var_name, variable in section.variables.items():
if variable.required:
logger.debug(f"Including required variable '{var_name}' from disabled section '{section_key}'")
satisfied_values[var_name] = variable.convert(variable.value)
return satisfied_values
@@ -381,17 +388,32 @@ class VariableCollection:
return successful
def validate_all(self) -> None:
"""Validate all variables in the collection, skipping disabled and unsatisfied sections."""
"""Validate all variables in the collection.
Validates:
- All variables in enabled sections with satisfied dependencies
- Required variables even if their section is disabled (but dependencies must be satisfied)
"""
errors: list[str] = []
for section_key, section in self._sections.items():
# Skip sections with unsatisfied dependencies or disabled via toggle
if not self.is_section_satisfied(section_key) or not section.is_enabled():
logger.debug(f"Skipping validation for section '{section_key}'")
# Skip sections with unsatisfied dependencies (even for required variables)
if not self.is_section_satisfied(section_key):
logger.debug(f"Skipping validation for section '{section_key}' - dependencies not satisfied")
continue
# Check if section is enabled
is_enabled = section.is_enabled()
if not is_enabled:
logger.debug(f"Section '{section_key}' is disabled - validating only required variables")
# Validate each variable in the section
# Validate variables in the section
for var_name, variable in section.variables.items():
# Skip non-required variables in disabled sections
if not is_enabled and not variable.required:
continue
try:
# Skip autogenerated variables when empty
if variable.autogenerated and not variable.value:
+31 -9
View File
@@ -37,19 +37,33 @@ class ConfigManager:
"""Initialize the configuration manager.
Args:
config_path: Path to the configuration file. If None, uses default location.
config_path: Path to the configuration file. If None, auto-detects:
1. Checks for ./config.yaml (local project config)
2. Falls back to ~/.config/boilerplates/config.yaml (global config)
"""
if config_path is None:
# Default to ~/.config/boilerplates/config.yaml
config_dir = Path.home() / ".config" / "boilerplates"
config_dir.mkdir(parents=True, exist_ok=True)
self.config_path = config_dir / "config.yaml"
# Check for local config.yaml in current directory first
local_config = Path.cwd() / "config.yaml"
if local_config.exists() and local_config.is_file():
self.config_path = local_config
self.is_local = True
logger.info(f"Using local config: {local_config}")
else:
# Fall back to global config
config_dir = Path.home() / ".config" / "boilerplates"
config_dir.mkdir(parents=True, exist_ok=True)
self.config_path = config_dir / "config.yaml"
self.is_local = False
else:
self.config_path = Path(config_path)
self.is_local = False
# Create default config if it doesn't exist
# Create default config if it doesn't exist (only for global config)
if not self.config_path.exists():
self._create_default_config()
if not self.is_local:
self._create_default_config()
else:
raise ConfigError(f"Local config file not found: {self.config_path}")
else:
# Migrate existing config if needed
self._migrate_config_if_needed()
@@ -411,12 +425,20 @@ class ConfigManager:
raise ConfigValidationError(f"Library 'enabled' at index {i} must be a boolean")
def get_config_path(self) -> Path:
"""Get the path to the configuration file.
"""Get the path to the configuration file being used.
Returns:
Path to the configuration file.
Path to the configuration file (global or local).
"""
return self.config_path
def is_using_local_config(self) -> bool:
"""Check if a local configuration file is being used.
Returns:
True if using local config, False if using global config.
"""
return self.is_local
def get_defaults(self, module_name: str) -> Dict[str, Any]:
"""Get default variable values for a module.
+3 -1
View File
@@ -444,7 +444,9 @@ class DisplayManager:
# Add lock icon for sensitive variables
sensitive_icon = f" {IconManager.lock()}" if variable.sensitive else ""
var_display = f" {var_name}{sensitive_icon}"
# Add required indicator for required variables
required_indicator = " [yellow](required)[/yellow]" if variable.required else ""
var_display = f" {var_name}{sensitive_icon}{required_indicator}"
variables_table.add_row(
var_display,
+9 -1
View File
@@ -560,7 +560,15 @@ class Module(ABC):
logger.info(f"Successfully rendered template '{id}'")
# Determine output directory
output_dir = Path(directory) if directory else Path(id)
if directory:
output_dir = Path(directory)
# Check if path looks like an absolute path but is missing the leading slash
# This handles cases like "Users/username/path" which should be "/Users/username/path"
if not output_dir.is_absolute() and str(output_dir).startswith(("Users/", "home/", "usr/", "opt/", "var/", "tmp/")):
output_dir = Path("/") / output_dir
logger.debug(f"Normalized relative-looking absolute path to: {output_dir}")
else:
output_dir = Path(id)
# Check for conflicts and get confirmation (skip in quiet mode)
if not quiet:
+15 -2
View File
@@ -34,6 +34,7 @@ class PromptHandler:
return {}
collected: Dict[str, Any] = {}
prompted_variables: set[str] = set() # Track which variables we've already prompted for
# Process each section
for section_key, section in variables.get_sections().items():
@@ -60,6 +61,9 @@ class PromptHandler:
# Always show section header first
self.display.display_section_header(section.title, section.description)
# Track whether this section will be enabled
section_will_be_enabled = True
# Handle section toggle - skip for required sections
if section.required:
# Required sections are always processed, no toggle prompt needed
@@ -78,18 +82,27 @@ class PromptHandler:
# Use section's native is_enabled() method
if not section.is_enabled():
continue
section_will_be_enabled = False
# Collect variables in this section
for var_name, variable in section.variables.items():
# Skip toggle variable (already handled)
if section.toggle and var_name == section.toggle:
continue
# Skip non-required variables if section is disabled
if not section_will_be_enabled and not variable.required:
logger.debug(f"Skipping non-required variable '{var_name}' from disabled section '{section_key}'")
continue
# Prompt for the variable
current_value = variable.convert(variable.value)
# Pass section.required so _prompt_variable can enforce required inputs
new_value = self._prompt_variable(variable, required=section.required)
# Track that we've prompted for this variable
prompted_variables.add(var_name)
# For autogenerated variables, always update even if None (signals autogeneration)
if variable.autogenerated and new_value is None:
collected[var_name] = None
+16 -3
View File
@@ -48,6 +48,8 @@ class Variable:
self.extra: Optional[str] = data.get("extra")
# Flag indicating this variable should be auto-generated when empty
self.autogenerated: bool = data.get("autogenerated", False)
# Flag indicating this variable is required even when section is disabled
self.required: bool = data.get("required", False)
# Original value before config override (used for display)
self.original_value: Optional[Any] = data.get("original_value")
@@ -212,6 +214,8 @@ class Variable:
result['sensitive'] = True
if self.autogenerated:
result['autogenerated'] = True
if self.required:
result['required'] = True
if self.options is not None: # Allow empty list
result['options'] = self.options
@@ -312,13 +316,21 @@ class Variable:
"""Check if this variable requires a value (cannot be empty/None).
A variable is considered required if:
- It doesn't have a default value (value is None)
- It's not marked as autogenerated (which can be empty and generated later)
- It's not a boolean type (booleans default to False if not set)
- It has an explicit 'required: true' flag (highest precedence)
- OR it doesn't have a default value (value is None)
AND it's not marked as autogenerated (which can be empty and generated later)
AND it's not a boolean type (booleans default to False if not set)
Returns:
True if the variable must have a non-empty value, False otherwise
"""
# Explicit required flag takes highest precedence
if self.required:
# But autogenerated variables can still be empty (will be generated later)
if self.autogenerated:
return False
return True
# Autogenerated variables can be empty (will be generated later)
if self.autogenerated:
return False
@@ -359,6 +371,7 @@ class Variable:
'sensitive': self.sensitive,
'extra': self.extra,
'autogenerated': self.autogenerated,
'required': self.required,
'original_value': self.original_value,
}
+1 -1
View File
@@ -2,7 +2,7 @@
external_url '{{ external_url }}'
# GitLab Shell SSH settings
gitlab_rails['gitlab_shell_ssh_port'] = {{ ssh_port }}
gitlab_rails['gitlab_shell_ssh_port'] = {{ ports_ssh }}
# Let's Encrypt and built-in TLS settings are currently not supported by the template
# as we are using Traefik as a reverse proxy
+1
View File
@@ -62,6 +62,7 @@ spec:
type: int
description: SSH port
default: 2424
required: true
ports_registry:
type: int
description: Container Registry port