Skip to content
Merged
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
24 changes: 24 additions & 0 deletions components/ecoindex/best_practices/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from ecoindex.best_practices.context import BestPracticesContext, DomMetrics
from ecoindex.best_practices.engine import (
DEFAULT_RULES_PATH,
BestPracticesEngine,
load_rules_config,
)
from ecoindex.best_practices.models import (
BestPracticesReport,
RuleCategory,
RuleResult,
RuleStatus,
)

__all__ = [
"BestPracticesContext",
"BestPracticesEngine",
"BestPracticesReport",
"DEFAULT_RULES_PATH",
"DomMetrics",
"RuleCategory",
"RuleResult",
"RuleStatus",
"load_rules_config",
]
14 changes: 14 additions & 0 deletions components/ecoindex/best_practices/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from pydantic import BaseModel, Field

from ecoindex.models.scraper import Requests


class DomMetrics(BaseModel):
inline_js: int = 0
inline_css: int = 0
print_stylesheet: int = 0


class BestPracticesContext(BaseModel):
requests: Requests = Field(default_factory=Requests)
dom: DomMetrics = Field(default_factory=DomMetrics)
64 changes: 64 additions & 0 deletions components/ecoindex/best_practices/default_rules.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
version: 1
rules:
- id: http_requests
enabled: true
checker: http_requests
category: network
rweb_id: RWEB_0047
url: https://rweb.greenit.fr/en/fiches/RWEB_0047-limit-the-number-of-http-requests
title: "Limit the number of HTTP requests"
description: >
Page load time correlates with the number of files the browser must
download and their individual size. Reducing HTTP requests per page
lowers the number of servers needed and the associated environmental
impacts.
thresholds:
# Soft warn inspired by GreenIT-Analysis intermediate target; fail = RWEB maxValue
warn: 26
fail: 40

- id: domains_number
enabled: true
checker: domains_number
category: network
rweb_id: RWEB_0082
url: https://rweb.greenit.fr/en/fiches/RWEB_0082-limit-the-number-of-domains-serving-resources
title: "Limit the number of domains serving resources"
description: >
When page components are hosted on several domains, the browser must
open an HTTP connection to each of them. Resources should be grouped
on as few domains as possible (a separate cookie-less domain for
static assets remaining an accepted exception).
thresholds:
warn: 3
fail: 5

- id: externalize_css_js
enabled: true
checker: externalize_css_js
category: network
rweb_id: RWEB_0042
url: https://rweb.greenit.fr/en/fiches/RWEB_0042-externalize-css-and-javascript
title: "Externalize CSS and Javascript"
description: >
CSS and JavaScript should not be inlined in the HTML of the page
(except small configuration variables). External files can be cached
by the browser and avoid re-downloading the same code on every page.
thresholds:
warn: 1
fail: 2

- id: print_stylesheet
enabled: true
checker: print_stylesheet
category: user-device
rweb_id: RWEB_0031
url: https://rweb.greenit.fr/en/fiches/RWEB_0031-provide-a-css-print
title: "Provide a CSS print"
description: >
A print stylesheet reduces unnecessary ink and paper by hiding chrome
(header, footer, menu, sidebars) and non-content images when printing.
# Presence check (GreenIT-Analysis style): at least one print stylesheet required
higher_is_worse: false
thresholds:
fail: 1
97 changes: 97 additions & 0 deletions components/ecoindex/best_practices/engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from pathlib import Path

from yaml import safe_load

from ecoindex.best_practices.context import BestPracticesContext
from ecoindex.best_practices.models import (
BestPracticesReport,
RuleDefinition,
RuleResult,
RuleStatus,
RulesConfig,
Thresholds,
)
from ecoindex.best_practices.registry import get_checker

# Ensure checkers are registered when the engine is used.
from ecoindex.best_practices import rules as _rules # noqa: F401

DEFAULT_RULES_PATH = Path(__file__).parent / "default_rules.yaml"


def load_rules_config(path: Path | None = None) -> RulesConfig:
config_path = path or DEFAULT_RULES_PATH
with open(config_path) as fp:
raw = safe_load(fp) or {}
return RulesConfig.model_validate(raw)


def evaluate_status(
value: float,
thresholds: Thresholds,
*,
higher_is_worse: bool = True,
) -> RuleStatus:
"""Evaluate a measured value against RWEB-style maximum thresholds.

When ``higher_is_worse`` is True (count metrics), ``fail`` / ``warn`` are
maximum acceptable values: status is fail when ``value > fail``, warn when
``value > warn``. This matches RWEB ``maxValue`` ("less than or equal to").
"""
warn = thresholds.warn
fail = thresholds.fail

if higher_is_worse:
if fail is not None and value > fail:
return RuleStatus.fail
if warn is not None and value > warn:
return RuleStatus.warn
return RuleStatus.ok

if fail is not None and value < fail:
return RuleStatus.fail
if warn is not None and value < warn:
return RuleStatus.warn
return RuleStatus.ok


def run_rule(context: BestPracticesContext, rule: RuleDefinition) -> RuleResult:
checker = get_checker(rule.checker)
outcome = checker(context, rule)
status = evaluate_status(
outcome.value,
rule.thresholds,
higher_is_worse=rule.higher_is_worse,
)
message = outcome.message or f"{rule.id}={outcome.value}"
return RuleResult(
id=rule.id,
category=rule.category,
title=rule.title,
description=rule.description,
rweb_id=rule.rweb_id,
url=rule.url,
status=status,
value=outcome.value,
thresholds=rule.thresholds,
message=message,
details=outcome.details,
)


