• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

conda-forge / conda-smithy / 11664690674

04 Nov 2024 12:58PM UTC coverage: 71.143% (-0.04%) from 71.187%
11664690674

Pull #2115

github

web-flow
Merge b2b1ef8f9 into 6b684d173
Pull Request #2115: feat: add hint for new noarch: python syntax

1451 of 2160 branches covered (67.18%)

8 of 14 new or added lines in 2 files covered. (57.14%)

9 existing lines in 1 file now uncovered.

3205 of 4505 relevant lines covered (71.14%)

0.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.07
/conda_smithy/lint_recipe.py
1
import copy
1✔
2
import json
1✔
3
import os
1✔
4
import sys
1✔
5
from collections.abc import Mapping
1✔
6
from functools import lru_cache
1✔
7
from glob import glob
1✔
8
from inspect import cleandoc
1✔
9
from pathlib import Path
1✔
10
from textwrap import indent
1✔
11
from typing import Any, List, Optional, Tuple
1✔
12

13
import github
1✔
14
import github.Auth
1✔
15
import github.Organization
1✔
16
import github.Team
1✔
17
import jsonschema
1✔
18
import requests
1✔
19
from conda_build.metadata import (
1✔
20
    ensure_valid_license_family,
21
)
22
from rattler_build_conda_compat import loader as rattler_loader
1✔
23
from ruamel.yaml.constructor import DuplicateKeyError
1✔
24

25
from conda_smithy.configure_feedstock import _read_forge_config
1✔
26
from conda_smithy.linter import conda_recipe_v1_linter
1✔
27
from conda_smithy.linter.hints import (
1✔
28
    hint_check_spdx,
29
    hint_noarch_python_use_python_min,
30
    hint_pip_no_build_backend,
31
    hint_pip_usage,
32
    hint_shellcheck_usage,
33
    hint_sources_should_not_mention_pypi_io_but_pypi_org,
34
    hint_suggest_noarch,
35
)
36
from conda_smithy.linter.lints import (
1✔
37
    lint_about_contents,
38
    lint_build_section_should_be_before_run,
39
    lint_build_section_should_have_a_number,
40
    lint_check_usage_of_whls,
41
    lint_go_licenses_are_bundled,
42
    lint_jinja_var_references,
43
    lint_jinja_variables_definitions,
44
    lint_legacy_usage_of_compilers,
45
    lint_license_cannot_be_unknown,
46
    lint_license_family_should_be_valid,
47
    lint_license_should_not_have_license,
48
    lint_noarch,
49
    lint_noarch_and_runtime_dependencies,
50
    lint_non_noarch_builds,
51
    lint_package_version,
52
    lint_pin_subpackages,
53
    lint_recipe_have_tests,
54
    lint_recipe_maintainers,
55
    lint_recipe_name,
56
    lint_recipe_v1_noarch_and_runtime_dependencies,
57
    lint_require_lower_bound_on_python_version,
58
    lint_rust_licenses_are_bundled,
59
    lint_section_order,
60
    lint_selectors_should_be_in_tidy_form,
61
    lint_should_be_empty_line,
62
    lint_single_space_in_pinned_requirements,
63
    lint_sources_should_have_hash,
64
    lint_stdlib,
65
    lint_subheaders,
66
    lint_usage_of_legacy_patterns,
67
)
68
from conda_smithy.linter.utils import (
1✔
69
    CONDA_BUILD_TOOL,
70
    EXPECTED_SECTION_ORDER,
71
    RATTLER_BUILD_TOOL,
72
    find_local_config_file,
73
    get_section,
74
    load_linter_toml_metdata,
75
)
76
from conda_smithy.utils import get_yaml, render_meta_yaml
1✔
77
from conda_smithy.validate_schema import validate_json_schema
1✔
78

79
NEEDED_FAMILIES = ["gpl", "bsd", "mit", "apache", "psf"]
1✔
80

81

82
def _get_forge_yaml(recipe_dir: Optional[str] = None) -> dict:
1✔
83
    if recipe_dir:
1✔
84
        forge_yaml_filename = (
1✔
85
            glob(os.path.join(recipe_dir, "..", "conda-forge.yml"))
86
            or glob(
87
                os.path.join(recipe_dir, "conda-forge.yml"),
88
            )
89
            or glob(
90
                os.path.join(recipe_dir, "..", "..", "conda-forge.yml"),
91
            )
92
        )
