Skip to content

Commit

Permalink
Merge pull request #339 from anaconda-distribution/smartin_independen…
Browse files Browse the repository at this point in the history
…t_exec

First pass: Independent Execution
  • Loading branch information
schuylermartin45 authored Jan 11, 2024
2 parents b087bc0 + f0ed3b7 commit d01d8bc
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 79 deletions.
5 changes: 2 additions & 3 deletions .pytest.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# This surpresses deprecation `pytest` warnings related to using `conda.*` packages.
# TODO Future: remove/upgrade deprecated packages
[pytest]
filterwarnings =
ignore:conda.* is pending deprecation:PendingDeprecationWarning
ignore:conda.* is deprecated:DeprecationWarning
ignore::DeprecationWarning
ignore::PendingDeprecationWarning
19 changes: 4 additions & 15 deletions anaconda_linter/lint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@ def __init__(
verbose: bool = False,
exclude: list[str] = None,
nocatch: bool = False,
severity_min: Optional[Severity | str] = None,
severity_min: Optional[Severity] = None,
) -> None:
"""
Constructs a linter instance
Expand All @@ -608,17 +608,7 @@ def __init__(
self.nocatch = nocatch
self.verbose = verbose
self._messages: list[LintMessage] = []
# TODO rm: de-risk this. Enforce `Severity` over `str` universally
if isinstance(severity_min, Severity):
self.severity_min = severity_min
elif isinstance(severity_min, str):
try:
self.severity_min = Severity[severity_min]
except KeyError as e:
raise ValueError(f"Unrecognized severity level {severity_min}") from e
else:
self.severity_min = SEVERITY_MIN_DEFAULT

self.severity_min = SEVERITY_MIN_DEFAULT if severity_min is None else severity_min
self.reload_checks()

def reload_checks(self) -> None:
Expand Down Expand Up @@ -766,9 +756,8 @@ def lint(
result: Severity = Severity.NONE
for message in self._messages:
if message.severity == Severity.ERROR:
result = Severity.ERROR
break
elif message.severity == Severity.WARNING:
return Severity.ERROR
if message.severity == Severity.WARNING:
result = Severity.WARNING

return result
Expand Down
122 changes: 91 additions & 31 deletions anaconda_linter/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,20 @@
import textwrap
import traceback
from enum import IntEnum
from typing import Final, Optional

from anaconda_linter import __version__, lint, utils

DEFAULT_SUBDIRS: Final[list[str]] = [
"linux-64",
"linux-aarch64",
"linux-ppc64le",
"linux-s390x",
"osx-64",
"osx-arm64",
"win-64",
]


# POSIX style return codes
class ReturnCode(IntEnum):
Expand All @@ -22,7 +33,20 @@ class ReturnCode(IntEnum):
EXIT_LINTING_ERRORS = 101


def lint_parser() -> argparse.ArgumentParser:
def _convert_severity(s: str) -> lint.Severity:
"""
Converts the string provided by the argument parser to a Severity Enum.
:param s: Sanitized, upper-cased Severity that is not `Severity.NONE`.
:returns: Equivalent severity enumeration
"""
s = s.upper()
if s in lint.Severity.__members__:
return lint.Severity.__members__[s]
# It should not be possible to be here, but I'd rather default to a severity level than crash or throw.
return lint.SEVERITY_DEFAULT


def _lint_parser() -> argparse.ArgumentParser:
"""
Configures the `argparser` instance used for the linter's CLI
:returns: An `argparser` instance to parse command line arguments
Expand Down Expand Up @@ -72,22 +96,16 @@ def check_path(value) -> str:
parser.add_argument(
"-s",
"--subdirs",
default=[
"linux-64",
"linux-aarch64",
"linux-ppc64le",
"linux-s390x",
"osx-64",
"osx-arm64",
"win-64",
],
default=DEFAULT_SUBDIRS,
action="append",
help="""List subdir to lint. Example: linux-64, win-64...""",
)
parser.add_argument(
"--severity",
choices=["INFO", "WARNING", "ERROR"],
type=str.upper,
choices=[lint.Severity.INFO.name, lint.Severity.WARNING.name, lint.Severity.ERROR.name],
default=lint.SEVERITY_MIN_DEFAULT.name,
# Handles case-insensitive versions of the names
type=lambda s: s.upper(),
help="""The minimum severity level displayed in the output.""",
)
# we do this one separately because we only allow one entry to conda render
Expand All @@ -114,49 +132,91 @@ def check_path(value) -> str:
return parser


def prime() -> ReturnCode:
def execute_linter(
recipe: str,
config: Optional[utils.RecipeConfigType] = None,
variant_config_files: Optional[list[str]] = None,
exclusive_config_files: Optional[list[str]] = None,
subdirs: Optional[list[str]] = None,
severity: lint.Severity = lint.SEVERITY_MIN_DEFAULT,
fix_flag: bool = False,
verbose_flag: bool = False,
) -> tuple[ReturnCode, str]:
"""
Contains the primary execution code that would be in `main()`. This allows us to easily wrap and control return
codes in unexpected failure cases.
Contains the primary execution code that would be in `main()`. This gives us a few benefits:
- Allows us to easily wrap and control return codes in unexpected failure cases.
- Allows us to execute the linter work as a library call in another Python project.
:param recipe: Path to the target feedstock (where `recipe/meta.yaml` can be found)
:param config: (Optional) Defaults to the contents of the default config file provided in `config.yaml`.
:param variant_config_files: (Optional) Configuration files for recipe variants
:param exclusive_config_files: (Optional) Configuration files for exclusive recipe variants
:param subdirs: (Optional) Target recipe architectures/platforms
:param severity: (Optional) Minimum severity level of linting messages to report
:param fix_flag: (Optional) If set to true, the linter will automatically attempt to make changes to the target
recipe file.
:param verbose_flag: (Optional) Enables verbose debugging output.
:returns: The appropriate error code and the final text report containing linter results.
"""
# parse arguments
parser = lint_parser()
args, _ = parser.parse_known_args()
# We want to remove our reliance on external files when the project is run as a library.
if config is None:
config = {
"requirements": "requirements.txt",
"blocklists": ["build-fail-blocklist"],
"channels": ["defaults"],
}

