Skip to content

Commit 368d2fc

Browse files
committed
engine: Add support for dynamic dependencies pt2
Because documentation generation follows the same code path as build-time when it comes to instantiating the layer manager, if a layer refers to an env var when specifying dependencies, unless that var is set when the documentation is generated, the layer will be skipped because it can't be found. This is the desired behaviour for build-time but not for docgen-time. To combat this, pass a flag down from the top level so we can be more lenient when generating documentation for layers with dynamic dependencies. Also, update the layer jinja template to strip whitespace. Next time we autogenerate docs there will be removal of blank lines in some cases, but that's better anyway.
1 parent 74f97a5 commit 368d2fc

File tree

5 files changed

+64
-23
lines changed

5 files changed

+64
-23
lines changed

docs/generate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def main():
8888
str(script_dir.parent / 'image'),
8989
str(script_dir.parent / 'layer')
9090
]
91-
manager = LayerManager(layer_paths)
91+
manager = LayerManager(layer_paths, doc_mode=True)
9292

9393
# All layer names
9494
layer_names = sorted(manager.layers.keys())

site/env_types.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ def __init__(self, name: str, description: str = "", version: str = "1.0.0",
313313

314314
@classmethod
315315
def from_metadata_fields(cls, metadata_dict: Dict[str, str],
316-
filepath: str = "") -> Optional['EnvLayer']:
316+
filepath: str = "", doc_mode: bool = False) -> Optional['EnvLayer']:
317317
"""Create an EnvLayer from metadata fields."""
318318
# Check if this has layer information
319319
layer_name = metadata_dict.get(XEnv.layer_name(), "")
@@ -329,16 +329,16 @@ def from_metadata_fields(cls, metadata_dict: Dict[str, str],
329329

330330
# Parse dependency lists
331331
requires_str = metadata_dict.get(XEnv.layer_requires(), "")
332-
requires = cls._parse_dependency_list(requires_str)
332+
requires = cls._parse_dependency_list(requires_str, doc_mode)
333333

334334
provides_str = metadata_dict.get(XEnv.layer_provides(), "")
335-
provides = cls._parse_dependency_list(provides_str)
335+
provides = cls._parse_dependency_list(provides_str, doc_mode)
336336

337337
requires_provider_str = metadata_dict.get(XEnv.layer_requires_provider(), "")
338-
requires_provider = cls._parse_dependency_list(requires_provider_str)
338+
requires_provider = cls._parse_dependency_list(requires_provider_str, doc_mode)
339339

340340
conflicts_str = metadata_dict.get(XEnv.layer_conflicts(), "")
341-
conflicts = cls._parse_dependency_list(conflicts_str)
341+
conflicts = cls._parse_dependency_list(conflicts_str, doc_mode)
342342

343343
# Infer config file from filepath if not provided
344344
import os
@@ -357,7 +357,7 @@ def from_metadata_fields(cls, metadata_dict: Dict[str, str],
357357
)
358358

359359
@staticmethod
360-
def _parse_dependency_list(depends_str: str) -> List[str]:
360+
def _parse_dependency_list(depends_str: str, doc_mode: bool = False) -> List[str]:
361361
"""Parse dependency string into list of layer names/IDs with environment variable evaluation."""
362362
if not depends_str.strip():
363363
return []
@@ -369,19 +369,22 @@ def _parse_dependency_list(depends_str: str) -> List[str]:
369369
if dep_name:
370370
# Find and evaluate environment variables in dependency names
371371
if '${' in dep_name:
372-
dep_name = EnvLayer._evaluate_env_variables(dep_name)
372+
dep_name = EnvLayer._evaluate_env_variables(dep_name, doc_mode)
373373

374374
# Validate dependency name format
375375
if re.search(r"\s", dep_name):
376376
raise ValueError(
377377
f"Invalid dependency token '{dep_name}' - dependencies must be comma-separated without spaces/newlines inside a token")
378-
if not re.match(r'^[A-Za-z0-9_-]+$', dep_name):
378+
# In doc_mode, allow environment variable placeholders like ${VAR}-suffix
379+
if doc_mode and not re.match(r'^[A-Za-z0-9_${}-]+$', dep_name):
380+
raise ValueError(f"Invalid dependency name '{dep_name}' - only alphanum, dash, underscore, and environment variable placeholders allowed")
381+
elif not doc_mode and not re.match(r'^[A-Za-z0-9_-]+$', dep_name):
379382
raise ValueError(f"Invalid dependency name '{dep_name}' - only alphanum, dash, underscore allowed")
380383
deps.append(dep_name)
381384
return deps
382385

383386
@staticmethod
384-
def _evaluate_env_variables(text: str) -> str:
387+
def _evaluate_env_variables(text: str, doc_mode: bool = False) -> str:
385388
"""Evaluate ${VAR} environment variable substitutions in text."""
386389
import re
387390
import os
@@ -390,7 +393,11 @@ def replacer(match):
390393
var_name = match.group(1)
391394
env_value = os.environ.get(var_name)
392395
if env_value is None:
393-
raise ValueError(f"Environment variable '{var_name}' not found for dependency evaluation")
396+
if doc_mode:
397+
# In documentation mode, return the original placeholder
398+
return match.group(0)
399+
else:
400+
raise ValueError(f"Environment variable '{var_name}' not found for dependency evaluation")
394401
return env_value
395402

396403
return re.sub(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}', replacer, text)
@@ -460,7 +467,7 @@ def __init__(self, filepath: str = ""):
460467

461468
@classmethod
462469
def from_metadata_dict(cls, metadata_dict: Dict[str, str],
463-
filepath: str = "") -> 'MetadataContainer':
470+
filepath: str = "", doc_mode: bool = False) -> 'MetadataContainer':
464471
"""Create a MetadataContainer from a metadata dictionary."""
465472
container = cls(filepath)
466473
container.raw_metadata = metadata_dict.copy()
@@ -472,7 +479,7 @@ def from_metadata_dict(cls, metadata_dict: Dict[str, str],
472479
container.var_prefix = container.raw_metadata.get(XEnv.var_prefix(), "").lower()
473480

474481
# Extract layer information
475-
container.layer = EnvLayer.from_metadata_fields(container.raw_metadata, filepath)
482+
container.layer = EnvLayer.from_metadata_fields(container.raw_metadata, filepath, doc_mode)
476483

477484
# Extract variables
478485
for key in container.raw_metadata.keys():

site/layer_manager.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
# Handles discovery, dependency resolution, and orchestration
1919
class LayerManager:
20-
def __init__(self, search_paths: Optional[List[str]] = None, file_patterns: Optional[List[str]] = None, *, show_loaded: bool = False):
20+
def __init__(self, search_paths: Optional[List[str]] = None, file_patterns: Optional[List[str]] = None, *, show_loaded: bool = False, doc_mode: bool = False):
2121
if search_paths is None:
2222
search_paths = ['./layer']
2323
if file_patterns is None:
@@ -28,6 +28,7 @@ def __init__(self, search_paths: Optional[List[str]] = None, file_patterns: Opti
2828
self.layers: Dict[str, Metadata] = {} # layer_name -> Metadata object
2929
self.layer_files: Dict[str, str] = {} # layer_name -> file_path
3030
self.show_loaded = show_loaded
31+
self.doc_mode = doc_mode # When True, load all layers regardless of environment variables
3132
# provider index will be built after layers are loaded
3233
self.provider_index: Dict[str, str] = {}
3334
self.provider_conflicts: Dict[str, Set[str]] = {}
@@ -74,7 +75,7 @@ def load_layers(self):
7475

7576
for metadata_file in all_files:
7677
try:
77-
meta = Metadata(metadata_file)
78+
meta = Metadata(metadata_file, doc_mode=self.doc_mode)
7879
except Exception:
7980
# Malformed YAML or metadata – skip
8081
continue
@@ -715,6 +716,13 @@ def get_layer_documentation_data(self, layer_name: str):
715716
# Check for companion doc
716717
companion_doc = self._get_companion_doc(layer_name, format='asciidoc')
717718

719+
# Process dependencies to categorise them as static or dynamic
720+
dependencies = self._categorise_dependencies(layer_name)
721+
722+
# Reverse dependencies don't need categorisation - we can't determine them
723+
# if they use env vars, so we only report static rdeps.
724+
reverse_dependencies = self.get_reverse_dependencies(layer_name)
725+
718726
return {
719727
'layer_info': layer.get_layer_info(),
720728
'variables': variables,
@@ -723,8 +731,8 @@ def get_layer_documentation_data(self, layer_name: str):
723731
'mmdebstrap': mmdebstrap_config,
724732
'file_path': relative_path,
725733
'companion_doc': companion_doc,
726-
'dependencies': self.get_dependencies(layer_name),
727-
'reverse_dependencies': self.get_reverse_dependencies(layer_name)
734+
'dependencies': dependencies,
735+
'reverse_dependencies': reverse_dependencies
728736
}
729737

730738
def _get_companion_doc(self, layer_name: str, format: str = 'markdown') -> str:
@@ -793,6 +801,29 @@ def _get_raw_metadata_fields(self, layer_name: str) -> dict:
793801
except Exception:
794802
return {}
795803

804+
def _categorise_dependencies(self, layer_name: str) -> dict:
805+
"""Categorise dependencies as static or dynamic based on environment variable usage."""
806+
layer_info = self.get_layer_info(layer_name)
807+
if not layer_info:
808+
return {'static_dep': [], 'dyn_dep': []}
809+
810+
static_deps = []
811+
dyn_deps = []
812+
813+
for dep in layer_info.get('depends', []):
814+
if '${' in dep and '}' in dep:
815+
# Contains env variable substitution (dynamic)
816+
dyn_deps.append(dep)
817+
else:
818+
# static
819+
static_deps.append(dep)
820+
821+
return {
822+
'static_dep': static_deps,
823+
'dyn_dep': dyn_deps
824+
}
825+
826+
796827

797828
def _generate_layer_boilerplate():
798829
"""Generate boilerplate example layer with metadata"""

site/metadata_parser.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,12 @@ def get_supported_fields_list() -> list:
152152
class Metadata:
153153
"""Metadata parser with modular classes."""
154154

155-
def __init__(self, filepath):
155+
def __init__(self, filepath, doc_mode: bool = False):
156156
self.filepath = filepath
157157
raw_metadata = self._load_metadata(filepath)
158158

159159
# Create the container (applies placeholder substitutions internally)
160-
self._container = MetadataContainer.from_metadata_dict(raw_metadata, filepath)
160+
self._container = MetadataContainer.from_metadata_dict(raw_metadata, filepath, doc_mode)
161161

162162
# Create validation result builder
163163
self._result_builder = ValidationResultBuilder(filepath)

templates/docs/html/layer.html

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,15 +136,18 @@ <h2>Additional Documentation</h2>
136136
</div>
137137
{% endif %}
138138

139-
{% if layer.dependencies or layer.reverse_dependencies or layer.layer_info.provides or layer.layer_info.provider_requires %}
139+
{% if layer.dependencies.static_dep or layer.dependencies.dyn_dep or layer.reverse_dependencies or layer.layer_info.provides or layer.layer_info.provider_requires %}
140140
<div class="section">
141141
<h2>Relationships</h2>
142-
{% if layer.dependencies %}
142+
{% if layer.dependencies.static_dep or layer.dependencies.dyn_dep %}
143143
<p><strong>Depends on:</strong></p>
144144
<div class="deps">
145-
{% for dep in layer.dependencies %}
145+
{%- for dep in layer.dependencies.static_dep %}
146146
<a href="{{ dep }}.html" class="dep-badge">{{ dep }}</a>
147-
{% endfor %}
147+
{%- endfor %}
148+
{%- for dep in layer.dependencies.dyn_dep %}
149+
<span class="dep-badge" style="background: #6c757d; cursor: default;" title="Dynamic dependency (contains environment variables)">{{ dep }}</span>
150+
{%- endfor %}
148151
</div>
149152
{% endif %}
150153
{% if layer.reverse_dependencies %}

0 commit comments

Comments
 (0)