Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hint about extra fields in conda-forge.yml #1920

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
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
3 changes: 3 additions & 0 deletions conda_smithy/data/conda-forge.json
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,7 @@
"type": "object"
},
"build_platform": {
"additionalProperties": true,
"properties": {
"emscripten_wasm32": {
"anyOf": [
Expand Down Expand Up @@ -1066,6 +1067,7 @@
"type": "object"
},
"os_version": {
"additionalProperties": true,
"properties": {
"linux_32": {
"anyOf": [
Expand Down Expand Up @@ -1207,6 +1209,7 @@
"type": "object"
},
"provider": {
"additionalProperties": true,
"properties": {
"linux": {
"anyOf": [
Expand Down
60 changes: 51 additions & 9 deletions conda_smithy/lint_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from conda_build.metadata import (
ensure_valid_license_family,
)
from pydantic import BaseModel
from rattler_build_conda_compat import loader as rattler_loader
from ruamel.yaml.constructor import DuplicateKeyError

Expand Down Expand Up @@ -74,12 +75,39 @@
get_section,
load_linter_toml_metdata,
)
from conda_smithy.schema import ConfigModel, NoExtraFieldsHint
from conda_smithy.utils import get_yaml, render_meta_yaml
from conda_smithy.validate_schema import validate_json_schema

NEEDED_FAMILIES = ["gpl", "bsd", "mit", "apache", "psf"]


def _forge_yaml_hint_extra_fields(forge_yaml: dict) -> List[str]:
"""
Identify unexpected keys in the conda-forge.yml file.
This only works if extra="allow" is set in the Pydantic sub-model where the unexpected key is found.
ytausch marked this conversation as resolved.
Show resolved Hide resolved
"""

config = ConfigModel.model_validate(forge_yaml)
hints = []

def _find_extra_fields(model: BaseModel, prefix=""):
if not (
isinstance(model, NoExtraFieldsHint)
and not model.HINT_EXTRA_FIELDS
):
for extra_field in (model.__pydantic_extra__ or {}).keys():
hints.append(f"Unexpected key {prefix + extra_field}")

for field, value in model:
if isinstance(value, BaseModel):
_find_extra_fields(value, f"{prefix + field}.")

_find_extra_fields(config)

return hints


def _get_forge_yaml(recipe_dir: Optional[str] = None) -> dict:
if recipe_dir:
forge_yaml_filename = (
Expand All @@ -102,10 +130,19 @@ def _get_forge_yaml(recipe_dir: Optional[str] = None) -> dict:
return forge_yaml


def lintify_forge_yaml(recipe_dir: Optional[str] = None) -> (list, list):
def lintify_forge_yaml(
recipe_dir: Optional[str] = None,
) -> (list[str], list[str]):
forge_yaml = _get_forge_yaml(recipe_dir)
# This is where we validate against the jsonschema and execute our custom validators.
return validate_json_schema(forge_yaml)
json_lints, json_hints = validate_json_schema(forge_yaml)

lints = [_format_validation_msg(err) for err in json_lints]
hints = [_format_validation_msg(hint) for hint in json_hints]

hints.extend(_forge_yaml_hint_extra_fields(forge_yaml))

return lints, hints


def lintify_meta_yaml(
Expand Down Expand Up @@ -704,24 +741,25 @@ def main(
recipe_dir=recipe_dir
)

results.extend([_format_validation_msg(err) for err in validation_errors])
hints.extend([_format_validation_msg(hint) for hint in validation_hints])
results.extend(validation_errors)
hints.extend(validation_hints)

if return_hints:
return results, hints
else:
return results


if __name__ == "__main__":
# This block is supposed to help debug how the rendered version
# of the linter bot would look like in Github. Taken from
# https://github.com/conda-forge/conda-forge-webservices/blob/747f75659/conda_forge_webservices/linting.py#L138C1-L146C72
def main_debug():
"""
This function is supposed to help debug how the rendered version
of the linter bot would look like in GitHub. Taken from
https://github.com/conda-forge/conda-forge-webservices/blob/747f75659/conda_forge_webservices/linting.py#L138C1-L146C72
"""
rel_path = sys.argv[1]
lints, hints = main(rel_path, False, True)
messages = []
if lints:
all_pass = False
messages.append(
"\nFor **{}**:\n\n{}".format(
rel_path, "\n".join(f"* ❌ {lint}" for lint in lints)
Expand All @@ -735,3 +773,7 @@ def main(
)

print(*messages, sep="\n")


if __name__ == "__main__":
main_debug()
27 changes: 24 additions & 3 deletions conda_smithy/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,31 @@
CONDA_FORGE_YAML_SCHEMA_FILE,
)

"""
Note: By default, we generate hints about additional fields the user added to the model
if extra="allow" is set. This can be disabled by inheriting from the NoExtraFieldsHint class
next to BaseModel.

If adding new fields, you should decide between extra="forbid" and extra="allow", since
extra="ignore" (the default) will not generate hints about additional fields.
"""


class Nullable(Enum):
"""Created to avoid issue with schema validation of null values in lists or dicts."""

null = None


class NoExtraFieldsHint:
"""
Inherit from this class next to BaseModel to disable hinting about extra fields, even
if the model has `ConfigDict(extra="allow")`.
"""

HINT_EXTRA_FIELDS = False


#############################################
######## Choices (Enum/Literals) definitions #########
#############################################
Expand Down Expand Up @@ -100,7 +118,7 @@ class Lints(StrEnum):
##############################################


class AzureRunnerSettings(BaseModel):
class AzureRunnerSettings(BaseModel, NoExtraFieldsHint):
"""This is the settings for runners."""

model_config: ConfigDict = ConfigDict(extra="allow")
Expand Down Expand Up @@ -445,7 +463,7 @@ class BotConfig(BaseModel):
)


class CondaBuildConfig(BaseModel):
class CondaBuildConfig(BaseModel, NoExtraFieldsHint):
model_config: ConfigDict = ConfigDict(extra="allow")

pkg_format: Optional[Literal["tar", 1, 2, "1", "2"]] = Field(
Expand Down Expand Up @@ -551,6 +569,7 @@ class DefaultTestPlatforms(StrEnum):
platform.value: (Optional[Platforms], Field(default=platform.value))
for platform in Platforms
},
__config__=ConfigDict(extra="allow"),
)

OSVersion = create_model(
Expand All @@ -560,6 +579,7 @@ class DefaultTestPlatforms(StrEnum):
for platform in Platforms
if platform.value.startswith("linux")
},
__config__=ConfigDict(extra="allow"),
)

ProviderType = Union[List[CIservices], CIservices, bool, Nullable]
Expand All @@ -576,6 +596,7 @@ class DefaultTestPlatforms(StrEnum):
for plat in ("linux_64", "osx_64", "win_64")
]
),
__config__=ConfigDict(extra="allow"),
)


Expand All @@ -593,7 +614,7 @@ class ConfigModel(BaseModel):
# Values which are not expected to be present in the model dump, are
# flagged with exclude=True. This is to avoid confusion when comparing
# the model dump with the default conda-forge.yml file used for smithy
# or to avoid deprecated values been rendered.
# or to avoid deprecated values being rendered.

conda_build: Optional[CondaBuildConfig] = Field(
default_factory=CondaBuildConfig,
Expand Down
23 changes: 23 additions & 0 deletions news/1920-hint-extra-fields.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
**Added:**

* <news item>

**Changed:**

* ``recipe-lint`` now hints about additional ``conda-forge.yml`` fields that are not part of the schema

**Deprecated:**

* <news item>

**Removed:**

* <news item>

**Fixed:**

* <news item>

**Security:**

* <news item>
76 changes: 73 additions & 3 deletions tests/test_lint_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -2459,8 +2459,8 @@ def test_lint_duplicate_cfyml():
fh.write(
textwrap.dedent(
"""
blah: 1
blah: 2
channel_priority: flexible
channel_priority: strict
"""
)
)
Expand All @@ -2472,7 +2472,7 @@ def test_lint_duplicate_cfyml():
fh.write(
textwrap.dedent(
"""
blah: 1
channel_priority: flexible
"""
)
)
Expand Down Expand Up @@ -3583,5 +3583,75 @@ def test_lint_recipe_parses_v1_duplicate_keys():
), hints


class TestLintifyForgeYamlHintExtraFields:
def test_extra_build_platforms_platform(self):
forge_yml = {
"build_platform": {
"osx_64": "linux_64",
"UNKNOWN_PLATFORM": "linux_64",
}
}

hints = linter._forge_yaml_hint_extra_fields(forge_yml)

assert len(hints) == 1

assert "Unexpected key build_platform.UNKNOWN_PLATFORM" in hints[0]

def test_extra_os_version_platform(self):
forge_yml = {
"os_version": {
"UNKNOWN_PLATFORM_2": "10.9",
}
}

hints = linter._forge_yaml_hint_extra_fields(forge_yml)

assert len(hints) == 1

assert "Unexpected key os_version.UNKNOWN_PLATFORM_2" in hints[0]

def test_extra_provider_platform(self):
forge_yml = {
"provider": {
"osx_64": "travis",
"UNKNOWN_PLATFORM_3": "azure",
}
}

hints = linter._forge_yaml_hint_extra_fields(forge_yml)

assert len(hints) == 1

assert "Unexpected key provider.UNKNOWN_PLATFORM_3" in hints[0]

@pytest.mark.parametrize(
"top_field", ["settings_linux", "settings_osx", "settings_win"]
)
def test_extra_azure_runner_settings_no_hint(self, top_field: str):
forge_yml = {
"azure": {
top_field: {
"EXTRA_FIELD": "EXTRA_VALUE",
}
}
}

hints = linter._forge_yaml_hint_extra_fields(forge_yml)

assert len(hints) == 0

def test_extra_conda_build_config_no_hint(self):
forge_yml = {
"conda_build": {
"EXTRA_FIELD": "EXTRA_VALUE",
}
}

hints = linter._forge_yaml_hint_extra_fields(forge_yml)

assert len(hints) == 0


if __name__ == "__main__":
unittest.main()
Loading