Files
ray-project--ray/python/ray/data/tests/conftest.py
T
2026-07-13 13:17:40 +08:00

816 lines
26 KiB
Python

import copy
import os
import posixpath
import time
from collections import defaultdict
from unittest.mock import MagicMock
import numpy as np
import pandas as pd
import pyarrow as pa
import pytest
import ray
from ray._common.test_utils import wait_for_condition
from ray._private.internal_api import get_memory_info_reply, get_state_from_address
from ray.data._internal.execution.block_ref_counter import BlockRefCounter
from ray.data._internal.execution.operators.base_physical_operator import (
AllToAllOperator,
)
from ray.data._internal.tensor_extensions.arrow import ArrowTensorArray
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
from ray.data.block import BlockExecStats, BlockMetadata
from ray.data.constants import TENSOR_COLUMN_NAME
from ray.data.context import DEFAULT_TARGET_MAX_BLOCK_SIZE, DataContext, ShuffleStrategy
from ray.data.tests.mock_server import * # noqa
# Trigger pytest hook to automatically zip test cluster logs to archive dir on failure
from ray.tests.conftest import * # noqa
from ray.tests.conftest import _ray_start
from ray.util.debug import reset_log_once
from ray.util.state import list_actors
def mock_all_to_all_op(input_op, name="MockAllToAll"):
"""Create a mock AllToAllOperator for testing.
Creates an AllToAllOperator which is NOT eligible for resource allocation
(throttling_disabled=True) but is a blocking materializing operator.
Note: Creating this operator automatically adds it to input_op._output_dependencies.
"""
op = AllToAllOperator(
bulk_fn=MagicMock(),
input_op=input_op,
data_context=ray.data.DataContext.get_current(),
name=name,
)
op.start = MagicMock(side_effect=lambda *_: None)
return op
def noop_counter():
"""BlockRefCounter that works without a Ray cluster."""
return BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True)
@pytest.fixture(scope="module")
def data_context_override(request):
overrides = getattr(request, "param", {})
ctx = DataContext.get_current()
copy = ctx.copy()
for k, v in overrides.items():
assert hasattr(ctx, k), f"Key '{k}' not found in DataContext"
setattr(ctx, k, v)
yield ctx
DataContext._set_current(copy)
@pytest.fixture(scope="module")
def ray_start_2_cpus_shared(request):
param = getattr(request, "param", {})
with _ray_start(num_cpus=2, **param) as res:
yield res
@pytest.fixture(scope="module")
def ray_start_10_cpus_shared(request):
param = getattr(request, "param", {})
with _ray_start(num_cpus=10, **param) as res:
yield res
@pytest.fixture(scope="function")
def aws_credentials():
import os
# Credentials dict that can be passed as kwargs to pa.fs.S3FileSystem
credentials = dict(
access_key="testing", secret_key="testing", session_token="testing"
)
old_env = os.environ
os.environ["AWS_ACCESS_KEY_ID"] = credentials["access_key"]
os.environ["AWS_SECRET_ACCESS_KEY"] = credentials["secret_key"]
os.environ["AWS_SECURITY_TOKEN"] = "testing"
os.environ["AWS_SESSION_TOKEN"] = credentials["session_token"]
yield credentials
os.environ = old_env
@pytest.fixture(scope="function")
def data_dir():
yield "test_data"
@pytest.fixture(scope="function")
def data_dir_with_space():
yield "test data"
@pytest.fixture(scope="function")
def data_dir_with_special_chars():
yield "test data#fragment?query=test/"
@pytest.fixture(scope="function")
def s3_path(tmp_path, data_dir):
yield "s3://" + posixpath.join(tmp_path, data_dir).strip("/")
@pytest.fixture(scope="function")
def s3_path_with_space(tmp_path, data_dir_with_space):
yield "s3://" + posixpath.join(tmp_path, data_dir_with_space).strip("/")
@pytest.fixture(scope="function")
def s3_path_with_special_chars(tmp_path, data_dir_with_special_chars):
yield "s3://" + posixpath.join(tmp_path, data_dir_with_special_chars).lstrip("/")
@pytest.fixture(scope="function")
def s3_path_with_anonymous_crendential(tmp_path, data_dir):
yield "s3://" + "anonymous@" + posixpath.join(tmp_path, data_dir).lstrip("/")
@pytest.fixture(scope="function")
def s3_fs(aws_credentials, s3_server, s3_path):
yield from _s3_fs(aws_credentials, s3_server, s3_path)
@pytest.fixture(scope="function")
def s3_fs_with_space(aws_credentials, s3_server, s3_path_with_space):
yield from _s3_fs(aws_credentials, s3_server, s3_path_with_space)
@pytest.fixture(scope="function")
def s3_fs_with_special_chars(aws_credentials, s3_server, s3_path_with_special_chars):
yield from _s3_fs(aws_credentials, s3_server, s3_path_with_special_chars)
@pytest.fixture(scope="function")
def s3_fs_with_anonymous_crendential(
aws_credentials, s3_server, s3_path_with_anonymous_crendential
):
yield from _s3_fs(aws_credentials, s3_server, s3_path_with_anonymous_crendential)
def _s3_fs(aws_credentials, s3_server, s3_path):
import urllib.parse
from packaging.version import parse as parse_version
kwargs = aws_credentials.copy()
if get_pyarrow_version() >= parse_version("9.0.0"):
kwargs["allow_bucket_creation"] = True
kwargs["allow_bucket_deletion"] = True
fs = None
try:
fs = pa.fs.S3FileSystem(
region="us-west-2",
endpoint_override=s3_server,
**kwargs,
)
if s3_path.startswith("s3://"):
if "@" in s3_path:
s3_path = s3_path.split("@")[-1]
else:
s3_path = s3_path[len("s3://") :]
s3_path = urllib.parse.quote(s3_path)
fs.create_dir(s3_path)
yield fs
finally:
# Explicit cleanup for S3FileSystem resources
if fs is not None:
try:
# Clean up test directory if it exists
try:
file_info = fs.get_file_info(s3_path)
if file_info.type != pa.fs.FileType.NotFound:
fs.delete_dir(s3_path)
except (OSError, pa.lib.ArrowIOError):
# Directory doesn't exist or can't be deleted, that's fine
pass
except Exception as e:
print(f"Warning: S3 filesystem cleanup error: {e}")
finally:
fs = None
@pytest.fixture(scope="function")
def local_path(tmp_path, data_dir):
path = os.path.join(tmp_path, data_dir)
os.mkdir(path)
yield path
@pytest.fixture(scope="function")
def local_fs():
yield pa.fs.LocalFileSystem()
@pytest.fixture(scope="function")
def base_partitioned_df():
yield pd.DataFrame(
{"one": [1, 1, 1, 3, 3, 3], "two": ["a", "b", "c", "e", "f", "g"]}
)
@pytest.fixture(scope="function")
def write_partitioned_df():
def _write_partitioned_df(
df,
partition_keys,
partition_path_encoder,
file_writer_fn,
file_name_suffix="_1",
):
import urllib.parse
df_partitions = [df for _, df in df.groupby(partition_keys, as_index=False)]
paths = []
for df_partition in df_partitions:
partition_values = []
for key in partition_keys:
partition_values.append(str(df_partition[key].iloc[0]))
path = partition_path_encoder(partition_values)
partition_path_encoder.scheme.resolved_filesystem.create_dir(path)
base_dir = partition_path_encoder.scheme.base_dir
parsed_base_dir = urllib.parse.urlparse(base_dir)
file_name = f"test_{file_name_suffix}.tmp"
if parsed_base_dir.scheme:
# replace the protocol removed by the partition path generator
path = posixpath.join(f"{parsed_base_dir.scheme}://{path}", file_name)
else:
path = os.path.join(path, file_name)
file_writer_fn(df_partition, path)
paths.append(path)
return paths
yield _write_partitioned_df
@pytest.fixture
def restore_data_context(request):
"""Restore any DataContext changes after the test runs"""
ctx = ray.data.context.DataContext.get_current()
original = copy.deepcopy(ctx)
yield ctx
ray.data.context.DataContext._set_current(original)
def _get_supported_tensor_formats():
"""Get list of supported tensor formats based on PyArrow version.
Returns V1, V2, and ARROW_NATIVE only if PyArrow >= 16 (which supports
native FixedShapeTensorScalar, FixedShapeTensorType, FixedShapeTensorArray).
"""
from ray.data._internal.tensor_extensions.arrow import (
MIN_PYARROW_VERSION_FIXED_SHAPE_TENSOR_SCALAR,
FixedShapeTensorFormat,
)
formats = [FixedShapeTensorFormat.V1, FixedShapeTensorFormat.V2]
if get_pyarrow_version() >= MIN_PYARROW_VERSION_FIXED_SHAPE_TENSOR_SCALAR:
formats.append(FixedShapeTensorFormat.ARROW_NATIVE)
return formats
@pytest.fixture(params=_get_supported_tensor_formats())
def tensor_format(request):
"""Fixture that yields supported tensor formats.
Yields V1, V2 for all PyArrow versions.
Yields ARROW_NATIVE only when PyArrow >= 16.
This allows tests to use `tensor_format.to_type()` safely without
needing fallback logic for unsupported PyArrow versions.
"""
return request.param
@pytest.fixture
def tensor_format_context(request, restore_data_context, tensor_format):
"""Fixture that sets the DataContext to use the given tensor format.
Combines restore_data_context with tensor_format to automatically
configure the context for tensor format testing.
"""
ctx = ray.data.context.DataContext.get_current()
ctx.arrow_fixed_shape_tensor_format = tensor_format
return tensor_format
@pytest.fixture
def disable_fallback_to_object_extension(request, restore_data_context):
"""Disables fallback to ArrowPythonObjectType"""
ray.data.context.DataContext.get_current().enable_fallback_to_arrow_object_ext_type = (
False
)
@pytest.fixture(
params=[
s
for s in ShuffleStrategy
if s != ShuffleStrategy.GPU_SHUFFLE
or os.environ.get("RAY_PYTEST_USE_GPU") == "1"
]
)
def configure_shuffle_method(request):
shuffle_strategy = request.param
ctx = ray.data.context.DataContext.get_current()
original_shuffle_strategy = ctx.shuffle_strategy
original_default_hash_shuffle_parallelism = ctx.default_hash_shuffle_parallelism
original_gpu_shuffle_num_actors = ctx.gpu_shuffle_num_actors
ctx.shuffle_strategy = shuffle_strategy
# NOTE: We override default parallelism for hash-based shuffling to
# avoid excessive partitioning of the data (to achieve desired
# parallelism
if shuffle_strategy in [ShuffleStrategy.HASH_SHUFFLE, ShuffleStrategy.GPU_SHUFFLE]:
ctx.default_hash_shuffle_parallelism = 8
if shuffle_strategy == ShuffleStrategy.GPU_SHUFFLE:
ctx.gpu_shuffle_num_actors = 1
yield request.param
ctx.shuffle_strategy = original_shuffle_strategy
ctx.default_hash_shuffle_parallelism = original_default_hash_shuffle_parallelism
ctx.gpu_shuffle_num_actors = original_gpu_shuffle_num_actors
@pytest.fixture(params=[True, False])
def use_polars_sort(request):
use_polars_sort = request.param
ctx = ray.data.context.DataContext.get_current()
original_use_polars = ctx.use_polars_sort
ctx.use_polars_sort = use_polars_sort
yield request.param
ctx.use_polars_sort = original_use_polars
@pytest.fixture(params=[True, False])
def enable_automatic_tensor_extension_cast(request):
ctx = ray.data.context.DataContext.get_current()
original = ctx.enable_tensor_extension_casting
ctx.enable_tensor_extension_casting = request.param
yield request.param
ctx.enable_tensor_extension_casting = original
@pytest.fixture(params=[True, False])
def enable_auto_log_stats(request):
ctx = ray.data.context.DataContext.get_current()
original = ctx.enable_auto_log_stats
ctx.enable_auto_log_stats = request.param
yield request.param
ctx.enable_auto_log_stats = original
@pytest.fixture(autouse=True)
def reset_log_once_fixture():
reset_log_once()
yield
@pytest.fixture(params=[1024])
def target_max_block_size(request):
ctx = ray.data.context.DataContext.get_current()
original = ctx.target_max_block_size
ctx.target_max_block_size = request.param
yield request.param
ctx.target_max_block_size = original
@pytest.fixture(params=[None, DEFAULT_TARGET_MAX_BLOCK_SIZE])
def target_max_block_size_infinite_or_default(request):
"""Fixture that sets target_max_block_size to None/DEFAULT_TARGET_MAX_BLOCK_SIZE and resets after test finishes."""
ctx = ray.data.context.DataContext.get_current()
original = ctx.target_max_block_size
ctx.target_max_block_size = request.param
yield
ctx.target_max_block_size = original
@pytest.fixture(params=[None])
def target_max_block_size_infinite(request):
"""Fixture that sets target_max_block_size to None and resets after test finishes."""
ctx = ray.data.context.DataContext.get_current()
original = ctx.target_max_block_size
ctx.target_max_block_size = request.param
yield
ctx.target_max_block_size = original
# ===== Pandas dataset formats =====
@pytest.fixture(scope="function")
def ds_pandas_single_column_format(ray_start_regular_shared):
in_df = pd.DataFrame({"column_1": [1, 2, 3, 4]})
yield ray.data.from_pandas(in_df)
@pytest.fixture(scope="function")
def ds_pandas_multi_column_format(ray_start_regular_shared):
in_df = pd.DataFrame({"column_1": [1, 2, 3, 4], "column_2": [1, -1, 1, -1]})
yield ray.data.from_pandas(in_df)
@pytest.fixture(scope="function")
def ds_pandas_list_multi_column_format(ray_start_regular_shared):
in_df = pd.DataFrame({"column_1": [1], "column_2": [1]})
yield ray.data.from_pandas([in_df] * 4)
# ===== Arrow dataset formats =====
@pytest.fixture(scope="function")
def ds_arrow_single_column_format(ray_start_regular_shared):
yield ray.data.from_arrow(pa.table({"column_1": [1, 2, 3, 4]}))
@pytest.fixture(scope="function")
def ds_arrow_single_column_tensor_format(ray_start_regular_shared):
yield ray.data.from_arrow(
pa.table(
{
TENSOR_COLUMN_NAME: ArrowTensorArray.from_numpy(
np.arange(16).reshape((4, 2, 2))
)
}
)
)
@pytest.fixture(scope="function")
def ds_arrow_multi_column_format(ray_start_regular_shared):
yield ray.data.from_arrow(
pa.table(
{
"column_1": [1, 2, 3, 4],
"column_2": [1, -1, 1, -1],
}
)
)
@pytest.fixture(scope="function")
def ds_list_arrow_multi_column_format(ray_start_regular_shared):
yield ray.data.from_arrow([pa.table({"column_1": [1], "column_2": [1]})] * 4)
# ===== Numpy dataset formats =====
@pytest.fixture(scope="function")
def ds_numpy_single_column_tensor_format(ray_start_regular_shared):
yield ray.data.from_numpy(np.arange(16).reshape((4, 2, 2)))
@pytest.fixture(scope="function")
def ds_numpy_list_of_ndarray_tensor_format(ray_start_regular_shared):
yield ray.data.from_numpy([np.arange(4).reshape((1, 2, 2))] * 4)
# ===== Observability & Logging Fixtures =====
@pytest.fixture
def op_two_block():
block_params = {
"num_rows": [10000, 5000],
"size_bytes": [100, 50],
"wall_time": [5, 10],
"cpu_time": [1.2, 3.4],
"udf_time": [1.1, 1.7],
"node_id": ["a1", "b2"],
"task_idx": [0, 1],
}
block_delay = 20
block_meta_list = []
for i in range(len(block_params["num_rows"])):
start_time_s = time.perf_counter() + i * block_delay
# The blocks are executing from [0, 5] and [20, 30].
block_exec_stats = BlockExecStats(
start_time_s=start_time_s,
end_time_s=start_time_s + block_params["wall_time"][i],
wall_time_s=block_params["wall_time"][i],
cpu_time_s=block_params["cpu_time"][i],
udf_time_s=block_params["udf_time"][i],
node_id=block_params["node_id"][i],
task_idx=block_params["task_idx"][i],
)
block_meta_list.append(
BlockMetadata(
num_rows=block_params["num_rows"][i],
size_bytes=block_params["size_bytes"][i],
input_files=None,
exec_stats=block_exec_stats,
)
)
return block_params, block_meta_list
def equals_or_true(count, expected_count):
if isinstance(expected_count, int):
if count != expected_count:
return False
else:
if not expected_count(count):
return False
return True
class CoreExecutionMetrics:
def __init__(self, task_count=None, object_store_stats=None, actor_count=None):
self.task_count = task_count
self.object_store_stats = object_store_stats
self.actor_count = actor_count
def get_task_count(self):
return self.task_count
def get_object_store_stats(self):
return self.object_store_stats
def get_actor_count(self):
return self.actor_count
def _assert_count_equals(self, actual_count, expected_count):
diff = {}
# Check that all tasks in expected tasks match those in actual task
# count.
for name, count in expected_count.items():
if not equals_or_true(actual_count[name], count):
diff[name] = (actual_count[name], count)
assert len(diff) == 0, "\nTask diff:\n" + "\n".join(
f" - {key}: expected {val[1]}, got {val[0]}" for key, val in diff.items()
)
def assert_task_metrics(self, expected_metrics):
"""
Assert equality to the given { <task name>: <task count> }.
A lambda that takes in the count and returns a bool to assert can also
be given instead of an integer task count.
An empty dict means that we expected no tasks to run. Pass None to skip
the check.
"""
if expected_metrics.get_task_count() is None:
return
expected_task_count = expected_metrics.get_task_count()
actual_task_count = self.get_task_count()
self._assert_count_equals(actual_task_count, expected_task_count)
def assert_object_store_metrics(self, expected_metrics):
"""
By default this checks that no objects were spilled or restored.
Collected stats only apply to plasma store objects and exclude inlined
or in-memory objects.
Caller can also override the following fields with a value or lambda to assert.
- spilled_bytes_total
- restored_bytes_total
- cumulative_created_plasma_bytes
- cumulative_created_plasma_objects
"""
expected_object_store_stats = (
CoreExecutionMetrics.get_default_object_store_stats()
)
if expected_metrics.get_object_store_stats() is not None:
for key, val in expected_metrics.get_object_store_stats().items():
expected_object_store_stats[key] = val
actual_object_store_stats = self.get_object_store_stats()
for key, val in expected_object_store_stats.items():
print(f"{key}: Expect {val}, got {actual_object_store_stats[key]}")
assert equals_or_true(
actual_object_store_stats[key], val
), f"{key}: expected {val} got {actual_object_store_stats[key]}"
def assert_actor_metrics(self, expected_metrics):
if expected_metrics.get_actor_count() is None:
return
expected_actor_count = expected_metrics.get_actor_count()
actual_actor_count = self.get_actor_count()
self._assert_count_equals(actual_actor_count, expected_actor_count)
@staticmethod
def get_default_object_store_stats():
return {
"spilled_bytes_total": 0,
"restored_bytes_total": 0,
}
class PhysicalCoreExecutionMetrics(CoreExecutionMetrics):
"""Generated from a snapshot of the metrics collected by Ray Core during
the physical execution.
NOTE(swang): Currently object store stats only include objects stored in
plasma shared memory.
"""
def __init__(self, last_snapshot=None):
self.task_metrics = ray.util.state.list_tasks(detail=True, limit=10_000)
self.last_snapshot = last_snapshot
memory_info = get_memory_info_reply(
get_state_from_address(ray.get_runtime_context().gcs_address)
)
self.object_store_stats = {
"spilled_bytes_total": memory_info.store_stats.spilled_bytes_total,
"restored_bytes_total": memory_info.store_stats.restored_bytes_total,
"cumulative_created_plasma_bytes": (
memory_info.store_stats.cumulative_created_bytes
),
"cumulative_created_plasma_objects": (
memory_info.store_stats.cumulative_created_objects
),
}
self.actor_metrics = list_actors(limit=10_000)
def clear_task_count(self):
self.task_metrics = []
def clear_object_store_stats(self):
self.object_store_stats = {}
def clear_actor_count(self):
self.actor_metrics = []
def get_task_count(self):
task_count = defaultdict(int)
tasks = self.task_metrics
tasks = [t for t in tasks if t.name != "barrier"]
for task in tasks:
task_count[task.name] += 1
# Filter out previous and dummy tasks.
if self.last_snapshot is not None:
prev_task_count = self.last_snapshot.get_task_count()
if prev_task_count is not None:
for name, count in prev_task_count.items():
task_count[name] -= count
if task_count[name] < 0:
task_count[name] = 0
return task_count
def get_actor_count(self):
actor_count = defaultdict(int)
for actor in self.actor_metrics:
actor_count[actor.class_name] += 1
if self.last_snapshot is not None:
prev_actor_count = self.last_snapshot.get_actor_count()
if prev_actor_count is not None:
for name, count in prev_actor_count.items():
actor_count[name] -= count
if actor_count[name] < 0:
actor_count[name] = 0
return actor_count
def get_object_store_stats(self):
object_store_stats = self.object_store_stats.copy()
if self.last_snapshot is not None:
prev_object_store_stats = self.last_snapshot.get_object_store_stats()
if prev_object_store_stats is not None:
for key, val in prev_object_store_stats.items():
object_store_stats[key] -= val
return object_store_stats
# Dummy task used to make sure that we wait until (most) stats are available.
@ray.remote
def barrier():
time.sleep(1)
return
@ray.remote
def warmup():
time.sleep(1)
return np.zeros(1024 * 1024, dtype=np.uint8)
def task_metrics_flushed(refs):
task_ids = [t.task_id for t in ray.util.state.list_tasks(limit=10_000)]
# All tasks appear in the metrics.
return all(ref.task_id().hex() in task_ids for ref in refs)
def get_initial_core_execution_metrics_snapshot():
# Warmup plasma store and workers.
refs = [warmup.remote() for _ in range(int(ray.cluster_resources()["CPU"]))]
ray.get(refs)
wait_for_condition(lambda: task_metrics_flushed(refs))
last_snapshot = assert_core_execution_metrics_equals(
CoreExecutionMetrics(
task_count={"warmup": lambda count: True}, object_store_stats={}
),
last_snapshot=None,
)
return last_snapshot
def assert_core_execution_metrics_equals(
expected_metrics: CoreExecutionMetrics,
last_snapshot=None,
):
# Wait for one task per CPU to finish to prevent a race condition where not
# all of the task metrics have been collected yet.
if expected_metrics.get_task_count() is not None:
refs = [barrier.remote() for _ in range(int(ray.cluster_resources()["CPU"]))]
ray.get(refs)
wait_for_condition(lambda: task_metrics_flushed(refs))
metrics = PhysicalCoreExecutionMetrics(last_snapshot)
metrics.assert_task_metrics(expected_metrics)
metrics.assert_object_store_metrics(expected_metrics)
metrics.assert_actor_metrics(expected_metrics)
# Return a last_snapshot to the current snapshot of metrics to make subsequent
# queries easier. Don't return a last_snapshot for metrics that weren't asserted.
last_snapshot = PhysicalCoreExecutionMetrics()
if expected_metrics.get_task_count() is None:
last_snapshot.clear_task_count()
elif expected_metrics.get_object_store_stats() is None:
last_snapshot.clear_object_store_stats()
elif expected_metrics.get_actor_count() is None:
last_snapshot.clear_actor_count()
return last_snapshot
def assert_blocks_expected_in_plasma(
last_snapshot,
num_blocks_expected,
block_size_expected=None,
):
total_bytes_expected = None
if block_size_expected is not None:
total_bytes_expected = num_blocks_expected * block_size_expected
print(f"Expecting {total_bytes_expected} bytes, {num_blocks_expected} blocks")
def _assert(last_snapshot):
assert_core_execution_metrics_equals(
CoreExecutionMetrics(
object_store_stats={
"cumulative_created_plasma_objects": (
lambda count: num_blocks_expected * 0.5
<= count
<= 1.5 * num_blocks_expected
),
"cumulative_created_plasma_bytes": (
lambda count: total_bytes_expected is None
or total_bytes_expected * 0.5
<= count
<= 1.5 * total_bytes_expected
),
},
),
last_snapshot,
)
return True
wait_for_condition(lambda: _assert(last_snapshot))
# Get the latest last_snapshot.
last_snapshot = assert_core_execution_metrics_equals(
CoreExecutionMetrics(
object_store_stats={
"cumulative_created_plasma_objects": lambda count: True,
"cumulative_created_plasma_bytes": lambda count: True,
}
),
last_snapshot,
)
return last_snapshot
@pytest.fixture(autouse=True, scope="function")
def log_internal_stack_trace_to_stdout(restore_data_context):
ray.data.context.DataContext.get_current().log_internal_stack_trace_to_stdout = True