-
Notifications
You must be signed in to change notification settings - Fork 14
Add CLI #73
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
base: main
Are you sure you want to change the base?
Add CLI #73
Changes from 5 commits
0916af0
24b304e
803e006
b5d1519
eca0a92
3595f94
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| """Command line utility for checking a recipe.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import hashlib | ||
| import sys | ||
| import tempfile | ||
| from pathlib import Path | ||
| from typing import TYPE_CHECKING, Any | ||
| from urllib import parse, request | ||
|
|
||
| import yaml | ||
| from jsonschema.validators import Draft7Validator | ||
| from pygments import highlight | ||
| from pygments.formatters import Terminal256Formatter | ||
| from pygments.lexers.templates import YamlJinjaLexer | ||
|
|
||
| from . import __version__ | ||
| from .model import ComplexRecipe, Recipe, SimpleRecipe | ||
|
|
||
| if TYPE_CHECKING: | ||
| from collections.abc import Iterator | ||
|
|
||
| HERE = Path(__file__).parent | ||
| SCHEMA = HERE.parent / "schema.json" | ||
| CLI = "conda-recipe-v2-schema" | ||
| CF_TEMPLATE = ( | ||
| "https://raw.githubusercontent.com/conda-forge/{recipe}-feedstock/" | ||
| "refs/heads/main/recipe/recipe.yaml" | ||
| ) | ||
|
|
||
| # force unescaped multiline string formatting | ||
| yaml.representer.SafeRepresenter.add_representer( | ||
| str, | ||
| lambda dumper, data: dumper.represent_scalar( | ||
| "tag:yaml.org,2002:str", data, style="|" if "\n" in data or len(data) > 80 else None | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| def get_parser() -> argparse.ArgumentParser: | ||
| """Build a command line parser.""" | ||
| parser = argparse.ArgumentParser(CLI) | ||
| parser.add_argument("-v", "--version", action="version", version=f"{CLI} {__version__}") | ||
| parser.add_argument( | ||
| "recipes", | ||
| nargs="*", | ||
| help="a relative path or URL for a `recipe.yaml`; may be given multiple times", | ||
| ) | ||
| parser.add_argument( | ||
| "-w", "--work-dir", type=Path, help="a work folder to persist remote recipes between runs" | ||
| ) | ||
| parser.add_argument( | ||
| "-c", | ||
| "--conda-forge", | ||
| action="append", | ||
| help="names of conda-forge recipe to check (no `-feedstock`); may be given multiple times", | ||
| ) | ||
| parser.add_argument( | ||
| "-u", | ||
| "--no-pretty", | ||
| action="store_true", | ||
| help="disable syntax highlighting for YAML findings", | ||
| ) | ||
| parser.add_argument( | ||
| "-q", | ||
| "--quiet", | ||
| action="store_true", | ||
| help="minimize output", | ||
| ) | ||
| return parser | ||
|
|
||
|
|
||
| def get_validator() -> Draft7Validator: | ||
| """Get a JSON schema validator for the recipe.""" | ||
| schema: dict[str, Any] | None = None | ||
| if SCHEMA.exists(): | ||
| schema = yaml.safe_load(SCHEMA.read_text(encoding="utf-8")) | ||
| else: | ||
| schema = Recipe.json_schema() | ||
| if not schema: | ||
| msg = "could not retrieve the schema" | ||
| raise RuntimeError(msg) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't get this logic. Why would I not always use the class from the pydantic model?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because the pydantic model is an intermediate that isn't published anywhere, while the JSON schema is already in use by many tools, if only via
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Then I don't understand what you would like us to do with this script :) I assumed the point of it is that you check out this repo and run it as a Pixi task |
||
|
|
||
| return Draft7Validator(schema, format_checker=Draft7Validator.FORMAT_CHECKER) | ||
|
|
||
|
|
||
| def check_one_local(path: Path, validator: Draft7Validator) -> Iterator[Any]: | ||
| """Validate one local path.""" | ||
| recipe = yaml.safe_load(path.read_text(encoding="utf-8")) | ||
| for error in validator.iter_errors(recipe): | ||
| yield { | ||
| "path": "/".join(["#", *error.path, ""]), | ||
| "schema_path": "/".join(["#", *error.absolute_schema_path, ""]), | ||
| "message": error.message, | ||
| } | ||
| try: | ||
| model_cls = ComplexRecipe if "outputs" in recipe else SimpleRecipe | ||
| model_cls(**recipe) | ||
| except Exception as err: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not only catch Pydantic exceptions here?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Welp, I don't care to know much about the pydantic exception API and what it might throw today, or change on a patch release tomorrow. In this context of a tiny dev tool that exists to help write schema, I'd rather not have to try/catch anything, and get back a list of errors (a la |
||
| yield {"pydantic": f"{err}"} | ||
|
|
||
|
|
||
| def check_one_recipe(path_or_url: str, validator: Draft7Validator, work_dir: Path) -> Iterator[Any]: | ||
| """Validate one path or URL.""" | ||
| url = parse.urlparse(path_or_url) | ||
| path: Path | None = None | ||
| if url.scheme in {"file"}: | ||
| path = Path(url.path) | ||
| elif url.scheme in {"http", "https"}: | ||
| sha = hashlib.sha256(path_or_url.encode()).hexdigest() | ||
| path = work_dir / f"{sha}/recipe.yaml" | ||
| if not path.is_file(): | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| try: | ||
| request.urlretrieve(path_or_url, path) | ||
| except Exception as err: | ||
| yield {"message": f"Failed to download {path_or_url}: {err}"} | ||
| elif not url.scheme: | ||
| path = Path(path_or_url) | ||
|
|
||
| if not (path and path.exists()): | ||
| yield {"message": f"Couldn't figure out what to do with {path_or_url}"} | ||
| return | ||
|
|
||
| yield from check_one_local(path, validator) | ||
|
|
||
|
|
||
| def check_recipes( | ||
| recipes: list[str], | ||
| work_dir: Path, | ||
| conda_forge: list[str] | None = None, | ||
| ) -> dict[str, Any]: | ||
| """Check all the recipes.""" | ||
| validator = get_validator() | ||
| cf = conda_forge or [] | ||
| recipes = sorted(recipes + [CF_TEMPLATE.format(recipe=recipe) for recipe in cf]) | ||
| return {recipe: [*check_one_recipe(recipe, validator, work_dir)] for recipe in recipes} | ||
|
|
||
|
|
||
| def main(argv: list[str] | None = None): | ||
| """Get the count of validation errors from the CLI arguments and print a reports.""" | ||
| kwargs = {**vars(get_parser().parse_args(argv))} | ||
| work_dir = kwargs.pop("work_dir") | ||
| no_pretty = kwargs.pop("no_pretty") | ||
| quiet = kwargs.pop("quiet") | ||
| if work_dir is None: | ||
| with tempfile.TemporaryDirectory(prefix=f"{CLI}-") as td: | ||
| findings_by_recipe = check_recipes(work_dir=Path(td), **kwargs) | ||
| else: | ||
| findings_by_recipe = check_recipes(work_dir=work_dir, **kwargs) | ||
| if not findings_by_recipe: | ||
| print( | ||
| "No recipes were checked; please provide some URLs or conda-forge names", | ||
| file=sys.stderr, | ||
| ) | ||
| return 1 | ||
| count = sum(map(len, findings_by_recipe.values())) | ||
| if count and not quiet: | ||
| text = yaml.safe_dump( | ||
| {recipe: findings for recipe, findings in findings_by_recipe.items() if findings}, | ||
| default_flow_style=False, | ||
| ) | ||
| print(text if no_pretty else highlight(text, YamlJinjaLexer(), Terminal256Formatter())) | ||
| print( | ||
| f"{'!!! ' if count else ''}{count} findings in {len(findings_by_recipe)} recipes", | ||
| file=sys.stderr, | ||
| ) | ||
| return count | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
in general, i'm wondering whether a tool that just does this for all yaml/json files would be more helpful. you could check at https://www.schemastore.org/ whether the filename
recipe.yamlhas a json schema and compare it against that. this would be more general than the conda-forge use case and i can imagine me using it in more places than only recipe.yamlThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
best check before building, maybe such a tool already exists somewhere
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I agree. If this is a general JSON schema validator, then this repo is probably not the right place for it
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah, sure schema everywhere, hooray!
again, as mentioned, the intent here is to make it easier for contributors to this repo to describe reproducible schema issues and their fixes.
while useful, schemastore is... kinda bad on many levels (privacy, accuracy).
yeah, there are a lot of related tools (see also #29)... but YAML is weird enough across implementations (see: executable
!!tags, lack of correct anchor support, etc.) that the finer points of being valid data sometimes is missed. Anyhow, all those tools don't know aboutpydantic(for good reasons) much less this repo's pydantic.i'll wager there are more
github.com/conda-forge/.../recipe.yamlthan anywhere else public, so doesn't seem like much of a stretch.