"""FM (Figure/Millimeter) rule detection — opt-in category.
Detects inch-based matplotlib patterns and suggests mm-based alternatives.
Used as a second-pass AST visitor from lint_source when "FM" is enabled.
"""
import ast
from . import rules
from ._packages import detect as _detect_pkgs
from .checker import Issue
[docs]
def _is_exempt_call(node):
"""Check if the call is on a stx.* or fr.* object (exempt from FM rules)."""
func = node.func
if not isinstance(func, ast.Attribute):
return False
if isinstance(func.value, ast.Name) and func.value.id in ("stx", "scitex", "fr"):
return True
if isinstance(func.value, ast.Attribute):
if isinstance(func.value.value, ast.Name) and func.value.value.id in (
"stx",
"scitex",
"fr",
):
return True
return False
[docs]
def _has_kwarg(node, name, value=None):
"""Check if call has a specific keyword argument."""
for kw in node.keywords:
if kw.arg == name:
if value is None:
return True
if isinstance(kw.value, ast.Constant) and kw.value.value == value:
return True
return False
# Suggestion variants: {rule_id: {context: suggestion}}
_SUGGESTIONS = {
"STX-FM001": {
"both": (
"Use mm-based sizing: `stx.plt.subplots(axes_width_mm=40, axes_height_mm=28)` "
"or `fr.subplots(axes_width_mm=40, axes_height_mm=28)`."
),
"stx": "Use mm-based sizing: `stx.plt.subplots(axes_width_mm=40, axes_height_mm=28)`.",
"fr": "Use mm-based sizing: `fr.subplots(axes_width_mm=40, axes_height_mm=28)`.",
},
"STX-FM002": {
"both": (
"Use mm-based margins: `stx.plt.subplots(margin_left_mm=15, margin_bottom_mm=12)` "
"or `fr.subplots(margin_left_mm=15, margin_bottom_mm=12)`."
),
"stx": "Use mm-based margins: `stx.plt.subplots(margin_left_mm=15, margin_bottom_mm=12)`.",
"fr": "Use mm-based margins: `fr.subplots(margin_left_mm=15, margin_bottom_mm=12)`.",
},
"STX-FM003": {
"both": "Use `stx.io.save(fig, './plot.png')` or `fr.save(fig, './plot.png')` for intelligent cropping.",
"stx": "Use `stx.io.save(fig, './plot.png')` which handles cropping intelligently.",
"fr": "Use `fr.save(fig, './plot.png')` which handles cropping intelligently.",
},
"STX-FM004": {
"both": "Use mm-based layout from `stx.plt.subplots()` or `fr.subplots()` instead.",
"stx": "Use mm-based layout from `stx.plt.subplots()` instead of constrained_layout.",
"fr": "Use mm-based layout from `fr.subplots()` instead of constrained_layout.",
},
"STX-FM005": {
"both": (
"Use mm-based spacing: `stx.plt.subplots(space_w_mm=8, space_h_mm=10)` "
"or `fr.subplots(space_w_mm=8, space_h_mm=10)`."
),
"stx": "Use mm-based spacing: `stx.plt.subplots(space_w_mm=8, space_h_mm=10)`.",
"fr": "Use mm-based spacing: `fr.subplots(space_w_mm=8, space_h_mm=10)`.",
},
"STX-FM006": {
"both": "Use `stx.io.save(fig, './plot.png')` or `fr.save(fig, './plot.png')` for provenance tracking.",
"stx": "Use `stx.io.save(fig, './plot.png')` for provenance tracking.",
"fr": "Use `fr.save(fig, './plot.png')` for recipe tracking.",
},
"STX-FM007": {
"both": "Use `stx.plt` style presets or `fr.load_style('SCITEX')` for consistent styling.",
"stx": "Use `stx.plt` style presets for consistent styling.",
"fr": "Use `fr.load_style('SCITEX')` for consistent styling.",
},
"STX-FM008": {
"both": (
"Use mm-based sizing: `stx.plt.subplots(axes_width_mm=40, axes_height_mm=28)` "
"or `fr.subplots(axes_width_mm=40, axes_height_mm=28)`."
),
"stx": "Use mm-based sizing: `stx.plt.subplots(axes_width_mm=40, axes_height_mm=28)`.",
"fr": "Use mm-based sizing: `fr.subplots(axes_width_mm=40, axes_height_mm=28)`.",
},
"STX-FM009": {
"both": (
"Use mm-based margins: `stx.plt.subplots(margin_left_mm=15, margin_bottom_mm=12)` "
"or `fr.subplots(margin_left_mm=15, margin_bottom_mm=12)`."
),
"stx": "Use mm-based margins: `stx.plt.subplots(margin_left_mm=15, margin_bottom_mm=12)`.",
"fr": "Use mm-based margins: `fr.subplots(margin_left_mm=15, margin_bottom_mm=12)`.",
},
}
[docs]
class FMChecker(ast.NodeVisitor):
"""AST visitor for FM (Figure/Millimeter) rules."""
category = "figure"
[docs]
def __init__(self, source_lines, config):
self.source_lines = source_lines
self.config = config
self.issues = []
pkgs = _detect_pkgs()
has_fr = pkgs.get("figrecipe", False)
# Always active when FM rules enabled in config (detect patterns
# regardless of whether figrecipe is installed)
self._active = True
has_stx = pkgs.get("scitex", False)
if has_fr and has_stx:
self._ctx = "both"
elif has_stx:
self._ctx = "stx"
else:
self._ctx = "fr"
def _get_source(self, lineno):
if 1 <= lineno <= len(self.source_lines):
return self.source_lines[lineno - 1].rstrip()
return ""
def _add(self, rule, line, col, source_line):
from dataclasses import replace as _replace
if rule.id in self.config.disable:
return
from .checker import _is_allowed_by_comment
if _is_allowed_by_comment(source_line, rule.id):
return
# Swap suggestion based on available packages
suggestion = rule.suggestion
variants = _SUGGESTIONS.get(rule.id)
if variants:
suggestion = variants.get(self._ctx, suggestion)
rule = _replace(rule, suggestion=suggestion)
sev = self.config.per_rule_severity.get(rule.id)
if sev:
rule = _replace(rule, severity=sev)
self.issues.append(
Issue(rule=rule, line=line, col=col, source_line=source_line)
)
[docs]
def visit_Call(self, node):
if self._active:
self._check_call(node)
self.generic_visit(node)
[docs]
def visit_Assign(self, node):
if self._active:
self._check_assign(node)
self.generic_visit(node)
[docs]
def _check_call(self, node):
"""Check Call nodes for FM001-FM006."""
if _is_exempt_call(node):
return
func = node.func
if not isinstance(func, ast.Attribute):
return
func_name = func.attr
line = self._get_source(node.lineno)
# FM002: tight_layout()
if func_name == "tight_layout":
self._add(rules.FM002, node.lineno, node.col_offset, line)
return
# FM005: subplots_adjust()
if func_name == "subplots_adjust":
self._add(rules.FM005, node.lineno, node.col_offset, line)
return
# FM006: savefig() + FM003: bbox_inches="tight"
if func_name == "savefig":
if _has_kwarg(node, "bbox_inches", "tight"):
self._add(rules.FM003, node.lineno, node.col_offset, line)
self._add(rules.FM006, node.lineno, node.col_offset, line)
return
# FM008: set_size_inches()
if func_name == "set_size_inches":
self._add(rules.FM008, node.lineno, node.col_offset, line)
return
# FM009: set_position()
if func_name == "set_position":
self._add(rules.FM009, node.lineno, node.col_offset, line)
return
# FM001: figsize= kwarg on figure()/subplots()
# FM004: constrained_layout=True kwarg
if func_name in ("figure", "subplots", "Figure"):
if _has_kwarg(node, "figsize"):
self._add(rules.FM001, node.lineno, node.col_offset, line)
if _has_kwarg(node, "constrained_layout", True):
self._add(rules.FM004, node.lineno, node.col_offset, line)
[docs]
def _check_assign(self, node):
"""Check Assign nodes for FM007 (rcParams modification)."""
for target in node.targets:
if not isinstance(target, ast.Subscript):
continue
val = target.value
if not isinstance(val, ast.Attribute) or val.attr != "rcParams":
continue
if isinstance(val.value, ast.Name) and val.value.id in (
"plt",
"mpl",
"matplotlib",
):
line = self._get_source(node.lineno)
self._add(rules.FM007, node.lineno, node.col_offset, line)
break