Skip to content

Commit

Permalink
updated test_switch to align with dependencies (#173)
Browse files Browse the repository at this point in the history
# 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 <[email protected]>
  • Loading branch information
astrogilda and Sankalp Gilda authored Oct 8, 2024
1 parent 0a29dc5 commit 75f5799
Show file tree
Hide file tree
Showing 2 changed files with 251 additions and 83 deletions.
79 changes: 46 additions & 33 deletions src/tsbootstrap/tests/test_switch.py
Original file line number Diff line number Diff line change
@@ -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
255 changes: 205 additions & 50 deletions src/tsbootstrap/utils/dependencies.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 75f5799

Please sign in to comment.