Source code for ClearMap.config.config_adjusters.policy

from __future__ import annotations

import warnings

"""
Adjusters are pure functions (view, sm) -> patch
that can inspect the current config view and sample manager
and return a patch to be merged into the working config.
They are defined in the ADJUSTERS DEFINITION section and registered
with the @adjuster decorator
"""
from copy import deepcopy
from typing import Any, Optional, Iterable, Dict, Sequence, List, Set, Tuple

from ClearMap.Utils.utilities import deep_merge, REPLACE, DELETE, _REPLACE

from ClearMap.config.compound_keys import CompoundKey

from ClearMap.config.config_handler import ALTERNATIVES_REG
from ClearMap.config.config_adjusters.type_hints import (ConfigView, ConfigPatch, SampleManagerProtocol, KeysPath,
                                                         AdjustmentContext)
from ClearMap.config.config_adjusters.dict_ops import (merge_section_missing_only, normalize_json_compat,
                                                       normalise_dict, get_nested, deep_merge_missing, is_under)
from ClearMap.config.config_adjusters.patch_ops import (_strip_template_like_keys_from_container,
                                                        _stable_order_keys,
                                                        _restrict_entry_to_template_keys,
                                                        _strip_template_like_keys_from_entry, merge_patches,
                                                        _raise_if_rename_map_collides, set_patch_at_path)
from ClearMap.config.config_adjusters.templates_resolver import get_current_resolver
from ClearMap.config.config_adjusters.adjusters_api import Phase, Step, patch_adjuster, AdjusterKind
from ClearMap.config.config_adjusters.policy_specs import (InstanceContainerSpec,
                                                           ContainerSpecRegistry, ContainerRole, ReconcileMode,
                                                           TemplateKind, ReconcileKind, Membership, ReconcileStepSpec,
                                                           SmellScanPolicy)

# ############ ADJUSTERS DEFINITION ############
INSTANCE_SPECS: tuple[InstanceContainerSpec, ...] = (
    InstanceContainerSpec(
        section='sample',
        container_path='.channels',
        defaults_templates_path='.templates',
        template_kind=TemplateKind.CHANNELS,
        membership=Membership(source='sample'),
        role=ContainerRole.SOURCE_OF_TRUTH,
        reconcile=ReconcileStepSpec(
            applies=True,
            mode=ReconcileMode.FILL_MISSING_ONLY,
            kind=ReconcileKind.GENERIC,
            restrict_to_template_keys=False,
        ),
    ),
    InstanceContainerSpec(
        section='stitching',
        container_path='.channels',
        defaults_templates_path='.templates',
        template_kind=TemplateKind.CHANNELS,
        membership=Membership(source='pipeline', pipeline='stitching'),
        reconcile=ReconcileStepSpec(
            applies=True,
            kind=ReconcileKind.SPECIAL,           # bespoke writer owns it
            restrict_to_template_keys=True,
        ),
    ),
    InstanceContainerSpec(
        section='registration',
        container_path='.channels',
        defaults_templates_path='.templates',
        template_kind=TemplateKind.CHANNELS,
        membership=Membership(source='sample'),
    ),
    InstanceContainerSpec(
        section='cell_map',
        container_path='.channels',
        defaults_templates_path='.templates',
        template_kind=TemplateKind.CHANNELS,
        membership=Membership(source='pipeline', pipeline='CellMap'),
    ),
    InstanceContainerSpec(
        section='cell_map',
        container_path='.performance.channels',
        defaults_templates_path='.performance.templates',
        template_kind=TemplateKind.PERF,  # you already have PERF kind; use it
        membership=Membership(source='pipeline', pipeline='CellMap'),
        # likely restrict to template keys; depends if you want to allow extra perf knobs
        reconcile=ReconcileStepSpec(
            applies=True,
            kind=ReconcileKind.GENERIC,
            restrict_to_template_keys=True,
        ),
    ),
    InstanceContainerSpec(
        section='tract_map',
        container_path='.channels',
        defaults_templates_path='.templates',
        template_kind=TemplateKind.CHANNELS,
        membership=Membership(source='pipeline', pipeline='TractMap'),
    ),
    InstanceContainerSpec(
        section='colocalization',
        container_path='.channels',
        defaults_templates_path='.templates',
        template_kind=TemplateKind.PAIRS,
        membership=Membership(source='sample', instance_kind='pairs', oriented=True),
        compound=True,
        compound_oriented=True,
        reconcile=ReconcileStepSpec(
            applies=True,
            kind=ReconcileKind.GENERIC,
            restrict_to_template_keys=True,
        ),
    ),
    InstanceContainerSpec(
        section='vasculature',
        container_path='.binarization.single_channels',
        defaults_templates_path='.binarization.templates',
        template_kind=TemplateKind.CHANNELS,
        membership=Membership(source='pipeline', pipeline='TubeMap'),
    ),
    InstanceContainerSpec(
        section='vasculature',
        container_path='.performance.binarization.single_channels',
        defaults_templates_path='.performance.binarization.templates',
        template_kind=TemplateKind.PERF,
        membership=Membership(source='pipeline', pipeline='TubeMap'),
    ),
)