# load global configuration
config_file = os.path.abspath(os.path.dirname(__file__) + "/config.yaml")
config = utils.load_config(config_file)
if subdirs is None:
subdirs = DEFAULT_SUBDIRS

# set up linter
linter = lint.Linter(config=config, verbose=args.verbose, exclude=None, nocatch=True, severity_min=args.severity)
linter = lint.Linter(config=config, verbose=verbose_flag, exclude=None, nocatch=True, severity_min=severity)

# run linter
recipes = [f"{args.recipe}/recipe/"]
recipes = [f"{recipe}/recipe/"]
messages = set()
overall_result = 0
for subdir in args.subdirs:
result = linter.lint(recipes, subdir, args.variant_config_files, args.exclusive_config_files, args.fix)
# TODO evaluate this: Not all of our rules require checking against variants now that we have the parser in percy.
for subdir in subdirs:
result = linter.lint(recipes, subdir, variant_config_files, exclusive_config_files, fix_flag)
if result > overall_result:
overall_result = result
messages = messages | set(linter.get_messages())

# print report
print(lint.Linter.get_report(messages, args.verbose))
# Calculate the final report
report: Final[str] = lint.Linter.get_report(messages, verbose_flag)

# Return appropriate error code.
if overall_result == lint.Severity.WARNING:
return ReturnCode.EXIT_LINTING_WARNINGS
return ReturnCode.EXIT_LINTING_WARNINGS, report
elif overall_result >= lint.Severity.ERROR:
return ReturnCode.EXIT_LINTING_ERRORS
return ReturnCode.EXIT_SUCCESS
return ReturnCode.EXIT_LINTING_ERRORS, report
return ReturnCode.EXIT_SUCCESS, report


def main() -> None:
"""
Primary execution point of the linter's CLI
"""
args, _ = _lint_parser().parse_known_args()
severity: Final[lint.Severity] = _convert_severity(args.severity)

# load global configuration
config_file: Final[str] = os.path.abspath(os.path.dirname(__file__) + "/config.yaml")
config: Final[utils.RecipeConfigType] = utils.load_config(config_file)

try:
sys.exit(prime())
return_code, report = execute_linter(
recipe=args.recipe,
config=config,
variant_config_files=args.variant_config_files,
exclusive_config_files=args.exclusive_config_files,
subdirs=args.subdirs,
severity=severity,
fix_flag=args.fix,
verbose_flag=args.verbose,
)
print(report)
sys.exit(return_code)
except Exception: # pylint: disable=broad-exception-caught
traceback.print_exc()
sys.exit(ReturnCode.EXIT_UNCAUGHT_EXCEPTION)
Expand Down
8 changes: 7 additions & 1 deletion anaconda_linter/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
check_url_cache: URLCache = {}


# TODO: Confirm this is correct
# Represents a recipe's "config" or "cbc.yaml" file
RecipeConfigType = dict[str, str | list[str]]


def validate_config(path: str) -> None:
"""
Validate config against schema
Expand All @@ -46,7 +51,8 @@ def validate_config(path: str) -> None:
validate(config, schema)


def load_config(path: str):
# TODO determine type of "value"
def load_config(path: str) -> RecipeConfigType:
"""
Parses config file, building paths to relevant block-lists.
TODO Future: determine if this config file is necessary and if we can just get away with constants in this file
Expand Down
30 changes: 1 addition & 29 deletions tests/test_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,35 +151,7 @@ def test_severity_level(base_yaml: str, level: Severity, string: str, lint_check
assert messages[0].get_level() == string


def test_severity_bad(base_yaml: str) -> None: # pylint: disable=unused-argument
with pytest.raises(ValueError):
config_file = Path(__file__).parent / "config.yaml"
config = utils.load_config(config_file)
lint.Linter(config=config, severity_min="BADSEVERITY")


# TODO rm: de-risk this. Enforce `Severity` over `str` universally
@pytest.mark.parametrize("level,expected", (("INFO", 3), ("WARNING", 2), ("ERROR", 1)))
def test_severity_min_string(base_yaml: str, level: str, expected: int) -> None:
yaml_str = (
base_yaml
+ """
extra:
only-lint:
- DummyInfo
- DummyError
- DummyWarning
"""
)
recipes = [Recipe.from_string(recipe_text=yaml_str, renderer=RendererType.RUAMEL)]
config_file = Path(__file__).parent / "config.yaml"
config = utils.load_config(config_file)
linter = lint.Linter(config=config, severity_min=level)
linter.lint(recipes)
assert len(linter.get_messages()) == expected


@pytest.mark.parametrize("level,expected", ((Severity.INFO, 3), ("WARNING", 2), ("ERROR", 1)))
@pytest.mark.parametrize("level,expected", ((Severity.INFO, 3), (Severity.WARNING, 2), (Severity.ERROR, 1)))
def test_severity_min_enum(base_yaml: str, level: Severity | str, expected: int) -> None:
yaml_str = (
base_yaml
Expand Down

0 comments on commit d01d8bc

Please sign in to comment.