571 lines
19 KiB
Python
571 lines
19 KiB
Python
import re
|
|
import warnings
|
|
from typing import Dict, Union
|
|
from unittest.mock import patch
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pyarrow
|
|
import pytest
|
|
|
|
import ray
|
|
from ray.data.aggregate import Mean
|
|
from ray.data.constants import MAX_REPR_LENGTH
|
|
from ray.data.preprocessor import Preprocessor
|
|
from ray.data.preprocessors import (
|
|
Categorizer,
|
|
Chain,
|
|
Concatenator,
|
|
CountVectorizer,
|
|
FeatureHasher,
|
|
HashingVectorizer,
|
|
LabelEncoder,
|
|
MaxAbsScaler,
|
|
MinMaxScaler,
|
|
MultiHotEncoder,
|
|
Normalizer,
|
|
OneHotEncoder,
|
|
OrdinalEncoder,
|
|
PowerTransformer,
|
|
RobustScaler,
|
|
SimpleImputer,
|
|
StandardScaler,
|
|
Tokenizer,
|
|
TorchVisionPreprocessor,
|
|
)
|
|
from ray.data.util.data_batch_conversion import BatchFormat
|
|
|
|
|
|
@pytest.fixture
|
|
def create_dummy_preprocessors():
|
|
class DummyPreprocessorWithNothing(Preprocessor):
|
|
_is_fittable = False
|
|
|
|
class DummyPreprocessorWithPandas(DummyPreprocessorWithNothing):
|
|
def _transform_pandas(self, df: "pd.DataFrame") -> "pd.DataFrame":
|
|
return df
|
|
|
|
class DummyPreprocessorWithNumpy(DummyPreprocessorWithNothing):
|
|
batch_format = "numpy"
|
|
|
|
def _transform_numpy(
|
|
self, np_data: Union[np.ndarray, Dict[str, np.ndarray]]
|
|
) -> Union[np.ndarray, Dict[str, np.ndarray]]:
|
|
return np_data
|
|
|
|
class DummyPreprocessorWithPandasAndNumpy(DummyPreprocessorWithNothing):
|
|
def _transform_pandas(self, df: "pd.DataFrame") -> "pd.DataFrame":
|
|
return df
|
|
|
|
def _transform_numpy(
|
|
self, np_data: Union[np.ndarray, Dict[str, np.ndarray]]
|
|
) -> Union[np.ndarray, Dict[str, np.ndarray]]:
|
|
return np_data
|
|
|
|
class DummyPreprocessorWithPandasAndNumpyPreferred(DummyPreprocessorWithNothing):
|
|
def _transform_pandas(self, df: "pd.DataFrame") -> "pd.DataFrame":
|
|
return df
|
|
|
|
def _transform_numpy(
|
|
self, np_data: Union[np.ndarray, Dict[str, np.ndarray]]
|
|
) -> Union[np.ndarray, Dict[str, np.ndarray]]:
|
|
return np_data
|
|
|
|
def preferred_batch_format(cls) -> BatchFormat:
|
|
return BatchFormat.NUMPY
|
|
|
|
yield (
|
|
DummyPreprocessorWithNothing(),
|
|
DummyPreprocessorWithPandas(),
|
|
DummyPreprocessorWithNumpy(),
|
|
DummyPreprocessorWithPandasAndNumpy(),
|
|
DummyPreprocessorWithPandasAndNumpyPreferred(),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"preprocessor",
|
|
[
|
|
Categorizer(columns=["X"]),
|
|
CountVectorizer(columns=["X"]),
|
|
Chain(StandardScaler(columns=["X"]), MinMaxScaler(columns=["X"])),
|
|
FeatureHasher(columns=["X"], num_features=1, output_column="X_transformed"),
|
|
HashingVectorizer(columns=["X"], num_features=1),
|
|
LabelEncoder(label_column="X"),
|
|
MaxAbsScaler(columns=["X"]),
|
|
MinMaxScaler(columns=["X"]),
|
|
MultiHotEncoder(columns=["X"]),
|
|
Normalizer(columns=["X"]),
|
|
OneHotEncoder(columns=["X"]),
|
|
OrdinalEncoder(columns=["X"]),
|
|
PowerTransformer(columns=["X"], power=1),
|
|
RobustScaler(columns=["X"]),
|
|
SimpleImputer(columns=["X"]),
|
|
StandardScaler(columns=["X"]),
|
|
Concatenator(columns=["X"]),
|
|
Tokenizer(columns=["X"]),
|
|
],
|
|
)
|
|
def test_repr(preprocessor):
|
|
representation = repr(preprocessor)
|
|
|
|
assert len(representation) < MAX_REPR_LENGTH
|
|
pattern = re.compile(f"^{preprocessor.__class__.__name__}\\((.*)\\)$")
|
|
assert pattern.match(representation)
|
|
|
|
|
|
def test_fitted_preprocessor_without_stats():
|
|
"""Tests that Preprocessors can be fitted without needing to set self.stats_."""
|
|
|
|
class FittablePreprocessor(Preprocessor):
|
|
def _fit(self, ds):
|
|
return self
|
|
|
|
preprocessor = FittablePreprocessor()
|
|
ds = ray.data.from_items([1])
|
|
_ = preprocessor.fit(ds)
|
|
assert preprocessor.fit_status() == Preprocessor.FitStatus.FITTED
|
|
|
|
|
|
def test_fitted_preprocessor_with_stats():
|
|
"""Tests that Preprocessors can be fitted by setting an attribute that ends
|
|
with _."""
|
|
|
|
class FittablePreprocessor(Preprocessor):
|
|
...
|
|
|
|
preprocessor = FittablePreprocessor()
|
|
preprocessor.stats_ = True
|
|
assert preprocessor.fit_status() == Preprocessor.FitStatus.FITTED
|
|
|
|
|
|
@patch.object(warnings, "warn")
|
|
def test_fit_twice(mocked_warn):
|
|
"""Tests that a warning msg should be printed."""
|
|
col_a = [-1, 0, 1]
|
|
col_b = [1, 3, 5]
|
|
col_c = [1, 1, None]
|
|
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b, "C": col_c})
|
|
ds = ray.data.from_pandas(in_df)
|
|
|
|
scaler = MinMaxScaler(["B", "C"])
|
|
|
|
# Fit data.
|
|
scaler.fit(ds)
|
|
assert scaler.stats_ == {"min(B)": 1, "max(B)": 5, "min(C)": 1, "max(C)": 1}
|
|
|
|
ds = ds.map_batches(lambda x: {k: v * 2 for k, v in x.items()})
|
|
# Fit again
|
|
scaler.fit(ds)
|
|
# Assert that the fitted state is corresponding to the second ds.
|
|
assert scaler.stats_ == {"min(B)": 2, "max(B)": 10, "min(C)": 2, "max(C)": 2}
|
|
msg = (
|
|
"`fit` has already been called on the preprocessor (or at least one "
|
|
"contained preprocessors if this is a chain). "
|
|
"All previously fitted state will be overwritten!"
|
|
)
|
|
mocked_warn.assert_called_once_with(msg)
|
|
|
|
|
|
def test_fit_twice_clears_stale_stats():
|
|
"""Tests that fit() clears stale stats when stat keys are data-dependent.
|
|
|
|
When a preprocessor's stat keys depend on the data (e.g., auto-detected columns),
|
|
calling fit() again on a different dataset should not retain stale stats from
|
|
the previous fit. This ensures that fit(A).fit(B) is equivalent to fit(B).
|
|
"""
|
|
|
|
class DataDependentPreprocessor(Preprocessor):
|
|
"""A preprocessor whose stat keys depend on the data columns present."""
|
|
|
|
_is_fittable = True
|
|
|
|
def _fit(self, ds):
|
|
# Dynamically detect columns from the dataset schema
|
|
schema = ds.schema()
|
|
column_names = list(schema.names)
|
|
self.stat_computation_plan.add_aggregator(
|
|
aggregator_fn=Mean,
|
|
columns=column_names,
|
|
)
|
|
return self
|
|
|
|
def _transform_pandas(self, df):
|
|
return df
|
|
|
|
# Dataset A has columns: "a", "b"
|
|
dataset_a = ray.data.from_items(
|
|
[
|
|
{"a": 1.0, "b": 10.0},
|
|
{"a": 2.0, "b": 20.0},
|
|
{"a": 3.0, "b": 30.0},
|
|
]
|
|
)
|
|
|
|
# Dataset B has columns: "b", "c" (note: "a" is missing, "c" is new)
|
|
dataset_b = ray.data.from_items(
|
|
[
|
|
{"b": 100.0, "c": 1000.0},
|
|
{"b": 200.0, "c": 2000.0},
|
|
{"b": 300.0, "c": 3000.0},
|
|
]
|
|
)
|
|
|
|
preprocessor = DataDependentPreprocessor()
|
|
|
|
# First fit on dataset A
|
|
preprocessor.fit(dataset_a)
|
|
assert preprocessor.stats_ == {"mean(a)": 2.0, "mean(b)": 20.0}
|
|
|
|
# Second fit on dataset B - stale stats should be cleared
|
|
preprocessor.fit(dataset_b)
|
|
|
|
# Verify stale stat "mean(a)" is NOT present
|
|
# Verify stats are correct after refit, and stale stats are cleared.
|
|
expected_stats = {"mean(b)": 200.0, "mean(c)": 2000.0}
|
|
assert preprocessor.stats_ == expected_stats, (
|
|
f"Stats after refit are incorrect. "
|
|
f"Expected: {expected_stats}, Got: {preprocessor.stats_}"
|
|
)
|
|
|
|
|
|
def test_transform_all_configs():
|
|
batch_size = 2
|
|
num_cpus = 2
|
|
concurrency = 2
|
|
memory = 1024
|
|
|
|
class DummyPreprocessor(Preprocessor):
|
|
_is_fittable = False
|
|
|
|
def _get_transform_config(self):
|
|
return {"batch_size": batch_size}
|
|
|
|
def _transform_numpy(self, data):
|
|
assert ray.get_runtime_context().get_assigned_resources()["CPU"] == num_cpus
|
|
assert (
|
|
ray.get_runtime_context().get_assigned_resources()["memory"] == memory
|
|
)
|
|
# Read(10 rows) → Limit(5) → Transform(batch_size=2)
|
|
assert (
|
|
len(data["value"]) <= batch_size
|
|
) # The last batch is size 1, and limit pushdown resulted in the transform occurring for fewer rows.
|
|
return data
|
|
|
|
def _transform_pandas(self, data):
|
|
raise RuntimeError(
|
|
"Pandas transform should not be called with numpy batch format."
|
|
)
|
|
|
|
def _determine_transform_to_use(self):
|
|
return "numpy"
|
|
|
|
prep = DummyPreprocessor()
|
|
ds = ray.data.from_pandas(pd.DataFrame({"value": list(range(10))}))
|
|
ds = prep.transform(
|
|
ds,
|
|
num_cpus=num_cpus,
|
|
memory=memory,
|
|
concurrency=concurrency,
|
|
)
|
|
assert [x["value"] for x in ds.take(5)] == [0, 1, 2, 3, 4]
|
|
|
|
|
|
@pytest.mark.parametrize("dataset_format", ["simple", "pandas", "arrow"])
|
|
def test_transform_all_formats(create_dummy_preprocessors, dataset_format):
|
|
(
|
|
with_nothing,
|
|
with_pandas,
|
|
with_numpy,
|
|
with_pandas_and_numpy,
|
|
with_pandas_and_numpy_preferred,
|
|
) = create_dummy_preprocessors
|
|
|
|
if dataset_format == "simple":
|
|
ds = ray.data.range(10)
|
|
elif dataset_format == "pandas":
|
|
df = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=["A", "B", "C"])
|
|
ds = ray.data.from_pandas(df)
|
|
elif dataset_format == "arrow":
|
|
df = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=["A", "B", "C"])
|
|
ds = ray.data.from_arrow(pyarrow.Table.from_pandas(df))
|
|
else:
|
|
raise ValueError(f"Untested dataset_format configuration: {dataset_format}.")
|
|
|
|
with pytest.raises(NotImplementedError):
|
|
with_nothing.transform(ds)
|
|
|
|
patcher = patch.object(ray.data.dataset.Dataset, "map_batches")
|
|
|
|
with patcher as mock_map_batches:
|
|
with_pandas.transform(ds)
|
|
mock_map_batches.assert_called_once_with(
|
|
with_pandas._transform_pandas,
|
|
batch_format=BatchFormat.PANDAS,
|
|
zero_copy_batch=True,
|
|
)
|
|
|
|
with patcher as mock_map_batches:
|
|
with_numpy.transform(ds)
|
|
mock_map_batches.assert_called_once_with(
|
|
with_numpy._transform_numpy,
|
|
batch_format=BatchFormat.NUMPY,
|
|
zero_copy_batch=True,
|
|
)
|
|
|
|
# Pandas preferred by default.
|
|
with patcher as mock_map_batches:
|
|
with_pandas_and_numpy.transform(ds)
|
|
mock_map_batches.assert_called_once_with(
|
|
with_pandas_and_numpy._transform_pandas,
|
|
batch_format=BatchFormat.PANDAS,
|
|
zero_copy_batch=True,
|
|
)
|
|
|
|
with patcher as mock_map_batches:
|
|
with_pandas_and_numpy_preferred.transform(ds)
|
|
mock_map_batches.assert_called_once_with(
|
|
with_pandas_and_numpy_preferred._transform_numpy,
|
|
batch_format=BatchFormat.NUMPY,
|
|
zero_copy_batch=True,
|
|
)
|
|
|
|
|
|
def test_numpy_pandas_support_transform_batch_wrong_format(create_dummy_preprocessors):
|
|
# Case 1: simple dataset. No support
|
|
(
|
|
with_nothing,
|
|
with_pandas,
|
|
with_numpy,
|
|
with_pandas_and_numpy,
|
|
with_pandas_and_numpy_preferred,
|
|
) = create_dummy_preprocessors
|
|
|
|
batch = [1, 2, 3]
|
|
with pytest.raises(ValueError):
|
|
with_nothing.transform_batch(batch)
|
|
|
|
with pytest.raises(ValueError):
|
|
with_pandas.transform_batch(batch)
|
|
|
|
with pytest.raises(ValueError):
|
|
with_numpy.transform_batch(batch)
|
|
|
|
with pytest.raises(ValueError):
|
|
with_pandas_and_numpy.transform_batch(batch)
|
|
|
|
with pytest.raises(ValueError):
|
|
with_pandas_and_numpy_preferred.transform_batch(batch)
|
|
|
|
|
|
def test_numpy_pandas_support_transform_batch_pandas(create_dummy_preprocessors):
|
|
# Case 2: pandas dataset
|
|
(
|
|
with_nothing,
|
|
with_pandas,
|
|
with_numpy,
|
|
with_pandas_and_numpy,
|
|
with_pandas_and_numpy_preferred,
|
|
) = create_dummy_preprocessors
|
|
|
|
df = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=["A", "B", "C"])
|
|
df_single_column = pd.DataFrame([1, 2, 3], columns=["A"])
|
|
with pytest.raises(NotImplementedError):
|
|
with_nothing.transform_batch(df)
|
|
with pytest.raises(NotImplementedError):
|
|
with_nothing.transform_batch(df_single_column)
|
|
|
|
assert isinstance(with_pandas.transform_batch(df), pd.DataFrame)
|
|
assert isinstance(with_pandas.transform_batch(df_single_column), pd.DataFrame)
|
|
|
|
assert isinstance(with_numpy.transform_batch(df), (np.ndarray, dict))
|
|
# We can get pd.DataFrame after returning numpy data from UDF
|
|
assert isinstance(with_numpy.transform_batch(df_single_column), (np.ndarray, dict))
|
|
|
|
assert isinstance(with_pandas_and_numpy.transform_batch(df), pd.DataFrame)
|
|
assert isinstance(
|
|
with_pandas_and_numpy.transform_batch(df_single_column), pd.DataFrame
|
|
)
|
|
|
|
assert isinstance(
|
|
with_pandas_and_numpy_preferred.transform_batch(df), (np.ndarray, dict)
|
|
)
|
|
assert isinstance(
|
|
with_pandas_and_numpy_preferred.transform_batch(df_single_column),
|
|
(np.ndarray, dict),
|
|
)
|
|
|
|
|
|
def test_numpy_pandas_support_transform_batch_arrow(create_dummy_preprocessors):
|
|
# Case 3: arrow dataset
|
|
(
|
|
with_nothing,
|
|
with_pandas,
|
|
with_numpy,
|
|
with_pandas_and_numpy,
|
|
with_pandas_and_numpy_preferred,
|
|
) = create_dummy_preprocessors
|
|
|
|
df = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=["A", "B", "C"])
|
|
df_single_column = pd.DataFrame([1, 2, 3], columns=["A"])
|
|
|
|
table = pyarrow.Table.from_pandas(df)
|
|
table_single_column = pyarrow.Table.from_pandas(df_single_column)
|
|
with pytest.raises(NotImplementedError):
|
|
with_nothing.transform_batch(table)
|
|
with pytest.raises(NotImplementedError):
|
|
with_nothing.transform_batch(table_single_column)
|
|
|
|
assert isinstance(with_pandas.transform_batch(table), pd.DataFrame)
|
|
assert isinstance(with_pandas.transform_batch(table_single_column), pd.DataFrame)
|
|
|
|
assert isinstance(with_numpy.transform_batch(table), (np.ndarray, dict))
|
|
# We can get pyarrow.Table after returning numpy data from UDF
|
|
assert isinstance(
|
|
with_numpy.transform_batch(table_single_column), (np.ndarray, dict)
|
|
)
|
|
|
|
assert isinstance(with_pandas_and_numpy.transform_batch(table), pd.DataFrame)
|
|
assert isinstance(
|
|
with_pandas_and_numpy.transform_batch(table_single_column), pd.DataFrame
|
|
)
|
|
|
|
assert isinstance(
|
|
with_pandas_and_numpy_preferred.transform_batch(table), (np.ndarray, dict)
|
|
)
|
|
assert isinstance(
|
|
with_pandas_and_numpy_preferred.transform_batch(table_single_column),
|
|
(np.ndarray, dict),
|
|
)
|
|
|
|
|
|
def test_numpy_pandas_support_transform_batch_tensor(create_dummy_preprocessors):
|
|
# Case 4: tensor dataset created by from numpy data directly
|
|
(
|
|
with_nothing,
|
|
with_pandas,
|
|
with_numpy,
|
|
with_pandas_and_numpy,
|
|
with_pandas_and_numpy_preferred,
|
|
) = create_dummy_preprocessors
|
|
np_data = np.arange(12).reshape(3, 2, 2)
|
|
np_single_column = {"A": np.arange(12).reshape(3, 2, 2)}
|
|
np_multi_column = {
|
|
"A": np.arange(12).reshape(3, 2, 2),
|
|
"B": np.arange(12, 24).reshape(3, 2, 2),
|
|
}
|
|
|
|
with pytest.raises(NotImplementedError):
|
|
with_nothing.transform_batch(np_data)
|
|
with pytest.raises(NotImplementedError):
|
|
with_nothing.transform_batch(np_single_column)
|
|
with pytest.raises(NotImplementedError):
|
|
with_nothing.transform_batch(np_multi_column)
|
|
|
|
assert isinstance(with_pandas.transform_batch(np_data), pd.DataFrame)
|
|
assert isinstance(with_pandas.transform_batch(np_single_column), pd.DataFrame)
|
|
assert isinstance(with_pandas.transform_batch(np_multi_column), pd.DataFrame)
|
|
|
|
assert isinstance(with_numpy.transform_batch(np_data), np.ndarray)
|
|
assert isinstance(with_numpy.transform_batch(np_single_column), dict)
|
|
assert isinstance(with_numpy.transform_batch(np_multi_column), dict)
|
|
|
|
assert isinstance(with_pandas_and_numpy.transform_batch(np_data), pd.DataFrame)
|
|
assert isinstance(
|
|
with_pandas_and_numpy.transform_batch(np_single_column), pd.DataFrame
|
|
)
|
|
assert isinstance(
|
|
with_pandas_and_numpy.transform_batch(np_multi_column), pd.DataFrame
|
|
)
|
|
|
|
assert isinstance(
|
|
with_pandas_and_numpy_preferred.transform_batch(np_data), np.ndarray
|
|
)
|
|
assert isinstance(
|
|
with_pandas_and_numpy_preferred.transform_batch(np_single_column), dict
|
|
)
|
|
assert isinstance(
|
|
with_pandas_and_numpy_preferred.transform_batch(np_multi_column), dict
|
|
)
|
|
|
|
|
|
def test_get_input_output_columns():
|
|
"""Tests get_input_columns() and get_output_columns() methods."""
|
|
# Test with preprocessors that have columns attribute
|
|
scaler = StandardScaler(columns=["A", "B"])
|
|
assert scaler.get_input_columns() == ["A", "B"]
|
|
assert scaler.get_output_columns() == ["A", "B"]
|
|
|
|
# Test with output_columns specified
|
|
scaler_with_output = StandardScaler(
|
|
columns=["A", "B"], output_columns=["A_scaled", "B_scaled"]
|
|
)
|
|
assert scaler_with_output.get_input_columns() == ["A", "B"]
|
|
assert scaler_with_output.get_output_columns() == ["A_scaled", "B_scaled"]
|
|
|
|
# Test with encoders
|
|
encoder = OneHotEncoder(columns=["X", "Y"])
|
|
assert encoder.get_input_columns() == ["X", "Y"]
|
|
assert encoder.get_output_columns() == ["X", "Y"]
|
|
|
|
encoder_with_output = OneHotEncoder(
|
|
columns=["X", "Y"], output_columns=["X_encoded", "Y_encoded"]
|
|
)
|
|
assert encoder_with_output.get_input_columns() == ["X", "Y"]
|
|
assert encoder_with_output.get_output_columns() == ["X_encoded", "Y_encoded"]
|
|
|
|
# Test LabelEncoder without output_column (in-place transformation)
|
|
label_encoder = LabelEncoder(label_column="target")
|
|
assert label_encoder.get_input_columns() == ["target"]
|
|
assert label_encoder.get_output_columns() == ["target"]
|
|
|
|
# Test LabelEncoder with output_column (append mode)
|
|
label_encoder = LabelEncoder(label_column="target", output_column="target_encoded")
|
|
assert label_encoder.get_input_columns() == ["target"]
|
|
assert label_encoder.get_output_columns() == ["target_encoded"]
|
|
|
|
# Test Concatenator (uses output_column_name instead of output_columns)
|
|
concatenator = Concatenator(columns=["A", "B"])
|
|
assert concatenator.get_input_columns() == ["A", "B"]
|
|
assert concatenator.get_output_columns() == ["concat_out"]
|
|
|
|
concatenator_with_output = Concatenator(
|
|
columns=["A", "B"], output_column_name="AB_concat"
|
|
)
|
|
assert concatenator_with_output.get_input_columns() == ["A", "B"]
|
|
assert concatenator_with_output.get_output_columns() == ["AB_concat"]
|
|
|
|
# Test FeatureHasher (uses output_column instead of output_columns)
|
|
feature_hasher = FeatureHasher(
|
|
columns=["token1", "token2"], num_features=8, output_column="hashed"
|
|
)
|
|
assert feature_hasher.get_input_columns() == ["token1", "token2"]
|
|
assert feature_hasher.get_output_columns() == ["hashed"]
|
|
|
|
# Test TorchVisionPreprocessor (uses _columns and _output_columns)
|
|
torch_preprocessor = TorchVisionPreprocessor(
|
|
columns=["image"], transform=lambda x: x
|
|
)
|
|
assert torch_preprocessor.get_input_columns() == ["image"]
|
|
assert torch_preprocessor.get_output_columns() == ["image"]
|
|
|
|
torch_preprocessor_with_output = TorchVisionPreprocessor(
|
|
columns=["image"], transform=lambda x: x, output_columns=["image_transformed"]
|
|
)
|
|
assert torch_preprocessor_with_output.get_input_columns() == ["image"]
|
|
assert torch_preprocessor_with_output.get_output_columns() == ["image_transformed"]
|
|
|
|
# Test with preprocessor without columns attribute
|
|
class CustomPreprocessor(Preprocessor):
|
|
_is_fittable = False
|
|
|
|
custom = CustomPreprocessor()
|
|
assert custom.get_input_columns() == []
|
|
assert custom.get_output_columns() == []
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
sys.exit(pytest.main(["-sv", __file__]))
|