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

894 lines
29 KiB
Python

import os
import sys
import time
from typing import Dict, Optional
import pytest
import ray
import ray._private.gcs_utils as gcs_utils
from ray._common.test_utils import wait_for_condition
from ray._private.test_utils import (
make_global_state_accessor,
)
from ray._raylet import GcsClient
from ray.core.generated import autoscaler_pb2
from ray.util.state import list_actors
def test_replenish_resources(ray_start_regular):
cluster_resources = ray.cluster_resources()
available_resources = ray.available_resources()
assert cluster_resources == available_resources
@ray.remote
def cpu_task():
pass
ray.get(cpu_task.remote())
wait_for_condition(lambda: ray.available_resources() == cluster_resources)
def test_uses_resources(ray_start_regular):
cluster_resources = ray.cluster_resources()
@ray.remote(num_cpus=1)
class Actor:
pass
actor = Actor.remote()
ray.get(actor.__ray_ready__.remote())
wait_for_condition(
lambda: ray.available_resources().get("CPU", 0)
== cluster_resources.get("CPU", 0) - 1
)
def test_available_resources_per_node(ray_start_cluster_head):
cluster = ray_start_cluster_head
@ray.remote
def get_node_id():
return ray.get_runtime_context().get_node_id()
head_node_id = ray.get(get_node_id.remote())
worker_node = cluster.add_node(num_cpus=3, resources={"worker": 1})
@ray.remote(num_cpus=1, resources={"worker": 1})
class Actor:
def ping(self):
return ray.get_runtime_context().get_node_id()
actor = Actor.remote()
worker_node_id = ray.get(actor.ping.remote())
def available_resources_per_node_check1():
available_resources_per_node = ray._private.state.available_resources_per_node()
assert len(available_resources_per_node) == 2
assert available_resources_per_node[head_node_id]["CPU"] == 1
assert available_resources_per_node[worker_node_id]["CPU"] == 2
assert available_resources_per_node[worker_node_id].get("worker", 0) == 0
return True
wait_for_condition(available_resources_per_node_check1)
cluster.remove_node(worker_node)
cluster.wait_for_nodes()
def available_resources_per_node_check2():
# Make sure worker node is not returned
available_resources_per_node = ray._private.state.available_resources_per_node()
assert len(available_resources_per_node) == 1
assert available_resources_per_node[head_node_id]["CPU"] == 1
return True
wait_for_condition(available_resources_per_node_check2)
def test_total_resources_per_node(ray_start_cluster_head):
cluster = ray_start_cluster_head
@ray.remote
def get_node_id():
return ray.get_runtime_context().get_node_id()
head_node_id = ray.get(get_node_id.remote())
worker_node = cluster.add_node(num_cpus=3, resources={"worker": 1})
@ray.remote(num_cpus=1, resources={"worker": 1})
class Actor:
def ping(self):
return ray.get_runtime_context().get_node_id()
actor = Actor.remote()
worker_node_id = ray.get(actor.ping.remote())
def total_resources_per_node_check1():
total_resources_per_node = ray._private.state.total_resources_per_node()
assert len(total_resources_per_node) == 2
assert total_resources_per_node[head_node_id]["CPU"] == 1
assert total_resources_per_node[worker_node_id]["CPU"] == 3
assert total_resources_per_node[worker_node_id].get("worker", 0) == 1
return True
wait_for_condition(total_resources_per_node_check1)
cluster.remove_node(worker_node)
cluster.wait_for_nodes()
def total_resources_per_node_check2():
# Make sure worker node is not returned
total_resources_per_node = ray._private.state.total_resources_per_node()
assert len(total_resources_per_node) == 1
assert total_resources_per_node[head_node_id]["CPU"] == 1
return True
wait_for_condition(total_resources_per_node_check2)
def test_add_remove_cluster_resources(ray_start_cluster_head):
"""Tests that Global State API is consistent with actual cluster."""
cluster = ray_start_cluster_head
assert ray.cluster_resources()["CPU"] == 1
nodes = []
nodes += [cluster.add_node(num_cpus=1)]
cluster.wait_for_nodes()
assert ray.cluster_resources()["CPU"] == 2
cluster.remove_node(nodes.pop())
cluster.wait_for_nodes()
assert ray.cluster_resources()["CPU"] == 1
for i in range(5):
nodes += [cluster.add_node(num_cpus=1)]
cluster.wait_for_nodes()
assert ray.cluster_resources()["CPU"] == 6
@pytest.mark.parametrize(
"ray_start_regular",
[{"include_dashboard": True}],
indirect=True,
)
def test_global_state_actor_table(ray_start_regular):
@ray.remote
class Actor:
def ready(self):
return os.getpid()
# actor table should be empty at first
assert len(list_actors()) == 0
a = Actor.remote()
pid = ray.get(a.ready.remote())
assert len(list_actors()) == 1
assert list_actors()[0].pid == pid
# actor table should contain only this entry
# even when the actor goes out of scope
del a
for _ in range(10):
if list_actors()[0].state == "DEAD":
break
else:
time.sleep(0.5)
assert list_actors()[0].state == "DEAD"
def test_global_state_worker_table(ray_start_regular):
def worker_initialized():
# Get worker table from gcs.
workers_data = ray._private.state.workers()
return len(workers_data) == 1
wait_for_condition(worker_initialized)
@pytest.mark.parametrize(
"ray_start_regular",
[{"include_dashboard": True}],
indirect=True,
)
def test_global_state_actor_entry(ray_start_regular):
@ray.remote
class Actor:
def ready(self):
pass
# actor table should be empty at first
assert len(list_actors()) == 0
a = Actor.remote()
b = Actor.remote()
ray.get(a.ready.remote())
ray.get(b.ready.remote())
assert len(list_actors()) == 2
a_actor_id = a._actor_id.hex()
b_actor_id = b._actor_id.hex()
assert ray.util.state.get_actor(id=a_actor_id).actor_id == a_actor_id
assert ray.util.state.get_actor(id=a_actor_id).state == "ALIVE"
assert ray.util.state.get_actor(id=b_actor_id).actor_id == b_actor_id
assert ray.util.state.get_actor(id=b_actor_id).state == "ALIVE"
def test_node_name_cluster(ray_start_cluster):
cluster = ray_start_cluster
cluster.add_node(node_name="head_node", include_dashboard=False)
head_context = ray.init(address=cluster.address, include_dashboard=False)
cluster.add_node(node_name="worker_node", include_dashboard=False)
cluster.wait_for_nodes()
global_state_accessor = make_global_state_accessor(head_context)
node_table = global_state_accessor.get_node_table()
assert len(node_table) == 2
for node in node_table:
if node["NodeID"] == head_context.address_info["node_id"]:
assert node["NodeName"] == "head_node"
else:
assert node["NodeName"] == "worker_node"
ray.shutdown()
cluster.shutdown()
def test_node_name_init():
# Test ray.init with _node_name directly
new_head_context = ray.init(_node_name="new_head_node", include_dashboard=False)
global_state_accessor = make_global_state_accessor(new_head_context)
node = global_state_accessor.get_node_table()[0]
assert node["NodeName"] == "new_head_node"
ray.shutdown()
def test_no_node_name():
# Test that starting ray with no node name will result in a node_name=ip_address
new_head_context = ray.init(include_dashboard=False)
global_state_accessor = make_global_state_accessor(new_head_context)
node = global_state_accessor.get_node_table()[0]
assert node["NodeName"] == ray.util.get_node_ip_address()
ray.shutdown()
@pytest.mark.parametrize("max_shapes", [0, 2, -1])
def test_load_report(shutdown_only, max_shapes):
resource1 = "A"
resource2 = "B"
cluster = ray.init(
num_cpus=1,
resources={resource1: 1},
_system_config={
"max_resource_shapes_per_load_report": max_shapes,
},
)
global_state_accessor = make_global_state_accessor(cluster)
@ray.remote
def sleep():
time.sleep(1000)
sleep.remote()
for _ in range(3):
sleep.remote()
sleep.options(resources={resource1: 1}).remote()
sleep.options(resources={resource2: 1}).remote()
class Checker:
def __init__(self):
self.report = None
def check_load_report(self):
message = global_state_accessor.get_all_resource_usage()
if message is None:
return False
resource_usage = gcs_utils.ResourceUsageBatchData.FromString(message)
self.report = resource_usage.resource_load_by_shape.resource_demands
if max_shapes == 0:
return True
elif max_shapes == 2:
return len(self.report) >= 2
else:
return len(self.report) >= 3
# Wait for load information to arrive.
checker = Checker()
wait_for_condition(checker.check_load_report)
# Check that we respect the max shapes limit.
if max_shapes != -1:
assert len(checker.report) <= max_shapes
print(checker.report)
if max_shapes > 0:
# Check that we differentiate between infeasible and ready tasks.
for demand in checker.report:
if resource2 in demand.shape:
assert demand.num_infeasible_requests_queued > 0
assert demand.num_ready_requests_queued == 0
else:
assert demand.num_ready_requests_queued > 0
assert demand.num_infeasible_requests_queued == 0
def test_placement_group_load_report(ray_start_cluster):
cluster = ray_start_cluster
# Add a head node that doesn't have gpu resource.
cluster.add_node(num_cpus=4)
global_state_accessor = make_global_state_accessor(
ray.init(address=cluster.address)
)
class PgLoadChecker:
def nothing_is_ready(self):
resource_usage = self._read_resource_usage()
if not resource_usage:
return False
if resource_usage.HasField("placement_group_load"):
pg_load = resource_usage.placement_group_load
return len(pg_load.placement_group_data) == 2
return False
def only_first_one_ready(self):
resource_usage = self._read_resource_usage()
if not resource_usage:
return False
if resource_usage.HasField("placement_group_load"):
pg_load = resource_usage.placement_group_load
return len(pg_load.placement_group_data) == 1
return False
def two_infeasible_pg(self):
resource_usage = self._read_resource_usage()
if not resource_usage:
return False
if resource_usage.HasField("placement_group_load"):
pg_load = resource_usage.placement_group_load
return len(pg_load.placement_group_data) == 2
return False
def _read_resource_usage(self):
message = global_state_accessor.get_all_resource_usage()
if message is None:
return False
resource_usage = gcs_utils.ResourceUsageBatchData.FromString(message)
return resource_usage
checker = PgLoadChecker()
# Create 2 placement groups that are infeasible.
pg_feasible = ray.util.placement_group([{"A": 1}])
pg_infeasible = ray.util.placement_group([{"B": 1}])
_, unready = ray.wait([pg_feasible.ready(), pg_infeasible.ready()], timeout=0)
assert len(unready) == 2
wait_for_condition(checker.nothing_is_ready)
# Add a node that makes pg feasible. Make sure load include this change.
cluster.add_node(resources={"A": 1})
ray.get(pg_feasible.ready())
wait_for_condition(checker.only_first_one_ready)
# Create one more infeasible pg and make sure load is properly updated.
pg_infeasible_second = ray.util.placement_group([{"C": 1}])
_, unready = ray.wait([pg_infeasible_second.ready()], timeout=0)
assert len(unready) == 1
wait_for_condition(checker.two_infeasible_pg)
def test_backlog_report(shutdown_only):
cluster = ray.init(
num_cpus=1,
_system_config={
"max_pending_lease_requests_per_scheduling_category": 1,
"report_worker_backlog_interval_ms": 100,
},
)
global_state_accessor = make_global_state_accessor(cluster)
@ray.remote(num_cpus=1)
def foo(x):
print(".")
time.sleep(x)
return None
def backlog_size_set():
message = global_state_accessor.get_all_resource_usage()
if message is None:
return False
resource_usage = gcs_utils.ResourceUsageBatchData.FromString(message)
aggregate_resource_load = resource_usage.resource_load_by_shape.resource_demands
if len(aggregate_resource_load) == 1:
backlog_size = aggregate_resource_load[0].backlog_size
print(backlog_size)
# Ideally we'd want to assert backlog_size == 8, but guaranteeing
# the order the order that submissions will occur is too
# hard/flaky.
return backlog_size > 0
return False
# We want this first task to finish
refs = [foo.remote(0.5)]
# These tasks should all start _before_ the first one finishes.
refs.extend([foo.remote(1000) for _ in range(9)])
# Now there's 1 request running, 1 queued in the raylet, and 8 queued in
# the worker backlog.
ray.get(refs[0])
# First request finishes, second request is now running, third lease
# request is sent to the raylet with backlog=7
wait_for_condition(backlog_size_set, timeout=2)
def test_default_load_reports(shutdown_only):
"""Despite the fact that default actors release their cpu after being
placed, they should still require 1 CPU for laod reporting purposes.
https://github.com/ray-project/ray/issues/26806
"""
cluster = ray.init(
num_cpus=0,
)
global_state_accessor = make_global_state_accessor(cluster)
@ray.remote
def foo():
return None
@ray.remote
class Foo:
pass
def actor_and_task_queued_together():
message = global_state_accessor.get_all_resource_usage()
if message is None:
return False
resource_usage = gcs_utils.ResourceUsageBatchData.FromString(message)
aggregate_resource_load = resource_usage.resource_load_by_shape.resource_demands
print(f"Num shapes {len(aggregate_resource_load)}")
if len(aggregate_resource_load) == 1:
num_infeasible = aggregate_resource_load[0].num_infeasible_requests_queued
print(f"num in shape {num_infeasible}")
# Ideally we'd want to assert backlog_size == 8, but guaranteeing
# the order the order that submissions will occur is too
# hard/flaky.
return num_infeasible == 2
return False
# Assign to variables to keep the ref counter happy.
handle = Foo.remote()
ref = foo.remote()
wait_for_condition(actor_and_task_queued_together, timeout=2)
# Do something with the variables so lint is happy.
del handle
del ref
def test_heartbeat_ip(shutdown_only):
cluster = ray.init(num_cpus=1)
global_state_accessor = make_global_state_accessor(cluster)
self_ip = ray.util.get_node_ip_address()
def self_ip_is_set():
message = global_state_accessor.get_all_resource_usage()
if message is None:
return False
resource_usage = gcs_utils.ResourceUsageBatchData.FromString(message)
resources_data = resource_usage.batch[0]
return resources_data.node_manager_address == self_ip
wait_for_condition(self_ip_is_set, timeout=2)
def test_next_job_id(ray_start_regular):
job_id_1 = ray._private.state.next_job_id()
job_id_2 = ray._private.state.next_job_id()
assert job_id_1.int() + 1 == job_id_2.int()
def test_get_cluster_config(shutdown_only):
ray.init(num_cpus=1)
gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address)
cluster_config = ray._private.state.state.get_cluster_config()
assert cluster_config is None
cluster_config = autoscaler_pb2.ClusterConfig()
cluster_config.max_resources["CPU"] = 100
node_group_config = autoscaler_pb2.NodeGroupConfig()
node_group_config.name = "m5.large"
node_group_config.resources["CPU"] = 5
node_group_config.max_count = -1
cluster_config.node_group_configs.append(node_group_config)
gcs_client.report_cluster_config(cluster_config.SerializeToString())
assert ray._private.state.state.get_cluster_config() == cluster_config
@pytest.mark.parametrize(
"description, cluster_config, num_cpu",
[
(
"should return 0 since empty config is provided",
autoscaler_pb2.ClusterConfig(),
0,
),
(
"should return 0 since no node_group_config is provided",
autoscaler_pb2.ClusterConfig(
max_resources={"CPU": 100},
),
0,
),
(
"should return 0 since no CPU is provided under node_group_configs",
autoscaler_pb2.ClusterConfig(
max_resources={"CPU": 100},
node_group_configs=[autoscaler_pb2.NodeGroupConfig(name="m5.large")],
),
0,
),
(
"should return None since 0 instance is provided under node_group_configs",
autoscaler_pb2.ClusterConfig(
max_resources={"CPU": 100},
node_group_configs=[
autoscaler_pb2.NodeGroupConfig(
resources={"CPU": 50},
name="m5.large",
max_count=0,
)
],
),
0,
),
(
"should return max since max_count=-1 under node_group_configs",
autoscaler_pb2.ClusterConfig(
max_resources={"CPU": 100},
node_group_configs=[
autoscaler_pb2.NodeGroupConfig(
resources={"CPU": 50},
name="m5.large",
max_count=-1,
)
],
),
sys.maxsize,
),
(
"should return the total under node_group_configs since it is less than max_resources",
autoscaler_pb2.ClusterConfig(
max_resources={"CPU": 100},
node_group_configs=[
autoscaler_pb2.NodeGroupConfig(
resources={"CPU": 50},
name="m5.large",
max_count=1,
)
],
),
50,
),
(
"should return the total under max_resources since it is less than node_group_configs total",
autoscaler_pb2.ClusterConfig(
max_resources={"CPU": 30},
node_group_configs=[
autoscaler_pb2.NodeGroupConfig(
resources={"CPU": 50},
name="m5.large",
max_count=1,
)
],
),
30,
),
(
"should return the total under node_group_configs - no max_resources",
autoscaler_pb2.ClusterConfig(
node_group_configs=[
autoscaler_pb2.NodeGroupConfig(
resources={"CPU": 50},
name="m5.large",
max_count=1,
)
],
),
50,
),
(
"should return the total under node_group_configs - multiple node_group_config",
autoscaler_pb2.ClusterConfig(
node_group_configs=[
autoscaler_pb2.NodeGroupConfig(
resources={"CPU": 50},
name="m5.large",
max_count=1,
),
autoscaler_pb2.NodeGroupConfig(
resources={"CPU": 10},
name="m5.small",
max_count=4,
),
],
),
90,
),
],
)
def test_get_max_cpus_from_cluster_config(
shutdown_only,
description: str,
cluster_config: autoscaler_pb2.ClusterConfig,
num_cpu: Optional[int],
):
ray.init(num_cpus=1)
gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address)
gcs_client.report_cluster_config(cluster_config.SerializeToString())
max_resources = ray._private.state.state.get_max_resources_from_cluster_config()
num_cpu_from_max_resources = max_resources.get("CPU", 0) if max_resources else 0
assert num_cpu_from_max_resources == num_cpu, description
@pytest.mark.parametrize(
"description, cluster_config, expected_resources",
[
(
"should return CPU/GPU/TPU as None since empty config is provided",
autoscaler_pb2.ClusterConfig(),
None,
),
(
"should return CPU/GPU/TPU as None since no node_group_config is provided",
autoscaler_pb2.ClusterConfig(
max_resources={"CPU": 100, "memory": 1000},
),
None,
),
(
"should return CPU/GPU/TPU plus resources from node_group_configs",
autoscaler_pb2.ClusterConfig(
node_group_configs=[
autoscaler_pb2.NodeGroupConfig(
name="m5.large",
resources={"CPU": 50, "memory": 500},
max_count=1,
)
],
),
{"CPU": 50, "memory": 500},
),
(
"should return resources from both node_group_configs and max_resources",
autoscaler_pb2.ClusterConfig(
max_resources={"GPU": 8},
node_group_configs=[
autoscaler_pb2.NodeGroupConfig(
name="m5.large",
resources={"CPU": 50, "memory": 500},
max_count=1,
)
],
),
{
"CPU": 50,
"memory": 500,
}, # GPU and TPU are None because not in node_group_configs
),
(
"should return limited by max_resources when node_group total exceeds it",
autoscaler_pb2.ClusterConfig(
max_resources={"CPU": 30, "memory": 200},
node_group_configs=[
autoscaler_pb2.NodeGroupConfig(
name="m5.large",
resources={"CPU": 50, "memory": 500},
max_count=1,
)
],
),
{"CPU": 30, "memory": 200},
),
(
"should return sys.maxsize when max_count=-1",
autoscaler_pb2.ClusterConfig(
node_group_configs=[
autoscaler_pb2.NodeGroupConfig(
name="m5.large",
resources={"CPU": 50, "custom_resource": 10},
max_count=-1,
)
],
),
{
"CPU": sys.maxsize,
"custom_resource": sys.maxsize,
},
),
(
"should sum across multiple node_group_configs",
autoscaler_pb2.ClusterConfig(
node_group_configs=[
autoscaler_pb2.NodeGroupConfig(
name="m5.large",
resources={"CPU": 50, "memory": 500},
max_count=1,
),
autoscaler_pb2.NodeGroupConfig(
name="m5.small",
resources={"CPU": 10, "GPU": 1},
max_count=4,
),
],
),
{
"CPU": 90,
"GPU": 4,
"memory": 500,
}, # 50 + (10*4), 500 + 0
),
(
"should return 0 for resources with 0 count or 0 resources",
autoscaler_pb2.ClusterConfig(
node_group_configs=[
autoscaler_pb2.NodeGroupConfig(
name="m5.large",
resources={"CPU": 50, "memory": 0},
max_count=0, # This makes all resources None
),
autoscaler_pb2.NodeGroupConfig(
name="m5.small",
resources={"GPU": 1},
max_count=2,
),
],
),
{
"CPU": 0,
"GPU": 2,
"memory": 0,
}, # CPU is None due to max_count=0, GPU has valid count
),
(
"should discover all resource types including custom ones",
autoscaler_pb2.ClusterConfig(
max_resources={"TPU": 16, "special_resource": 100},
node_group_configs=[
autoscaler_pb2.NodeGroupConfig(
name="gpu-node",
resources={
"CPU": 32,
"GPU": 8,
"memory": 1000,
"custom_accelerator": 4,
},
max_count=2,
),
autoscaler_pb2.NodeGroupConfig(
name="cpu-node",
resources={"CPU": 96, "memory": 2000, "disk": 500},
max_count=1,
),
],
),
{
"CPU": 160, # (32*2) + (96*1)
"GPU": 16, # (8*2) + 0
"memory": 4000, # (1000*2) + (2000*1)
"custom_accelerator": 8, # (4*2) + 0
"disk": 500, # 0 + (500*1)
},
),
],
)
def test_get_max_resources_from_cluster_config(
shutdown_only,
description: str,
cluster_config: autoscaler_pb2.ClusterConfig,
expected_resources: Dict[str, Optional[int]],
):
"""Test get_max_resources_from_cluster_config method.
This test verifies that the method correctly:
1. Always includes CPU/GPU/TPU in the results
2. Discovers additional resource types from node_group_configs and max_resources
3. Calculates maximum values for each resource type
4. Handles edge cases like empty configs, zero counts, unlimited resources
5. Supports resource types beyond CPU/GPU/TPU
"""
ray.init(num_cpus=1)
gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address)
gcs_client.report_cluster_config(cluster_config.SerializeToString())
max_resources = ray._private.state.state.get_max_resources_from_cluster_config()
assert (
max_resources == expected_resources
), f"{description}\nExpected: {expected_resources}\nActual: {max_resources}"
def test_get_draining_nodes(ray_start_cluster):
cluster = ray_start_cluster
cluster.add_node()
ray.init(address=cluster.address)
cluster.add_node(resources={"worker1": 1})
cluster.add_node(resources={"worker2": 1})
cluster.wait_for_nodes()
@ray.remote
def get_node_id():
return ray.get_runtime_context().get_node_id()
worker1_node_id = ray.get(get_node_id.options(resources={"worker1": 1}).remote())
worker2_node_id = ray.get(get_node_id.options(resources={"worker2": 1}).remote())
# Initially there is no draining node.
assert ray._private.state.state.get_draining_nodes() == {}
@ray.remote
class Actor:
def ping(self):
pass
actor1 = Actor.options(num_cpus=1, resources={"worker1": 1}).remote()
actor2 = Actor.options(num_cpus=1, resources={"worker2": 1}).remote()
ray.get(actor1.ping.remote())
ray.get(actor2.ping.remote())
gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address)
# Drain the worker nodes.
is_accepted, _ = gcs_client.drain_node(
worker1_node_id,
autoscaler_pb2.DrainNodeReason.Value("DRAIN_NODE_REASON_PREEMPTION"),
"preemption",
2**63 - 2,
)
assert is_accepted
is_accepted, _ = gcs_client.drain_node(
worker2_node_id,
autoscaler_pb2.DrainNodeReason.Value("DRAIN_NODE_REASON_PREEMPTION"),
"preemption",
0,
)
assert is_accepted
def get_draining_nodes_check():
draining_nodes = ray._private.state.state.get_draining_nodes()
if (
draining_nodes[worker1_node_id] == (2**63 - 2)
and draining_nodes[worker2_node_id] == 0
):
return True
else:
return False
wait_for_condition(get_draining_nodes_check)
# Kill the actors running on the draining worker nodes so
# that the worker nodes become idle and can be drained.
ray.kill(actor1)
ray.kill(actor2)
wait_for_condition(lambda: ray._private.state.state.get_draining_nodes() == {})
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))