class BestPracticesEngine:
def __init__(self, config: RulesConfig | Path | None = None):
if isinstance(config, RulesConfig):
self.config = config
elif isinstance(config, Path):
self.config = load_rules_config(config)
else:
self.config = load_rules_config(None)

def run(self, context: BestPracticesContext) -> BestPracticesReport:
results: list[RuleResult] = []
for rule in self.config.rules:
if not rule.enabled:
continue
results.append(run_rule(context, rule))
return BestPracticesReport(results=results)
86 changes: 86 additions & 0 deletions components/ecoindex/best_practices/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from enum import Enum

from pydantic import BaseModel, Field, HttpUrl


class RuleStatus(str, Enum):
ok = "ok"
warn = "warn"
fail = "fail"


class RuleCategory(str, Enum):
"""RWEB `tiers` values used by the référentiel."""

network = "network"
user_device = "user-device"


class Thresholds(BaseModel):
"""Maximum acceptable values (RWEB `maxValue` semantics: value must be <= fail)."""

warn: float | None = None
fail: float | None = None


class RuleDefinition(BaseModel):
id: str
enabled: bool = True
checker: str
category: RuleCategory
title: str
description: str = ""
rweb_id: str | None = None
url: HttpUrl | str | None = None
thresholds: Thresholds = Field(default_factory=Thresholds)
higher_is_worse: bool = True
params: dict = Field(default_factory=dict)


class RulesConfig(BaseModel):
version: int = 1
rules: list[RuleDefinition] = Field(default_factory=list)


class RuleResult(BaseModel):
id: str
category: RuleCategory
title: str
description: str = ""
rweb_id: str | None = None
url: HttpUrl | str | None = None
status: RuleStatus
value: float
thresholds: Thresholds
message: str
details: list[str] = Field(default_factory=list)


class BestPracticesReport(BaseModel):
results: list[RuleResult] = Field(default_factory=list)

@property
def ok_count(self) -> int:
return sum(1 for r in self.results if r.status == RuleStatus.ok)

@property
def warn_count(self) -> int:
return sum(1 for r in self.results if r.status == RuleStatus.warn)

@property
def fail_count(self) -> int:
return sum(1 for r in self.results if r.status == RuleStatus.fail)

def results_by_category(self) -> dict[RuleCategory, list[RuleResult]]:
grouped: dict[RuleCategory, list[RuleResult]] = {}
for result in self.results:
grouped.setdefault(result.category, []).append(result)
return grouped


class CheckOutcome(BaseModel):
"""Raw measurement returned by a checker before threshold evaluation."""

value: float
details: list[str] = Field(default_factory=list)
message: str | None = None
27 changes: 27 additions & 0 deletions components/ecoindex/best_practices/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from collections.abc import Callable

from ecoindex.best_practices.context import BestPracticesContext
from ecoindex.best_practices.models import CheckOutcome, RuleDefinition

Checker = Callable[[BestPracticesContext, RuleDefinition], CheckOutcome]

_REGISTRY: dict[str, Checker] = {}


def register(name: str) -> Callable[[Checker], Checker]:
def decorator(func: Checker) -> Checker:
_REGISTRY[name] = func
return func

return decorator


def get_checker(name: str) -> Checker:
try:
return _REGISTRY[name]
except KeyError as exc:
raise KeyError(f"Unknown best-practice checker: {name!r}") from exc


def list_checkers() -> list[str]:
return sorted(_REGISTRY.keys())
4 changes: 4 additions & 0 deletions components/ecoindex/best_practices/rules/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from ecoindex.best_practices.rules import dom as dom
from ecoindex.best_practices.rules import network as network

__all__ = ["dom", "network"]
29 changes: 29 additions & 0 deletions components/ecoindex/best_practices/rules/dom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from ecoindex.best_practices.context import BestPracticesContext
from ecoindex.best_practices.models import CheckOutcome, RuleDefinition
from ecoindex.best_practices.registry import register


@register("externalize_css_js")
def check_externalize_css_js(
context: BestPracticesContext, rule: RuleDefinition
) -> CheckOutcome:
value = float(context.dom.inline_js + context.dom.inline_css)
return CheckOutcome(
value=value,
message=(
f"{int(value)} inline CSS/JavaScript block(s) "
f"({context.dom.inline_css} CSS, {context.dom.inline_js} JS)"
),
)


@register("print_stylesheet")
def check_print_stylesheet(
context: BestPracticesContext, rule: RuleDefinition
) -> CheckOutcome:
value = float(context.dom.print_stylesheet)
if value >= 1:
message = f"{int(value)} print stylesheet(s) found"
else:
message = "No print stylesheet found"
return CheckOutcome(value=value, message=message)
26 changes: 26 additions & 0 deletions components/ecoindex/best_practices/rules/network.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from ecoindex.best_practices.context import BestPracticesContext
from ecoindex.best_practices.models import CheckOutcome, RuleDefinition
from ecoindex.best_practices.registry import register


@register("http_requests")
def check_http_requests(
context: BestPracticesContext, rule: RuleDefinition
) -> CheckOutcome:
value = float(context.requests.total_count)
return CheckOutcome(
value=value,
message=f"{int(value)} HTTP request(s)",
)


@register("domains_number")
def check_domains_number(
context: BestPracticesContext, rule: RuleDefinition
) -> CheckOutcome:
domains = sorted(context.requests.domain_aggregation.keys())
return CheckOutcome(
value=float(len(domains)),
details=domains,
message=f"{len(domains)} distinct domain(s)",
)
Loading
Loading