INSTANCE_SPECS_REGISTRY = ContainerSpecRegistry(INSTANCE_SPECS)

RENAME_KEYS = tuple(spec.owns_prefix() for spec in INSTANCE_SPECS_REGISTRY.specs)
# Keys whose values are channel name references
BACKREF_FIELDS = {  # REFACTOR: rename to more explicit like CHANNEL_REF_FIELDS
    'registration': ('align_with', 'moving_channel'),
    'stitching': ('layout_channel',),
}
GENERIC_INSTANCE_KEYS = tuple(s.owns_prefix() for s in INSTANCE_SPECS_REGISTRY.generic_instance_specs())
static_candidates = ALTERNATIVES_REG.canonical_pipeline_config_names + ALTERNATIVES_REG.canonical_global_config_names
STATIC_SECTIONS = tuple(s for s in static_candidates if s != 'sample')
STATIC_WATCHED_KEYS = (('sample', 'channels'),) + tuple((s,) for s in STATIC_SECTIONS)
STATIC_OWNED_KEYS = tuple((s,) for s in STATIC_SECTIONS)


# ############ Global rename triggered by SampleManager ############
[docs] @patch_adjuster(step=Step.APPLY_RENAMES, phase=Phase.PRE_VALIDATE, requires_sample_manager=True, watched_keys=None, kind=AdjusterKind.INSTANCE_OWNER, owned_keys=RENAME_KEYS) def apply_channel_renames(view: ConfigView, ctx: AdjustmentContext) -> ConfigPatch: """ Rename section channels Parameters ---------- view: ConfigView Current config view sm: SampleManagerProtocol Sample manager with renamed_channels mapping Returns ------- ConfigPatch Patch with renamed channels applied """ rename_map = ctx.sample_manager.renamed_channels if not rename_map: return {} _raise_if_rename_map_collides(rename_map) patch: ConfigPatch = {} # 1. Structural: rename keys in instance containers for spec in INSTANCE_SPECS_REGISTRY.specs: if not spec.rename.applies: continue abs_path = spec.abs_container_path() current = normalise_dict(get_nested(view, abs_path)) if not current: continue if spec.compound and spec.rename.migrate_payload: updated, changed = CompoundKey.rename_container_keys(current, rename_map, oriented=spec.compound_oriented) else: changed = False updated = deepcopy(current) for old, new in rename_map.items(): if old in updated and new not in updated: updated[new] = updated.pop(old) changed = True if changed: set_patch_at_path(patch, abs_path, REPLACE(updated)) # 2. Values: fix back-references to old channel names _fix_channel_backreferences(view, rename_map, patch) return patch
def _fix_channel_backreferences(view: ConfigView, rename_map: dict[str, str], patch: ConfigPatch) -> None: """ Update config values that hold channel names as references (e.g. align_with='Channel_0' → 'alexa' after rename). Mutates *patch* in place. """ for section, fields in BACKREF_FIELDS.items(): # Read from patch if the container was already REPLACE'd, # otherwise from view channels = _get_channels_from_patch_or_view(patch, view, section) if not channels: continue for ch_name, ch_cfg in channels.items(): if not isinstance(ch_cfg, dict): continue for field in fields: val = ch_cfg.get(field) if val in rename_map: ch_cfg[field] = rename_map[val] def _get_channels_from_patch_or_view(patch: ConfigPatch, view: ConfigView, section: str) -> dict[str, Any]: """ Get the channels dict for a section, preferring the patch (which may contain a REPLACE'd version from the structural rename step above). """ from_patch = (patch.get(section) or {}).get('channels') if isinstance(from_patch, _REPLACE): return from_patch.payload if isinstance(from_patch, dict): return from_patch return (view.get(section) or {}).get('channels') or {} # WARNING: this must not touch instance containers declared by InstanceContainerSpec
[docs] @patch_adjuster(step=Step.ENSURE_STATIC_BLOCKS, phase=Phase.PRE_VALIDATE, requires_sample_manager=True, watched_keys=STATIC_WATCHED_KEYS, owned_keys=STATIC_OWNED_KEYS, kind=AdjusterKind.STATIC_FILLER, order=50) def ensure_required_static_blocks(view: ConfigView, ctx: AdjustmentContext) -> ConfigPatch: """ Missing-only fill of required static blocks from defaults. .. warning:: Must not touch instance containers declared by InstanceContainerSpec, because those are owned by reconcile/materialize adjusters and may be REPLACE'd. """ resolver = get_current_resolver() patch: ConfigPatch = {} # FIXME:: decide for batch sections allowed_sections = ctx.sample_manager.compute_required_sections() | set(ALTERNATIVES_REG.canonical_global_config_names) for sec in STATIC_SECTIONS: if sec not in allowed_sections: continue defaults = resolver.section_missing_only_defaults(sec) if not defaults: continue current = deepcopy(view.get(sec) or {}) skip_paths = INSTANCE_SPECS_REGISTRY.skip_paths_for_missing_only_merge(sec) changed = merge_section_missing_only(current, defaults, skip_paths=skip_paths) if changed and current: patch[sec] = current return patch
def _stitching_ensure_layout_and_root(current: dict[str, Any], stitchable: list[str], ordered: list[str]) -> tuple[set[str], str, dict[str, Any], bool]: if not stitchable: return set(), '', current, False def _is_layout(channel: str, cfg: dict[str, Any]) -> bool: return cfg.get('layout_channel', channel) == channel changed = False layout_set = {ch for ch, cfg in current.items() if isinstance(cfg, dict) and _is_layout(ch, cfg)} if not layout_set: root = stitchable[0] cfg = current.get(root, {}) current[root] = {**cfg, 'layout_channel': root} layout_set.add(root) changed = True index = {ch: i for i, ch in enumerate(ordered)} root_layout = min(layout_set, key=lambda ch: index.get(ch, 10**9)) return layout_set, root_layout, current, changed def _stitching_is_layout_and_repair(*, ch: str, cfg: dict[str, Any], layout_channels_set: set[str], root_layout: str) -> tuple[str, dict[str, Any], bool]: changed = False target = cfg.get('layout_channel', ch) if target == ch: # layout role; ensure explicit layout_channel if 'layout_channel' not in cfg: cfg = {**cfg, 'layout_channel': ch} changed = True return ch, cfg, changed # derived role; repair invalid reference if target not in layout_channels_set: cfg = {**cfg, 'layout_channel': root_layout} changed = True target = root_layout return target, cfg, changed def _build_stitching_entry(*, ch: str, cfg: dict[str, Any] | None, layout_channels_set: set[str], root_layout: str) -> tuple[dict[str, Any], bool]: """ Returns (entry, changed_vs_cfg). - Determines role from cfg.get('layout_channel', ch) - Repairs derived references to point to an existing layout (root_layout fallback) - Canonicalizes layout channels to explicitly carry layout_channel: ch (if absent) - Applies template + deep_merge(tpl_base, cfg) - Pops irrelevant keys (opposite-role keys, template-like keys) """ resolver = get_current_resolver() cfg = normalise_dict(cfg) ref, cfg2, repaired = _stitching_is_layout_and_repair(ch=ch, cfg=cfg, layout_channels_set=layout_channels_set, root_layout=root_layout) tpl_base = resolver.stitching_entry_template(ch, root_layout=ref) # role decided by ref==ch or not merged = deep_merge(deepcopy(tpl_base), deepcopy(cfg2)) # keep-only template schema: this replaces “strip opposite-role keys” merged = _restrict_entry_to_template_keys(merged, tpl_base) _strip_template_like_keys_from_entry(merged) return merged, repaired def _stitching_prune_to_membership(current: dict[str, Any], keep: list[str]) -> tuple[dict[str, Any], bool]: pruned = {k: current[k] for k in keep if k in current} return pruned, pruned != current
[docs] def reconcile_stitching_channels_step(*, stitching_section: dict[str, Any], sm: SampleManagerProtocol) -> tuple[dict[str, Any], bool]: stitchable = sm.stitchable_channels existing_channels_cfg = stitching_section.get('channels') current = normalise_dict(existing_channels_cfg) ordered = _stable_order_keys(current_keys=current.keys(), target_keys=stitchable) current, changed_prune = _stitching_prune_to_membership(current, ordered) layout_set, root_layout, current, changed_root = _stitching_ensure_layout_and_root(current, stitchable, ordered) changed = changed_prune or changed_root updated = {} for ch in stitchable: cfg = current.get(ch) if not isinstance(cfg, dict): cfg = {} changed = True entry, entry_changed = _build_stitching_entry(ch=ch, cfg=cfg, layout_channels_set=layout_set, root_layout=root_layout) updated[ch] = entry if current.get(ch) != entry: changed = True changed = changed or entry_changed if not changed and updated == current: return stitching_section, False out = deepcopy(stitching_section) out['channels'] = REPLACE(updated) return out, True
[docs] @patch_adjuster(step=Step.CREATE_CHANNELS_RECONCILE, phase=Phase.PRE_VALIDATE, requires_sample_manager=True, watched_keys=('sample.channels', 'stitching.channels'), owned_keys=('stitching.channels',), kind=AdjusterKind.INSTANCE_OWNER, order=50) def reconcile_stitching_channels(view: ConfigView, ctx: AdjustmentContext) -> ConfigPatch: """ Single-writer for stitching.channels. Responsibilities (in one place): - prune to sm.stitchable_channels - ensure at least one layout channel (root) - repair derived layout_channel references (fallback to root layout) - materialize missing channels using defaults stitching.templates.{layout_template,derived_template} - apply role templates (layout vs derived) while preserving user overrides - strip opposite-role keys and template-leak keys Emits: {'stitching': {'channels': REPLACE(updated)}} when changed, else {}. """ stitching_section = deepcopy(view.get('stitching') or {}) stitching_section, changed = reconcile_stitching_channels_step(stitching_section=stitching_section, sm=ctx.sample_manager) return {'stitching': stitching_section} if changed else {}
def _ctx_for_key(*, spec: InstanceContainerSpec, key: str, view: ConfigView, sm: SampleManagerProtocol) -> dict[str, Any]: return spec.ctx_builder(key, view, sm) if spec.ctx_builder else {} def _bound_template_for_key(*, spec: InstanceContainerSpec, key: str, view: ConfigView, sm: SampleManagerProtocol, resolver) -> dict[str, Any]: ctx = _ctx_for_key(spec=spec, key=key, view=view, sm=sm) return resolver.entry_template(section=spec.section, kind=spec.template_kind, key=key, **ctx) def _emit_container_patch(*, container_path: tuple[str, ...], new_map: dict[str, Any], replace: bool) -> ConfigPatch: patch: ConfigPatch = {} set_patch_at_path(patch, container_path, REPLACE(new_map) if replace else new_map) return patch def _compound_preprocess(*, spec: InstanceContainerSpec, cur_map: dict[str, Any], sm: SampleManagerProtocol) -> tuple[dict[str, Any], bool]: """ Canonicalize compound keys (if enabled) and optionally prune invalid atoms. Returns (updated_cur_map, moved_flag). """ if not spec.compound: return cur_map, False cur_map, moved = CompoundKey.normalize_container_keys(cur_map, oriented=spec.compound_oriented) if spec.compound_prune_invalid_atoms: pruned = CompoundKey.prune_container_invalid_atoms(cur_map, allowed_atoms=set(sm.channels), oriented=spec.compound_oriented) moved = moved or (pruned != cur_map) cur_map = pruned return cur_map, moved def _reconcile_missing_only(*, spec: InstanceContainerSpec, view: ConfigView, sm: SampleManagerProtocol, resolver, cur_map: dict[str, Any], moved: bool) -> tuple[dict[str, Any], bool]: """ Missing-only reconcile: - does NOT create/prune membership keys - fills missing fields within existing entries - does NOT REPLACE the container """ changed = moved out = {} for k, v in (cur_map or {}).items(): entry = v if isinstance(v, dict) else {} if entry is not v: changed = True before = deepcopy(entry) tpl = _bound_template_for_key(spec=spec, key=k, view=view, sm=sm, resolver=resolver) deep_merge_missing(entry, tpl) entry = normalize_json_compat(entry) out[k] = entry changed = changed or (entry != before) return out, changed def _reconcile_replace_container(*, spec: InstanceContainerSpec, view: ConfigView, sm: SampleManagerProtocol, resolver, cur_map: dict[str, Any], moved: bool) -> tuple[dict[str, Any], bool]: """ REPLACE reconcile: - establishes membership keys (resolve_keys) - materializes full entries from templates - optionally migrates payload for compound rename - optionally restricts to template schema - emits REPLACE(container) ..note:: An existing entry that is an empty dict is treated as absent (falls through to full materialization) — an empty dict signals "channel known but not yet configured", which is the same as a new channel. """ changed = moved raw_keys = spec.resolve_keys(sm=sm, resolver=resolver) if spec.reconcile.preserve_existing_order: keys = _stable_order_keys(current_keys=cur_map.keys(), target_keys=raw_keys) else: keys = raw_keys new_map = {} for k in keys: base = _bound_template_for_key(spec=spec, key=k, view=view, sm=sm, resolver=resolver) existing = cur_map.get(k) if existing and spec.reconcile.policy.preserve_existing: # Fill missing entries from defaults if entry exists # Fill gaps only — semantically identical to _reconcile_missing_only per-entry # but inside REPLACE_CONTAINER so membership (add/remove) still applies. entry = deepcopy(existing) deep_merge_missing(entry, base) # ← same call as _reconcile_missing_only entry = normalize_json_compat(entry) if entry != existing: changed = True else: # New or empty entry — full template materialization, with optional compound payload migration carried_cfg = None carried = False if spec.compound and spec.rename.migrate_payload: carried_cfg, carried = CompoundKey.migrate_container_payload( container=cur_map, new_key=k, rename_map=sm.renamed_channels, oriented=spec.compound_oriented) entry = deep_merge(deepcopy(base), deepcopy(carried_cfg)) if carried else deepcopy(base) # needs_materialize = (not spec.reconcile.policy.preserve_existing) or (k not in cur_map) or (not existing) # changed = changed or (carried or needs_materialize) changed = True # new entry always counts as changed if spec.reconcile.restrict_to_template_keys and isinstance(entry, dict) and isinstance(base, dict): entry = _restrict_entry_to_template_keys(entry, base) _strip_template_like_keys_from_entry(entry) new_map[k] = entry if not spec.reconcile.policy.prune_removed: for k, v in cur_map.items(): if k not in new_map: new_map[k] = v if not changed and cur_map == new_map: return new_map, False return new_map, True
[docs] def reconcile_instance_container(*, spec: InstanceContainerSpec, view: ConfigView, sm: SampleManagerProtocol, resolver) -> Optional[ConfigPatch]: if not spec.reconcile.applies: return None container_path = spec.abs_container_path() cur_map = spec.container_map(view) cur_map, moved = _compound_preprocess(spec=spec, cur_map=cur_map, sm=sm) # ========================================================= # Missing-only reconcile mode # ========================================================= if spec.reconcile.mode == ReconcileMode.FILL_MISSING_ONLY: # only fill per-entry defaults. (no resolve_keys(), no membership prunning, no channel creation new_map, changed = _reconcile_missing_only(spec=spec, view=view, sm=sm, resolver=resolver, cur_map=cur_map, moved=moved) if not changed: return None return _emit_container_patch(container_path=container_path, new_map=new_map, replace=False) # ========================================================= # REPLACE-container mode # ========================================================= new_map, changed = _reconcile_replace_container(spec=spec, view=view, sm=sm, resolver=resolver, cur_map=cur_map, moved=moved) if not changed: return None # Defensive: strip template-like keys from final container _strip_template_like_keys_from_container(new_map) return _emit_container_patch(container_path=container_path, new_map=new_map, replace=True)
# WARNING: all but stitching (SPECIAL writer)
[docs] @patch_adjuster(step=Step.CREATE_CHANNELS_RECONCILE, phase=Phase.PRE_VALIDATE, requires_sample_manager=True, watched_keys=('sample.channels',), kind=AdjusterKind.INSTANCE_OWNER, owned_keys=GENERIC_INSTANCE_KEYS) def reconcile_generic_instance_containers(view: ConfigView, ctx: AdjustmentContext) -> ConfigPatch: resolver = get_current_resolver() patch: ConfigPatch = {} for spec in INSTANCE_SPECS_REGISTRY.ordered_for_reconcile(kind=ReconcileKind.GENERIC): p = reconcile_instance_container(spec=spec, view=view, sm=ctx.sample_manager, resolver=resolver) if p: merge_patches(patch, p) return patch
########################## TEMPLATES CLEANUP ############## TEMPLATE_ROOT_KEYS = tuple(spec.abs_templates_path() for spec in INSTANCE_SPECS_REGISTRY.specs if spec.abs_templates_path())
[docs] @patch_adjuster( step=Step.ADJUST, # keep it simple for the release phase=Phase.PRE_VALIDATE, requires_sample_manager=False, watched_keys=None, # must run regardless of changed_keys owned_keys=TEMPLATE_ROOT_KEYS, # only allowed to touch canonical template roots kind=AdjusterKind.OTHER, order=10_000, # run late (after reconcile/materialize) ) def sanitize_runtime_template_roots(view, ctx: AdjustmentContext) -> dict: """ A1 sanitizer: delete template reservoirs located at canonical defaults_templates_path derived from InstanceContainerSpec. Scope: - only affects sections that exist in the current view - runner will additionally scope by active_sections based on owned_keys sections """ patch = {} deleted: set[tuple[str, ...]] = set() for spec in INSTANCE_SPECS_REGISTRY.specs: if spec.section not in view: continue tpl_path = spec.abs_templates_path() if not tpl_path: continue if get_nested(view, tpl_path) is None: # Nothing in instance cfg -> move on continue # Delete the entire templates reservoir subtree set_patch_at_path(patch, tpl_path, DELETE) deleted.add(tuple(tpl_path)) # Warn once per path for p in sorted(deleted): warnings.warn(f'[policy-violation][runtime-templates] deleted {".".join(p)}', RuntimeWarning, stacklevel=2) return patch
############## TEMPLATES SCORIES SCANNER ############## def _any_under(path: KeysPath, roots: Sequence[KeysPath]) -> bool: return any(is_under(path, r) for r in roots) def _iter_dict_paths(obj: Any, prefix: KeysPath = ()) -> Iterable[Tuple[KeysPath, Any]]: """Yield (path, node) for all dict nodes in a nested structure.""" if not isinstance(obj, dict): return yield prefix, obj for k, v in obj.items(): if isinstance(v, dict): yield from _iter_dict_paths(v, prefix + (str(k),))
[docs] def scan_config_smells(*, view: Dict[str, Any], sm: SampleManagerProtocol, instance_specs_reg: ContainerSpecRegistry, policy: SmellScanPolicy = SmellScanPolicy()) -> List[str]: """ Returns a list of warning strings (does not emit warnings by itself). Callers may choose to warnings.warn(...) or log. Focus: - template-ish keys outside canonical templates roots (strong) - channel-ish keys under reserved namespaces like performance (light) """ warnings_out: List[str] = [] # Canonical template roots (section-rooted absolute paths) templates_roots: List[KeysPath] = [] # Canonical instance containers container_roots: List[KeysPath] = [] # For reserved namespace checks: collect parents that have a canonical ".<reserved>.channels" container reserved_parents_with_channels: Set[KeysPath] = set() for spec in instance_specs_reg: if spec.section not in view: continue # spec.abs_templates_path() -> Optional[KeysPath] tpath = spec.abs_templates_path() if tpath: templates_roots.append(tuple(tpath)) cpath = spec.abs_container_path() if cpath: container_roots.append(tuple(cpath)) # If this spec defines ".../<reserved>/channels", remember ".../<reserved>" as a reserved parent. # Example: ('cell_map','performance','channels') -> parent ('cell_map','performance') if len(cpath) >= 2 and cpath[-1] == "channels" and cpath[-2] in policy.reserved_namespace_keys: reserved_parents_with_channels.add(tuple(cpath[:-1])) # ---------------------------- # Strong: template-ish keys outside template roots # ---------------------------- if policy.strong_template_smells: for dict_path, node in _iter_dict_paths(view): # If we are already inside an allowed templates root, do not warn about template-ish keys here. # (Templates are expected under templates roots.) in_templates_root = _any_under(dict_path, templates_roots) if not isinstance(node, dict): continue for k in node.keys(): ks = str(k) key_path = dict_path + (ks,) if ks == 'templates': # FIXME: forbidden warnings_out.append(f'[config-smell][templates-key-in-runtime] ' f'Found forbidden key at {".".join(key_path)}') continue is_templateish = ( ks in policy.templateish_exact or ks.endswith(policy.templateish_suffix) ) if is_templateish: warnings_out.append( f'[config-smell][template-key-outside-templates-root] ' f'Found template-ish key at {".".join(key_path)} (expected under one of: ' f'{", ".join(".".join(r) for r in templates_roots) or "<none>"})' ) # ---------------------------- # Light: channel-ish keys under reserved namespaces (e.g. performance.<channel>) # ---------------------------- if policy.light_channelish_smells and reserved_parents_with_channels: channels_set = set(getattr(sm, "channels", []) or []) for parent in reserved_parents_with_channels: if not parent or parent[0] not in view: continue # parent is like ('cell_map','performance') # locate node node: Any = view for p in parent: if not isinstance(node, dict): node = None break node = node.get(p) if not isinstance(node, dict): continue # if canonical children exist, channel-like siblings are suspicious for k in list(node.keys()): ks = str(k) if ks in ("channels", "templates"): continue if ks in channels_set: warnings_out.append( f"[config-smell][channel-key-in-reserved-namespace] " f"Found channel-like key at {'.'.join(parent + (ks,))}; " f"canonical container exists at {'.'.join(parent + ('channels',))}" ) return warnings_out
[docs] def warn_config_smells(*, view: Dict[str, Any], sm: SampleManagerProtocol, instance_specs_reg: ContainerSpecRegistry, policy: SmellScanPolicy = SmellScanPolicy()) -> None: for msg in scan_config_smells(view=view, sm=sm, instance_specs_reg=instance_specs_reg, policy=policy): warnings.warn(msg, RuntimeWarning, stacklevel=3)
# # @adjuster(step=Step.CREATE_CHANNELS_RECONCILE, # phase=Phase.PRE_VALIDATE, # pipelines=('registration',), # keys=('sample.channels', 'registration.channels')) # def reconcile_registration_channels(view: ConfigView, sm: SampleManagerProtocol) -> ConfigPatch: # """ # Build registration.channels for all sample channels using defaults/registration.yml:templates. # - Channels with data_type == 'autofluorescence' use 'autofluorescence' template. # - Others use 'regular' template with ${reference} = alignment reference channel. # Also expands ${channel}/${reference} placeholders. # """ # return _reconcile_simple_section_channels('registration', view, sm) # # # # FIXME: add collocation_compatible=True if n> =2 # @adjuster(step=Step.CREATE_CHANNELS_RECONCILE, # phase=Phase.PRE_VALIDATE, # pipelines=('cell_map',), # keys=('sample.channels','cell_map.channels')) # def reconcile_cell_map_channels(view: ConfigView, sm: SampleManagerProtocol) -> ConfigPatch: # return _reconcile_simple_section_channels('cell_map', view, sm) # # # @adjuster(step=Step.CREATE_CHANNELS_RECONCILE, # phase=Phase.PRE_VALIDATE, # pipelines=('tract_map',), # keys=('sample.channels','tract_map.channels')) # def reconcile_tract_map_channels(view: ConfigView, sm: SampleManagerProtocol) -> ConfigPatch: # """ # Keep tract_map.channels aligned with sample channels using defaults/tract_map.yml:template.channel # (add missing, keep overrides, drop removed). # """ # return _reconcile_simple_section_channels('tract_map', view, sm) # @adjuster(step=Step.CREATE_CHANNELS_RECONCILE, # phase=Phase.PRE_VALIDATE, # pipelines=('colocalization',), # keys=('sample.channels', 'colocalization.channels')) # def reconcile_colocalization_channels(view: ConfigView, sm: SampleManagerProtocol) -> ConfigPatch: # """ # Pairs = all 2-combinations of sm.channels_to_detect (when sm.is_colocalization_compatible). # Canonical keys 'A-B'. Preserves overrides, migrates on rename, prunes removed. # """ # resolver = get_current_resolver() # # target_keys = resolver.colocalization_target_keys() # if not target_keys: # col = deepcopy(view.get('colocalization') or {}) # if not (col.get('channels') or {}): # return {} # col['channels'] = {} # return {'colocalization': col} # # def build_entry(k: str) -> Dict[str, Any]: # return resolver.colocalization_entry_template(k) # # return _reconcile_section_channels( section_name='colocalization', view=view, target_channels=target_keys, # build_entry=build_entry, preserve_existing=True, compound=True, # rename_map=sm.renamed_channels, sample_set=set(sm.channels)) # # ################################ GLOBAL SECTIONS ############# # TODO: ensure we can remove those # @adjuster(step=Step.CREATE_PIPELINE_SECTIONS, # phase=Phase.PRE_VALIDATE, # pipelines=('machine',), # keys=('machine',), # order=10) # def populate_machine_defaults(view: ConfigView, sm: SampleManagerProtocol) -> ConfigPatch: # defaults = DEFAULTS_PROVIDER.get('machine') # section = deepcopy(view.get('machine') or {}) # before = deepcopy(section) # # Only fill missing keys from defaults # _deep_merge_missing(section, defaults) # return {'machine': section} if section != before else {} # # # @adjuster(step=Step.CREATE_PIPELINE_SECTIONS, # phase=Phase.PRE_VALIDATE, # pipelines=('display',), # keys=('display',), # order=10) # def populate_display_defaults(view: ConfigView, sm: SampleManagerProtocol) -> ConfigPatch: # defaults = DEFAULTS_PROVIDER.get('display') # section = deepcopy(view.get('display') or {}) # before = deepcopy(section) # _deep_merge_missing(section, defaults) # return {'display': section} if section != before else {}