Source code for neurodocker.reproenv.template

"""Template objects."""

from __future__ import annotations

import copy
from typing import Mapping, Optional, cast

from neurodocker.reproenv.exceptions import TemplateKeywordArgumentError
from neurodocker.reproenv.state import _validate_template
from neurodocker.reproenv.types import (
    TemplateType,
    _BinariesTemplateType,
    _SourceTemplateType,
)


[docs]class Template: """Template object. This class makes it more convenient to work with templates. It also allows one to set keyword arguments for instances of templates. For example, if a template calls for an argument `version`, this class can be used to hold both the template and the value for `version`. Keywords are not validated during initialization. To validate keywords, use the instance method `self.binaries.validate_kwds()` and`self.source.validate_kwds()`. Parameters ---------- template : TemplateType Dictionary that defines how to install software from pre-compiled binaries and/or from source. binaries_kwds : dict Keyword arguments to pass to the binaries section of the template. All keys and values must be strings. source_kwds : dict Keyword arguments passed to the source section of the template. All keys and values must be strings. """ def __init__( self, template: TemplateType, binaries_kwds: Mapping[str, str] = None, source_kwds: Mapping[str, str] = None, ): # Validate against JSON schema. Registered templates were already validated at # registration time, but if we do not validate here, then in-memory templates # (ie python dictionaries) will never be validated. _validate_template(template) self._template = copy.deepcopy(template) self._binaries: Optional[_BinariesTemplate] = None self._binaries_kwds = {} if binaries_kwds is None else binaries_kwds self._source: Optional[_SourceTemplate] = None self._source_kwds = {} if source_kwds is None else source_kwds if "binaries" in self._template: self._binaries = _BinariesTemplate( self._template["binaries"], **self._binaries_kwds ) if "source" in self._template: self._source = _SourceTemplate( self._template["source"], **self._source_kwds ) @property def name(self) -> str: return self._template["name"] @property def binaries(self) -> None | _BinariesTemplate: return self._binaries @property def source(self) -> None | _SourceTemplate: return self._source @property def alert(self) -> str: """Return the template's `alert` property. Return an empty string if it does not exist. """ return self._template.get("alert", "")
class _BaseInstallationTemplate: """Base class for installation template classes. This class and its subclasses make it more convenient to work with templates. It also allows one to set keyword arguments for instances of templates. For example, if a template calls for an argument `version`, this class can be used to hold both the template and the value for `version`. Parameters ---------- template : BinariesTemplateType or SourceTemplateType Dictionary that defines how to install software from pre-compiled binaries or from source. kwds Keyword arguments to pass to the template. All values must be strings. Values that are not strings are cast to string. """ def __init__( self, template: _BinariesTemplateType | _SourceTemplateType, **kwds: str, ) -> None: self._template = copy.deepcopy(template) # User-defined arguments that are passed to template at render time. for key, value in kwds.items(): if not isinstance(value, str): kwds[key] = str(value) self._kwds = kwds # This is meant to be overwritten by renderers, so that self.pkg_manager can # be used in templates. self.pkg_manager = None # We cannot validate kwds immediately... The Renderer should not validate # immediately. It should validate only the installation method being used. self._set_kwds_as_attrs() def __repr__(self) -> str: return f"{self.__class__.__name__}({self._template}, **{self._kwds})" def validate_kwds(self): """Raise `TemplateKeywordArgumentError` if keyword arguments to template are invalid. """ # Check that all required keywords were provided by user. req_keys_not_found = self.required_arguments.difference(self._kwds) if req_keys_not_found: raise TemplateKeywordArgumentError( "Missing required arguments: '{}'.".format( "', '".join(req_keys_not_found) ) ) # Check that unknown kwargs were not provided. all_kwds = self.required_arguments.union(self.optional_arguments.keys()) unknown_kwds = set(self._kwds).difference(all_kwds) if unknown_kwds: raise TemplateKeywordArgumentError( "Keyword argument provided is not specified in template: '{}'.".format( "', '".join(unknown_kwds) ) ) # Check that version is valid. if "version" in self.required_arguments: # At this point, we are certain "version" has been provided. v = self._kwds["version"] # Templates for builds from source have versions `{"ANY"}` because they can # ideally build any version. if ( v not in self.versions # This indicates a source method. and self.versions != {"ANY"} # The presence of * in the list of binary urls indicates that any # version is allowed. It also suggests that the version passed by # the user is substituted into the URL for the binaries. # TODO: consider changing {"ANY"} to "*" in source methods. and "*" not in self.versions ): raise TemplateKeywordArgumentError( "Unknown version '{}'. Allowed versions are '{}'.".format( v, "', '".join(self.versions) ) ) def _set_kwds_as_attrs(self): # Check that keywords do not shadow attributes of this object. shadowed = set(self._kwds).intersection(dir(self)) if shadowed: raise TemplateKeywordArgumentError( "Invalid keyword arguments: '{}'. If these keywords are used by the" " template, then the template must be modified to use different" " keywords.".format("', '".join(shadowed)) ) # Set optional arguments to their default value, if the argument was not # provided. for k, v in self.optional_arguments.items(): self._kwds.setdefault(k, v) # Set keywords as attributes to this object. This makes it easy to render # variables in the jinja template. for k, v in self._kwds.items(): setattr(self, k, v) @property def template(self): return self._template @property def env(self) -> Mapping[str, str]: return self._template.get("env", {}) @property def instructions(self) -> str: return self._template.get("instructions", "") @property def arguments(self) -> Mapping: return self._template.get("arguments", {}) @property def required_arguments(self) -> set[str]: args = self.arguments.get("required", None) return set(args) if args is not None else set() @property def optional_arguments(self) -> dict[str, str]: args = self.arguments.get("optional", None) return args if args is not None else {} @property def versions(self) -> set[str]: raise NotImplementedError() def dependencies(self, pkg_manager: str) -> list[str]: deps_dict = self._template.get("dependencies", {}) # TODO: not sure why the following line raises a type error in mypy. return deps_dict.get(pkg_manager, []) # type: ignore def install(self, pkgs: list[str], opts: str = None) -> str: raise NotImplementedError( "This method is meant to be patched by renderer objects, so it can be used" " in templates and have access to the pkg_manager being used." ) def install_dependencies(self, opts: str = None) -> str: raise NotImplementedError( "This method is meant to be patched by renderer objects, so it can be used" " in templates and have access to the pkg_manager being used." ) class _BinariesTemplate(_BaseInstallationTemplate): def __init__(self, template: _BinariesTemplateType, **kwds: str): super().__init__(template=template, **kwds) @property def urls(self) -> Mapping[str, str]: # TODO: how can the code be changed so this cast is not necessary? self._template = cast(_BinariesTemplateType, self._template) return self._template.get("urls", {}) @property def versions(self) -> set[str]: # TODO: how can the code be changed so this cast is not necessary? self._template = cast(_BinariesTemplateType, self._template) return set(self.urls.keys()) class _SourceTemplate(_BaseInstallationTemplate): def __init__(self, template: _SourceTemplateType, **kwds: str): super().__init__(template=template, **kwds) @property def versions(self) -> set[str]: return {"ANY"}