Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions examples/template/build_template_example.py

This file was deleted.

29 changes: 0 additions & 29 deletions examples/template/templated_config.yaml

This file was deleted.

149 changes: 14 additions & 135 deletions pyaml/configuration/fileloader.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
"""
Load PyAML configuration files and expand nested references.

The loader supports YAML and JSON files, parameterized templates, environment
and path resolvers, recursive file includes, and optional source-location
metadata for diagnostics.
The loader supports YAML and JSON files, environment and path resolvers,
recursive file includes, and optional source-location metadata for diagnostics.
"""

import io
Expand All @@ -24,7 +23,6 @@
from yaml.loader import SafeLoader

from .. import PyAMLException
from .template import TemplateManager

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -139,11 +137,6 @@ class LoadContext:
Preserve source locations in loaded mappings.
include_stack : list[pathlib.Path], optional
Active include chain. Usually left empty for a new session.
expand : callable or None, optional
Callback used to expand generated configurations. Installed temporarily
by the active file loader and restored when that loader finishes.
templates : TemplateManager, optional
Template registry shared by files in this session. Defaults to a fresh registry.

Methods
-------
Expand All @@ -153,9 +146,6 @@ class LoadContext:

include_locations: bool = False
include_stack: list[Path] = field(default_factory=list)
expand: Callable[[Any], Any] | None = None
# Is populated with ConfigLoader's expand function for use in template resolver
templates: TemplateManager = field(default_factory=TemplateManager)

@contextmanager
def loading(self, path: Path):
Expand Down Expand Up @@ -302,86 +292,16 @@ def resolve_include(value: str, context: LoadContext | None = None) -> Any:
return _load(value, context)


@resolver("template")
def resolve_template(value: str, context: LoadContext | None = None) -> Any:
def load(filename: str, include_locations: bool = False) -> Union[dict, list]:
"""
Instantiate a template and recursively expand its configuration.
Load a configuration file.

Parameters
----------
value : str
Name and comma-separated string arguments in the format
``NAME,ARG1,ARG2,...``, or ``NAME`` for no arguments. A trailing comma
supplies an empty-string argument. Commas in arguments are not escaped.
context : LoadContext or None, optional
Active loading context providing the template registry and expansion
callback. Required for resolution despite the compatibility default.

Returns
-------
object
Parsed and expanded configuration data.

Raises
------
PyAMLException
If the active context is missing, the template name is unknown, or
expansion exceeds the recursion limit. Expansion errors also propagate.
PyAMLConfigException
If the number of arguments does not match the template parameters.
"""
if context is None or context.expand is None:
raise PyAMLException("Template resolver requires an active loading context.")

try:
name, separator, argument_text = value.partition(",")
arguments = argument_text.split(",") if separator else []
try:
generated = context.templates.generate(name, *arguments)
return context.expand(generated)
except RecursionError as exc:
raise PyAMLException(
f"Recursion limit reached while expanding template {name!r}. "
"Check for circular template references or excessive nesting."
) from exc
except KeyError as exc:
raise PyAMLException(f"Invalid template resolver call {value}.") from exc


def load(filename: str, include_locations: bool = False, *, templates: TemplateManager | None = None) -> Union[dict, list]:
"""
Load a configuration file, register templates, and expand references.

Parameters
----------
filename : str
YAML or JSON filename. Relative paths are resolved against ``ROOT``.
include_locations : bool, optional
Preserve source-location metadata in YAML mappings. Defaults to False,
which uses the faster C-based YAML loader without location metadata.
templates : TemplateManager or None, optional
Registry to share across configuration fragments. If omitted, a fresh
registry is created. Included files share the same registry.

Returns
-------
dict or list
Expanded configuration with root-level template definitions removed.

Raises
------
PyAMLException
If the file format is unsupported, parsing fails, or reference
expansion fails.
PyAMLConfigException
If template registration or argument-count validation fails.
When include_locations is False, uses the faster C-based YAML loader
and skips including source location metadata.
"""

# Create a new context
context = LoadContext(
include_locations=include_locations,
templates=templates if templates is not None else TemplateManager(),
)
context = LoadContext(include_locations=include_locations)

return _load(filename, context)

Expand All @@ -399,12 +319,7 @@ def _load(filename: str, context: LoadContext) -> Union[dict, list]:
else:
raise PyAMLException(f"{filename} File format not supported (only .yaml .yml or .json)")

previous_expand = context.expand
context.expand = loader.expand
try:
return loader.load()
finally:
context.expand = previous_expand
return loader.load()


class ConfigLoader(ABC):
Expand All @@ -422,8 +337,6 @@ class ConfigLoader(ABC):
-------
expand(obj)
Recursively expand configuration values.
register_templates(config)
Register and remove root-level template definitions before expansion.
load()
Load and parse the current configuration file.
"""
Expand Down Expand Up @@ -586,34 +499,6 @@ def _expand_list(self, items: list) -> list:

return expanded

def register_templates(self, config):
"""
Register root-level templates in the current loading context.

Parameters
----------
config : object
Parsed configuration. For dictionaries, the ``templates`` section
is removed in place and its definitions are registered. Other
values are left unchanged.

