blob: 3688264b1ab26a6b9ed32fa4c4a5055f042353de [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 __future__ import annotations
from contextlib import nullcontext
import pytest
from airflow import PY311
from airflow.decorators import task
from airflow.exceptions import ParamValidationError, RemovedInAirflow3Warning
from airflow.models.param import Param, ParamsDict
from airflow.utils import timezone
from airflow.utils.types import DagRunType
from tests.test_utils.db import clear_db_dags, clear_db_runs, clear_db_xcom
class TestParam:
def test_param_without_schema(self):
p = Param("test")
assert p.resolve() == "test"
p.value = 10
assert p.resolve() == 10
def test_null_param(self):
p = Param()
with pytest.raises(ParamValidationError, match="No value passed and Param has no default value"):
p.resolve()
assert p.resolve(None) is None
p = Param(None)
assert p.resolve() is None
assert p.resolve(None) is None
p = Param(None, type="null")
assert p.resolve() is None
assert p.resolve(None) is None
with pytest.raises(ParamValidationError):
p.resolve("test")
def test_string_param(self):
p = Param("test", type="string")
assert p.resolve() == "test"
p = Param("test")
assert p.resolve() == "test"
p = Param("10.0.0.0", type="string", format="ipv4")
assert p.resolve() == "10.0.0.0"
p = Param(type="string")
with pytest.raises(ParamValidationError):
p.resolve(None)
with pytest.raises(ParamValidationError, match="No value passed and Param has no default value"):
p.resolve()
@pytest.mark.parametrize(
"dt",
[
pytest.param("2022-01-02T03:04:05.678901Z", id="microseconds-zed-timezone"),
pytest.param("2022-01-02T03:04:05.678Z", id="milliseconds-zed-timezone"),
pytest.param("2022-01-02T03:04:05+00:00", id="seconds-00-00-timezone"),
pytest.param("2022-01-02T03:04:05+04:00", id="seconds-custom-timezone"),
],
)
def test_string_rfc3339_datetime_format(self, dt):
"""Test valid rfc3339 datetime."""
assert Param(dt, type="string", format="date-time").resolve() == dt
@pytest.mark.parametrize(
"dt",
[
pytest.param("2022-01-02 03:04:05.678901Z", id="space-sep"),
pytest.param("2022-01-02T03:04:05.678901", id="tz-naive"),
pytest.param("2022-01-02T03Z", id="datetime-with-day-only"),
pytest.param("20161001T143028+0530", id="not-formatted-date-time"),
],
)
def test_string_iso8601_datetime_invalid_rfc3339_format(self, dt):
"""Test valid iso8601 datetime but not valid rfc3339 datetime conversion."""
with pytest.warns(RemovedInAirflow3Warning):
assert Param(dt, type="string", format="date-time").resolve() == dt
@pytest.mark.parametrize(
"dt",
[
pytest.param("2022-01-02", id="date"),
pytest.param("03:04:05", id="time"),
pytest.param("Thu, 04 Mar 2021 05:06:07 GMT", id="rfc2822-datetime"),
],
)
def test_string_datetime_invalid_format(self, dt):
"""Test invalid iso8601 and rfc3339 datetime format."""
with pytest.raises(ParamValidationError, match="is not a 'date-time'"):
Param(dt, type="string", format="date-time").resolve()
def test_string_time_format(self):
"""Test string time format."""
assert Param("03:04:05", type="string", format="time").resolve() == "03:04:05"
error_pattern = "is not a 'time'"
with pytest.raises(ParamValidationError, match=error_pattern):
Param("03:04:05.06", type="string", format="time").resolve()
with pytest.raises(ParamValidationError, match=error_pattern):
Param("03:04", type="string", format="time").resolve()
with pytest.raises(ParamValidationError, match=error_pattern):
Param("24:00:00", type="string", format="time").resolve()
@pytest.mark.parametrize(
"date_string",
[
"2021-01-01",
pytest.param(
"20120503",
marks=pytest.mark.skipif(not PY311, reason="Improved fromisoformat() in 3.11."),
),
],
)
def test_string_date_format(self, date_string):
"""Test string date format."""
assert Param(date_string, type="string", format="date").resolve() == date_string
@pytest.mark.parametrize(
"date_string",
[
"01/01/2021",
"21 May 1975",
pytest.param(
"20120503",
marks=pytest.mark.skipif(PY311, reason="Improved fromisoformat() in 3.11."),
),
],
)
def test_string_date_format_error(self, date_string):
"""Test string date format failures."""
with pytest.raises(ParamValidationError, match="is not a 'date'"):
Param(date_string, type="string", format="date").resolve()
def test_int_param(self):
p = Param(5)
assert p.resolve() == 5
p = Param(type="integer", minimum=0, maximum=10)
assert p.resolve(value=5) == 5
with pytest.raises(ParamValidationError):
p.resolve(value=20)
def test_number_param(self):
p = Param(42, type="number")
assert p.resolve() == 42
p = Param(1.0, type="number")
assert p.resolve() == 1.0
with pytest.raises(ParamValidationError):
p = Param("42", type="number")
p.resolve()
def test_list_param(self):
p = Param([1, 2], type="array")
assert p.resolve() == [1, 2]
def test_dict_param(self):
p = Param({"a": 1, "b": 2}, type="object")
assert p.resolve() == {"a": 1, "b": 2}
def test_composite_param(self):
p = Param(type=["string", "number"])
assert p.resolve(value="abc") == "abc"
assert p.resolve(value=5.0) == 5.0
def test_param_with_description(self):
p = Param(10, description="Sample description")
assert p.description == "Sample description"
def test_suppress_exception(self):
p = Param("abc", type="string", minLength=2, maxLength=4)
assert p.resolve() == "abc"
p.value = "long_string"
assert p.resolve(suppress_exception=True) is None
def test_explicit_schema(self):
p = Param("abc", schema={type: "string"})
assert p.resolve() == "abc"
def test_custom_param(self):
class S3Param(Param):
def __init__(self, path: str):
schema = {"type": "string", "pattern": r"s3:\/\/(.+?)\/(.+)"}
super().__init__(default=path, schema=schema)
p = S3Param("s3://my_bucket/my_path")
assert p.resolve() == "s3://my_bucket/my_path"
with pytest.raises(ParamValidationError):
p = S3Param("file://not_valid/s3_path")
p.resolve()
def test_value_saved(self):
p = Param("hello", type="string")
assert p.resolve("world") == "world"
assert p.resolve() == "world"
def test_dump(self):
p = Param("hello", description="world", type="string", minLength=2)
dump = p.dump()
assert dump["__class"] == "airflow.models.param.Param"
assert dump["value"] == "hello"
assert dump["description"] == "world"
assert dump["schema"] == {"type": "string", "minLength": 2}
class TestParamsDict:
def test_params_dict(self):
# Init with a simple dictionary
pd = ParamsDict(dict_obj={"key": "value"})
assert isinstance(pd.get_param("key"), Param)
assert pd["key"] == "value"
assert pd.suppress_exception is False
# Init with a dict which contains Param objects
pd2 = ParamsDict({"key": Param("value", type="string")}, suppress_exception=True)
assert isinstance(pd2.get_param("key"), Param)
assert pd2["key"] == "value"
assert pd2.suppress_exception is True
# Init with another object of another ParamsDict
pd3 = ParamsDict(pd2)
assert isinstance(pd3.get_param("key"), Param)
assert pd3["key"] == "value"
assert pd3.suppress_exception is False # as it's not a deepcopy of pd2
# Dump the ParamsDict
assert pd.dump() == {"key": "value"}
assert pd2.dump() == {"key": "value"}
assert pd3.dump() == {"key": "value"}
# Validate the ParamsDict
plain_dict = pd.validate()
assert type(plain_dict) == dict
pd2.validate()
pd3.validate()
# Update the ParamsDict
with pytest.raises(ParamValidationError, match=r"Invalid input for param key: 1 is not"):
pd3["key"] = 1
# Should not raise an error as suppress_exception is True
pd2["key"] = 1
pd2.validate()
def test_update(self):
pd = ParamsDict({"key": Param("value", type="string")})
pd.update({"key": "a"})
internal_value = pd.get_param("key")
assert isinstance(internal_value, Param)
with pytest.raises(ParamValidationError, match=r"Invalid input for param key: 1 is not"):
pd.update({"key": 1})
def test_repr(self):
pd = ParamsDict({"key": Param("value", type="string")})
assert repr(pd) == "{'key': 'value'}"
class TestDagParamRuntime:
VALUE = 42
DEFAULT_DATE = timezone.datetime(2016, 1, 1)
@staticmethod
def clean_db():
clear_db_runs()
clear_db_dags()
clear_db_xcom()
def setup_class(self):
self.clean_db()
def teardown_method(self):
self.clean_db()
def test_dag_param_resolves(self, dag_maker):
"""Test dagparam resolves on operator execution"""
with dag_maker(dag_id="test_xcom_pass_to_op") as dag:
value = dag.param("value", default=self.VALUE)
@task
def return_num(num):
return num
xcom_arg = return_num(value)
dr = dag_maker.create_dagrun(
run_id=DagRunType.MANUAL.value,
start_date=timezone.utcnow(),
)
xcom_arg.operator.run(dr.execution_date, dr.execution_date)
ti = dr.get_task_instances()[0]
assert ti.xcom_pull() == self.VALUE
def test_dag_param_overwrite(self, dag_maker):
"""Test dag param is overwritten from dagrun config"""
with dag_maker(dag_id="test_xcom_pass_to_op") as dag:
value = dag.param("value", default=self.VALUE)
@task
def return_num(num):
return num
xcom_arg = return_num(value)
assert dag.params["value"] == self.VALUE
new_value = 2
dr = dag_maker.create_dagrun(
run_id=DagRunType.MANUAL.value,
start_date=timezone.utcnow(),
conf={"value": new_value},
)
xcom_arg.operator.run(dr.execution_date, dr.execution_date)
ti = dr.get_task_instances()[0]
assert ti.xcom_pull() == new_value
def test_dag_param_default(self, dag_maker):
"""Test dag param is retrieved from default config"""
with dag_maker(dag_id="test_xcom_pass_to_op", params={"value": "test"}) as dag:
value = dag.param("value")
@task
def return_num(num):
return num
xcom_arg = return_num(value)
dr = dag_maker.create_dagrun(run_id=DagRunType.MANUAL.value, start_date=timezone.utcnow())
xcom_arg.operator.run(dr.execution_date, dr.execution_date)
ti = dr.get_task_instances()[0]
assert ti.xcom_pull() == "test"
@pytest.mark.parametrize(
"default, should_warn",
[
pytest.param({0, 1, 2}, True, id="default-non-JSON-serializable"),
pytest.param(None, False, id="default-None"), # Param init should not warn
pytest.param({"b": 1}, False, id="default-JSON-serializable"), # Param init should not warn
],
)
def test_param_json_warning(self, default, should_warn):
warning_msg = "The use of non-json-serializable params is deprecated"
cm = pytest.warns(DeprecationWarning, match=warning_msg) if should_warn else nullcontext()
with cm:
p = Param(default=default)
p.resolve() # when resolved with NOTSET, should not warn.
p.resolve(value={"a": 1}) # when resolved with JSON-serializable, should not warn.
with pytest.warns(DeprecationWarning, match=warning_msg):
p.resolve(value={1, 2, 3}) # when resolved with not JSON-serializable, should warn.