93
        if forge_yaml_filename:
1✔
94
            with open(forge_yaml_filename[0]) as fh:
1✔
95
                forge_yaml = get_yaml().load(fh)
1✔
96
        else:
97
            forge_yaml = {}
1✔
98
    else:
99
        forge_yaml = {}
1✔
100

101
    return forge_yaml
1✔
102

103

104
def lintify_forge_yaml(recipe_dir: Optional[str] = None) -> (list, list):
1✔
105
    forge_yaml = _get_forge_yaml(recipe_dir)
1✔
106
    # This is where we validate against the jsonschema and execute our custom validators.
107
    return validate_json_schema(forge_yaml)
1✔
108

109

110
def lintify_meta_yaml(
1✔
111
    meta: Any,
112
    recipe_dir: Optional[str] = None,
113
    conda_forge: bool = False,
114
    recipe_version: int = 0,
115
) -> Tuple[List[str], List[str]]:
116
    lints = []
1✔
117
    hints = []
1✔
118
    major_sections = list(meta.keys())
1✔
119
    lints_to_skip = (
1✔
120
        _get_forge_yaml(recipe_dir).get("linter", {}).get("skip", [])
121
    )
122

123
    # If the recipe_dir exists (no guarantee within this function) , we can
124
    # find the meta.yaml within it.
125
    recipe_name = "meta.yaml" if recipe_version == 0 else "recipe.yaml"
1✔
126
    recipe_fname = os.path.join(recipe_dir or "", recipe_name)
1✔
127

128
    sources_section = get_section(meta, "source", lints, recipe_version)
1✔
129
    build_section = get_section(meta, "build", lints, recipe_version)
1✔
130
    requirements_section = get_section(
1✔
131
        meta, "requirements", lints, recipe_version
132
    )
133
    build_requirements = requirements_section.get("build", [])
1✔
134
    run_reqs = requirements_section.get("run", [])
1✔
135
    if recipe_version == 1:
1✔
136
        test_section = get_section(meta, "tests", lints, recipe_version)
1✔
137
    else:
138
        test_section = get_section(meta, "test", lints, recipe_version)
1✔
139
    about_section = get_section(meta, "about", lints, recipe_version)
1✔
140
    extra_section = get_section(meta, "extra", lints, recipe_version)
1✔
141
    package_section = get_section(meta, "package", lints, recipe_version)
1✔
142
    outputs_section = get_section(meta, "outputs", lints, recipe_version)
1✔
143

144
    recipe_dirname = os.path.basename(recipe_dir) if recipe_dir else "recipe"
1✔
145
    is_staged_recipes = recipe_dirname != "recipe"
1✔
146

147
    # 0: Top level keys should be expected
148
    unexpected_sections = []
1✔
149
    if recipe_version == 0:
1✔
150
        expected_keys = EXPECTED_SECTION_ORDER
1✔
151
    else:
152
        expected_keys = (
1✔
153
            conda_recipe_v1_linter.EXPECTED_SINGLE_OUTPUT_SECTION_ORDER
154
            + conda_recipe_v1_linter.EXPECTED_MULTIPLE_OUTPUT_SECTION_ORDER
155
        )
156

157
    for section in major_sections:
1✔
158
        if section not in expected_keys:
1✔
159
            lints.append(f"The top level meta key {section} is unexpected")
1✔
160
            unexpected_sections.append(section)
1✔
161

162
    for section in unexpected_sections:
1✔
163
        major_sections.remove(section)
1✔
164

165
    # 1: Top level meta.yaml keys should have a specific order.
166
    lint_section_order(major_sections, lints, recipe_version)
1✔
167

168
    # 2: The about section should have a home, license and summary.
169
    lint_about_contents(about_section, lints, recipe_version)
1✔
170

171
    # 3a: The recipe should have some maintainers.
172
    # 3b: Maintainers should be a list
173
    lint_recipe_maintainers(extra_section, lints)
1✔
174

175
    # 4: The recipe should have some tests.
176
    lint_recipe_have_tests(
1✔
177
        recipe_dir,
178
        test_section,
179
        outputs_section,
180
        lints,
181
        hints,
182
        recipe_version,
183
    )
184

185
    # 5: License cannot be 'unknown.'
