Source code for gedih3.cli.gh3_doctor

#! python

# Copyright (C) 2026, University of Maryland. All Rights Reserved.
# Authors: Tiago de Conto, Amelia Grace Holcomb
# For commercial licensing inquiries, contact UM Ventures at umdtechtransfer@umd.edu

"""gh3_doctor — audit and (optionally) heal a gedih3 database."""

import argparse


def _build_epilog():
    """Render the help epilog with the live diagnosis registry + alias groups.

    Importing the diagnoses package auto-registers every check, so `--check`
    options are derived from runtime state and stay in sync as new diagnoses
    are added (no hardcoded lists to drift).
    """
    import gedih3.doctor.diagnoses  # noqa: F401  (auto-register)
    from gedih3.doctor.runner import get_diagnoses, ALIAS_GROUPS

    diagnoses = get_diagnoses()
    diag_lines = []
    for name in sorted(diagnoses):
        d = diagnoses[name]
        fixable = ' (fixable)' if d.fix is not None else ''
        diag_lines.append(f"  {name:<16}{fixable:<11} {d.description}")

    alias_lines = []
    for group, members in ALIAS_GROUPS.items():
        if members is None:
            members_str = '<all registered diagnoses>'
        else:
            members_str = ', '.join(members)
        alias_lines.append(f"  {group:<6} -> {members_str}")

    return f"""\
Available diagnoses (use with --check / --fix):
{chr(10).join(diag_lines)}

Alias groups:
{chr(10).join(alias_lines)}

Exit codes:
  0    no findings (or --fix resolved every finding)
  1    findings remain after the run
  2    a check or fix raised an unhandled exception, OR bad CLI args

Examples:
  gh3_doctor -i /db                        # read-only audit, default 'db' alias
  gh3_doctor -i /db --check all            # include soc_health too
  gh3_doctor -i /db --check backfill,parquet_health
  gh3_doctor -i /db --fix                  # apply safe remedies for all checked
  gh3_doctor -i /db --fix backfill --s3    # backfill via S3 ETL temp
  gh3_doctor -i /db --online               # decorate with NASA upstream check
  gh3_doctor -i /db --report report.json   # machine-readable JSON output

Common knobs:
  --orphan-age-hours  protects in-progress builds (default 24h: anything younger is ignored)
  --soc-dir           required for backfill / soc_health if not at default location
  -s tcp://...        attach to an existing dask cluster (for parallel diagnoses)
"""


