diff --git a/.github/workflows/conda.yml b/.github/workflows/conda.yml index 1a9949f1..bd9b0f28 100644 --- a/.github/workflows/conda.yml +++ b/.github/workflows/conda.yml @@ -28,7 +28,7 @@ jobs: shell: bash -l {0} run: | conda install -c conda-forge conda-build scikit-build-core numpy anaconda-client conda-libmamba-solver -y - conda build -c conda-forge -c loop3d --output-folder conda conda --python ${{matrix.python-version}} + conda build -c conda-forge -c loop3d --output-folder conda conda --python ${{matrix.python-version}}reve anaconda upload --label main conda/*/*.tar.bz2 - name: upload artifacts diff --git a/map2loop/config.py b/map2loop/config.py index 48d017d3..2a0efdf7 100644 --- a/map2loop/config.py +++ b/map2loop/config.py @@ -103,9 +103,6 @@ def update_from_dictionary(self, dictionary: dict, lower: bool = True): # make sure dictionary doesn't contain legacy keys self.check_for_legacy_keys(dictionary) - # make sure it has the minimum requirements - self.validate_config_dictionary(dictionary) - if "structure" in dictionary: self.structure_config.update(dictionary["structure"]) for key in dictionary["structure"].keys(): @@ -218,25 +215,20 @@ def update_from_file( @beartype.beartype def validate_config_dictionary(self, config_dict: dict) -> None: - """ - Validate the structure and keys of the configuration dictionary. - - Args: - config_dict (dict): The config dictionary to validate. - - Raises: - ValueError: If the dictionary does not meet the minimum requirements for ma2p2loop. - """ required_keys = { "structure": {"dipdir_column", "dip_column"}, "geology": {"unitname_column", "alt_unitname_column"}, } + # Loop over "structure" and "geology" for section, keys in required_keys.items(): + + # 1) Check that "section" exists if section not in config_dict: logger.error(f"Missing required section '{section}' in config dictionary.") raise ValueError(f"Missing required section '{section}' in config dictionary.") - + + # 2) Check that each required key is in config_dict[section] for key in keys: if key not in config_dict[section]: logger.error( @@ -246,6 +238,7 @@ def validate_config_dictionary(self, config_dict: dict) -> None: f"Missing required key '{key}' for '{section}' section of the config dictionary." ) + @beartype.beartype def check_for_legacy_keys(self, config_dict: dict) -> None: diff --git a/map2loop/project.py b/map2loop/project.py index 84aa0eea..6e4303c6 100644 --- a/map2loop/project.py +++ b/map2loop/project.py @@ -18,7 +18,7 @@ gdal.UseExceptions() import geopandas import beartype -from beartype.typing import Union, List +from beartype.typing import Union, List, Dict, Any import pathlib import numpy import pandas @@ -75,7 +75,7 @@ def __init__( loop_project_filename: str = "", overwrite_loopprojectfile: bool = False, **kwargs, - ): + ): """ The initialiser for the map2loop project @@ -119,6 +119,30 @@ def __init__( TypeError: Type of bounding_box not a dict or tuple ValueError: use_australian_state_data not in state list ['WA', 'SA', 'QLD', 'NSW', 'TAS', 'VIC', 'ACT', 'NT'] """ + + # Throw error if unexpected keyword arguments are passed to project + allowed_kwargs = {"metadata_filename"} + for key in kwargs.keys(): + if key not in allowed_kwargs: + logger.error( + f"Unexpected keyword argument '{key}' passed to Project. Allowed keywords: {', '.join(allowed_kwargs)}." + ) + raise TypeError( + f"Project got an unexpected keyword argument '{key}' - please double-check this before proceeding with map2loop processing" + ) + + # make sure all the needed arguments are provided + if not use_australian_state_data: # this check has to skip if using Loop server data + self.validate_required_inputs( + bounding_box=bounding_box, + working_projection=working_projection, + geology_filename=geology_filename, + structure_filename=structure_filename, + dtm_filename=dtm_filename, + config_dictionary=config_dictionary, + config_filename=config_filename, + ) + self._error_state = ErrorState.NONE self._error_state_msg = "" self.verbose_level = verbose_level @@ -207,12 +231,12 @@ def __init__( self.map_data.set_config_filename(config_filename) if config_dictionary != {}: + self.map_data.config.validate_config_dictionary(config_dictionary) self.map_data.config.update_from_dictionary(config_dictionary) if clut_filename != "": self.map_data.set_colour_filename(clut_filename) - # Load all data (both shape and raster) self.map_data.load_all_map_data() @@ -233,6 +257,58 @@ def __init__( if len(kwargs): logger.warning(f"Unused keyword arguments: {kwargs}") + @beartype.beartype + def validate_required_inputs( + self, + bounding_box: Dict[str, Union[float, int]], + working_projection: str, + geology_filename: str, + structure_filename: str, + dtm_filename: str, + config_filename: str = None, + config_dictionary: Dict[str, Any] = {}, + ) -> None: + + required_inputs = { + "bounding_box": bounding_box, + "working_projection": working_projection, # this may be removed when fix is added for https://github.com/Loop3D/map2loop/issues/103 + "geology_filename": geology_filename, + "structure_filename": structure_filename, + "dtm_filename": dtm_filename, + } + + # Check for missing required inputs in project + missing_inputs = [key for key, value in required_inputs.items() if not value] + + if missing_inputs: + missing_list = ", ".join(missing_inputs) + logger.error( + f"Project construction is missing required inputs: {missing_list}. " + "Please add them to the Project()." + ) + raise ValueError( + f"Project construction is missing required inputs: {missing_list}. " + "Please add them to the Project()." + ) + + # Either config_filename or config_dictionary must be provided (but not both or neither) + if not config_filename and not config_dictionary: + logger.error( + "A config file is required to run map2loop - use either 'config_filename' or 'config_dictionary' to initialise the project." + ) + raise ValueError( + "A config file is required to run map2loop - use either 'config_filename' or 'config_dictionary' to initialise the project." + ) + if config_filename and config_dictionary: + logger.error( + "Both 'config_filename' and 'config_dictionary' were provided. Please specify only one config." + ) + raise ValueError( + "Both 'config_filename' and 'config_dictionary' were provided. Please specify only one config." + ) + + + # Getters and Setters @beartype.beartype def set_ignore_lithology_codes(self, codes: list): @@ -734,9 +810,10 @@ def save_into_projectfile(self): logger.info('Saving data into loop project file') if not self.loop_filename: logger.info('No loop project file specified, creating a new one') - self.loop_filename = os.path.join( - self.map_data.tmp_path, os.path.basename(self.map_data.tmp_path) + ".loop3d" - ) + output_dir = pathlib.Path.cwd() + output_dir.mkdir(parents=True, exist_ok=True) + filename = "new_project.loop3d" + self.loop_filename = str(output_dir / filename) file_exists = os.path.isfile(self.loop_filename) diff --git a/tests/project/test_config_arguments.py b/tests/project/test_config_arguments.py new file mode 100644 index 00000000..53e6ce35 --- /dev/null +++ b/tests/project/test_config_arguments.py @@ -0,0 +1,148 @@ +import pytest +import pathlib +from map2loop.project import Project +import map2loop + +# ------------------------------------------------------------------------------ +# Common fixtures or helper data (bounding box, minimal filenames, etc.) +# ------------------------------------------------------------------------------ + +@pytest.fixture +def minimal_bounding_box(): + return { + "minx": 515687.31005864, + "miny": 7493446.76593407, + "maxx": 562666.860106543, + "maxy": 7521273.57407786, + "base": -3200, + "top": 3000, + } + +@pytest.fixture +def geology_file(): + return str( + pathlib.Path(map2loop.__file__).parent + / pathlib.Path('_datasets/geodata_files/hamersley/geology.geojson') + ) + +@pytest.fixture +def structure_file(): + return str( + pathlib.Path(map2loop.__file__).parent + / pathlib.Path('_datasets/geodata_files/hamersley/structure.geojson') + ) + +@pytest.fixture +def dtm_file(): + return str( + pathlib.Path(map2loop.__file__).parent + / pathlib.Path('_datasets/geodata_files/hamersley/dtm_rp.tif') + ) + +@pytest.fixture +def valid_config_dictionary(): + """ + A valid config dictionary that meets the 'structure' and 'geology' requirements + """ + return { + "structure": { + "dipdir_column": "azimuth2", + "dip_column": "dip" + }, + "geology": { + "unitname_column": "unitname", + "alt_unitname_column": "code", + } + } + + + +# 1) config_filename and config_dictionary both present should raise ValueError +def test_config_filename_and_dictionary_raises_error( + minimal_bounding_box, geology_file, dtm_file, structure_file, valid_config_dictionary +): + + with pytest.raises(ValueError, match="Both 'config_filename' and 'config_dictionary' were provided"): + Project( + bounding_box=minimal_bounding_box, + working_projection="EPSG:28350", + geology_filename=geology_file, + dtm_filename=dtm_file, + structure_filename=structure_file, + config_filename="dummy_config.json", + config_dictionary=valid_config_dictionary, + ) + +# 2) No config_filename or config_dictionary should raise ValueError +def test_no_config_provided_raises_error( + minimal_bounding_box, geology_file, dtm_file, structure_file +): + + with pytest.raises(ValueError, match="A config file is required to run map2loop"): + Project( + bounding_box=minimal_bounding_box, + working_projection="EPSG:28350", + geology_filename=geology_file, + dtm_filename=dtm_file, + structure_filename=structure_file, + ) + +# 3) Passing an unexpected argument should raise TypeError +def test_unexpected_argument_raises_error( + minimal_bounding_box, geology_file, dtm_file, structure_file, valid_config_dictionary +): + + with pytest.raises(TypeError, match="unexpected keyword argument 'config_file'"): + Project( + bounding_box=minimal_bounding_box, + working_projection="EPSG:28350", + geology_filename=geology_file, + dtm_filename=dtm_file, + structure_filename=structure_file, + config_dictionary=valid_config_dictionary, + config_file="wrong_kwarg.json", + ) + +# 4) Dictionary missing a required key should raise ValueError + +def test_dictionary_missing_required_key_raises_error( + minimal_bounding_box, geology_file, dtm_file, structure_file +): + + invalid_dictionary = { + "structure": {"dipdir_column": "azimuth2", "dip_column": "dip"}, + "geology": {"unitname_column": "unitname"} # alt_unitname_column missing + } + + with pytest.raises(ValueError, match="Missing required key 'alt_unitname_column' for 'geology'"): + Project( + bounding_box=minimal_bounding_box, + working_projection="EPSG:28350", + geology_filename=geology_file, + dtm_filename=dtm_file, + structure_filename=structure_file, + config_dictionary=invalid_dictionary, + ) + +# 5) All good => The Project should be created without errors +def test_good_config_runs_successfully( + minimal_bounding_box, geology_file, dtm_file, structure_file, valid_config_dictionary +): + project = None + try: + project = Project( + bounding_box=minimal_bounding_box, + working_projection="EPSG:28350", + geology_filename=geology_file, + dtm_filename=dtm_file, + structure_filename=structure_file, + config_dictionary=valid_config_dictionary, + ) + except Exception as e: + pytest.fail(f"Project initialization raised an unexpected exception: {e}") + + assert project is not None, "Project was not created." + assert project.map_data.config.structure_config["dipdir_column"] == "azimuth2" + assert project.map_data.config.structure_config["dip_column"] == "dip" + assert project.map_data.config.geology_config["unitname_column"] == "unitname" + assert project.map_data.config.geology_config["alt_unitname_column"] == "code" \ No newline at end of file diff --git a/tests/project/test_ignore_codes_setters_getters.py b/tests/project/test_ignore_codes_setters_getters.py index 4cebdba7..34de34a4 100644 --- a/tests/project/test_ignore_codes_setters_getters.py +++ b/tests/project/test_ignore_codes_setters_getters.py @@ -2,6 +2,7 @@ from map2loop.project import Project from map2loop.m2l_enums import Datatype import map2loop +from unittest.mock import patch # Sample test function for lithology and fault ignore codes @@ -21,24 +22,25 @@ def test_set_get_ignore_codes(): "structure": {"dipdir_column": "azimuth2", "dip_column": "dip"}, "geology": {"unitname_column": "unitname", "alt_unitname_column": "code"}, } - - project = Project( - working_projection='EPSG:28350', - bounding_box=bbox_3d, - geology_filename=str( - pathlib.Path(map2loop.__file__).parent - / pathlib.Path('_datasets/geodata_files/hamersley/geology.geojson') - ), - fault_filename=str( - pathlib.Path(map2loop.__file__).parent - / pathlib.Path('_datasets/geodata_files/hamersley/faults.geojson') - ), - dtm_filename=str( - pathlib.Path(map2loop.__file__).parent - / pathlib.Path('_datasets/geodata_files/hamersley/dtm_rp.tif') - ), - config_dictionary=config_dictionary, - ) + with patch.object(Project, 'validate_required_inputs', return_value=None): + project = Project( + working_projection='EPSG:28350', + bounding_box=bbox_3d, + geology_filename=str( + pathlib.Path(map2loop.__file__).parent + / pathlib.Path('_datasets/geodata_files/hamersley/geology.geojson') + ), + fault_filename=str( + pathlib.Path(map2loop.__file__).parent + / pathlib.Path('_datasets/geodata_files/hamersley/faults.geojson') + ), + dtm_filename=str( + pathlib.Path(map2loop.__file__).parent + / pathlib.Path('_datasets/geodata_files/hamersley/dtm_rp.tif') + ), + config_dictionary=config_dictionary, + structure_filename="", + ) # Define test ignore codes for lithology and faults lithology_codes = ["cover", "Fortescue_Group", "A_FO_od"]