186
    lint_license_cannot_be_unknown(about_section, lints)
1✔
187

188
    # 6: Selectors should be in a tidy form.
189
    if recipe_version == 0:
1✔
190
        # v1 does not have selectors in comments form
191
        lint_selectors_should_be_in_tidy_form(recipe_fname, lints, hints)
1✔
192

193
    # 7: The build section should have a build number.
194
    lint_build_section_should_have_a_number(build_section, lints)
1✔
195

196
    # 8: The build section should be before the run section in requirements.
197
    lint_build_section_should_be_before_run(requirements_section, lints)
1✔
198

199
    # 9: Files downloaded should have a hash.
200
    lint_sources_should_have_hash(sources_section, lints)
1✔
201

202
    # 10: License should not include the word 'license'.
203
    lint_license_should_not_have_license(about_section, lints)
1✔
204

205
    # 11: There should be one empty line at the end of the file.
206
    lint_should_be_empty_line(recipe_fname, lints)
1✔
207

208
    # 12: License family must be valid (conda-build checks for that)
209
    # we skip it for v1 builds as it will validate it
210
    # See more: https://prefix-dev.github.io/rattler-build/latest/reference/recipe_file/#about-section
211
    if recipe_version == 0:
1✔
212
        try:
1✔
213
            ensure_valid_license_family(meta)
1✔
214
        except RuntimeError as e:
1✔
215
            lints.append(str(e))
1✔
216

217
    # 12a: License family must be valid (conda-build checks for that)
218
    license = about_section.get("license", "").lower()
1✔
219
    lint_license_family_should_be_valid(
1✔
220
        about_section, license, NEEDED_FAMILIES, lints, recipe_version
221
    )
222

223
    # 13: Check that the recipe name is valid
224
    if recipe_version == 1:
1✔
225
        conda_recipe_v1_linter.lint_recipe_name(meta, lints)
1✔
226
    else:
227
        lint_recipe_name(
1✔
228
            package_section,
229
            lints,
230
        )
231

232
    # 14: Run conda-forge specific lints
233
    if conda_forge:
1✔
234
        run_conda_forge_specific(
1✔
235
            meta, recipe_dir, lints, hints, recipe_version=recipe_version
236
        )
237

238
    # 15: Check if we are using legacy patterns
239
    lint_usage_of_legacy_patterns(requirements_section, lints)
1✔
240

241
    # 16: Subheaders should be in the allowed subheadings
242
    if recipe_version == 0:
1✔
243
        lint_subheaders(major_sections, meta, lints)
1✔
244

245
    # 17: Validate noarch
246
    noarch_value = build_section.get("noarch")
1✔
247
    lint_noarch(noarch_value, lints)
1✔
248

249
    conda_build_config_filename = None
1✔
250
    if recipe_dir:
1✔
251
        cbc_file = "conda_build_config.yaml"
1✔
252
        if recipe_version == 1:
1✔
253
            cbc_file = "variants.yaml"
1✔
254

255
        conda_build_config_filename = find_local_config_file(
1✔
256
            recipe_dir, cbc_file
257
        )
258

259
        if conda_build_config_filename:
1✔
260
            with open(conda_build_config_filename) as fh:
1✔
261
                conda_build_config_keys = set(get_yaml().load(fh).keys())
1✔
262
        else:
263
            conda_build_config_keys = set()
1✔
264

265
        forge_yaml_filename = find_local_config_file(
1✔
266
            recipe_dir, "conda-forge.yml"
267
        )
268

269
        if forge_yaml_filename:
1✔
270
            with open(forge_yaml_filename) as fh:
1✔
271
                forge_yaml = get_yaml().load(fh)
1✔
272
        else:
273
            forge_yaml = {}
1✔
274
    else:
275
        conda_build_config_keys = set()
1✔
276
        forge_yaml = {}
1✔
277

278
    # 18: noarch doesn't work with selectors for runtime dependencies
279
    noarch_platforms = len(forge_yaml.get("noarch_platforms", [])) > 1
1✔
280
    if "lint_noarch_selectors" not in lints_to_skip:
1✔
281
        if recipe_version == 1:
1✔
282
            raw_requirements_section = meta.get("requirements", {})
1✔
283
            lint_recipe_v1_noarch_and_runtime_dependencies(
1✔
284
                noarch_value,
285
                raw_requirements_section,
286
                build_section,
287
                noarch_platforms,
288
                lints,
289
            )