[docs] def get_cmd_args(): from gedih3.cliutils import add_dask_args, add_verbosity_args, add_storage_args p = argparse.ArgumentParser( description="Audit and (optionally) heal a gedih3 database", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=_build_epilog(), ) p.add_argument("-i", "--indir", dest="indir", type=str, default=None, help="root directory of the gedih3 database (default: GH3_DEFAULT_H3_DIR)") p.add_argument("--soc-dir", dest="soc_dir", type=str, default=None, help="root directory of SOC HDF5 files (default: GH3_DEFAULT_SOC_DIR)") p.add_argument("-t", "--tmpdir", dest="tmpdir", type=str, default=None, help="temp directory for orphan scans and S3 ETL") p.add_argument("--check", dest="check", type=str, default=None, help="comma-separated diagnosis names or aliases (db, soc, all). " "Default: 'db' alias. See epilog for the full list.") p.add_argument("--fix", dest="fix", type=str, nargs='?', const='__ALL__', default=None, help="apply safe remedies (only some diagnoses are fixable - see epilog). " "Optional comma-separated names; bare flag = all checked.") p.add_argument("-s3", "--s3", dest="s3", action='store_true', help="for backfill, fetch missing source files via NASA S3 ETL temp directory") p.add_argument("--online", dest="online", action='store_true', help="query NASA CMR for granule availability and emit recommendations") p.add_argument("--report", dest="report", type=str, default=None, help="write machine-readable JSON report to this path") p.add_argument("--orphan-age-hours", dest="orphan_age_hours", type=float, default=24.0, help="minimum age (hours) before orphan files/dirs are eligible for cleanup [default=24]") p.add_argument("--gedi-version", dest="version", type=int, default=None, help="GEDI data version (only needed when --online uses CMR)") add_dask_args(p) add_verbosity_args(p) add_storage_args(p) return p.parse_args()
def _resolve_request(arg_value, default_aliases): """Split a comma-separated arg into a list of names. None → default_aliases.""" if arg_value is None: return list(default_aliases) return [n.strip() for n in arg_value.split(',') if n.strip()]
[docs] def main(): args = get_cmd_args() import os import sys import json from gedih3.config import GH3_DEFAULT_H3_DIR, GH3_DEFAULT_SOC_DIR from gedih3.cliutils import ( setup_logging, print_banner, print_success, cli_exception_handler, parse_dask_args, setup_storage, ) from gedih3.logger import H3BuildLogger from gedih3.doctor import DoctorContext, run_diagnoses, get_diagnoses from gedih3.doctor.runner import resolve_names # Importing the diagnoses package auto-registers all known diagnoses. import gedih3.doctor.diagnoses # noqa: F401 from gedih3.doctor.inspect import discover_partition_dirs logger = setup_logging(args, __name__) print_banner("GEDI H3 Database Doctor", logger=logger) if args.indir is None: args.indir = GH3_DEFAULT_H3_DIR if args.soc_dir is None: args.soc_dir = GH3_DEFAULT_SOC_DIR if os.path.isdir(GH3_DEFAULT_SOC_DIR) else None # No default for tmpdir: the build's tmp directory layout varies # (sometimes under the DB root, sometimes a sibling, sometimes # outside the DB tree entirely). Scanning a guessed path that # doesn't exist produces no findings; scanning an *unrelated* path # is worse (false positives). Diagnoses that need a tmp tree # (``tmp_partitions_health``, the tmp_dir leg of ``orphans``) skip # silently when ``args.tmpdir`` is None. # Resolve all input paths to absolute. Per-partition workers serialize # over pickle to remote dask workers, whose CWD differs from the # driver's — a relative path passed in via ``-i database/`` would # fail every ``os.scandir`` on the worker side (silently treated as # an OSError by the doctor helpers) and produce 10k false # ``empty_partition`` + ``missing_partition_meta`` findings. args.indir = os.path.abspath(args.indir) if args.soc_dir is not None: args.soc_dir = os.path.abspath(args.soc_dir) if args.tmpdir is not None: args.tmpdir = os.path.abspath(args.tmpdir) if getattr(args, 'report', None): args.report = os.path.abspath(args.report) if not os.path.isdir(args.indir): logger.error(f"Database directory not found: {args.indir}") sys.exit(2) setup_storage(args, logger=logger) # Always create a dask Client so every diagnosis dispatches through # the same code path. ``parse_dask_args`` returns the address of a # remote scheduler when ``--scheduler-address`` is given, otherwise # the kwargs (n_workers / threads / memory) for a transient # LocalCluster. The doctor's ``parallel_map`` primitive assumes a # Client is registered — keeping CLI startup uniform with every # other gedih3 tool (gh3_extract, gh3_aggregate, gh3_build, …) # eliminates the dual-path "serial fallback" that used to ship # whenever a user invoked the doctor without ``-s``. from dask.distributed import Client dask_kwargs = parse_dask_args(args) if dask_kwargs.get('address'): logger.info(f"Connecting to dask cluster: {dask_kwargs['address']}") else: logger.info( f"Spinning up local dask cluster ({args.cores} workers, " f"{args.threads} threads/worker)" ) with Client(**dask_kwargs) as _client, cli_exception_handler(args, logger=logger): try: logger.info(f"Dask dashboard available at: {_client.dashboard_link}") except Exception: pass # Load the build log (lazy upgrade applied automatically). If absent, # most diagnoses still work — they fall back to filesystem inspection. try: h3_logger = H3BuildLogger(product_vars=None, dir=args.indir, version=args.version) except Exception as e: logger.warning(f"Could not load build log: {e}. Diagnoses will skip log-only checks.") h3_logger = None partition_dirs = discover_partition_dirs(args.indir) logger.info(f"Database: {args.indir} ({len(partition_dirs)} partitions)") if args.soc_dir: logger.info(f"SOC directory: {args.soc_dir}") ctx = DoctorContext( h3_dir=args.indir, soc_dir=args.soc_dir, tmp_dir=args.tmpdir, h3_logger=h3_logger, partition_dirs=partition_dirs, args=args, ) # Optionally enrich with upstream availability info before diagnoses run. if args.online: try: from gedih3.doctor.upstream import gather_upstream ctx.upstream = gather_upstream(ctx, logger=logger) except Exception as e: logger.warning(f"Upstream check failed (continuing without): {e}") # Determine which diagnoses to check registry = get_diagnoses() if not registry: logger.error("No diagnoses registered. This is a packaging bug.") sys.exit(2) check_names = _resolve_request(args.check, default_aliases=['db']) try: check_resolved = resolve_names(check_names) except ValueError as e: logger.error(str(e)) sys.exit(2) logger.info(f"Running diagnoses: {', '.join(check_resolved)}") # Run as 'check' (read-only) first to know what's wrong. check_reports = run_diagnoses(ctx, check_names, mode='check') # If --fix is requested, run only the requested fix subset against # the same context (re-runs the check then applies remedy). fix_reports = None if args.fix is not None: if args.fix == '__ALL__': fix_names = check_resolved else: fix_names = _resolve_request(args.fix, default_aliases=check_resolved) try: resolve_names(fix_names) except ValueError as e: logger.error(str(e)) sys.exit(2) logger.info(f"Applying fixes: {', '.join(fix_names)}") fix_reports = run_diagnoses(ctx, fix_names, mode='fix') # Persist any logger mutations made by fix routines if h3_logger is not None and any(getattr(r, 'applied', False) for r in fix_reports): try: h3_logger.save_log(h3_logger.previous_status or 'COMPLETED') except Exception as e: logger.warning(f"Failed to persist build log updates: {e}") reports_to_emit = fix_reports if fix_reports is not None else check_reports _print_summary(reports_to_emit, logger) if ctx.upstream is not None: _print_upstream(ctx.upstream, logger) if args.report: from gedih3.utils import AtomicFileWriter payload = { 'reports': [r.to_dict() for r in reports_to_emit], 'upstream': ctx.upstream.to_dict() if ctx.upstream is not None else None, } # Atomic write so an interrupted run leaves no half-written # report file at the user's target path. with AtomicFileWriter(args.report) as tmp: with open(tmp, 'w') as f: json.dump(payload, f, indent=2, default=str) logger.info(f"Wrote report: {args.report}") worst = _worst_severity(reports_to_emit) if not any(r.has_findings for r in reports_to_emit): print_success("Database is clean", logger=logger) sys.exit(0) # Exit code: 0 if --fix was used and resolved everything, 1 if findings remain if fix_reports is not None and all(r.is_clean for r in fix_reports): print_success("All issues resolved", logger=logger) sys.exit(0) sys.exit(2 if worst == 'error' else 1)
def _worst_severity(reports): order = {'info': 0, 'warn': 1, 'error': 2} if not reports: return 'info' return max((r.severity.value if hasattr(r.severity, 'value') else r.severity for r in reports), key=lambda s: order.get(s, 0)) def _print_summary(reports, logger): logger.info("") logger.info("=" * 70) logger.info(" Doctor Summary".center(70)) logger.info("=" * 70) for r in reports: sev = r.severity.value if hasattr(r.severity, 'value') else str(r.severity) marker = {'info': '·', 'warn': '!', 'error': 'X'}.get(sev, '?') status = 'fixed' if r.applied else 'check' logger.info(f" [{marker}] {r.name:<18} {status:<5} {len(r.findings):>4} findings {r.summary}") for rec in r.recommendations: logger.info(f" -> {rec}") logger.info("=" * 70) def _print_upstream(upstream, logger): logger.info("") logger.info("=" * 70) logger.info(" Upstream NASA Availability".center(70)) logger.info("=" * 70) for cls, items in sorted(upstream.classifications.items()): logger.info(f" {cls:<18}: {len(items)} (granule × product)") if upstream.recommendations: logger.info("") logger.info(" Recommended commands ".center(70, '-')) for line in upstream.recommendations: logger.info(f" {line}") logger.info("=" * 70) if __name__ == '__main__': main()