add support of otlp exporter
Depends-On: Idcff5d79b4884dd1cc7ed1ab42c9e4ce89d4a6d2 Signed-off-by: Sahid Orentino Ferdjaoui <sahid.ferdjaoui@industrialdiscipline.com> Change-Id: I74cdcb2aa99b0162ba1c14059111f09d0bb534e3
This commit is contained in:
@@ -58,19 +58,29 @@ function install_redis() {
|
|||||||
pip_install_gr redis
|
pip_install_gr redis
|
||||||
}
|
}
|
||||||
|
|
||||||
function install_jaeger() {
|
function install_jaeger_backend() {
|
||||||
if is_ubuntu; then
|
if is_ubuntu; then
|
||||||
install_package docker.io
|
install_package docker.io
|
||||||
start_service docker
|
start_service docker
|
||||||
add_user_to_group $STACK_USER docker
|
add_user_to_group $STACK_USER docker
|
||||||
sg docker -c "docker run -d --name jaeger -p 6831:6831/udp -p 16686:16686 jaegertracing/all-in-one:1.7"
|
sg docker -c "docker run -d --name jaeger -e COLLECTOR_OTLP_ENABLED=true -p 6831:6831/udp -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:1.42"
|
||||||
else
|
else
|
||||||
exit_distro_not_supported "docker.io installation"
|
exit_distro_not_supported "docker.io installation"
|
||||||
fi
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function install_jaeger() {
|
||||||
|
install_jaeger_backend
|
||||||
pip_install jaeger-client
|
pip_install jaeger-client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function install_otlp() {
|
||||||
|
# For OTLP we use Jaeger backend but any OTLP compatible backend
|
||||||
|
# can be used.
|
||||||
|
install_jaeger_backend
|
||||||
|
pip_install opentelemetry-sdk opentelemetry-exporter-otlp
|
||||||
|
}
|
||||||
|
|
||||||
function drop_jaeger() {
|
function drop_jaeger() {
|
||||||
sg docker -c 'docker rm jaeger --force'
|
sg docker -c 'docker rm jaeger --force'
|
||||||
}
|
}
|
||||||
@@ -112,6 +122,9 @@ function install_osprofiler_collector() {
|
|||||||
elif [ "$OSPROFILER_COLLECTOR" == "jaeger" ]; then
|
elif [ "$OSPROFILER_COLLECTOR" == "jaeger" ]; then
|
||||||
install_jaeger
|
install_jaeger
|
||||||
OSPROFILER_CONNECTION_STRING=${OSPROFILER_CONNECTION_STRING:-"jaeger://localhost:6831"}
|
OSPROFILER_CONNECTION_STRING=${OSPROFILER_CONNECTION_STRING:-"jaeger://localhost:6831"}
|
||||||
|
elif [ "$OSPROFILER_COLLECTOR" == "otlp" ]; then
|
||||||
|
install_otlp
|
||||||
|
OSPROFILER_CONNECTION_STRING=${OSPROFILER_CONNECTION_STRING:-"otlp://localhost:4318"}
|
||||||
elif [ "$OSPROFILER_COLLECTOR" == "elasticsearch" ]; then
|
elif [ "$OSPROFILER_COLLECTOR" == "elasticsearch" ]; then
|
||||||
install_elasticsearch
|
install_elasticsearch
|
||||||
OSPROFILER_CONNECTION_STRING=${OSPROFILER_CONNECTION_STRING:-"elasticsearch://elastic:changeme@localhost:9200"}
|
OSPROFILER_CONNECTION_STRING=${OSPROFILER_CONNECTION_STRING:-"elasticsearch://elastic:changeme@localhost:9200"}
|
||||||
|
@@ -20,7 +20,8 @@ elif [[ "$1" == "stack" && "$2" == "test-config" ]]; then
|
|||||||
configure_osprofiler_in_tempest
|
configure_osprofiler_in_tempest
|
||||||
|
|
||||||
elif [[ "$1" == "unstack" ]]; then
|
elif [[ "$1" == "unstack" ]]; then
|
||||||
if [[ "$OSPROFILER_COLLECTOR" == "jaeger" ]]; then
|
if [[ "$OSPROFILER_COLLECTOR" == "jaeger" || \
|
||||||
|
"$OSPROFILER_COLLECTOR" == "otlp" ]]; then
|
||||||
echo_summary "Deleting jaeger docker container"
|
echo_summary "Deleting jaeger docker container"
|
||||||
drop_jaeger
|
drop_jaeger
|
||||||
fi
|
fi
|
||||||
|
@@ -71,3 +71,25 @@ to create tables and select and insert rows.
|
|||||||
- MySQL 5.7.8
|
- MySQL 5.7.8
|
||||||
|
|
||||||
.. _SQLAlchemy understands: https://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls
|
.. _SQLAlchemy understands: https://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls
|
||||||
|
|
||||||
|
|
||||||
|
OTLP
|
||||||
|
----
|
||||||
|
|
||||||
|
Use OTLP exporter. Can be used with any comptable backend that support
|
||||||
|
OTLP.
|
||||||
|
|
||||||
|
Usage
|
||||||
|
=====
|
||||||
|
To use the driver, the `connection_string` in the `[osprofiler]` config section
|
||||||
|
needs to be set::
|
||||||
|
|
||||||
|
[osprofiler]
|
||||||
|
connection_string = otlp://192.168.192.81:4318
|
||||||
|
|
||||||
|
Example: By default, jaeger is listening OTLP on 4318.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Curently the exporter is only supporting HTTP. In future some work
|
||||||
|
may happen to support gRPC.
|
||||||
|
@@ -5,6 +5,8 @@ dulwich===0.15.0
|
|||||||
elasticsearch===2.0.0
|
elasticsearch===2.0.0
|
||||||
importlib_metadata==1.7.0
|
importlib_metadata==1.7.0
|
||||||
jaeger-client==3.8.0
|
jaeger-client==3.8.0
|
||||||
|
opentelemetry-exporter-otlp==1.16.0
|
||||||
|
opentelemetry-sdk==1.16.0
|
||||||
netaddr===0.7.18
|
netaddr===0.7.18
|
||||||
openstackdocstheme==2.2.1
|
openstackdocstheme==2.2.1
|
||||||
oslo.concurrency===3.26.0
|
oslo.concurrency===3.26.0
|
||||||
|
@@ -161,3 +161,15 @@ def shorten_id(span_id):
|
|||||||
# Return a new short id for this
|
# Return a new short id for this
|
||||||
short_id = shorten_id(uuidutils.generate_uuid())
|
short_id = shorten_id(uuidutils.generate_uuid())
|
||||||
return short_id
|
return short_id
|
||||||
|
|
||||||
|
|
||||||
|
def uuid_to_int128(span_uuid):
|
||||||
|
"""Convert from uuid4 to 128 bit id for OpenTracing"""
|
||||||
|
if isinstance(span_uuid, int):
|
||||||
|
return span_uuid
|
||||||
|
try:
|
||||||
|
span_int = uuid.UUID(span_uuid).int
|
||||||
|
except ValueError:
|
||||||
|
# Return a new short id for this
|
||||||
|
span_int = uuid_to_int128(uuidutils.generate_uuid())
|
||||||
|
return span_int
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
from osprofiler.drivers import base # noqa
|
from osprofiler.drivers import base # noqa
|
||||||
from osprofiler.drivers import elasticsearch_driver # noqa
|
from osprofiler.drivers import elasticsearch_driver # noqa
|
||||||
from osprofiler.drivers import jaeger # noqa
|
from osprofiler.drivers import jaeger # noqa
|
||||||
|
from osprofiler.drivers import otlp # noqa
|
||||||
from osprofiler.drivers import loginsight # noqa
|
from osprofiler.drivers import loginsight # noqa
|
||||||
from osprofiler.drivers import messaging # noqa
|
from osprofiler.drivers import messaging # noqa
|
||||||
from osprofiler.drivers import mongodb # noqa
|
from osprofiler.drivers import mongodb # noqa
|
||||||
|
179
osprofiler/drivers/otlp.py
Normal file
179
osprofiler/drivers/otlp.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# All Rights Reserved.
|
||||||
|
#
|
||||||
|
# Licensed 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 collections
|
||||||
|
from urllib import parse as parser
|
||||||
|
|
||||||
|
from oslo_config import cfg
|
||||||
|
from oslo_serialization import jsonutils
|
||||||
|
|
||||||
|
from osprofiler import _utils as utils
|
||||||
|
from osprofiler.drivers import base
|
||||||
|
from osprofiler import exc
|
||||||
|
|
||||||
|
|
||||||
|
class OTLP(base.Driver):
|
||||||
|
def __init__(self, connection_str, project=None, service=None, host=None,
|
||||||
|
conf=cfg.CONF, **kwargs):
|
||||||
|
"""OTLP driver using OTLP exporters."""
|
||||||
|
|
||||||
|
super(OTLP, self).__init__(connection_str, project=project,
|
||||||
|
service=service, host=host,
|
||||||
|
conf=conf, **kwargs)
|
||||||
|
try:
|
||||||
|
from opentelemetry import trace as trace_api
|
||||||
|
|
||||||
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter # noqa
|
||||||
|
from opentelemetry.sdk.resources import Resource
|
||||||
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||||
|
from opentelemetry.sdk.trace import TracerProvider
|
||||||
|
|
||||||
|
self.trace_api = trace_api
|
||||||
|
except ImportError:
|
||||||
|
raise exc.CommandError(
|
||||||
|
"To use OSProfiler with OTLP exporters, "
|
||||||
|
"please install `opentelemetry-sdk` and "
|
||||||
|
"opentelemetry-exporter-otlp libraries. "
|
||||||
|
"To install with pip:\n `pip install opentelemetry-sdk "
|
||||||
|
"opentelemetry-exporter-otlp`.")
|
||||||
|
|
||||||
|
service_name = self._get_service_name(conf, project, service)
|
||||||
|
resource = Resource(attributes={
|
||||||
|
"service.name": service_name
|
||||||
|
})
|
||||||
|
|
||||||
|
parsed_url = parser.urlparse(connection_str)
|
||||||
|
# TODO("sahid"): We also want to handle https scheme?
|
||||||
|
parsed_url = parsed_url._replace(scheme="http")
|
||||||
|
|
||||||
|
self.trace_api.set_tracer_provider(
|
||||||
|
TracerProvider(resource=resource))
|
||||||
|
self.tracer = self.trace_api.get_tracer(__name__)
|
||||||
|
|
||||||
|
exporter = OTLPSpanExporter("{}/v1/traces".format(
|
||||||
|
parsed_url.geturl()))
|
||||||
|
self.trace_api.get_tracer_provider().add_span_processor(
|
||||||
|
BatchSpanProcessor(exporter))
|
||||||
|
|
||||||
|
self.spans = collections.deque()
|
||||||
|
|
||||||
|
def _get_service_name(self, conf, project, service):
|
||||||
|
prefix = conf.profiler_otlp.service_name_prefix
|
||||||
|
if prefix:
|
||||||
|
return "{}-{}-{}".format(prefix, project, service)
|
||||||
|
return "{}-{}".format(project, service)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_name(cls):
|
||||||
|
return "otlp"
|
||||||
|
|
||||||
|
def _kind(self, name):
|
||||||
|
if "wsgi" in name:
|
||||||
|
return self.trace_api.SpanKind.SERVER
|
||||||
|
elif ("db" in name or "http_client" in name or "api" in name):
|
||||||
|
return self.trace_api.SpanKind.CLIENT
|
||||||
|
return self.trace_api.SpanKind.INTERNAL
|
||||||
|
|
||||||
|
def _name(self, payload):
|
||||||
|
info = payload["info"]
|
||||||
|
if info.get("request"):
|
||||||
|
return "{}_{}".format(
|
||||||
|
info["request"]["method"], info["request"]["path"])
|
||||||
|
elif info.get("db"):
|
||||||
|
return "SQL_{}".format(
|
||||||
|
info["db"]["statement"].split(' ', 1)[0].upper())
|
||||||
|
return payload["name"].rstrip("-start")
|
||||||
|
|
||||||
|
def notify(self, payload):
|
||||||
|
if payload["name"].endswith("start"):
|
||||||
|
parent = self.trace_api.SpanContext(
|
||||||
|
trace_id=utils.uuid_to_int128(payload["base_id"]),
|
||||||
|
span_id=utils.shorten_id(payload["parent_id"]),
|
||||||
|
is_remote=False,
|
||||||
|
trace_flags=self.trace_api.TraceFlags(
|
||||||
|
self.trace_api.TraceFlags.SAMPLED))
|
||||||
|
|
||||||
|
ctx = self.trace_api.set_span_in_context(
|
||||||
|
self.trace_api.NonRecordingSpan(parent))
|
||||||
|
|
||||||
|
# OTLP Tracing span
|
||||||
|
span = self.tracer.start_span(
|
||||||
|
name=self._name(payload),
|
||||||
|
kind=self._kind(payload['name']),
|
||||||
|
attributes=self.create_span_tags(payload),
|
||||||
|
context=ctx)
|
||||||
|
|
||||||
|
span._context = self.trace_api.SpanContext(
|
||||||
|
trace_id=span.context.trace_id,
|
||||||
|
span_id=utils.shorten_id(payload["trace_id"]),
|
||||||
|
is_remote=span.context.is_remote,
|
||||||
|
trace_flags=span.context.trace_flags,
|
||||||
|
trace_state=span.context.trace_state)
|
||||||
|
|
||||||
|
self.spans.append(span)
|
||||||
|
else:
|
||||||
|
span = self.spans.pop()
|
||||||
|
|
||||||
|
# Store result of db call and function call
|
||||||
|
for call in ("db", "function"):
|
||||||
|
if payload.get("info", {}).get(call):
|
||||||
|
span.set_attribute(
|
||||||
|
"result", payload["info"][call]["result"])
|
||||||
|
# Span error tag and log
|
||||||
|
if payload["info"].get("etype"):
|
||||||
|
span.set_attribute("error", True)
|
||||||
|
span.add_event("log", {
|
||||||
|
"error.kind": payload["info"]["etype"],
|
||||||
|
"message": payload["info"]["message"]})
|
||||||
|
span.end()
|
||||||
|
|
||||||
|
def get_report(self, base_id):
|
||||||
|
return self._parse_results()
|
||||||
|
|
||||||
|
def list_traces(self, fields=None):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def list_error_traces(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_span_tags(self, payload):
|
||||||
|
"""Create tags an OpenTracing compatible span.
|
||||||
|
|
||||||
|
:param info: Information from OSProfiler trace.
|
||||||
|
:returns tags: A dictionary contains standard tags
|
||||||
|
from OpenTracing sematic conventions,
|
||||||
|
and some other custom tags related to http, db calls.
|
||||||
|
"""
|
||||||
|
tags = {}
|
||||||
|
info = payload["info"]
|
||||||
|
|
||||||
|
if info.get("db"):
|
||||||
|
# DB calls
|
||||||
|
tags["db.statement"] = info["db"]["statement"]
|
||||||
|
tags["db.params"] = jsonutils.dumps(info["db"]["params"])
|
||||||
|
elif info.get("request"):
|
||||||
|
# WSGI call
|
||||||
|
tags["http.path"] = info["request"]["path"]
|
||||||
|
tags["http.query"] = info["request"]["query"]
|
||||||
|
tags["http.method"] = info["request"]["method"]
|
||||||
|
tags["http.scheme"] = info["request"]["scheme"]
|
||||||
|
elif info.get("function"):
|
||||||
|
# RPC, function calls
|
||||||
|
if "args" in info["function"]:
|
||||||
|
tags["args"] = info["function"]["args"]
|
||||||
|
if "kwargs" in info["function"]:
|
||||||
|
tags["kwargs"] = info["function"]["kwargs"]
|
||||||
|
tags["name"] = info["function"]["name"]
|
||||||
|
|
||||||
|
return tags
|
@@ -195,6 +195,22 @@ _JAEGER_OPTS = [
|
|||||||
|
|
||||||
cfg.CONF.register_opts(_JAEGER_OPTS, group=_jaegerprofiler_opt_group)
|
cfg.CONF.register_opts(_JAEGER_OPTS, group=_jaegerprofiler_opt_group)
|
||||||
|
|
||||||
|
_otlp_profiler_opt_group = cfg.OptGroup(
|
||||||
|
"profiler_otlp",
|
||||||
|
title="OTLP's profiler driver related options")
|
||||||
|
|
||||||
|
_otlp_service_name_prefix = cfg.StrOpt(
|
||||||
|
"service_name_prefix",
|
||||||
|
help="""
|
||||||
|
Set service name prefix to OTLP exporters.
|
||||||
|
""")
|
||||||
|
|
||||||
|
_OTLP_OPTS = [
|
||||||
|
_otlp_service_name_prefix,
|
||||||
|
]
|
||||||
|
|
||||||
|
cfg.CONF.register_opts(_OTLP_OPTS, group=_otlp_profiler_opt_group)
|
||||||
|
|
||||||
|
|
||||||
def set_defaults(conf, enabled=None, trace_sqlalchemy=None, hmac_keys=None,
|
def set_defaults(conf, enabled=None, trace_sqlalchemy=None, hmac_keys=None,
|
||||||
connection_string=None, es_doc_type=None,
|
connection_string=None, es_doc_type=None,
|
||||||
@@ -265,4 +281,5 @@ def disable_web_trace(conf=None):
|
|||||||
|
|
||||||
def list_opts():
|
def list_opts():
|
||||||
return [(_profiler_opt_group.name, _PROFILER_OPTS),
|
return [(_profiler_opt_group.name, _PROFILER_OPTS),
|
||||||
(_jaegerprofiler_opt_group, _JAEGER_OPTS)]
|
(_jaegerprofiler_opt_group, _JAEGER_OPTS),
|
||||||
|
(_otlp_profiler_opt_group, _OTLP_OPTS)]
|
||||||
|
84
osprofiler/tests/unit/drivers/test_otlp.py
Normal file
84
osprofiler/tests/unit/drivers/test_otlp.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# All Rights Reserved.
|
||||||
|
#
|
||||||
|
# Licensed 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.
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from oslo_config import cfg
|
||||||
|
|
||||||
|
from osprofiler.drivers import otlp
|
||||||
|
from osprofiler import opts
|
||||||
|
from osprofiler.tests import test
|
||||||
|
|
||||||
|
|
||||||
|
class OTLPTestCase(test.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(OTLPTestCase, self).setUp()
|
||||||
|
|
||||||
|
opts.set_defaults(cfg.CONF)
|
||||||
|
|
||||||
|
self.payload_start = {
|
||||||
|
"name": "api-start",
|
||||||
|
"base_id": "4e3e0ec6-2938-40b1-8504-09eb1d4b0dee",
|
||||||
|
"trace_id": "1c089ea8-28fe-4f3d-8c00-f6daa2bc32f1",
|
||||||
|
"parent_id": "e2715537-3d1c-4f0c-b3af-87355dc5fc5b",
|
||||||
|
"timestamp": "2018-05-03T04:31:51.781381",
|
||||||
|
"info": {
|
||||||
|
"host": "test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.payload_stop = {
|
||||||
|
"name": "api-stop",
|
||||||
|
"base_id": "4e3e0ec6-2938-40b1-8504-09eb1d4b0dee",
|
||||||
|
"trace_id": "1c089ea8-28fe-4f3d-8c00-f6daa2bc32f1",
|
||||||
|
"parent_id": "e2715537-3d1c-4f0c-b3af-87355dc5fc5b",
|
||||||
|
"timestamp": "2018-05-03T04:31:51.781381",
|
||||||
|
"info": {
|
||||||
|
"host": "test",
|
||||||
|
"function": {
|
||||||
|
"result": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.driver = otlp.OTLP(
|
||||||
|
"otlp://127.0.0.1:6831",
|
||||||
|
project="nova", service="api",
|
||||||
|
conf=cfg.CONF)
|
||||||
|
|
||||||
|
def test_notify_start(self):
|
||||||
|
self.driver.notify(self.payload_start)
|
||||||
|
self.assertEqual(1, len(self.driver.spans))
|
||||||
|
|
||||||
|
def test_notify_stop(self):
|
||||||
|
mock_end = mock.MagicMock()
|
||||||
|
self.driver.notify(self.payload_start)
|
||||||
|
self.driver.spans[0].end = mock_end
|
||||||
|
self.driver.notify(self.payload_stop)
|
||||||
|
mock_end.assert_called_once()
|
||||||
|
|
||||||
|
def test_service_name_default(self):
|
||||||
|
self.assertEqual("pr1-svc1", self.driver._get_service_name(
|
||||||
|
cfg.CONF, "pr1", "svc1"))
|
||||||
|
|
||||||
|
def test_service_name_prefix(self):
|
||||||
|
cfg.CONF.set_default(
|
||||||
|
"service_name_prefix", "prx1", "profiler_otlp")
|
||||||
|
self.assertEqual("prx1-pr1-svc1", self.driver._get_service_name(
|
||||||
|
cfg.CONF, "pr1", "svc1"))
|
||||||
|
|
||||||
|
def test_process_tags(self):
|
||||||
|
# Need to be implemented.
|
||||||
|
pass
|
8
releasenotes/notes/otlp-driver-cb932038ad580ac2.yaml
Normal file
8
releasenotes/notes/otlp-driver-cb932038ad580ac2.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
- |
|
||||||
|
An OTLP (OpenTelemetry) exporter is now supported. The current
|
||||||
|
support is experimental but the aim is to deprecate and remove
|
||||||
|
legacy Jaeger driver which is using the already deprecated python
|
||||||
|
library jaeger client. Operators who want to use it should enable
|
||||||
|
`otlp`. OTLP is comptatible with Jaeger backend.
|
@@ -20,5 +20,7 @@ redis>=2.10.0 # MIT
|
|||||||
|
|
||||||
# For Jaeger Tracing
|
# For Jaeger Tracing
|
||||||
jaeger-client>=3.8.0 # Apache-2.0
|
jaeger-client>=3.8.0 # Apache-2.0
|
||||||
|
opentelemetry-exporter-otlp>=1.16.0
|
||||||
|
opentelemetry-sdk>=1.16.0
|
||||||
|
|
||||||
pre-commit>=2.6.0 # MIT
|
pre-commit>=2.6.0 # MIT
|
||||||
|
Reference in New Issue
Block a user