From d95896aa50419e6bc4bca5af8b3be13cdc7f271a Mon Sep 17 00:00:00 2001 From: btobers Date: Thu, 27 Feb 2025 15:02:30 -0500 Subject: [PATCH 01/29] config.py now object oriented and contains update_config() function --- pygem/setup/config.py | 121 ++++++++++++++++++++++++++++++------------ 1 file changed, 86 insertions(+), 35 deletions(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 4645af90..791d4bfd 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -7,42 +7,93 @@ """ import os import shutil -import sys import yaml +import argparse +from ruamel.yaml import YAML -# pygem_params file name -config_fn = 'config.yaml' - -# Define the base directory and the path to the configuration file -basedir = os.path.join(os.path.expanduser('~'), 'PyGEM') -config_file = os.path.join(basedir, config_fn) # Path where you want the config file - -# Get the source configuration file path from your package -package_dir = os.path.dirname(__file__) # Get the directory of the current script -source_config_file = os.path.join(package_dir, config_fn) # Path to params.py - -def ensure_config(overwrite=False): - isfile = os.path.isfile(config_file) - if isfile and overwrite: - overwrite = None - while overwrite is None: - # Ask the user for a y/n response - response = input(f"PyGEM configuration.yaml file already exist ({config_file}), do you wish to overwrite (y/n):").strip().lower() - # Check if the response is valid - if response == 'y' or response == 'yes': - overwrite = True - elif response == 'n' or response == 'no': - overwrite = False - else: - print("Invalid input. Please enter 'y' or 'n'.") +class ConfigManager: + def __init__(self, config_filename='config.yaml', base_dir=None): + """initialize the ConfigManager class""" + self.config_filename = config_filename + self.base_dir = base_dir or os.path.join(os.path.expanduser('~'), 'PyGEM') + self.config_path = os.path.join(self.base_dir, self.config_filename) + self.package_dir = os.path.dirname(__file__) + self.source_config_path = os.path.join(self.package_dir, self.config_filename) - if (not isfile) or (overwrite): - os.makedirs(basedir, exist_ok=True) # Ensure the base directory exists - shutil.copy(source_config_file, config_file) # Copy the file - print(f"Copied default configuration to {config_file}") + def ensure_config(self, overwrite=False): + """Ensure the configuration file exists""" + if os.path.isfile(self.config_path) and not overwrite: + return + + if os.path.isfile(self.config_path) and overwrite: + overwrite = self._prompt_overwrite() + + if not os.path.isfile(self.config_path) or overwrite: + os.makedirs(self.base_dir, exist_ok=True) + shutil.copy(self.source_config_path, self.config_path) + print(f"Copied default configuration to {self.config_path}") -def read_config(): - """Read the configuration file and return its contents as a dictionary.""" - with open(config_file, 'r') as f: - config = yaml.safe_load(f) # Use safe_load to avoid arbitrary code execution - return config \ No newline at end of file + def read_config(self): + """Read the configuration file and return its contents as a dictionary.""" + with open(self.config_path, 'r') as f: + return yaml.safe_load(f) + + def update_config(self, updates): + """Update multiple keys in the YAML configuration file while preserving quotes and original types.""" + yaml = YAML() + yaml.preserve_quotes = True # Preserve quotes around string values + + try: + with open(self.config_path, 'r') as file: + config = yaml.load(file) + + for key, value in updates.items(): + keys = key.split('.') + d = config + for k in keys[:-1]: + if k not in d: + print(f"Key '{key}' not found in the config. Skipping update.") + break + d = d[k] + else: + # Reparse value with YAML to infer correct type + if keys[-1] in d: + d[keys[-1]] = yaml.load(value) + else: + print(f"Key '{key}' not found in the config. Skipping update.") + + with open(self.config_path, 'w') as file: + yaml.dump(config, file) + + except Exception as e: + print(f"Error updating config: {e}") + + + def _prompt_overwrite(self): + """Prompt the user for confirmation before overwriting the config file.""" + while True: + response = input(f"Configuration file already exists ({self.config_path}). Overwrite? (y/n): ").strip().lower() + if response in ['y', 'yes']: + return True + elif response in ['n', 'no']: + return False + print("Invalid input. Please enter 'y' or 'n'.") + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--config', default='config.yaml', help='Path to the config file') + parser.add_argument('updates', nargs='*', help='Key-value pairs to update in the config file') + + args = parser.parse_args() + + # Parse the updates into a dictionary + updates = {} + for update in args.updates: + key, value = update.split('=') + updates[key] = value + + config_manager = ConfigManager(config_filename=args.config) + config_manager.update_config(updates) + +if __name__ == '__main__': + main() \ No newline at end of file From 0b8209cc884e275458caa8589a8464e942857aa2 Mon Sep 17 00:00:00 2001 From: btobers Date: Thu, 27 Feb 2025 15:04:10 -0500 Subject: [PATCH 02/29] all prior calls to config updated for compatability with new config.ConfigManager class --- pygem/bin/op/duplicate_gdirs.py | 8 ++++-- pygem/bin/op/initialize.py | 28 ++++++------------- pygem/bin/op/list_failed_simulations.py | 8 ++++-- .../postproc/postproc_binned_monthly_mass.py | 10 +++++-- .../postproc/postproc_compile_simulations.py | 8 ++++-- pygem/bin/postproc/postproc_distribute_ice.py | 10 +++++-- pygem/bin/postproc/postproc_monthly_mass.py | 10 +++++-- pygem/bin/preproc/preproc_fetch_mbdata.py | 8 ++++-- pygem/bin/preproc/preproc_wgms_estimate_kp.py | 8 ++++-- pygem/bin/run/run_calibration.py | 8 ++++-- .../run/run_calibration_frontalablation.py | 8 ++++-- pygem/bin/run/run_calibration_reg_glena.py | 12 ++++---- pygem/bin/run/run_mcmc_priors.py | 8 ++++-- pygem/bin/run/run_simulation.py | 8 ++++-- pygem/class_climate.py | 9 +++--- pygem/gcmbiasadj.py | 9 +++--- pygem/glacierdynamics.py | 9 +++--- pygem/massbalance.py | 9 +++--- pygem/mcmc.py | 9 +++--- pygem/oggm_compat.py | 9 +++--- pygem/output.py | 8 ++++-- pygem/pygem_modelsetup.py | 10 ++++--- pygem/shop/debris.py | 10 ++++--- pygem/shop/icethickness.py | 10 ++++--- pygem/shop/mbdata.py | 8 +++--- pygem/shop/oib.py | 9 +++--- pygem/utils/_funcs.py | 9 +++--- 27 files changed, 148 insertions(+), 112 deletions(-) diff --git a/pygem/bin/op/duplicate_gdirs.py b/pygem/bin/op/duplicate_gdirs.py index 016f8c29..823d7f7a 100644 --- a/pygem/bin/op/duplicate_gdirs.py +++ b/pygem/bin/op/duplicate_gdirs.py @@ -11,11 +11,13 @@ import os import shutil # pygem imports -import pygem.setup.config as config +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() # check for config -config.ensure_config() +config_manager.ensure_config() # read the config -pygem_prms = config.read_config() +pygem_prms = config_manager.read_config() def main(): parser = argparse.ArgumentParser(description="Script to make duplicate oggm glacier directories - primarily to avoid corruption if parellelizing runs on a single glacier") diff --git a/pygem/bin/op/initialize.py b/pygem/bin/op/initialize.py index 21685a20..0a0822e8 100644 --- a/pygem/bin/op/initialize.py +++ b/pygem/bin/op/initialize.py @@ -11,25 +11,13 @@ import zipfile import os,sys import shutil -from ruamel.yaml import YAML -# set up config.yaml -import pygem.setup.config as config -config.ensure_config(overwrite=True) - -def update_config_root(conf_path, datapath): - yaml = YAML() - yaml.preserve_quotes = True # Preserve quotes around string values - - # Read the YAML file - with open(conf_path, 'r') as file: - config = yaml.load(file) - - # Update the key with the new value - config['root'] = datapath - - # Save the updated configuration back to the file - with open(conf_path, 'w') as file: - yaml.dump(config, file) +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# check for config +config_manager.ensure_config(overwrite=True) +# read the config +pygem_prms = config_manager.read_config() def print_file_tree(start_path, indent=""): # Loop through all files and directories in the current directory @@ -136,7 +124,7 @@ def main(): # update root path in config.yaml try: - update_config_root(config.config_file, out+'/sample_data/') + config_manager.update_config(updates={'root':f'{out}/sample_data'}) except: pass diff --git a/pygem/bin/op/list_failed_simulations.py b/pygem/bin/op/list_failed_simulations.py index 66f83c15..fa2c1363 100644 --- a/pygem/bin/op/list_failed_simulations.py +++ b/pygem/bin/op/list_failed_simulations.py @@ -15,11 +15,13 @@ import argparse import numpy as np # pygem imports -import pygem.setup.config as config +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() # check for config -config.ensure_config() +config_manager.ensure_config() # read the config -pygem_prms = config.read_config() +pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup def run(reg, simpath, gcm, scenario, calib_opt, bias_adj, gcm_startyear, gcm_endyear): diff --git a/pygem/bin/postproc/postproc_binned_monthly_mass.py b/pygem/bin/postproc/postproc_binned_monthly_mass.py index 3fea6ba1..a9888d7a 100644 --- a/pygem/bin/postproc/postproc_binned_monthly_mass.py +++ b/pygem/bin/postproc/postproc_binned_monthly_mass.py @@ -21,9 +21,13 @@ import numpy as np import xarray as xr # pygem imports -import pygem.setup.config as config -# read config -pygem_prms = config.read_config() +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# check for config +config_manager.ensure_config() +# read the config +pygem_prms = config_manager.read_config() # ----- FUNCTIONS ----- def getparser(): diff --git a/pygem/bin/postproc/postproc_compile_simulations.py b/pygem/bin/postproc/postproc_compile_simulations.py index 2f097dca..f399b73f 100644 --- a/pygem/bin/postproc/postproc_compile_simulations.py +++ b/pygem/bin/postproc/postproc_compile_simulations.py @@ -21,11 +21,13 @@ # pygem imports import pygem -import pygem.setup.config as config +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() # check for config -config.ensure_config() +config_manager.ensure_config() # read the config -pygem_prms = config.read_config() +pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup rgi_reg_dict = {'all':'Global', diff --git a/pygem/bin/postproc/postproc_distribute_ice.py b/pygem/bin/postproc/postproc_distribute_ice.py index 892c335a..3832470f 100644 --- a/pygem/bin/postproc/postproc_distribute_ice.py +++ b/pygem/bin/postproc/postproc_distribute_ice.py @@ -24,9 +24,13 @@ from oggm import workflow, tasks, cfg from oggm.sandbox import distribute_2d # pygem imports -import pygem.setup.config as config -# read config -pygem_prms = config.read_config() +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# check for config +config_manager.ensure_config() +# read the config +pygem_prms = config_manager.read_config() import pygem import pygem.pygem_modelsetup as modelsetup from pygem.oggm_compat import single_flowline_glacier_directory diff --git a/pygem/bin/postproc/postproc_monthly_mass.py b/pygem/bin/postproc/postproc_monthly_mass.py index ea18f641..dff1d66f 100644 --- a/pygem/bin/postproc/postproc_monthly_mass.py +++ b/pygem/bin/postproc/postproc_monthly_mass.py @@ -25,9 +25,13 @@ import xarray as xr # pygem imports import pygem -import pygem.setup.config as config -# read config -pygem_prms = config.read_config() +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# check for config +config_manager.ensure_config() +# read the config +pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup diff --git a/pygem/bin/preproc/preproc_fetch_mbdata.py b/pygem/bin/preproc/preproc_fetch_mbdata.py index d25cf9ef..b7e381e7 100644 --- a/pygem/bin/preproc/preproc_fetch_mbdata.py +++ b/pygem/bin/preproc/preproc_fetch_mbdata.py @@ -19,11 +19,13 @@ # oggm from oggm import utils # pygem imports -import pygem.setup.config as config +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() # check for config -config.ensure_config() +config_manager.ensure_config() # read the config -pygem_prms = config.read_config() +pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup diff --git a/pygem/bin/preproc/preproc_wgms_estimate_kp.py b/pygem/bin/preproc/preproc_wgms_estimate_kp.py index 684258a7..e08ab8a6 100644 --- a/pygem/bin/preproc/preproc_wgms_estimate_kp.py +++ b/pygem/bin/preproc/preproc_wgms_estimate_kp.py @@ -22,11 +22,13 @@ from scipy.stats import median_abs_deviation # pygem imports from pygem import class_climate -import pygem.setup.config as config +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() # check for config -config.ensure_config() +config_manager.ensure_config() # read the config -pygem_prms = config.read_config() +pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup diff --git a/pygem/bin/run/run_calibration.py b/pygem/bin/run/run_calibration.py index 424780ad..da2f3ae0 100755 --- a/pygem/bin/run/run_calibration.py +++ b/pygem/bin/run/run_calibration.py @@ -31,11 +31,13 @@ import sklearn.model_selection # pygem imports -import pygem.setup.config as config +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() # check for config -config.ensure_config() +config_manager.ensure_config() # read the config -pygem_prms = config.read_config() +pygem_prms = config_manager.read_config() from pygem import mcmc from pygem import class_climate from pygem.massbalance import PyGEMMassBalance diff --git a/pygem/bin/run/run_calibration_frontalablation.py b/pygem/bin/run/run_calibration_frontalablation.py index cd8b0c3d..7d114d9e 100644 --- a/pygem/bin/run/run_calibration_frontalablation.py +++ b/pygem/bin/run/run_calibration_frontalablation.py @@ -24,11 +24,13 @@ from scipy.stats import linregress import xarray as xr # pygem imports -import pygem.setup.config as config +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() # check for config -config.ensure_config() +config_manager.ensure_config() # read the config -pygem_prms = config.read_config() +pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup from pygem.massbalance import PyGEMMassBalance from pygem.glacierdynamics import MassRedistributionCurveModel diff --git a/pygem/bin/run/run_calibration_reg_glena.py b/pygem/bin/run/run_calibration_reg_glena.py index fb0d1bb5..acc4aaa6 100644 --- a/pygem/bin/run/run_calibration_reg_glena.py +++ b/pygem/bin/run/run_calibration_reg_glena.py @@ -22,11 +22,13 @@ from scipy.optimize import brentq # pygem imports import pygem -import pygem.setup.config as config -# Check for config -config.ensure_config() # This will ensure the config file is created -# Read the config -pygem_prms = config.read_config() # This reads the configuration file +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# check for config +config_manager.ensure_config() +# read the config +pygem_prms = config_manager.read_config() from pygem import class_climate from pygem.massbalance import PyGEMMassBalance from pygem.oggm_compat import single_flowline_glacier_directory diff --git a/pygem/bin/run/run_mcmc_priors.py b/pygem/bin/run/run_mcmc_priors.py index b196a7f1..be3ebcce 100644 --- a/pygem/bin/run/run_mcmc_priors.py +++ b/pygem/bin/run/run_mcmc_priors.py @@ -13,11 +13,13 @@ from scipy import stats # pygem imports -import pygem.setup.config as config +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() # check for config -config.ensure_config() +config_manager.ensure_config() # read the config -pygem_prms = config.read_config() +pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup # Region dictionary for titles diff --git a/pygem/bin/run/run_simulation.py b/pygem/bin/run/run_simulation.py index fb6069c7..33922f2f 100755 --- a/pygem/bin/run/run_simulation.py +++ b/pygem/bin/run/run_simulation.py @@ -35,11 +35,13 @@ # pygem imports import pygem -import pygem.setup.config as config +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() # check for config -config.ensure_config() +config_manager.ensure_config() # read the config -pygem_prms = config.read_config() +pygem_prms = config_manager.read_config() import pygem.gcmbiasadj as gcmbiasadj import pygem.pygem_modelsetup as modelsetup from pygem.massbalance import PyGEMMassBalance diff --git a/pygem/class_climate.py b/pygem/class_climate.py index 10c28935..bd701d39 100755 --- a/pygem/class_climate.py +++ b/pygem/class_climate.py @@ -12,10 +12,11 @@ class of climate data and functions associated with manipulating the dataset to import pandas as pd import numpy as np import xarray as xr -# Local libraries -import pygem.setup.config as config -# Read the config -pygem_prms = config.read_config() # This reads the configuration file +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# read the config +pygem_prms = config_manager.read_config() class GCM(): """ diff --git a/pygem/gcmbiasadj.py b/pygem/gcmbiasadj.py index 52ebce0f..e3cc8e81 100755 --- a/pygem/gcmbiasadj.py +++ b/pygem/gcmbiasadj.py @@ -16,10 +16,11 @@ import numpy as np from scipy.ndimage import uniform_filter from scipy.stats import percentileofscore -# load pygem config -import pygem.setup.config as config -# Read the config -pygem_prms = config.read_config() +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# read the config +pygem_prms = config_manager.read_config() #%% FUNCTIONS def annual_avg_2darray(x): diff --git a/pygem/glacierdynamics.py b/pygem/glacierdynamics.py index 6b6b024f..03c22a26 100755 --- a/pygem/glacierdynamics.py +++ b/pygem/glacierdynamics.py @@ -17,10 +17,11 @@ from oggm.core.flowline import FlowlineModel from oggm.exceptions import InvalidParamsError from oggm import __version__ -# Local libraries -import pygem.setup.config as config -# Read the config -pygem_prms = config.read_config() # This reads the configuration file +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# read the config +pygem_prms = config_manager.read_config() cfg.initialize() diff --git a/pygem/massbalance.py b/pygem/massbalance.py index 15532c2a..2b143162 100644 --- a/pygem/massbalance.py +++ b/pygem/massbalance.py @@ -10,10 +10,11 @@ # Local libraries from oggm.core.massbalance import MassBalanceModel from pygem.utils._funcs import annualweightedmean_array -# Local libraries -import pygem.setup.config as config -# Read the config -pygem_prms = config.read_config() # This reads the configuration file +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# read the config +pygem_prms = config_manager.read_config() #%% class PyGEMMassBalance(MassBalanceModel): diff --git a/pygem/mcmc.py b/pygem/mcmc.py index 98420d0f..1f41c27c 100644 --- a/pygem/mcmc.py +++ b/pygem/mcmc.py @@ -14,10 +14,11 @@ from tqdm import tqdm import matplotlib.pyplot as plt import matplotlib.cm as cm -# Local libraries -import pygem.setup.config as config -# Read the config -pygem_prms = config.read_config() # This reads the configuration file +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# read the config +pygem_prms = config_manager.read_config() torch.set_default_dtype(torch.float64) plt.rcParams["font.family"] = "arial" diff --git a/pygem/oggm_compat.py b/pygem/oggm_compat.py index b3bebdb4..7a98e844 100755 --- a/pygem/oggm_compat.py +++ b/pygem/oggm_compat.py @@ -20,10 +20,11 @@ from oggm.core.massbalance import MassBalanceModel #from oggm.shop import rgitopo from pygem.shop import debris, mbdata, icethickness -# Local libraries -import pygem.setup.config as config -# read config -pygem_prms = config.read_config() +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# read the config +pygem_prms = config_manager.read_config() class CompatGlacDir: diff --git a/pygem/output.py b/pygem/output.py index a04e7ec9..e01bc6cd 100644 --- a/pygem/output.py +++ b/pygem/output.py @@ -18,9 +18,11 @@ import pandas as pd import xarray as xr import os, types, json, cftime, collections -import pygem.setup.config as config -# Read the config -pygem_prms = config.read_config() # This reads the configuration file +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# read the config +pygem_prms = config_manager.read_config() ### single glacier output parent class ### @dataclass diff --git a/pygem/pygem_modelsetup.py b/pygem/pygem_modelsetup.py index a602decc..3182669e 100755 --- a/pygem/pygem_modelsetup.py +++ b/pygem/pygem_modelsetup.py @@ -13,10 +13,12 @@ import pandas as pd import numpy as np from datetime import datetime -# Local libraries -import pygem.setup.config as config -# Read the config -pygem_prms = config.read_config() # This reads the configuration file +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# read the config +pygem_prms = config_manager.read_config() + def datesmodelrun(startyear=pygem_prms['climate']['ref_startyear'], endyear=pygem_prms['climate']['ref_endyear'], spinupyears=pygem_prms['climate']['ref_spinupyears'], option_wateryear=pygem_prms['climate']['ref_wateryear']): diff --git a/pygem/shop/debris.py b/pygem/shop/debris.py index 191f256a..13b3f933 100755 --- a/pygem/shop/debris.py +++ b/pygem/shop/debris.py @@ -16,10 +16,12 @@ from oggm.utils import entity_task from oggm.core.gis import rasterio_to_gdir from oggm.utils import ncDataset -# Local libraries -import pygem.setup.config as config -# Read the config -pygem_prms = config.read_config() # This reads the configuration file +# pygem imports +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# read the config +pygem_prms = config_manager.read_config() """ To-do list: diff --git a/pygem/shop/icethickness.py b/pygem/shop/icethickness.py index dcdc4656..6d7660d3 100755 --- a/pygem/shop/icethickness.py +++ b/pygem/shop/icethickness.py @@ -18,10 +18,12 @@ from oggm.utils import entity_task from oggm.core.gis import rasterio_to_gdir from oggm.utils import ncDataset -# Local libraries -import pygem.setup.config as config -# Read the config -pygem_prms = config.read_config() # This reads the configuration file +# pygem imports +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# read the config +pygem_prms = config_manager.read_config() if not 'consensus_mass' in cfg.BASENAMES: cfg.BASENAMES['consensus_mass'] = ('consensus_mass.pkl', 'Glacier mass from consensus ice thickness data') diff --git a/pygem/shop/mbdata.py b/pygem/shop/mbdata.py index f73ebdc2..ba87ffcd 100755 --- a/pygem/shop/mbdata.py +++ b/pygem/shop/mbdata.py @@ -23,11 +23,11 @@ #from oggm.core.gis import rasterio_to_gdir #from oggm.utils import ncDataset # pygem imports -import pygem.setup.config as config -# check for config -config.ensure_config() +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() # read the config -pygem_prms = config.read_config() +pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup diff --git a/pygem/shop/oib.py b/pygem/shop/oib.py index cbc79edc..70f1f9fc 100644 --- a/pygem/shop/oib.py +++ b/pygem/shop/oib.py @@ -12,10 +12,11 @@ import pandas as pd from scipy import signal, stats import matplotlib.pyplot as plt -# Local libraries -import pygem.setup.config as config -# Read the config -pygem_prms = config.read_config() # This reads the configuration file +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# read the config +pygem_prms = config_manager.read_config() class oib: def __init__(self, rgi6id='', rgi7id=''): diff --git a/pygem/utils/_funcs.py b/pygem/utils/_funcs.py index 2ecb9b5d..11172bad 100755 --- a/pygem/utils/_funcs.py +++ b/pygem/utils/_funcs.py @@ -9,10 +9,11 @@ """ import numpy as np import json -# Local libraries -import pygem.setup.config as config -# Read the config -pygem_prms = config.read_config() # This reads the configuration file +from pygem.setup.config import ConfigManager +# instantiate ConfigManager +config_manager = ConfigManager() +# read the config +pygem_prms = config_manager.read_config() def annualweightedmean_array(var, dates_table): """ From f8f6eb03bb2804d535c42e41931e64a6ecdcd3ed Mon Sep 17 00:00:00 2001 From: btobers Date: Thu, 27 Feb 2025 18:13:37 -0500 Subject: [PATCH 03/29] raise errors, and ensure that dictionary keys are not overwritten --- pygem/setup/config.py | 44 ++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 791d4bfd..7d65fd25 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -43,31 +43,33 @@ def update_config(self, updates): yaml = YAML() yaml.preserve_quotes = True # Preserve quotes around string values - try: - with open(self.config_path, 'r') as file: - config = yaml.load(file) + with open(self.config_path, 'r') as file: + config = yaml.load(file) - for key, value in updates.items(): - keys = key.split('.') - d = config - for k in keys[:-1]: - if k not in d: - print(f"Key '{key}' not found in the config. Skipping update.") - break - d = d[k] - else: - # Reparse value with YAML to infer correct type - if keys[-1] in d: - d[keys[-1]] = yaml.load(value) - else: - print(f"Key '{key}' not found in the config. Skipping update.") + for key, value in updates.items(): + keys = key.split('.') + d = config + # Traverse the keys up to the second-to-last + for i, k in enumerate(keys[:-1]): + if k not in d: + raise KeyError(f"No matching `{'.'.join(keys[:i+1])}` key found in the configuration file at path: {self.config_path}") + d = d[k] - with open(self.config_path, 'w') as file: - yaml.dump(config, file) + final_key = keys[-1] - except Exception as e: - print(f"Error updating config: {e}") + # Ensure the final key exists before updating its value + if final_key not in d: + raise KeyError(f"No matching `{key}` key found in the configuration file at path: {self.config_path}") + # Prevent replacing a dictionary with a non-dictionary value + if isinstance(d[final_key], dict): + raise ValueError(f"Cannot directly overwrite key `{key}` because it contains a dictionary.") + + d[final_key] = yaml.load(value) + + # Save the updated config back to the file + with open(self.config_path, 'w') as file: + yaml.dump(config, file) def _prompt_overwrite(self): """Prompt the user for confirmation before overwriting the config file.""" From 16f85d4d1a9499faec780d28256e5db7aa489d7b Mon Sep 17 00:00:00 2001 From: btobers Date: Thu, 27 Feb 2025 18:23:34 -0500 Subject: [PATCH 04/29] create_config() function created and _prompt_overwrite() function removed to clean up the class and remove redundancy --- pygem/setup/config.py | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 7d65fd25..926e2123 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -21,17 +21,18 @@ def __init__(self, config_filename='config.yaml', base_dir=None): self.source_config_path = os.path.join(self.package_dir, self.config_filename) def ensure_config(self, overwrite=False): - """Ensure the configuration file exists""" - if os.path.isfile(self.config_path) and not overwrite: - return - - if os.path.isfile(self.config_path) and overwrite: - overwrite = self._prompt_overwrite() - + """Ensure the configuration file exists, creating or overwriting it if necessary""" if not os.path.isfile(self.config_path) or overwrite: - os.makedirs(self.base_dir, exist_ok=True) - shutil.copy(self.source_config_path, self.config_path) - print(f"Copied default configuration to {self.config_path}") + self.create_config() + + def create_config(self): + """Copy the default configuration file to the expected location""" + if not os.path.exists(self.source_config_path): + raise FileNotFoundError(f"Default config file not found at {self.source_config_path}, there may have been an installation issue") + + os.makedirs(self.base_dir, exist_ok=True) + shutil.copy(self.source_config_path, self.config_path) + print(f"Copied default configuration to {self.config_path}") def read_config(self): """Read the configuration file and return its contents as a dictionary.""" @@ -70,16 +71,6 @@ def update_config(self, updates): # Save the updated config back to the file with open(self.config_path, 'w') as file: yaml.dump(config, file) - - def _prompt_overwrite(self): - """Prompt the user for confirmation before overwriting the config file.""" - while True: - response = input(f"Configuration file already exists ({self.config_path}). Overwrite? (y/n): ").strip().lower() - if response in ['y', 'yes']: - return True - elif response in ['n', 'no']: - return False - print("Invalid input. Please enter 'y' or 'n'.") def main(): parser = argparse.ArgumentParser() From 81531bea64ff90b3b0897f535f4a989667fef24c Mon Sep 17 00:00:00 2001 From: btobers Date: Thu, 27 Feb 2025 19:22:46 -0500 Subject: [PATCH 05/29] config test created --- pygem/tests/test_config.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 pygem/tests/test_config.py diff --git a/pygem/tests/test_config.py b/pygem/tests/test_config.py new file mode 100644 index 00000000..1f4fa601 --- /dev/null +++ b/pygem/tests/test_config.py @@ -0,0 +1,24 @@ +""" +Python Glacier Evolution Model (PyGEM) + +copyright © 2018 David Rounce + +Distrubted under the MIT lisence +""" +from pygem.setup.config import ConfigManager + +def test_update_config(): + updates = { + "sim.nsims": "5", + "user.email": "updated@example.com", + "constants.density_ice": "850" + } + + config_manager = ConfigManager() + config_manager.ensure_config(overwrite=True) + config_manager.update_config(updates) + config = config_manager.read_config() + + assert config["sim"]["nsims"] == 5 + assert config["user"]["email"] == "updated@example.com" + assert config["constants"]["density_ice"] == 850 \ No newline at end of file From b6db8d8aad4ed484665ebafef75e57db17af33b9 Mon Sep 17 00:00:00 2001 From: "Brandon S. Tober" Date: Fri, 28 Feb 2025 09:28:22 -0500 Subject: [PATCH 06/29] update source_config_path Co-authored-by: Davor Dundovic <33790330+ddundo@users.noreply.github.com> --- pygem/setup/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 926e2123..4a093f71 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -18,7 +18,7 @@ def __init__(self, config_filename='config.yaml', base_dir=None): self.base_dir = base_dir or os.path.join(os.path.expanduser('~'), 'PyGEM') self.config_path = os.path.join(self.base_dir, self.config_filename) self.package_dir = os.path.dirname(__file__) - self.source_config_path = os.path.join(self.package_dir, self.config_filename) + self.source_config_path = os.path.join(os.path.abspath(__file__), "config.yaml") def ensure_config(self, overwrite=False): """Ensure the configuration file exists, creating or overwriting it if necessary""" From b981c1dd32c6e813c47f99a37fe8ae533385ebe0 Mon Sep 17 00:00:00 2001 From: btobers Date: Fri, 28 Feb 2025 09:29:40 -0500 Subject: [PATCH 07/29] remove self.package_dir definition --- pygem/setup/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 4a093f71..71a1ac8a 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -17,7 +17,6 @@ def __init__(self, config_filename='config.yaml', base_dir=None): self.config_filename = config_filename self.base_dir = base_dir or os.path.join(os.path.expanduser('~'), 'PyGEM') self.config_path = os.path.join(self.base_dir, self.config_filename) - self.package_dir = os.path.dirname(__file__) self.source_config_path = os.path.join(os.path.abspath(__file__), "config.yaml") def ensure_config(self, overwrite=False): From 4314f85541ffde75f13dce8eba7bda882dce3bd0 Mon Sep 17 00:00:00 2001 From: "Brandon S. Tober" Date: Fri, 28 Feb 2025 09:58:16 -0500 Subject: [PATCH 08/29] update source path Co-authored-by: Davor Dundovic <33790330+ddundo@users.noreply.github.com> --- pygem/setup/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 71a1ac8a..7118140e 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -17,7 +17,7 @@ def __init__(self, config_filename='config.yaml', base_dir=None): self.config_filename = config_filename self.base_dir = base_dir or os.path.join(os.path.expanduser('~'), 'PyGEM') self.config_path = os.path.join(self.base_dir, self.config_filename) - self.source_config_path = os.path.join(os.path.abspath(__file__), "config.yaml") + self.source_config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml") def ensure_config(self, overwrite=False): """Ensure the configuration file exists, creating or overwriting it if necessary""" From 4fd5493f1a9ef26b97e2bcf952a1e5f37c788ec1 Mon Sep 17 00:00:00 2001 From: btobers Date: Fri, 28 Feb 2025 10:14:00 -0500 Subject: [PATCH 09/29] rename ruamel.yaml.YAML() as ryaml --- pygem/setup/config.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 7118140e..8af85da5 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -39,9 +39,13 @@ def read_config(self): return yaml.safe_load(f) def update_config(self, updates): - """Update multiple keys in the YAML configuration file while preserving quotes and original types.""" - yaml = YAML() - yaml.preserve_quotes = True # Preserve quotes around string values + """Update multiple keys in the YAML configuration file while preserving quotes and original types. + + Args: + updates (dict): Dictionary with key-value pairs to be updated + """ + ryaml = YAML() + ryaml.preserve_quotes = True # Preserve quotes around string values with open(self.config_path, 'r') as file: config = yaml.load(file) @@ -65,11 +69,11 @@ def update_config(self, updates): if isinstance(d[final_key], dict): raise ValueError(f"Cannot directly overwrite key `{key}` because it contains a dictionary.") - d[final_key] = yaml.load(value) + d[final_key] = ryaml.load(value) # Save the updated config back to the file with open(self.config_path, 'w') as file: - yaml.dump(config, file) + ryaml.dump(config, file) def main(): parser = argparse.ArgumentParser() From 4308e79597ff9a9d68c4ec54867a643c1c94c94d Mon Sep 17 00:00:00 2001 From: btobers Date: Fri, 28 Feb 2025 10:14:29 -0500 Subject: [PATCH 10/29] call ensure_config() upon __init__ --- pygem/setup/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 8af85da5..bce651e6 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -18,7 +18,8 @@ def __init__(self, config_filename='config.yaml', base_dir=None): self.base_dir = base_dir or os.path.join(os.path.expanduser('~'), 'PyGEM') self.config_path = os.path.join(self.base_dir, self.config_filename) self.source_config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml") - + self.ensure_config() + def ensure_config(self, overwrite=False): """Ensure the configuration file exists, creating or overwriting it if necessary""" if not os.path.isfile(self.config_path) or overwrite: From 2922e6d75c2a168d69119333afaddcd2ef53e9f0 Mon Sep 17 00:00:00 2001 From: btobers Date: Fri, 28 Feb 2025 10:17:44 -0500 Subject: [PATCH 11/29] user config validation, make sure necessary keys exist --- pygem/setup/config.py | 51 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index bce651e6..c09c97b7 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -19,7 +19,7 @@ def __init__(self, config_filename='config.yaml', base_dir=None): self.config_path = os.path.join(self.base_dir, self.config_filename) self.source_config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml") self.ensure_config() - + def ensure_config(self, overwrite=False): """Ensure the configuration file exists, creating or overwriting it if necessary""" if not os.path.isfile(self.config_path) or overwrite: @@ -33,11 +33,20 @@ def create_config(self): os.makedirs(self.base_dir, exist_ok=True) shutil.copy(self.source_config_path, self.config_path) print(f"Copied default configuration to {self.config_path}") - - def read_config(self): - """Read the configuration file and return its contents as a dictionary.""" + + def read_config(self, validate=True): + """Read the configuration file and return its contents as a dictionary. + + Args: + validate (bool): Whether to compare with the default config + """ with open(self.config_path, 'r') as f: - return yaml.safe_load(f) + user_config = yaml.safe_load(f) + + if validate: + self.compare_with_source() + + return user_config def update_config(self, updates): """Update multiple keys in the YAML configuration file while preserving quotes and original types. @@ -76,6 +85,38 @@ def update_config(self, updates): with open(self.config_path, 'w') as file: ryaml.dump(config, file) + def compare_with_source(self): + """Compare the user's config with the default and raise errors for missing keys or type mismatches.""" + with open(self.source_config_path, 'r') as f: + default_config = yaml.safe_load(f) + with open(self.config_path, 'r') as f: + user_config = yaml.safe_load(f) + + def _check(ref, test, path=""): + if not isinstance(ref, dict) or not isinstance(test, dict): + return + + for key in ref: + current_path = f"{path}.{key}" if path else key + if key not in test: + raise ValueError(f"Missing key in user config: {current_path}") + + ref_val, test_val = ref[key], test[key] + + # Allow any type if ref[key] is None + if ref_val is not None: + # Ignore type mismatches if the source was a list but now a single value + if isinstance(ref_val, list) and not isinstance(test_val, list): + pass # Ignore type mismatch + elif type(ref_val) != type(test_val): + raise TypeError(f"Type mismatch at {current_path}: " + f"expected {type(ref_val)}, got {type(test_val)}") + + if isinstance(ref_val, dict): + _check(ref_val, test_val, current_path) + + _check(default_config, user_config) + def main(): parser = argparse.ArgumentParser() parser.add_argument('--config', default='config.yaml', help='Path to the config file') From 4cc29aca510c91571ae711656f384441bd17aa80 Mon Sep 17 00:00:00 2001 From: btobers Date: Fri, 28 Feb 2025 10:26:08 -0500 Subject: [PATCH 12/29] ConfigManager.ensure_config() calls removed, now handled on __init__ --- pygem/bin/op/duplicate_gdirs.py | 2 -- pygem/bin/op/initialize.py | 4 +--- pygem/bin/op/list_failed_simulations.py | 2 -- pygem/bin/postproc/postproc_binned_monthly_mass.py | 2 -- pygem/bin/postproc/postproc_compile_simulations.py | 2 -- pygem/bin/postproc/postproc_distribute_ice.py | 2 -- pygem/bin/postproc/postproc_monthly_mass.py | 2 -- pygem/bin/preproc/preproc_fetch_mbdata.py | 2 -- pygem/bin/preproc/preproc_wgms_estimate_kp.py | 2 -- pygem/bin/run/run_calibration.py | 2 -- pygem/bin/run/run_calibration_frontalablation.py | 2 -- pygem/bin/run/run_calibration_reg_glena.py | 2 -- pygem/bin/run/run_mcmc_priors.py | 2 -- pygem/bin/run/run_simulation.py | 2 -- pygem/setup/config.py | 7 ++++--- pygem/tests/test_config.py | 3 +-- 16 files changed, 6 insertions(+), 34 deletions(-) diff --git a/pygem/bin/op/duplicate_gdirs.py b/pygem/bin/op/duplicate_gdirs.py index 823d7f7a..a84a2f4b 100644 --- a/pygem/bin/op/duplicate_gdirs.py +++ b/pygem/bin/op/duplicate_gdirs.py @@ -14,8 +14,6 @@ from pygem.setup.config import ConfigManager # instantiate ConfigManager config_manager = ConfigManager() -# check for config -config_manager.ensure_config() # read the config pygem_prms = config_manager.read_config() diff --git a/pygem/bin/op/initialize.py b/pygem/bin/op/initialize.py index 0a0822e8..ead82679 100644 --- a/pygem/bin/op/initialize.py +++ b/pygem/bin/op/initialize.py @@ -13,9 +13,7 @@ import shutil from pygem.setup.config import ConfigManager # instantiate ConfigManager -config_manager = ConfigManager() -# check for config -config_manager.ensure_config(overwrite=True) +config_manager = ConfigManager(overwrite=True) # read the config pygem_prms = config_manager.read_config() diff --git a/pygem/bin/op/list_failed_simulations.py b/pygem/bin/op/list_failed_simulations.py index fa2c1363..707b1f12 100644 --- a/pygem/bin/op/list_failed_simulations.py +++ b/pygem/bin/op/list_failed_simulations.py @@ -18,8 +18,6 @@ from pygem.setup.config import ConfigManager # instantiate ConfigManager config_manager = ConfigManager() -# check for config -config_manager.ensure_config() # read the config pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup diff --git a/pygem/bin/postproc/postproc_binned_monthly_mass.py b/pygem/bin/postproc/postproc_binned_monthly_mass.py index a9888d7a..c72961ff 100644 --- a/pygem/bin/postproc/postproc_binned_monthly_mass.py +++ b/pygem/bin/postproc/postproc_binned_monthly_mass.py @@ -24,8 +24,6 @@ from pygem.setup.config import ConfigManager # instantiate ConfigManager config_manager = ConfigManager() -# check for config -config_manager.ensure_config() # read the config pygem_prms = config_manager.read_config() diff --git a/pygem/bin/postproc/postproc_compile_simulations.py b/pygem/bin/postproc/postproc_compile_simulations.py index f399b73f..8b998626 100644 --- a/pygem/bin/postproc/postproc_compile_simulations.py +++ b/pygem/bin/postproc/postproc_compile_simulations.py @@ -24,8 +24,6 @@ from pygem.setup.config import ConfigManager # instantiate ConfigManager config_manager = ConfigManager() -# check for config -config_manager.ensure_config() # read the config pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup diff --git a/pygem/bin/postproc/postproc_distribute_ice.py b/pygem/bin/postproc/postproc_distribute_ice.py index 3832470f..cf350f8f 100644 --- a/pygem/bin/postproc/postproc_distribute_ice.py +++ b/pygem/bin/postproc/postproc_distribute_ice.py @@ -27,8 +27,6 @@ from pygem.setup.config import ConfigManager # instantiate ConfigManager config_manager = ConfigManager() -# check for config -config_manager.ensure_config() # read the config pygem_prms = config_manager.read_config() import pygem diff --git a/pygem/bin/postproc/postproc_monthly_mass.py b/pygem/bin/postproc/postproc_monthly_mass.py index dff1d66f..972bb570 100644 --- a/pygem/bin/postproc/postproc_monthly_mass.py +++ b/pygem/bin/postproc/postproc_monthly_mass.py @@ -28,8 +28,6 @@ from pygem.setup.config import ConfigManager # instantiate ConfigManager config_manager = ConfigManager() -# check for config -config_manager.ensure_config() # read the config pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup diff --git a/pygem/bin/preproc/preproc_fetch_mbdata.py b/pygem/bin/preproc/preproc_fetch_mbdata.py index b7e381e7..c2282c22 100644 --- a/pygem/bin/preproc/preproc_fetch_mbdata.py +++ b/pygem/bin/preproc/preproc_fetch_mbdata.py @@ -22,8 +22,6 @@ from pygem.setup.config import ConfigManager # instantiate ConfigManager config_manager = ConfigManager() -# check for config -config_manager.ensure_config() # read the config pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup diff --git a/pygem/bin/preproc/preproc_wgms_estimate_kp.py b/pygem/bin/preproc/preproc_wgms_estimate_kp.py index e08ab8a6..0cabd84c 100644 --- a/pygem/bin/preproc/preproc_wgms_estimate_kp.py +++ b/pygem/bin/preproc/preproc_wgms_estimate_kp.py @@ -25,8 +25,6 @@ from pygem.setup.config import ConfigManager # instantiate ConfigManager config_manager = ConfigManager() -# check for config -config_manager.ensure_config() # read the config pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup diff --git a/pygem/bin/run/run_calibration.py b/pygem/bin/run/run_calibration.py index da2f3ae0..d78378bb 100755 --- a/pygem/bin/run/run_calibration.py +++ b/pygem/bin/run/run_calibration.py @@ -34,8 +34,6 @@ from pygem.setup.config import ConfigManager # instantiate ConfigManager config_manager = ConfigManager() -# check for config -config_manager.ensure_config() # read the config pygem_prms = config_manager.read_config() from pygem import mcmc diff --git a/pygem/bin/run/run_calibration_frontalablation.py b/pygem/bin/run/run_calibration_frontalablation.py index 7d114d9e..6b3a5fb3 100644 --- a/pygem/bin/run/run_calibration_frontalablation.py +++ b/pygem/bin/run/run_calibration_frontalablation.py @@ -27,8 +27,6 @@ from pygem.setup.config import ConfigManager # instantiate ConfigManager config_manager = ConfigManager() -# check for config -config_manager.ensure_config() # read the config pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup diff --git a/pygem/bin/run/run_calibration_reg_glena.py b/pygem/bin/run/run_calibration_reg_glena.py index acc4aaa6..18e5ca16 100644 --- a/pygem/bin/run/run_calibration_reg_glena.py +++ b/pygem/bin/run/run_calibration_reg_glena.py @@ -25,8 +25,6 @@ from pygem.setup.config import ConfigManager # instantiate ConfigManager config_manager = ConfigManager() -# check for config -config_manager.ensure_config() # read the config pygem_prms = config_manager.read_config() from pygem import class_climate diff --git a/pygem/bin/run/run_mcmc_priors.py b/pygem/bin/run/run_mcmc_priors.py index be3ebcce..04fc088f 100644 --- a/pygem/bin/run/run_mcmc_priors.py +++ b/pygem/bin/run/run_mcmc_priors.py @@ -16,8 +16,6 @@ from pygem.setup.config import ConfigManager # instantiate ConfigManager config_manager = ConfigManager() -# check for config -config_manager.ensure_config() # read the config pygem_prms = config_manager.read_config() import pygem.pygem_modelsetup as modelsetup diff --git a/pygem/bin/run/run_simulation.py b/pygem/bin/run/run_simulation.py index 33922f2f..e1492e6c 100755 --- a/pygem/bin/run/run_simulation.py +++ b/pygem/bin/run/run_simulation.py @@ -38,8 +38,6 @@ from pygem.setup.config import ConfigManager # instantiate ConfigManager config_manager = ConfigManager() -# check for config -config_manager.ensure_config() # read the config pygem_prms = config_manager.read_config() import pygem.gcmbiasadj as gcmbiasadj diff --git a/pygem/setup/config.py b/pygem/setup/config.py index c09c97b7..dcf10228 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -12,17 +12,18 @@ from ruamel.yaml import YAML class ConfigManager: - def __init__(self, config_filename='config.yaml', base_dir=None): + def __init__(self, config_filename='config.yaml', base_dir=None, overwrite=False): """initialize the ConfigManager class""" self.config_filename = config_filename self.base_dir = base_dir or os.path.join(os.path.expanduser('~'), 'PyGEM') self.config_path = os.path.join(self.base_dir, self.config_filename) self.source_config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml") + self.overwrite = overwrite self.ensure_config() - def ensure_config(self, overwrite=False): + def ensure_config(self): """Ensure the configuration file exists, creating or overwriting it if necessary""" - if not os.path.isfile(self.config_path) or overwrite: + if not os.path.isfile(self.config_path) or self.overwrite: self.create_config() def create_config(self): diff --git a/pygem/tests/test_config.py b/pygem/tests/test_config.py index 1f4fa601..c00240b7 100644 --- a/pygem/tests/test_config.py +++ b/pygem/tests/test_config.py @@ -14,8 +14,7 @@ def test_update_config(): "constants.density_ice": "850" } - config_manager = ConfigManager() - config_manager.ensure_config(overwrite=True) + config_manager = ConfigManager(overwrite=True) config_manager.update_config(updates) config = config_manager.read_config() From d3d20920109bed306a928dc5e1e84e6c470af8b6 Mon Sep 17 00:00:00 2001 From: btobers Date: Fri, 28 Feb 2025 10:36:02 -0500 Subject: [PATCH 13/29] bug fix with commit:4fd5493 --- pygem/setup/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index dcf10228..653a1b16 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -59,7 +59,7 @@ def update_config(self, updates): ryaml.preserve_quotes = True # Preserve quotes around string values with open(self.config_path, 'r') as file: - config = yaml.load(file) + config = ryaml.load(file) for key, value in updates.items(): keys = key.split('.') From 197ea6d41f377ce88b040d03d7ef155caf5c9da0 Mon Sep 17 00:00:00 2001 From: btobers Date: Fri, 28 Feb 2025 11:12:50 -0500 Subject: [PATCH 14/29] more tests added to test_config --- pygem/tests/test_config.py | 97 +++++++++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/pygem/tests/test_config.py b/pygem/tests/test_config.py index c00240b7..6dec1772 100644 --- a/pygem/tests/test_config.py +++ b/pygem/tests/test_config.py @@ -5,8 +5,32 @@ Distrubted under the MIT lisence """ +import os, yaml from pygem.setup.config import ConfigManager +# Test case to check if the configuration is created or overwritten correctly +def test_ensure_config(): + config_manager = ConfigManager(overwrite=True) + config_manager.ensure_config() + + # Check if the config file is created + assert os.path.isfile(config_manager.config_path) + + # Check if the default config file was copied correctly + with open(config_manager.config_path, 'r') as f: + config = f.read() + assert "sim" in config # Check if a known key exists in the config + + # Test without overwriting + config_manager_no_overwrite = ConfigManager(overwrite=False) + config_manager_no_overwrite.ensure_config() + + # The config should not be overwritten if it already exists + with open(config_manager_no_overwrite.config_path, 'r') as f: + config_no_overwrite = f.read() + assert config_no_overwrite == config # No change should happen + +# Test case to verify updating the config def test_update_config(): updates = { "sim.nsims": "5", @@ -20,4 +44,75 @@ def test_update_config(): assert config["sim"]["nsims"] == 5 assert config["user"]["email"] == "updated@example.com" - assert config["constants"]["density_ice"] == 850 \ No newline at end of file + assert config["constants"]["density_ice"] == 850 + +# Test case to check if the `read_config` function works +def test_read_config(): + config_manager = ConfigManager(overwrite=True) + config_manager.ensure_config() + config = config_manager.read_config() + + # Check that the config reads a known key correctly + assert "sim" in config + assert isinstance(config["sim"], dict) + +# Test case to check if the `compare_with_source` function works +def test_compare_with_source(): + config_manager = ConfigManager(overwrite=True) + config_manager.ensure_config() + + # Modify the config file to simulate an error (e.g., remove a required key) + config = config_manager.read_config() + config["sim"].pop("nsims", None) # Remove a required key + + # Write the modified config back to the file + with open(config_manager.config_path, 'w') as f: + yaml.dump(config, f) + + # Test if it raises a ValueError when comparing with the source config + try: + config_manager.compare_with_source() + assert False, "Expected ValueError due to missing key" + except ValueError: + pass # Expected error, test passes + +# Test case for wrong type key-value overwrite +def test_update_config_wrong_type(): + # Test for overwriting a dict with a non-dict value + updates = { + "sim": "invalid_value" # Trying to overwrite a dict with a non-dict value + } + + config_manager = ConfigManager(overwrite=True) + + try: + config_manager.update_config(updates) + assert False, "Expected ValueError due to non-dict overwrite" + except ValueError: + pass # Expected error, test passes + + # Test for attempting to overwrite the 'root' key (top-level structure) with a non-dict value + updates_root = { + "root": "invalid_root_value" # Trying to overwrite the root of the config with a non-dict value + } + + try: + config_manager.update_config(updates_root) + assert False, "Expected ValueError due to overwriting the root with a non-dict value" + except ValueError: + pass # Expected error, test passes + +# Test the `create_config` function when the source config doesn't exist +def test_create_config_missing_source(): + # Simulate the absence of the source config by removing it if it exists + source_config_path = ConfigManager().source_config_path + if os.path.exists(source_config_path): + os.remove(source_config_path) + + config_manager = ConfigManager(overwrite=True) + + try: + config_manager.create_config() + assert False, "Expected FileNotFoundError due to missing source config" + except FileNotFoundError: + pass # Expected error, test passes \ No newline at end of file From 441da219f8639736001bb7e094770bde672e17a2 Mon Sep 17 00:00:00 2001 From: btobers Date: Fri, 28 Feb 2025 11:42:34 -0500 Subject: [PATCH 15/29] more tests and TypeError checks in update_config --- pygem/setup/config.py | 11 ++- pygem/tests/test_config.py | 155 +++++++++++++++---------------------- 2 files changed, 70 insertions(+), 96 deletions(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 653a1b16..04f81349 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -75,11 +75,16 @@ def update_config(self, updates): # Ensure the final key exists before updating its value if final_key not in d: raise KeyError(f"No matching `{key}` key found in the configuration file at path: {self.config_path}") - # Prevent replacing a dictionary with a non-dictionary value if isinstance(d[final_key], dict): - raise ValueError(f"Cannot directly overwrite key `{key}` because it contains a dictionary.") - + raise TypeError(f"Cannot directly overwrite key `{key}` because it contains a dictionary.") + # Check if the original value is a string, and raise an error if a non-string type is passed + if isinstance(d[final_key], str) and not isinstance(value, str): + raise TypeError(f"Cannot update `{key}` with a non-string value: expected a string.") + # Check if the original value is a string, and raise an error if a non-string type is passed + if isinstance(d[final_key], bool) and not isinstance(value, bool): + raise TypeError(f"Cannot update `{key}` with a non-bool value: expected a bool.") + d[final_key] = ryaml.load(value) # Save the updated config back to the file diff --git a/pygem/tests/test_config.py b/pygem/tests/test_config.py index 6dec1772..f49a5009 100644 --- a/pygem/tests/test_config.py +++ b/pygem/tests/test_config.py @@ -1,118 +1,87 @@ -""" -Python Glacier Evolution Model (PyGEM) - -copyright © 2018 David Rounce - -Distrubted under the MIT lisence -""" -import os, yaml +import os +import shutil from pygem.setup.config import ConfigManager -# Test case to check if the configuration is created or overwritten correctly -def test_ensure_config(): - config_manager = ConfigManager(overwrite=True) - config_manager.ensure_config() - - # Check if the config file is created - assert os.path.isfile(config_manager.config_path) +def test_create_config(): + """Test that create_config creates the config file.""" + test_dir = os.path.join(os.getcwd(), "test_config") + os.makedirs(test_dir, exist_ok=True) + config_manager = ConfigManager(config_filename='config.yaml', base_dir=test_dir, overwrite=True) - # Check if the default config file was copied correctly - with open(config_manager.config_path, 'r') as f: - config = f.read() - assert "sim" in config # Check if a known key exists in the config - - # Test without overwriting - config_manager_no_overwrite = ConfigManager(overwrite=False) - config_manager_no_overwrite.ensure_config() - - # The config should not be overwritten if it already exists - with open(config_manager_no_overwrite.config_path, 'r') as f: - config_no_overwrite = f.read() - assert config_no_overwrite == config # No change should happen + config_manager.create_config() + assert os.path.isfile(config_manager.config_path), "Config file was not created" + + # Clean up + shutil.rmtree(test_dir) -# Test case to verify updating the config def test_update_config(): + """Test updating keys in the config file.""" + test_dir = os.path.join(os.getcwd(), "test_config") + os.makedirs(test_dir, exist_ok=True) + config_manager = ConfigManager(config_filename='config.yaml', base_dir=test_dir, overwrite=True) + updates = { "sim.nsims": "5", "user.email": "updated@example.com", "constants.density_ice": "850" } - config_manager = ConfigManager(overwrite=True) config_manager.update_config(updates) config = config_manager.read_config() - + + # Assert the updates were made correctly assert config["sim"]["nsims"] == 5 assert config["user"]["email"] == "updated@example.com" assert config["constants"]["density_ice"] == 850 - -# Test case to check if the `read_config` function works -def test_read_config(): - config_manager = ConfigManager(overwrite=True) - config_manager.ensure_config() - config = config_manager.read_config() - - # Check that the config reads a known key correctly - assert "sim" in config - assert isinstance(config["sim"], dict) - -# Test case to check if the `compare_with_source` function works -def test_compare_with_source(): - config_manager = ConfigManager(overwrite=True) - config_manager.ensure_config() - # Modify the config file to simulate an error (e.g., remove a required key) - config = config_manager.read_config() - config["sim"].pop("nsims", None) # Remove a required key - - # Write the modified config back to the file - with open(config_manager.config_path, 'w') as f: - yaml.dump(config, f) - - # Test if it raises a ValueError when comparing with the source config - try: - config_manager.compare_with_source() - assert False, "Expected ValueError due to missing key" - except ValueError: - pass # Expected error, test passes + # Clean up + shutil.rmtree(test_dir) -# Test case for wrong type key-value overwrite -def test_update_config_wrong_type(): - # Test for overwriting a dict with a non-dict value - updates = { - "sim": "invalid_value" # Trying to overwrite a dict with a non-dict value - } - - config_manager = ConfigManager(overwrite=True) +def test_update_config_key_error(): + """Test that update_config raises an error if a key doesn't exist.""" + test_dir = os.path.join(os.getcwd(), "test_config") + os.makedirs(test_dir, exist_ok=True) + config_manager = ConfigManager(config_filename='config.yaml', base_dir=test_dir, overwrite=True) try: - config_manager.update_config(updates) - assert False, "Expected ValueError due to non-dict overwrite" - except ValueError: - pass # Expected error, test passes - - # Test for attempting to overwrite the 'root' key (top-level structure) with a non-dict value - updates_root = { - "root": "invalid_root_value" # Trying to overwrite the root of the config with a non-dict value - } + config_manager.update_config({"nonexistent.key": "value"}) + except KeyError: + pass # Expected error + else: + assert False, "KeyError not raised" + + # Clean up + shutil.rmtree(test_dir) +def test_update_config_type_error(): + """Test that update_config raises an error if there is a type mismatch.""" + test_dir = os.path.join(os.getcwd(), "test_config") + os.makedirs(test_dir, exist_ok=True) + config_manager = ConfigManager(config_filename='config.yaml', base_dir=test_dir, overwrite=True) + + # try setting a dictionary key-value with an value that is not a dictionary try: - config_manager.update_config(updates_root) - assert False, "Expected ValueError due to overwriting the root with a non-dict value" - except ValueError: - pass # Expected error, test passes - -# Test the `create_config` function when the source config doesn't exist -def test_create_config_missing_source(): - # Simulate the absence of the source config by removing it if it exists - source_config_path = ConfigManager().source_config_path - if os.path.exists(source_config_path): - os.remove(source_config_path) + config_manager.update_config({"sim": "not a dict"}) + except TypeError: + pass # Expected error + else: + assert False, "TypeError not raised" - config_manager = ConfigManager(overwrite=True) + # try setting a bool key-value type with a non-bool value type + try: + config_manager.update_config({"setup.include_tidewater": -999}) + except TypeError: + pass # Expected error + else: + assert False, "TypeError not raised" + # try setting a string key-value type with a non-string value type try: - config_manager.create_config() - assert False, "Expected FileNotFoundError due to missing source config" - except FileNotFoundError: - pass # Expected error, test passes \ No newline at end of file + config_manager.update_config({"root": -999}) + except TypeError: + pass # Expected error + else: + assert False, "TypeError not raised" + + # Clean up + shutil.rmtree(test_dir) \ No newline at end of file From ceb00c0e8027252cd0e3218f6069e61bafcc705d Mon Sep 17 00:00:00 2001 From: btobers Date: Fri, 28 Feb 2025 12:24:15 -0500 Subject: [PATCH 16/29] test ensure config, no overwrite --- pygem/tests/test_config.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pygem/tests/test_config.py b/pygem/tests/test_config.py index f49a5009..6aa19080 100644 --- a/pygem/tests/test_config.py +++ b/pygem/tests/test_config.py @@ -14,6 +14,34 @@ def test_create_config(): # Clean up shutil.rmtree(test_dir) +def test_ensure_config_no_overwrite(): + """Test that create_config does not overwrite the existing config file when overwrite=False.""" + test_dir = os.path.join(os.getcwd(), "test_config") + os.makedirs(test_dir, exist_ok=True) + + # First, create the config file with overwrite=True + config_manager = ConfigManager(config_filename='config.yaml', base_dir=test_dir, overwrite=True) + config_manager.create_config() # This will create the file + + # Now, try initializing ConfigManager without overwrite=True + config_manager_no_overwrite = ConfigManager(config_filename='config.yaml', base_dir=test_dir, overwrite=False) + + # Try to ensure the config - it should not overwrite the existing file + try: + config_manager_no_overwrite.ensure_config() + # If it doesn't raise any exceptions, check that the file exists and is not modified + assert os.path.isfile(config_manager_no_overwrite.config_path), "Config file should exist" + original_mtime = os.path.getmtime(config_manager.config_path) + + # Trigger ensure_config again to confirm it does not modify the existing file + config_manager_no_overwrite.ensure_config() + assert os.path.getmtime(config_manager_no_overwrite.config_path) == original_mtime, "Config file should not be modified" + except Exception as e: + assert False, f"Unexpected error: {e}" + + # Clean up + shutil.rmtree(test_dir) + def test_update_config(): """Test updating keys in the config file.""" test_dir = os.path.join(os.getcwd(), "test_config") From 0b4b1624dbbb671cb83e5bd9f27efb699749a9d6 Mon Sep 17 00:00:00 2001 From: btobers Date: Fri, 28 Feb 2025 14:42:57 -0500 Subject: [PATCH 17/29] remove CLI update_config funcitonality --- pygem/setup/config.py | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 04f81349..03f1f604 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -121,23 +121,4 @@ def _check(ref, test, path=""): if isinstance(ref_val, dict): _check(ref_val, test_val, current_path) - _check(default_config, user_config) - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument('--config', default='config.yaml', help='Path to the config file') - parser.add_argument('updates', nargs='*', help='Key-value pairs to update in the config file') - - args = parser.parse_args() - - # Parse the updates into a dictionary - updates = {} - for update in args.updates: - key, value = update.split('=') - updates[key] = value - - config_manager = ConfigManager(config_filename=args.config) - config_manager.update_config(updates) - -if __name__ == '__main__': - main() \ No newline at end of file + _check(default_config, user_config) \ No newline at end of file From 9b9dce2ec157ed30e7ccdca4e7f4ad251823d4ea Mon Sep 17 00:00:00 2001 From: Davor Dundovic <33790330+ddundo@users.noreply.github.com> Date: Sun, 2 Mar 2025 19:27:57 +0000 Subject: [PATCH 18/29] Handle datatypes in `config.yaml` and expand tests (#79) --- .github/workflows/test_suite.yml | 59 +++++ pygem/setup/config.py | 372 ++++++++++++++++++++++++------- pygem/tests/test_config.py | 224 ++++++++++--------- pygem/tests/test_notebooks.py | 20 ++ pygem/tests/test_oggm_compat.py | 78 ------- pyproject.toml | 10 +- 6 files changed, 502 insertions(+), 261 deletions(-) create mode 100644 .github/workflows/test_suite.yml create mode 100644 pygem/tests/test_notebooks.py delete mode 100755 pygem/tests/test_oggm_compat.py diff --git a/.github/workflows/test_suite.yml b/.github/workflows/test_suite.yml new file mode 100644 index 00000000..a0692c85 --- /dev/null +++ b/.github/workflows/test_suite.yml @@ -0,0 +1,59 @@ +name: 'Install PyGEM and Run Test Suite' + +on: + push: + branches: + - master + - dev + paths: + - '**.py' + - '.github/workflows/test_suite.yml' + - 'pyproject.toml' + + pull_request: + paths: + - '**.py' + - '.github/workflows/test_suite.yml' + - 'pyproject.toml' + + # Run test suite every Saturday at 1AM GMT (1 hour after the Docker image is updated) + schedule: + - cron: '0 1 * * 6' + +# Stop the workflow if a new one is started +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test_suite: + name: 'Test suite' + runs-on: ubuntu-latest + container: + # Use pygem:latest for master branch and pygem:dev otherwise + image: ghcr.io/pygem-community/pygem:${{ github.ref == 'refs/heads/master' && 'latest' || 'dev' }} + options: --user root + env: + # Since we are root we need to set PYTHONPATH to be able to find the installed packages + PYTHONPATH: /home/ubuntu/.local/lib/python3.12/site-packages + + steps: + - name: 'Checkout the PyGEM repo' + id: checkout + uses: actions/checkout@v4 + + - name: 'Reinstall PyGEM' + run: pip install --break-system-packages -e . + + - name: 'Initialize PyGEM' + run: initialize + + - name: 'Clone the PyGEM-notebooks repo' + run: | + git clone -b 11_config_update --depth 1 https://github.com/pygem-community/PyGEM-notebooks.git + echo "PYGEM_NOTEBOOKS_DIRPATH=$(pwd)/PyGEM-notebooks" >> $GITHUB_ENV + + - name: 'Run tests' + run: | + python3 -m coverage erase + python3 -m pytest --cov=pygem -v --durations=20 pygem/tests diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 03f1f604..1e793ab3 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -9,7 +9,7 @@ import shutil import yaml import argparse -from ruamel.yaml import YAML +import ruamel.yaml class ConfigManager: def __init__(self, config_filename='config.yaml', base_dir=None, overwrite=False): @@ -24,101 +24,319 @@ def __init__(self, config_filename='config.yaml', base_dir=None, overwrite=False def ensure_config(self): """Ensure the configuration file exists, creating or overwriting it if necessary""" if not os.path.isfile(self.config_path) or self.overwrite: - self.create_config() + self.copy_source_config() - def create_config(self): + def copy_source_config(self): """Copy the default configuration file to the expected location""" - if not os.path.exists(self.source_config_path): - raise FileNotFoundError(f"Default config file not found at {self.source_config_path}, there may have been an installation issue") - + os.makedirs(self.base_dir, exist_ok=True) shutil.copy(self.source_config_path, self.config_path) print(f"Copied default configuration to {self.config_path}") def read_config(self, validate=True): - """Read the configuration file and return its contents as a dictionary. - - Args: - validate (bool): Whether to compare with the default config - """ + """Read the configuration file and return its contents as a dictionary while preserving formatting.""" + ryaml = ruamel.yaml.YAML() with open(self.config_path, 'r') as f: - user_config = yaml.safe_load(f) + user_config = ryaml.load(f) # Using ruamel.yaml for preservation if validate: - self.compare_with_source() + self.validate_config(user_config) return user_config + + def write_config(self, config): + """Write the configuration dictionary to the file while preserving quotes.""" + ryaml = ruamel.yaml.YAML() + ryaml.preserve_quotes = True + with open(self.config_path, 'w') as file: + ryaml.dump(config, file) # This will preserve quotes def update_config(self, updates): - """Update multiple keys in the YAML configuration file while preserving quotes and original types. - - Args: - updates (dict): Dictionary with key-value pairs to be updated - """ - ryaml = YAML() - ryaml.preserve_quotes = True # Preserve quotes around string values - - with open(self.config_path, 'r') as file: - config = ryaml.load(file) - + """Update multiple keys in the YAML configuration file while preserving quotes and original types.""" + config = self.read_config(validate=False) # Read existing config + for key, value in updates.items(): + if key not in self.EXPECTED_TYPES: + raise KeyError(f"Unrecognized configuration key: {key}") keys = key.split('.') d = config - # Traverse the keys up to the second-to-last - for i, k in enumerate(keys[:-1]): - if k not in d: - raise KeyError(f"No matching `{'.'.join(keys[:i+1])}` key found in the configuration file at path: {self.config_path}") - d = d[k] - - final_key = keys[-1] + for sub_key in keys[:-1]: + d = d[sub_key] - # Ensure the final key exists before updating its value - if final_key not in d: - raise KeyError(f"No matching `{key}` key found in the configuration file at path: {self.config_path}") - # Prevent replacing a dictionary with a non-dictionary value - if isinstance(d[final_key], dict): - raise TypeError(f"Cannot directly overwrite key `{key}` because it contains a dictionary.") - # Check if the original value is a string, and raise an error if a non-string type is passed - if isinstance(d[final_key], str) and not isinstance(value, str): - raise TypeError(f"Cannot update `{key}` with a non-string value: expected a string.") - # Check if the original value is a string, and raise an error if a non-string type is passed - if isinstance(d[final_key], bool) and not isinstance(value, bool): - raise TypeError(f"Cannot update `{key}` with a non-bool value: expected a bool.") - - d[final_key] = ryaml.load(value) - - # Save the updated config back to the file - with open(self.config_path, 'w') as file: - ryaml.dump(config, file) - - def compare_with_source(self): - """Compare the user's config with the default and raise errors for missing keys or type mismatches.""" - with open(self.source_config_path, 'r') as f: - default_config = yaml.safe_load(f) - with open(self.config_path, 'r') as f: - user_config = yaml.safe_load(f) - - def _check(ref, test, path=""): - if not isinstance(ref, dict) or not isinstance(test, dict): - return - - for key in ref: - current_path = f"{path}.{key}" if path else key - if key not in test: - raise ValueError(f"Missing key in user config: {current_path}") + d[keys[-1]] = value + + self.validate_config(config) + self.write_config(config) + + def validate_config(self, data): + """Validate the configuration file against expected types and required keys""" + for key, expected_type in self.EXPECTED_TYPES.items(): + keys = key.split(".") + sub_data = data + for sub_key in keys: + if isinstance(sub_data, dict) and sub_key in sub_data: + sub_data = sub_data[sub_key] + else: + raise KeyError(f"Missing required key in configuration: {key}") - ref_val, test_val = ref[key], test[key] + if not isinstance(sub_data, expected_type): + raise TypeError(f"Invalid type for '{key}': expected {expected_type}, not {type(sub_data)}") - # Allow any type if ref[key] is None - if ref_val is not None: - # Ignore type mismatches if the source was a list but now a single value - if isinstance(ref_val, list) and not isinstance(test_val, list): - pass # Ignore type mismatch - elif type(ref_val) != type(test_val): - raise TypeError(f"Type mismatch at {current_path}: " - f"expected {type(ref_val)}, got {type(test_val)}") + # Check elements inside lists (if defined) + if key in self.LIST_ELEMENT_TYPES and isinstance(sub_data, list): + elem_type = self.LIST_ELEMENT_TYPES[key] + if not all(isinstance(item, elem_type) for item in sub_data): + raise TypeError(f"Invalid type for elements in '{key}': expected all elements to be {elem_type}, but got {sub_data}") + - if isinstance(ref_val, dict): - _check(ref_val, test_val, current_path) + # expected config types + EXPECTED_TYPES = { + "root": str, + "user": dict, + "user.name": (str, type(None)), + "user.institution": (str, type(None)), + "user.email": (str, type(None)), + "setup": dict, + "setup.rgi_region01": list, + "setup.rgi_region02": str, + "setup.glac_no_skip": (list, type(None)), + "setup.glac_no": (list, type(None)), + "setup.min_glac_area_km2": int, + "setup.include_landterm": bool, + "setup.include_laketerm": bool, + "setup.include_tidewater": bool, + "setup.include_frontalablation": bool, + "oggm": dict, + "oggm.base_url": str, + "oggm.logging_level": str, + "oggm.border": int, + "oggm.oggm_gdir_relpath": str, + "oggm.overwrite_gdirs": bool, + "oggm.has_internet": bool, + "climate": dict, + "climate.ref_gcm_name": str, + "climate.ref_startyear": int, + "climate.ref_endyear": int, + "climate.ref_wateryear": str, + "climate.ref_spinupyears": int, + "climate.gcm_name": str, + "climate.scenario": (str, type(None)), + "climate.gcm_startyear": int, + "climate.gcm_endyear": int, + "climate.gcm_wateryear": str, + "climate.constantarea_years": int, + "climate.gcm_spinupyears": int, + "climate.hindcast": bool, + "climate.paths": dict, + "climate.paths.era5_relpath": str, + "climate.paths.era5_temp_fn": str, + "climate.paths.era5_tempstd_fn": str, + "climate.paths.era5_prec_fn": str, + "climate.paths.era5_elev_fn": str, + "climate.paths.era5_pressureleveltemp_fn": str, + "climate.paths.era5_lr_fn": str, + "climate.paths.cmip5_relpath": str, + "climate.paths.cmip5_fp_var_ending": str, + "climate.paths.cmip5_fp_fx_ending": str, + "climate.paths.cmip6_relpath": str, + "climate.paths.cesm2_relpath": str, + "climate.paths.cesm2_fp_var_ending": str, + "climate.paths.cesm2_fp_fx_ending": str, + "climate.paths.gfdl_relpath": str, + "climate.paths.gfdl_fp_var_ending": str, + "climate.paths.gfdl_fp_fx_ending": str, + "calib": dict, + "calib.option_calibration": str, + "calib.priors_reg_fn": str, + "calib.HH2015_params": dict, + "calib.HH2015_params.tbias_init": int, + "calib.HH2015_params.tbias_step": int, + "calib.HH2015_params.kp_init": float, + "calib.HH2015_params.kp_bndlow": float, + "calib.HH2015_params.kp_bndhigh": int, + "calib.HH2015_params.ddfsnow_init": float, + "calib.HH2015_params.ddfsnow_bndlow": float, + "calib.HH2015_params.ddfsnow_bndhigh": float, + "calib.HH2015mod_params": dict, + "calib.HH2015mod_params.tbias_init": int, + "calib.HH2015mod_params.tbias_step": float, + "calib.HH2015mod_params.kp_init": int, + "calib.HH2015mod_params.kp_bndlow": float, + "calib.HH2015mod_params.kp_bndhigh": int, + "calib.HH2015mod_params.ddfsnow_init": float, + "calib.HH2015mod_params.method_opt": str, + "calib.HH2015mod_params.params2opt": list, + "calib.HH2015mod_params.ftol_opt": float, + "calib.HH2015mod_params.eps_opt": float, + "calib.emulator_params": dict, + "calib.emulator_params.emulator_sims": int, + "calib.emulator_params.overwrite_em_sims": bool, + "calib.emulator_params.opt_hh2015_mod": bool, + "calib.emulator_params.tbias_step": float, + "calib.emulator_params.tbias_init": int, + "calib.emulator_params.kp_init": int, + "calib.emulator_params.kp_bndlow": float, + "calib.emulator_params.kp_bndhigh": int, + "calib.emulator_params.ddfsnow_init": float, + "calib.emulator_params.option_areaconstant": bool, + "calib.emulator_params.tbias_disttype": str, + "calib.emulator_params.tbias_sigma": int, + "calib.emulator_params.kp_gamma_alpha": int, + "calib.emulator_params.kp_gamma_beta": int, + "calib.emulator_params.ddfsnow_disttype": str, + "calib.emulator_params.ddfsnow_mu": float, + "calib.emulator_params.ddfsnow_sigma": float, + "calib.emulator_params.ddfsnow_bndlow": int, + "calib.emulator_params.ddfsnow_bndhigh": float, + "calib.emulator_params.method_opt": str, + "calib.emulator_params.params2opt": list, + "calib.emulator_params.ftol_opt": float, + "calib.emulator_params.eps_opt": float, + "calib.MCMC_params": dict, + "calib.MCMC_params.option_use_emulator": bool, + "calib.MCMC_params.emulator_sims": int, + "calib.MCMC_params.tbias_step": float, + "calib.MCMC_params.tbias_stepsmall": float, + "calib.MCMC_params.option_areaconstant": bool, + "calib.MCMC_params.mcmc_step": float, + "calib.MCMC_params.n_chains": int, + "calib.MCMC_params.mcmc_sample_no": int, + "calib.MCMC_params.mcmc_burn_pct": int, + "calib.MCMC_params.thin_interval": int, + "calib.MCMC_params.ddfsnow_disttype": str, + "calib.MCMC_params.ddfsnow_mu": float, + "calib.MCMC_params.ddfsnow_sigma": float, + "calib.MCMC_params.ddfsnow_bndlow": int, + "calib.MCMC_params.ddfsnow_bndhigh": float, + "calib.MCMC_params.kp_disttype": str, + "calib.MCMC_params.tbias_disttype": str, + "calib.MCMC_params.tbias_mu": int, + "calib.MCMC_params.tbias_sigma": int, + "calib.MCMC_params.tbias_bndlow": int, + "calib.MCMC_params.tbias_bndhigh": int, + "calib.MCMC_params.kp_gamma_alpha": int, + "calib.MCMC_params.kp_gamma_beta": int, + "calib.MCMC_params.kp_lognorm_mu": int, + "calib.MCMC_params.kp_lognorm_tau": int, + "calib.MCMC_params.kp_mu": int, + "calib.MCMC_params.kp_sigma": float, + "calib.MCMC_params.kp_bndlow": float, + "calib.MCMC_params.kp_bndhigh": float, + "calib.data": dict, + "calib.data.massbalance": dict, + "calib.data.massbalance.hugonnet2021_relpath": str, + "calib.data.massbalance.hugonnet2021_fn": str, + "calib.data.massbalance.hugonnet2021_facorrected_fn": str, + "calib.data.oib": dict, + "calib.data.oib.oib_relpath": str, + "calib.data.oib.oib_rebin": int, + "calib.data.oib.oib_filter_pctl": int, + "calib.data.frontalablation": dict, + "calib.data.frontalablation.frontalablation_relpath": str, + "calib.data.frontalablation.frontalablation_cal_fn": str, + "calib.data.icethickness": dict, + "calib.data.icethickness.h_consensus_relpath": str, + "calib.icethickness_cal_frac_byarea": float, + "sim": dict, + "sim.option_dynamics": (str, type(None)), + "sim.option_bias_adjustment": int, + "sim.nsims": int, + "sim.out": dict, + "sim.out.sim_stats": list, + "sim.out.export_all_simiters": bool, + "sim.out.export_extra_vars": bool, + "sim.out.export_binned_data": bool, + "sim.out.export_binned_components": bool, + "sim.out.export_binned_area_threshold": int, + "sim.oggm_dynamics": dict, + "sim.oggm_dynamics.cfl_number": float, + "sim.oggm_dynamics.cfl_number_calving": float, + "sim.oggm_dynamics.glena_reg_relpath": str, + "sim.oggm_dynamics.use_reg_glena": bool, + "sim.oggm_dynamics.fs": int, + "sim.oggm_dynamics.glen_a_multiplier": int, + "sim.icethickness_advancethreshold": int, + "sim.terminus_percentage": int, + "sim.params": dict, + "sim.params.use_constant_lapserate": bool, + "sim.params.kp": int, + "sim.params.tbias": int, + "sim.params.ddfsnow": float, + "sim.params.ddfsnow_iceratio": float, + "sim.params.precgrad": float, + "sim.params.lapserate": float, + "sim.params.tsnow_threshold": int, + "sim.params.calving_k": float, + "mb": dict, + "mb.option_surfacetype_initial": int, + "mb.include_firn": bool, + "mb.include_debris": bool, + "mb.debris_relpath": str, + "mb.option_elev_ref_downscale": str, + "mb.option_temp2bins": int, + "mb.option_adjusttemp_surfelev": int, + "mb.option_prec2bins": int, + "mb.option_preclimit": int, + "mb.option_accumulation": int, + "mb.option_ablation": int, + "mb.option_ddf_firn": int, + "mb.option_refreezing": str, + "mb.Woodard_rf_opts": dict, + "mb.Woodard_rf_opts.rf_month": int, + "mb.HH2015_rf_opts": dict, + "mb.HH2015_rf_opts.rf_layers": int, + "mb.HH2015_rf_opts.rf_dz": int, + "mb.HH2015_rf_opts.rf_dsc": int, + "mb.HH2015_rf_opts.rf_meltcrit": float, + "mb.HH2015_rf_opts.pp": float, + "mb.HH2015_rf_opts.rf_dens_top": int, + "mb.HH2015_rf_opts.rf_dens_bot": int, + "mb.HH2015_rf_opts.option_rf_limit_meltsnow": int, + "rgi": dict, + "rgi.rgi_relpath": str, + "rgi.rgi_lat_colname": str, + "rgi.rgi_lon_colname": str, + "rgi.elev_colname": str, + "rgi.indexname": str, + "rgi.rgi_O1Id_colname": str, + "rgi.rgi_glacno_float_colname": str, + "rgi.rgi_cols_drop": list, + "time": dict, + "time.option_leapyear": int, + "time.startmonthday": str, + "time.endmonthday": str, + "time.wateryear_month_start": int, + "time.winter_month_start": int, + "time.summer_month_start": int, + "time.option_dates": int, + "time.timestep": str, + "constants": dict, + "constants.density_ice": int, + "constants.density_water": int, + "constants.area_ocean": float, + "constants.k_ice": float, + "constants.k_air": float, + "constants.ch_ice": int, + "constants.ch_air": int, + "constants.Lh_rf": int, + "constants.tolerance": float, + "constants.gravity": float, + "constants.pressure_std": int, + "constants.temp_std": float, + "constants.R_gas": float, + "constants.molarmass_air": float, + "debug": dict, + "debug.refreeze": bool, + "debug.mb": bool, + } - _check(default_config, user_config) \ No newline at end of file + # expected types of elements in lists + LIST_ELEMENT_TYPES = { + "setup.rgi_region01": int, + "setup.glac_no_skip": float, + "setup.glac_no": float, + "calib.HH2015mod_params.params2opt": str, + "calib.emulator_params.params2opt": str, + "sim.out.sim_stats": str, + "rgi.rgi_cols_drop": str, + } diff --git a/pygem/tests/test_config.py b/pygem/tests/test_config.py index 6aa19080..8e1a727c 100644 --- a/pygem/tests/test_config.py +++ b/pygem/tests/test_config.py @@ -1,115 +1,129 @@ -import os -import shutil +import pathlib +import pytest +import yaml from pygem.setup.config import ConfigManager -def test_create_config(): - """Test that create_config creates the config file.""" - test_dir = os.path.join(os.getcwd(), "test_config") - os.makedirs(test_dir, exist_ok=True) - config_manager = ConfigManager(config_filename='config.yaml', base_dir=test_dir, overwrite=True) - - config_manager.create_config() - assert os.path.isfile(config_manager.config_path), "Config file was not created" - - # Clean up - shutil.rmtree(test_dir) -def test_ensure_config_no_overwrite(): - """Test that create_config does not overwrite the existing config file when overwrite=False.""" - test_dir = os.path.join(os.getcwd(), "test_config") - os.makedirs(test_dir, exist_ok=True) - - # First, create the config file with overwrite=True - config_manager = ConfigManager(config_filename='config.yaml', base_dir=test_dir, overwrite=True) - config_manager.create_config() # This will create the file +class TestConfigManager: + """Tests for the ConfigManager class.""" - # Now, try initializing ConfigManager without overwrite=True - config_manager_no_overwrite = ConfigManager(config_filename='config.yaml', base_dir=test_dir, overwrite=False) + @pytest.fixture(autouse=True) + def setup(self, tmp_path): + """Setup a ConfigManager instance for each test.""" + self.config_manager = ConfigManager( + config_filename='config.yaml', + base_dir=tmp_path, + overwrite=True + ) - # Try to ensure the config - it should not overwrite the existing file - try: - config_manager_no_overwrite.ensure_config() - # If it doesn't raise any exceptions, check that the file exists and is not modified - assert os.path.isfile(config_manager_no_overwrite.config_path), "Config file should exist" - original_mtime = os.path.getmtime(config_manager.config_path) - - # Trigger ensure_config again to confirm it does not modify the existing file - config_manager_no_overwrite.ensure_config() - assert os.path.getmtime(config_manager_no_overwrite.config_path) == original_mtime, "Config file should not be modified" - except Exception as e: - assert False, f"Unexpected error: {e}" - - # Clean up - shutil.rmtree(test_dir) + def test_config_created(self, tmp_path): + config_path = pathlib.Path(tmp_path) / 'config.yaml' + assert config_path.is_file() -def test_update_config(): - """Test updating keys in the config file.""" - test_dir = os.path.join(os.getcwd(), "test_config") - os.makedirs(test_dir, exist_ok=True) - config_manager = ConfigManager(config_filename='config.yaml', base_dir=test_dir, overwrite=True) + def test_read_config(self): + config = self.config_manager.read_config() + assert isinstance(config, dict) + assert "sim" in config + assert "nsims" in config["sim"] - updates = { - "sim.nsims": "5", - "user.email": "updated@example.com", - "constants.density_ice": "850" - } - - config_manager.update_config(updates) - config = config_manager.read_config() + def test_update_config_unrecognized_key_error(self): + """Test that a KeyError is raised when updating a value with an unrecognized key.""" + with pytest.raises(KeyError, match="Unrecognized configuration key: invalid_key"): + self.config_manager.update_config({"invalid_key": None}) - # Assert the updates were made correctly - assert config["sim"]["nsims"] == 5 - assert config["user"]["email"] == "updated@example.com" - assert config["constants"]["density_ice"] == 850 - - # Clean up - shutil.rmtree(test_dir) + @pytest.mark.parametrize("key, invalid_value, expected_type, invalid_type", [ + ("sim.nsims", [1, 2, 3], "int", "list"), + ("calib.HH2015_params.kp_init", "0.5", "float", "str"), + ("setup.include_landterm", -999, "bool", "int"), + ("rgi.rgi_cols_drop", "not-a-list", "list", "str"), + ]) + def test_update_config_type_error(self, key, invalid_value, expected_type, invalid_type): + """ + Test that a TypeError is raised when updating a value with a new value of a + wrong type. + """ + with pytest.raises( + TypeError, + match=f"Invalid type for '{key.replace('.', '\\.')}':" + f" expected.*{expected_type}.*, not.*{invalid_type}.*" + ): + self.config_manager.update_config({key: invalid_value}) -def test_update_config_key_error(): - """Test that update_config raises an error if a key doesn't exist.""" - test_dir = os.path.join(os.getcwd(), "test_config") - os.makedirs(test_dir, exist_ok=True) - config_manager = ConfigManager(config_filename='config.yaml', base_dir=test_dir, overwrite=True) - - try: - config_manager.update_config({"nonexistent.key": "value"}) - except KeyError: - pass # Expected error - else: - assert False, "KeyError not raised" - - # Clean up - shutil.rmtree(test_dir) + def test_update_config_list_element_type_error(self): + """ + Test that a TypeError is raised when updating a value with a new list value + containing elements of a different type than expected. + """ + key = "rgi.rgi_cols_drop" + invalid_value = ["a", "b", 100] + expected_type = "str" -def test_update_config_type_error(): - """Test that update_config raises an error if there is a type mismatch.""" - test_dir = os.path.join(os.getcwd(), "test_config") - os.makedirs(test_dir, exist_ok=True) - config_manager = ConfigManager(config_filename='config.yaml', base_dir=test_dir, overwrite=True) - - # try setting a dictionary key-value with an value that is not a dictionary - try: - config_manager.update_config({"sim": "not a dict"}) - except TypeError: - pass # Expected error - else: - assert False, "TypeError not raised" - - # try setting a bool key-value type with a non-bool value type - try: - config_manager.update_config({"setup.include_tidewater": -999}) - except TypeError: - pass # Expected error - else: - assert False, "TypeError not raised" - - # try setting a string key-value type with a non-string value type - try: - config_manager.update_config({"root": -999}) - except TypeError: - pass # Expected error - else: - assert False, "TypeError not raised" - - # Clean up - shutil.rmtree(test_dir) \ No newline at end of file + with pytest.raises( + TypeError, + match=f"Invalid type for elements in '{key.replace('.', '\\.')}':" + f" expected all elements to be .*{expected_type}.*, but got.*{invalid_value}.*" + ): + self.config_manager.update_config({key: invalid_value}) + + def test_compare_with_source(self): + """Test that compare_with_source detects missing keys.""" + # Remove a key from the config file + with open(self.config_manager.config_path, 'r') as f: + config = yaml.safe_load(f) + del config['sim']['nsims'] + with open(self.config_manager.config_path, 'w') as f: + yaml.dump(config, f) + + with pytest.raises(KeyError, match=r"Missing required key in configuration: sim\.nsims"): + self.config_manager.read_config(validate=True) + + def test_update_config(self): + """Test that update_config updates the config file for all data types.""" + updates = { + "sim.nsims": 5, # int + "calib.HH2015_params.kp_init": 0.5, # float + "user.email": "updated@example.com", # str + "setup.include_landterm": False, # bool + "rgi.rgi_cols_drop": ['Item1', 'Item2'], # list + } + + # Values before updating + config = self.config_manager.read_config() + assert config["sim"]["nsims"] == 1 + assert config["calib"]["HH2015_params"]["kp_init"] == 1.5 + assert config["user"]["email"] == "drounce@cmu.edu" + assert config["setup"]["include_landterm"] == True + assert config["rgi"]["rgi_cols_drop"] == ["GLIMSId", "BgnDate", "EndDate", "Status", "Linkages", "Name"] + + self.config_manager.update_config(updates) + config = self.config_manager.read_config() + + # Values after updating + assert config["sim"]["nsims"] == 5 + assert config["calib"]["HH2015_params"]["kp_init"] == 0.5 + assert config["setup"]["include_landterm"] == False + assert config["user"]["email"] == "updated@example.com" + assert config["rgi"]["rgi_cols_drop"] == ["Item1", "Item2"] + + def test_update_config_dict(self): + """Test that update_config updates the config file for nested dictionaries.""" + # Values before updating + config = self.config_manager.read_config() + assert config["user"]["name"] == "David Rounce" + assert config["user"]["email"] == "drounce@cmu.edu" + assert config["user"]["institution"] == "Carnegie Mellon University, Pittsburgh PA" + + updates = { + "user": { + "name": "New Name", + "email": "New email", + "institution": "New Institution", + } + } + self.config_manager.update_config(updates) + + # Values after updating + config = self.config_manager.read_config() + assert config["user"]["name"] == "New Name" + assert config["user"]["email"] == "New email" + assert config["user"]["institution"] == "New Institution" diff --git a/pygem/tests/test_notebooks.py b/pygem/tests/test_notebooks.py new file mode 100644 index 00000000..350a2a20 --- /dev/null +++ b/pygem/tests/test_notebooks.py @@ -0,0 +1,20 @@ +import os +import subprocess + +import pytest + +# Get all notebooks in the PyGEM-notebooks repository +nb_dir = os.environ.get("PYGEM_NOTEBOOKS_DIRPATH") or os.path.join( + os.path.expanduser("~"), "PyGEM-notebooks" +) +notebooks = [f for f in os.listdir(nb_dir) if f.endswith(".ipynb")] + + +@pytest.mark.parametrize("notebook", notebooks) +def test_notebook(notebook): + # TODO #54: Test all notebooks + if notebook not in ("simple_test.ipynb", "advanced_test.ipynb"): + pytest.skip() + subprocess.check_call( + ["pytest", "--nbmake", os.path.join(nb_dir, notebook)] + ) \ No newline at end of file diff --git a/pygem/tests/test_oggm_compat.py b/pygem/tests/test_oggm_compat.py deleted file mode 100755 index 28c8afff..00000000 --- a/pygem/tests/test_oggm_compat.py +++ /dev/null @@ -1,78 +0,0 @@ -from pygem import oggm_compat -import numpy as np - -do_plot = False - - -def test_single_flowline_glacier_directory(): - - rid = 'RGI60-15.03473' - gdir = oggm_compat.single_flowline_glacier_directory(rid) - assert gdir.rgi_area_km2 == 61.054 - - if do_plot: - from oggm import graphics - import matplotlib.pyplot as plt - f, (ax1, ax2) = plt.subplots(1, 2) - graphics.plot_googlemap(gdir, ax=ax1) - graphics.plot_inversion(gdir, ax=ax2) - plt.show() - - -def test_get_glacier_zwh(): - - rid = 'RGI60-15.03473' - gdir = oggm_compat.single_flowline_glacier_directory(rid) - df = oggm_compat.get_glacier_zwh(gdir) - - # Ref area km2 - ref_area = gdir.rgi_area_km2 - ref_area_m2 = ref_area * 1e6 - - # Check that glacier area is conserved at 0.1% - np.testing.assert_allclose((df.w * df.dx).sum(), ref_area_m2, rtol=0.001) - - # Check that volume is within VAS at 25% - vas_vol = 0.034 * ref_area**1.375 - vas_vol_m3 = vas_vol * 1e9 - np.testing.assert_allclose((df.w * df.dx * df.h).sum(), vas_vol_m3, - rtol=0.25) - - -def test_random_mb_run(): - - rid = 'RGI60-15.03473' - gdir = oggm_compat.single_flowline_glacier_directory(rid, prepro_border=80) - - # This initializes the mass balance model, but does not run it - mbmod = oggm_compat.RandomLinearMassBalance(gdir, seed=1, sigma_ela=300, - h_perc=55) - # HERE CAN BE THE LOOP SUCH THAT EVERYTHING IS ALREADY LOADED - for i in [1,2,3,4]: - # Change the model parameter - mbmod.param1 = i - # Run the mass balance model with fixed geometry - ts_mb = mbmod.get_specific_mb(years=[2000,2001,2002]) - - # Run the glacier flowline model with a mass balance model - from oggm.core.flowline import robust_model_run - flmodel = robust_model_run(gdir, mb_model=mbmod, ys=0, ye=700) - - # Check that "something" is computed - import xarray as xr - ds = xr.open_dataset(gdir.get_filepath('model_diagnostics')) - assert ds.isel(time=-1).volume_m3 > 0 - - if do_plot: - import matplotlib.pyplot as plt - from oggm import graphics - graphics.plot_modeloutput_section(flmodel) - f, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(12, 4)) - (ds.volume_m3 * 1e-9).plot(ax=ax1) - ax1.set_ylabel('Glacier volume (km$^{3}$)') - (ds.area_m2 * 1e-6).plot(ax=ax2) - ax2.set_ylabel('Glacier area (km$^{2}$)') - (ds.length_m * 1e3).plot(ax=ax3) - ax3.set_ylabel('Glacier length (km)') - plt.tight_layout() - plt.show() diff --git a/pyproject.toml b/pyproject.toml index b661a0b2..7ecc8490 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,9 @@ jupyter = "^1.1.1" arviz = "^0.20.0" oggm = "^1.6.2" ruamel-yaml = "^0.18.10" +pytest = ">=8.3.4" +pytest-cov = ">=6.0.0" +nbmake = ">=1.5.5" [tool.poetry.scripts] initialize = "pygem.bin.op.initialize:main" @@ -53,4 +56,9 @@ duplicate_gdirs = "pygem.bin.op.duplicate_gdirs:main" [build-system] requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" \ No newline at end of file +build-backend = "poetry.core.masonry.api" + +[tool.coverage.report] +omit = ["pygem/tests/*"] +show_missing = true +skip_empty = true \ No newline at end of file From f8f62049893c2099d2380dc6b366291ef44bc029 Mon Sep 17 00:00:00 2001 From: "Brandon S. Tober" Date: Sun, 2 Mar 2025 16:39:11 -0500 Subject: [PATCH 19/29] clean up imports Co-authored-by: Davor Dundovic <33790330+ddundo@users.noreply.github.com> --- pygem/setup/config.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 1e793ab3..72e8d82e 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -7,8 +7,6 @@ """ import os import shutil -import yaml -import argparse import ruamel.yaml class ConfigManager: From 7207d048b53c3197f9053ee241a92b5979820a7b Mon Sep 17 00:00:00 2001 From: "Brandon S. Tober" Date: Sun, 2 Mar 2025 16:45:02 -0500 Subject: [PATCH 20/29] add `advanced_test_tw.ipynb` test --- pygem/tests/test_notebooks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygem/tests/test_notebooks.py b/pygem/tests/test_notebooks.py index 350a2a20..b443267c 100644 --- a/pygem/tests/test_notebooks.py +++ b/pygem/tests/test_notebooks.py @@ -13,8 +13,8 @@ @pytest.mark.parametrize("notebook", notebooks) def test_notebook(notebook): # TODO #54: Test all notebooks - if notebook not in ("simple_test.ipynb", "advanced_test.ipynb"): + if notebook not in ("simple_test.ipynb", "advanced_test.ipynb", "advanced_test_tw.ipynb): pytest.skip() subprocess.check_call( ["pytest", "--nbmake", os.path.join(nb_dir, notebook)] - ) \ No newline at end of file + ) From 4d745b7842c07f64e5dbec0e2ef70f4bcb21d4be Mon Sep 17 00:00:00 2001 From: "Brandon S. Tober" Date: Sun, 2 Mar 2025 16:46:17 -0500 Subject: [PATCH 21/29] typo fix --- pygem/tests/test_notebooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygem/tests/test_notebooks.py b/pygem/tests/test_notebooks.py index b443267c..2eb5c020 100644 --- a/pygem/tests/test_notebooks.py +++ b/pygem/tests/test_notebooks.py @@ -13,7 +13,7 @@ @pytest.mark.parametrize("notebook", notebooks) def test_notebook(notebook): # TODO #54: Test all notebooks - if notebook not in ("simple_test.ipynb", "advanced_test.ipynb", "advanced_test_tw.ipynb): + if notebook not in ("simple_test.ipynb", "advanced_test.ipynb", "advanced_test_tw.ipynb"): pytest.skip() subprocess.check_call( ["pytest", "--nbmake", os.path.join(nb_dir, notebook)] From 43adb9ca66caeae19e35318a9fd0232aa410c564 Mon Sep 17 00:00:00 2001 From: "Brandon S. Tober" Date: Sun, 2 Mar 2025 16:47:44 -0500 Subject: [PATCH 22/29] add __all__ variable Co-authored-by: Davor Dundovic <33790330+ddundo@users.noreply.github.com> --- pygem/setup/config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 72e8d82e..faf8d6ce 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -9,6 +9,9 @@ import shutil import ruamel.yaml +__all__ = ["ConfigManager"] + + class ConfigManager: def __init__(self, config_filename='config.yaml', base_dir=None, overwrite=False): """initialize the ConfigManager class""" From 8f57b642830ea9b39dcda812bbd04deef174a1f6 Mon Sep 17 00:00:00 2001 From: "Brandon S. Tober" Date: Sun, 2 Mar 2025 16:48:05 -0500 Subject: [PATCH 23/29] cleanup commenting Co-authored-by: Davor Dundovic <33790330+ddundo@users.noreply.github.com> --- pygem/setup/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index faf8d6ce..510925a7 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -38,7 +38,7 @@ def read_config(self, validate=True): """Read the configuration file and return its contents as a dictionary while preserving formatting.""" ryaml = ruamel.yaml.YAML() with open(self.config_path, 'r') as f: - user_config = ryaml.load(f) # Using ruamel.yaml for preservation + user_config = ryaml.load(f) if validate: self.validate_config(user_config) From 8319561f11cf62fe4d12f67026eb18cc1b62617e Mon Sep 17 00:00:00 2001 From: "Brandon S. Tober" Date: Sun, 2 Mar 2025 16:48:21 -0500 Subject: [PATCH 24/29] cleanup commenting Co-authored-by: Davor Dundovic <33790330+ddundo@users.noreply.github.com> --- pygem/setup/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 510925a7..40a2fd32 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -54,7 +54,7 @@ def write_config(self, config): def update_config(self, updates): """Update multiple keys in the YAML configuration file while preserving quotes and original types.""" - config = self.read_config(validate=False) # Read existing config + config = self.read_config(validate=False) for key, value in updates.items(): if key not in self.EXPECTED_TYPES: From 3fefb4399baba697e087c18ba988ff6ce6d1515f Mon Sep 17 00:00:00 2001 From: btobers Date: Sun, 2 Mar 2025 17:01:20 -0500 Subject: [PATCH 25/29] more descriptive variable in validate_config --- pygem/setup/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 40a2fd32..8784348b 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -69,11 +69,11 @@ def update_config(self, updates): self.validate_config(config) self.write_config(config) - def validate_config(self, data): + def validate_config(self, config): """Validate the configuration file against expected types and required keys""" for key, expected_type in self.EXPECTED_TYPES.items(): keys = key.split(".") - sub_data = data + sub_data = config for sub_key in keys: if isinstance(sub_data, dict) and sub_key in sub_data: sub_data = sub_data[sub_key] From e87e35e7531136b8c5cfc7e64684bbf85bafe92b Mon Sep 17 00:00:00 2001 From: btobers Date: Sun, 2 Mar 2025 17:15:40 -0500 Subject: [PATCH 26/29] private methods and expanded docstrings --- pygem/setup/config.py | 53 +++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/pygem/setup/config.py b/pygem/setup/config.py index 8784348b..108f6fb9 100644 --- a/pygem/setup/config.py +++ b/pygem/setup/config.py @@ -13,21 +13,29 @@ class ConfigManager: + """Manages PyGEMs configuration file, ensuring it exists, reading, updating, and validating its contents.""" def __init__(self, config_filename='config.yaml', base_dir=None, overwrite=False): - """initialize the ConfigManager class""" + """ + Initialize the ConfigManager class. + + Parameters: + config_filename (str, optional): Name of the configuration file. Defaults to 'config.yaml'. + base_dir (str, optional): Directory where the configuration file is stored. Defaults to '~/PyGEM'. + overwrite (bool, optional): Whether to overwrite an existing configuration file. Defaults to False. + """ self.config_filename = config_filename self.base_dir = base_dir or os.path.join(os.path.expanduser('~'), 'PyGEM') self.config_path = os.path.join(self.base_dir, self.config_filename) self.source_config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml") self.overwrite = overwrite - self.ensure_config() + self._ensure_config() - def ensure_config(self): + def _ensure_config(self): """Ensure the configuration file exists, creating or overwriting it if necessary""" if not os.path.isfile(self.config_path) or self.overwrite: - self.copy_source_config() + self._copy_source_config() - def copy_source_config(self): + def _copy_source_config(self): """Copy the default configuration file to the expected location""" os.makedirs(self.base_dir, exist_ok=True) @@ -35,25 +43,36 @@ def copy_source_config(self): print(f"Copied default configuration to {self.config_path}") def read_config(self, validate=True): - """Read the configuration file and return its contents as a dictionary while preserving formatting.""" + """Read the configuration file and return its contents as a dictionary while preserving formatting. + Parameters: + validate (bool): Whether to validate the configuration file contents. Defaults to True. + """ ryaml = ruamel.yaml.YAML() with open(self.config_path, 'r') as f: user_config = ryaml.load(f) if validate: - self.validate_config(user_config) + self._validate_config(user_config) return user_config - def write_config(self, config): - """Write the configuration dictionary to the file while preserving quotes.""" + def _write_config(self, config): + """Write the configuration dictionary to the file while preserving quotes. + + Parameters: + config (dict): configuration dictionary object + """ ryaml = ruamel.yaml.YAML() ryaml.preserve_quotes = True with open(self.config_path, 'w') as file: - ryaml.dump(config, file) # This will preserve quotes + ryaml.dump(config, file) def update_config(self, updates): - """Update multiple keys in the YAML configuration file while preserving quotes and original types.""" + """Update multiple keys in the YAML configuration file while preserving quotes and original types. + + Parameters: + updates (dict): Key-Value pairs to be updated + """ config = self.read_config(validate=False) for key, value in updates.items(): @@ -66,11 +85,15 @@ def update_config(self, updates): d[keys[-1]] = value - self.validate_config(config) - self.write_config(config) + self._validate_config(config) + self._write_config(config) - def validate_config(self, config): - """Validate the configuration file against expected types and required keys""" + def _validate_config(self, config): + """Validate the configuration dictionary against expected types and required keys. + + Parameters: + config (dict): The configuration dictionary to be validated. + """ for key, expected_type in self.EXPECTED_TYPES.items(): keys = key.split(".") sub_data = config From b9694a52c94cc8aaf94947ad1f04c92c3e47bfc6 Mon Sep 17 00:00:00 2001 From: "Brandon S. Tober" Date: Sun, 2 Mar 2025 18:16:02 -0500 Subject: [PATCH 27/29] swap PyGEM-Notebooks clone repo back to main branch --- .github/workflows/test_suite.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test_suite.yml b/.github/workflows/test_suite.yml index ffd583f6..c060e5d3 100644 --- a/.github/workflows/test_suite.yml +++ b/.github/workflows/test_suite.yml @@ -50,8 +50,7 @@ jobs: - name: 'Clone the PyGEM-notebooks repo' run: | - git clone -b 11_config_update --depth 1 https://github.com/pygem-community/PyGEM-notebooks.git - + git clone --depth 1 https://github.com/pygem-community/PyGEM-notebooks.git echo "PYGEM_NOTEBOOKS_DIRPATH=$(pwd)/PyGEM-notebooks" >> $GITHUB_ENV - name: 'Run tests' From a0a91beb432cca05034205efbf9a6dcf350a55a3 Mon Sep 17 00:00:00 2001 From: "Brandon S. Tober" Date: Sun, 2 Mar 2025 22:45:53 -0500 Subject: [PATCH 28/29] clone appropriate PyGEM-notebook brach --- .github/workflows/test_suite.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test_suite.yml b/.github/workflows/test_suite.yml index c060e5d3..4620f58b 100644 --- a/.github/workflows/test_suite.yml +++ b/.github/workflows/test_suite.yml @@ -50,7 +50,9 @@ jobs: - name: 'Clone the PyGEM-notebooks repo' run: | - git clone --depth 1 https://github.com/pygem-community/PyGEM-notebooks.git + BRANCH=${GITHUB_REF#refs/heads/} + git clone --depth 1 --branch $([[ "$BRANCH" == "master" ]] && echo "main" || echo "dev") \ + https://github.com/pygem-community/PyGEM-notebooks.git echo "PYGEM_NOTEBOOKS_DIRPATH=$(pwd)/PyGEM-notebooks" >> $GITHUB_ENV - name: 'Run tests' From 47b1bd78e3936bbee151f6ed28fd14a7e9bc354d Mon Sep 17 00:00:00 2001 From: btobers Date: Mon, 3 Mar 2025 07:43:02 -0500 Subject: [PATCH 29/29] comment added --- .github/workflows/test_suite.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test_suite.yml b/.github/workflows/test_suite.yml index 4620f58b..2a9f2bf0 100644 --- a/.github/workflows/test_suite.yml +++ b/.github/workflows/test_suite.yml @@ -50,6 +50,7 @@ jobs: - name: 'Clone the PyGEM-notebooks repo' run: | + # Use PyGEM-notebook:main for master branch and PyGEM-notebooks:dev otherwise BRANCH=${GITHUB_REF#refs/heads/} git clone --depth 1 --branch $([[ "$BRANCH" == "master" ]] && echo "main" || echo "dev") \ https://github.com/pygem-community/PyGEM-notebooks.git