290
        else:
291
            lint_noarch_and_runtime_dependencies(
1✔
292
                noarch_value,
293
                recipe_fname,
294
                forge_yaml,
295
                conda_build_config_keys,
296
                lints,
297
            )
298

299
    # 19: check version
300
    if recipe_version == 1:
1✔
301
        conda_recipe_v1_linter.lint_package_version(meta, lints)
1✔
302
    else:
303
        lint_package_version(package_section, lints)
1✔
304

305
    # 20: Jinja2 variable definitions should be nice.
306
    lint_jinja_variables_definitions(recipe_fname, lints)
1✔
307

308
    # 21: Legacy usage of compilers
309
    lint_legacy_usage_of_compilers(build_requirements, lints)
1✔
310

311
    # 22: Single space in pinned requirements
312
    lint_single_space_in_pinned_requirements(requirements_section, lints)
1✔
313

314
    # 23: non noarch builds shouldn't use version constraints on python and r-base
315
    lint_non_noarch_builds(
1✔
316
        requirements_section, outputs_section, noarch_value, lints
317
    )
318

319
    # 24: jinja2 variable references should be {{<one space>var<one space>}}
320
    lint_jinja_var_references(
1✔
321
        recipe_fname, hints, recipe_version=recipe_version
322
    )
323

324
    # 25: require a lower bound on python version
325
    lint_require_lower_bound_on_python_version(
1✔
326
        run_reqs, outputs_section, noarch_value, lints
327
    )
328

329
    # 26: pin_subpackage is for subpackages and pin_compatible is for
330
    # non-subpackages of the recipe. Contact @carterbox for troubleshooting
331
    # this lint.
332
    lint_pin_subpackages(
1✔
333
        meta,
334
        outputs_section,
335
        package_section,
336
        lints,
337
        recipe_version=recipe_version,
338
    )
339

340
    # 27: Check usage of whl files as a source
341
    lint_check_usage_of_whls(recipe_fname, noarch_value, lints, hints)
1✔
342

343
    # 28: Check that Rust licenses are bundled.
344
    lint_rust_licenses_are_bundled(
1✔
345
        build_requirements, lints, recipe_version=recipe_version
346
    )
347

348
    # 29: Check that go licenses are bundled.
349
    lint_go_licenses_are_bundled(
1✔
350
        build_requirements, lints, recipe_version=recipe_version
351
    )
352

353
    # hints
354
    # 1: suggest pip
355
    hint_pip_usage(build_section, hints)
1✔
356

357
    # 2: suggest python noarch (skip on feedstocks)
358
    raw_requirements_section = meta.get("requirements", {})
1✔
359
    hint_suggest_noarch(
1✔
360
        noarch_value,
361
        build_requirements,
362
        raw_requirements_section,
363
        is_staged_recipes,
364
        conda_forge,
365
        recipe_fname,
366
        hints,
367
        recipe_version=recipe_version,
368
    )
369

370
    # 3: suggest fixing all recipe/*.sh shellcheck findings
371
    hint_shellcheck_usage(recipe_dir, hints)
1✔
372

373
    # 4: Check for SPDX
374
    hint_check_spdx(about_section, hints)
1✔
375

376
    # 5: hint pypi.io -> pypi.org
377
    hint_sources_should_not_mention_pypi_io_but_pypi_org(
1✔
378
        sources_section, hints
379
    )
380

381
    # 6: stdlib-related lints
382
    lint_stdlib(
1✔
383
        meta,
384
        requirements_section,
385
        conda_build_config_filename,
386
        lints,
387
        hints,
388
        recipe_version=recipe_version,
389
    )
390

391
    return lints, hints
1✔
392

393

394
# the two functions here allow the cache to refresh
395
# if some changes the value of os.environ["GH_TOKEN"]
396
# in the same Python process
397
@lru_cache(maxsize=1)
1✔
398
def _cached_gh_with_token(token: str) -> github.Github:
1✔
399
    return github.Github(auth=github.Auth.Token(token))
1✔
400

401

402
def _cached_gh() -> github.Github:
1✔
403
    return _cached_gh_with_token(os.environ["GH_TOKEN"])
1✔
404

405

