diff --git a/docs/how_to/config_deployment.md b/docs/how_to/config_deployment.md index 00a7871e..549a848c 100644 --- a/docs/how_to/config_deployment.md +++ b/docs/how_to/config_deployment.md @@ -22,7 +22,7 @@ from fabric_cicd import deploy_with_config # Deploy using a config file deploy_with_config( - config_file_path="C:/dev/workspace/config.yml", + config_file_path="C:/dev/workspace/config.yml", # required environment="dev" ) ``` @@ -96,22 +96,22 @@ core: Required Fields: -- Workspace Identifier: - - Workspace ID takes precedence over workspace name when both are provided. - - `workspace_id` must be a valid string GUID. -- Repository Directory Path: - - Supports relative or absolute path. - - Relative path must be relative to the `config.yml` file location. +- Workspace Identifier: + - Workspace ID takes precedence over workspace name when both are provided. + - `workspace_id` must be a valid string GUID. +- Repository Directory Path: + - Supports relative or absolute path. + - Relative path must be relative to the `config.yml` file location. Optional Fields: -- Item Types in Scope: - - If `item_types_in_scope` is not specified, all item types will be included by default. - - Item types must be provided as a list, use `-` or `[]` notation. - - Only accepts supported item types. -- Parameter Path: - - Supports relative or absolute path. - - Relative path must be relative to the `config.yml` file location. +- Item Types in Scope: + - If `item_types_in_scope` is not specified, all item types will be included by default. + - Item types must be provided as a list, use `-` or `[]` notation. + - Only accepts supported item types. +- Parameter Path: + - Supports relative or absolute path. + - Relative path must be relative to the `config.yml` file location. ### Publish Settings @@ -239,7 +239,89 @@ constants: : ``` -### Sample `config.yml` File +## Environment-Specific Values + +All configuration fields support environment-specific values using a mapping format: + +```yaml +core: + workspace_id: + dev: "dev-workspace-id" + test: "test-workspace-id" + prod: "prod-workspace-id" +``` + +### Required vs Optional Fields + +Fields are categorized as **required** or **optional**, which affects how missing environment values are handled when environment is passed into `deploy_with_config()`: + +| Field | Required | Environment Missing Behavior | +| --------------------------------------- | -------- | ------------------------------- | +| `core.workspace_id` or `core.workspace` | ✅ | Validation error | +| `core.repository_directory` | ✅ | Validation error | +| `core.item_types_in_scope` | ❌ | Warning logged, setting skipped | +| `core.parameter` | ❌ | Warning logged, setting skipped | +| `publish.exclude_regex` | ❌ | Debug logged, setting skipped | +| `publish.folder_exclude_regex` | ❌ | Debug logged, setting skipped | +| `publish.shortcut_exclude_regex` | ❌ | Debug logged, setting skipped | +| `publish.items_to_include` | ❌ | Debug logged, setting skipped | +| `publish.skip` | ❌ | Defaults to `False` | +| `unpublish.exclude_regex` | ❌ | Debug logged, setting skipped | +| `unpublish.items_to_include` | ❌ | Debug logged, setting skipped | +| `unpublish.skip` | ❌ | Defaults to `False` | +| `features` | ❌ | Debug logged, setting skipped | +| `constants` | ❌ | Debug logged, setting skipped | + +### Selective Environment Configuration + +Optional fields allow you to apply settings to specific environments without affecting others. This is useful when you want different behavior per environment: + +```yaml +core: + workspace_id: + dev: "dev-workspace-id" + test: "test-workspace-id" + prod: "prod-workspace-id" + repository_directory: "./workspace" # Same for all environments + +publish: + # Only exclude legacy folders in prod environment + folder_exclude_regex: + prod: "^legacy_.*" + # dev and test not specified - no folder exclusion applied + + # Skip publish in dev, run in test and prod + skip: + dev: true + # test and prod default to false +``` + +In this example: + +- Deploying to `dev`: No folder exclusion applied, `skip` = `true` +- Deploying to `test`: No folder exclusion applied, `skip` = `false` +- Deploying to `prod`: `folder_exclude_regex` = `"^legacy_.*"`, `skip` = `false` + +### Logging Behavior + +When an optional field uses environment mapping and does not include the target environment: + +- **Important optional fields** (`item_types_in_scope`, `parameter`): A **warning** is logged to alert users that the setting is being skipped. +- **Other optional fields**: A **debug** message is logged, visible only when debug logging is enabled. + +Example log output when deploying to `prod` with the config above: + +``` +[Debug] - No value for 'folder_exclude_regex' in environment 'prod'. Available environments: ['dev']. This setting will be skipped. +``` + +To enable debug logging: + +```python +change_log_level() +``` + +## Sample `config.yml` File ```yaml core: @@ -359,12 +441,12 @@ deploy_with_config( **Important Considerations:** -- **Caution:** Exercise caution when overriding configuration values for _production_ environments. -- **Support:** Configuration overrides are supported for all sections and settings in the configuration file. -- **Rules:** - - Existing values can be overridden for any field in the configuration. - - New values can only be added for optional fields that aren't present in the original configuration. - - Required fields must exist in the original configuration in order to override. +- **Caution:** Exercise caution when overriding configuration values for _production_ environments. +- **Support:** Configuration overrides are supported for all sections and settings in the configuration file. +- **Rules:** + - Existing values can be overridden for any field in the configuration. + - New values can only be added for optional fields that aren't present in the original configuration. + - Required fields must exist in the original configuration in order to override. ## Troubleshooting Guide diff --git a/src/fabric_cicd/_common/_config_utils.py b/src/fabric_cicd/_common/_config_utils.py index 7327aa23..555fbbc5 100644 --- a/src/fabric_cicd/_common/_config_utils.py +++ b/src/fabric_cicd/_common/_config_utils.py @@ -27,47 +27,77 @@ def load_config_file(config_file_path: str, environment: str, config_override: O return validator.validate_config_file(config_file_path, environment, config_override) +def get_config_value(config_section: dict, key: str, environment: str) -> str | list | bool | None: + """Extract a value from config, handling both single and environment-specific formats. + + Args: + config_section: The config section to extract from + key: The key to extract + environment: Target environment + + Returns: + The extracted value, or None if key doesn't exist or environment not found in dict + """ + if key not in config_section: + return None + + value = config_section[key] + + if isinstance(value, dict): + return value.get(environment) + + return value + + +def update_setting( + settings: dict, + config: dict, + key: str, + environment: str, + default_value: Optional[str] = None, + output_key: Optional[str] = None, +) -> None: + """ + Gets a config value using get_config_value and updates the settings dictionary + if the value is not None. + + Args: + settings: The settings dictionary to update + config: The configuration dictionary + key: The key to extract from the config + environment: Target environment + default_value: The default value to set if the config value is None + output_key: The key to use in the settings dictionary (defaults to `key` if None) + """ + value = get_config_value(config, key, environment) + target_key = output_key or key + if value is not None: + settings[target_key] = value + elif default_value is not None: + settings[target_key] = default_value + + def extract_workspace_settings(config: dict, environment: str) -> dict: """Extract workspace-specific settings from config for the given environment.""" environment = environment.strip() core = config["core"] settings = {} - # Extract workspace ID or name based on environment + # Workspace ID or name - required, validation ensures value exists for target environment if "workspace_id" in core: - if isinstance(core["workspace_id"], dict): - settings["workspace_id"] = core["workspace_id"][environment] - else: - settings["workspace_id"] = core["workspace_id"] - + settings["workspace_id"] = get_config_value(core, "workspace_id", environment) logger.info(f"Using workspace ID '{settings['workspace_id']}'") - elif "workspace" in core: - if isinstance(core["workspace"], dict): - settings["workspace_name"] = core["workspace"][environment] - else: - settings["workspace_name"] = core["workspace"] - + settings["workspace_name"] = get_config_value(core, "workspace", environment) logger.info(f"Using workspace '{settings['workspace_name']}'") - # Extract other settings + # Repository directory - required, validation ensures value exists for target environment if "repository_directory" in core: - if isinstance(core["repository_directory"], dict): - settings["repository_directory"] = core["repository_directory"][environment] - else: - settings["repository_directory"] = core["repository_directory"] - - if "item_types_in_scope" in core: - if isinstance(core["item_types_in_scope"], dict): - settings["item_types_in_scope"] = core["item_types_in_scope"][environment] - else: - settings["item_types_in_scope"] = core["item_types_in_scope"] + settings["repository_directory"] = get_config_value(core, "repository_directory", environment) - if "parameter" in core: - if isinstance(core["parameter"], dict): - settings["parameter_file_path"] = core["parameter"][environment] - else: - settings["parameter_file_path"] = core["parameter"] + # Optional settings - validation logs warning if value not found for target environment + update_setting(settings, core, "item_types_in_scope", environment) + update_setting(settings, core, "parameter", environment, output_key="parameter_file_path") return settings @@ -79,35 +109,18 @@ def extract_publish_settings(config: dict, environment: str) -> dict: if "publish" in config: publish_config = config["publish"] - if "exclude_regex" in publish_config: - if isinstance(publish_config["exclude_regex"], dict): - settings["exclude_regex"] = publish_config["exclude_regex"][environment] - else: - settings["exclude_regex"] = publish_config["exclude_regex"] - - if "folder_exclude_regex" in publish_config: - if isinstance(publish_config["folder_exclude_regex"], dict): - settings["folder_exclude_regex"] = publish_config["folder_exclude_regex"][environment] - else: - settings["folder_exclude_regex"] = publish_config["folder_exclude_regex"] - - if "items_to_include" in publish_config: - if isinstance(publish_config["items_to_include"], dict): - settings["items_to_include"] = publish_config["items_to_include"][environment] - else: - settings["items_to_include"] = publish_config["items_to_include"] - - if "shortcut_exclude_regex" in publish_config: - if isinstance(publish_config["shortcut_exclude_regex"], dict): - settings["shortcut_exclude_regex"] = publish_config["shortcut_exclude_regex"][environment] - else: - settings["shortcut_exclude_regex"] = publish_config["shortcut_exclude_regex"] - - if "skip" in publish_config: - if isinstance(publish_config["skip"], dict): - settings["skip"] = publish_config["skip"].get(environment, False) - else: - settings["skip"] = publish_config["skip"] + # Optional settings - validation logs debug if value not found for target environment + settings_to_update = [ + "exclude_regex", + "folder_exclude_regex", + "items_to_include", + "shortcut_exclude_regex", + ] + for key in settings_to_update: + update_setting(settings, publish_config, key, environment) + + # Skip defaults to False if setting not found + update_setting(settings, publish_config, "skip", environment, default_value=False) return settings @@ -119,23 +132,16 @@ def extract_unpublish_settings(config: dict, environment: str) -> dict: if "unpublish" in config: unpublish_config = config["unpublish"] - if "exclude_regex" in unpublish_config: - if isinstance(unpublish_config["exclude_regex"], dict): - settings["exclude_regex"] = unpublish_config["exclude_regex"][environment] - else: - settings["exclude_regex"] = unpublish_config["exclude_regex"] - - if "items_to_include" in unpublish_config: - if isinstance(unpublish_config["items_to_include"], dict): - settings["items_to_include"] = unpublish_config["items_to_include"][environment] - else: - settings["items_to_include"] = unpublish_config["items_to_include"] - - if "skip" in unpublish_config: - if isinstance(unpublish_config["skip"], dict): - settings["skip"] = unpublish_config["skip"].get(environment, False) - else: - settings["skip"] = unpublish_config["skip"] + # Optional settings - validation logs debug if value not found for target environment + settings_to_update = [ + "exclude_regex", + "items_to_include", + ] + for key in settings_to_update: + update_setting(settings, unpublish_config, key, environment) + + # Skip defaults to False if setting not found + update_setting(settings, unpublish_config, "skip", environment, default_value=False) return settings diff --git a/src/fabric_cicd/_common/_config_validator.py b/src/fabric_cicd/_common/_config_validator.py index 0943ae95..449b89c3 100644 --- a/src/fabric_cicd/_common/_config_validator.py +++ b/src/fabric_cicd/_common/_config_validator.py @@ -342,14 +342,14 @@ def _validate_environment_exists(self) -> None: # Handle no target environment case if any( field_name in section and isinstance(section[field_name], dict) - for section, field_name, _ in _get_config_fields(self.config) + for section, field_name, _, _, _ in _get_config_fields(self.config) if not (field_name == "constants" and _is_regular_constants_dict(section.get(field_name, {}))) ): self.errors.append(constants.CONFIG_VALIDATION_MSGS["environment"]["no_env_with_mappings"]) return # Check each field for target environment presence - for section, field_name, display_name in _get_config_fields(self.config): + for section, field_name, display_name, is_required, log_warning in _get_config_fields(self.config): if field_name in section: field_value = section[field_name] # Handle constants special case @@ -359,12 +359,22 @@ def _validate_environment_exists(self) -> None: # If it's a dict (environment mapping), check if target environment exists if isinstance(field_value, dict) and self.environment not in field_value: available_envs = list(field_value.keys()) - self.errors.append( - constants.CONFIG_VALIDATION_MSGS["environment"]["env_not_found"].format( - self.environment, display_name, available_envs - ) + msg = ( + f"Environment '{self.environment}' not found in '{display_name}'. " + f"Available environments: {available_envs}. This setting will be skipped." ) + if is_required: + self.errors.append( + constants.CONFIG_VALIDATION_MSGS["environment"]["env_not_found"].format( + self.environment, display_name, available_envs + ) + ) + elif log_warning: + logger.warning(msg) + else: + logger.debug(msg) + def _validate_environment_mapping(self, field_value: dict, field_name: str, accepted_type: type) -> bool: """Validate field with environment mapping.""" if not field_value: @@ -572,6 +582,12 @@ def _resolve_path_field( # If environment mapping is used and target environment is provided, only process that environment path if self.environment and self.environment != "N/A" and isinstance(field_value, dict): + if self.environment not in paths_to_resolve: + # Skip if environment not in mapping (for parameter field, which is optional) + logger.debug( + f"Skipping path resolution for '{field_name}' - environment '{self.environment}' not in mapping" + ) + return paths_to_resolve = {self.environment: paths_to_resolve[self.environment]} for env_key, path_str in paths_to_resolve.items(): @@ -954,26 +970,35 @@ def _validate_constants_dict(self, constants_dict: dict, context: str) -> None: ) -def _get_config_fields(config: dict) -> list[tuple[dict, str, str]]: - """Get list of all fields that support environment mappings.""" +def _get_config_fields(config: dict) -> list[tuple[dict, str, str, bool, bool]]: + """Get list of all fields that support environment mappings. + + Returns: + List of tuples: (section_dict, field_name, display_name, is_required, log_warning) + - is_required: If True, missing environment causes error. + - log_warning: logging type (e.g., warning (True), debug (False)). + """ return [ - # Core section fields - (config.get("core", {}), "workspace_id", "core.workspace_id"), - (config.get("core", {}), "workspace", "core.workspace"), - (config.get("core", {}), "repository_directory", "core.repository_directory"), - (config.get("core", {}), "item_types_in_scope", "core.item_types_in_scope"), - (config.get("core", {}), "parameter", "core.parameter"), - # Publish section fields - (config.get("publish", {}), "exclude_regex", "publish.exclude_regex"), - (config.get("publish", {}), "items_to_include", "publish.items_to_include"), - (config.get("publish", {}), "skip", "publish.skip"), - # Unpublish section fields - (config.get("unpublish", {}), "exclude_regex", "unpublish.exclude_regex"), - (config.get("unpublish", {}), "items_to_include", "unpublish.items_to_include"), - (config.get("unpublish", {}), "skip", "unpublish.skip"), - # Top-level sections - (config, "features", "features"), - (config, "constants", "constants"), + # Core section fields - required + (config.get("core", {}), "workspace_id", "core.workspace_id", True, False), + (config.get("core", {}), "workspace", "core.workspace", True, False), + (config.get("core", {}), "repository_directory", "core.repository_directory", True, False), + # Core section fields - optional but important (warn if missing) + (config.get("core", {}), "item_types_in_scope", "core.item_types_in_scope", False, True), + (config.get("core", {}), "parameter", "core.parameter", False, True), + # Publish section fields - optional (debug if missing) + (config.get("publish", {}), "exclude_regex", "publish.exclude_regex", False, False), + (config.get("publish", {}), "folder_exclude_regex", "publish.folder_exclude_regex", False, False), + (config.get("publish", {}), "shortcut_exclude_regex", "publish.shortcut_exclude_regex", False, False), + (config.get("publish", {}), "items_to_include", "publish.items_to_include", False, False), + (config.get("publish", {}), "skip", "publish.skip", False, False), + # Unpublish section fields - optional (debug if missing) + (config.get("unpublish", {}), "exclude_regex", "unpublish.exclude_regex", False, False), + (config.get("unpublish", {}), "items_to_include", "unpublish.items_to_include", False, False), + (config.get("unpublish", {}), "skip", "unpublish.skip", False, False), + # Top-level sections - optional (debug if missing) + (config, "features", "features", False, False), + (config, "constants", "constants", False, False), ] diff --git a/tests/test_config_validator.py b/tests/test_config_validator.py index 801dc106..0192d3ee 100644 --- a/tests/test_config_validator.py +++ b/tests/test_config_validator.py @@ -765,6 +765,28 @@ def test_resolve_path_field_environment_mapping(self, tmp_path): # PROD should remain unchanged since it wasn't the target environment assert self.validator.config["test_section"]["test_field"]["PROD"] == "prod_dir" + def test_resolve_path_field_environment_not_in_mapping(self, tmp_path): + """Test _resolve_path_field skips gracefully when environment is not in mapping.""" + self.validator.environment = "prod" # Target environment + + # Create directory only for 'dev' environment + dev_dir = tmp_path / "dev_dir" + dev_dir.mkdir() + + # Parameter mapping only has 'dev', not 'prod' + field_value = {"dev": "dev_dir"} + + self.validator.config = {"test_section": {"test_field": field_value}} + self.validator.config_path = tmp_path / "config.yml" + + # Should NOT raise KeyError - should skip gracefully + self.validator._resolve_path_field(field_value, "test_field", "test_section", "directory") + + # No errors should be added (optional field behavior) + assert self.validator.errors == [] + # Config should remain unchanged since resolution was skipped + assert self.validator.config["test_section"]["test_field"] == {"dev": "dev_dir"} + def test_resolve_path_field_nonexistent_path(self, tmp_path): """Test _resolve_path_field with nonexistent path.""" self.validator.config = {"test_section": {"test_field": "nonexistent_dir"}} @@ -1351,7 +1373,13 @@ def test_get_config_fields_complete_config(self): "item_types_in_scope": ["Notebook"], "parameter": "param.yml", }, - "publish": {"exclude_regex": ".*_test", "items_to_include": ["item1"], "skip": False}, + "publish": { + "exclude_regex": ".*_test", + "folder_exclude_regex": "^temp/", + "shortcut_exclude_regex": "^shortcut_temp/", + "items_to_include": ["item1"], + "skip": False, + }, "unpublish": {"exclude_regex": ".*_old", "items_to_include": ["item2"], "skip": True}, "features": ["feature1"], "constants": {"KEY": "value"}, @@ -1360,16 +1388,28 @@ def test_get_config_fields_complete_config(self): fields = _get_config_fields(config) # Should return all fields from all sections - assert len(fields) == 13 + assert len(fields) == 15 # Updated count with folder_exclude_regex and shortcut_exclude_regex # Check some specific fields field_names = [field[1] for field in fields] assert "workspace_id" in field_names assert "repository_directory" in field_names assert "parameter" in field_names + assert "folder_exclude_regex" in field_names + assert "shortcut_exclude_regex" in field_names assert "features" in field_names assert "constants" in field_names + # Check required vs optional flags + for _section, field_name, _display_name, is_required, warn_if_missing in fields: + if field_name in ["workspace_id", "workspace", "repository_directory"]: + assert is_required is True, f"{field_name} should be required" + else: + assert is_required is False, f"{field_name} should be optional" + + if field_name in ["item_types_in_scope", "parameter"]: + assert warn_if_missing is True, f"{field_name} should warn if missing" + def test_is_regular_constants_dict_regular(self): """Test _is_regular_constants_dict with regular constants dictionary.""" from fabric_cicd._common._config_validator import _is_regular_constants_dict @@ -1865,10 +1905,12 @@ def test_environment_mismatch_in_multiple_fields(self): self.validator._validate_environment_exists() - # Should get multiple errors for each field that has environment mapping - assert len(self.validator.errors) >= 3 # At least workspace_id, repository_directory, and item_types + # Only required fields (workspace_id, repository_directory) cause errors + # Optional fields (item_types_in_scope, skip) only log warnings/debug + assert len(self.validator.errors) == 2 error_text = " ".join(self.validator.errors) - assert "Environment 'test' not found" in error_text + assert "workspace_id" in error_text + assert "repository_directory" in error_text def test_environment_mapping_vs_basic_values_mixed(self): """Test configuration with both environment mappings and basic values.""" @@ -1902,17 +1944,67 @@ def test_environment_mapping_vs_basic_values_mismatch(self): }, "publish": { "exclude_regex": "^TEST.*", # Basic value - should be ignored - "skip": {"dev": True}, # Environment mapping - missing 'prod' + "skip": {"dev": True}, # Environment mapping - missing 'prod' (optional, no error) + }, + } + self.validator.environment = "prod" + + self.validator._validate_environment_exists() + + # Should get error only for required field with environment mapping + # Optional fields (skip) only log debug, not errors + assert len(self.validator.errors) == 1 + error_text = " ".join(self.validator.errors) + assert "workspace_id" in error_text + assert "skip" not in error_text # Optional field should not cause error + + def test_environment_mismatch_optional_fields_log_only(self): + """Test that optional fields only log warnings/debug when environment is missing.""" + import logging + + self.validator.config = { + "core": { + "workspace_id": "simple-id", # Not a mapping, won't trigger error + "repository_directory": "/path", + "item_types_in_scope": {"dev": ["Notebook"]}, # Optional, warn_if_missing=True + "parameter": {"dev": "param.yml"}, # Optional, warn_if_missing=True + }, + "publish": { + "exclude_regex": {"dev": "^TEST.*"}, # Optional, warn_if_missing=False + "skip": {"dev": True}, # Optional, warn_if_missing=False + }, + } + self.validator.environment = "prod" + + with ( + patch.object(logging.getLogger("fabric_cicd._common._config_validator"), "warning") as mock_warning, + patch.object(logging.getLogger("fabric_cicd._common._config_validator"), "debug") as mock_debug, + ): + self.validator._validate_environment_exists() + + # No errors for optional fields + assert self.validator.errors == [] + + # Warnings should be logged for item_types_in_scope and parameter + assert mock_warning.call_count == 2 + + # Debug should be logged for exclude_regex and skip + assert mock_debug.call_count == 2 + + def test_environment_mismatch_required_fields_error(self): + """Test that required fields cause errors when environment is missing.""" + self.validator.config = { + "core": { + "workspace_id": {"dev": "dev-id"}, # Required + "repository_directory": {"dev": "/dev/path"}, # Required }, } self.validator.environment = "prod" self.validator._validate_environment_exists() - # Should get errors only for fields with environment mappings + # Should get errors for both required fields assert len(self.validator.errors) == 2 error_text = " ".join(self.validator.errors) assert "workspace_id" in error_text - assert "skip" in error_text - assert "repository_directory" not in error_text # Basic value should not cause error - assert "exclude_regex" not in error_text # Basic value should not cause error + assert "repository_directory" in error_text diff --git a/tests/test_deploy_with_config.py b/tests/test_deploy_with_config.py index 194bd084..f1ddfb68 100644 --- a/tests/test_deploy_with_config.py +++ b/tests/test_deploy_with_config.py @@ -762,3 +762,150 @@ def test_extract_publish_settings_with_environment_specific_folder_exclude_regex settings = extract_publish_settings(config, "prod") assert settings["folder_exclude_regex"] == "^PROD_FOLDER/" + + def test_extract_publish_settings_missing_environment_skips_setting(self): + """Test that missing environment in optional publish settings skips the setting.""" + config = { + "publish": { + "exclude_regex": {"dev": "^DEV.*"}, # Only dev defined + "folder_exclude_regex": {"dev": "^DEV_FOLDER/"}, # Only dev defined + } + } + + # prod environment not defined - settings should be skipped + settings = extract_publish_settings(config, "prod") + assert "exclude_regex" not in settings + assert "folder_exclude_regex" not in settings + + def test_extract_unpublish_settings_missing_environment_skips_setting(self): + """Test that missing environment in optional unpublish settings skips the setting.""" + config = { + "unpublish": { + "exclude_regex": {"dev": "^DEV.*"}, # Only dev defined + "items_to_include": {"dev": ["item1"]}, # Only dev defined + } + } + + # prod environment not defined - settings should be skipped + settings = extract_unpublish_settings(config, "prod") + assert "exclude_regex" not in settings + assert "items_to_include" not in settings + + def test_extract_publish_settings_skip_defaults_false_when_env_missing(self): + """Test that skip defaults to False when environment is not in skip mapping.""" + config = { + "publish": { + "skip": {"dev": True}, # Only dev defined + } + } + + # prod environment not defined - skip should default to False + settings = extract_publish_settings(config, "prod") + assert settings["skip"] is False + + def test_extract_unpublish_settings_skip_defaults_false_when_env_missing(self): + """Test that skip defaults to False when environment is not in skip mapping.""" + config = { + "unpublish": { + "skip": {"dev": True}, # Only dev defined + } + } + + # prod environment not defined - skip should default to False + settings = extract_unpublish_settings(config, "prod") + assert settings["skip"] is False + + def test_extract_workspace_settings_optional_fields_missing_environment(self): + """Test that optional workspace fields are skipped when environment is missing.""" + config = { + "core": { + "workspace_id": "12345678-1234-1234-1234-123456789abc", # Simple value + "repository_directory": "/path/to/repo", + "item_types_in_scope": {"dev": ["Notebook"]}, # Only dev defined + "parameter": {"dev": "dev-param.yml"}, # Only dev defined + } + } + + # prod environment not defined for optional fields - they should be skipped + settings = extract_workspace_settings(config, "prod") + assert "item_types_in_scope" not in settings + assert "parameter_file_path" not in settings + # Required fields should still be present + assert settings["workspace_id"] == "12345678-1234-1234-1234-123456789abc" + assert settings["repository_directory"] == "/path/to/repo" + + def test_extract_publish_settings_shortcut_exclude_regex_missing_environment(self): + """Test that shortcut_exclude_regex is skipped when environment is missing.""" + config = { + "publish": { + "shortcut_exclude_regex": {"dev": "^dev_temp_.*"}, # Only dev defined + } + } + + # prod environment not defined - setting should be skipped + settings = extract_publish_settings(config, "prod") + assert "shortcut_exclude_regex" not in settings + + def test_extract_publish_settings_items_to_include_missing_environment(self): + """Test that items_to_include is skipped when environment is missing.""" + config = { + "publish": { + "items_to_include": {"dev": ["item1.Notebook", "item2.DataPipeline"]}, # Only dev defined + } + } + + # prod environment not defined - setting should be skipped + settings = extract_publish_settings(config, "prod") + assert "items_to_include" not in settings + + +class TestGetConfigValue: + """Test the get_config_value utility function.""" + + def test_get_config_value_key_not_present(self): + """Test get_config_value when key doesn't exist.""" + from fabric_cicd._common._config_utils import get_config_value + + config = {"other_key": "value"} + result = get_config_value(config, "missing_key", "dev") + assert result is None + + def test_get_config_value_simple_value(self): + """Test get_config_value with simple (non-dict) value.""" + from fabric_cicd._common._config_utils import get_config_value + + config = {"key": "simple_value"} + result = get_config_value(config, "key", "dev") + assert result == "simple_value" + + def test_get_config_value_dict_with_environment(self): + """Test get_config_value with dict containing target environment.""" + from fabric_cicd._common._config_utils import get_config_value + + config = {"key": {"dev": "dev_value", "prod": "prod_value"}} + result = get_config_value(config, "key", "dev") + assert result == "dev_value" + + def test_get_config_value_dict_missing_environment(self): + """Test get_config_value with dict missing target environment.""" + from fabric_cicd._common._config_utils import get_config_value + + config = {"key": {"dev": "dev_value"}} + result = get_config_value(config, "key", "prod") + assert result is None + + def test_get_config_value_list_value(self): + """Test get_config_value with list value.""" + from fabric_cicd._common._config_utils import get_config_value + + config = {"key": ["item1", "item2"]} + result = get_config_value(config, "key", "dev") + assert result == ["item1", "item2"] + + def test_get_config_value_bool_value(self): + """Test get_config_value with boolean value.""" + from fabric_cicd._common._config_utils import get_config_value + + config = {"key": True} + result = get_config_value(config, "key", "dev") + assert result is True