diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 174eb4971e..5fcaf26458 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -64,7 +64,7 @@ jobs: runs-on: ubuntu-latest defaults: run: - shell: bash -l {0} + shell: bash -el {0} strategy: fail-fast: false matrix: @@ -125,7 +125,7 @@ jobs: --file ./tests/requirements.txt \ --file ./tests/requirements-linux.txt \ ${{ env.CONDA_CHANNEL_LABEL }}::${{ env.CONDA_VERSION }} - pip install -e . + pip install -e . --no-deps - name: Show info run: | @@ -224,13 +224,19 @@ jobs: run-post: false # skip post cleanup - name: Setup environment + shell: cmd /C CALL {0} run: | - choco install visualstudio2017-workload-vctools - conda install -q -y -c defaults ` - --file .\tests\requirements.txt ` - --file .\tests\requirements-windows.txt ` - ${{ env.CONDA_CHANNEL_LABEL }}::conda - pip install -e . + @echo on + CALL choco install visualstudio2017-workload-vctools || exit 1 + CALL conda install -q -y -c defaults ^ + --file .\tests\requirements.txt ^ + --file .\tests\requirements-windows.txt ^ + ${{ env.CONDA_CHANNEL_LABEL }}::conda || exit 1 + :: TEMPORARY + if "${{ matrix.python-version }}" == "3.8" CALL conda install "https://anaconda.org/conda-forge/menuinst/2.0.0/download/win-64/menuinst-2.0.0-py38hd3f51b4_1.conda" || exit 1 + if "${{ matrix.python-version }}" == "3.11" CALL conda install "https://anaconda.org/conda-forge/menuinst/2.0.0/download/win-64/menuinst-2.0.0-py311h12c1d0e_1.conda" || exit 1 + :: /TEMPORARY + CALL pip install -e . --no-deps || exit 1 - name: Show info run: | @@ -288,7 +294,7 @@ jobs: runs-on: macos-11 defaults: run: - shell: bash -l {0} + shell: bash -el {0} strategy: fail-fast: false matrix: @@ -341,7 +347,7 @@ jobs: --file ./tests/requirements.txt \ --file ./tests/requirements-macos.txt \ ${{ env.CONDA_CHANNEL_LABEL }}::conda - pip install -e . + pip install -e . --no-deps - name: Show info run: | diff --git a/conda_build/post.py b/conda_build/post.py index 76fe82ae96..018c1f7e58 100644 --- a/conda_build/post.py +++ b/conda_build/post.py @@ -1732,6 +1732,65 @@ def fix_permissions(files, prefix): log.warn(str(e)) +def check_menuinst_json(files, prefix) -> None: + """ + Check that Menu/*.json files are valid menuinst v2 JSON documents, + as defined by the CEP-11 schema. This JSON schema is part of the `menuinst` + package. + + Validation can fail if the menu/*.json file is not valid JSON, or if it doesn't + comply with the menuinst schema. + + We validate at build-time so we don't have to validate at install-time, saving + `conda` a few dependencies. + """ + json_files = fnmatch_filter(files, "[Mm][Ee][Nn][Uu][/\\]*.[Jj][Ss][Oo][Nn]") + if not json_files: + return + + print("Validating Menu/*.json files") + log = utils.get_logger(__name__, dedupe=False) + try: + import jsonschema + from menuinst.utils import data_path + except ModuleNotFoundError as exc: + log.warning( + "Found 'Menu/*.json' files but couldn't validate: %s", + ", ".join(json_files), + exc_info=exc, + ) + return + + try: + schema_path = data_path("menuinst.schema.json") + with open(schema_path) as f: + schema = json.load(f) + ValidatorClass = jsonschema.validators.validator_for(schema) + validator = ValidatorClass(schema) + except (jsonschema.SchemaError, json.JSONDecodeError, OSError) as exc: + log.warning("'%s' is not a valid menuinst schema", schema_path, exc_info=exc) + return + + for json_file in json_files: + try: + with open(join(prefix, json_file)) as f: + text = f.read() + if "$schema" not in text: + log.warning( + "menuinst v1 JSON document '%s' won't be validated.", json_file + ) + continue + validator.validate(json.loads(text)) + except (jsonschema.ValidationError, json.JSONDecodeError, OSError) as exc: + log.warning( + "'%s' is not a valid menuinst JSON document!", + json_file, + exc_info=exc, + ) + else: + log.info("'%s' is a valid menuinst JSON document", json_file) + + def post_build(m, files, build_python, host_prefix=None, is_already_linked=False): print("number of files:", len(files)) @@ -1765,6 +1824,7 @@ def post_build(m, files, build_python, host_prefix=None, is_already_linked=False ): post_process_shared_lib(m, f, prefix_files, host_prefix) check_overlinking(m, files, host_prefix) + check_menuinst_json(files, host_prefix) def check_symlinks(files, prefix, croot): diff --git a/pyproject.toml b/pyproject.toml index 6125bce2ee..edf6f493b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,8 @@ dependencies = [ "six", "tomli ; python_version<'3.11'", "tqdm", + "jsonschema >=4.19", + "menuinst >=2" ] dynamic = ["version"] diff --git a/recipe/meta.yaml b/recipe/meta.yaml index 6a3ed0ea27..c2451656da 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -51,6 +51,8 @@ requirements: - six - tomli # [py<311] - tqdm + - menuinst >=2 + - jsonschema >=4.19 run_constrained: - conda-verify >=3.1.0 diff --git a/tests/requirements-linux.txt b/tests/requirements-linux.txt index 149ce09bad..b1785e2c4f 100644 --- a/tests/requirements-linux.txt +++ b/tests/requirements-linux.txt @@ -1,3 +1,5 @@ +# TEMP +conda-forge::menuinst >=2 patch patchelf shellcheck diff --git a/tests/requirements-macos.txt b/tests/requirements-macos.txt index 133b191333..caa4235c84 100644 --- a/tests/requirements-macos.txt +++ b/tests/requirements-macos.txt @@ -1,2 +1,4 @@ +# TEMP +conda-forge::menuinst >=2 patch shellcheck diff --git a/tests/requirements.txt b/tests/requirements.txt index 02d34d6787..3e230a6c24 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -11,6 +11,7 @@ cytoolz filelock git jinja2 +jsonschema numpy perl pip diff --git a/tests/test-recipes/metadata/_menu_json_validation/menu.json b/tests/test-recipes/metadata/_menu_json_validation/menu.json new file mode 100644 index 0000000000..eeed9e756f --- /dev/null +++ b/tests/test-recipes/metadata/_menu_json_validation/menu.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "https://schemas.conda.io/menuinst-1.schema.json", + "menu_name": "Example 1", + "menu_items": [ + { + "name": "Example", + "description": "This will install to Windows and Linux with default options. MacOS has a custom option.", + "command": [ + "{{ PYTHON }}", + "-c", + "import sys; print(sys.executable)" + ], + "platforms": { + "win": {}, + "linux": {}, + "osx": { + "CFBundleName": "My Example" + } + } + } + ] +} \ No newline at end of file diff --git a/tests/test-recipes/metadata/_menu_json_validation/meta.yaml b/tests/test-recipes/metadata/_menu_json_validation/meta.yaml new file mode 100644 index 0000000000..ac23805ec9 --- /dev/null +++ b/tests/test-recipes/metadata/_menu_json_validation/meta.yaml @@ -0,0 +1,10 @@ +package: + name: menu_json_validation + version: "1.0" + +build: + script: + - mkdir -p "${PREFIX}/Menu" # [unix] + - cp "${RECIPE_DIR}/menu.json" "${PREFIX}/Menu/menu_json_validation.json" # [unix] + - md "%PREFIX%\\Menu" # [win] + - copy /y "%RECIPE_DIR%\\menu.json" "%PREFIX%\\Menu\\menu_json_validation.json" # [win] diff --git a/tests/test_post.py b/tests/test_post.py index 3fa808fad5..c15fffaf2a 100644 --- a/tests/test_post.py +++ b/tests/test_post.py @@ -1,8 +1,11 @@ # Copyright (C) 2014 Anaconda, Inc # SPDX-License-Identifier: BSD-3-Clause +import json +import logging import os import shutil import sys +from pathlib import Path import pytest @@ -91,3 +94,57 @@ def test_pypi_installer_metadata(testing_config): get_site_packages("", "3.9") ) assert "conda" == (package_has_file(pkg, expected_installer, refresh_mode="forced")) + + +def test_menuinst_validation_ok(testing_config, caplog, tmp_path): + "1st check - validation passes with recipe as is" + recipe = Path(metadata_dir, "_menu_json_validation") + recipe_tmp = tmp_path / "_menu_json_validation" + shutil.copytree(recipe, recipe_tmp) + + with caplog.at_level(logging.INFO): + pkg = api.build(str(recipe_tmp), config=testing_config, notest=True)[0] + + captured_text = caplog.text + assert "Found 'Menu/*.json' files but couldn't validate:" not in captured_text + assert "not a valid menuinst JSON file" not in captured_text + assert "is a valid menuinst JSON document" in captured_text + assert package_has_file(pkg, "Menu/menu_json_validation.json") + + +def test_menuinst_validation_fails_bad_schema(testing_config, caplog, tmp_path): + "2nd check - valid JSON but invalid content fails validation" + recipe = Path(metadata_dir, "_menu_json_validation") + recipe_tmp = tmp_path / "_menu_json_validation" + shutil.copytree(recipe, recipe_tmp) + menu_json = recipe_tmp / "menu.json" + menu_json_contents = menu_json.read_text() + + bad_data = json.loads(menu_json_contents) + bad_data["menu_items"][0]["osx"] = ["bad", "schema"] + menu_json.write_text(json.dumps(bad_data, indent=2)) + with caplog.at_level(logging.WARNING): + api.build(str(recipe_tmp), config=testing_config, notest=True) + + captured_text = caplog.text + assert "Found 'Menu/*.json' files but couldn't validate:" not in captured_text + assert "not a valid menuinst JSON document" in captured_text + assert "ValidationError" in captured_text + + +def test_menuinst_validation_fails_bad_json(testing_config, caplog, tmp_path): + "3rd check - non-parsable JSON fails validation" + recipe = Path(metadata_dir, "_menu_json_validation") + recipe_tmp = tmp_path / "_menu_json_validation" + shutil.copytree(recipe, recipe_tmp) + menu_json = recipe_tmp / "menu.json" + menu_json_contents = menu_json.read_text() + menu_json.write_text(menu_json_contents + "Make this an invalid JSON") + + with caplog.at_level(logging.WARNING): + api.build(str(recipe_tmp), config=testing_config, notest=True) + + captured_text = caplog.text + assert "Found 'Menu/*.json' files but couldn't validate:" not in captured_text + assert "not a valid menuinst JSON document" in captured_text + assert "JSONDecodeError" in captured_text