406
def _maintainer_exists(maintainer: str) -> bool:
1✔
407
    """Check if a maintainer exists on GitHub."""
408
    if "GH_TOKEN" in os.environ:
1✔
409
        # use a token if we have one
410
        gh = _cached_gh()
1✔
411
        try:
1✔
412
            gh.get_user(maintainer)
1✔
413
        except github.UnknownObjectException:
1✔
414
            return False
1✔
415
        return True
1✔
416
    else:
417
        return (
×
418
            requests.get(
419
                f"https://api.github.com/users/{maintainer}"
420
            ).status_code
421
            == 200
422
        )
423

424

425
@lru_cache(maxsize=1)
1✔
426
def _cached_gh_org(org: str) -> github.Organization.Organization:
1✔
427
    return _cached_gh().get_organization(org)
1✔
428

429

430
@lru_cache(maxsize=1)
1✔
431
def _cached_gh_team(org: str, team: str) -> github.Team.Team:
1✔
432
    return _cached_gh_org(org).get_team_by_slug(team)
1✔
433

434

435
def _team_exists(org_team: str) -> bool:
1✔
436
    """Check if a team exists on GitHub."""
437
    if "GH_TOKEN" in os.environ:
1✔
438
        _res = org_team.split("/", 1)
1✔
439
        if len(_res) != 2:
1✔
440
            return False
×
441
        org, team = _res
1✔
442
        try:
1✔
443
            _cached_gh_team(org, team)
1✔
444
        except github.UnknownObjectException:
1✔
445
            return False
1✔
446
        return True
×
447
    else:
448
        # we cannot check without a token
449
        return True
×
450

451

452
def run_conda_forge_specific(
1✔
453
    meta,
454
    recipe_dir,
455
    lints,
456
    hints,
457
    recipe_version: int = 0,
458
):
459
    # Retrieve sections from meta
460
    package_section = get_section(
1✔
461
        meta, "package", lints, recipe_version=recipe_version
462
    )
463
    extra_section = get_section(
1✔
464
        meta, "extra", lints, recipe_version=recipe_version
465
    )
466
    requirements_section = get_section(
1✔
467
        meta, "requirements", lints, recipe_version=recipe_version
468
    )
469
    outputs_section = get_section(
1✔
470
        meta, "outputs", lints, recipe_version=recipe_version
471
    )
472

473
    build_section = get_section(meta, "build", lints, recipe_version)
1✔
474
    noarch_value = build_section.get("noarch")
1✔
475

476
    if recipe_version == 1:
1✔
NEW
477
        test_section = get_section(meta, "tests", lints, recipe_version)
×
478
    else:
479
        test_section = get_section(meta, "test", lints, recipe_version)
1✔
480
        test_reqs = test_section.get("requires") or []
1✔
481

482
    # Fetch list of recipe maintainers
483
    maintainers = extra_section.get("recipe-maintainers", [])
1✔
484

485
    recipe_dirname = os.path.basename(recipe_dir) if recipe_dir else "recipe"
1✔
486
    if recipe_version == 1:
1✔
UNCOV
487
        recipe_name = conda_recipe_v1_linter.get_recipe_name(meta)
×
488
    else:
489
        recipe_name = package_section.get("name", "").strip()
1✔
490
    is_staged_recipes = recipe_dirname != "recipe"
1✔
491

492
    # 1: Check that the recipe does not exist in conda-forge or bioconda
493
    # moved to staged-recipes directly
494

495
    # 2: Check that the recipe maintainers exists:
496
    for maintainer in maintainers:
1!
497
        if "/" in maintainer:
1✔
498
            if not _team_exists(maintainer):
1✔
499
                lints.append(
1✔
500
                    f'Recipe maintainer team "{maintainer}" does not exist'
501
                )
502
        else:
503
            if not _maintainer_exists(maintainer):
1✔
504
                lints.append(
1✔
505
                    f'Recipe maintainer "{maintainer}" does not exist'
506
                )
507

508
    # 3: if the recipe dir is inside the example dir
509
    # moved to staged-recipes directly
510

511
    # 4: Do not delete example recipe
512
    # removed in favor of direct check in staged-recipes CI
513

514
    # 5: Package-specific hints
515
    # (e.g. do not depend on matplotlib, only matplotlib-base)
516
    # we use a copy here since the += below mofiies the original list
