from __future__ import annotations
import logging
from enum import Enum
from typing import TYPE_CHECKING
import abc
from attrs import define, field
from collections import defaultdict
if TYPE_CHECKING:
from analyzer.core.analysis import Analysis
from analyzer.core.analysis_modules import AnalyzerModule
[docs]
logger = logging.getLogger("analyzer.core.linting")
@define
[docs]
class LintMessage:
[docs]
pipeline_name: str | None = None
[docs]
module_name: str | None = None
[docs]
def __str__(self):
ctx = []
if self.pipeline_name:
ctx.append(f"Pipeline: {self.pipeline_name}")
if self.module_name:
ctx.append(f"Module: {self.module_name}")
ctx_str = f" [{', '.join(ctx)}]" if ctx else ""
return f"[{self.level.name}] {self.category}{ctx_str}: {self.message}"
@define
[docs]
class LintConfig:
[docs]
ignore: list[str] = field(factory=list)
[docs]
class PipelineLinter(abc.ABC):
[docs]
name: str = "BasePipelineLinter"
@abc.abstractmethod
[docs]
def lint(
self, pipeline_name: str, pipeline: list[AnalyzerModule], metadatas: list[dict]
) -> list[LintMessage]:
return []
[docs]
class AnalysisLinter(abc.ABC):
[docs]
name: str = "BaseAnalysisLinter"
@abc.abstractmethod
[docs]
def lint(self, analysis: Analysis) -> list[LintMessage]:
return []
[docs]
class DanglingSelectionLinter(PipelineLinter):
[docs]
name = "DanglingSelection"
[docs]
def lint(
self, pipeline_name: str, pipeline: list[AnalyzerModule], metadatas: list[dict]
) -> list[LintMessage]:
from analyzer.modules.common.selection import SelectOnColumns
messages = []
pending_selections = set()
meta = metadatas[0]
for module in pipeline:
outputs = module.outputs(meta)
if outputs != "EVENTS":
for col in outputs:
if len(col.fields) >= 2 and col.fields[0] == "Selection":
pending_selections.add(col.fields[1])
if isinstance(module, SelectOnColumns):
if module.selection_names is not None:
for s in module.selection_names:
pending_selections.discard(s)
else:
pending_selections.clear()
for sel in pending_selections:
messages.append(
LintMessage(
level=LintLevel.WARNING,
category=self.name,
message=f"Selection '{sel}' was added but never consumed by a SelectOnColumns module.",
pipeline_name=pipeline_name,
)
)
return messages
[docs]
class DataQualityLinter(PipelineLinter):
[docs]
def lint(
self, pipeline_name: str, pipeline: list[AnalyzerModule], metadatas: list[dict]
) -> list[LintMessage]:
from analyzer.core.analysis_modules import IsSampleType
messages = []
runs_on_data = False
expr = IsSampleType("Data")
for meta in metadatas:
if expr.evaluate(meta):
runs_on_data = True
break
if not runs_on_data:
return messages
has_golden = any(type(m).__name__ == "GoldenLumi" for m in pipeline)
has_noise = any(type(m).__name__ == "NoiseFilter" for m in pipeline)
if not has_golden:
messages.append(
LintMessage(
level=LintLevel.WARNING,
category=self.name,
message="Pipeline is missing a 'GoldenLumi' module. If this pipeline runs on Data, it will not apply golden JSON certification.",
pipeline_name=pipeline_name,
)
)
if not has_noise:
messages.append(
LintMessage(
level=LintLevel.WARNING,
category=self.name,
message="Pipeline is missing a 'NoiseFilter' module. If this pipeline runs on Data, it will not apply standard noise filters.",
pipeline_name=pipeline_name,
)
)
return messages
[docs]
class PipelineReferenceLinter(AnalysisLinter):
[docs]
name = "PipelineReference"
[docs]
def lint(self, analysis: Analysis) -> list[LintMessage]:
messages = []
defined_pipelines = set(analysis.analyzer.base_pipelines.keys())
for desc in analysis.event_collections:
for p in desc.pipelines:
if p not in defined_pipelines:
messages.append(
LintMessage(
level=LintLevel.ERROR,
category=self.name,
message=f"Event collection references undefined pipeline '{p}'.",
)
)
return messages
[docs]
class MissingCutflowLinter(PipelineLinter):
[docs]
name = "MissingCutflow"
[docs]
def lint(
self, pipeline_name: str, pipeline: list[AnalyzerModule], metadatas: list[dict]
) -> list[LintMessage]:
from analyzer.modules.common.selection import SelectOnColumns
messages = []
has_cutflow = False
has_selection = False
for module in pipeline:
if isinstance(module, SelectOnColumns):
has_selection = True
if module.save_cutflow:
has_cutflow = True
if has_selection and not has_cutflow:
messages.append(
LintMessage(
level=LintLevel.WARNING,
category=self.name,
message="Pipeline applies selections but no 'SelectOnColumns' module has 'save_cutflow=True'. Cutflow yields will not be saved.",
pipeline_name=pipeline_name,
)
)
return messages
[docs]
PIPELINE_LINTERS: list[PipelineLinter] = [
DanglingSelectionLinter(),
DataQualityLinter(),
MissingCutflowLinter(),
]
[docs]
ANALYSIS_LINTERS: list[AnalysisLinter] = [
PipelineReferenceLinter(),
]
[docs]
def runLint(analysis: Analysis) -> list[LintMessage]:
from analyzer.core.running import getRepos, getTasks
all_messages = []
dataset_repo, era_repo = getRepos(
analysis.extra_dataset_paths, analysis.extra_era_paths
)
tasks = getTasks(dataset_repo, era_repo, analysis.event_collections)
pipeline_metadatas = defaultdict(list)
for task in tasks:
for pipeline in task.pipelines:
pipeline_metadatas[pipeline].append(task.metadata)
for linter in ANALYSIS_LINTERS:
msgs = linter.lint(analysis)
all_messages.extend(msgs)
for pipeline_name, pipeline in analysis.analyzer.base_pipelines.items():
metadatas = pipeline_metadatas.get(pipeline_name, [])
for module in pipeline:
msgs = module.lint()
for msg in msgs:
msg.pipeline_name = pipeline_name
all_messages.extend(msgs)
for linter in PIPELINE_LINTERS:
msgs = linter.lint(pipeline_name, pipeline, metadatas)
all_messages.extend(msgs)
filtered_messages = []
for msg in all_messages:
if msg.category not in analysis.lint_config.ignore:
filtered_messages.append(msg)
return filtered_messages