Raises
------
PyAMLConfigException
If a template name is already registered in this context's registry.
KeyError
If a definition lacks ``name``, ``parameters``, or ``config``.
"""
if not isinstance(config, dict):
return

for template in config.pop("templates", []):
self.context.templates.add(
name=template["name"],
parameters=template["parameters"],
config=template["config"],
)

@abstractmethod
def load(self) -> Union[dict, list]:
"""Load and parse the current configuration file."""
Expand All @@ -634,7 +519,7 @@ class YAMLLoader(ConfigLoader):
Methods
-------
load()
Parse YAML, register root-level templates, and expand references.
Parse the YAML file and expand nested configuration references.
"""

def __init__(self, path: Path, context: LoadContext):
Expand All @@ -644,15 +529,12 @@ def __init__(self, path: Path, context: LoadContext):
self._loader = SafeLineLoader if context.include_locations else CLoader

def load(self) -> Union[dict, list]:
"""Parse YAML, register root-level templates, and expand configuration references."""
"""Parse the YAML file and expand nested configuration references."""

logger.log(logging.DEBUG, f"Loading YAML file '{self.path}'")
with open(self.path) as file:
try:
parsed_config = yaml.load(file, Loader=self._loader)
# "templates" is popped out here if it exists
self.register_templates(parsed_config)
return self.expand(parsed_config)
return self.expand(yaml.load(file, Loader=self._loader))
except yaml.YAMLError as exc:
raise PyAMLException(f"{self.path}: {exc}") from exc

Expand All @@ -671,7 +553,7 @@ class JSONLoader(ConfigLoader):
Methods
-------
load()
Parse JSON, register root-level templates, and expand references.
Parse the JSON file and expand nested configuration references.
"""

def __init__(self, path: Path, context: LoadContext):
Expand All @@ -680,15 +562,12 @@ def __init__(self, path: Path, context: LoadContext):
super().__init__(path, context)

def load(self) -> Union[dict, list]:
"""Parse JSON, register root-level templates, and expand configuration references."""
"""Parse the JSON file and expand nested configuration references."""

logger.log(logging.DEBUG, f"Loading JSON file '{self.path}'")
with open(self.path) as file:
try:
parsed_config = json.load(file)
# "templates" is popped out here if it exists
self.register_templates(parsed_config)
return self.expand(parsed_config)
return self.expand(json.load(file))
except json.JSONDecodeError as exc:
raise PyAMLException(f"{self.path}: {exc}") from exc

Expand Down
16 changes: 5 additions & 11 deletions pyaml/configuration/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
from ..common.exception import PyAMLConfigException
from .fileloader import ROOT, load
from .restfetcher import REMOTE_BASE_URL_KEY, SourceRoot, fetch_remote_config, is_remote_url, resolve_reference
from .template import TemplateManager

_INTERNAL_METADATA_KEYS = {"__location__", "__fieldlocations__", REMOTE_BASE_URL_KEY}
_VALID_QUERY_KEY_RE = re.compile(r"^[A-Z0-9_]+$")
Expand Down Expand Up @@ -60,7 +59,7 @@ class ConfigurationManager:
replace(category, element)
Replace an existing named entry in an aggregated category.
clear(category=None)
Clear the aggregated state and templates, or a single root field/category.
Clear the aggregated state, or a single root field/category.
categories()
Return categories that currently contain entries.
keys(category=None)
Expand Down Expand Up @@ -157,16 +156,14 @@ def __init__(self):
Initialize an empty configuration manager.

The manager starts with the default accelerator type and empty named
categories. Source tracking is enabled as fragments are added. Local
file fragments share this manager's private template registry.
categories. Source tracking is enabled as fragments are added.
"""
self._state: dict[str, Any] = {"class_path": self.DEFAULT_CLASS_PATH}
self._items_by_category: dict[str, dict[str, dict[str, Any]]] = {category: {} for category in self.NAMED_CATEGORIES}
self._sources_by_category: dict[str, dict[str, str]] = {category: {} for category in self.NAMED_CATEGORIES}
self._field_sources: dict[str, str] = {}
self._build_root: SourceRoot = ROOT.get()
self._build_root_locked = False
self._templates = TemplateManager()

def add(self, payload, **kwargs) -> None:
r"""
Expand Down Expand Up @@ -290,14 +287,12 @@ def replace(self, category: str, element: dict) -> None:

def clear(self, category: str | None = None) -> None:
r"""
Clear all configuration state and templates, or one root field/category.
Clear the aggregated state, or a single root field/category.

Parameters
----------
category : str, optional
If provided, only that category or root field is cleared and
template definitions are retained. If omitted, all aggregated
state and this manager's template registry are cleared.
If provided, only that category or root field is cleared.

Examples
--------
Expand All @@ -311,7 +306,6 @@ def clear(self, category: str | None = None) -> None:
>>> manager.clear()
"""
if category is None:
self._templates.clear()
self._state = {"class_path": self.DEFAULT_CLASS_PATH}
self._field_sources.clear()
for name in self.NAMED_CATEGORIES:
Expand Down Expand Up @@ -704,7 +698,7 @@ def _load_payload(
source_root = resolved_path.parent
try:
ROOT.set(source_root)
fragment = load(resolved_path.name, include_locations, templates=self._templates)
fragment = load(resolved_path.name, include_locations)
finally:
ROOT.set(previous_root)

Expand Down
Loading
Loading