517
    build_reqs = copy.deepcopy(requirements_section.get("build") or [])
1✔
518
    host_reqs = copy.deepcopy(requirements_section.get("host") or [])
1✔
519
    run_reqs = copy.deepcopy(requirements_section.get("run") or [])
1✔
520
    for out in outputs_section:
1✔
521
        if recipe_version == 1:
1✔
UNCOV
522
            output_requirements = rattler_loader.load_all_requirements(out)
×
UNCOV
523
            build_reqs += output_requirements.get("build") or []
×
UNCOV
524
            host_reqs += output_requirements.get("host") or []
×
UNCOV
525
            run_reqs += output_requirements.get("run") or []
×
526
        else:
527
            _req = out.get("requirements") or {}
1✔
528
            if isinstance(_req, Mapping):
1✔
529
                build_reqs += _req.get("build") or []
1✔
530
                host_reqs += _req.get("host") or []
1✔
531
                run_reqs += _req.get("run") or []
1✔
532
            else:
533
                run_reqs += _req
1✔
534

535
    specific_hints = (load_linter_toml_metdata() or {}).get("hints", [])
1✔
536

537
    for rq in build_reqs + host_reqs + run_reqs:
1✔
538
        dep = rq.split(" ")[0].strip()
1✔
539
        if dep in specific_hints and specific_hints[dep] not in hints:
1✔
540
            hints.append(specific_hints[dep])
1✔
541

542
    # 6: Check if all listed maintainers have commented:
543
    # moved to staged recipes directly
544

545
    # 7: Ensure that the recipe has some .ci_support files
546
    if not is_staged_recipes and recipe_dir is not None:
1✔
547
        ci_support_files = glob(
1✔
548
            os.path.join(recipe_dir, "..", ".ci_support", "*.yaml")
549
        )
550
        if not ci_support_files:
1✔
551
            lints.append(
1✔
552
                "The feedstock has no `.ci_support` files and thus will not build any packages."
553
            )
554

555
    # 8: Ensure the recipe specifies a Python build backend if needed
556
    host_or_build_reqs = (requirements_section.get("host") or []) or (
1✔
557
        requirements_section.get("build") or []
558
    )
559
    hint_pip_no_build_backend(host_or_build_reqs, recipe_name, hints)
1✔
560
    for out in outputs_section:
1✔
561
        if recipe_version == 1:
1✔
UNCOV
562
            output_requirements = rattler_loader.load_all_requirements(out)
×
UNCOV
563
            build_reqs = output_requirements.get("build") or []
×
UNCOV
564
            host_reqs = output_requirements.get("host") or []
×
565
        else:
566
            _req = out.get("requirements") or {}
1✔
567
            if isinstance(_req, Mapping):
1✔
568
                build_reqs = _req.get("build") or []
1✔
569
                host_reqs = _req.get("host") or []
1✔
570
            else:
571
                build_reqs = []
1✔
572
                host_reqs = []
1✔
573

574
        name = out.get("name", "").strip()
1✔
575
        hint_pip_no_build_backend(host_reqs or build_reqs, name, hints)
1✔
576

577
    # 9: No duplicates in conda-forge.yml
578
    if (
1✔
579
        not is_staged_recipes
580
        and recipe_dir is not None
581
        and os.path.exists(
582
            cfyml_pth := os.path.join(recipe_dir, "..", "conda-forge.yml")
583
        )
584
    ):
585
        try:
1✔
586
            with open(cfyml_pth) as fh:
1✔
587
                get_yaml(allow_duplicate_keys=False).load(fh)
1✔
588
        except DuplicateKeyError:
1✔
589
            lints.append(
1✔
590
                "The ``conda-forge.yml`` file is not allowed to have duplicate keys."
591
            )
592

593
    # 10: check for proper noarch python syntax
594
    hint_noarch_python_use_python_min(
1✔
595
        requirements_section.get("host") or [],
596
        requirements_section.get("run") or [],
597
        test_reqs,
598
        outputs_section,
599
        noarch_value,
600
        hints,
601
    )
602

603

604
def _format_validation_msg(error: jsonschema.ValidationError):
1✔
605
    """Use the data on the validation error to generate improved reporting.
606

607
    If available, get the help URL from the first level of the JSON path:
608

609
        $(.top_level_key.2nd_level_key)
610
    """
