Source code for ClearMap.gui.pretty_imports

import ast
import sys
import time
import importlib
from dataclasses import dataclass
from typing import List, Optional, Dict, Any, Callable
from pathlib import Path

import yaml

from ClearMap.config.early_boot import MachineConfig

CLEARMAP_PATH = Path(__file__).parent.parent


CLEARMAP_PREFIX = 'ClearMap.'
EPSILON_DT = 0.0005  # minimum time to avoid zero-division in progress calculations

OnMsg = Optional[Callable[[str], None]]
OnProg = Optional[Callable[[int], None]]

START_MARKER = '### SLOW IMPORTS ###'
END_MARKER   = '### END SLOW IMPORTS ###'


[docs] @dataclass(frozen=True) class ImportTask: """ A single staged-import task. - module-only: ImportTask("pyqtgraph", as_name="pg") - from-imports: ImportTask("pkg.mod", symbols=("Foo","Bar")) """ module: str attrs: tuple[str, ...] = () # from x import (a, b, c) message: Optional[str] = None weight: Optional[float] = None # optional manual weight override, else learned profile as_name: Optional[str] = None # optional "import x as y" # Alternate constructors
[docs] @classmethod def module_only(cls, module: str, *, as_name: Optional[str] = None, message: Optional[str] = None, weight: Optional[float] = None) -> "ImportTask": return cls(module=module, attrs=(), as_name=as_name, message=message, weight=weight)
[docs] @classmethod def from_imports(cls, module: str, *attrs: str, message: Optional[str] = None, weight: Optional[float] = None) -> "ImportTask": return cls(module=module, attrs=tuple(attrs), as_name=None, message=message, weight=weight)
# derived properties @property def has_attrs(self) -> bool: return len(self.attrs) > 0 @property def export_name(self) -> str: """Name under which the *module* will be exported (ignored for from-imports).""" return self.as_name or self.module.rsplit(".", 1)[-1] @property def display_message(self) -> str: return self.message or f"Importing {self.module}…" @property def key(self) -> str: """Key for profiling/weighting (group by module).""" return self.module def __str__(self) -> str: return self.display_message
def _module_to_path(module: str) -> Path | None: """ Convert 'ClearMap.foo.bar' → ClearMap/foo/bar.py """ rel = module.split('.')[1:] # drop 'ClearMap' path = CLEARMAP_PATH.joinpath(*rel).with_suffix('.py') if path.exists(): return path init_py = CLEARMAP_PATH.joinpath(*rel, '__init__.py') if init_py.exists(): return init_py return None def _parse_regular_import(line: str) -> ImportTask | None: mod = line[len('import '):].strip() if mod.startswith(CLEARMAP_PREFIX): return ImportTask.module_only(mod) return None def _parse_from_import(line: str) -> ImportTask | None: rest = line[len('from '):] try: module, modules_list = rest.split(' import ', 1) except ValueError: return None module = module.strip() if module.startswith(CLEARMAP_PREFIX): modules_list = modules_list.strip() # Remove optional parentheses around imported names: "from x import (a, b, c)" if modules_list.startswith('(') and modules_list.endswith(')'): modules_list = modules_list[1:-1].strip() names = tuple(n.strip() for n in modules_list.split(',') if n.strip()) if names: return ImportTask.from_imports(module, *names) return None def _read_clear_map_imports(py_file: Path) -> List[ImportTask]: """ Read a single .py file and extract column-0 ClearMap imports in order. """ tasks: List[ImportTask] = [] try: acc_line = '' for line in py_file.read_text(encoding='utf-8').splitlines(): line = line.rstrip() if line.startswith('import '): task = _parse_regular_import(line) elif line.startswith('from '): if '(' in line and ')' not in line: acc_line += line.strip() + ' ' # accumulate multi-line from-import continue # skip multi-line from-imports for simplicity else: task = _parse_from_import(line) elif acc_line: acc_line += line.strip() + ' ' if '(' in acc_line and ')' in acc_line: task = _parse_from_import(acc_line) acc_line = '' # reset accumulator after processing else: continue else: continue if task is not None: tasks.append(task) except Exception: pass # best-effort only return tasks class _ImportGraph: def __init__(self) -> None: self._deps_cache: Dict[str, List[str]] = {} @staticmethod def _resolve_relative(from_module: str, level: int, mod: str | None) -> str | None: parts = from_module.split('.') if level <= 0: return mod if level >= len(parts): return None base = parts[:-level] if mod: base += mod.split('.') return '.'.join(base) @staticmethod def _candidate_submodule(parent: str, name: str) -> str | None: cand = f'{parent}.{name}' return cand if _module_to_path(cand) is not None else None def deps(self, module: str) -> List[str]: if module in self._deps_cache: return self._deps_cache[module] out: List[str] = [] py_file = _module_to_path(module) if py_file is None: self._deps_cache[module] = out return out try: source = py_file.read_text(encoding='utf-8') tree = ast.parse(source) except Exception: self._deps_cache[module] = out return out for node in getattr(tree, 'body', ()): if getattr(node, 'col_offset', 0) != 0: continue if isinstance(node, ast.Import): for alias in node.names: name = alias.name if name.startswith(CLEARMAP_PREFIX): out.append(name) elif isinstance(node, ast.ImportFrom): level = int(getattr(node, 'level', 0) or 0) parent = self._resolve_relative(module, level, node.module) if not parent or not parent.startswith(CLEARMAP_PREFIX): continue out.append(parent) for a in node.names: sym = a.name if not sym or sym == '*': continue cand = self._candidate_submodule(parent, sym) if cand is not None: out.append(cand) seen: set[str] = set() dedup: List[str] = [] for d in out: if d not in seen: seen.add(d) dedup.append(d) self._deps_cache[module] = dedup return dedup
[docs] def discover_import_tasks(entry_file: Path) -> List[ImportTask]: """ Recursively discover *unprotected* ClearMap imports starting from entry_file. Rules: - Only column-0 imports - Only ClearMap.* - Order preserved by discovery - Recursive expansion via source files - sys.modules short-circuit """ entry_file = Path(entry_file).resolve() in_block = False tasks: List[ImportTask] = [] # Parse modules from main file acc_line = '' for line in entry_file.read_text(encoding='utf-8').splitlines(): line = line.rstrip() task = None if line.strip() == START_MARKER: in_block = True continue elif not in_block: # second to ensure we allow it to become True continue elif line.strip() == END_MARKER: break elif line.startswith('import '): task = _parse_regular_import(line) elif line.startswith('from '): if '(' in line and ')' not in line: acc_line += line.strip() + ' ' # accumulate multi-line from-import continue # skip multi-line from-imports for simplicity else: task = _parse_from_import(line) elif acc_line: acc_line += line.strip() + ' ' if '(' in acc_line and ')' in acc_line: task = _parse_from_import(acc_line) acc_line = '' # reset accumulator after processing else: continue if task is not None and task.module.startswith(CLEARMAP_PREFIX): tasks.append(task) graph = _ImportGraph() seed_modules: List[str] = [] seen_seed: set[str] = set() for t in tasks: if t.module not in seen_seed: seen_seed.add(t.module) seed_modules.append(t.module) visited: set[str] = set() visiting: set[str] = set() ordered: List[str] = [] def dfs(m: str) -> None: if m in visited: return if m in set(sys.modules): visited.add(m) return if m in visiting: return visiting.add(m) for d in graph.deps(m): if d.startswith(CLEARMAP_PREFIX): dfs(d) visiting.remove(m) visited.add(m) ordered.append(m) for m in seed_modules: dfs(m) import_tasks: List[ImportTask] = [] for m in ordered: if m not in set(sys.modules): import_tasks.append(ImportTask.module_only(m)) return import_tasks
def _profile_path() -> Path: major, minor = MachineConfig._version.split('.')[:2] return Path.home() / '.clearmap' / f'.import_profile_v{major}_{minor}.yml' def _load_weights(tasks: List[ImportTask]) -> List[float]: """ Read the import time weights from disk if available. If this is the first run and a profile is not available, assume even weights (1.0). Parameters ---------- tasks : List[ImportTask] List of import tasks to be performed. Returns ------- List[float] Weights to drive the progress bar. """ path = _profile_path() persisted: Dict[str, float] = {} if path.exists(): try: data = yaml.safe_load(path.read_text(encoding='utf-8')) or [] persisted = {d.get('module'): float(d.get('weight', 1.0)) for d in data if isinstance(d, dict)} except Exception: persisted = {} raw = [] for t in tasks: if t.weight is not None: raw.append(float(t.weight)) else: raw.append(float(persisted.get(t.module, 1.0))) total = sum(raw) or 1.0 return [x / total for x in raw] def _save_weights(tasks: List[ImportTask], seconds: List[float]) -> None: """ Save the import time weights to disk for next time. Best-effort only, ignore any errors. Parameters ---------- tasks: List[ImportTask] List of import tasks that were performed. seconds: List[float] List of elapsed times for each task. """ # Normalize to sum=1 and persist total = sum(seconds) or 1.0 data = [{'module': t.module, 'weight': f'{s / total:.4f}'} for t, s in zip(tasks, seconds)] try: weights_f_path = _profile_path() weights_f_path.parent.mkdir(parents=True, exist_ok=True) weights_f_path.write_text(yaml.safe_dump(data, sort_keys=False, indent=2), encoding='utf-8') except Exception: pass # not critical if we cannot write the profile
[docs] def run_staged_imports(tasks: List[ImportTask], *, on_message: OnMsg = None, on_progress: OnProg = None, target_namespace: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """ Imports modules in stages, with progress callbacks and persistent weights. See `ImportTask` for details on how to specify each import. Returns a dict {export_name: object}, where export_name is either: - task.as_name (for whole-module alias), else last segment of module name - or the alias/name for each entry in task.names (from-imports) Parameters ---------- tasks : List[ImportTask] List of import tasks to be performed. on_message : Optional[Callable[[str], None]], optional Callback for status messages, by default None. on_progress : Optional[Callable[[int], None]], optional Callback for progress updates (0-100), by default None. target_namespace : Optional[Dict[str, Any]], optional If provided, injects imported names into this dict (e.g. globals()), by default """ expected_weights = _load_weights(tasks) exports: Dict[str, Any] = {} observed_seconds: List[float] = [] total_time = 0.0 def _emit_msg(msg: str): if on_message: on_message(msg) def _emit_progress(percent: float): if on_progress: on_progress(int(max(0, min(100, round(percent))))) # clip percentage ([0..100]) for i, task in enumerate(tasks): _emit_msg(task.display_message) start_t = time.perf_counter() # Import module itself. mod = importlib.import_module(task.module) # Import attributes or alias module as needed. if task.has_attrs: for name in task.attrs: attr = getattr(mod, name) exports[name] = attr if target_namespace is not None: target_namespace[name] = attr else: name = task.export_name exports[name] = mod if target_namespace is not None: target_namespace[name] = mod # Measure actual import time for accurate display next time. dt = max(EPSILON_DT, time.perf_counter() - start_t) observed_seconds.append(dt) total_time += expected_weights[i] _emit_progress(total_time * 100.0) _save_weights(tasks, observed_seconds) _emit_progress(100.0) return exports