diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..bcdece61 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +**/*_pb2.py linguist-generated +**/*_pb2_grpc.py linguist-generated +**/*_pb2.pyi linguist-generated diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 67c81136..8170918f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -89,6 +89,8 @@ jobs: TOXCFG: tox-integration.ini - TOXENV: py3-advanced TOXCFG: tox-integration.ini + - TOXENV: py3-grpc + TOXCFG: tox-integration.ini services: docker: @@ -123,6 +125,12 @@ jobs: with: python-version: "3.11" + - name: Install Protoc + if: ${{ contains(matrix.TOXENV, 'grpc') }} + uses: arduino/setup-protoc@v2 + with: + version: "23.x" + - name: install deps run: | pip install tox -c constraints.txt diff --git a/.gitignore b/.gitignore index 09a8a5b9..fa0769df 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,4 @@ bazel-out bazel-tavern bazel-testlogs +example/grpc/proto diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f42a1331..13d44fd4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,8 +23,10 @@ If on Windows, you should be able to just run the 'tox' commands in that file. 1. Update requirements files (BOTH of them) - pip-compile --all-extras --resolver=backtracking pyproject.toml --output-file requirements.txt --reuse-hashes --generate-hashes - pip-compile --all-extras --resolver=backtracking pyproject.toml --output-file constraints.txt --strip-extras +```shell +pip-compile --all-extras --resolver=backtracking pyproject.toml --output-file requirements.txt --reuse-hashes --generate-hashes -U +pip-compile --all-extras --resolver=backtracking pyproject.toml --output-file constraints.txt --strip-extras -U +``` 1. Run tests as above diff --git a/MANIFEST.in b/MANIFEST.in index 7340ceea..05b2580c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include tavern/_core/schema/tests.jsonschema.yaml include tavern/_plugins/mqtt/jsonschema.yaml +include tavern/_plugins/grpc/schema.yaml include LICENSE diff --git a/constraints.txt b/constraints.txt index 90ec1cee..cf884fc0 100644 --- a/constraints.txt +++ b/constraints.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --all-extras --output-file=constraints.txt --resolver=backtracking --strip-extras pyproject.toml +# pip-compile --all-extras --output-file=constraints.txt --strip-extras pyproject.toml # alabaster==0.7.16 # via sphinx @@ -25,7 +25,9 @@ build==1.0.3 bump2version==1.0.1 # via tavern (pyproject.toml) cachetools==5.3.2 - # via tox + # via + # google-auth + # tox certifi==2023.11.17 # via requests cffi==1.16.0 @@ -68,13 +70,13 @@ docutils==0.20.1 # tavern (pyproject.toml) execnet==2.0.2 # via pytest-xdist -faker==22.2.0 +faker==22.4.0 # via tavern (pyproject.toml) filelock==3.13.1 # via # tox # virtualenv -flask==3.0.0 +flask==3.0.1 # via tavern (pyproject.toml) flit==3.9.0 # via tavern (pyproject.toml) @@ -82,6 +84,40 @@ flit-core==3.9.0 # via flit fluent-logger==0.10.0 # via tavern (pyproject.toml) +google-api-core==2.15.0 + # via google-api-python-client +google-api-python-client==2.114.0 + # via tavern (pyproject.toml) +google-auth==2.26.2 + # via + # google-api-core + # google-api-python-client + # google-auth-httplib2 +google-auth-httplib2==0.2.0 + # via google-api-python-client +googleapis-common-protos==1.62.0 + # via + # google-api-core + # grpcio-status +grpc-interceptor==0.15.4 + # via tavern (pyproject.toml) +grpcio==1.60.0 + # via + # grpc-interceptor + # grpcio-reflection + # grpcio-status + # grpcio-tools + # tavern (pyproject.toml) +grpcio-reflection==1.60.0 + # via tavern (pyproject.toml) +grpcio-status==1.60.0 + # via tavern (pyproject.toml) +grpcio-tools==1.60.0 + # via tavern (pyproject.toml) +httplib2==0.22.0 + # via + # google-api-python-client + # google-auth-httplib2 identify==2.5.33 # via pre-commit idna==3.6 @@ -110,7 +146,7 @@ jinja2==3.1.3 # sphinx jmespath==1.0.1 # via tavern (pyproject.toml) -jsonschema==4.21.0 +jsonschema==4.21.1 # via tavern (pyproject.toml) jsonschema-specifications==2023.12.1 # via jsonschema @@ -120,7 +156,7 @@ markdown==3.5.2 # via sphinx-markdown-tables markdown-it-py==3.0.0 # via rich -markupsafe==2.1.3 +markupsafe==2.1.4 # via # jinja2 # werkzeug @@ -166,8 +202,25 @@ pluggy==1.3.0 # tox pre-commit==3.6.0 # via tavern (pyproject.toml) +proto-plus==1.23.0 + # via tavern (pyproject.toml) +protobuf==4.25.2 + # via + # google-api-core + # googleapis-common-protos + # grpcio-reflection + # grpcio-status + # grpcio-tools + # proto-plus + # tavern (pyproject.toml) py==1.11.0 # via tavern (pyproject.toml) +pyasn1==0.5.1 + # via + # pyasn1-modules + # rsa +pyasn1-modules==0.3.0 + # via google-auth pycparser==2.21 # via cffi pygments==2.17.2 @@ -180,6 +233,8 @@ pyjwt==2.8.0 # via tavern (pyproject.toml) pykwalify==1.8.0 # via tavern (pyproject.toml) +pyparsing==3.1.1 + # via httplib2 pyproject-api==1.6.1 # via tox pyproject-hooks==1.0.0 @@ -215,6 +270,7 @@ referencing==0.32.1 requests==2.31.0 # via # flit + # google-api-core # requests-toolbelt # sphinx # tavern (pyproject.toml) @@ -229,11 +285,13 @@ rpds-py==0.17.1 # via # jsonschema # referencing +rsa==4.9 + # via google-auth ruamel-yaml==0.18.5 # via pykwalify ruamel-yaml-clib==0.2.8 # via ruamel-yaml -ruff==0.1.13 +ruff==0.1.14 # via tavern (pyproject.toml) secretstorage==3.3.3 # via keyring @@ -281,6 +339,8 @@ types-setuptools==69.0.0.20240115 # via tavern (pyproject.toml) typing-extensions==4.9.0 # via mypy +uritemplate==4.1.1 + # via google-api-python-client urllib3==2.1.0 # via # requests diff --git a/docs/source/conf.py b/docs/source/conf.py index e6a22697..08e5a0a7 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -29,7 +29,6 @@ # needs_sphinx = '1.0' import sphinx_rtd_theme -import recommonmark from recommonmark.transform import AutoStructify # Add any Sphinx extension module names here, as strings. They can be diff --git a/docs/source/grpc.md b/docs/source/grpc.md new file mode 100644 index 00000000..13ba4962 --- /dev/null +++ b/docs/source/grpc.md @@ -0,0 +1,168 @@ +# gRPC integration testing + +## Current limitations / future plans + +- Should be able to specify channel credentials. +- Currently there is no way of doing custom TLS options (like with rest/mqtt) +- Better syntax around importing modules +- Some way of representing streaming RPCs? This is pretty niche and Tavern is built around a core of only making 1 + request which doesn't work well with streaming request RPCs, but streaming response RPCs could be handled like + multiple MQTT responses. +- Much like the tavern-flask plugin it wouldn't be too difficult to write a plugin which started a Python gRPC server + in-process and ran tests against that instead of having to use a remote server +- Fix comparing results - currently it serialises with + + including_default_value_fields=True, + preserving_proto_field_name=True, + + Which formats a field like `my_field_name` as `my_field_name` and not `myFieldName` which is what protojson in Go + converts it to for example, need to provide a way to allow people to write tests using either one +- protos are compiled into a folder based on `tempfile.gettempdir()`, this could be configurable + +## Connection + +There are 2 ways of specifying the grpc connection, in the `grpc` block at the top of the test similarly to an mqtt +connection block, or in the test stage itself. + +In the `grpc.connect` block: + +```yaml +grpc: + connect: + host: localhost + port: 50052 +``` + +In the test stage itself: + +```yaml +stages: + - name: Do a thing + grpc_request: + host: "localhost: 50052" + service: my.cool.service/Waoh + body: + ... +``` + +The connection will be established at the beginning of the test and dropped when it finishes. + +### SSL connection + +Tavern currently _defaults to an insecure connection_ when connecting to grpc, to enable SSL connections add +the `secure` key in the `connect` block: + +```yaml +grpc: + connect: + secure: true +``` + +### Metadata + +Generic metadata can be passed on every message using the `metadata` key: + +```yaml +grpc: + metadata: + my-extra-info: something +``` + +### Advanced: connection options + +Generic connection options can be passed as key:value pairs under the `options` block: + +```yaml +grpc: + connect: + options: + grpc.max_send_message_length: 10000000 +``` + +See [the gRPC documentation](https://grpc.github.io/grpc/core/group__grpc__arg__keys.html) for a list of possible +options, note that some of these may not be implemented in Python. + +## Requests + +The `grpc_request` block requires, at minimum, the name of the service to send the request to + +```yaml +stages: + - name: Say hello + grpc_request: + service: helloworld.v3.Greeter/SayHello + body: + name: "John" +``` + +The 'body' block will be reflected into the protobuf message type expected for the service, if the schema is invalid +then an exception will be raised. + +## Responses + +If no response is specified, Tavern will assume that _any_ response with an `OK` status code to be successful. + +Other status codes are specified using the `status` key. The gRPC status code should be a string matching +a [gRPC status code](https://grpc.github.io/grpc/core/md_doc_statuscodes.html), for +example `OK`, `NOT_FOUND`, etc. or the numerical value of the code. It can also be a list of codes. + +```yaml +stages: + - name: Echo text + grpc_request: + service: helloworld.v1.Greeter/SayHello + body: + name: "John" + grpc_response: + status: "OK" # Also the default +``` + +## Loading protobuf definitions + +There are 3 different ways Tavern will try to load the appropriate proto definitions: + +#### Specifying the proto module to use + +If you already have all the Python gRPC stubs in your repository. Example: + +```yaml +grpc: + proto: + module: server/helloworld_pb2_grpc +``` + +This will attempt to import the given module (it should not be a Python file, but the path to the module containing the +existing stubs) and register all the protos in it. + +#### Specifying a folder with some protos in + +Example: + +```yaml +grpc: + proto: + source: path/to/protos +``` + +This will attempt to find all files ending in `.proto` in the given folder and compile them using +the protoc compiler. It first checks the value of the environment variable `PROTOC` and use that, +and if not defined it will then look for a binary called `protoc` in the path. proto files are +compiled into a folder called `proto` under the same folder that the Tavern yaml is in. + +This has a few drawbacks, especially that if it can't find the protoc compiler at runtime it will +fail, but it might be useful if you're talking to a Java/Go/other server and you don't want to keep +some compiled Python gRPC stubs in your repository. + +#### Server reflection + +This is obviously the least useful method. If you don't specify a proto source or module, the client +can attempt to +use [gRPC reflection](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md) to +determine what is the appropriate message type for the message you're trying to send. This is not +reliable as the server you're trying to talk to might not have reflection turned on. This needs to be specified in +the `grpc` block: + +```yaml +grpc: + attempt_reflection: true +``` diff --git a/docs/source/index.md b/docs/source/index.md index 584562e6..105814a8 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -32,6 +32,7 @@ Tavern is still in active development and is used by 100s of companies. * [Basic Concepts](basics.md) * [HTTP Integration testing](http.md) * [MQTT Integration testing](mqtt.md) +* [gRPC Integration testing](grpc.md) * [Plugins](plugins.md) * [Debugging Tests](debugging.md) * [Examples](examples.md) diff --git a/example/grpc/Dockerfile b/example/grpc/Dockerfile new file mode 100644 index 00000000..f3f5d780 --- /dev/null +++ b/example/grpc/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim-bookworm@sha256:ee9a59cfdad294560241c9a8c8e40034f165feb4af7088c1479c2cdd84aafbed AS base + +RUN pip install grpcio-tools==1.59.0 grpcio==1.59.0 grpcio-reflection==1.59.0 grpcio-status==1.59.0 grpc-interceptor==0.15.3 + + +FROM base AS protos + +RUN apt-get update && apt-get install protobuf-compiler --yes --no-install-recommends && apt-get clean + +COPY *.proto . + +RUN python3 -m grpc_tools.protoc --proto_path=$(pwd) --pyi_out=$(pwd) --python_out=$(pwd) --grpc_python_out=$(pwd) *.proto + + + +FROM base + +COPY --from=protos /*.py / + +COPY server/server.py / + +CMD ["python3", "/server.py"] diff --git a/example/grpc/common.yaml b/example/grpc/common.yaml new file mode 100644 index 00000000..cf010c48 --- /dev/null +++ b/example/grpc/common.yaml @@ -0,0 +1,8 @@ +--- +name: test includes +description: used for testing against local server + +variables: + grpc_host: localhost + grpc_port: 50051 + grpc_reflecting_port: 50052 diff --git a/example/grpc/conftest.py b/example/grpc/conftest.py new file mode 100644 index 00000000..a53434e2 --- /dev/null +++ b/example/grpc/conftest.py @@ -0,0 +1,20 @@ +import os.path +import shutil +import tempfile + +import pytest + + +@pytest.fixture() +def make_temp_dir(): + with tempfile.TemporaryDirectory() as d: + yield d + + +@pytest.fixture(autouse=True, scope="session") +def single_compiled_proto_for_test(): + with tempfile.TemporaryDirectory() as d: + proto_filename = "helloworld_v2_compiled.proto" + dst = os.path.join(d, proto_filename) + shutil.copy(proto_filename, dst) + yield dst diff --git a/example/grpc/docker-compose.yaml b/example/grpc/docker-compose.yaml new file mode 100644 index 00000000..6acebbd0 --- /dev/null +++ b/example/grpc/docker-compose.yaml @@ -0,0 +1,12 @@ +--- +version: '2' + +services: + server: + build: + context: . + dockerfile: Dockerfile + ports: + - "127.0.0.1:50051:50051/tcp" + - "127.0.0.1:50052:50052/tcp" + stop_grace_period: "1s" diff --git a/example/grpc/helloworld_v1_precompiled.proto b/example/grpc/helloworld_v1_precompiled.proto new file mode 100644 index 00000000..886f3df9 --- /dev/null +++ b/example/grpc/helloworld_v1_precompiled.proto @@ -0,0 +1,17 @@ +// Pre compiled and checked into the repo so it can be imported by Tavern at runtime + +syntax = "proto3"; + +package helloworld.v1; + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} + +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply) {} +} diff --git a/example/grpc/helloworld_v1_precompiled_pb2.py b/example/grpc/helloworld_v1_precompiled_pb2.py new file mode 100644 index 00000000..c892f3b4 --- /dev/null +++ b/example/grpc/helloworld_v1_precompiled_pb2.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: helloworld_v1_precompiled.proto +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n\x1fhelloworld_v1_precompiled.proto\x12\rhelloworld.v1"\x1c\n\x0cHelloRequest\x12\x0c\n\x04name\x18\x01 \x01(\t"\x1d\n\nHelloReply\x12\x0f\n\x07message\x18\x01 \x01(\t2O\n\x07Greeter\x12\x44\n\x08SayHello\x12\x1b.helloworld.v1.HelloRequest\x1a\x19.helloworld.v1.HelloReply"\x00\x62\x06proto3' +) + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages( + DESCRIPTOR, "helloworld_v1_precompiled_pb2", _globals +) +if _descriptor._USE_C_DESCRIPTORS == False: + DESCRIPTOR._options = None + _globals["_HELLOREQUEST"]._serialized_start = 50 + _globals["_HELLOREQUEST"]._serialized_end = 78 + _globals["_HELLOREPLY"]._serialized_start = 80 + _globals["_HELLOREPLY"]._serialized_end = 109 + _globals["_GREETER"]._serialized_start = 111 + _globals["_GREETER"]._serialized_end = 190 +# @@protoc_insertion_point(module_scope) diff --git a/example/grpc/helloworld_v1_precompiled_pb2.pyi b/example/grpc/helloworld_v1_precompiled_pb2.pyi new file mode 100644 index 00000000..fa5ff436 --- /dev/null +++ b/example/grpc/helloworld_v1_precompiled_pb2.pyi @@ -0,0 +1,19 @@ +from typing import ClassVar as _ClassVar +from typing import Optional as _Optional + +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message + +DESCRIPTOR: _descriptor.FileDescriptor + +class HelloRequest(_message.Message): + __slots__ = ["name"] + NAME_FIELD_NUMBER: _ClassVar[int] + name: str + def __init__(self, name: _Optional[str] = ...) -> None: ... + +class HelloReply(_message.Message): + __slots__ = ["message"] + MESSAGE_FIELD_NUMBER: _ClassVar[int] + message: str + def __init__(self, message: _Optional[str] = ...) -> None: ... diff --git a/example/grpc/helloworld_v1_precompiled_pb2_grpc.py b/example/grpc/helloworld_v1_precompiled_pb2_grpc.py new file mode 100644 index 00000000..34c0d6f3 --- /dev/null +++ b/example/grpc/helloworld_v1_precompiled_pb2_grpc.py @@ -0,0 +1,78 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import helloworld_v1_precompiled_pb2 as helloworld__v1__precompiled__pb2 + + +class GreeterStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.SayHello = channel.unary_unary( + "/helloworld.v1.Greeter/SayHello", + request_serializer=helloworld__v1__precompiled__pb2.HelloRequest.SerializeToString, + response_deserializer=helloworld__v1__precompiled__pb2.HelloReply.FromString, + ) + + +class GreeterServicer(object): + """Missing associated documentation comment in .proto file.""" + + def SayHello(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + +def add_GreeterServicer_to_server(servicer, server): + rpc_method_handlers = { + "SayHello": grpc.unary_unary_rpc_method_handler( + servicer.SayHello, + request_deserializer=helloworld__v1__precompiled__pb2.HelloRequest.FromString, + response_serializer=helloworld__v1__precompiled__pb2.HelloReply.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + "helloworld.v1.Greeter", rpc_method_handlers + ) + server.add_generic_rpc_handlers((generic_handler,)) + + +# This class is part of an EXPERIMENTAL API. +class Greeter(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def SayHello( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/helloworld.v1.Greeter/SayHello", + helloworld__v1__precompiled__pb2.HelloRequest.SerializeToString, + helloworld__v1__precompiled__pb2.HelloReply.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + ) diff --git a/example/grpc/helloworld_v2_compiled.proto b/example/grpc/helloworld_v2_compiled.proto new file mode 100644 index 00000000..09e68ab7 --- /dev/null +++ b/example/grpc/helloworld_v2_compiled.proto @@ -0,0 +1,17 @@ +// Not compiled, but compiled at runtime by Tavern + +syntax = "proto3"; + +package helloworld.v2; + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} + +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply) {} +} diff --git a/example/grpc/helloworld_v3_reflected.proto b/example/grpc/helloworld_v3_reflected.proto new file mode 100644 index 00000000..aa983ec3 --- /dev/null +++ b/example/grpc/helloworld_v3_reflected.proto @@ -0,0 +1,17 @@ +// Not compiled, Tavern uses server side reflection to determine the schema + +syntax = "proto3"; + +package helloworld.v3; + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} + +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply) {} +} diff --git a/example/grpc/regenerate.sh b/example/grpc/regenerate.sh new file mode 100755 index 00000000..3003cca5 --- /dev/null +++ b/example/grpc/regenerate.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +python3 -m grpc_tools.protoc --proto_path=$(pwd) --pyi_out=$(pwd) --python_out=$(pwd) --grpc_python_out=$(pwd) helloworld_v1_precompiled.proto +black *pb2*py diff --git a/example/grpc/server/server.py b/example/grpc/server/server.py new file mode 100644 index 00000000..e1ec2517 --- /dev/null +++ b/example/grpc/server/server.py @@ -0,0 +1,88 @@ +import logging +import threading +from concurrent import futures +from typing import Any, Callable + +import grpc +import helloworld_v1_precompiled_pb2 as helloworld_pb2_v1 +import helloworld_v1_precompiled_pb2_grpc as helloworld_pb2_grpc_v1 +import helloworld_v2_compiled_pb2 as helloworld_pb2_v2 +import helloworld_v2_compiled_pb2_grpc as helloworld_pb2_grpc_v2 +import helloworld_v3_reflected_pb2 as helloworld_pb2_v3 +import helloworld_v3_reflected_pb2_grpc as helloworld_pb2_grpc_v3 +from grpc_interceptor import ServerInterceptor +from grpc_interceptor.exceptions import GrpcException +from grpc_reflection.v1alpha import reflection + + +class GreeterV1(helloworld_pb2_grpc_v1.GreeterServicer): + def SayHello(self, request, context): + return helloworld_pb2_v1.HelloReply(message="Hello, %s!" % request.name) + + +class GreeterV2(helloworld_pb2_grpc_v2.GreeterServicer): + def SayHello(self, request, context): + return helloworld_pb2_v2.HelloReply(message="Hello, %s!" % request.name) + + +class GreeterV3(helloworld_pb2_grpc_v3.GreeterServicer): + def SayHello(self, request, context): + return helloworld_pb2_v3.HelloReply(message="Hello, %s!" % request.name) + + +class LoggingInterceptor(ServerInterceptor): + def intercept( + self, + method: Callable, + request_or_iterator: Any, + context: grpc.ServicerContext, + method_name: str, + ) -> Any: + logging.info(f"got request on {method_name}") + + try: + return method(request_or_iterator, context) + except GrpcException as e: + logging.exception("error processing request") + context.set_code(e.status_code) + context.set_details(e.details) + raise + + +def serve(): + interceptors = [LoggingInterceptor()] + executor = futures.ThreadPoolExecutor(max_workers=10) + + # One server which exposes these two + server = grpc.server( + executor, + interceptors=interceptors, + ) + helloworld_pb2_grpc_v1.add_GreeterServicer_to_server(GreeterV1(), server) + helloworld_pb2_grpc_v2.add_GreeterServicer_to_server(GreeterV2(), server) + + server.add_insecure_port("0.0.0.0:50051") + server.start() + + # One server which exposes the V3 API and has reflection turned on + reflecting_server = grpc.server( + executor, + interceptors=interceptors, + ) + helloworld_pb2_grpc_v3.add_GreeterServicer_to_server(GreeterV3(), reflecting_server) + service_names = ( + helloworld_pb2_v3.DESCRIPTOR.services_by_name["Greeter"].full_name, + reflection.SERVICE_NAME, + ) + reflection.enable_server_reflection(service_names, reflecting_server) + reflecting_server.add_insecure_port("0.0.0.0:50052") + reflecting_server.start() + + logging.info("Starting grpc server") + event = threading.Event() + event.wait() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + serve() diff --git a/example/grpc/test_grpc.tavern.yaml b/example/grpc/test_grpc.tavern.yaml new file mode 100644 index 00000000..08382346 --- /dev/null +++ b/example/grpc/test_grpc.tavern.yaml @@ -0,0 +1,372 @@ +--- + +test_name: Test grpc message echo importing a module instead of compiling from source + +includes: + - !include common.yaml + +grpc: + connect: &grpc_connect + host: "{grpc_host}" + port: !int "{grpc_port}" + timeout: 3 + proto: + module: helloworld_v1_precompiled_pb2_grpc + +stages: + - name: Echo text + grpc_request: + service: helloworld.v1.Greeter/SayHello + body: + name: "John" + grpc_response: + status: "OK" + body: + message: "Hello, John!" + +--- + +test_name: Test trying to connect using an invalid option + +includes: + - !include common.yaml + +grpc: + connect: + <<: *grpc_connect + options: + woah: cool + proto: + module: helloworld_v1_precompiled_pb2_grpc + +_xfail: + run: invalid grpc option 'woah' + +stages: + - name: Echo text + grpc_request: + service: helloworld.v1.Greeter/SayHello + body: + name: "John" + grpc_response: + status: "OK" + body: + message: "Hello, John!" + +--- + +test_name: Test grpc message echo importing a module but its a path to a file + +includes: + - !include common.yaml + +_xfail: run + +grpc: + connect: + <<: *grpc_connect + proto: + module: helloworld_v1_precompiled_pb2_grpc.py + +stages: + - name: Echo text + grpc_request: + service: helloworld.v1.Greeter/SayHello + body: + name: "John" + grpc_response: + status: "OK" + body: + message: "Hello, John!" + +--- + +test_name: Test grpc connection without the 'connect' block + +includes: + - !include common.yaml + +grpc: + proto: + module: helloworld_v1_precompiled_pb2_grpc + +stages: + - name: Echo text + grpc_request: + host: "{grpc_host}:{grpc_port}" + service: helloworld.v1.Greeter/SayHello + body: + name: "John" + grpc_response: + status: "OK" + body: + message: "Hello, John!" + +--- + +test_name: Test grpc connection without the 'connect' block, with a bad message + +includes: + - !include common.yaml + +grpc: + proto: + module: helloworld_pb2_grpc + +_xfail: run + +stages: + - name: Echo text + grpc_request: + host: "{grpc_host}:{grpc_port}" + service: helloworld.v1.Greeter/SayHello + body: + aarg: wooo + name: "John" + grpc_response: + status: "OK" + body: + message: "Hello, John!" + +--- + +test_name: Test grpc message echo compiling proto + +includes: + - !include common.yaml + +grpc: &grpc_spec + connect: + <<: *grpc_connect + proto: + source: "{single_compiled_proto_for_test}" + +stages: + - name: Echo text + grpc_request: + service: helloworld.v2.Greeter/SayHello + body: + name: "John" + grpc_response: + status: "OK" + body: + message: "Hello, John!" + +--- + +test_name: Test grpc message echo compiling folder with multiple protos + +includes: + - !include common.yaml + +grpc: *grpc_spec + +stages: + - name: Echo text + grpc_request: + service: helloworld.v2.Greeter/SayHello + body: + name: "John" + grpc_response: + status: "OK" + body: + message: "Hello, John!" + +--- + +test_name: Test trying to compile a folder with no protos in it + +includes: + - !include common.yaml + +marks: + - usefixtures: + - make_temp_dir + +_xfail: + run: "No protos defined in" + +grpc: + connect: + <<: *grpc_connect + proto: + source: "{make_temp_dir}" + +stages: + - name: Echo text + grpc_request: + service: helloworld.v2.Greeter/SayHello + body: + name: "John" + grpc_response: + status: "OK" + body: + message: "Hello, John!" + +--- + +grpc: + attempt_reflection: True + connect: + host: "{grpc_host}" + port: !int "{grpc_reflecting_port}" + timeout: 3 + +test_name: Test server reflection + +includes: + - !include common.yaml + +stages: + - name: Echo text + grpc_request: + service: helloworld.v3.Greeter/SayHello + body: + name: "John" + grpc_response: + status: "OK" + body: + message: "Hello, John!" + +--- + +grpc: + attempt_reflection: True + +test_name: Test grpc connection without the 'connect' block, using server reflection + +includes: + - !include common.yaml + +stages: + - name: Echo text + grpc_request: + host: "{grpc_host}:{grpc_reflecting_port}" + service: helloworld.v3.Greeter/SayHello + body: + name: "John" + grpc_response: + status: "OK" + body: + message: "Hello, John!" + +--- + +grpc: + attempt_reflection: True + +test_name: Tried to use grpc reflection but the service did not expose it + +_xfail: + run: "Service coolservice.v9/SayGoodbye was not found on host" + +includes: + - !include common.yaml + +stages: + - name: Echo text + grpc_request: + host: "{grpc_host}:{grpc_reflecting_port}" + service: coolservice.v9/SayGoodbye + body: + name: "John" + grpc_response: + status: "OK" + body: + message: "Hello, John!" + +--- + +test_name: Test grpc connection without the 'connect' block, using server reflection, with a bad message + +includes: + - !include common.yaml + +_xfail: + run: error creating request from json body + +stages: + - name: Echo text + grpc_request: + host: "{grpc_host}:{grpc_reflecting_port}" + service: helloworld.v3.Greeter/SayHello + body: + aarg: wooo + name: "John" + grpc_response: + status: "OK" + body: + message: "Hello, John!" + +--- + +test_name: Test grpc compiling source, with a bad message + +includes: + - !include common.yaml + +grpc: *grpc_spec + +_xfail: + run: error creating request from json body + +stages: + - name: Echo text + grpc_request: + host: "{grpc_host}:{grpc_port}" + service: helloworld.v2.Greeter/SayHello + body: + name: "John" + A: klk + grpc_response: + status: "OK" + body: + message: "Hello, John!" + +--- + +test_name: Test grpc message echo importing a module that doesn't exist + +includes: + - !include common.yaml + +grpc: + connect: + <<: *grpc_connect + proto: + module: cool_grpc_server + +_xfail: run + +stages: + - name: Echo text + grpc_request: + service: helloworld.v1.Greeter/SayHello + body: + name: "John" + grpc_response: + status: "OK" + body: + message: "Hello, John!" + +--- + +test_name: Test cannot use invalid string status + +includes: + - !include common.yaml + +grpc: *grpc_spec + +_xfail: verify + +stages: + - name: Echo text + grpc_request: + service: helloworld.v1.Greeter/SayHello + body: + name: "Jim" + grpc_response: + status: "GREETINGS" + body: + message: "Hello, Jim!" diff --git a/pyproject.toml b/pyproject.toml index 5488b6e0..32332fa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,8 @@ classifiers = [ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Utilities", "Topic :: Software Development :: Testing", "License :: OSI Approved :: MIT License", @@ -54,6 +56,15 @@ Documentation = "https://tavern.readthedocs.io/en/latest/" Source = "https://github.com/taverntesting/tavern" [project.optional-dependencies] +grpc = [ + "grpcio", + "grpcio-reflection", + "grpcio-status", + "google-api-python-client", + "protobuf", + "proto-plus", +] + dev = [ "Faker", "allure-pytest", @@ -86,6 +97,8 @@ dev = [ "docutils", "pygments", "sphinx-markdown-tables", + "grpcio-tools", + "grpc-interceptor", # This has to be installed separately, otherwise you can't upload to pypi # "tbump@https://github.com/michaelboulton/tbump/archive/714ba8957a3c84b625608ceca39811ebe56229dc.zip", ] @@ -103,6 +116,8 @@ tavern = "tavern._core.pytest" requests = "tavern._plugins.rest.tavernhook:TavernRestPlugin" [project.entry-points.tavern_mqtt] paho-mqtt = "tavern._plugins.mqtt.tavernhook" +[project.entry-points.tavern_grpc] +grpc = "tavern._plugins.grpc.tavernhook" [tool.mypy] python_version = 3.8 @@ -135,12 +150,13 @@ addopts = [ "--strict-markers", "-p", "no:logging", "--tb=short", - "--color=yes" + "--color=yes", ] norecursedirs = [ ".git", ".tox", "example", + "example/grpc/server" ] [tool.ruff] @@ -155,14 +171,21 @@ ignore = [ select = ["E", "F", "B", "W", "I", "S", "C4", "ICN", "T20", "PLE", "RUF", "SIM105", "PL"] # Look at: UP target-version = "py38" +extend-exclude = [ + "tests/unit/tavern_grpc/test_services_pb2.py", + "tests/unit/tavern_grpc/test_services_pb2.pyi", + "tests/unit/tavern_grpc/test_services_pb2_grpc.py", +] [tool.ruff.per-file-ignores] "tests/*" = ["S", "RUF"] +"tests/unit/tavern_grpc/test_grpc.py" = ["E402"] [tool.ruff.isort] known-first-party = ["tavern"] [tool.ruff.format] +exclude = ["*_pb2.py", "*_pb2_grpc.py", "*_pb2.pyi"] docstring-code-format = true [tool.tbump.version] diff --git a/requirements.txt b/requirements.txt index c9ff385f..b90e9332 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --all-extras --generate-hashes --output-file=requirements.txt --resolver=backtracking pyproject.toml +# pip-compile --all-extras --generate-hashes --output-file=requirements.txt pyproject.toml # alabaster==0.7.16 \ --hash=sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65 \ @@ -43,7 +43,9 @@ bump2version==1.0.1 \ cachetools==5.3.2 \ --hash=sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2 \ --hash=sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1 - # via tox + # via + # google-auth + # tox certifi==2023.11.17 \ --hash=sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1 \ --hash=sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474 @@ -324,9 +326,9 @@ execnet==2.0.2 \ --hash=sha256:88256416ae766bc9e8895c76a87928c0012183da3cc4fc18016e6f050e025f41 \ --hash=sha256:cc59bc4423742fd71ad227122eb0dd44db51efb3dc4095b45ac9a08c770096af # via pytest-xdist -faker==22.2.0 \ - --hash=sha256:2c2b7a8e55368defd718226bd5b48ef31b2d082c2900ccb4200987e433be500e \ - --hash=sha256:fab78f435d27fa7bd109b095eea3504477e4149051c903fd63f11ce252e3d9b7 +faker==22.4.0 \ + --hash=sha256:9abc6decb78dde54cccbad4432431b3caba796bd06950225da158e86c55855d3 \ + --hash=sha256:b649d7b9b03e9e8283506411a56ecef124c8cd8d2bd300d8d7c858fa42350c4e # via tavern (pyproject.toml) filelock==3.13.1 \ --hash=sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e \ @@ -334,9 +336,9 @@ filelock==3.13.1 \ # via # tox # virtualenv -flask==3.0.0 \ - --hash=sha256:21128f47e4e3b9d597a3e8521a329bf56909b690fcc3fa3e477725aa81367638 \ - --hash=sha256:cfadcdb638b609361d29ec22360d6070a77d7463dcb3ab08d2c2f2f168845f58 +flask==3.0.1 \ + --hash=sha256:6489f51bb3666def6f314e15f19d50a1869a19ae0e8c9a3641ffe66c77d42403 \ + --hash=sha256:ca631a507f6dfe6c278ae20112cea3ff54ff2216390bf8880f6b035a5354af13 # via tavern (pyproject.toml) flit==3.9.0 \ --hash=sha256:076c3aaba5ac24cf0ad3251f910900d95a08218e6bcb26f21fef1036cc4679ca \ @@ -350,6 +352,166 @@ fluent-logger==0.10.0 \ --hash=sha256:543637e5e62ec3fc3c92b44e5a4e148a3cea88a0f8ca4fae26c7e60fda7564c1 \ --hash=sha256:678bda90c513ff0393964b64544ce41ef25669d2089ce6c3b63d9a18554b9bfa # via tavern (pyproject.toml) +google-api-core==2.15.0 \ + --hash=sha256:2aa56d2be495551e66bbff7f729b790546f87d5c90e74781aa77233bcb395a8a \ + --hash=sha256:abc978a72658f14a2df1e5e12532effe40f94f868f6e23d95133bd6abcca35ca + # via google-api-python-client +google-api-python-client==2.114.0 \ + --hash=sha256:690e0bb67d70ff6dea4e8a5d3738639c105a478ac35da153d3b2a384064e9e1a \ + --hash=sha256:e041bbbf60e682261281e9d64b4660035f04db1cccba19d1d68eebc24d1465ed + # via tavern (pyproject.toml) +google-auth==2.26.2 \ + --hash=sha256:3f445c8ce9b61ed6459aad86d8ccdba4a9afed841b2d1451a11ef4db08957424 \ + --hash=sha256:97327dbbf58cccb58fc5a1712bba403ae76668e64814eb30f7316f7e27126b81 + # via + # google-api-core + # google-api-python-client + # google-auth-httplib2 +google-auth-httplib2==0.2.0 \ + --hash=sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05 \ + --hash=sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d + # via google-api-python-client +googleapis-common-protos==1.62.0 \ + --hash=sha256:4750113612205514f9f6aa4cb00d523a94f3e8c06c5ad2fee466387dc4875f07 \ + --hash=sha256:83f0ece9f94e5672cced82f592d2a5edf527a96ed1794f0bab36d5735c996277 + # via + # google-api-core + # grpcio-status +grpc-interceptor==0.15.4 \ + --hash=sha256:0035f33228693ed3767ee49d937bac424318db173fef4d2d0170b3215f254d9d \ + --hash=sha256:1f45c0bcb58b6f332f37c637632247c9b02bc6af0fdceb7ba7ce8d2ebbfb0926 + # via tavern (pyproject.toml) +grpcio==1.60.0 \ + --hash=sha256:073f959c6f570797272f4ee9464a9997eaf1e98c27cb680225b82b53390d61e6 \ + --hash=sha256:0fd3b3968ffe7643144580f260f04d39d869fcc2cddb745deef078b09fd2b328 \ + --hash=sha256:1434ca77d6fed4ea312901122dc8da6c4389738bf5788f43efb19a838ac03ead \ + --hash=sha256:1c30bb23a41df95109db130a6cc1b974844300ae2e5d68dd4947aacba5985aa5 \ + --hash=sha256:20e7a4f7ded59097c84059d28230907cd97130fa74f4a8bfd1d8e5ba18c81491 \ + --hash=sha256:2199165a1affb666aa24adf0c97436686d0a61bc5fc113c037701fb7c7fceb96 \ + --hash=sha256:297eef542156d6b15174a1231c2493ea9ea54af8d016b8ca7d5d9cc65cfcc444 \ + --hash=sha256:2aef56e85901c2397bd557c5ba514f84de1f0ae5dd132f5d5fed042858115951 \ + --hash=sha256:30943b9530fe3620e3b195c03130396cd0ee3a0d10a66c1bee715d1819001eaf \ + --hash=sha256:3b36a2c6d4920ba88fa98075fdd58ff94ebeb8acc1215ae07d01a418af4c0253 \ + --hash=sha256:428d699c8553c27e98f4d29fdc0f0edc50e9a8a7590bfd294d2edb0da7be3629 \ + --hash=sha256:43e636dc2ce9ece583b3e2ca41df5c983f4302eabc6d5f9cd04f0562ee8ec1ae \ + --hash=sha256:452ca5b4afed30e7274445dd9b441a35ece656ec1600b77fff8c216fdf07df43 \ + --hash=sha256:467a7d31554892eed2aa6c2d47ded1079fc40ea0b9601d9f79204afa8902274b \ + --hash=sha256:4b44d7e39964e808b071714666a812049765b26b3ea48c4434a3b317bac82f14 \ + --hash=sha256:4c86343cf9ff7b2514dd229bdd88ebba760bd8973dac192ae687ff75e39ebfab \ + --hash=sha256:5208a57eae445ae84a219dfd8b56e04313445d146873117b5fa75f3245bc1390 \ + --hash=sha256:5ff21e000ff2f658430bde5288cb1ac440ff15c0d7d18b5fb222f941b46cb0d2 \ + --hash=sha256:675997222f2e2f22928fbba640824aebd43791116034f62006e19730715166c0 \ + --hash=sha256:676e4a44e740deaba0f4d95ba1d8c5c89a2fcc43d02c39f69450b1fa19d39590 \ + --hash=sha256:6e306b97966369b889985a562ede9d99180def39ad42c8014628dd3cc343f508 \ + --hash=sha256:6fd9584bf1bccdfff1512719316efa77be235469e1e3295dce64538c4773840b \ + --hash=sha256:705a68a973c4c76db5d369ed573fec3367d7d196673fa86614b33d8c8e9ebb08 \ + --hash=sha256:74d7d9fa97809c5b892449b28a65ec2bfa458a4735ddad46074f9f7d9550ad13 \ + --hash=sha256:77c8a317f0fd5a0a2be8ed5cbe5341537d5c00bb79b3bb27ba7c5378ba77dbca \ + --hash=sha256:79a050889eb8d57a93ed21d9585bb63fca881666fc709f5d9f7f9372f5e7fd03 \ + --hash=sha256:7db16dd4ea1b05ada504f08d0dca1cd9b926bed3770f50e715d087c6f00ad748 \ + --hash=sha256:83f2292ae292ed5a47cdcb9821039ca8e88902923198f2193f13959360c01860 \ + --hash=sha256:87c9224acba0ad8bacddf427a1c2772e17ce50b3042a789547af27099c5f751d \ + --hash=sha256:8a97a681e82bc11a42d4372fe57898d270a2707f36c45c6676e49ce0d5c41353 \ + --hash=sha256:9073513ec380434eb8d21970e1ab3161041de121f4018bbed3146839451a6d8e \ + --hash=sha256:90bdd76b3f04bdb21de5398b8a7c629676c81dfac290f5f19883857e9371d28c \ + --hash=sha256:91229d7203f1ef0ab420c9b53fe2ca5c1fbeb34f69b3bc1b5089466237a4a134 \ + --hash=sha256:92f88ca1b956eb8427a11bb8b4a0c0b2b03377235fc5102cb05e533b8693a415 \ + --hash=sha256:95ae3e8e2c1b9bf671817f86f155c5da7d49a2289c5cf27a319458c3e025c320 \ + --hash=sha256:9e30be89a75ee66aec7f9e60086fadb37ff8c0ba49a022887c28c134341f7179 \ + --hash=sha256:a48edde788b99214613e440fce495bbe2b1e142a7f214cce9e0832146c41e324 \ + --hash=sha256:a7152fa6e597c20cb97923407cf0934e14224af42c2b8d915f48bc3ad2d9ac18 \ + --hash=sha256:a9c7b71211f066908e518a2ef7a5e211670761651039f0d6a80d8d40054047df \ + --hash=sha256:b0571a5aef36ba9177e262dc88a9240c866d903a62799e44fd4aae3f9a2ec17e \ + --hash=sha256:b0fb2d4801546598ac5cd18e3ec79c1a9af8b8f2a86283c55a5337c5aeca4b1b \ + --hash=sha256:b10241250cb77657ab315270b064a6c7f1add58af94befa20687e7c8d8603ae6 \ + --hash=sha256:b87efe4a380887425bb15f220079aa8336276398dc33fce38c64d278164f963d \ + --hash=sha256:b98f43fcdb16172dec5f4b49f2fece4b16a99fd284d81c6bbac1b3b69fcbe0ff \ + --hash=sha256:c193109ca4070cdcaa6eff00fdb5a56233dc7610216d58fb81638f89f02e4968 \ + --hash=sha256:c826f93050c73e7769806f92e601e0efdb83ec8d7c76ddf45d514fee54e8e619 \ + --hash=sha256:d020cfa595d1f8f5c6b343530cd3ca16ae5aefdd1e832b777f9f0eb105f5b139 \ + --hash=sha256:d6a478581b1a1a8fdf3318ecb5f4d0cda41cacdffe2b527c23707c9c1b8fdb55 \ + --hash=sha256:de2ad69c9a094bf37c1102b5744c9aec6cf74d2b635558b779085d0263166454 \ + --hash=sha256:e278eafb406f7e1b1b637c2cf51d3ad45883bb5bd1ca56bc05e4fc135dfdaa65 \ + --hash=sha256:e381fe0c2aa6c03b056ad8f52f8efca7be29fb4d9ae2f8873520843b6039612a \ + --hash=sha256:e61e76020e0c332a98290323ecfec721c9544f5b739fab925b6e8cbe1944cf19 \ + --hash=sha256:f897c3b127532e6befdcf961c415c97f320d45614daf84deba0a54e64ea2457b \ + --hash=sha256:fb464479934778d7cc5baf463d959d361954d6533ad34c3a4f1d267e86ee25fd + # via + # grpc-interceptor + # grpcio-reflection + # grpcio-status + # grpcio-tools + # tavern (pyproject.toml) +grpcio-reflection==1.60.0 \ + --hash=sha256:3f6c0c73ba8f20d1420c5e72fc4dd0389fac346ed8fb32a28e6e1967b44fff35 \ + --hash=sha256:f7a347ebd6cecf347fc836fd520fd1f0b3411912981649c7fb34d62a3a15aa4e + # via tavern (pyproject.toml) +grpcio-status==1.60.0 \ + --hash=sha256:7d383fa36e59c1e61d380d91350badd4d12ac56e4de2c2b831b050362c3c572e \ + --hash=sha256:f10e0b6db3adc0fdc244b71962814ee982996ef06186446b5695b9fa635aa1ab + # via tavern (pyproject.toml) +grpcio-tools==1.60.0 \ + --hash=sha256:081336d8258f1a56542aa8a7a5dec99a2b38d902e19fbdd744594783301b0210 \ + --hash=sha256:1748893efd05cf4a59a175d7fa1e4fbb652f4d84ccaa2109f7869a2be48ed25e \ + --hash=sha256:17a32b3da4fc0798cdcec0a9c974ac2a1e98298f151517bf9148294a3b1a5742 \ + --hash=sha256:18976684a931ca4bcba65c78afa778683aefaae310f353e198b1823bf09775a0 \ + --hash=sha256:1b93ae8ffd18e9af9a965ebca5fa521e89066267de7abdde20721edc04e42721 \ + --hash=sha256:1fbb9554466d560472f07d906bfc8dcaf52f365c2a407015185993e30372a886 \ + --hash=sha256:24c4ead4a03037beaeb8ef2c90d13d70101e35c9fae057337ed1a9144ef10b53 \ + --hash=sha256:2a8a758701f3ac07ed85f5a4284c6a9ddefcab7913a8e552497f919349e72438 \ + --hash=sha256:2dd01257e4feff986d256fa0bac9f56de59dc735eceeeb83de1c126e2e91f653 \ + --hash=sha256:2e00de389729ca8d8d1a63c2038703078a887ff738dc31be640b7da9c26d0d4f \ + --hash=sha256:2fb4cf74bfe1e707cf10bc9dd38a1ebaa145179453d150febb121c7e9cd749bf \ + --hash=sha256:2fd1671c52f96e79a2302c8b1c1f78b8a561664b8b3d6946f20d8f1cc6b4225a \ + --hash=sha256:321b18f42a70813545e416ddcb8bf20defa407a8114906711c9710a69596ceda \ + --hash=sha256:3456df087ea61a0972a5bc165aed132ed6ddcc63f5749e572f9fff84540bdbad \ + --hash=sha256:4041538f55aad5b3ae7e25ab314d7995d689e968bfc8aa169d939a3160b1e4c6 \ + --hash=sha256:559ce714fe212aaf4abbe1493c5bb8920def00cc77ce0d45266f4fd9d8b3166f \ + --hash=sha256:5a907a4f1ffba86501b2cdb8682346249ea032b922fc69a92f082ba045cca548 \ + --hash=sha256:5ce6bbd4936977ec1114f2903eb4342781960d521b0d82f73afedb9335251f6f \ + --hash=sha256:6170873b1e5b6580ebb99e87fb6e4ea4c48785b910bd7af838cc6e44b2bccb04 \ + --hash=sha256:6192184b1f99372ff1d9594bd4b12264e3ff26440daba7eb043726785200ff77 \ + --hash=sha256:6807b7a3f3e6e594566100bd7fe04a2c42ce6d5792652677f1aaf5aa5adaef3d \ + --hash=sha256:687f576d7ff6ce483bc9a196d1ceac45144e8733b953620a026daed8e450bc38 \ + --hash=sha256:74025fdd6d1cb7ba4b5d087995339e9a09f0c16cf15dfe56368b23e41ffeaf7a \ + --hash=sha256:7a5263a0f2ddb7b1cfb2349e392cfc4f318722e0f48f886393e06946875d40f3 \ + --hash=sha256:7a6fe752205caae534f29fba907e2f59ff79aa42c6205ce9a467e9406cbac68c \ + --hash=sha256:7c1cde49631732356cb916ee1710507967f19913565ed5f9991e6c9cb37e3887 \ + --hash=sha256:811abb9c4fb6679e0058dfa123fb065d97b158b71959c0e048e7972bbb82ba0f \ + --hash=sha256:857c5351e9dc33a019700e171163f94fcc7e3ae0f6d2b026b10fda1e3c008ef1 \ + --hash=sha256:87cf439178f3eb45c1a889b2e4a17cbb4c450230d92c18d9c57e11271e239c55 \ + --hash=sha256:9970d384fb0c084b00945ef57d98d57a8d32be106d8f0bd31387f7cbfe411b5b \ + --hash=sha256:9ee35234f1da8fba7ddbc544856ff588243f1128ea778d7a1da3039be829a134 \ + --hash=sha256:addc9b23d6ff729d9f83d4a2846292d4c84f5eb2ec38f08489a6a0d66ac2b91e \ + --hash=sha256:b22b1299b666eebd5752ba7719da536075eae3053abcf2898b65f763c314d9da \ + --hash=sha256:b8f7a5094adb49e85db13ea3df5d99a976c2bdfd83b0ba26af20ebb742ac6786 \ + --hash=sha256:b96981f3a31b85074b73d97c8234a5ed9053d65a36b18f4a9c45a2120a5b7a0a \ + --hash=sha256:bbf0ed772d2ae7e8e5d7281fcc00123923ab130b94f7a843eee9af405918f924 \ + --hash=sha256:bd2a17b0193fbe4793c215d63ce1e01ae00a8183d81d7c04e77e1dfafc4b2b8a \ + --hash=sha256:c771b19dce2bfe06899247168c077d7ab4e273f6655d8174834f9a6034415096 \ + --hash=sha256:d941749bd8dc3f8be58fe37183143412a27bec3df8482d5abd6b4ec3f1ac2924 \ + --hash=sha256:dba6e32c87b4af29b5f475fb2f470f7ee3140bfc128644f17c6c59ddeb670680 \ + --hash=sha256:dd1e68c232fe01dd5312a8dbe52c50ecd2b5991d517d7f7446af4ba6334ba872 \ + --hash=sha256:e5614cf0960456d21d8a0f4902e3e5e3bcacc4e400bf22f196e5dd8aabb978b7 \ + --hash=sha256:e5c519a0d4ba1ab44a004fa144089738c59278233e2010b2cf4527dc667ff297 \ + --hash=sha256:e68dc4474f30cad11a965f0eb5d37720a032b4720afa0ec19dbcea2de73b5aae \ + --hash=sha256:e70d867c120d9849093b0ac24d861e378bc88af2552e743d83b9f642d2caa7c2 \ + --hash=sha256:e87cabac7969bdde309575edc2456357667a1b28262b2c1f12580ef48315b19d \ + --hash=sha256:eae27f9b16238e2aaee84c77b5923c6924d6dccb0bdd18435bf42acc8473ae1a \ + --hash=sha256:ec0e401e9a43d927d216d5169b03c61163fb52b665c5af2fed851357b15aef88 \ + --hash=sha256:ed30499340228d733ff69fcf4a66590ed7921f94eb5a2bf692258b1280b9dac7 \ + --hash=sha256:f10ef47460ce3c6fd400f05fe757b90df63486c9b84d1ecad42dcc5f80c8ac14 \ + --hash=sha256:f3d916606dcf5610d4367918245b3d9d8cd0d2ec0b7043d1bbb8c50fe9815c3a \ + --hash=sha256:f610384dee4b1ca705e8da66c5b5fe89a2de3d165c5282c3d1ddf40cb18924e4 \ + --hash=sha256:fb4df80868b3e397d5fbccc004c789d2668b622b51a9d2387b4c89c80d31e2c5 \ + --hash=sha256:fc01bc1079279ec342f0f1b6a107b3f5dc3169c33369cf96ada6e2e171f74e86 + # via tavern (pyproject.toml) +httplib2==0.22.0 \ + --hash=sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc \ + --hash=sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81 + # via + # google-api-python-client + # google-auth-httplib2 identify==2.5.33 \ --hash=sha256:161558f9fe4559e1557e1bff323e8631f6a0e4837f7497767c1782832f16b62d \ --hash=sha256:d40ce5fcd762817627670da8a7d8d8e65f24342d14539c59488dc603bf662e34 @@ -398,9 +560,9 @@ jmespath==1.0.1 \ --hash=sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980 \ --hash=sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe # via tavern (pyproject.toml) -jsonschema==4.21.0 \ - --hash=sha256:3ba18e27f7491ea4a1b22edce00fb820eec968d397feb3f9cb61d5894bb38167 \ - --hash=sha256:70a09719d375c0a2874571b363c8a24be7df8071b80c9aa76bc4551e7297c63c +jsonschema==4.21.1 \ + --hash=sha256:7996507afae316306f9e2290407761157c6f78002dcf7419acb99822143d1c6f \ + --hash=sha256:85727c00279f5fa6bedbe6238d2aa6403bedd8b4864ab11207d07df3cc1b2ee5 # via tavern (pyproject.toml) jsonschema-specifications==2023.12.1 \ --hash=sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc \ @@ -418,67 +580,67 @@ markdown-it-py==3.0.0 \ --hash=sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1 \ --hash=sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb # via rich -markupsafe==2.1.3 \ - --hash=sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e \ - --hash=sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e \ - --hash=sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431 \ - --hash=sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686 \ - --hash=sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c \ - --hash=sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559 \ - --hash=sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc \ - --hash=sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb \ - --hash=sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939 \ - --hash=sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c \ - --hash=sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0 \ - --hash=sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4 \ - --hash=sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9 \ - --hash=sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575 \ - --hash=sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba \ - --hash=sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d \ - --hash=sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd \ - --hash=sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3 \ - --hash=sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00 \ - --hash=sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155 \ - --hash=sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac \ - --hash=sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52 \ - --hash=sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f \ - --hash=sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8 \ - --hash=sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b \ - --hash=sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007 \ - --hash=sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24 \ - --hash=sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea \ - --hash=sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198 \ - --hash=sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0 \ - --hash=sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee \ - --hash=sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be \ - --hash=sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2 \ - --hash=sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1 \ - --hash=sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707 \ - --hash=sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6 \ - --hash=sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c \ - --hash=sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58 \ - --hash=sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823 \ - --hash=sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779 \ - --hash=sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636 \ - --hash=sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c \ - --hash=sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad \ - --hash=sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee \ - --hash=sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc \ - --hash=sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2 \ - --hash=sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48 \ - --hash=sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7 \ - --hash=sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e \ - --hash=sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b \ - --hash=sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa \ - --hash=sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5 \ - --hash=sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e \ - --hash=sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb \ - --hash=sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9 \ - --hash=sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57 \ - --hash=sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc \ - --hash=sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc \ - --hash=sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2 \ - --hash=sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11 +markupsafe==2.1.4 \ + --hash=sha256:0042d6a9880b38e1dd9ff83146cc3c9c18a059b9360ceae207805567aacccc69 \ + --hash=sha256:0c26f67b3fe27302d3a412b85ef696792c4a2386293c53ba683a89562f9399b0 \ + --hash=sha256:0fbad3d346df8f9d72622ac71b69565e621ada2ce6572f37c2eae8dacd60385d \ + --hash=sha256:15866d7f2dc60cfdde12ebb4e75e41be862348b4728300c36cdf405e258415ec \ + --hash=sha256:1c98c33ffe20e9a489145d97070a435ea0679fddaabcafe19982fe9c971987d5 \ + --hash=sha256:21e7af8091007bf4bebf4521184f4880a6acab8df0df52ef9e513d8e5db23411 \ + --hash=sha256:23984d1bdae01bee794267424af55eef4dfc038dc5d1272860669b2aa025c9e3 \ + --hash=sha256:31f57d64c336b8ccb1966d156932f3daa4fee74176b0fdc48ef580be774aae74 \ + --hash=sha256:3583a3a3ab7958e354dc1d25be74aee6228938312ee875a22330c4dc2e41beb0 \ + --hash=sha256:36d7626a8cca4d34216875aee5a1d3d654bb3dac201c1c003d182283e3205949 \ + --hash=sha256:396549cea79e8ca4ba65525470d534e8a41070e6b3500ce2414921099cb73e8d \ + --hash=sha256:3a66c36a3864df95e4f62f9167c734b3b1192cb0851b43d7cc08040c074c6279 \ + --hash=sha256:3aae9af4cac263007fd6309c64c6ab4506dd2b79382d9d19a1994f9240b8db4f \ + --hash=sha256:3ab3a886a237f6e9c9f4f7d272067e712cdb4efa774bef494dccad08f39d8ae6 \ + --hash=sha256:47bb5f0142b8b64ed1399b6b60f700a580335c8e1c57f2f15587bd072012decc \ + --hash=sha256:49a3b78a5af63ec10d8604180380c13dcd870aba7928c1fe04e881d5c792dc4e \ + --hash=sha256:4df98d4a9cd6a88d6a585852f56f2155c9cdb6aec78361a19f938810aa020954 \ + --hash=sha256:5045e892cfdaecc5b4c01822f353cf2c8feb88a6ec1c0adef2a2e705eef0f656 \ + --hash=sha256:5244324676254697fe5c181fc762284e2c5fceeb1c4e3e7f6aca2b6f107e60dc \ + --hash=sha256:54635102ba3cf5da26eb6f96c4b8c53af8a9c0d97b64bdcb592596a6255d8518 \ + --hash=sha256:54a7e1380dfece8847c71bf7e33da5d084e9b889c75eca19100ef98027bd9f56 \ + --hash=sha256:55d03fea4c4e9fd0ad75dc2e7e2b6757b80c152c032ea1d1de487461d8140efc \ + --hash=sha256:698e84142f3f884114ea8cf83e7a67ca8f4ace8454e78fe960646c6c91c63bfa \ + --hash=sha256:6aa5e2e7fc9bc042ae82d8b79d795b9a62bd8f15ba1e7594e3db243f158b5565 \ + --hash=sha256:7653fa39578957bc42e5ebc15cf4361d9e0ee4b702d7d5ec96cdac860953c5b4 \ + --hash=sha256:765f036a3d00395a326df2835d8f86b637dbaf9832f90f5d196c3b8a7a5080cb \ + --hash=sha256:78bc995e004681246e85e28e068111a4c3f35f34e6c62da1471e844ee1446250 \ + --hash=sha256:7a07f40ef8f0fbc5ef1000d0c78771f4d5ca03b4953fc162749772916b298fc4 \ + --hash=sha256:8b570a1537367b52396e53325769608f2a687ec9a4363647af1cded8928af959 \ + --hash=sha256:987d13fe1d23e12a66ca2073b8d2e2a75cec2ecb8eab43ff5624ba0ad42764bc \ + --hash=sha256:9896fca4a8eb246defc8b2a7ac77ef7553b638e04fbf170bff78a40fa8a91474 \ + --hash=sha256:9e9e3c4020aa2dc62d5dd6743a69e399ce3de58320522948af6140ac959ab863 \ + --hash=sha256:a0b838c37ba596fcbfca71651a104a611543077156cb0a26fe0c475e1f152ee8 \ + --hash=sha256:a4d176cfdfde84f732c4a53109b293d05883e952bbba68b857ae446fa3119b4f \ + --hash=sha256:a76055d5cb1c23485d7ddae533229039b850db711c554a12ea64a0fd8a0129e2 \ + --hash=sha256:a76cd37d229fc385738bd1ce4cba2a121cf26b53864c1772694ad0ad348e509e \ + --hash=sha256:a7cc49ef48a3c7a0005a949f3c04f8baa5409d3f663a1b36f0eba9bfe2a0396e \ + --hash=sha256:abf5ebbec056817057bfafc0445916bb688a255a5146f900445d081db08cbabb \ + --hash=sha256:b0fe73bac2fed83839dbdbe6da84ae2a31c11cfc1c777a40dbd8ac8a6ed1560f \ + --hash=sha256:b6f14a9cd50c3cb100eb94b3273131c80d102e19bb20253ac7bd7336118a673a \ + --hash=sha256:b83041cda633871572f0d3c41dddd5582ad7d22f65a72eacd8d3d6d00291df26 \ + --hash=sha256:b835aba863195269ea358cecc21b400276747cc977492319fd7682b8cd2c253d \ + --hash=sha256:bf1196dcc239e608605b716e7b166eb5faf4bc192f8a44b81e85251e62584bd2 \ + --hash=sha256:c669391319973e49a7c6230c218a1e3044710bc1ce4c8e6eb71f7e6d43a2c131 \ + --hash=sha256:c7556bafeaa0a50e2fe7dc86e0382dea349ebcad8f010d5a7dc6ba568eaaa789 \ + --hash=sha256:c8f253a84dbd2c63c19590fa86a032ef3d8cc18923b8049d91bcdeeb2581fbf6 \ + --hash=sha256:d18b66fe626ac412d96c2ab536306c736c66cf2a31c243a45025156cc190dc8a \ + --hash=sha256:d5291d98cd3ad9a562883468c690a2a238c4a6388ab3bd155b0c75dd55ece858 \ + --hash=sha256:d5c31fe855c77cad679b302aabc42d724ed87c043b1432d457f4976add1c2c3e \ + --hash=sha256:d6e427c7378c7f1b2bef6a344c925b8b63623d3321c09a237b7cc0e77dd98ceb \ + --hash=sha256:dac1ebf6983148b45b5fa48593950f90ed6d1d26300604f321c74a9ca1609f8e \ + --hash=sha256:de8153a7aae3835484ac168a9a9bdaa0c5eee4e0bc595503c95d53b942879c84 \ + --hash=sha256:e1a0d1924a5013d4f294087e00024ad25668234569289650929ab871231668e7 \ + --hash=sha256:e7902211afd0af05fbadcc9a312e4cf10f27b779cf1323e78d52377ae4b72bea \ + --hash=sha256:e888ff76ceb39601c59e219f281466c6d7e66bd375b4ec1ce83bcdc68306796b \ + --hash=sha256:f06e5a9e99b7df44640767842f414ed5d7bedaaa78cd817ce04bbd6fd86e2dd6 \ + --hash=sha256:f6be2d708a9d0e9b0054856f07ac7070fbe1754be40ca8525d5adccdbda8f475 \ + --hash=sha256:f9917691f410a2e0897d1ef99619fd3f7dd503647c8ff2475bf90c3cf222ad74 \ + --hash=sha256:fc1a75aa8f11b87910ffd98de62b29d6520b6d6e8a3de69a70ca34dea85d2a8a \ + --hash=sha256:fe8512ed897d5daf089e5bd010c3dc03bb1bdae00b35588c49b98268d4a01e00 # via # jinja2 # werkzeug @@ -646,10 +808,44 @@ pre-commit==3.6.0 \ --hash=sha256:c255039ef399049a5544b6ce13d135caba8f2c28c3b4033277a788f434308376 \ --hash=sha256:d30bad9abf165f7785c15a21a1f46da7d0677cb00ee7ff4c579fd38922efe15d # via tavern (pyproject.toml) +proto-plus==1.23.0 \ + --hash=sha256:89075171ef11988b3fa157f5dbd8b9cf09d65fffee97e29ce403cd8defba19d2 \ + --hash=sha256:a829c79e619e1cf632de091013a4173deed13a55f326ef84f05af6f50ff4c82c + # via tavern (pyproject.toml) +protobuf==4.25.2 \ + --hash=sha256:10894a2885b7175d3984f2be8d9850712c57d5e7587a2410720af8be56cdaf62 \ + --hash=sha256:2db9f8fa64fbdcdc93767d3cf81e0f2aef176284071507e3ede160811502fd3d \ + --hash=sha256:33a1aeef4b1927431d1be780e87b641e322b88d654203a9e9d93f218ee359e61 \ + --hash=sha256:47f3de503fe7c1245f6f03bea7e8d3ec11c6c4a2ea9ef910e3221c8a15516d62 \ + --hash=sha256:5e5c933b4c30a988b52e0b7c02641760a5ba046edc5e43d3b94a74c9fc57c1b3 \ + --hash=sha256:8f62574857ee1de9f770baf04dde4165e30b15ad97ba03ceac65f760ff018ac9 \ + --hash=sha256:a8b7a98d4ce823303145bf3c1a8bdb0f2f4642a414b196f04ad9853ed0c8f830 \ + --hash=sha256:b50c949608682b12efb0b2717f53256f03636af5f60ac0c1d900df6213910fd6 \ + --hash=sha256:d66a769b8d687df9024f2985d5137a337f957a0916cf5464d1513eee96a63ff0 \ + --hash=sha256:fc381d1dd0516343f1440019cedf08a7405f791cd49eef4ae1ea06520bc1c020 \ + --hash=sha256:fe599e175cb347efc8ee524bcd4b902d11f7262c0e569ececcb89995c15f0a5e + # via + # google-api-core + # googleapis-common-protos + # grpcio-reflection + # grpcio-status + # grpcio-tools + # proto-plus + # tavern (pyproject.toml) py==1.11.0 \ --hash=sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719 \ --hash=sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378 # via tavern (pyproject.toml) +pyasn1==0.5.1 \ + --hash=sha256:4439847c58d40b1d0a573d07e3856e95333f1976294494c325775aeca506eb58 \ + --hash=sha256:6d391a96e59b23130a5cfa74d6fd7f388dbbe26cc8f1edf39fdddf08d9d6676c + # via + # pyasn1-modules + # rsa +pyasn1-modules==0.3.0 \ + --hash=sha256:5bd01446b736eb9d31512a30d46c1ac3395d676c6f3cafa4c03eb54b9925631c \ + --hash=sha256:d3ccd6ed470d9ffbc716be08bd90efbd44d0734bc9303818f7336070984a162d + # via google-auth pycparser==2.21 \ --hash=sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9 \ --hash=sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206 @@ -670,6 +866,10 @@ pykwalify==1.8.0 \ --hash=sha256:731dfa87338cca9f559d1fca2bdea37299116e3139b73f78ca90a543722d6651 \ --hash=sha256:796b2ad3ed4cb99b88308b533fb2f559c30fa6efb4fa9fda11347f483d245884 # via tavern (pyproject.toml) +pyparsing==3.1.1 \ + --hash=sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb \ + --hash=sha256:ede28a1a32462f5a9705e07aea48001a08f7cf81a021585011deba701581a0db + # via httplib2 pyproject-api==1.6.1 \ --hash=sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538 \ --hash=sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675 @@ -749,6 +949,7 @@ pyyaml==6.0.1 \ --hash=sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4 \ --hash=sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba \ --hash=sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8 \ + --hash=sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef \ --hash=sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5 \ --hash=sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd \ --hash=sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3 \ @@ -792,6 +993,7 @@ requests==2.31.0 \ --hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1 # via # flit + # google-api-core # requests-toolbelt # sphinx # tavern (pyproject.toml) @@ -911,6 +1113,10 @@ rpds-py==0.17.1 \ # via # jsonschema # referencing +rsa==4.9 \ + --hash=sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7 \ + --hash=sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21 + # via google-auth ruamel-yaml==0.18.5 \ --hash=sha256:61917e3a35a569c1133a8f772e1226961bf5a1198bea7e23f06a0841dea1ab0e \ --hash=sha256:a013ac02f99a69cdd6277d9664689eb1acba07069f912823177c5eced21a6ada @@ -967,24 +1173,24 @@ ruamel-yaml-clib==0.2.8 \ --hash=sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875 \ --hash=sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412 # via ruamel-yaml -ruff==0.1.13 \ - --hash=sha256:226b517f42d59a543d6383cfe03cccf0091e3e0ed1b856c6824be03d2a75d3b6 \ - --hash=sha256:2f59bcf5217c661254bd6bc42d65a6fd1a8b80c48763cb5c2293295babd945dd \ - --hash=sha256:5f0312ba1061e9b8c724e9a702d3c8621e3c6e6c2c9bd862550ab2951ac75c16 \ - --hash=sha256:6bbbc3042075871ec17f28864808540a26f0f79a4478c357d3e3d2284e832998 \ - --hash=sha256:7a36fa90eb12208272a858475ec43ac811ac37e91ef868759770b71bdabe27b6 \ - --hash=sha256:9a1600942485c6e66119da294c6294856b5c86fd6df591ce293e4a4cc8e72989 \ - --hash=sha256:9ebb40442f7b531e136d334ef0851412410061e65d61ca8ce90d894a094feb22 \ - --hash=sha256:9fb6b3b86450d4ec6a6732f9f60c4406061b6851c4b29f944f8c9d91c3611c7a \ - --hash=sha256:a623349a505ff768dad6bd57087e2461be8db58305ebd5577bd0e98631f9ae69 \ - --hash=sha256:b13ba5d7156daaf3fd08b6b993360a96060500aca7e307d95ecbc5bb47a69296 \ - --hash=sha256:dcaab50e278ff497ee4d1fe69b29ca0a9a47cd954bb17963628fa417933c6eb1 \ - --hash=sha256:e261f1baed6291f434ffb1d5c6bd8051d1c2a26958072d38dfbec39b3dda7352 \ - --hash=sha256:e3fd36e0d48aeac672aa850045e784673449ce619afc12823ea7868fcc41d8ba \ - --hash=sha256:e6894b00495e00c27b6ba61af1fc666f17de6140345e5ef27dd6e08fb987259d \ - --hash=sha256:ee3febce7863e231a467f90e681d3d89210b900d49ce88723ce052c8761be8c7 \ - --hash=sha256:f57de973de4edef3ad3044d6a50c02ad9fc2dff0d88587f25f1a48e3f72edf5e \ - --hash=sha256:f988746e3c3982bea7f824c8fa318ce7f538c4dfefec99cd09c8770bd33e6539 +ruff==0.1.14 \ + --hash=sha256:1c8eca1a47b4150dc0fbec7fe68fc91c695aed798532a18dbb1424e61e9b721f \ + --hash=sha256:2270504d629a0b064247983cbc495bed277f372fb9eaba41e5cf51f7ba705a6a \ + --hash=sha256:269302b31ade4cde6cf6f9dd58ea593773a37ed3f7b97e793c8594b262466b67 \ + --hash=sha256:62ce2ae46303ee896fc6811f63d6dabf8d9c389da0f3e3f2bce8bc7f15ef5488 \ + --hash=sha256:653230dd00aaf449eb5ff25d10a6e03bc3006813e2cb99799e568f55482e5cae \ + --hash=sha256:6b3dadc9522d0eccc060699a9816e8127b27addbb4697fc0c08611e4e6aeb8b5 \ + --hash=sha256:7060156ecc572b8f984fd20fd8b0fcb692dd5d837b7606e968334ab7ff0090ab \ + --hash=sha256:722bafc299145575a63bbd6b5069cb643eaa62546a5b6398f82b3e4403329cab \ + --hash=sha256:80258bb3b8909b1700610dfabef7876423eed1bc930fe177c71c414921898efa \ + --hash=sha256:87b3acc6c4e6928459ba9eb7459dd4f0c4bf266a053c863d72a44c33246bfdbf \ + --hash=sha256:96f76536df9b26622755c12ed8680f159817be2f725c17ed9305b472a757cdbb \ + --hash=sha256:a53d8e35313d7b67eb3db15a66c08434809107659226a90dcd7acb2afa55faea \ + --hash=sha256:ab3f71f64498c7241123bb5a768544cf42821d2a537f894b22457a543d3ca7a9 \ + --hash=sha256:ad3f8088b2dfd884820289a06ab718cde7d38b94972212cc4ba90d5fbc9955f3 \ + --hash=sha256:b2027dde79d217b211d725fc833e8965dc90a16d0d3213f1298f97465956661b \ + --hash=sha256:bea9be712b8f5b4ebed40e1949379cfb2a7d907f42921cf9ab3aae07e6fba9eb \ + --hash=sha256:e3d241aa61f92b0805a7082bd89a9990826448e4d0398f0e2bc8f05c75c63d99 # via tavern (pyproject.toml) secretstorage==3.3.3 \ --hash=sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77 \ @@ -1074,6 +1280,10 @@ typing-extensions==4.9.0 \ --hash=sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783 \ --hash=sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd # via mypy +uritemplate==4.1.1 \ + --hash=sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0 \ + --hash=sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e + # via google-api-python-client urllib3==2.1.0 \ --hash=sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3 \ --hash=sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54 @@ -1103,6 +1313,7 @@ zipp==3.17.0 \ # via importlib-metadata # WARNING: The following packages were not pinned, but pip requires them to be -# pinned when the requirements file includes hashes. Consider using the --allow-unsafe flag. +# pinned when the requirements file includes hashes and the requirement is not +# satisfied by a package already installed. Consider using the --allow-unsafe flag. # pip # setuptools diff --git a/scripts/smoke.bash b/scripts/smoke.bash index 2ee8043e..e8d7818c 100755 --- a/scripts/smoke.bash +++ b/scripts/smoke.bash @@ -13,4 +13,4 @@ tox --parallel -c tox.ini \ -e py3,py3mypy tox -c tox-integration.ini \ - -e py3-generic,py3-mqtt + -e py3-generic,py3-grpc,py3-mqtt diff --git a/tavern/_core/exceptions.py b/tavern/_core/exceptions.py index 6ef7234e..9c825007 100644 --- a/tavern/_core/exceptions.py +++ b/tavern/_core/exceptions.py @@ -72,6 +72,18 @@ class RestRequestException(TavernException): """Error making requests in RestRequest()""" +class GRPCRequestException(TavernException): + """Error making requests in GRPCRequest()""" + + +class GRPCServiceException(TavernException): + """Some kind of error when trying to get the gRPC service""" + + +class ProtoCompilerException(TavernException): + """Some kind of error using protoc""" + + class MQTTRequestException(TavernException): """Error making requests in MQTTRequest()""" diff --git a/tavern/_core/plugins.py b/tavern/_core/plugins.py index 5120de48..8ac2dc0a 100644 --- a/tavern/_core/plugins.py +++ b/tavern/_core/plugins.py @@ -24,7 +24,6 @@ class PluginHelperBase: def plugin_load_error(mgr, entry_point, err): """Handle import errors""" - logger.exception("f") msg = "Error loading plugin {} - {}".format(entry_point, err) raise exceptions.PluginLoadError(msg) from err @@ -48,13 +47,13 @@ def is_valid_reqresp_plugin(ext: Any) -> bool: "session_type", # RestRequest, MQTTRequest "request_type", - # request, mqtt_publish + # request, mqtt_publish, grpc_request "request_block_name", # Some function that returns a dict "get_expected_from_request", # MQTTResponse, RestResponse "verifier_type", - # response, mqtt_response + # response, mqtt_response, grpc_request "response_block_name", # dictionary with pykwalify schema "schema", @@ -78,7 +77,7 @@ def __call__(self, config: Optional[TestConfig] = None): self.plugins = self._load_plugins(config) return self.plugins - def _load_plugins(self, test_block_config): + def _load_plugins(self, test_block_config: TestConfig) -> List[Any]: """Load plugins from the 'tavern' entrypoint namespace This can be a module or a class as long as it defines the right things @@ -89,13 +88,13 @@ def _load_plugins(self, test_block_config): - Different plugin names Args: - test_block_config (tavern.pytesthook.config.TestConfig): available config for test + test_block_config: available config for test Raises: - exceptions.MissingSettingsError: Description + exceptions.MissingSettingsError: invalid entry points set Returns: - list: Loaded plugins, can be a class or a module + Loaded plugins, can be a class or a module """ plugins = [] @@ -105,7 +104,9 @@ def enabled(current_backend, ext): ext.name == test_block_config.tavern_internal.backends[current_backend] ) - for backend in ["http", "mqtt"]: + for backend in test_block_config.backends(): + logger.debug("loading backend for %s", backend) + namespace = "tavern_{}".format(backend) manager = stevedore.EnabledExtensionManager( diff --git a/tavern/_core/pytest/config.py b/tavern/_core/pytest/config.py index 22da8ca0..2e33620e 100644 --- a/tavern/_core/pytest/config.py +++ b/tavern/_core/pytest/config.py @@ -1,9 +1,13 @@ import copy import dataclasses -from typing import Any +import logging +from importlib.util import find_spec +from typing import Any, List from tavern._core.strict_util import StrictLevel +logger = logging.getLogger(__name__) + @dataclasses.dataclass(frozen=True) class TavernInternalConfig: @@ -46,3 +50,23 @@ def with_new_variables(self) -> "TestConfig": def with_strictness(self, new_strict: StrictLevel) -> "TestConfig": """Create a copy of the config but with a new strictness setting""" return dataclasses.replace(self, strict=new_strict) + + @staticmethod + def backends() -> List[str]: + available_backends = ["http"] + + if has_module("paho.mqtt"): + available_backends.append("mqtt") + if has_module("grpc"): + available_backends.append("grpc") + + logger.debug(f"available request backends: {available_backends}") + + return available_backends + + +def has_module(module: str) -> bool: + try: + return find_spec(module) is not None + except ModuleNotFoundError: + return False diff --git a/tavern/_core/pytest/hooks.py b/tavern/_core/pytest/hooks.py index c3e1c89b..9a83ee9e 100644 --- a/tavern/_core/pytest/hooks.py +++ b/tavern/_core/pytest/hooks.py @@ -1,8 +1,12 @@ +import logging +import logging.config import os import pathlib import re +from textwrap import dedent import pytest +import yaml from tavern._core import exceptions @@ -22,6 +26,42 @@ def pytest_collect_file(parent, path: os.PathLike): if int(pytest.__version__.split(".", maxsplit=1)[0]) < 7: raise exceptions.TavernException("Only pytest >=7 is supported") + try: + setup_initial_logging = get_option_generic( + parent.config, "tavern-setup-init-logging", False + ) + except ValueError: + pass + else: + if setup_initial_logging: + cfg = dedent( + """ + --- + version: 1 + formatters: + default: + format: "%(asctime)s [%(levelname)s]: (%(name)s:%(lineno)d) %(message)s" + style: "%" + datefmt: "%X" + + handlers: + stderr: + class : logging.StreamHandler + level : DEBUG + formatter: default + stream : ext://sys.stderr + + loggers: + tavern: + handlers: + - stderr + level: DEBUG + """ + ) + + settings = yaml.load(cfg, Loader=yaml.SafeLoader) + logging.config.dictConfig(settings) + pattern = get_option_generic( parent.config, "tavern-file-path-regex", r".+\.tavern\.ya?ml$" ) diff --git a/tavern/_core/pytest/item.py b/tavern/_core/pytest/item.py index dfd24fa4..f2ff0a33 100644 --- a/tavern/_core/pytest/item.py +++ b/tavern/_core/pytest/item.py @@ -1,6 +1,6 @@ import logging import pathlib -from typing import Optional, Tuple +from typing import MutableMapping, Optional, Tuple import attr import pytest @@ -39,8 +39,11 @@ class YamlItem(pytest.Item): _patched_yaml = False def __init__( - self, *, name: str, parent, spec, path: pathlib.Path, **kwargs + self, *, name: str, parent, spec: MutableMapping, path: pathlib.Path, **kwargs ) -> None: + if "grpc" in spec: + logger.warning("Tavern grpc support is in an experimental stage") + super().__init__(name, parent, **kwargs) self.path = path self.spec = spec @@ -63,7 +66,9 @@ def initialise_fixture_attrs(self) -> None: # _get_direct_parametrize_args checks parametrize arguments in Python # functions, but we don't care about that in Tavern. - self.session._fixturemanager._get_direct_parametrize_args = lambda _: [] # type: ignore + self.session._fixturemanager._get_direct_parametrize_args = ( # type: ignore + lambda _: [] # type: ignore + ) # type: ignore fixtureinfo = self.session._fixturemanager.getfixtureinfo( self, self.obj, type(self), funcargs=False @@ -217,7 +222,17 @@ def runtest(self) -> None: self.add_marker(pytest.mark.xfail, True) raise except exceptions.TavernException as e: - if xfail == "run" and not e.is_final: + if isinstance(xfail, dict): + if msg := xfail.get("run"): + if msg not in str(e): + raise Exception( + f"error message did not match: expected '{msg}', got '{e!s}'" + ) from e + logger.info("xfailing test when running") + self.add_marker(pytest.mark.xfail, True) + else: + logger.warning("internal error checking 'xfail'") + elif xfail == "run" and not e.is_final: logger.info("xfailing test when running") self.add_marker(pytest.mark.xfail, True) elif xfail == "finally" and e.is_final: diff --git a/tavern/_core/pytest/util.py b/tavern/_core/pytest/util.py index 7c0d94c1..8418b10c 100644 --- a/tavern/_core/pytest/util.py +++ b/tavern/_core/pytest/util.py @@ -34,6 +34,11 @@ def add_parser_options(parser_addoption, with_defaults: bool = True) -> None: help="Which mqtt backend to use", default="paho-mqtt" if with_defaults else None, ) + parser_addoption( + "--tavern-grpc-backend", + help="Which grpc backend to use", + default="grpc" if with_defaults else None, + ) parser_addoption( "--tavern-strict", help="Default response matching strictness", @@ -59,6 +64,12 @@ def add_parser_options(parser_addoption, with_defaults: bool = True) -> None: action="store", nargs=1, ) + parser_addoption( + "--tavern-setup-init-logging", + help="Set up a simple logger for tavern initialisation. Only for internal use and debugging, may be removed in future with no warning.", + default=False, + action="store_true", + ) def add_ini_options(parser: pytest.Parser) -> None: @@ -78,6 +89,9 @@ def add_ini_options(parser: pytest.Parser) -> None: parser.addini( "tavern-mqtt-backend", help="Which mqtt backend to use", default="paho-mqtt" ) + parser.addini( + "tavern-grpc-backend", help="Which grpc backend to use", default="grpc" + ) parser.addini( "tavern-strict", help="Default response matching strictness", @@ -102,6 +116,12 @@ def add_ini_options(parser: pytest.Parser) -> None: default=r".+\.tavern\.ya?ml$", type="args", ) + parser.addini( + "tavern-setup-init-logging", + help="Set up a simple logger for tavern initialisation. Only for internal use and debugging, may be removed in future with no warning.", + type="bool", + default=False, + ) def load_global_cfg(pytest_config: pytest.Config) -> TestConfig: @@ -158,8 +178,7 @@ def _load_global_backends(pytest_config: pytest.Config) -> Dict[str, Any]: """Load which backend should be used""" backend_settings = {} - backends = ["http", "mqtt"] - for b in backends: + for b in TestConfig.backends(): backend_settings[b] = get_option_generic( pytest_config, "tavern-{}-backend".format(b), None ) diff --git a/tavern/_core/schema/extensions.py b/tavern/_core/schema/extensions.py index ca352be0..1cf10ea7 100644 --- a/tavern/_core/schema/extensions.py +++ b/tavern/_core/schema/extensions.py @@ -1,6 +1,6 @@ import os import re -from typing import Union +from typing import TYPE_CHECKING, Union from pykwalify.types import is_bool, is_float, is_int @@ -15,6 +15,9 @@ from tavern._core.loader import ApproxScalar, BoolToken, FloatToken, IntToken from tavern._core.strict_util import StrictLevel +if TYPE_CHECKING: + from tavern._plugins.grpc.response import GRPCCode + # To extend pykwalify's type validation, extend its internal functions # These return boolean values @@ -133,6 +136,43 @@ def check_usefixtures(value, rule_obj, path) -> bool: return True +def validate_grpc_status_is_valid_or_list_of_names(value: "GRPCCode", rule_obj, path): + """Validate GRPC statuses https://github.com/grpc/grpc/blob/master/doc/statuscodes.md""" + # pylint: disable=unused-argument + err_msg = ( + "status has to be an valid grpc status code, name, or list (got {})".format( + value + ) + ) + + if isinstance(value, (str, int)): + if not to_grpc_status(value): + raise BadSchemaError(err_msg) + elif isinstance(value, list): + if not all(to_grpc_status(i) for i in value): + raise BadSchemaError(err_msg) + else: + raise BadSchemaError(err_msg) + + return True + + +def to_grpc_status(value: Union[str, int]): + from grpc import StatusCode + + if isinstance(value, str): + value = value.upper() + for status in StatusCode: + if status.name == value: + return status.name + elif isinstance(value, int): + for status in StatusCode: + if status.value[0] == value: + return status.name + + return None + + def verify_oneof_id_name(value, rule_obj, path) -> bool: """Checks that if 'name' is not present, 'id' is""" diff --git a/tavern/_core/schema/jsonschema.py b/tavern/_core/schema/jsonschema.py index 3230b49b..fcea7492 100644 --- a/tavern/_core/schema/jsonschema.py +++ b/tavern/_core/schema/jsonschema.py @@ -6,6 +6,7 @@ from jsonschema import Draft7Validator, ValidationError from jsonschema.validators import extend +from tavern._core import exceptions from tavern._core.dict_util import recurse_access_key from tavern._core.exceptions import BadSchemaError from tavern._core.loader import ( @@ -17,11 +18,13 @@ TypeConvertToken, TypeSentinel, ) +from tavern._core.pytest.config import has_module from tavern._core.schema.extensions import ( check_parametrize_marks, check_strict_key, retry_variable, validate_file_spec, + validate_grpc_status_is_valid_or_list_of_names, validate_http_method, validate_json_with_ext, validate_request_json, @@ -118,6 +121,15 @@ def verify_jsonschema(to_verify: Mapping, schema: Mapping) -> None: validator = CustomValidator(schema) + if "grpc" in to_verify and not has_module("grpc"): + raise exceptions.BadSchemaError( + "Tried to use grpc connection string, but grpc was not installed. Reinstall Tavern with the grpc extra like `pip install tavern[grpc]`" + ) + if "mqtt" in to_verify and not has_module("paho.mqtt"): + raise exceptions.BadSchemaError( + "Tried to use mqtt connection string, but mqtt was not installed. Reinstall Tavern with the mqtt extra like `pip install tavern[mqtt]`" + ) + try: validator.validate(to_verify) except jsonschema.ValidationError as e: @@ -178,6 +190,7 @@ def verify_jsonschema(to_verify: Mapping, schema: Mapping) -> None: "stages[*].request.data[]": validate_request_json, "stages[*].request.params[]": validate_request_json, "stages[*].request.headers[]": validate_request_json, + "stages[*].grpc_response.status[]": validate_grpc_status_is_valid_or_list_of_names, "stages[*].request.method[]": validate_http_method, "stages[*].request.save[]": validate_json_with_ext, "stages[*].request.files[]": validate_file_spec, diff --git a/tavern/_core/schema/tests.jsonschema.yaml b/tavern/_core/schema/tests.jsonschema.yaml index 473856ec..428f62d4 100644 --- a/tavern/_core/schema/tests.jsonschema.yaml +++ b/tavern/_core/schema/tests.jsonschema.yaml @@ -251,6 +251,50 @@ definitions: type: object description: Which objects to save from the response + grpc_request: + type: object + required: + - service + properties: + host: + type: string + + service: + type: string + + body: + type: object + + json: + type: object + + retain: + type: boolean + + grpc_response: + type: object + properties: + status: + oneOf: + - type: string + - type: integer + + details: + type: object + + proto_body: + type: object + + timeout: + type: number + + verify_response_with: + oneOf: + - $ref: "#/definitions/verify_block" + - type: array + items: + $ref: "#/definitions/verify_block" + http_response: type: object additionalProperties: false @@ -375,6 +419,12 @@ definitions: response: $ref: "#/definitions/http_response" + grpc_request: + $ref: "#/definitions/grpc_request" + + grpc_response: + $ref: "#/definitions/grpc_response" + ### type: object @@ -389,11 +439,18 @@ properties: description: Name of test _xfail: - type: string - enum: - - verify - - run - - finally + oneOf: + - type: string + enum: + - verify + - run + - finally + - type: object + properties: + verify: + type: string + run: + type: string marks: type: array diff --git a/tavern/_core/schema/tests.schema.yaml b/tavern/_core/schema/tests.schema.yaml index ced99f63..c2388b31 100644 --- a/tavern/_core/schema/tests.schema.yaml +++ b/tavern/_core/schema/tests.schema.yaml @@ -125,6 +125,53 @@ schema;stage: json: type: any + grpc_request: + type: map + required: false + mapping: + host: + type: str + required: false + service: + type: str + required: true + body: + include: any_json_with_ext + retain: + type: any + func: bool_variable + required: false + + grpc_response: + type: map + required: false + mapping: + status: + type: any + func: validate_grpc_status_is_valid_or_list_of_names + details: + type: any + required: false + body: + type: any + required: false + json: + include: any_json_with_ext + required: false + timeout: + type: any + func: float_variable + required: false + verify_response_with: + func: validate_extensions + type: any + + save: + include: any_json_with_ext + mapping: + json: + type: any + request: type: map required: false diff --git a/tavern/_plugins/grpc/__init__.py b/tavern/_plugins/grpc/__init__.py new file mode 100644 index 00000000..5c8edcd6 --- /dev/null +++ b/tavern/_plugins/grpc/__init__.py @@ -0,0 +1,18 @@ +import warnings + +# Shut up warnings caused by proto libraries +warnings.filterwarnings( + "ignore", category=DeprecationWarning, module="pkg_resources", lineno=2804 +) +warnings.filterwarnings( + "ignore", category=DeprecationWarning, module="pkg_resources", lineno=2309 +) +warnings.filterwarnings( + "ignore", category=DeprecationWarning, module="pkg_resources", lineno=2870 +) +warnings.filterwarnings( + "ignore", category=DeprecationWarning, module="pkg_resources", lineno=2349 +) +warnings.filterwarnings( + "ignore", category=DeprecationWarning, module="pkg_resources", lineno=20 +) diff --git a/tavern/_plugins/grpc/client.py b/tavern/_plugins/grpc/client.py new file mode 100644 index 00000000..5fd262cb --- /dev/null +++ b/tavern/_plugins/grpc/client.py @@ -0,0 +1,289 @@ +import dataclasses +import logging +import typing +import warnings +from typing import Any, Dict, List, Mapping, Optional, Tuple + +import grpc +import grpc_reflection +import proto.message +from google._upb._message import DescriptorPool +from google.protobuf import ( + descriptor_pb2, + json_format, + message_factory, + symbol_database, +) +from google.protobuf.json_format import ParseError +from grpc_reflection.v1alpha import reflection_pb2, reflection_pb2_grpc +from grpc_status import rpc_status + +from tavern._core import exceptions +from tavern._core.dict_util import check_expected_keys +from tavern._plugins.grpc.protos import _generate_proto_import, _import_grpc_module + +logger = logging.getLogger(__name__) + +with warnings.catch_warnings(): + warnings.simplefilter("ignore") + warnings.warn("deprecated", DeprecationWarning) # noqa: B028 + +_ProtoMessageType = typing.Type[proto.message.Message] + + +@dataclasses.dataclass +class _ChannelVals: + channel: grpc.UnaryUnaryMultiCallable + input_type: _ProtoMessageType + output_type: _ProtoMessageType + + +class GRPCClient: + def __init__(self, **kwargs): + logger.debug("Initialising GRPC client with %s", kwargs) + expected_blocks = { + "connect": {"host", "port", "options", "timeout", "secure"}, + "proto": {"source", "module"}, + "metadata": {}, + "attempt_reflection": {}, + } + # check main block first + check_expected_keys(expected_blocks.keys(), kwargs) + + _connect_args = kwargs.pop("connect", {}) + check_expected_keys(expected_blocks["connect"], _connect_args) + + metadata = kwargs.pop("metadata", {}) + self._metadata = list(metadata.items()) + + _proto_args = kwargs.pop("proto", {}) + check_expected_keys(expected_blocks["proto"], _proto_args) + + self._attempt_reflection = bool(kwargs.pop("attempt_reflection", False)) + + if default_host := _connect_args.get("host"): + self.default_host = default_host + if port := _connect_args.get("port"): + self.default_host += ":{}".format(port) + + self.timeout = int(_connect_args.get("timeout", 5)) + self.secure = bool(_connect_args.get("secure", False)) + + self._options: List[Tuple[str, Any]] = [] + for key, value in _connect_args.pop("options", {}).items(): + if not key.startswith("grpc."): + raise exceptions.GRPCServiceException( + f"invalid grpc option '{key}' - must be in the form 'grpc.option_name'" + ) + self._options.append((key, value)) + + self.channels: Dict[str, grpc.Channel] = {} + # Using the default symbol database is a bit undesirable because it means that things being imported from + # previous tests will affect later ones which can mask bugs. But there isn't a nice way to have a + # self-contained symbol database, because then you need to transitively import all dependencies of protos and + # add them to the database. + self.sym_db = symbol_database.Default() + + if proto_source := _proto_args.get("source"): + _generate_proto_import(proto_source) + + if proto_module := _proto_args.get("module"): + try: + _import_grpc_module(proto_module) + except (ImportError, ModuleNotFoundError) as e: + logger.exception(f"could not import {proto_module}") + raise exceptions.GRPCServiceException( + "error importing gRPC modules" + ) from e + + def _register_file_descriptor( + self, + service_proto: grpc_reflection.v1alpha.reflection_pb2.FileDescriptorResponse, + ): + for file_descriptor_proto in service_proto.file_descriptor_proto: + descriptor = descriptor_pb2.FileDescriptorProto() + descriptor.ParseFromString(file_descriptor_proto) + self.sym_db.pool.Add(descriptor) + + def _get_reflection_info( + self, channel, service_name: Optional[str] = None, file_by_filename=None + ): + logger.debug( + "Getting GRPC protobuf for service %s from reflection", service_name + ) + ref_request = reflection_pb2.ServerReflectionRequest( + file_containing_symbol=service_name, file_by_filename=file_by_filename + ) + reflection_stub = reflection_pb2_grpc.ServerReflectionStub(channel) + ref_response = reflection_stub.ServerReflectionInfo( + iter([ref_request]), metadata=self._metadata + ) + for response in ref_response: + self._register_file_descriptor(response.file_descriptor_response) + + def _get_grpc_service( + self, channel: grpc.Channel, service: str, method: str + ) -> Optional[_ChannelVals]: + full_service_name = f"{service}/{method}" + try: + input_type, output_type = self.get_method_types(full_service_name) + except KeyError as e: + logger.debug(f"could not find types: {e}") + return None + + logger.info(f"reflected info for {service}: {full_service_name}") + + grpc_method = channel.unary_unary( + "/" + full_service_name, + request_serializer=input_type.SerializeToString, + response_deserializer=output_type.FromString, + ) + + return _ChannelVals(grpc_method, input_type, output_type) + + def get_method_types( + self, full_method_name: str + ) -> Tuple[_ProtoMessageType, _ProtoMessageType]: + """Uses the builtin symbol pool to try and find the input and output types for the given method + + Args: + full_method_name: full RPC name in the form 'pkg.ServiceName/Method' + + Returns: + input and output types (class objects) for the RPC + + Raises: + KeyError: If the types are not registered. Should ideally never happen? + """ + logger.debug(f"looking up types for {full_method_name}") + + service, method = full_method_name.split("/") + + pool: DescriptorPool = self.sym_db.pool + grpc_service = pool.FindServiceByName(service) + method = grpc_service.FindMethodByName(method) + input_type = message_factory.GetMessageClass(method.input_type) # type: ignore + output_type = message_factory.GetMessageClass(method.output_type) # type: ignore + + return input_type, output_type + + def _make_call_request( + self, host: str, full_service: str + ) -> Optional[_ChannelVals]: + full_service = full_service.replace("/", ".") + service_method = full_service.rsplit(".", 1) + if len(service_method) != 2: + raise exceptions.GRPCRequestException( + f"Invalid service/method name {full_service}" + ) + + service = service_method[0] + method = service_method[1] + logger.debug( + "Make call for host %s service %s method %s", host, service, method + ) + + if host not in self.channels: + if self.secure: + credentials = grpc.ssl_channel_credentials() + self.channels[host] = grpc.secure_channel( + host, + credentials, + options=self._options, + ) + else: + self.channels[host] = grpc.insecure_channel( + host, + options=self._options, + ) + + channel = self.channels[host] + + if channel_vals := self._get_grpc_service(channel, service, method): + return channel_vals + + if not self._attempt_reflection: + logger.error( + "could not find service and gRPC reflection disabled, cannot continue" + ) + raise exceptions.GRPCServiceException( + f"Service {service} was not registered for host {host}" + ) + + logger.info("service not registered, doing reflection from server") + try: + self._get_reflection_info(channel, service_name=service) + except grpc.RpcError as rpc_error: + code = details = None + try: + code = rpc_error.code() + details = rpc_error.details() + except AttributeError: + status = rpc_status.from_call(rpc_error) + if status is None: + logger.warning("Unknown error occurred in RPC call", exc_info=True) + else: + code = status.code + details = status.details + + if code and details: + logger.warning( + "Unable get %s service reflection information code %s detail %s", + service, + code, + details, + exc_info=True, + ) + + raise exceptions.GRPCRequestException from rpc_error + + return self._get_grpc_service(channel, service, method) + + def __enter__(self): + logger.debug("Connecting to GRPC") + + def call( + self, + service: str, + host: Optional[str] = None, + body: Optional[Mapping] = None, + timeout: Optional[int] = None, + ) -> grpc.Future: + """Makes the request and returns a future with the response.""" + if host is None: + if getattr(self, "default_host", None) is None: + raise exceptions.GRPCRequestException( + "no host specified in request and no default host in settings" + ) + + host = self.default_host + + if timeout is None: + timeout = self.timeout + + channel_vals = self._make_call_request(host, service) + if channel_vals is None: + raise exceptions.GRPCServiceException( + f"Service {service} was not found on host {host}" + ) + + request = channel_vals.input_type() + if body is not None: + try: + request = json_format.ParseDict(body, request) + except ParseError as e: + raise exceptions.GRPCRequestException( + "error creating request from json body" + ) from e + + logger.debug("Sending request %s", request) + + return channel_vals.channel.future( + request, metadata=self._metadata, timeout=timeout + ) + + def __exit__(self, *args): + logger.debug("Disconnecting from GRPC") + for v in self.channels.values(): + v.close() + self.channels = {} diff --git a/tavern/_plugins/grpc/jsonschema.yaml b/tavern/_plugins/grpc/jsonschema.yaml new file mode 100644 index 00000000..f18c1131 --- /dev/null +++ b/tavern/_plugins/grpc/jsonschema.yaml @@ -0,0 +1,52 @@ +--- +$schema: "http://json-schema.org/draft-07/schema#" + +title: gRPC schema +description: Schema for Python gRPC connection + +type: object +additionalProperties: false +required: + - grpc + +properties: + grpc: + type: object + properties: + connect: + type: object + properties: + host: + type: string + port: + type: integer + timeout: + type: number + keepalive: + type: integer + secure: + type: boolean + description: use a secure channel using the system default ssl certs + options: + description: connection options, in map format + type: object + + # TODO + # tls: ... + + attempt_reflection: + description: If a gRPC definition could not be found for a service, try to use server reflection to create the gRPC call instead. This can be useful if you do not have the compiled proto definition on hand but you know what the schema is. + type: boolean + + metadata: + description: gRPC metadata to send to the server + type: object + + proto: + type: object + properties: + source: + description: path to a folder containing proto definitions + type: string + module: + type: string diff --git a/tavern/_plugins/grpc/protos.py b/tavern/_plugins/grpc/protos.py new file mode 100644 index 00000000..b70aa238 --- /dev/null +++ b/tavern/_plugins/grpc/protos.py @@ -0,0 +1,161 @@ +import functools +import hashlib +import importlib.util +import logging +import os +import string +import subprocess +import sys +import tempfile +from distutils.spawn import find_executable +from importlib.machinery import ModuleSpec +from typing import List + +from tavern._core import exceptions + +logger = logging.getLogger(__name__) + + +@functools.lru_cache +def find_protoc() -> str: + # Find the Protocol Compiler. + if "PROTOC" in os.environ and os.path.exists(os.environ["PROTOC"]): + return os.environ["PROTOC"] + + if protoc := find_executable("protoc"): + return protoc + + raise exceptions.ProtoCompilerException( + "Wanted to dynamically compile a proto source, but could not find protoc" + ) + + +@functools.lru_cache +def _generate_proto_import(source: str): + """Invokes the Protocol Compiler to generate a _pb2.py from the given + .proto file. Does nothing if the output already exists and is newer than + the input. + """ + + if not os.path.exists(source): + raise exceptions.ProtoCompilerException(f"Can't find required file: {source}") + + logger.info("Generating protos from %s...", source) + + # If its a dir, compile them all + if not os.path.isdir(source): + if not source.endswith(".proto"): + raise exceptions.ProtoCompilerException( + f"invalid proto source file {source}" + ) + protos = [source] + include_path = os.path.dirname(source) + else: + protos = [ + os.path.join(source, child) + for child in os.listdir(source) + if (not os.path.isdir(child)) and child.endswith(".proto") + ] + include_path = source + + if not protos: + raise exceptions.ProtoCompilerException( + f"No protos defined in {os.path.abspath(source)}" + ) + + for p in protos: + if not os.path.exists(p): + raise exceptions.ProtoCompilerException(f"{p} does not exist") + + def sanitise(s): + """Do basic sanitisation for creating a temporary directory based on + the name of the input proto file""" + return "".join(c for c in s if c in string.ascii_letters) + + # Create a temporary directory to put the generated protobuf files in + output = os.path.join( + tempfile.gettempdir(), + "tavern_proto", + sanitise(protos[0]), + hashlib.new("sha3_224", "".join(protos).encode("utf8")).hexdigest(), + ) + + if not os.path.exists(output): + os.makedirs(output) + + protoc = find_protoc() + + protoc_command = [protoc, "-I" + include_path, "--python_out=" + output] + protoc_command.extend(protos) + + call = subprocess.run(protoc_command, capture_output=True, check=False) # noqa + if call.returncode != 0: + logger.error(f"Error calling '{protoc_command}'") + raise exceptions.ProtoCompilerException(call.stderr.decode("utf8")) + + logger.info(f"Generated module from protos: {protos}") + + # Invalidate caches so the module can be loaded + sys.path.append(output) + importlib.invalidate_caches() + _import_grpc_module(output) + + +def _import_grpc_module(python_module_name: str): + """takes an expected python module name and tries to import the relevant + file, adding service to the symbol database. + """ + + logger.debug("attempting to import %s", python_module_name) + + if python_module_name.endswith(".py"): + raise exceptions.GRPCServiceException( + f"grpc module definitions should not end with .py, but got {python_module_name}" + ) + + if python_module_name.startswith("."): + raise exceptions.GRPCServiceException( + f"relative imports for Python grpc modules not allowed (got {python_module_name})" + ) + + import_specs: List[ModuleSpec] = [] + + # Check if its already on the python path + if (spec := importlib.util.find_spec(python_module_name)) is not None: + logger.debug(f"{python_module_name} on sys path already") + import_specs.append(spec) + + # See if the file exists + module_path = python_module_name.replace(".", "/") + ".py" + if os.path.exists(module_path): + logger.debug(f"{python_module_name} found in file") + if ( + spec := importlib.util.spec_from_file_location( + python_module_name, module_path + ) + ) is not None: + import_specs.append(spec) + + # If its a dir then load files in the dir instead + if os.path.isdir(python_module_name): + for s in os.listdir(python_module_name): + s = os.path.join(python_module_name, s) + if s.endswith(".py"): + logger.debug(f"found py file {s}") + # Guess a package name + if ( + spec := importlib.util.spec_from_file_location(s[:-3], s) + ) is not None: + import_specs.append(spec) + + if not import_specs: + raise exceptions.GRPCServiceException( + f"could not determine how to import {python_module_name}" + ) + + # Actually import them to register them in the symbol db + for spec in import_specs: + mod = importlib.util.module_from_spec(spec) + logger.debug(f"loading from {spec.name}") + if spec.loader: + spec.loader.exec_module(mod) diff --git a/tavern/_plugins/grpc/request.py b/tavern/_plugins/grpc/request.py new file mode 100644 index 00000000..6fe311ba --- /dev/null +++ b/tavern/_plugins/grpc/request.py @@ -0,0 +1,91 @@ +import dataclasses +import functools +import json +import logging +import warnings +from typing import Mapping, Union + +import grpc +from box import Box + +from tavern._core import exceptions +from tavern._core.dict_util import check_expected_keys, format_keys +from tavern._core.pytest.config import TestConfig +from tavern._plugins.grpc.client import GRPCClient +from tavern.request import BaseRequest + +logger = logging.getLogger(__name__) + + +def get_grpc_args(rspec, test_block_config): + """Format GRPC request args""" + + fspec = format_keys(rspec, test_block_config.variables) + + # FIXME: Clarify 'json' and 'body' for grpc requests + # FIXME 2: also it should allow proto text format. Maybe binary. + if "json" in rspec: + if "body" in rspec: + raise exceptions.BadSchemaError( + "Can only specify one of 'body' or 'json' in GRPC request" + ) + + fspec["body"] = json.dumps(fspec.pop("json")) + + return fspec + + +@dataclasses.dataclass +class WrappedFuture: + response: Union[grpc.Call, grpc.Future] + service_name: str + + +class GRPCRequest(BaseRequest): + """Wrapper for a single GRPC request on a client + + Similar to RestRequest, publishes a single message. + """ + + _warned = False + + def __init__( + self, client: GRPCClient, request_spec: Mapping, test_block_config: TestConfig + ): + if not self._warned: + warnings.warn( + "Tavern gRPC support is experimental and will be updated in a future release.", + RuntimeWarning, + stacklevel=0, + ) + GRPCRequest._warned = True + + expected = {"host", "service", "body"} + + check_expected_keys(expected, request_spec) + + grpc_args = get_grpc_args(request_spec, test_block_config) + + self._prepared = functools.partial(client.call, **grpc_args) + + self._service_name = grpc_args.get("service", None) + + # Need to do this here because get_publish_args will modify the original + # input, which we might want to use to format. No error handling because + # all the error handling is done in the previous call + self._original_request_vars = format_keys( + request_spec, test_block_config.variables + ) + + def run(self) -> WrappedFuture: + try: + return WrappedFuture( + response=self._prepared(), service_name=self._service_name + ) + except ValueError as e: + logger.exception("Error executing request") + raise exceptions.GRPCRequestException from e + + @property + def request_vars(self): + return Box(self._original_request_vars) diff --git a/tavern/_plugins/grpc/response.py b/tavern/_plugins/grpc/response.py new file mode 100644 index 00000000..3eee4ba9 --- /dev/null +++ b/tavern/_plugins/grpc/response.py @@ -0,0 +1,164 @@ +import logging +from typing import TYPE_CHECKING, Any, List, Mapping, TypedDict, Union + +import proto.message +from google.protobuf import json_format +from grpc import StatusCode + +from tavern._core import exceptions +from tavern._core.dict_util import check_expected_keys +from tavern._core.exceptions import TestFailError +from tavern._core.pytest.config import TestConfig +from tavern._core.schema.extensions import to_grpc_status +from tavern._plugins.grpc.client import GRPCClient +from tavern.response import BaseResponse + +if TYPE_CHECKING: + from tavern._plugins.grpc.request import WrappedFuture + +logger = logging.getLogger(__name__) + + +GRPCCode = Union[str, int, List[str], List[int]] + + +def _to_grpc_name(status: GRPCCode) -> Union[str, List[str]]: + if isinstance(status, list): + return [_to_grpc_name(s) for s in status] # type:ignore + + if status_name := to_grpc_status(status): + return status_name.upper() + + # This should have been verified before this + raise exceptions.GRPCServiceException(f"unknown status code '{status}'") + + +class _GRPCExpected(TypedDict): + """What the 'expected' block for a grpc response should contain""" + + status: GRPCCode + details: Any + body: Mapping + + +class GRPCResponse(BaseResponse): + def __init__( + self, + client: GRPCClient, + name: str, + expected: Union[_GRPCExpected, Mapping], + test_block_config: TestConfig, + ): + check_expected_keys({"body", "status", "details"}, expected) + super(GRPCResponse, self).__init__(name, expected, test_block_config) + + self._client = client + + def __str__(self): + if self.response: + return self.response.payload + else: + return "" + + def _validate_block(self, blockname: str, block: Mapping): + """Validate a block of the response + + Args: + blockname: which part of the response is being checked + block: The actual part being checked + """ + try: + expected_block = self.expected["body"] or {} + except KeyError: + expected_block = {} + + if isinstance(expected_block, dict): + if expected_block.pop("$ext", None): + logger.warning( + "$ext function found in block %s - this has been moved to verify_response_with block - see documentation", + blockname, + ) + + logger.debug("Validating response %s against %s", blockname, expected_block) + + test_strictness = self.test_block_config.strict + block_strictness = test_strictness.option_for(blockname) + self.recurse_check_key_match(expected_block, block, blockname, block_strictness) + + def verify(self, response: "WrappedFuture") -> Mapping: + grpc_response = response.response + + logger.debug(f"grpc status code: {grpc_response.code()}") + logger.debug(f"grpc details: {grpc_response.details()}") + + # Get any keys to save + saved = {} + verify_status = [StatusCode.OK.name] + if status := self.expected.get("status", None): + verify_status = _to_grpc_name(status) # type: ignore + if not isinstance(verify_status, list): + verify_status = [verify_status] + + if grpc_response.code().name not in verify_status: + self._adderr( + "expected status %s, but the actual response '%s'", + verify_status, + grpc_response.code().name, + ) + + if "details" in self.expected: + verify_details = self.expected["details"] + if verify_details != grpc_response.details(): + self._adderr( + "expected details '%s', but the actual response '%s'", + verify_details, + grpc_response.details(), + ) + + if "body" in self.expected: + if verify_status != ["OK"]: + self._adderr( + "'body' was specified in response, but expected status code was not 'OK'" + ) + elif grpc_response.code().name != "OK": + logger.info( + f"skipping body checking due to {grpc_response.code()} response" + ) + else: + _, output_type = self._client.get_method_types(response.service_name) + expected_parsed = output_type() + try: + json_format.ParseDict(self.expected["body"], expected_parsed) + except json_format.ParseError as e: + self._adderr(f"response body was not in the right format: {e}", e=e) + + result: proto.message.Message = grpc_response.result() + + if not isinstance(result, output_type): + self._adderr( + f"response from server ({type(response)}) was not the same type as expected from the registered definition ({output_type})" + ) + + json_result = json_format.MessageToDict( + result, + including_default_value_fields=True, + preserving_proto_field_name=True, + ) + + self._validate_block("json", json_result) + self._maybe_run_validate_functions(json_result) + + saved.update( + self.maybe_get_save_values_from_save_block("body", json_result) + ) + saved.update( + self.maybe_get_save_values_from_ext(json_result, self.expected) + ) + + if self.errors: + raise TestFailError( + f"Test '{self.name:s}' failed:\n{self._str_errors():s}", + failures=self.errors, + ) + + return saved diff --git a/tavern/_plugins/grpc/schema.yaml b/tavern/_plugins/grpc/schema.yaml new file mode 100644 index 00000000..771a0c21 --- /dev/null +++ b/tavern/_plugins/grpc/schema.yaml @@ -0,0 +1,45 @@ +--- +name: GRPC schemas +desc: pykwalify schemas for 'grpc' plugin block, grpc_request, and grpc_response + +initialisation: + grpc: + required: false + type: map + mapping: + connect: + required: true + type: map + mapping: + host: + required: false + type: any + port: + required: false + type: any + func: int_variable + keepalive: + required: false + type: float + timeout: + required: false + type: float + tls: + required: false + type: any + func: bool_variable + + metadata: + required: false + type: any + + proto: + required: false + type: map + mapping: + source: + required: false + type: str + module: + required: false + type: str diff --git a/tavern/_plugins/grpc/tavernhook.py b/tavern/_plugins/grpc/tavernhook.py new file mode 100644 index 00000000..58b9c120 --- /dev/null +++ b/tavern/_plugins/grpc/tavernhook.py @@ -0,0 +1,34 @@ +import logging +from os.path import abspath, dirname, join + +import yaml + +from tavern._core.dict_util import format_keys +from tavern._core.pytest.config import TestConfig + +from .client import GRPCClient +from .request import GRPCRequest +from .response import GRPCResponse + +logger = logging.getLogger(__name__) + + +session_type = GRPCClient + +request_type = GRPCRequest +request_block_name = "grpc_request" + + +def get_expected_from_request(response_block, test_block_config: TestConfig, session): + f_expected = format_keys(response_block, test_block_config.variables) + expected = f_expected + + return expected + + +verifier_type = GRPCResponse +response_block_name = "grpc_response" + +schema_path = join(abspath(dirname(__file__)), "jsonschema.yaml") +with open(schema_path, "r") as schema_file: + schema = yaml.load(schema_file, Loader=yaml.SafeLoader) diff --git a/tavern/core.py b/tavern/core.py index a9a5e8e3..c4c289df 100644 --- a/tavern/core.py +++ b/tavern/core.py @@ -53,6 +53,7 @@ def run( tavern_global_cfg=None, tavern_mqtt_backend=None, tavern_http_backend=None, + tavern_grpc_backend=None, tavern_strict=None, pytest_args=None, ): @@ -65,6 +66,8 @@ def run( specified, uses tavern-mqtt tavern_http_backend (str, optional): name of HTTP plugin to use. If not specified, use tavern-http + tavern_grpc_backend (str, optional): name of GRPC plugin to use. If not + specified, use tavern-grpc tavern_strict (bool, optional): Strictness of checking for responses. See documentation for details pytest_args (list, optional): List of extra arguments to pass directly @@ -81,6 +84,8 @@ def run( pytest_args += ["--tavern-mqtt-backend", tavern_mqtt_backend] if tavern_http_backend: pytest_args += ["--tavern-http-backend", tavern_http_backend] + if tavern_grpc_backend: + pytest_args += ["--tavern-grpc-backend", tavern_grpc_backend] if tavern_strict: pytest_args += ["--tavern-strict", tavern_strict] diff --git a/tavern/response.py b/tavern/response.py index a840d4eb..b9ffb86f 100644 --- a/tavern/response.py +++ b/tavern/response.py @@ -22,6 +22,7 @@ def indent_err_text(err: str) -> str: class BaseResponse: def __init__(self, name: str, expected, test_block_config: TestConfig) -> None: + # Stage name self.name = name # all errors in this response diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index f9ffa780..57ab621d 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -18,7 +18,7 @@ strict=StrictLevel.all_on(), tavern_internal=TavernInternalConfig( pytest_hook_caller=Mock(), - backends={"mqtt": "paho-mqtt", "http": "requests"}, + backends={"mqtt": "paho-mqtt", "http": "requests", "grpc": "grpc"}, ), follow_redirects=False, stages=[], diff --git a/tests/unit/tavern_grpc/__init__.py b/tests/unit/tavern_grpc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/tavern_grpc/test_grpc.py b/tests/unit/tavern_grpc/test_grpc.py new file mode 100644 index 00000000..5e9903e0 --- /dev/null +++ b/tests/unit/tavern_grpc/test_grpc.py @@ -0,0 +1,198 @@ +import dataclasses +import os.path +import random +import sys +from concurrent import futures +from typing import Any, Mapping, Optional + +import grpc +import pytest +from _pytest.mark import MarkGenerator +from google.protobuf import json_format +from google.protobuf.empty_pb2 import Empty +from grpc_reflection.v1alpha import reflection + +from tavern._core.pytest.config import TestConfig +from tavern._plugins.grpc.client import GRPCClient +from tavern._plugins.grpc.request import GRPCRequest +from tavern._plugins.grpc.response import GRPCCode, GRPCResponse + +sys.path.append(os.path.dirname(__file__)) + +from . import test_services_pb2, test_services_pb2_grpc + + +class ServiceImpl(test_services_pb2_grpc.DummyServiceServicer): + def Empty(self, request: Empty, context) -> Empty: + return Empty() + + def SimpleTest( + self, request: test_services_pb2.DummyRequest, context: grpc.ServicerContext + ) -> test_services_pb2.DummyResponse: + if request.request_id > 1000: + context.abort(grpc.StatusCode.FAILED_PRECONDITION, "number too big!") + return test_services_pb2.DummyResponse(response_id=request.request_id + 1) + + +@pytest.fixture(scope="session") +def service() -> int: + server = grpc.server(futures.ThreadPoolExecutor(max_workers=5)) + service_impl = ServiceImpl() + test_services_pb2_grpc.add_DummyServiceServicer_to_server(service_impl, server) + + service_names = ( + test_services_pb2.DESCRIPTOR.services_by_name["DummyService"].full_name, + reflection.SERVICE_NAME, + ) + reflection.enable_server_reflection(service_names, server) + + port = random.randint(10000, 40000) + server.add_insecure_port(f"127.0.0.1:{port}") + server.start() + + yield port + + server.stop(1) + + +@pytest.fixture() +def grpc_client(service: int) -> GRPCClient: + opts = { + "connect": {"host": "localhost", "port": service, "secure": False}, + "attempt_reflection": False, + } + + return GRPCClient(**opts) + + +@dataclasses.dataclass +class GRPCTestSpec: + test_name: str + method: str + req: Any + + resp: Optional[Any] = None + xfail: bool = False + code: GRPCCode = grpc.StatusCode.OK.value[0] + service: str = "tavern.tests.v1.DummyService" + + def service_method(self): + return f"{self.service}/{self.method}" + + def request(self) -> Mapping: + return json_format.MessageToDict( + self.req, + including_default_value_fields=True, + preserving_proto_field_name=True, + ) + + def body(self) -> Mapping: + return json_format.MessageToDict( + self.resp, + including_default_value_fields=True, + preserving_proto_field_name=True, + ) + + +def test_grpc(grpc_client: GRPCClient, includes: TestConfig, test_spec: GRPCTestSpec): + request = GRPCRequest( + grpc_client, + {"service": test_spec.service_method(), "body": test_spec.request()}, + includes, + ) + + expected = {"status": test_spec.code} + if test_spec.resp: + expected["body"] = test_spec.body() + + resp = GRPCResponse(grpc_client, "test", expected, includes) + + if test_spec.xfail: + pytest.xfail() + + future = request.run() + + resp.verify(future) + + +def pytest_generate_tests(metafunc: MarkGenerator): + if "test_spec" in metafunc.fixturenames: + tests = [ + GRPCTestSpec( + test_name="basic empty", method="Empty", req=Empty(), resp=Empty() + ), + GRPCTestSpec( + test_name="nonexistent method", + method="Wek", + req=Empty(), + resp=Empty(), + xfail=True, + ), + GRPCTestSpec( + test_name="empty with numeric status code", + method="Empty", + req=Empty(), + resp=Empty(), + code=0, + ), + GRPCTestSpec( + test_name="empty with wrong status code", + method="Empty", + req=Empty(), + resp=Empty(), + code="ABORTED", + xfail=True, + ), + GRPCTestSpec( + test_name="empty with the wrong request type", + method="Empty", + req=test_services_pb2.DummyRequest(), + resp=Empty(), + code=0, + xfail=True, + ), + GRPCTestSpec( + test_name="empty with the wrong response type", + method="Empty", + req=Empty(), + resp=test_services_pb2.DummyResponse(), + code=0, + xfail=True, + ), + GRPCTestSpec( + test_name="Simple service", + method="SimpleTest", + req=test_services_pb2.DummyRequest(request_id=2), + resp=test_services_pb2.DummyResponse(response_id=3), + ), + GRPCTestSpec( + test_name="Simple service with error", + method="SimpleTest", + req=test_services_pb2.DummyRequest(request_id=10000), + code="FAILED_PRECONDITION", + ), + GRPCTestSpec( + test_name="Simple service with error code but also a response", + method="SimpleTest", + req=test_services_pb2.DummyRequest(request_id=10000), + resp=test_services_pb2.DummyResponse(response_id=3), + code="FAILED_PRECONDITION", + xfail=True, + ), + GRPCTestSpec( + test_name="Simple service with wrong request type", + method="SimpleTest", + req=Empty(), + resp=test_services_pb2.DummyResponse(response_id=3), + xfail=True, + ), + GRPCTestSpec( + test_name="Simple service with wrong response type", + method="SimpleTest", + req=test_services_pb2.DummyRequest(request_id=2), + resp=Empty(), + xfail=True, + ), + ] + + metafunc.parametrize("test_spec", tests, ids=[g.test_name for g in tests]) diff --git a/tests/unit/tavern_grpc/test_services.proto b/tests/unit/tavern_grpc/test_services.proto new file mode 100644 index 00000000..0a60edcc --- /dev/null +++ b/tests/unit/tavern_grpc/test_services.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package tavern.tests.v1; + +import "google/protobuf/empty.proto"; + +service DummyService { + rpc Empty(google.protobuf.Empty) returns (google.protobuf.Empty); + rpc SimpleTest(DummyRequest) returns (DummyResponse); +} + +message DummyRequest { + int32 request_id = 1; +} + +message DummyResponse { + int32 response_id = 1; +} diff --git a/tests/unit/tavern_grpc/test_services_pb2.py b/tests/unit/tavern_grpc/test_services_pb2.py new file mode 100644 index 00000000..1209f0e9 --- /dev/null +++ b/tests/unit/tavern_grpc/test_services_pb2.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: test_services.proto +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13test_services.proto\x12\x0ftavern.tests.v1\x1a\x1bgoogle/protobuf/empty.proto\"\"\n\x0c\x44ummyRequest\x12\x12\n\nrequest_id\x18\x01 \x01(\x05\"$\n\rDummyResponse\x12\x13\n\x0bresponse_id\x18\x01 \x01(\x05\x32\x94\x01\n\x0c\x44ummyService\x12\x37\n\x05\x45mpty\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\x12K\n\nSimpleTest\x12\x1d.tavern.tests.v1.DummyRequest\x1a\x1e.tavern.tests.v1.DummyResponseb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'test_services_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + DESCRIPTOR._options = None + _globals['_DUMMYREQUEST']._serialized_start=69 + _globals['_DUMMYREQUEST']._serialized_end=103 + _globals['_DUMMYRESPONSE']._serialized_start=105 + _globals['_DUMMYRESPONSE']._serialized_end=141 + _globals['_DUMMYSERVICE']._serialized_start=144 + _globals['_DUMMYSERVICE']._serialized_end=292 +# @@protoc_insertion_point(module_scope) diff --git a/tests/unit/tavern_grpc/test_services_pb2.pyi b/tests/unit/tavern_grpc/test_services_pb2.pyi new file mode 100644 index 00000000..b5e5af86 --- /dev/null +++ b/tests/unit/tavern_grpc/test_services_pb2.pyi @@ -0,0 +1,18 @@ +from google.protobuf import empty_pb2 as _empty_pb2 +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Optional as _Optional + +DESCRIPTOR: _descriptor.FileDescriptor + +class DummyRequest(_message.Message): + __slots__ = ["request_id"] + REQUEST_ID_FIELD_NUMBER: _ClassVar[int] + request_id: int + def __init__(self, request_id: _Optional[int] = ...) -> None: ... + +class DummyResponse(_message.Message): + __slots__ = ["response_id"] + RESPONSE_ID_FIELD_NUMBER: _ClassVar[int] + response_id: int + def __init__(self, response_id: _Optional[int] = ...) -> None: ... diff --git a/tests/unit/tavern_grpc/test_services_pb2_grpc.py b/tests/unit/tavern_grpc/test_services_pb2_grpc.py new file mode 100644 index 00000000..3401b73d --- /dev/null +++ b/tests/unit/tavern_grpc/test_services_pb2_grpc.py @@ -0,0 +1,100 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +import test_services_pb2 as test__services__pb2 + + +class DummyServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Empty = channel.unary_unary( + '/tavern.tests.v1.DummyService/Empty', + request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + ) + self.SimpleTest = channel.unary_unary( + '/tavern.tests.v1.DummyService/SimpleTest', + request_serializer=test__services__pb2.DummyRequest.SerializeToString, + response_deserializer=test__services__pb2.DummyResponse.FromString, + ) + + +class DummyServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def Empty(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def SimpleTest(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_DummyServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Empty': grpc.unary_unary_rpc_method_handler( + servicer.Empty, + request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + ), + 'SimpleTest': grpc.unary_unary_rpc_method_handler( + servicer.SimpleTest, + request_deserializer=test__services__pb2.DummyRequest.FromString, + response_serializer=test__services__pb2.DummyResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'tavern.tests.v1.DummyService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class DummyService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def Empty(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/tavern.tests.v1.DummyService/Empty', + google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + google_dot_protobuf_dot_empty__pb2.Empty.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def SimpleTest(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/tavern.tests.v1.DummyService/SimpleTest', + test__services__pb2.DummyRequest.SerializeToString, + test__services__pb2.DummyResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/tests/unit/test_call_run.py b/tests/unit/test_call_run.py index 62482d74..30972c1a 100644 --- a/tests/unit/test_call_run.py +++ b/tests/unit/test_call_run.py @@ -26,7 +26,12 @@ def test_run_with_cfg(self): @pytest.mark.parametrize( "expected_kwarg", - ("tavern_mqtt_backend", "tavern_http_backend", "tavern_strict"), + ( + "tavern_mqtt_backend", + "tavern_http_backend", + "tavern_grpc_backend", + "tavern_strict", + ), ) def test_doesnt_warn_about_expected_kwargs(self, expected_kwarg): kw = {expected_kwarg: 123} diff --git a/tests/unit/test_extensions.py b/tests/unit/test_extensions.py new file mode 100644 index 00000000..24ba0802 --- /dev/null +++ b/tests/unit/test_extensions.py @@ -0,0 +1,21 @@ +import pytest + +from tavern._core import exceptions +from tavern._core.schema.extensions import ( + validate_grpc_status_is_valid_or_list_of_names as validate_grpc, +) + + +class TestGrpcCodes: + @pytest.mark.parametrize("code", ("UNAVAILABLE", "unavailable", "ok", 14, 0)) + def test_validate_grpc_valid_status(self, code): + assert True is validate_grpc(code, None, None) + assert True is validate_grpc([code], None, None) + + @pytest.mark.parametrize("code", (-1, "fo", "J", {"status": "OK"})) + def test_validate_grpc_invalid_status(self, code): + with pytest.raises(exceptions.BadSchemaError): + assert False is validate_grpc(code, None, None) + + with pytest.raises(exceptions.BadSchemaError): + assert False is validate_grpc([code], None, None) diff --git a/tox-integration.ini b/tox-integration.ini index e4eeea81..9a7b7abc 100644 --- a/tox-integration.ini +++ b/tox-integration.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3-{generic,cookies,mqtt,advanced,components,noextra,hooks} +envlist = py3-{generic,cookies,mqtt,grpc,advanced,components,noextra,hooks} skip_missing_interpreters = true isolated_build = True @@ -13,6 +13,7 @@ setenv = SECOND_URL_PART = again PYTHONPATH = . changedir = + grpc: example/grpc mqtt: example/mqtt cookies: example/cookies advanced: example/advanced @@ -28,12 +29,14 @@ deps = pytest-cov colorlog mqtt: fluent-logger +extras = + grpc: grpc commands = ; docker compose stop ; docker compose build docker compose up --build -d python -m pytest --collect-only - python -m pytest --tavern-global-cfg={toxinidir}/tests/integration/global_cfg.yaml --cov tavern {posargs} + python -m pytest --tavern-global-cfg={toxinidir}/tests/integration/global_cfg.yaml --cov tavern {posargs} --tavern-setup-init-logging generic: py.test --tavern-global-cfg={toxinidir}/tests/integration/global_cfg.yaml -n 3 generic: tavern-ci --stdout . --tavern-global-cfg={toxinidir}/tests/integration/global_cfg.yaml diff --git a/tox.ini b/tox.ini index 17e0c0bd..aef629ac 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,7 @@ allowlist_externals = install_command = python -m pip install {opts} {packages} -c constraints.txt extras = dev + grpc commands = {envbindir}/python -m pytest --cov-report term-missing --cov tavern {posargs}