blob: 708f3aa343944457ceb87bb7a79502ff9ef42eba [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.
################################################################################
import os
import unittest
from unittest.mock import MagicMock, patch
import pyflink.dataframe as pf
from pyflink.dataframe import DataType
from pyflink.testing.test_case_utils import (
PyFlinkDataFrameUTTestCase,
PyFlinkStreamDataFrameTestCase,
)
class GenericIOTests(PyFlinkDataFrameUTTestCase):
_SCHEMA = {"id": DataType.int64(), "name": DataType.string()}
def test_read_generic_builds_source_descriptor(self):
with patch.object(
self.t_env,
"from_descriptor",
wraps=self.t_env.from_descriptor,
) as from_descriptor:
dataframe = pf.read_generic(
"datagen",
schema={
"id": DataType.int64(),
"ts_millis": DataType.int64(),
},
options={"number-of-rows": "1"},
computed_columns={
"event_time": "TO_TIMESTAMP_LTZ(ts_millis, 3)"
},
watermark=(
"event_time",
"event_time - INTERVAL '5' SECOND",
),
)
descriptor = from_descriptor.call_args.args[0]
self.assertEqual(descriptor.get_options().get("connector"), "datagen")
self.assertEqual(descriptor.get_options().get("number-of-rows"), "1")
self.assert_dataframe_schema(
dataframe,
["id", "ts_millis", "event_time"],
)
watermark_specs = dataframe._table.get_resolved_schema().get_watermark_specs()
self.assertEqual(len(watermark_specs), 1)
self.assertEqual(
watermark_specs[0].get_rowtime_attribute(), "event_time"
)
def test_read_generic_rejects_invalid_arguments(self):
cases = [
(
"connector type",
{"connector": None},
TypeError,
"connector must be a string",
),
(
"empty connector",
{"connector": ""},
ValueError,
"connector must not be empty",
),
(
"schema type",
{"schema": []},
TypeError,
"schema must be a dict",
),
(
"empty schema",
{"schema": {}},
ValueError,
"schema must not be empty",
),
(
"schema name",
{"schema": {1: DataType.int64()}},
TypeError,
"schema column names must be strings",
),
(
"empty schema name",
{"schema": {"": DataType.int64()}},
ValueError,
"schema column names must not be empty",
),
(
"schema data type",
{"schema": {"id": object()}},
TypeError,
"must use a DataType value",
),
(
"options type",
{"options": []},
TypeError,
"options must be a dict",
),
(
"option name",
{"options": {"": "value"}},
ValueError,
"option keys must not be empty",
),
(
"option key type",
{"options": {1: "value"}},
TypeError,
"option keys must be strings",
),
(
"reserved connector option",
{"options": {"connector": "filesystem"}},
ValueError,
"connector argument",
),
(
"option value",
{"options": {"rows": 1}},
TypeError,
"must have a string value",
),
(
"computed columns type",
{"computed_columns": []},
TypeError,
"computed_columns must be a dict",
),
(
"duplicate computed column",
{"computed_columns": {"id": "id + 1"}},
ValueError,
"conflicts with a physical column",
),
(
"computed column name type",
{"computed_columns": {1: "id + 1"}},
TypeError,
"computed column names must be strings",
),
(
"empty computed column name",
{"computed_columns": {"": "id + 1"}},
ValueError,
"computed column names must not be empty",
),
(
"computed expression type",
{"computed_columns": {"computed": 1}},
TypeError,
"must use a string expression",
),
(
"empty computed expression",
{"computed_columns": {"computed": ""}},
ValueError,
"expression must not be empty",
),
(
"watermark shape",
{"watermark": ("id",)},
TypeError,
"watermark must be a tuple",
),
(
"watermark type",
{"watermark": ("id", 1)},
TypeError,
"watermark column and expression must be strings",
),
(
"watermark value",
{"watermark": ("", "id")},
ValueError,
"must not be empty",
),
]
for name, overrides, error_type, message in cases:
with self.subTest(name=name):
arguments = {
"connector": "datagen",
"schema": self._SCHEMA,
"options": {},
}
arguments.update(overrides)
with self.assertRaisesRegex(error_type, message):
pf.read_generic(**arguments)
def test_write_generic_builds_sink_descriptor(self):
dataframe = pf.from_records([(1, "a")], schema=["id", "name"])
with patch.object(dataframe._table, "execute_insert") as execute_insert:
execute_insert.return_value = MagicMock()
result = dataframe.write_generic(
"blackhole", options={"sink.parallelism": "1"}
)
descriptor = execute_insert.call_args.args[0]
self.assertIsNone(result)
self.assertIsNone(descriptor.get_schema())
self.assertEqual(descriptor.get_options().get("connector"), "blackhole")
self.assertEqual(descriptor.get_options().get("sink.parallelism"), "1")
def test_write_generic_waits_for_local_and_minicluster_execution(self):
dataframe = pf.from_records([(1,)], schema=["id"])
for execution_target, waits in [
("local", True),
("minicluster", True),
("remote", False),
]:
with self.subTest(execution_target=execution_target):
table_result = MagicMock()
table_config = MagicMock()
table_config.get.return_value = execution_target
with patch.object(
dataframe._table,
"execute_insert",
return_value=table_result,
), patch.object(
dataframe._table._t_env,
"get_config",
return_value=table_config,
):
dataframe.write_generic("blackhole", options={})
if waits:
table_result.wait.assert_called_once_with()
else:
table_result.wait.assert_not_called()
def test_write_generic_uses_shared_validation(self):
dataframe = pf.from_records([(1,)], schema=["id"])
cases = [
(None, {}, TypeError, "connector must be a string"),
("", {}, ValueError, "connector must not be empty"),
("blackhole", [], TypeError, "options must be a dict"),
(
"blackhole",
{"connector": "filesystem"},
ValueError,
"connector argument",
),
(
"blackhole",
{"sink.parallelism": 1},
TypeError,
"must have a string value",
),
]
for connector, options, error_type, message in cases:
with self.subTest(connector=connector, options=options):
with self.assertRaisesRegex(error_type, message):
dataframe.write_generic(connector, options=options)
class GenericIOITTests(PyFlinkStreamDataFrameTestCase):
def test_filesystem_csv_round_trip(self):
input_path = os.path.join(self.tempdir, "input.csv")
with open(input_path, "w", encoding="utf-8") as input_file:
input_file.write("1,a\n2,b\n3,c\n")
source = pf.read_generic(
"filesystem",
schema={
"id": DataType.int64(),
"name": DataType.string(),
},
options={"path": input_path, "format": "csv"},
)
output_path = os.path.join(self.tempdir, "output")
source.write_generic(
"filesystem",
options={"path": output_path, "format": "csv"},
)
output_lines = []
for file_name in os.listdir(output_path):
file_path = os.path.join(output_path, file_name)
if os.path.isfile(file_path) and not file_name.startswith((".", "_")):
with open(file_path, encoding="utf-8") as output_file:
output_lines.extend(line.rstrip("\n") for line in output_file)
self.assertEqual(sorted(output_lines), ["1,a", "2,b", "3,c"])
if __name__ == "__main__":
unittest.main()