611
    help_url = "https://conda-forge.org/docs/maintainer/conda_forge_yml"
1✔
612
    path = error.json_path.split(".")
1✔
613
    descriptionless_schema = {}
1✔
614
    subschema_text = ""
1✔
615

616
    if error.schema:
1✔
617
        descriptionless_schema = {
1✔
618
            k: v for (k, v) in error.schema.items() if k != "description"
619
        }
620

621
    if len(path) > 1:
1!
622
        help_url += f"""/#{path[1].split("[")[0].replace("_", "-")}"""
1✔
623
        subschema_text = json.dumps(descriptionless_schema, indent=2)
1✔
624

625
    return cleandoc(
1✔
626
        f"""
627
        In conda-forge.yml: [`{error.json_path}`]({help_url}) `=` `{error.instance}`.
628
{indent(error.message, " " * 12 + "> ")}
629
            <details>
630
            <summary>Schema</summary>
631

632
            ```json
633
{indent(subschema_text, " " * 12)}
634
            ```
635

636
            </details>
637
        """
638
    )
639

640

641
def main(
1✔
642
    recipe_dir, conda_forge=False, return_hints=False, feedstock_dir=None
643
):
644
    recipe_dir = os.path.abspath(recipe_dir)
1✔
645
    build_tool = CONDA_BUILD_TOOL
1✔
646
    if feedstock_dir:
1✔
647
        feedstock_dir = os.path.abspath(feedstock_dir)
1✔
648
        forge_config = _read_forge_config(feedstock_dir)
1✔
649
        if forge_config.get("conda_build_tool", "") == RATTLER_BUILD_TOOL:
1✔
650
            build_tool = RATTLER_BUILD_TOOL
1✔
651
    else:
652
        if os.path.exists(os.path.join(recipe_dir, "recipe.yaml")):
1✔
653
            build_tool = RATTLER_BUILD_TOOL
1✔
654

655
    if build_tool == RATTLER_BUILD_TOOL:
1!
656
        recipe_file = os.path.join(recipe_dir, "recipe.yaml")
1✔
657
    else:
658
        recipe_file = os.path.join(recipe_dir, "meta.yaml")
1✔
659

660
    if not os.path.exists(recipe_file):
1✔
UNCOV
661
        raise OSError(
×
662
            f"Feedstock has no recipe/{os.path.basename(recipe_file)}"
663
        )
664

665
    if build_tool == CONDA_BUILD_TOOL:
1✔
666
        with open(recipe_file) as fh:
1✔
667
            content = render_meta_yaml("".join(fh))
1✔
668
            meta = get_yaml().load(content)
1✔
669
    else:
670
        meta = get_yaml().load(Path(recipe_file))
1✔
671

672
    recipe_version = 1 if build_tool == RATTLER_BUILD_TOOL else 0
1✔
673

674
    results, hints = lintify_meta_yaml(
1✔
675
        meta,
676
        recipe_dir,
677
        conda_forge,
678
        recipe_version=recipe_version,
679
    )
680
    validation_errors, validation_hints = lintify_forge_yaml(
1✔
681
        recipe_dir=recipe_dir
682
    )
683

684
    results.extend([_format_validation_msg(err) for err in validation_errors])
1✔
685
    hints.extend([_format_validation_msg(hint) for hint in validation_hints])
1✔
686

687
    if return_hints:
1✔
688
        return results, hints
1✔
689
    else:
690
        return results
1✔
691

692

693
if __name__ == "__main__":
694
    # This block is supposed to help debug how the rendered version
695
    # of the linter bot would look like in Github. Taken from
696
    # https://github.com/conda-forge/conda-forge-webservices/blob/747f75659/conda_forge_webservices/linting.py#L138C1-L146C72
697
    rel_path = sys.argv[1]
698
    lints, hints = main(rel_path, False, True)
699
    messages = []
700
    if lints:
701
        all_pass = False
702
        messages.append(
703
            "\nFor **{}**:\n\n{}".format(
704
                rel_path, "\n".join(f"* {lint}" for lint in lints)
705
            )
706
        )
707
    if hints:
708
        messages.append(
709
            "\nFor **{}**:\n\n{}".format(
710
                rel_path, "\n".join(f"* {hint}" for hint in hints)
711
            )
712
        )
713

714
    print(*messages, sep="\n")
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc