Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions conformance/test/_cov_embed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# Includes work from:
#
# The MIT License
#
# Copyright (c) 2010 Meme Dough
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

# Copied from pytest-cov 6.3.0's https://github.com/pytest-dev/pytest-cov/blob/v6.3.0/src/pytest_cov/embed.py
# It was removed to rely on coverage.py's patching of subprocess, etc commands in Python, but because we have
# a Go command in the middle, that doesn't work for us and we go ahead and vendor in and call the embed code
# ourselves.

"""Activate coverage at python startup if appropriate.

The python site initialisation will ensure that anything we import
will be removed and not visible at the end of python startup. However
we minimise all work by putting these init actions in this separate
module and only importing what is needed when needed.

For normal python startup when coverage should not be activated the pth
file checks a single env var and does not import or call the init fn
here.

For python startup when an ancestor process has set the env indicating
that code coverage is being collected we activate coverage based on
info passed via env vars.
"""

from __future__ import annotations

import atexit
import contextlib
import os
import signal

import coverage

_active_cov = None


def init():
# Only continue if ancestor process has set everything needed in
# the env.
global _active_cov # noqa: PLW0603

cov_source = os.environ.get("COV_CORE_SOURCE")
cov_config = os.environ.get("COV_CORE_CONFIG")
cov_datafile = os.environ.get("COV_CORE_DATAFILE")
cov_branch = True if os.environ.get("COV_CORE_BRANCH") == "enabled" else None
cov_context = os.environ.get("COV_CORE_CONTEXT")

if cov_datafile:
assert cov_source is not None
if _active_cov:
cleanup()

# Determine all source roots.
cov_source = None if cov_source in os.pathsep else cov_source.split(os.pathsep)
if cov_config == os.pathsep:
cov_config = True

# Activate coverage for this process.
cov = _active_cov = coverage.Coverage(
source=cov_source,
branch=cov_branch,
data_suffix=True,
config_file=cov_config or False,
auto_data=True,
data_file=cov_datafile,
)
cov.load()
cov.start()
if cov_context:
cov.switch_context(cov_context)
cov._warn_no_data = False
cov._warn_unimported_source = False
cov._warn_preimported_source = False
return cov
return None


def _cleanup(cov):
if cov is not None:
cov.stop()
cov.save()
cov._auto_save = False # prevent autosaving from cov._atexit in case the interpreter lacks atexit.unregister
with contextlib.suppress(Exception):
atexit.unregister(cov._atexit)


def cleanup():
global _active_cov # noqa: PLW0603
global _cleanup_in_progress # noqa: PLW0603
global _pending_signal # noqa: PLW0603

_cleanup_in_progress = True
_cleanup(_active_cov)
_active_cov = None
_cleanup_in_progress = False
if _pending_signal:
pending_signal = _pending_signal
_pending_signal = None
_signal_cleanup_handler(*pending_signal)


_previous_handlers = {}
_pending_signal = None
_cleanup_in_progress = False


def _signal_cleanup_handler(signum, frame):
global _pending_signal # noqa: PLW0603
if _cleanup_in_progress:
_pending_signal = signum, frame
return
cleanup()
_previous_handler = _previous_handlers.get(signum)
if _previous_handler == signal.SIG_IGN:
return
if _previous_handler and _previous_handler is not _signal_cleanup_handler:
_previous_handler(signum, frame)
elif signum == signal.SIGTERM:
os._exit(128 + signum)
elif signum == signal.SIGINT:
raise KeyboardInterrupt


def cleanup_on_signal(signum):
previous = signal.getsignal(signum)
if previous is not _signal_cleanup_handler:
_previous_handlers[signum] = previous
signal.signal(signum, _signal_cleanup_handler)


def cleanup_on_sigterm():
cleanup_on_signal(signal.SIGTERM)


init()
24 changes: 24 additions & 0 deletions conformance/test/_util.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
from __future__ import annotations

import asyncio
import os
import sys
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from coverage import Coverage

VERSION_CONFORMANCE = "v1.0.5"

Expand Down Expand Up @@ -30,3 +35,22 @@ def maybe_patch_args_with_debug(args: list[str]) -> list[str]:
return _pydev_bundle.pydev_monkey.patch_args(args)
except Exception:
return args


def coverage_env(cov: Coverage | None) -> dict[str, str] | None:
if cov is None:
return None
env: dict[str, str] = {**os.environ}
# cov.config.source only contains . but we need .. too.
# It should be fine to just hard-code this.
env["COV_CORE_SOURCE"] = os.pathsep.join((".", ".."))
if cov.config.config_file:
env["COV_CORE_CONFIG"] = cov.config.config_file
if cov.config.data_file:
env["COV_CORE_DATAFILE"] = cov.config.data_file
if cov.config.branch:
env["COV_CORE_BRANCH"] = "enabled"
if cov.config.context:
env["COV_CORE_CONTEXT"] = cov.config.context

