From 75f5799921bf47a1c7606e34b807d63247c71d08 Mon Sep 17 00:00:00 2001 From: Sankalp Gilda Date: Mon, 7 Oct 2024 22:55:38 -0400 Subject: [PATCH] updated test_switch to align with dependencies (#173) # Pull Request Template ## Description This pull request introduces essential updates to the `dependencies.py` and `test_switch.py` modules to enhance dependency management and streamline the testing process within the project. The primary changes include: 1. **Refactored `dependencies.py`:** - **Integrated Pydantic 2.0 for Inline Validation:** Implemented inline validation using Pydantic's `BaseModel` and `field_validator`, eliminating the need for external validation mechanisms. This ensures robust input validation while maintaining a lean codebase. - **Introduced `SeverityEnum`:** Utilized Python's `Enum` to define allowable severity levels (`ERROR`, `WARNING`, `NONE`), reducing the risk of typos and enhancing code clarity. - **Enhanced Type Hinting and Documentation:** Added comprehensive type hints and detailed docstrings to improve readability and maintainability. - **Logging Integration:** Configured a logger to handle warnings and informational messages, replacing direct print statements for better observability. - **PEP8 Compliance:** Ensured that the code adheres to Python's PEP8 style guidelines for consistency and readability. - **Author Attribution:** Updated the `__author__` list to include "astrogilda" alongside "fkiraly" to acknowledge contributions. 2. **Updated `test_switch.py`:** - **Aligned with Updated `dependencies.py`:** Replaced the old `_check_estimator_deps` function with the new `check_estimator_dependencies` function to maintain consistency. - **Type Hinting and Documentation:** Incorporated type hints and enhanced docstrings to clarify the function's behavior and expected inputs. - **Optimized Logic:** Simplified the dependency checking logic by directly utilizing the new `check_estimator_dependencies` function, eliminating the need for internal helper functions. - **PEP8 Compliance and Author Attribution:** Ensured consistent styling and updated the `__author__` list to include "astrogilda" for proper credit. These enhancements collectively improve the robustness, readability, and maintainability of the dependency management and testing utilities within the project, ensuring seamless integration and scalability. ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? The changes have been thoroughly tested to ensure functionality and compatibility: - [x] **Unit Tests:** Updated and ran existing unit tests in `test_switch.py` to verify that the `run_test_for_class` function correctly determines whether to run tests based on dependency checks. - [x] **Static Type Checking:** Utilized type checkers (e.g., Pylance in VSCode) to ensure that type hints are correctly implemented and that no type-related errors are present. - [x] **Manual Testing:** Conducted manual tests by simulating different scenarios where dependencies are present or missing to confirm that the functions behave as expected. - [x] **PEP8 Linting:** Ran linting tools to ensure that the code adheres to PEP8 standards without introducing any style violations. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] Any dependent changes have been merged and published in downstream modules ## Additional Information (if applicable) - **Pydantic 2.0 Usage:** Transitioned to using Pydantic's `BaseModel` and `field_validator` for inline validation, ensuring compatibility with the latest Pydantic version and enhancing input validation robustness. - **Type Checker Compatibility:** Addressed type checker (Pylance) errors by correctly handling the conversion between `str` and `SeverityEnum` using `field_validator` and suppressing false positives where necessary with `# type: ignore`. - **Logging Configuration:** The module now uses Python's built-in `logging` module, allowing for better integration with different logging configurations in various environments. - **Dependency Alignment:** Ensured that `test_switch.py` is fully aligned with the updated `dependencies.py`, maintaining consistency across modules and reducing potential integration issues. ## Add All Contributors Command Remember to acknowledge your contributions, replace `contribution_type` with your contribution (code, doc, etc.): ```plaintext @all-contributors please add @astrogilda for code, documentation ``` Co-authored-by: Sankalp Gilda --- src/tsbootstrap/tests/test_switch.py | 79 ++++---- src/tsbootstrap/utils/dependencies.py | 255 +++++++++++++++++++++----- 2 files changed, 251 insertions(+), 83 deletions(-) diff --git a/src/tsbootstrap/tests/test_switch.py b/src/tsbootstrap/tests/test_switch.py index a419cae2..ca34c884 100644 --- a/src/tsbootstrap/tests/test_switch.py +++ b/src/tsbootstrap/tests/test_switch.py @@ -1,50 +1,63 @@ -# copyright: tsbootstrap developers, BSD-3-Clause License (see LICENSE file) +# copyright: +# tsbootstrap developers, BSD-3-Clause License (see LICENSE file) # based on utility from sktime of the same name -"""Switch utility for determining whether tests for a class should be run or not.""" -__author__ = ["fkiraly"] +"""Switch utility for determining whether tests for a class should be run or not.""" +__author__ = ["fkiraly", "astrogilda"] -def run_test_for_class(cls): - """Check if test should run for a class or function. +from typing import Any, List, Optional, Union - This checks the following conditions: +from tsbootstrap.utils.dependencies import _check_estimator_dependencies - 1. whether all required soft dependencies are present. - If not, does not run the test. - If yes, runs the test - cls can also be a list of classes or functions, - in this case the test is run if and only if: +def run_test_for_class(cls: Union[Any, List[Any], tuple]) -> bool: + """ + Determine whether tests should be run for a given class or function based on dependency checks. - * all required soft dependencies are present + This function evaluates whether the provided class/function or a list of them has all required + soft dependencies present in the current environment. If all dependencies are satisfied, it returns + `True`, indicating that tests should be executed. Otherwise, it returns `False`. Parameters ---------- - cls : class, function or list of classes/functions - class for which to determine whether it should be tested + cls : Union[Any, List[Any], tuple] + A single class/function or a list/tuple of classes/functions for which to determine + whether tests should be run. Each class/function should be a descendant of `BaseObject` + and have the `get_class_tag` method for dependency retrieval. Returns ------- - bool : True if class should be tested, False otherwise - if cls was a list, is True iff True for at least one of the classes in the list + bool + `True` if all provided classes/functions have their required dependencies present. + `False` otherwise. + + Raises + ------ + ValueError + If the severity level provided in dependency checks is invalid. + TypeError + If any object in `cls` does not have the `get_class_tag` method or is not a `BaseObject` descendant. """ - if not isinstance(cls, list): + # Ensure cls is a list for uniform processing + if not isinstance(cls, (list, tuple)): cls = [cls] - from tsbootstrap.utils.dependencies import _check_estimator_deps - - def _required_deps_present(obj): - """Check if all required soft dependencies are present, return bool.""" - if hasattr(obj, "get_class_tag"): - return _check_estimator_deps(obj, severity="none") - else: - return True - - # Condition 1: - # if any of the required soft dependencies are not present, do not run the test - if not all(_required_deps_present(x) for x in cls): - return False - # otherwise, continue - - return True + # Define the severity level and message for dependency checks + # Set to 'none' to silently return False without raising exceptions or warnings + severity = "none" + msg: Optional[str] = None # No custom message + + # Perform dependency checks for all classes/functions + # If any dependency is not met, the function will return False + # Since severity is 'none', no exceptions or warnings will be raised + try: + all_dependencies_present = _check_estimator_dependencies( + obj=cls, severity=severity, msg=msg + ) + except (ValueError, TypeError): + # Log the error if necessary, or handle it as per testing framework + # For now, we assume that any exception means dependencies are not met + all_dependencies_present = False + + return all_dependencies_present diff --git a/src/tsbootstrap/utils/dependencies.py b/src/tsbootstrap/utils/dependencies.py index 75a08923..83bc2cd7 100644 --- a/src/tsbootstrap/utils/dependencies.py +++ b/src/tsbootstrap/utils/dependencies.py @@ -1,75 +1,230 @@ -"""Utility to check soft dependency imports, and raise warnings or errors.""" +"""Utility module for checking soft dependency imports and raising warnings or errors.""" -__author__ = ["fkiraly"] +__author__ = ["fkiraly", "astrogilda"] +import logging +from enum import Enum +from typing import Any, List, Optional, Union + +from pydantic import BaseModel, Field, ValidationError, field_validator from skbase.utils.dependencies import ( _check_python_version, _check_soft_dependencies, ) +# Configure logging for the module +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +class SeverityEnum(str, Enum): + """ + Enumeration for severity levels. + + Attributes + ---------- + ERROR : str + Indicates that a `ModuleNotFoundError` should be raised if dependencies are not met. + WARNING : str + Indicates that a warning should be emitted if dependencies are not met. + NONE : str + Indicates that no action should be taken if dependencies are not met. + """ + + ERROR = "error" + WARNING = "warning" + NONE = "none" -def _check_estimator_deps(obj, msg=None, severity="error"): - """Check if object/estimator's package & python requirements are met by python env. - Convenience wrapper around `_check_python_version` and `_check_soft_dependencies`, - checking against estimator tags `"python_version"`, `"python_dependencies"`. +def _check_estimator_dependencies( + obj: Union[Any, List[Any], tuple], + severity: Union[str, SeverityEnum] = "error", + msg: Optional[str] = None, +) -> bool: + """ + Check if an object or list of objects' package and Python requirements are met by the current environment. - Checks whether dependency requirements of `BaseObject`-s in `obj` - are satisfied by the current python environment. + This function serves as a convenience wrapper around `_check_python_version` and `_check_soft_dependencies`, + utilizing the estimator tags `"python_version"` and `"python_dependencies"`. Parameters ---------- - obj : `BaseObject` descendant, instance or class, or list/tuple thereof - object(s) that this function checks compatibility of, with the python env - msg : str, optional, default = default message (msg below) - error message to be returned in the `ModuleNotFoundError`, overrides default - severity : str, "error" (default), "warning", or "none" - behaviour for raising errors or warnings - "error" - raises a `ModuleNotFoundError` if environment is incompatible - "warning" - raises a warning if environment is incompatible - function returns False if environment is incompatible, otherwise True - "none" - does not raise exception or warning - function returns False if environment is incompatible, otherwise True + obj : Union[Any, List[Any], tuple] + An object (instance or class) that is a descendant of `BaseObject`, or a list/tuple of such objects. + These objects are checked for compatibility with the current Python environment. + severity : Union[str, SeverityEnum], default="error" + Determines the behavior when incompatibility is detected: + - "error": Raises a `ModuleNotFoundError`. + - "warning": Emits a warning and returns `False`. + - "none": Silently returns `False` without raising an exception or warning. + msg : Optional[str], default=None + Custom error message to be used in the `ModuleNotFoundError`. + Overrides the default message if provided. Returns ------- - compatible : bool, whether `obj` is compatible with python environment - False is returned only if no exception is raised by the function - checks for python version using the python_version tag of obj - checks for soft dependencies present using the python_dependencies tag of obj - if `obj` contains multiple `BaseObject`-s, checks whether all are compatible + bool + `True` if all objects are compatible with the current environment; `False` otherwise. Raises ------ ModuleNotFoundError - User friendly error if obj has python_version tag that is - incompatible with the system python version. - Compatible python versions are determined by the "python_version" tag of obj. - User friendly error if obj has package dependencies that are not satisfied. - Packages are determined based on the "python_dependencies" tag of obj. + If `severity` is set to "error" and incompatibility is detected. + ValueError + If an invalid severity level is provided. + TypeError + If `obj` is not a `BaseObject` descendant or a list/tuple thereof. """ - compatible = True - # if list or tuple, recurse & iterate over element, and return conjunction - if isinstance(obj, (list, tuple)): # noqa: UP038 - for x in obj: - x_chk = _check_estimator_deps(x, msg=msg, severity=severity) - compatible = compatible and x_chk - return compatible + # Define an inner Pydantic model for validating input parameters + class DependencyCheckConfig(BaseModel): + """ + Pydantic model for configuring dependency checks. + + Attributes + ---------- + severity : SeverityEnum + Determines the behavior when incompatibility is detected. + msg : Optional[str] + Custom error message to be used in the `ModuleNotFoundError`. + """ - compatible = compatible and _check_python_version(obj, severity=severity) - - pkg_deps = obj.get_class_tag("python_dependencies", None) - pck_alias = obj.get_class_tag("python_dependencies_alias", None) - if pkg_deps is not None and not isinstance(pkg_deps, list): - pkg_deps = [pkg_deps] - if pkg_deps is not None: - pkg_deps_ok = _check_soft_dependencies( - *pkg_deps, - severity=severity, - obj=obj, - package_import_alias=pck_alias, + severity: SeverityEnum = Field( + default=SeverityEnum.ERROR, + description=( + "Determines the behavior when incompatibility is detected.\n" + "- 'error': Raises a `ModuleNotFoundError`.\n" + "- 'warning': Emits a warning and returns `False`.\n" + "- 'none': Silently returns `False` without raising an exception or warning." + ), ) - compatible = compatible and pkg_deps_ok + msg: Optional[str] = Field( + default=None, + description=( + "Custom error message to be used in the `ModuleNotFoundError`. " + "Overrides the default message if provided." + ), + ) + + @field_validator("severity", mode="before") + @classmethod + def validate_severity( + cls, v: Union[str, SeverityEnum] + ) -> SeverityEnum: + """ + Validate and convert the severity level to SeverityEnum. + + Parameters + ---------- + v : Union[str, SeverityEnum] + The severity level to validate. + + Returns + ------- + SeverityEnum + The validated severity level. + + Raises + ------ + ValueError + If the severity level is not one of the defined Enum members. + """ + if isinstance(v, str): + try: + return SeverityEnum(v.lower()) + except ValueError: + raise ValueError( + f"Invalid severity level '{v}'. Choose {[level.value for level in SeverityEnum]}" + ) from None + elif isinstance(v, SeverityEnum): + return v + else: + raise TypeError( + f"Severity must be a string or an instance of SeverityEnum, got {type(v)}." + ) + + try: + # Instantiate DependencyCheckConfig to validate severity and msg + config = DependencyCheckConfig(severity=severity, msg=msg) # type: ignore + except ValidationError as ve: + # Re-raise as a ValueError with detailed message + raise ValueError(f"Invalid input parameters: {ve}") from ve + + def _check_single_dependency(obj_single: Any) -> bool: + """ + Check dependencies for a single object. + + Parameters + ---------- + obj_single : Any + A single `BaseObject` descendant to check. + + Returns + ------- + bool + `True` if the object is compatible; `False` otherwise. + """ + if not hasattr(obj_single, "get_class_tag"): + raise TypeError( + f"Object {obj_single} does not have 'get_class_tag' method." + ) + + compatible = True + + # Check Python version compatibility + if not _check_python_version( + obj_single, severity=config.severity.value + ): + compatible = False + message = ( + config.msg or f"Python version incompatible for {obj_single}." + ) + if config.severity == SeverityEnum.ERROR: + raise ModuleNotFoundError(message) + elif config.severity == SeverityEnum.WARNING: + logger.warning(message) + + # Check soft dependencies + pkg_deps = obj_single.get_class_tag("python_dependencies", None) + pkg_alias = obj_single.get_class_tag("python_dependencies_alias", None) + + if pkg_deps: + if not isinstance(pkg_deps, list): + pkg_deps = [pkg_deps] + if not _check_soft_dependencies( + *pkg_deps, + severity=config.severity.value, + obj=obj_single, + package_import_alias=pkg_alias, + ): + compatible = False + message = ( + config.msg or f"Missing dependencies for {obj_single}." + ) + if config.severity == SeverityEnum.ERROR: + raise ModuleNotFoundError(message) + elif config.severity == SeverityEnum.WARNING: + logger.warning(message) + + return compatible + + compatible = True + + # If obj is a list or tuple, iterate and check each element + if isinstance(obj, (list, tuple)): + for item in obj: + try: + item_compatible = _check_single_dependency(item) + compatible = compatible and item_compatible + # Early exit if incompatibility detected and severity is ERROR + if not compatible and config.severity == SeverityEnum.ERROR: + break + except (ModuleNotFoundError, TypeError, ValueError): + if config.severity == SeverityEnum.ERROR: + raise + elif config.severity == SeverityEnum.WARNING: + compatible = False + return compatible - return compatible + # Single object check + return _check_single_dependency(obj)