Source code for scitex_io._linter.rules

"""Public, importable view of scitex-io's raw-IO detection rules.

``_linter.plugin.get_plugin()["call_rules"]`` is AST-shaped: its keys are
the names as they appear in SOURCE TEXT (``"np"``, ``"pd"``, ``"sio"``,
``"plt"``, ``"Image"``, ...), which is exactly right for a static
text/AST matcher but wrong for a runtime consumer (e.g. a monkeypatch
guard installed at ``@stx.session.start()``) that needs the REAL,
importable module path regardless of what alias a given script's
``import ... as`` happened to pick.

``iter_io_bypass_targets()`` is that runtime-oriented view: canonical
``(module_path, attr, rule_id, severity)`` tuples, generated FROM
``call_rules`` (single source of truth, no second hand-maintained list to
drift out of sync) but deduplicated to one row per real module+attr.
Entries with no derivable module path (e.g. ``(None, "savefig")``,
matched on any receiver's ``.savefig()`` method, not a module-level
call) are skipped — there is nothing to ``importlib.import_module`` for
those.

This module intentionally does NOT decide policy (warn vs. raise, which
severities matter). Callers get the full ``(module_path, attr, rule_id,
severity)`` picture and decide for themselves — the rule DATA lives here
once; what to do with it is a runtime-guard concern (see the operator's
2026-07-05 raw-IO-provenance incident thread, scitex-session's runtime
guard).
"""

from __future__ import annotations

from typing import Iterator, NamedTuple

# Alias (as commonly written in source) -> real importable module path.
# "np"/"pd" are NOT themselves valid import paths (nobody has a module
# literally named "np") — they're bound names, so they need remapping
# same as "sio"/"plt"/"Image" (which have no canonical sibling entry in
# call_rules at all). Listing both alias and canonical explicitly here
# is what lets `iter_io_bypass_targets` collapse them to one row.
_ALIAS_TO_CANONICAL = {
    "np": "numpy",
    "pd": "pandas",
    "sio": "scipy.io",
    "plt": "matplotlib.pyplot",
    "Image": "PIL.Image",
    # cPickle: py2-only real module; in py3 it's always an alias for
    # `pickle` (or `_pickle`). Not a distinct real target — skip via
    # the canonical-name check in `iter_io_bypass_targets` (its rule_id
    # is identical to "pickle"'s, so dropping it loses no coverage).
}

# Aliases with no real-module meaning at all (skip outright).
_SKIP_MODULES = {"cPickle"}


class IOBypassTarget(NamedTuple):
    module_path: str
    attr: str
    rule_id: str
    severity: str


[docs] def iter_io_bypass_targets() -> Iterator[IOBypassTarget]: """Yield canonical (module_path, attr, rule_id, severity) targets. One row per distinct (real module path, attr) — aliases in ``call_rules`` that resolve to the same canonical module collapse to a single row. Skips rules with no module-level call shape (receiver- agnostic method rules like ``.savefig()``). """ from .plugin import get_plugin call_rules = get_plugin()["call_rules"] seen = set() for (module_name, attr), rule in call_rules.items(): if module_name is None or module_name in _SKIP_MODULES: continue canonical = _ALIAS_TO_CANONICAL.get(module_name, module_name) key = (canonical, attr) if key in seen: continue seen.add(key) yield IOBypassTarget(canonical, attr, rule.id, rule.severity)
__all__ = ["IOBypassTarget", "iter_io_bypass_targets"]