return env
1 change: 1 addition & 0 deletions conformance/test/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import traceback
from typing import TYPE_CHECKING, Literal, TypeVar, get_args

import _cov_embed # noqa: F401
from _util import create_standard_streams
from gen.connectrpc.conformance.v1.client_compat_pb2 import (
ClientCompatRequest,
Expand Down
2 changes: 2 additions & 0 deletions conformance/test/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from tempfile import NamedTemporaryFile
from typing import TYPE_CHECKING, Literal, TypeVar, get_args

# Needs to run before importing from connectrpc
import _cov_embed # noqa: F401
from _util import create_standard_streams
from gen.connectrpc.conformance.v1.config_pb2 import Code as ConformanceCode
from gen.connectrpc.conformance.v1.server_compat_pb2 import (
Expand Down
12 changes: 9 additions & 3 deletions conformance/test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING

import pytest

from ._util import VERSION_CONFORMANCE, maybe_patch_args_with_debug
from ._util import VERSION_CONFORMANCE, coverage_env, maybe_patch_args_with_debug

if TYPE_CHECKING:
from coverage import Coverage

_current_dir = Path(__file__).parent
_client_py_path = str(_current_dir / "client.py")
Expand All @@ -19,7 +23,7 @@
]


def test_client_sync() -> None:
def test_client_sync(cov: Coverage | None) -> None:
args = maybe_patch_args_with_debug(
[sys.executable, _client_py_path, "--mode", "sync"]
)
Expand All @@ -40,12 +44,13 @@ def test_client_sync() -> None:
capture_output=True,
text=True,
check=False,
env=coverage_env(cov),
)
if result.returncode != 0:
pytest.fail(f"\n{result.stdout}\n{result.stderr}")


def test_client_async() -> None:
def test_client_async(cov: Coverage | None) -> None:
args = maybe_patch_args_with_debug(
[sys.executable, _client_py_path, "--mode", "async"]
)
Expand All @@ -65,6 +70,7 @@ def test_client_async() -> None:
capture_output=True,
text=True,
check=False,
env=coverage_env(cov),
)
if result.returncode != 0:
pytest.fail(f"\n{result.stdout}\n{result.stderr}")
12 changes: 9 additions & 3 deletions conformance/test/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING

import pytest

from ._util import VERSION_CONFORMANCE, maybe_patch_args_with_debug
from ._util import VERSION_CONFORMANCE, coverage_env, maybe_patch_args_with_debug

if TYPE_CHECKING:
from coverage import Coverage

_current_dir = Path(__file__).parent
_server_py_path = str(_current_dir / "server.py")
Expand Down Expand Up @@ -40,7 +44,7 @@ def macos_raise_ulimit():


@pytest.mark.parametrize("server", ["gunicorn", "pyvoy"])
def test_server_sync(server: str) -> None:
def test_server_sync(server: str, cov: Coverage) -> None:
args = maybe_patch_args_with_debug(
[sys.executable, _server_py_path, "--mode", "sync", "--server", server]
)
Expand All @@ -67,13 +71,14 @@ def test_server_sync(server: str) -> None:
capture_output=True,
text=True,
check=False,
env=coverage_env(cov),
)
if result.returncode != 0:
pytest.fail(f"\n{result.stdout}\n{result.stderr}")


@pytest.mark.parametrize("server", ["daphne", "pyvoy", "uvicorn"])
def test_server_async(server: str) -> None:
def test_server_async(server: str, cov: Coverage) -> None:
args = maybe_patch_args_with_debug(
[sys.executable, _server_py_path, "--mode", "async", "--server", server]
)
Expand Down Expand Up @@ -117,6 +122,7 @@ def test_server_async(server: str) -> None:
capture_output=True,
text=True,
check=False,
env=coverage_env(cov),
)
if result.returncode != 0:
pytest.fail(f"\n{result.stdout}\n{result.stderr}")
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ build-backend = "uv_build"
[tool.uv]
resolution = "lowest-direct"
constraint-dependencies = [
"coverage==7.13.2",
"pytest==9.0.2",
"pytest-asyncio==1.3.0",
"pytest-cov==7.0.0",
Expand Down Expand Up @@ -234,7 +235,7 @@ exclude = [
# See https://github.com/grpc/grpc/issues/39555
"**/*_pb2_grpc.py",

# TODO: Work out the import issues to allow it to work.
# TODO: Work out the import issues to allow it to work.p
"conformance/**",
]

Expand Down
Loading