Source code for fetchez.recipe

#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
fetchez.recipe
~~~~~~~~~~~~~~
The Workflow Engine.
Loads a configuration (The Recipe) and executes it against the target region.

:copyright: (c) 2010-2026 Regents of the University of Colorado
:license: MIT, see LICENSE for more details.
"""

import os
import copy
import json
import yaml
import inspect
import logging

from .core import run_fetchez
from .spatial import yield_parsed_regions
from .registry import (
    ModuleRegistry,
    HookRegistry,
    SchemaRegistry,
    PresetRegistry,
    BundleRegistry,
    RecipeRegistry,
)
from .utils import TqdmLoggingHandler, colorize, CYAN
from . import __version__ as fetchez_version

logger = logging.getLogger(__name__)


# This is duplicated in fetchez.cli
# We should move this to utils
[docs] def setup_logging(verbose=False): log_level = logging.INFO if verbose else logging.WARNING logger = logging.getLogger() logger.setLevel(log_level) if logger.hasHandlers(): logger.handlers.clear() handler = TqdmLoggingHandler() formatter = logging.Formatter("[ %(levelname)s ] %(module)s: %(message)s") handler.setFormatter(formatter) logger.addHandler(handler)
def _parse_version(v_str): """Dependency-free semantic version parser. Converts '2.1.0-beta' into (2, 1, 0). """ parts = [] for p in v_str.split("."): num = "".join(filter(str.isdigit, p)) parts.append(int(num) if num else 0) return tuple(parts)
[docs] class Recipe: """The Workflow Orchestrator. Reads data ingestion and processing recipes from YAML/JSON files and executes them. Usage: # Load the Recipe recipe = Recipe.from_file("socal_project.yaml") # Run it. recipe.run() """
[docs] def __init__(self, config, base_dir=None): self.config = config self.base_dir = base_dir or os.getcwd() self.name = self.config.get("project", {}).get("name", "Unnamed_Recipe") setup_logging(True)
[docs] @classmethod def from_file(cls, config_source): """Factory method to load the Recipe. Accepts a filename (str) or a dictionary directly. """ if isinstance(config_source, dict): return cls(config_source) if not os.path.exists(config_source): raise FileNotFoundError(f"Recipe not found: {config_source}") base_dir = os.path.dirname(os.path.abspath(config_source)) ext = os.path.splitext(config_source)[1].lower() with open(config_source, "r") as f: if ext in [".yaml", ".yml"]: import yaml config = yaml.safe_load(f) else: config = json.load(f) return cls(config, base_dir=base_dir)
from_dict = from_file
[docs] def to_json(self, indent=2) -> str: """Serialize the recipe configuration to a JSON string.""" return json.dumps(self.config, indent=indent)
[docs] def to_cli(self, executable="fetchez run") -> str: """Translate the recipe configuration into a runnable CLI command string.""" cmd_parts = [executable] # Base Pipeline Arguments if "region" in self.config: cmd_parts.append(f"-R {self.config['region']}") if "region_srs" in self.config: cmd_parts.append(f"--region-srs {self.config['region_srs']}") if "execution" in self.config and "threads" in self.config["execution"]: cmd_parts.append(f"--threads {self.config['execution']['threads']}") # Global Hooks for hook in self.config.get("global_hooks", []): hook_name = hook.get("name") or hook.get("preset") args = hook.get("args", {}) if args: arg_str = ",".join( f"{k}={'/'.join(map(str, v)) if isinstance(v, list) else v}" for k, v in args.items() ) cmd_parts.append(f"--global-hook {hook_name}:{arg_str}") else: cmd_parts.append(f"--global-hook {hook_name}") # Modules & Bundles for mod in self.config.get("modules", []): if isinstance(mod, str): cmd_parts.append(mod) continue mod_name = mod.get("module") or mod.get("bundle") or mod.get("recipe") mod_args = mod.get("args", {}) # Start the module segment mod_cmd = [mod_name] # Append Module Arguments (e.g., --weight 3.0) for k, v in mod_args.items(): mod_cmd.append(f"--{k.replace('_', '-')} {v}") cmd_parts.append(" ".join(mod_cmd)) # Append specific Module Hooks for hook in mod.get("hooks", []): hook_name = hook.get("name") or hook.get("preset") args = hook.get("args", {}) if args: arg_str = ",".join( f"{k}={'/'.join(map(str, v)) if isinstance(v, list) else v}" for k, v in args.items() ) cmd_parts.append(f" --hook {hook_name}:{arg_str}") else: cmd_parts.append(f" --hook {hook_name}") return " \\\n ".join(cmd_parts)
def _check_integrity(self): """Ensures the fetchez version meets the recipe's minimum requirements.""" conf = self.config.get("config", {}) min_fz = conf.get("min_fetchez_version") if min_fz: current = _parse_version(fetchez_version) required = _parse_version(min_fz) if current < required: logger.error( f"Recipe requires fetchez v{min_fz}, but found v{fetchez_version}" ) raise RuntimeError("Fetchez version incompatibility.") def _resolve_path(self, path): """Resolves output paths relative to the recipe file.""" if not isinstance(path, str): return path if path.startswith(("http", "s3://", "gs://", "ftp://")): return path if os.path.isabs(path): return path return os.path.abspath(os.path.join(self.base_dir, path)) def _init_modules( self, module_defs, target_region=None, global_region_srs="EPSG:4326" ): """Takes a flat list of module dictionaries and instantiates the Python classes.""" modules_to_run = [] for mod_def in module_defs: mod_key = mod_def.get("module") mod_args = mod_def.get("args", {}) mod_region_srs = mod_def.get("region_srs", global_region_srs) mod_regions = [target_region] if target_region else [None] ModCls = ModuleRegistry.get_class(mod_key) if not ModCls: logger.error(f"Unknown module: {mod_key}") continue sig = inspect.signature(ModCls.__init__) valid_mod_args = { k: v for k, v in mod_args.items() if k in sig.parameters or "kwargs" in str(sig.parameters) } # Initialize the hooks attached to this specific module mod_hooks = self._init_hooks(mod_def.get("hooks", [])) for region in mod_regions: if region is not None and region.valid_p(): region.srs = mod_region_srs if "path" in mod_args: mod_args["path"] = self._resolve_path(mod_args["path"]) try: instance = ModCls( src_region=region, hook=mod_hooks, **valid_mod_args ) modules_to_run.append(instance) except Exception as e: logger.error(f"Failed to load {mod_key}: {e}") return modules_to_run def _init_hooks(self, hook_defs, mod=None): """Takes a flat list of expanded hook dictionaries and instantiates the Python classes.""" HookRegistry.load_all() active_hooks = [] for h in hook_defs: name = h.get("name") raw_kwargs = h.get("args", {}) kwargs = {} for k, v in raw_kwargs.items(): if k in [ "file", "output", "output_grid", "mask_fn", "dem", "barrier", "aux_path", "path", ]: kwargs[k] = self._resolve_path(v) else: kwargs[k] = v HookCls = HookRegistry.get_class(name) if HookCls: sig = inspect.signature(HookCls.__init__) valid_kwargs = { k: v for k, v in kwargs.items() if k in sig.parameters or "kwargs" in str(sig.parameters) } active_hooks.append(HookCls(**valid_kwargs)) else: logger.warning(f"Hook '{name}' missing.") return active_hooks def _inject_batch_context(self, config_block, batch_name, batch_region): """Recursively formats strings in the config to inject the batch name.""" if not batch_name: return config_block if isinstance(config_block, dict): return { k: self._inject_batch_context(v, batch_name, batch_region) for k, v in config_block.items() } elif isinstance(config_block, list): return [ self._inject_batch_context(v, batch_name, batch_region) for v in config_block ] elif isinstance(config_block, str): return config_block.replace("{batch_name}", str(batch_name)) return config_block def _get_module_signature(self, mod_dict): """Creates a unique signature for a module to handle deduplication. Ensures that 'tnm' (dataset 1) does not collide with 'tnm' (dataset 3). """ if isinstance(mod_dict, str): return mod_dict m_name = mod_dict.get("module") if not m_name: return str(mod_dict) args = mod_dict.get("args", {}) # Ids that make a dataset truly unique within a module ids = [] for key in ["datatype", "datasets", "formats", "layer", "product"]: if key in args: ids.append(f"{key}={args[key]}") if ids: return f"{m_name}::" + "::".join(sorted(ids)) return m_name def _expand_modules(self, raw_modules, parent_weight=1.0): """Recursively flattens bundles and deduplicates/merges modules.""" RecipeRegistry.load_all() expanded_dict = {} for mod_dict in raw_modules: if isinstance(mod_dict, str): expanded_dict[mod_dict] = mod_dict continue # Check if this item is a bundle or a nested recipe target = mod_dict.get("bundle") or mod_dict.get("recipe") if target: BundleRegistry.load_all() RecipeRegistry.load_all() user_args = mod_dict.get("args", {}) user_hooks = mod_dict.get("hooks", []) # Stack the weights recursively current_weight = float(user_args.get("weight", 1.0)) * parent_weight bundle_def = BundleRegistry.get_yaml(target) if not bundle_def: recipe_meta = RecipeRegistry.get_yaml(target) if recipe_meta: bundle_def = recipe_meta.get("config", {}) if not bundle_def: try: bundle_def = self.from_file(self._resolve_path(target)).config except Exception: logger.error( f"Bundle/Recipe '{target}' not found locally or in registry." ) continue child_modules = bundle_def.get("modules", []) # Recursively expand the children first child_expanded = self._expand_modules(child_modules, current_weight) # Process the returned children and inject them into our current pipeline for child_mod in child_expanded: if isinstance(child_mod, dict) and "hooks" in child_mod: child_mod["hooks"] = self._expand_hooks( child_mod.get("hooks", []), user_hooks ) sig = self._get_module_signature(child_mod) # If a bundle defines a module, add it or let it override an earlier one expanded_dict[sig] = child_mod elif "module" in mod_dict: sig = self._get_module_signature(mod_dict) # --- DEDUPLICATION & MERGE --- if sig in expanded_dict and isinstance(expanded_dict[sig], dict): existing = expanded_dict[sig] # Merge args: The newer declaration (Parent) overwrites the existing (Bundle) merged_args = existing.get("args", {}).copy() merged_args.update(mod_dict.get("args", {})) # Hooks: If the override explicitly defines hooks, replace them. # Otherwise, keep the original hooks from the bundle. merged_hooks = mod_dict.get("hooks", existing.get("hooks", [])) expanded_dict[sig] = { "module": mod_dict["module"], "args": merged_args, "hooks": merged_hooks, } logger.debug(f"Merged/Overridden module via deduplication: {sig}") else: expanded_dict[sig] = mod_dict else: logger.error(f"Invalid module definition: {mod_dict}") return list(expanded_dict.values()) def _expand_hooks(self, hook_defs, parent_hooks=None): """Recursively expands presets into a flat list of hook definition dictionaries.""" expanded_list = [] parent_hooks = parent_hooks or [] for h in hook_defs: name = h.get("name") is_preset = h.get("preset") hook_map = {} if parent_hooks: hook_map = { hook.get("name"): hook for hook in parent_hooks if "name" in hook } if name in hook_map: h_args = h.get("args", {}).copy() h_args.update(hook_map[name].get("args", {})) h["args"] = h_args if is_preset: user_args = h.get("args") PresetRegistry.load_all() preset_def = PresetRegistry.get_yaml(is_preset) if preset_def: preset_hooks = copy.deepcopy(preset_def.get("hooks", [])) if user_args: if parent_hooks: hook_map = { hook.get("name"): hook for hook in user_args if "name" in hook } for parent_hook in parent_hooks: name = parent_hook.get("name") if name in hook_map: # parent_hook.update(hook_map[name]) merged_args = parent_hook.get("args", {}).copy() merged_args.update(hook_map[name].get("args", {})) parent_hook["args"] = merged_args else: # parent_hooks.extend(user_args) parent_hooks = ( copy.deepcopy(parent_hooks) if parent_hooks else copy.deepcopy(user_args) ) # Recursively expand the child hooks, passing down the merged args expanded_child_hooks = self._expand_hooks( preset_hooks, parent_hooks=parent_hooks ) expanded_list.extend(expanded_child_hooks) else: logger.error(f"Preset '{is_preset}' not found in registry.") else: expanded_list.append(h) return expanded_list def _generate_receipt(self): import datetime name = self.name desc = self.config.get("project", {}).get( "description", "No description provided." ) region = self.config.get("region", "Global") region_srs = self.config.get("region_srs", "EPSG:4326") receipt_filename = f"{name.lower().replace(' ', '_')}_receipt.md" receipt_path = os.path.join(self.base_dir, receipt_filename) timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") region_str = ( f"`{region}` (Native CRS: {region_srs})" if region and str(region) != "None" else "`Global / Not Specified`" ) # Parse Modules for the receipt modules_info = [] mod_counts = {} for mod in self.config.get("modules", []): m_name = mod.get("module", "Unknown") args = mod.get("args", {}) weight = args.get("weight", 1.0) datatype = args.get("datatype", "default") modules_info.append( f"- **{m_name}** (Weight: {weight}, Datatype: {datatype})" ) mod_counts[m_name] = mod_counts.get(m_name, 0) + 1 # Parse Global Hooks for the receipt hooks_info = [] expected_outputs = [] for hook in self.config.get("global_hooks", []): h_name = hook.get("name", "Unknown") hooks_info.append(f"1. `{h_name}`") args = hook.get("args", {}) for key in args: if "output" in key: expected_outputs.append(args[key]) with open(receipt_path, "w", encoding="utf-8") as f: f.write(f"# Pipeline Execution Receipt: {name}\n") f.write(f"**Generated by Fetchez v{fetchez_version}**\n") f.write(f"*Execution Time: {timestamp}*\n\n") f.write("## Project Description\n") f.write(f"{desc}\n\n") f.write("## Target Region\n") f.write(f"{region_str}\n\n") f.write("## Data Modules Executed\n") if modules_info: f.write("\n".join(modules_info) + "\n\n") else: f.write("*None*\n\n") f.write("## Processing Pipeline (Hooks)\n") if hooks_info: f.write("\n".join(hooks_info) + "\n") else: f.write("*None*\n") # --- Print Terminal Summary --- logger.info("=" * 67) logger.info(f"✅ PIPELINE COMPLETE: {name}") logger.info("=" * 67) short_desc = desc[:75] + "..." if len(desc) > 75 else desc logger.info(f"Description: {short_desc}") if region and str(region) != "None": logger.info(f"Region: {region}") sources_str = ", ".join( [f"{k} (x{v})" if v > 1 else k for k, v in mod_counts.items()] ) logger.info(f"Sources: {sources_str}") logger.info(f"💾 OUTPUTS SAVED TO: {self.base_dir}") for out in expected_outputs: logger.info(f" ➔ {out}") logger.info(f"📄 Full processing receipt saved to: {receipt_filename}") logger.info("=" * 67)
[docs] def validate(self): """Validates the recipe for syntax, missing plugins, dependencies, and logical errors. Returns: bool: True if valid, False if errors exist. list: List of error messages. """ ModuleRegistry.load_all() HookRegistry.load_all() errors = [] claimed_outputs = set() def check_output_collision(hook_dict, context_name): """Helper to check if a hook is clobbering an existing file.""" out_file = hook_dict.get("args", {}).get("output") if out_file: if out_file in claimed_outputs: errors.append( f"[{context_name}] Output Collision: Multiple hooks are attempting to write to '{out_file}'." ) claimed_outputs.add(out_file) # Validate Modules for mod in self.config.get("modules", []): mod_name = mod.get("module") mod_keys = mod.keys() valid_keys = [ "module", "bundle", "hooks", "args", "region", "region_srs", "description", "_comment", ] for key in mod_keys: if key not in valid_keys: errors.append( f"Module `{mod_name}` has unexpected reference to `{key}`" ) if not ModuleRegistry.get_class(mod_name) and mod_name not in [ "file", "local_fs", ]: errors.append(f"Missing Module: '{mod_name}'") # Check Module-level Hooks # mod_hook_counts = {} for hook in mod.get("hooks", []): h_name = hook.get("name") HookCls = HookRegistry.get_class(h_name) if not HookCls: errors.append(f"Missing Hook: '{h_name}' (in module {mod_name})") continue # Dependency Check if hasattr(HookCls, "_validate_deps"): passed, msg = HookCls()._validate_deps() if not passed: errors.append( f"[{mod_name} -> {h_name}] Missing Dependency: {msg}" ) check_output_collision(hook, f"Module: {mod_name}") # Validate Global Hooks # global_hook_counts = {} for hook in self.config.get("global_hooks", []): h_name = hook.get("name") HookCls = HookRegistry.get_class(h_name) if not HookCls: errors.append(f"Missing Global Hook: '{h_name}'") continue # Dependency Check if hasattr(HookCls, "_validate_deps"): passed, msg = HookCls()._validate_deps() if not passed: errors.append(f"[Global -> {h_name}] Missing Dependency: {msg}") check_output_collision(hook, "Global Hooks") return len(errors) == 0, errors
[docs] def run(self, outdir=None, shared_cache=None, overwrite=False): """Execute the recipe, supporting vector-based batching, caching, and resumption.""" ModuleRegistry.load_all() BundleRegistry.load_all() SchemaRegistry.load_all() if not self.config: return self._check_integrity() # Expand the modules # self.config["modules"] = self._expand_modules(self.config.get("modules", [])) # # Apply any schemas # self.config = SchemaRegistry.apply_schema(self.config) # self._check_integrity() # Check for 'domain' to see if we have the proper extension to run the recipe. # domain = self.config.get("domain") # Execution parameters run_opts = self.config.get("execution", {}) threads = run_opts.get("threads", 1) raw_region = self.config.get("region") global_region_srs = self.config.get("region_srs", "EPSG:4326") recipe_name = self.config.get("project", {}).get("name", "Unnamed") original_cwd = os.getcwd() if outdir is None: base_outdir = os.path.abspath(original_cwd) else: base_outdir = os.path.abspath(outdir) # State Tracking state_file = os.path.join(original_cwd, ".fetchez_batch_state.json") completed_tiles = [] if os.path.exists(state_file) and not overwrite: try: with open(state_file, "r") as f: completed_tiles = json.load(f) except Exception: pass # Shared Cache abs_cache = None if shared_cache: abs_cache = os.path.abspath(shared_cache) os.makedirs(abs_cache, exist_ok=True) logger.info(f"Shared cache enabled: {abs_cache}") # Batch Loop for i, (target_region, feat_name) in enumerate( yield_parsed_regions(raw_region) ): # Batch Name if feat_name: batch_name = str(feat_name) elif target_region and i > 0: batch_name = f"batch_{i:03d}" else: batch_name = None # Check State if batch_name and batch_name in completed_tiles and not overwrite: logger.info( f"Skipping completed tile: {batch_name} (use --overwrite to force)" ) continue iteration_config = copy.deepcopy(self.config) # Setup the Sub-Folder tile_dir = base_outdir # original_cwd if batch_name: logger.info( colorize(f"\n--- Running Batch Iteration: {batch_name} ---", CYAN) ) orig_name = iteration_config.get("project", {}).get("name", "Unnamed") iteration_config.setdefault("project", {})["name"] = ( f"{orig_name}_{batch_name}" ) tile_dir = os.path.join(tile_dir, batch_name) os.makedirs(tile_dir, exist_ok=True) os.chdir(tile_dir) try: # Local Region if target_region: iteration_config["region"] = target_region.to_list() # Initialize & Run Pipeline self.base_dir = tile_dir # Expand Hooks and Modules iteration_config["global_hooks"] = self._expand_hooks( iteration_config.get("global_hooks", []) ) iteration_config["modules"] = self._expand_modules( iteration_config.get("modules", []) ) for mod in iteration_config["modules"]: mod["hooks"] = self._expand_hooks(mod.get("hooks", [])) # Inject outdir for shared caching if abs_cache: for mod in iteration_config.get("modules", []): if mod.get("module") not in ["file", "local_fs", "stdin"]: mod.setdefault("args", {})["outdir"] = abs_cache if batch_name or target_region: # Inject the {batch_name} context into outputs iteration_config = self._inject_batch_context( iteration_config, batch_name or target_region.format("fn"), target_region, ) # Apply any schemas iteration_config_mutated = SchemaRegistry.apply_schema( iteration_config.copy() ) iteration_valid, iteration_errors = Recipe( iteration_config_mutated ).validate() if not iteration_valid: logger.warning( f"The recipe that was mutated by the schema is invalid: {iteration_errors}" ) else: iteration_config = copy.deepcopy(iteration_config) # Initialize Hooks and Modules ( to python classes ) try: global_hooks = self._init_hooks(iteration_config["global_hooks"]) except Exception as e: logger.error(f"Could not initialize recipe global hooks: {e}") continue try: modules_to_run = self._init_modules( iteration_config["modules"], target_region=target_region, global_region_srs=global_region_srs, ) except Exception as e: logger.error(f"Could not initialize recipe modules: {e}") continue if not modules_to_run: continue # Dump the localized recipe for debugging and reproducibility # if batch_name: batch_config_fn = f"{batch_name or recipe_name}_recipe.yaml" with open(batch_config_fn, "w") as f: yaml.dump( iteration_config, f, sort_keys=False, default_flow_style=False, ) logger.debug(f"Saved localized recipe to {batch_config_fn}") for mod in modules_to_run: mod.run() run_fetchez(modules_to_run, threads=threads, global_hooks=global_hooks) # Update State if batch_name: completed_tiles.append(batch_name) with open(state_file, "w") as f: json.dump(completed_tiles, f, indent=2) except Exception as e: logger.error(f"Batch '{batch_name or 'run'}' failed: {e}") logger.warning( "Batch processing halted. Re-run command to resume from this tile." ) raise finally: self.base_dir = original_cwd os.chdir(original_cwd) logger.debug(f"Batch execution complete for {self.name}")