chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,600 @@
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
from opencensus.metrics.export.metric_descriptor import MetricDescriptorType
|
||||
from opencensus.metrics.export.value import ValueDouble
|
||||
from opencensus.stats import execution_context
|
||||
from opencensus.stats.aggregation_data import (
|
||||
CountAggregationData,
|
||||
DistributionAggregationData,
|
||||
LastValueAggregationData,
|
||||
SumAggregationData,
|
||||
)
|
||||
from opencensus.stats.stats_recorder import StatsRecorder
|
||||
from opencensus.stats.view_manager import ViewManager
|
||||
from prometheus_client.core import REGISTRY
|
||||
|
||||
import ray._private.prometheus_exporter as prometheus_exporter
|
||||
from ray._common.test_utils import (
|
||||
fetch_prometheus_metrics,
|
||||
fetch_raw_prometheus,
|
||||
wait_for_condition,
|
||||
)
|
||||
from ray._private.metrics_agent import (
|
||||
RAY_WORKER_TIMEOUT_S,
|
||||
Gauge,
|
||||
MetricsAgent,
|
||||
OpenCensusProxyCollector,
|
||||
OpencensusProxyMetric,
|
||||
Record,
|
||||
)
|
||||
from ray._private.telemetry.metric_cardinality import WORKER_ID_TAG_KEY
|
||||
from ray._raylet import WorkerID
|
||||
from ray.core.generated.metrics_pb2 import (
|
||||
LabelKey,
|
||||
LabelValue,
|
||||
Metric,
|
||||
MetricDescriptor,
|
||||
Point,
|
||||
TimeSeries,
|
||||
)
|
||||
|
||||
|
||||
def raw_metrics(export_port):
|
||||
metrics_page = "localhost:{}".format(export_port)
|
||||
res = fetch_prometheus_metrics([metrics_page])
|
||||
return res
|
||||
|
||||
|
||||
def get_metric(metric_name, export_port):
|
||||
res = raw_metrics(export_port)
|
||||
for name, samples in res.items():
|
||||
if name == metric_name:
|
||||
return name, samples
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_prom_metric_name(namespace, metric_name):
|
||||
return f"{namespace}_{metric_name}"
|
||||
|
||||
|
||||
def generate_timeseries(label_values: List[str], points: List[float]):
|
||||
return TimeSeries(
|
||||
label_values=[LabelValue(value=val) for val in label_values],
|
||||
points=[Point(double_value=val) for val in points],
|
||||
)
|
||||
|
||||
|
||||
def generate_protobuf_metric(
|
||||
name: str,
|
||||
desc: str,
|
||||
unit: str,
|
||||
type: MetricDescriptorType,
|
||||
label_keys: List[str] = None,
|
||||
timeseries: List[TimeSeries] = None,
|
||||
):
|
||||
if not label_keys:
|
||||
label_keys = []
|
||||
if not timeseries:
|
||||
timeseries = []
|
||||
|
||||
return Metric(
|
||||
metric_descriptor=MetricDescriptor(
|
||||
name=name,
|
||||
description=desc,
|
||||
unit=unit,
|
||||
type=type,
|
||||
label_keys=[LabelKey(key="a"), LabelKey(key="b")],
|
||||
),
|
||||
timeseries=timeseries,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def get_agent(request, monkeypatch):
|
||||
with monkeypatch.context() as m:
|
||||
if hasattr(request, "param"):
|
||||
delay = request.param
|
||||
else:
|
||||
delay = 0
|
||||
|
||||
m.setenv(RAY_WORKER_TIMEOUT_S, delay)
|
||||
agent_port = random.randint(10000, 65535)
|
||||
stats_recorder = StatsRecorder()
|
||||
view_manager = ViewManager()
|
||||
stats_exporter = prometheus_exporter.new_stats_exporter(
|
||||
prometheus_exporter.Options(
|
||||
namespace="test",
|
||||
port=agent_port,
|
||||
address="127.0.0.1",
|
||||
)
|
||||
)
|
||||
agent = MetricsAgent(view_manager, stats_recorder, stats_exporter)
|
||||
REGISTRY.register(agent.proxy_exporter_collector)
|
||||
yield agent, agent_port
|
||||
REGISTRY.unregister(agent.stats_exporter.collector)
|
||||
REGISTRY.unregister(agent.proxy_exporter_collector)
|
||||
execution_context.set_measure_to_view_map({})
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
|
||||
def test_metrics_agent_record_and_export(get_agent):
|
||||
namespace = "test"
|
||||
agent, agent_port = get_agent
|
||||
|
||||
# Record a new gauge.
|
||||
metric_name = "test"
|
||||
test_gauge = Gauge(metric_name, "desc", "unit", ["tag"])
|
||||
record_a = Record(
|
||||
gauge=test_gauge,
|
||||
value=3,
|
||||
tags={"tag": "a"},
|
||||
)
|
||||
agent.record_and_export([record_a])
|
||||
name, samples = get_metric(get_prom_metric_name(namespace, metric_name), agent_port)
|
||||
assert name == get_prom_metric_name(namespace, metric_name)
|
||||
assert len(samples) == 1
|
||||
assert samples[0].value == 3
|
||||
assert samples[0].labels == {"tag": "a"}
|
||||
|
||||
# Record the same gauge.
|
||||
record_b = Record(
|
||||
gauge=test_gauge,
|
||||
value=4,
|
||||
tags={"tag": "a"},
|
||||
)
|
||||
record_c = Record(
|
||||
gauge=test_gauge,
|
||||
value=4,
|
||||
tags={"tag": "a"},
|
||||
)
|
||||
agent.record_and_export([record_b, record_c])
|
||||
name, samples = get_metric(get_prom_metric_name(namespace, metric_name), agent_port)
|
||||
assert name == get_prom_metric_name(namespace, metric_name)
|
||||
assert len(samples) == 1
|
||||
assert samples[0].value == 4
|
||||
assert samples[0].labels == {"tag": "a"}
|
||||
|
||||
# Record the same gauge with different ag.
|
||||
record_d = Record(
|
||||
gauge=test_gauge,
|
||||
value=6,
|
||||
tags={"tag": "aa"},
|
||||
)
|
||||
agent.record_and_export(
|
||||
[
|
||||
record_d,
|
||||
]
|
||||
)
|
||||
name, samples = get_metric(get_prom_metric_name(namespace, metric_name), agent_port)
|
||||
assert name == get_prom_metric_name(namespace, metric_name)
|
||||
assert len(samples) == 2
|
||||
assert samples[0].value == 4
|
||||
assert samples[0].labels == {"tag": "a"}
|
||||
assert samples[1].value == 6
|
||||
assert samples[1].labels == {"tag": "aa"}
|
||||
|
||||
# Record more than 1 gauge.
|
||||
metric_name_2 = "test2"
|
||||
test_gauge_2 = Gauge(metric_name_2, "desc", "unit", ["tag"])
|
||||
record_e = Record(
|
||||
gauge=test_gauge_2,
|
||||
value=1,
|
||||
tags={"tag": "b"},
|
||||
)
|
||||
agent.record_and_export([record_e])
|
||||
name, samples = get_metric(
|
||||
get_prom_metric_name(namespace, metric_name_2), agent_port
|
||||
)
|
||||
|
||||
assert name == get_prom_metric_name(namespace, metric_name_2)
|
||||
assert samples[0].value == 1
|
||||
assert samples[0].labels == {"tag": "b"}
|
||||
|
||||
# Make sure the previous record is still there.
|
||||
name, samples = get_metric(get_prom_metric_name(namespace, metric_name), agent_port)
|
||||
assert name == get_prom_metric_name(namespace, metric_name)
|
||||
assert len(samples) == 2
|
||||
assert samples[0].value == 4
|
||||
assert samples[0].labels == {"tag": "a"}
|
||||
assert samples[1].value == 6
|
||||
assert samples[1].labels == {"tag": "aa"}
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
|
||||
def test_metrics_agent_record_and_export_failed_records_dont_block_other_records(
|
||||
get_agent,
|
||||
capsys,
|
||||
):
|
||||
namespace = "test"
|
||||
agent, agent_port = get_agent
|
||||
|
||||
metric_name = "test"
|
||||
test_gauge = Gauge(metric_name, "desc", "unit", ["tag"])
|
||||
record_a = Record(
|
||||
gauge=test_gauge,
|
||||
value=1,
|
||||
tags={"tag": "a"},
|
||||
)
|
||||
record_b = Record(
|
||||
gauge=test_gauge,
|
||||
value=1,
|
||||
# this tag is much too long (>255 characters), so recording this metric will fail
|
||||
tags={"tag": "b" * 1000},
|
||||
)
|
||||
record_c = Record(
|
||||
gauge=test_gauge,
|
||||
value=1,
|
||||
tags={"tag": "c"},
|
||||
)
|
||||
agent.record_and_export([record_a, record_b, record_c])
|
||||
|
||||
name, samples = get_metric(get_prom_metric_name(namespace, metric_name), agent_port)
|
||||
assert name == get_prom_metric_name(namespace, metric_name)
|
||||
|
||||
# a and c should be recorded, b's failure should be ignored
|
||||
assert {sample.labels["tag"] for sample in samples} == {"a", "c"}
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
|
||||
def test_metrics_agent_proxy_record_and_export_basic(get_agent):
|
||||
"""Test the case the metrics are exported without worker_id."""
|
||||
namespace = "test"
|
||||
agent, agent_port = get_agent
|
||||
|
||||
# Test the basic case.
|
||||
m = generate_protobuf_metric(
|
||||
"test",
|
||||
"desc",
|
||||
"",
|
||||
MetricDescriptorType.GAUGE_DOUBLE,
|
||||
label_keys=["a", "b"],
|
||||
timeseries=[],
|
||||
)
|
||||
m.timeseries.append(generate_timeseries(["a", "b"], [1, 2, 3]))
|
||||
agent.proxy_export_metrics([m])
|
||||
name, samples = get_metric(f"{namespace}_test", agent_port)
|
||||
assert name == f"{namespace}_test"
|
||||
assert len(samples) == 1
|
||||
assert samples[0].labels == {"a": "a", "b": "b"}
|
||||
assert samples[0].value == 3
|
||||
|
||||
# Test new metric has proxyed.
|
||||
m = generate_protobuf_metric(
|
||||
"test",
|
||||
"desc",
|
||||
"",
|
||||
MetricDescriptorType.GAUGE_DOUBLE,
|
||||
label_keys=["a", "b"],
|
||||
timeseries=[],
|
||||
)
|
||||
m.timeseries.append(generate_timeseries(["a", "b"], [4]))
|
||||
agent.proxy_export_metrics([m])
|
||||
name, samples = get_metric(f"{namespace}_test", agent_port)
|
||||
assert name == f"{namespace}_test"
|
||||
assert len(samples) == 1
|
||||
assert samples[0].labels == {"a": "a", "b": "b"}
|
||||
assert samples[0].value == 4
|
||||
|
||||
# Test new metric with different tag is reported.
|
||||
m = generate_protobuf_metric(
|
||||
"test",
|
||||
"desc",
|
||||
"",
|
||||
MetricDescriptorType.GAUGE_DOUBLE,
|
||||
label_keys=["a", "b"],
|
||||
timeseries=[],
|
||||
)
|
||||
m.timeseries.append(generate_timeseries(["a", "c"], [5]))
|
||||
agent.proxy_export_metrics([m])
|
||||
name, samples = get_metric(f"{namespace}_test", agent_port)
|
||||
assert name == f"{namespace}_test"
|
||||
assert len(samples) == 2
|
||||
assert samples[0].labels == {"a": "a", "b": "b"}
|
||||
assert samples[0].value == 4
|
||||
# Newly added metric has different tags and values.
|
||||
assert samples[1].labels == {"a": "a", "b": "c"}
|
||||
assert samples[1].value == 5
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
|
||||
def test_metrics_agent_proxy_record_and_export_from_workers(get_agent):
|
||||
"""
|
||||
Test the basic worker death case.
|
||||
"""
|
||||
namespace = "test"
|
||||
agent, agent_port = get_agent
|
||||
worker_id = WorkerID.from_random()
|
||||
|
||||
m = generate_protobuf_metric(
|
||||
"test",
|
||||
"desc",
|
||||
"",
|
||||
MetricDescriptorType.GAUGE_DOUBLE,
|
||||
label_keys=["a", "b"],
|
||||
timeseries=[],
|
||||
)
|
||||
m.timeseries.append(generate_timeseries(["a", "b"], [1, 2, 3]))
|
||||
agent.proxy_export_metrics([m], worker_id_hex=worker_id.hex())
|
||||
# Metrics should be exposed.
|
||||
assert get_metric(f"{namespace}_test", agent_port) is not None
|
||||
agent.clean_all_dead_worker_metrics()
|
||||
# Once the worker is dead, metrics should be unavailble.
|
||||
assert get_metric(f"{namespace}_test", agent_port) is None
|
||||
# Once the worker metrics is re-reported, it is treated as alive again.
|
||||
agent.proxy_export_metrics([m], worker_id_hex=worker_id.hex())
|
||||
assert get_metric(f"{namespace}_test", agent_port) is not None
|
||||
# Clean it again and the worker metrics is cleaned again.
|
||||
agent.clean_all_dead_worker_metrics()
|
||||
assert get_metric(f"{namespace}_test", agent_port) is None
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
|
||||
def test_metrics_agent_proxy_record_and_export_from_workers_complicated(
|
||||
get_agent,
|
||||
): # noqa
|
||||
"""
|
||||
Test the complicated worker death case.
|
||||
"""
|
||||
namespace = "test"
|
||||
agent, agent_port = get_agent
|
||||
|
||||
# Each worker will report 2 metrics.
|
||||
# i.e.,
|
||||
# worker 1 => test_1, test_2.
|
||||
# worker 2 => test_3, test_4.
|
||||
# ...
|
||||
worker_ids = [WorkerID.from_random() for _ in range(4)]
|
||||
|
||||
metrics = []
|
||||
for i in range(8):
|
||||
m = generate_protobuf_metric(
|
||||
f"test_{i}",
|
||||
"desc",
|
||||
"",
|
||||
MetricDescriptorType.GAUGE_DOUBLE,
|
||||
label_keys=["a", "b"],
|
||||
timeseries=[],
|
||||
)
|
||||
m.timeseries.append(generate_timeseries(["a", str(i)], [3]))
|
||||
metrics.append(m)
|
||||
|
||||
i = 0
|
||||
for worker_id in worker_ids:
|
||||
agent.proxy_export_metrics(
|
||||
[metrics[i], metrics[i + 1]], worker_id_hex=worker_id.hex()
|
||||
)
|
||||
i += 2
|
||||
|
||||
# All metrics must be available.
|
||||
for i in range(len(metrics)):
|
||||
assert get_metric(f"{namespace}_test_{i}", agent_port) is not None
|
||||
|
||||
# Mark the worker as dead and make sure metrics are properly cleaned.
|
||||
i = 0
|
||||
|
||||
while len(worker_ids):
|
||||
for worker_id in worker_ids:
|
||||
agent.clean_all_dead_worker_metrics()
|
||||
assert get_metric(f"{namespace}_test_{i}", agent_port) is None
|
||||
assert get_metric(f"{namespace}_test_{i+1}", agent_port) is None
|
||||
|
||||
worker_ids.pop(0)
|
||||
metrics.pop(0)
|
||||
metrics.pop(0)
|
||||
|
||||
i = 0
|
||||
for worker_id in worker_ids:
|
||||
agent.proxy_export_metrics(
|
||||
[metrics[i], metrics[i + 1]], worker_id_hex=worker_id.hex()
|
||||
)
|
||||
i += 2
|
||||
|
||||
# Make sure the rest of metrics are still there because new metrics
|
||||
# are reported.
|
||||
for j in range(i + 2, len(metrics)):
|
||||
assert get_metric(f"{namespace}_test_{j}", agent_port) is not None, j
|
||||
i += 2
|
||||
|
||||
|
||||
DELAY = 3
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
|
||||
@pytest.mark.parametrize("get_agent", [DELAY], indirect=True)
|
||||
def test_metrics_agent_proxy_record_and_export_from_workers_delay(get_agent): # noqa
|
||||
"""
|
||||
Test the worker metrics are deleted after the delay.
|
||||
"""
|
||||
namespace = "test"
|
||||
agent, agent_port = get_agent
|
||||
worker_id = WorkerID.from_random()
|
||||
|
||||
m = generate_protobuf_metric(
|
||||
"test",
|
||||
"desc",
|
||||
"",
|
||||
MetricDescriptorType.GAUGE_DOUBLE,
|
||||
label_keys=["a", "b"],
|
||||
timeseries=[],
|
||||
)
|
||||
m.timeseries.append(generate_timeseries(["a", "b"], [1, 2, 3]))
|
||||
agent.proxy_export_metrics([m], worker_id_hex=worker_id.hex())
|
||||
agent.clean_all_dead_worker_metrics()
|
||||
start = time.time()
|
||||
|
||||
def verify():
|
||||
agent.clean_all_dead_worker_metrics()
|
||||
return get_metric(f"{namespace}_test", agent_port) is None
|
||||
|
||||
wait_for_condition(verify)
|
||||
assert time.time() - start > DELAY
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
|
||||
def test_metrics_agent_export_format_correct(get_agent):
|
||||
"""
|
||||
Verifies that there is one metric per metric name and not one
|
||||
per metric name + tag combination.
|
||||
Also verifies that the prometheus output is in the right format.
|
||||
"""
|
||||
namespace = "test"
|
||||
agent, agent_port = get_agent
|
||||
|
||||
# Record a new gauge.
|
||||
metric_name = "test"
|
||||
test_gauge = Gauge(metric_name, "desc", "unit", ["tag"])
|
||||
record_a = Record(
|
||||
gauge=test_gauge,
|
||||
value=3,
|
||||
tags={"tag": "a"},
|
||||
)
|
||||
agent.record_and_export([record_a])
|
||||
|
||||
# Record a different tag.
|
||||
record_b = Record(
|
||||
gauge=test_gauge,
|
||||
value=4,
|
||||
tags={"tag": "b"},
|
||||
)
|
||||
agent.record_and_export([record_b])
|
||||
|
||||
# Record more than 1 gauge.
|
||||
metric_name_2 = "test2"
|
||||
test_gauge_2 = Gauge(metric_name_2, "desc", "unit", ["tag"])
|
||||
record_c = Record(
|
||||
gauge=test_gauge_2,
|
||||
value=1,
|
||||
tags={"tag": "c"},
|
||||
)
|
||||
agent.record_and_export([record_c])
|
||||
|
||||
# Basic assertions
|
||||
name, samples = get_metric(
|
||||
get_prom_metric_name(namespace, metric_name_2), agent_port
|
||||
)
|
||||
assert name == get_prom_metric_name(namespace, metric_name_2)
|
||||
assert len(samples) == 1
|
||||
assert samples[0].value == 1
|
||||
assert samples[0].labels == {"tag": "c"}
|
||||
|
||||
name, samples = get_metric(get_prom_metric_name(namespace, metric_name), agent_port)
|
||||
assert name == get_prom_metric_name(namespace, metric_name)
|
||||
assert len(samples) == 2
|
||||
assert samples[0].value == 3
|
||||
assert samples[0].labels == {"tag": "a"}
|
||||
assert samples[1].value == 4
|
||||
assert samples[1].labels == {"tag": "b"}
|
||||
|
||||
# Assert there is not multiple HELP text per metric
|
||||
# Need to manually parse the prometheus output because the official
|
||||
# `prometheus_client.parser` is more lenient than the actual
|
||||
# specification and ignores the multiple HELP / TYPE comments.
|
||||
metrics_page = "localhost:{}".format(agent_port)
|
||||
_, response = list(fetch_raw_prometheus([metrics_page]))[0]
|
||||
assert response.count("# HELP test_test desc") == 1
|
||||
assert response.count("# TYPE test_test gauge") == 1
|
||||
assert response.count("# HELP test_test2 desc") == 1
|
||||
assert response.count("# TYPE test_test2 gauge") == 1
|
||||
|
||||
|
||||
def _stub_node_level_metric(label: str, value: float) -> OpencensusProxyMetric:
|
||||
metric = OpencensusProxyMetric(
|
||||
name="test_metric_01",
|
||||
desc="",
|
||||
unit="",
|
||||
label_keys=["NodeId"],
|
||||
)
|
||||
metric.add_data(
|
||||
(label,),
|
||||
LastValueAggregationData(ValueDouble, value),
|
||||
)
|
||||
return metric
|
||||
|
||||
|
||||
def _stub_worker_level_metric(label: str, value: float) -> OpencensusProxyMetric:
|
||||
metric = OpencensusProxyMetric(
|
||||
name="test_metric_01",
|
||||
desc="",
|
||||
unit="",
|
||||
label_keys=["NodeId", WORKER_ID_TAG_KEY],
|
||||
)
|
||||
metric.add_data(
|
||||
(label, "worker_01"),
|
||||
LastValueAggregationData(ValueDouble, value),
|
||||
)
|
||||
return metric
|
||||
|
||||
|
||||
def test_aggregate_metric_data():
|
||||
collector = OpenCensusProxyCollector("")
|
||||
collector._aggregate_metric_data(
|
||||
[
|
||||
LastValueAggregationData(ValueDouble, 1.0),
|
||||
LastValueAggregationData(ValueDouble, 2.0),
|
||||
LastValueAggregationData(ValueDouble, 3.0),
|
||||
]
|
||||
).value == 6.0
|
||||
collector._aggregate_metric_data(
|
||||
[
|
||||
SumAggregationData(ValueDouble, 1.0),
|
||||
SumAggregationData(ValueDouble, 4.0),
|
||||
]
|
||||
).sum_data == 5.0
|
||||
collector._aggregate_metric_data(
|
||||
[
|
||||
CountAggregationData(1),
|
||||
CountAggregationData(1),
|
||||
]
|
||||
).count_data == 2
|
||||
with pytest.raises(ValueError, match="Unsupported aggregation type"):
|
||||
collector._aggregate_metric_data(
|
||||
[
|
||||
DistributionAggregationData(
|
||||
mean_data=1.0,
|
||||
count_data=1,
|
||||
sum_of_sqd_deviations=1.0,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_collect_worker_metrics_with_recommended_cardinality():
|
||||
aggregated_metrics = OpenCensusProxyCollector(
|
||||
""
|
||||
)._aggregate_with_recommended_cardinality(
|
||||
[
|
||||
_stub_worker_level_metric("node_01", 1.0),
|
||||
_stub_worker_level_metric("node_01", 3.0),
|
||||
_stub_worker_level_metric("node_02", 2.0),
|
||||
]
|
||||
)
|
||||
assert len(aggregated_metrics) == 1
|
||||
metric = aggregated_metrics[0]
|
||||
assert metric.name == "test_metric_01"
|
||||
assert metric.label_keys == ["NodeId"]
|
||||
# Check that the worker id is removed from the label keys, and the correct metric
|
||||
# values are returned.
|
||||
assert metric._data.get(("node_01",)).value == 4.0
|
||||
assert metric._data.get(("node_02",)).value == 2.0
|
||||
|
||||
|
||||
def test_collect_node_metrics_with_recommended_cardinality():
|
||||
aggregated_metrics = OpenCensusProxyCollector(
|
||||
""
|
||||
)._aggregate_with_recommended_cardinality(
|
||||
[
|
||||
_stub_node_level_metric("node_01", 1.0),
|
||||
_stub_node_level_metric("node_02", 2.0),
|
||||
]
|
||||
)
|
||||
# Metrics are already at node level, so they should be returned as is.
|
||||
assert len(aggregated_metrics) == 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
Reference in New Issue
Block a user