blob: 65da7554052da242ec9961f636992bc44ed6ec27 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from collections.abc import Callable
import pandas as pd
import pytest
from hamilton import settings
from hamilton.function_modifiers import (
ResolveAt,
base,
extract_columns,
extract_fields,
parameterize_sources,
resolve,
resolve_from_config,
)
CONFIG_WITH_POWER_MODE_ENABLED = {
settings.ENABLE_POWER_USER_MODE: True,
}
CONFIG_WITH_POWER_MODE_DISABLED = {
settings.ENABLE_POWER_USER_MODE: False,
}
@pytest.mark.parametrize(
("fn", "required", "optional"),
[
(lambda: 1, [], {}),
(lambda a, b: 1, ["a", "b"], {}),
(lambda a, b=1: 1, ["a"], {"b": 1}),
(lambda a=1, b=1: 1, [], {"a": 1, "b": 1}),
],
)
def test_extract_and_validate_params_happy(fn: Callable, required: Callable, optional: Callable):
from hamilton.function_modifiers import delayed
assert delayed.extract_and_validate_params(fn) == (required, optional)
@pytest.mark.parametrize(
"fn",
[
lambda **kwargs: 1,
lambda a, b, *args: 1,
lambda a, b, *args, **kwargs: 1,
],
)
def test_extract_and_validate_params_unhappy(fn: Callable):
from hamilton.function_modifiers import delayed
with pytest.raises(base.InvalidDecoratorException):
delayed.extract_and_validate_params(fn)
def test_dynamic_resolves():
# Note: we use an empty DataFrame for validation only. This test would fail at runtime
# if we actually tried to execute the DAG because there are no columns "a" or "b" to extract.
def fn() -> pd.DataFrame:
return pd.DataFrame()
decorator = resolve(
when=ResolveAt.CONFIG_AVAILABLE,
decorate_with=lambda cols_to_extract: extract_columns(*cols_to_extract),
)
decorator_resolved = decorator.resolve(
{"cols_to_extract": ["a", "b"], **CONFIG_WITH_POWER_MODE_ENABLED}, fn=fn
)
# This uses an internal component of extract_columns
# We may want to add a little more comprehensive testing
# But for now this will work
assert decorator_resolved.columns == ("a", "b")
def test_dynamic_resolve_with_configs():
def fn() -> pd.DataFrame:
return pd.DataFrame()
decorator = resolve_from_config(
decorate_with=lambda cols_to_extract: extract_columns(*cols_to_extract),
)
decorator_resolved = decorator.resolve(
{"cols_to_extract": ["a", "b"], **CONFIG_WITH_POWER_MODE_ENABLED},
fn=fn,
)
# This uses an internal component of extract_columns
# We may want to add a little more comprehensive testing
# But for now this will work
assert decorator_resolved.columns == ("a", "b")
def test_dynamic_resolve_without_power_mode_fails():
def fn() -> pd.DataFrame:
return pd.DataFrame()
decorator = resolve(
when=ResolveAt.CONFIG_AVAILABLE,
decorate_with=lambda cols_to_extract: extract_columns(*cols_to_extract),
)
with pytest.raises(base.InvalidDecoratorException):
decorator.resolve(CONFIG_WITH_POWER_MODE_DISABLED, fn=fn)
def test_config_derivation():
decorator = resolve(
when=ResolveAt.CONFIG_AVAILABLE,
decorate_with=lambda cols_to_extract, some_cols_you_might_want_to_extract=[]: (
extract_columns(*cols_to_extract + some_cols_you_might_want_to_extract)
),
)
assert decorator.required_config() == ["cols_to_extract"]
assert decorator.optional_config() == {
"some_cols_you_might_want_to_extract": [],
}
def test_delayed_with_optional():
def fn() -> pd.DataFrame:
return pd.DataFrame()
decorator = resolve(
when=ResolveAt.CONFIG_AVAILABLE,
decorate_with=lambda cols_to_extract, some_cols_you_might_want_to_extract=["c"]: (
extract_columns(*cols_to_extract + some_cols_you_might_want_to_extract)
),
)
resolved = decorator.resolve(
{"cols_to_extract": ["a", "b"], **CONFIG_WITH_POWER_MODE_ENABLED},
fn=fn,
)
assert list(resolved.columns) == ["a", "b", "c"]
resolved = decorator.resolve(
{
"cols_to_extract": ["a", "b"],
"some_cols_you_might_want_to_extract": ["d"],
**CONFIG_WITH_POWER_MODE_ENABLED,
},
fn=fn,
)
assert list(resolved.columns) == ["a", "b", "d"]
def test_delayed_without_power_mode_fails():
def fn() -> pd.DataFrame:
return pd.DataFrame()
decorator = resolve(
when=ResolveAt.CONFIG_AVAILABLE,
decorate_with=lambda cols_to_extract, some_cols_you_might_want_to_extract=["c"]: (
extract_columns(*cols_to_extract + some_cols_you_might_want_to_extract)
),
)
with pytest.raises(base.InvalidDecoratorException) as exc_info:
decorator.resolve(
{"cols_to_extract": ["a", "b"], **CONFIG_WITH_POWER_MODE_DISABLED},
fn=fn,
)
error_message = str(exc_info.value)
assert "power user mode" in error_message
assert ".with_config({'hamilton.enable_power_user_mode': True})" in error_message
def test_delayed_without_power_mode_config_fails_with_helpful_error():
def fn() -> pd.DataFrame:
return pd.DataFrame()
decorator = resolve(
when=ResolveAt.CONFIG_AVAILABLE,
decorate_with=lambda cols_to_extract: extract_columns(*cols_to_extract),
)
with pytest.raises(base.InvalidDecoratorException) as exc_info:
decorator.resolve({"cols_to_extract": ["a", "b"]}, fn=fn)
error_message = str(exc_info.value)
assert "power user mode" in error_message
assert ".with_config({'hamilton.enable_power_user_mode': True})" in error_message
def test_dynamic_resolve_with_extract_fields():
"""Test that @resolve with @extract_fields calls validate() correctly."""
def fn() -> dict[str, int]:
return {"a": 1, "b": 2}
decorator = resolve(
when=ResolveAt.CONFIG_AVAILABLE,
decorate_with=lambda fields: extract_fields(fields),
)
decorator_resolved = decorator.resolve(
{"fields": {"a": int, "b": int}, **CONFIG_WITH_POWER_MODE_ENABLED},
fn=fn,
)
assert hasattr(decorator_resolved, "resolved_fields")
assert decorator_resolved.resolved_fields == {"a": int, "b": int}
def test_resolve_with_parameterize_sources():
"""Test that @resolve with @parameterize_sources calls validate() correctly."""
def fn(x: int, y: int) -> int:
return x + y
decorator = resolve(
when=ResolveAt.CONFIG_AVAILABLE,
decorate_with=lambda: parameterize_sources(result_1={"x": "source_x", "y": "source_y"}),
)
decorator_resolved = decorator.resolve(
{**CONFIG_WITH_POWER_MODE_ENABLED},
fn=fn,
)
assert "result_1" in decorator_resolved.parameterization
mapping = decorator_resolved.parameterization["result_1"]
assert mapping["x"].source == "source_x"
assert mapping["y"].source == "source_y"
def test_resolve_from_config_with_extract_fields():
"""Test @resolve_from_config with @extract_fields calls validate() correctly."""
def fn() -> dict[str, int]:
return {"a": 1, "b": 2}
decorator = resolve_from_config(
decorate_with=lambda fields: extract_fields(fields),
)
decorator_resolved = decorator.resolve(
{"fields": {"a": int, "b": int}, **CONFIG_WITH_POWER_MODE_ENABLED},
fn=fn,
)
assert hasattr(decorator_resolved, "resolved_fields")
assert decorator_resolved.resolved_fields == {"a": int, "b": int}
def test_resolve_propagates_validate_failure():
"""Test that validate() failures are propagated through resolve."""
def fn() -> str:
return "not what you were expecting..."
decorator = resolve(
when=ResolveAt.CONFIG_AVAILABLE,
decorate_with=lambda fields: extract_fields(fields),
)
with pytest.raises(base.InvalidDecoratorException):
decorator.resolve(
{"fields": {"a": int, "b": int}, **CONFIG_WITH_POWER_MODE_ENABLED},
fn=fn,
)
def test_resolve_with_arbitrary_decorator():
"""Test behavior when decorate_with returns something that is not a NodeTransformLifecycle."""
# NOTE: we want to ensure we don't interfere with other decorators on functions.
# A decorator that doesn't inherit from NodeTransformLifecycle (but still uses kwargs only)
class ArbitraryDecorator:
def __init__(self, a: int, b: int) -> None:
pass
def __call__(self, f: Callable) -> Callable:
return f
def fn() -> pd.DataFrame:
return pd.DataFrame()
decorator = resolve(
when=ResolveAt.CONFIG_AVAILABLE,
decorate_with=lambda kwargs: ArbitraryDecorator(**kwargs),
)
decorator_resolved = decorator.resolve(
{"kwargs": {"a": 1, "b": 2}, **CONFIG_WITH_POWER_MODE_ENABLED},
fn=fn,
)
assert isinstance(decorator_resolved, ArbitraryDecorator)