From 795ff479c9f785afe8a1b07c14ef723a3c6d4715 Mon Sep 17 00:00:00 2001 From: Alberto Invernizzi Date: Wed, 30 Jul 2025 09:26:18 +0200 Subject: [PATCH 1/9] refactor schema and its validation --- stackinator/cache.py | 2 +- stackinator/recipe.py | 6 +-- stackinator/schema.py | 94 +++++++++++++++++++++++++--------------- unittests/test_schema.py | 18 ++++---- 4 files changed, 71 insertions(+), 49 deletions(-) diff --git a/stackinator/cache.py b/stackinator/cache.py index ddafb981..c77655da 100644 --- a/stackinator/cache.py +++ b/stackinator/cache.py @@ -12,7 +12,7 @@ def configuration_from_file(file, mount): raw = yaml.load(fid, Loader=yaml.Loader) # validate the yaml - schema.cache_validator.validate(raw) + schema.validate(schema.CacheValidator, raw) # verify that the root path exists path = pathlib.Path(os.path.expandvars(raw["root"])) diff --git a/stackinator/recipe.py b/stackinator/recipe.py index 2e997983..0c0a1e1f 100644 --- a/stackinator/recipe.py +++ b/stackinator/recipe.py @@ -89,7 +89,7 @@ def __init__(self, args): with compiler_path.open() as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.compilers_validator.validate(raw) + schema.validate(schema.CompilersValidator, raw) self.generate_compiler_specs(raw) # required environments.yaml file @@ -110,7 +110,7 @@ def __init__(self, args): "specs": ["squashfs"], "views": {}, } - schema.environments_validator.validate(raw) + schema.validate(schema.EnvironmentsValidator, raw) self.generate_environment_specs(raw) # optional modules.yaml file @@ -269,7 +269,7 @@ def config(self, config_path): with config_path.open() as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.config_validator.validate(raw) + schema.validate(schema.ConfigValidator, raw) self._config = raw # In Stackinator 6 we replaced logic required to determine the diff --git a/stackinator/schema.py b/stackinator/schema.py index 0f6379d6..a5c9cc7d 100644 --- a/stackinator/schema.py +++ b/stackinator/schema.py @@ -6,49 +6,71 @@ prefix = pathlib.Path(__file__).parent.resolve() -# create a validator that will insert optional fields with their default values -# if they have not been provided. +def py2yaml(data, indent): + dump = yaml.dump(data) + lines = [ln for ln in dump.split("\n") if ln != ""] + res = ("\n" + " " * indent).join(lines) + return res -def extend_with_default(validator_class): - validate_properties = validator_class.VALIDATORS["properties"] - def set_defaults(validator, properties, instance, schema): - # if instance is none, it's not possible to set any default for any sub-property - if instance is not None: - for property, subschema in properties.items(): - if "default" in subschema: - instance.setdefault(property, subschema["default"]) +def validator(schema): + """ + Create a new validator class that will insert optional fields with their default values + if they have not been provided. + """ - for error in validate_properties( - validator, - properties, - instance, - schema, - ): - yield error + def extend_with_default(validator_class): + validate_properties = validator_class.VALIDATORS["properties"] - return jsonschema.validators.extend( - validator_class, - {"properties": set_defaults}, - ) + def set_defaults(validator, properties, instance, schema): + # if instance is none, it's not possible to set any default for any sub-property + if instance is not None: + for property, subschema in properties.items(): + if "default" in subschema: + instance.setdefault(property, subschema["default"]) + for error in validate_properties( + validator, + properties, + instance, + schema, + ): + yield error -def py2yaml(data, indent): - dump = yaml.dump(data) - lines = [ln for ln in dump.split("\n") if ln != ""] - res = ("\n" + " " * indent).join(lines) - return res + return jsonschema.validators.extend( + validator_class, + {"properties": set_defaults}, + ) + + # try to read dialect metaschema from the $schema entry, otherwise fallback to a default one. + metaschema = jsonschema.validators.validator_for(schema) + + return extend_with_default(metaschema)(schema) + + +def validator_from_schemafile(schema_filepath): + """ + Create a new validator class given the schema filepath. + See validator function for details. + """ + return validator(json.load(open(schema_filepath))) + + +def validate(schema_validator, instance): + """ + Validate an instance of a schema against a given schema_validator class. + It prints all errors detected during validation and then it raises the first one. + """ + errors = [error for error in schema_validator.iter_errors(instance)] + if len(errors) != 0: + for error in errors: + print(error.json_path, error.message) + raise errors[0] -validator = extend_with_default(jsonschema.Draft7Validator) -# load recipe yaml schema -config_schema = json.load(open(prefix / "schema/config.json")) -config_validator = validator(config_schema) -compilers_schema = json.load(open(prefix / "schema/compilers.json")) -compilers_validator = validator(compilers_schema) -environments_schema = json.load(open(prefix / "schema/environments.json")) -environments_validator = validator(environments_schema) -cache_schema = json.load(open(prefix / "schema/cache.json")) -cache_validator = validator(cache_schema) +ConfigValidator = validator_from_schemafile(prefix / "schema/config.json") +CompilersValidator = validator_from_schemafile(prefix / "schema/compilers.json") +EnvironmentsValidator = validator_from_schemafile(prefix / "schema/environments.json") +CacheValidator = validator_from_schemafile(prefix / "schema/cache.json") diff --git a/unittests/test_schema.py b/unittests/test_schema.py index da1f8ed8..2b00cfea 100644 --- a/unittests/test_schema.py +++ b/unittests/test_schema.py @@ -38,7 +38,7 @@ def test_config_yaml(yaml_path): # test that the defaults are set as expected with open(yaml_path / "config.defaults.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validator(schema.config_schema).validate(raw) + schema.validate(schema.ConfigValidator, raw) assert raw["store"] == "/user-environment" assert raw["spack"]["commit"] is None assert raw["modules"] == True # noqa: E712 @@ -47,7 +47,7 @@ def test_config_yaml(yaml_path): with open(yaml_path / "config.full.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validator(schema.config_schema).validate(raw) + schema.validate(schema.ConfigValidator, raw) assert raw["store"] == "/alternative-point" assert raw["spack"]["commit"] == "6408b51" assert raw["modules"] == False # noqa: E712 @@ -60,20 +60,20 @@ def test_recipe_config_yaml(recipe_paths): for p in recipe_paths: with open(p / "config.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validator(schema.config_schema).validate(raw) + schema.validate(schema.ConfigValidator, raw) def test_compilers_yaml(yaml_path): # test that the defaults are set as expected with open(yaml_path / "compilers.defaults.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validator(schema.compilers_schema).validate(raw) + schema.validate(schema.CompilersValidator, raw) assert raw["gcc"] == {"version": "10.2"} assert raw["llvm"] is None with open(yaml_path / "compilers.full.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validator(schema.compilers_schema).validate(raw) + schema.validate(schema.CompilersValidator, raw) assert raw["gcc"] == {"version": "11"} assert raw["llvm"] == {"version": "13"} assert raw["nvhpc"] == {"version": "25.1"} @@ -84,13 +84,13 @@ def test_recipe_compilers_yaml(recipe_paths): for p in recipe_paths: with open(p / "compilers.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validator(schema.compilers_schema).validate(raw) + schema.validate(schema.CompilersValidator, raw) def test_environments_yaml(yaml_path): with open(yaml_path / "environments.full.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validator(schema.environments_schema).validate(raw) + schema.validate(schema.EnvironmentsValidator, raw) # the defaults-env does not set fields # test that they have been set to the defaults correctly @@ -136,7 +136,7 @@ def test_environments_yaml(yaml_path): jsonschema.exceptions.ValidationError, match=r"Additional properties are not allowed \('providers' was unexpected", ): - schema.validator(schema.environments_schema).validate(raw) + schema.validate(schema.EnvironmentsValidator, raw) def test_recipe_environments_yaml(recipe_paths): @@ -144,4 +144,4 @@ def test_recipe_environments_yaml(recipe_paths): for p in recipe_paths: with open(p / "environments.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validator(schema.environments_schema).validate(raw) + schema.validate(schema.EnvironmentsValidator, raw) From c7c5564aae4c35ccc81feac351aa56cbb5d663df Mon Sep 17 00:00:00 2001 From: Alberto Invernizzi Date: Wed, 30 Jul 2025 10:20:08 +0200 Subject: [PATCH 2/9] extend a bit the error message --- stackinator/schema.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/stackinator/schema.py b/stackinator/schema.py index a5c9cc7d..c40c3b12 100644 --- a/stackinator/schema.py +++ b/stackinator/schema.py @@ -57,17 +57,26 @@ def validator_from_schemafile(schema_filepath): return validator(json.load(open(schema_filepath))) +class ValidationError(jsonschema.ValidationError): + def __init__(self, name: str, errors: [jsonschema.ValidationError]): + assert len(errors) != 0 + messages = [ + f"- Failed validating '{error.validator}' in {error.json_path} : {error.message}" for error in errors + ] + message = f"ValidationError for '{name}'\n" + message += "\n".join(messages) + super().__init__(message) + + def validate(schema_validator, instance): """ Validate an instance of a schema against a given schema_validator class. - It prints all errors detected during validation and then it raises the first one. + :raises ValidationError: if the instance is invalid """ errors = [error for error in schema_validator.iter_errors(instance)] if len(errors) != 0: - for error in errors: - print(error.json_path, error.message) - raise errors[0] + raise ValidationError(schema_validator.schema.get("title", "no-title"), errors) ConfigValidator = validator_from_schemafile(prefix / "schema/config.json") From 044d4477d9a8ddf25e1459c11038006847cc4ffd Mon Sep 17 00:00:00 2001 From: Alberto Invernizzi Date: Wed, 30 Jul 2025 14:42:06 +0200 Subject: [PATCH 3/9] move version check before validation --- stackinator/recipe.py | 54 +++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/stackinator/recipe.py b/stackinator/recipe.py index 0c0a1e1f..2d64fd0a 100644 --- a/stackinator/recipe.py +++ b/stackinator/recipe.py @@ -1,5 +1,6 @@ import copy import pathlib +from textwrap import dedent import jinja2 import yaml @@ -51,28 +52,6 @@ def __init__(self, args): # required config.yaml file self.config = self.path / "config.yaml" - # check the version of the recipe - if self.config["version"] != 2: - rversion = self.config["version"] - if rversion == 1: - self._logger.error( - "\nThe recipe is an old version 1 recipe for Spack v0.23 and earlier.\n" - "This version of Stackinator supports Spack 1.0, and has deprecated support for Spack v0.23.\n" - "Use version 5 of stackinator, which can be accessed via the releases/v5 branch:\n" - " git switch releases/v5\n\n" - "If this recipe is to be used with Spack 1.0, then please add the field 'version: 2' to\n" - "config.yaml in your recipe.\n\n" - "For more information: https://eth-cscs.github.io/stackinator/recipes/#configuration\n" - ) - raise RuntimeError("incompatible uenv recipe version") - else: - self._logger.error( - f"\nThe config.yaml file sets an unknown recipe version={rversion}.\n" - "This version of Stackinator supports version 2 recipes.\n\n" - "For more information: https://eth-cscs.github.io/stackinator/recipes/#configuration\n" - ) - raise RuntimeError("incompatible uenv recipe version") - # override the mount point if defined as a CLI argument if args.mount: self.config["store"] = args.mount @@ -269,6 +248,37 @@ def config(self, config_path): with config_path.open() as fid: raw = yaml.load(fid, Loader=yaml.Loader) + + # check config version + rversion = raw.get("version", 1) + if rversion != 2: + if rversion == 1: + self._logger.error( + dedent(""" + The recipe is an old version 1 recipe for Spack v0.23 and earlier. + This version of Stackinator supports Spack 1.0, and has deprecated support for Spack v0.23. + Use version 5 of stackinator, which can be accessed via the releases/v5 branch: + git switch releases/v5 + + If this recipe is to be used with Spack 1.0, then please add the field 'version: 2' to + config.yaml in your recipe. + + For more information: https://eth-cscs.github.io/stackinator/recipes/#configuration + """) + ) + raise RuntimeError("incompatible uenv recipe version") + else: + self._logger.error( + dedent(f""" + The config.yaml file sets an unknown recipe version={rversion}. + This version of Stackinator supports version 2 recipes. + + For more information: https://eth-cscs.github.io/stackinator/recipes/#configuration + """) + ) + raise RuntimeError("incompatible uenv recipe version") + + # validate config against schema schema.validate(schema.ConfigValidator, raw) self._config = raw From f04c72a07583241ea4908cb049e51e4f2c4f24b2 Mon Sep 17 00:00:00 2001 From: Alberto Invernizzi Date: Wed, 30 Jul 2025 14:42:29 +0200 Subject: [PATCH 4/9] minor changes: type hints --- stackinator/schema.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/stackinator/schema.py b/stackinator/schema.py index c40c3b12..cec92dc1 100644 --- a/stackinator/schema.py +++ b/stackinator/schema.py @@ -58,7 +58,7 @@ def validator_from_schemafile(schema_filepath): class ValidationError(jsonschema.ValidationError): - def __init__(self, name: str, errors: [jsonschema.ValidationError]): + def __init__(self, name: str, errors: list[jsonschema.ValidationError]): assert len(errors) != 0 messages = [ f"- Failed validating '{error.validator}' in {error.json_path} : {error.message}" for error in errors @@ -68,13 +68,14 @@ def __init__(self, name: str, errors: [jsonschema.ValidationError]): super().__init__(message) -def validate(schema_validator, instance): +def validate(schema_validator: jsonschema.protocols.Validator, instance: dict): """ Validate an instance of a schema against a given schema_validator class. :raises ValidationError: if the instance is invalid """ errors = [error for error in schema_validator.iter_errors(instance)] + if len(errors) != 0: raise ValidationError(schema_validator.schema.get("title", "no-title"), errors) From 08408006d73f58c19ac6d80c3810e755988d9176 Mon Sep 17 00:00:00 2001 From: Alberto Invernizzi Date: Wed, 30 Jul 2025 16:17:55 +0200 Subject: [PATCH 5/9] wrap validation logic into SchemaValidator --- stackinator/cache.py | 2 +- stackinator/recipe.py | 6 +++--- stackinator/schema.py | 31 +++++++++++-------------------- unittests/test_schema.py | 18 +++++++++--------- 4 files changed, 24 insertions(+), 33 deletions(-) diff --git a/stackinator/cache.py b/stackinator/cache.py index c77655da..afeba5e2 100644 --- a/stackinator/cache.py +++ b/stackinator/cache.py @@ -12,7 +12,7 @@ def configuration_from_file(file, mount): raw = yaml.load(fid, Loader=yaml.Loader) # validate the yaml - schema.validate(schema.CacheValidator, raw) + schema.CacheValidator.validate(raw) # verify that the root path exists path = pathlib.Path(os.path.expandvars(raw["root"])) diff --git a/stackinator/recipe.py b/stackinator/recipe.py index 2d64fd0a..5d85d455 100644 --- a/stackinator/recipe.py +++ b/stackinator/recipe.py @@ -68,7 +68,7 @@ def __init__(self, args): with compiler_path.open() as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validate(schema.CompilersValidator, raw) + schema.CompilersValidator.validate(raw) self.generate_compiler_specs(raw) # required environments.yaml file @@ -89,7 +89,7 @@ def __init__(self, args): "specs": ["squashfs"], "views": {}, } - schema.validate(schema.EnvironmentsValidator, raw) + schema.EnvironmentsValidator.validate(raw) self.generate_environment_specs(raw) # optional modules.yaml file @@ -279,7 +279,7 @@ def config(self, config_path): raise RuntimeError("incompatible uenv recipe version") # validate config against schema - schema.validate(schema.ConfigValidator, raw) + schema.ConfigValidator.validate(raw) self._config = raw # In Stackinator 6 we replaced logic required to determine the diff --git a/stackinator/schema.py b/stackinator/schema.py index cec92dc1..8d3cec6e 100644 --- a/stackinator/schema.py +++ b/stackinator/schema.py @@ -49,14 +49,6 @@ def set_defaults(validator, properties, instance, schema): return extend_with_default(metaschema)(schema) -def validator_from_schemafile(schema_filepath): - """ - Create a new validator class given the schema filepath. - See validator function for details. - """ - return validator(json.load(open(schema_filepath))) - - class ValidationError(jsonschema.ValidationError): def __init__(self, name: str, errors: list[jsonschema.ValidationError]): assert len(errors) != 0 @@ -68,19 +60,18 @@ def __init__(self, name: str, errors: list[jsonschema.ValidationError]): super().__init__(message) -def validate(schema_validator: jsonschema.protocols.Validator, instance: dict): - """ - Validate an instance of a schema against a given schema_validator class. +class SchemaValidator: + def __init__(self, schema_filepath: pathlib.Path): + self._validator = validator(json.load(open(schema_filepath))) - :raises ValidationError: if the instance is invalid - """ - errors = [error for error in schema_validator.iter_errors(instance)] + def validate(self, instance: dict): + errors = [error for error in self._validator.iter_errors(instance)] - if len(errors) != 0: - raise ValidationError(schema_validator.schema.get("title", "no-title"), errors) + if len(errors) != 0: + raise ValidationError(self._validator.schema.get("title", "no-title"), errors) -ConfigValidator = validator_from_schemafile(prefix / "schema/config.json") -CompilersValidator = validator_from_schemafile(prefix / "schema/compilers.json") -EnvironmentsValidator = validator_from_schemafile(prefix / "schema/environments.json") -CacheValidator = validator_from_schemafile(prefix / "schema/cache.json") +ConfigValidator = SchemaValidator(prefix / "schema/config.json") +CompilersValidator = SchemaValidator(prefix / "schema/compilers.json") +EnvironmentsValidator = SchemaValidator(prefix / "schema/environments.json") +CacheValidator = SchemaValidator(prefix / "schema/cache.json") diff --git a/unittests/test_schema.py b/unittests/test_schema.py index 2b00cfea..f6e79ce1 100644 --- a/unittests/test_schema.py +++ b/unittests/test_schema.py @@ -38,7 +38,7 @@ def test_config_yaml(yaml_path): # test that the defaults are set as expected with open(yaml_path / "config.defaults.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validate(schema.ConfigValidator, raw) + schema.ConfigValidator.validate(raw) assert raw["store"] == "/user-environment" assert raw["spack"]["commit"] is None assert raw["modules"] == True # noqa: E712 @@ -47,7 +47,7 @@ def test_config_yaml(yaml_path): with open(yaml_path / "config.full.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validate(schema.ConfigValidator, raw) + schema.ConfigValidator.validate(raw) assert raw["store"] == "/alternative-point" assert raw["spack"]["commit"] == "6408b51" assert raw["modules"] == False # noqa: E712 @@ -60,20 +60,20 @@ def test_recipe_config_yaml(recipe_paths): for p in recipe_paths: with open(p / "config.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validate(schema.ConfigValidator, raw) + schema.ConfigValidator.validate(raw) def test_compilers_yaml(yaml_path): # test that the defaults are set as expected with open(yaml_path / "compilers.defaults.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validate(schema.CompilersValidator, raw) + schema.CompilersValidator.validate(raw) assert raw["gcc"] == {"version": "10.2"} assert raw["llvm"] is None with open(yaml_path / "compilers.full.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validate(schema.CompilersValidator, raw) + schema.CompilersValidator.validate(raw) assert raw["gcc"] == {"version": "11"} assert raw["llvm"] == {"version": "13"} assert raw["nvhpc"] == {"version": "25.1"} @@ -84,13 +84,13 @@ def test_recipe_compilers_yaml(recipe_paths): for p in recipe_paths: with open(p / "compilers.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validate(schema.CompilersValidator, raw) + schema.CompilersValidator.validate(raw) def test_environments_yaml(yaml_path): with open(yaml_path / "environments.full.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validate(schema.EnvironmentsValidator, raw) + schema.EnvironmentsValidator.validate(raw) # the defaults-env does not set fields # test that they have been set to the defaults correctly @@ -136,7 +136,7 @@ def test_environments_yaml(yaml_path): jsonschema.exceptions.ValidationError, match=r"Additional properties are not allowed \('providers' was unexpected", ): - schema.validate(schema.EnvironmentsValidator, raw) + schema.EnvironmentsValidator.validate(raw) def test_recipe_environments_yaml(recipe_paths): @@ -144,4 +144,4 @@ def test_recipe_environments_yaml(recipe_paths): for p in recipe_paths: with open(p / "environments.yaml") as fid: raw = yaml.load(fid, Loader=yaml.Loader) - schema.validate(schema.EnvironmentsValidator, raw) + schema.EnvironmentsValidator.validate(raw) From f18188fa3e436650db6a534b0991178be7fc1272 Mon Sep 17 00:00:00 2001 From: Alberto Invernizzi Date: Wed, 30 Jul 2025 16:25:09 +0200 Subject: [PATCH 6/9] move version check (as precheck) just before every validation --- stackinator/recipe.py | 32 -------------------------------- stackinator/schema.py | 42 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/stackinator/recipe.py b/stackinator/recipe.py index 5d85d455..473cd2e9 100644 --- a/stackinator/recipe.py +++ b/stackinator/recipe.py @@ -1,6 +1,5 @@ import copy import pathlib -from textwrap import dedent import jinja2 import yaml @@ -248,37 +247,6 @@ def config(self, config_path): with config_path.open() as fid: raw = yaml.load(fid, Loader=yaml.Loader) - - # check config version - rversion = raw.get("version", 1) - if rversion != 2: - if rversion == 1: - self._logger.error( - dedent(""" - The recipe is an old version 1 recipe for Spack v0.23 and earlier. - This version of Stackinator supports Spack 1.0, and has deprecated support for Spack v0.23. - Use version 5 of stackinator, which can be accessed via the releases/v5 branch: - git switch releases/v5 - - If this recipe is to be used with Spack 1.0, then please add the field 'version: 2' to - config.yaml in your recipe. - - For more information: https://eth-cscs.github.io/stackinator/recipes/#configuration - """) - ) - raise RuntimeError("incompatible uenv recipe version") - else: - self._logger.error( - dedent(f""" - The config.yaml file sets an unknown recipe version={rversion}. - This version of Stackinator supports version 2 recipes. - - For more information: https://eth-cscs.github.io/stackinator/recipes/#configuration - """) - ) - raise RuntimeError("incompatible uenv recipe version") - - # validate config against schema schema.ConfigValidator.validate(raw) self._config = raw diff --git a/stackinator/schema.py b/stackinator/schema.py index 8d3cec6e..ce663bce 100644 --- a/stackinator/schema.py +++ b/stackinator/schema.py @@ -1,9 +1,12 @@ import json import pathlib +from textwrap import dedent import jsonschema import yaml +from . import root_logger + prefix = pathlib.Path(__file__).parent.resolve() @@ -61,17 +64,52 @@ def __init__(self, name: str, errors: list[jsonschema.ValidationError]): class SchemaValidator: - def __init__(self, schema_filepath: pathlib.Path): + def __init__(self, schema_filepath: pathlib.Path, precheck=None): self._validator = validator(json.load(open(schema_filepath))) + self._precheck = precheck def validate(self, instance: dict): + if self._precheck: + self._precheck(instance) + errors = [error for error in self._validator.iter_errors(instance)] if len(errors) != 0: raise ValidationError(self._validator.schema.get("title", "no-title"), errors) -ConfigValidator = SchemaValidator(prefix / "schema/config.json") +def check_config_version(instance): + # check config version + rversion = instance.get("version", 1) + if rversion != 2: + if rversion == 1: + root_logger.error( + dedent(""" + The recipe is an old version 1 recipe for Spack v0.23 and earlier. + This version of Stackinator supports Spack 1.0, and has deprecated support for Spack v0.23. + Use version 5 of stackinator, which can be accessed via the releases/v5 branch: + git switch releases/v5 + + If this recipe is to be used with Spack 1.0, then please add the field 'version: 2' to + config.yaml in your recipe. + + For more information: https://eth-cscs.github.io/stackinator/recipes/#configuration + """) + ) + raise RuntimeError("incompatible uenv recipe version") + else: + root_logger.error( + dedent(f""" + The config.yaml file sets an unknown recipe version={rversion}. + This version of Stackinator supports version 2 recipes. + + For more information: https://eth-cscs.github.io/stackinator/recipes/#configuration + """) + ) + raise RuntimeError("incompatible uenv recipe version") + + +ConfigValidator = SchemaValidator(prefix / "schema/config.json", check_config_version) CompilersValidator = SchemaValidator(prefix / "schema/compilers.json") EnvironmentsValidator = SchemaValidator(prefix / "schema/environments.json") CacheValidator = SchemaValidator(prefix / "schema/cache.json") From 5beaa5611f77a9afdb50d39252ef15eaa0092aff Mon Sep 17 00:00:00 2001 From: Alberto Invernizzi Date: Wed, 30 Jul 2025 16:37:45 +0200 Subject: [PATCH 7/9] fix tests --- unittests/recipes/base-nvgpu/config.yaml | 1 + unittests/recipes/cache/config.yaml | 1 + unittests/recipes/host-recipe/config.yaml | 2 +- unittests/recipes/with-repo/config.yaml | 1 + unittests/test_schema.py | 11 +++++++++++ unittests/yaml/config.defaults.yaml | 1 + unittests/yaml/config.full.yaml | 1 + 7 files changed, 17 insertions(+), 1 deletion(-) diff --git a/unittests/recipes/base-nvgpu/config.yaml b/unittests/recipes/base-nvgpu/config.yaml index 0b6d54c1..a2b14813 100644 --- a/unittests/recipes/base-nvgpu/config.yaml +++ b/unittests/recipes/base-nvgpu/config.yaml @@ -5,3 +5,4 @@ spack: commit: 6408b51 mirror: enable: false +version: 2 diff --git a/unittests/recipes/cache/config.yaml b/unittests/recipes/cache/config.yaml index 175b89b4..ee25a292 100644 --- a/unittests/recipes/cache/config.yaml +++ b/unittests/recipes/cache/config.yaml @@ -6,3 +6,4 @@ spack: mirror: key: /scratch/e1000/bcumming/secret/spack-key.gpg enable: true +version: 2 diff --git a/unittests/recipes/host-recipe/config.yaml b/unittests/recipes/host-recipe/config.yaml index e108b998..30b15889 100644 --- a/unittests/recipes/host-recipe/config.yaml +++ b/unittests/recipes/host-recipe/config.yaml @@ -4,4 +4,4 @@ description: "An example gcc configuration for CPU-only development" spack: commit: releases/v0.23 repo: https://github.com/spack/spack.git - +version: 2 diff --git a/unittests/recipes/with-repo/config.yaml b/unittests/recipes/with-repo/config.yaml index 6f00c077..89c9dcac 100644 --- a/unittests/recipes/with-repo/config.yaml +++ b/unittests/recipes/with-repo/config.yaml @@ -3,3 +3,4 @@ store: '/user-environment' spack: repo: https://github.com/spack/spack.git commit: v21.0 +version: 2 diff --git a/unittests/test_schema.py b/unittests/test_schema.py index f6e79ce1..6850e5fe 100644 --- a/unittests/test_schema.py +++ b/unittests/test_schema.py @@ -1,6 +1,7 @@ #!/usr/bin/python3 import pathlib +from textwrap import dedent import jsonschema import pytest @@ -54,6 +55,16 @@ def test_config_yaml(yaml_path): assert raw["mirror"] == {"enable": True, "key": "/home/bob/veryprivate.key"} assert raw["description"] == "a really useful environment" + # unsupported old version + with pytest.raises(RuntimeError, match="incompatible uenv recipe version"): + config = dedent(""" + name: cuda-env + spack: + repo: https://github.com/spack/spack.git + """) + raw = yaml.load(config, Loader=yaml.Loader) + schema.ConfigValidator.validate(raw) + def test_recipe_config_yaml(recipe_paths): # validate the config.yaml in the test recipes diff --git a/unittests/yaml/config.defaults.yaml b/unittests/yaml/config.defaults.yaml index 4f90b1ea..0571b297 100644 --- a/unittests/yaml/config.defaults.yaml +++ b/unittests/yaml/config.defaults.yaml @@ -12,3 +12,4 @@ spack: #enable: True # default True #modules: True +version: 2 diff --git a/unittests/yaml/config.full.yaml b/unittests/yaml/config.full.yaml index fc9fa369..26267c6d 100644 --- a/unittests/yaml/config.full.yaml +++ b/unittests/yaml/config.full.yaml @@ -8,3 +8,4 @@ mirror: enable: True modules: False description: "a really useful environment" +version: 2 From 797f9c80a95fb8f251d14af0d3985f056819942f Mon Sep 17 00:00:00 2001 From: Alberto Invernizzi Date: Wed, 30 Jul 2025 17:03:52 +0200 Subject: [PATCH 8/9] drop superfluous comment --- stackinator/schema.py | 1 - 1 file changed, 1 deletion(-) diff --git a/stackinator/schema.py b/stackinator/schema.py index ce663bce..94a00774 100644 --- a/stackinator/schema.py +++ b/stackinator/schema.py @@ -79,7 +79,6 @@ def validate(self, instance: dict): def check_config_version(instance): - # check config version rversion = instance.get("version", 1) if rversion != 2: if rversion == 1: From 4427f19f2bedde81db64f61b3bed8e098de3977c Mon Sep 17 00:00:00 2001 From: Alberto Invernizzi Date: Wed, 30 Jul 2025 17:23:10 +0200 Subject: [PATCH 9/9] ValidationError "in" instead of "for" --- stackinator/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackinator/schema.py b/stackinator/schema.py index 94a00774..4e981900 100644 --- a/stackinator/schema.py +++ b/stackinator/schema.py @@ -58,7 +58,7 @@ def __init__(self, name: str, errors: list[jsonschema.ValidationError]): messages = [ f"- Failed validating '{error.validator}' in {error.json_path} : {error.message}" for error in errors ] - message = f"ValidationError for '{name}'\n" + message = f"ValidationError in '{name}'\n" message += "\n".join(messages) super().__init__(message)