pytest-cov-container 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_cov_container/__about__.py +4 -0
- pytest_cov_container/__init__.py +36 -0
- pytest_cov_container/config.py +47 -0
- pytest_cov_container/docker_backend.py +101 -0
- pytest_cov_container/drivers/__init__.py +21 -0
- pytest_cov_container/drivers/python.py +105 -0
- pytest_cov_container/models.py +52 -0
- pytest_cov_container/plugin.py +117 -0
- pytest_cov_container/resolver.py +9 -0
- pytest_cov_container-0.0.1.dist-info/METADATA +153 -0
- pytest_cov_container-0.0.1.dist-info/RECORD +14 -0
- pytest_cov_container-0.0.1.dist-info/WHEEL +4 -0
- pytest_cov_container-0.0.1.dist-info/entry_points.txt +5 -0
- pytest_cov_container-0.0.1.dist-info/licenses/LICENSE.txt +18 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present tomaDev <genins21@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
_active_plugin: Any = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def collect_container_coverage():
|
|
11
|
+
"""Flush and collect coverage from running containers.
|
|
12
|
+
|
|
13
|
+
Call this before stopping containers to capture mid-session coverage.
|
|
14
|
+
|
|
15
|
+
Example::
|
|
16
|
+
|
|
17
|
+
from pytest_cov_container import collect_container_coverage
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture(scope="session")
|
|
21
|
+
def sam_api():
|
|
22
|
+
proc = start_sam(...)
|
|
23
|
+
yield SAM_URL
|
|
24
|
+
collect_container_coverage()
|
|
25
|
+
proc.terminate()
|
|
26
|
+
"""
|
|
27
|
+
if _active_plugin is None:
|
|
28
|
+
warnings.warn(
|
|
29
|
+
"pytest-cov-container is not active. "
|
|
30
|
+
"Ensure pytest-cov is running (--cov) and "
|
|
31
|
+
"[tool.pytest-cov-container] is configured.",
|
|
32
|
+
UserWarning,
|
|
33
|
+
stacklevel=2,
|
|
34
|
+
)
|
|
35
|
+
return
|
|
36
|
+
_active_plugin.collect_from_running()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import tomllib
|
|
5
|
+
|
|
6
|
+
from pytest_cov_container.models import DriverConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class PluginConfig:
|
|
11
|
+
image_pattern: str | None = None
|
|
12
|
+
label: str | None = None
|
|
13
|
+
language: str = "python"
|
|
14
|
+
enabled: bool = True
|
|
15
|
+
path_mapping: dict[str, str] = field(default_factory=dict)
|
|
16
|
+
driver_config: DriverConfig | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_config(pyproject_path: Path) -> PluginConfig | None:
|
|
20
|
+
if not pyproject_path.exists():
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
with open(pyproject_path, "rb") as f:
|
|
24
|
+
data = tomllib.load(f)
|
|
25
|
+
|
|
26
|
+
tool_config = data.get("tool", {}).get("pytest-cov-container")
|
|
27
|
+
if tool_config is None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
path_mapping = {str(k): str(v) for k, v in tool_config.get("path_mapping", {}).items()}
|
|
31
|
+
|
|
32
|
+
plugin_config = PluginConfig(
|
|
33
|
+
image_pattern=tool_config.get("image_pattern"),
|
|
34
|
+
label=tool_config.get("label"),
|
|
35
|
+
language=tool_config.get("language", "python"),
|
|
36
|
+
enabled=tool_config.get("enabled", True),
|
|
37
|
+
path_mapping=path_mapping,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
driver_section = tool_config.get(plugin_config.language, {})
|
|
41
|
+
plugin_config.driver_config = DriverConfig(
|
|
42
|
+
build_dir=driver_section.get("build_dir", ".aws-sam/build/ApiFunction"),
|
|
43
|
+
entrypoint=driver_section.get("entrypoint", "uvicorn app:app --host 0.0.0.0 --port 8080"),
|
|
44
|
+
path_mapping=path_mapping,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return plugin_config
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
import io
|
|
3
|
+
import tarfile
|
|
4
|
+
import warnings
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import docker
|
|
8
|
+
import docker.errors
|
|
9
|
+
|
|
10
|
+
from pytest_cov_container.models import ContainerInfo
|
|
11
|
+
|
|
12
|
+
_SIGNAL_CMD = [
|
|
13
|
+
"sh",
|
|
14
|
+
"-c",
|
|
15
|
+
"for p in /proc/[0-9]*/cmdline; do "
|
|
16
|
+
"pid=$(echo $p | cut -d/ -f3); "
|
|
17
|
+
"if grep -q _cov_wrapper $p 2>/dev/null; "
|
|
18
|
+
"then kill -USR1 $pid; break; fi; done",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DockerBackend:
|
|
23
|
+
def __init__(self, client: docker.DockerClient | None = None):
|
|
24
|
+
self._client = client or docker.from_env()
|
|
25
|
+
|
|
26
|
+
def find_containers(
|
|
27
|
+
self,
|
|
28
|
+
image_pattern: str | None = None,
|
|
29
|
+
label: str | None = None,
|
|
30
|
+
) -> list[ContainerInfo]:
|
|
31
|
+
filters: dict = {}
|
|
32
|
+
if label:
|
|
33
|
+
filters["label"] = label
|
|
34
|
+
|
|
35
|
+
containers = self._client.containers.list(all=True, filters=filters)
|
|
36
|
+
|
|
37
|
+
if image_pattern:
|
|
38
|
+
containers = [
|
|
39
|
+
c for c in containers if any(fnmatch.fnmatch(tag, image_pattern) for tag in (c.image.tags or []))
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
return [self._to_info(c) for c in containers]
|
|
43
|
+
|
|
44
|
+
def send_signal(self, container_id: str) -> None:
|
|
45
|
+
try:
|
|
46
|
+
container = self._client.containers.get(container_id)
|
|
47
|
+
container.exec_run(_SIGNAL_CMD)
|
|
48
|
+
except docker.errors.APIError as exc:
|
|
49
|
+
warnings.warn(
|
|
50
|
+
f"Failed to send signal to container {container_id[:12]}: {exc}",
|
|
51
|
+
UserWarning,
|
|
52
|
+
stacklevel=2,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def extract_matching_files(
|
|
56
|
+
self,
|
|
57
|
+
container_id: str,
|
|
58
|
+
source_dir: str,
|
|
59
|
+
prefix: str,
|
|
60
|
+
dest: Path,
|
|
61
|
+
) -> list[Path]:
|
|
62
|
+
try:
|
|
63
|
+
container = self._client.containers.get(container_id)
|
|
64
|
+
stream, _ = container.get_archive(source_dir)
|
|
65
|
+
except docker.errors.APIError as exc:
|
|
66
|
+
warnings.warn(
|
|
67
|
+
f"Failed to extract from container {container_id[:12]}: {exc}. Check that the container is accessible.",
|
|
68
|
+
UserWarning,
|
|
69
|
+
stacklevel=2,
|
|
70
|
+
)
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
tar_bytes = b"".join(stream)
|
|
74
|
+
extracted: list[Path] = []
|
|
75
|
+
with tarfile.open(fileobj=io.BytesIO(tar_bytes)) as tar:
|
|
76
|
+
for member in tar.getmembers():
|
|
77
|
+
if not member.isfile():
|
|
78
|
+
continue
|
|
79
|
+
name = Path(member.name).name
|
|
80
|
+
if not name.startswith(prefix):
|
|
81
|
+
continue
|
|
82
|
+
member_file = tar.extractfile(member)
|
|
83
|
+
if member_file:
|
|
84
|
+
target = dest / name
|
|
85
|
+
target.write_bytes(member_file.read())
|
|
86
|
+
extracted.append(target)
|
|
87
|
+
return extracted
|
|
88
|
+
|
|
89
|
+
def inspect(self, container_id: str) -> dict:
|
|
90
|
+
container = self._client.containers.get(container_id)
|
|
91
|
+
return container.attrs
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _to_info(container) -> ContainerInfo:
|
|
95
|
+
return ContainerInfo(
|
|
96
|
+
id=container.id,
|
|
97
|
+
name=container.name,
|
|
98
|
+
image=container.image.tags[0] if container.image.tags else "",
|
|
99
|
+
labels=container.labels,
|
|
100
|
+
status=container.status,
|
|
101
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from importlib.metadata import entry_points
|
|
2
|
+
|
|
3
|
+
from pytest_cov_container.models import LanguageDriver
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def discover_drivers() -> dict[str, LanguageDriver]:
|
|
7
|
+
eps = entry_points(group="pytest_cov_container.drivers")
|
|
8
|
+
drivers: dict[str, LanguageDriver] = {}
|
|
9
|
+
for ep in eps:
|
|
10
|
+
driver_class = ep.load()
|
|
11
|
+
drivers[ep.name] = driver_class()
|
|
12
|
+
return drivers
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_driver(name: str) -> LanguageDriver:
|
|
16
|
+
found = discover_drivers()
|
|
17
|
+
if name not in found:
|
|
18
|
+
available = ", ".join(sorted(found.keys()))
|
|
19
|
+
msg = f"Unknown language driver '{name}'. Available: {available}"
|
|
20
|
+
raise ValueError(msg)
|
|
21
|
+
return found[name]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import stat
|
|
2
|
+
import time
|
|
3
|
+
import warnings
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pytest_cov_container.models import ContainerInfo, DockerBackendProtocol, DriverConfig, InjectionResult
|
|
7
|
+
|
|
8
|
+
_COVERAGERC_TEMPLATE = """\
|
|
9
|
+
[run]
|
|
10
|
+
data_file = /tmp/.coverage.container
|
|
11
|
+
relative_files = true
|
|
12
|
+
parallel = true
|
|
13
|
+
sigterm = true
|
|
14
|
+
include =
|
|
15
|
+
*.py
|
|
16
|
+
omit =
|
|
17
|
+
_cov_wrapper.py
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
_COV_WRAPPER_TEMPLATE = """\
|
|
21
|
+
import os
|
|
22
|
+
import signal
|
|
23
|
+
import subprocess
|
|
24
|
+
|
|
25
|
+
import coverage
|
|
26
|
+
|
|
27
|
+
cov = coverage.Coverage(
|
|
28
|
+
config_file=os.environ.get("COVERAGE_PROCESS_START", "/var/task/.coveragerc")
|
|
29
|
+
)
|
|
30
|
+
cov.start()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _flush(*_):
|
|
34
|
+
cov.save()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
signal.signal(signal.SIGUSR1, _flush)
|
|
38
|
+
|
|
39
|
+
proc = subprocess.Popen(
|
|
40
|
+
["sh", "-c", os.environ.get("CONTAINER_COV_ENTRYPOINT", "{entrypoint}")]
|
|
41
|
+
)
|
|
42
|
+
proc.wait()
|
|
43
|
+
cov.stop()
|
|
44
|
+
cov.save()
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
_RUN_SH_TEMPLATE = """\
|
|
48
|
+
#!/bin/bash
|
|
49
|
+
exec python /var/task/_cov_wrapper.py
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PythonDriver:
|
|
54
|
+
name: str = "python"
|
|
55
|
+
|
|
56
|
+
def inject(self, target_dir: Path, config: DriverConfig) -> InjectionResult:
|
|
57
|
+
files_written: list[Path] = []
|
|
58
|
+
|
|
59
|
+
# .coveragerc
|
|
60
|
+
coveragerc = target_dir / ".coveragerc"
|
|
61
|
+
coveragerc.write_text(_COVERAGERC_TEMPLATE)
|
|
62
|
+
files_written.append(coveragerc)
|
|
63
|
+
|
|
64
|
+
# _cov_wrapper.py
|
|
65
|
+
wrapper = target_dir / "_cov_wrapper.py"
|
|
66
|
+
wrapper.write_text(_COV_WRAPPER_TEMPLATE.format(entrypoint=config.entrypoint))
|
|
67
|
+
files_written.append(wrapper)
|
|
68
|
+
|
|
69
|
+
# run.sh
|
|
70
|
+
run_sh = target_dir / "run.sh"
|
|
71
|
+
run_sh.write_text(_RUN_SH_TEMPLATE)
|
|
72
|
+
run_sh.chmod(run_sh.stat().st_mode | stat.S_IEXEC)
|
|
73
|
+
files_written.append(run_sh)
|
|
74
|
+
|
|
75
|
+
return InjectionResult(
|
|
76
|
+
files_written=files_written,
|
|
77
|
+
env_vars={"COVERAGE_PROCESS_START": "/var/task/.coveragerc"},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def collect(
|
|
81
|
+
self,
|
|
82
|
+
docker_backend: DockerBackendProtocol,
|
|
83
|
+
container: ContainerInfo,
|
|
84
|
+
dest: Path,
|
|
85
|
+
config: DriverConfig, # noqa: ARG002
|
|
86
|
+
) -> Path:
|
|
87
|
+
if container.status == "running":
|
|
88
|
+
docker_backend.send_signal(container.id)
|
|
89
|
+
time.sleep(1)
|
|
90
|
+
|
|
91
|
+
extracted = docker_backend.extract_matching_files(
|
|
92
|
+
container.id,
|
|
93
|
+
"/tmp", # noqa: S108
|
|
94
|
+
".coverage.container",
|
|
95
|
+
dest,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if not extracted:
|
|
99
|
+
warnings.warn(
|
|
100
|
+
f"No coverage data found in container {container.name} ({container.id[:12]})",
|
|
101
|
+
UserWarning,
|
|
102
|
+
stacklevel=2,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return dest
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ContainerInfo:
|
|
8
|
+
id: str
|
|
9
|
+
name: str
|
|
10
|
+
image: str
|
|
11
|
+
labels: dict[str, str]
|
|
12
|
+
status: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class DriverConfig:
|
|
17
|
+
build_dir: str
|
|
18
|
+
entrypoint: str
|
|
19
|
+
path_mapping: dict[str, str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class InjectionResult:
|
|
24
|
+
files_written: list[Path]
|
|
25
|
+
env_vars: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DockerBackendProtocol(Protocol):
|
|
29
|
+
def send_signal(self, container_id: str) -> None: ...
|
|
30
|
+
|
|
31
|
+
def extract_matching_files(
|
|
32
|
+
self,
|
|
33
|
+
container_id: str,
|
|
34
|
+
source_dir: str,
|
|
35
|
+
prefix: str,
|
|
36
|
+
dest: Path,
|
|
37
|
+
) -> list[Path]: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@runtime_checkable
|
|
41
|
+
class LanguageDriver(Protocol):
|
|
42
|
+
name: str
|
|
43
|
+
|
|
44
|
+
def inject(self, target_dir: Path, config: DriverConfig) -> InjectionResult: ...
|
|
45
|
+
|
|
46
|
+
def collect(
|
|
47
|
+
self,
|
|
48
|
+
docker_backend: DockerBackendProtocol,
|
|
49
|
+
container: ContainerInfo,
|
|
50
|
+
dest: Path,
|
|
51
|
+
config: DriverConfig,
|
|
52
|
+
) -> Path: ...
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
import tempfile
|
|
4
|
+
import warnings
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import pytest_cov_container
|
|
10
|
+
from pytest_cov_container import config as config_module
|
|
11
|
+
from pytest_cov_container import drivers
|
|
12
|
+
from pytest_cov_container.docker_backend import DockerBackend
|
|
13
|
+
from pytest_cov_container.resolver import SamBuildResolver
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ContainerCovPlugin:
|
|
17
|
+
def __init__(self, plugin_config):
|
|
18
|
+
self.config = plugin_config
|
|
19
|
+
self.backend = DockerBackend()
|
|
20
|
+
self.driver = drivers.get_driver(plugin_config.language)
|
|
21
|
+
self.resolver = SamBuildResolver()
|
|
22
|
+
self.injection_result = None
|
|
23
|
+
self.coverage_dir = Path(tempfile.mkdtemp(prefix="cov_container_"))
|
|
24
|
+
|
|
25
|
+
def pytest_sessionstart(self, session):
|
|
26
|
+
target_dir = self.resolver.resolve_target_dir(
|
|
27
|
+
self.config.driver_config.build_dir,
|
|
28
|
+
session.config.rootpath,
|
|
29
|
+
)
|
|
30
|
+
if not target_dir.exists():
|
|
31
|
+
msg = f"Build directory {target_dir} does not exist. Run 'sam build' before running tests."
|
|
32
|
+
raise FileNotFoundError(msg)
|
|
33
|
+
|
|
34
|
+
self.injection_result = self.driver.inject(target_dir, self.config.driver_config)
|
|
35
|
+
|
|
36
|
+
@pytest.hookimpl(trylast=True)
|
|
37
|
+
def pytest_sessionfinish(self, session, exitstatus): # noqa: ARG002
|
|
38
|
+
containers = self.backend.find_containers(
|
|
39
|
+
image_pattern=self.config.image_pattern,
|
|
40
|
+
label=self.config.label,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if not containers:
|
|
44
|
+
warnings.warn(
|
|
45
|
+
"No matching containers found for coverage collection",
|
|
46
|
+
UserWarning,
|
|
47
|
+
stacklevel=2,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
for container in containers:
|
|
51
|
+
self.driver.collect(self.backend, container, self.coverage_dir, self.config.driver_config)
|
|
52
|
+
|
|
53
|
+
self._combine_coverage(session.config.rootpath)
|
|
54
|
+
|
|
55
|
+
def collect_from_running(self):
|
|
56
|
+
containers = self.backend.find_containers(
|
|
57
|
+
image_pattern=self.config.image_pattern,
|
|
58
|
+
label=self.config.label,
|
|
59
|
+
)
|
|
60
|
+
running = [c for c in containers if c.status == "running"]
|
|
61
|
+
for container in running:
|
|
62
|
+
self.driver.collect(self.backend, container, self.coverage_dir, self.config.driver_config)
|
|
63
|
+
|
|
64
|
+
def _combine_coverage(self, root: Path):
|
|
65
|
+
rc_path = self.coverage_dir / ".coveragerc"
|
|
66
|
+
lines = ["[paths]"]
|
|
67
|
+
for i, (host_path, container_path) in enumerate(self.config.path_mapping.items()):
|
|
68
|
+
label = "source" if i == 0 else f"source{i}"
|
|
69
|
+
lines.append(f"{label} =")
|
|
70
|
+
lines.append(f" {host_path}")
|
|
71
|
+
lines.append(f" {container_path}")
|
|
72
|
+
rc_path.write_text("\n".join(lines) + "\n")
|
|
73
|
+
|
|
74
|
+
result = subprocess.run(
|
|
75
|
+
[sys.executable, "-m", "coverage", "combine", f"--rcfile={rc_path}", str(self.coverage_dir)],
|
|
76
|
+
check=False,
|
|
77
|
+
cwd=root,
|
|
78
|
+
capture_output=True,
|
|
79
|
+
text=True,
|
|
80
|
+
)
|
|
81
|
+
if result.returncode != 0:
|
|
82
|
+
warnings.warn(
|
|
83
|
+
f"coverage combine failed: {result.stderr}",
|
|
84
|
+
UserWarning,
|
|
85
|
+
stacklevel=2,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def pytest_addoption(parser):
|
|
90
|
+
group = parser.getgroup("cov-container")
|
|
91
|
+
group.addoption(
|
|
92
|
+
"--no-cov-container",
|
|
93
|
+
action="store_true",
|
|
94
|
+
default=False,
|
|
95
|
+
help="Disable container coverage collection",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.hookimpl(trylast=True)
|
|
100
|
+
def pytest_configure(config):
|
|
101
|
+
if config.getoption("--no-cov-container", default=False):
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
if not config.pluginmanager.hasplugin("pytest_cov"):
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
cov_sources = config.getoption("--cov", default=[])
|
|
108
|
+
if not cov_sources:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
plugin_config = config_module.load_config(config.rootpath / "pyproject.toml")
|
|
112
|
+
if plugin_config is None or not plugin_config.enabled:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
plugin = ContainerCovPlugin(plugin_config)
|
|
116
|
+
config.pluginmanager.register(plugin, "cov_container")
|
|
117
|
+
pytest_cov_container._active_plugin = plugin # noqa: SLF001
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-cov-container
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Pytest plugin to collect code coverage from applications running inside Docker containers
|
|
5
|
+
Project-URL: Documentation, https://github.com/tomaDev/pytest-cov-container#readme
|
|
6
|
+
Project-URL: Issues, https://github.com/tomaDev/pytest-cov-container/issues
|
|
7
|
+
Project-URL: Source, https://github.com/tomaDev/pytest-cov-container
|
|
8
|
+
Author-email: tomaDev <genins21@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE.txt
|
|
11
|
+
Keywords: containers,coverage,docker,pytest,testing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Pytest
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
20
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: coverage>=7.2
|
|
23
|
+
Requires-Dist: docker>=6.0
|
|
24
|
+
Requires-Dist: pytest-cov>=4.0
|
|
25
|
+
Requires-Dist: pytest>=7.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# pytest-cov-container
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/pytest-cov-container)
|
|
31
|
+
[](https://pypi.org/project/pytest-cov-container)
|
|
32
|
+
[](https://github.com/PyCQA/bandit)
|
|
33
|
+
|
|
34
|
+
Collect code coverage from Python applications running inside Docker containers during integration tests. Works alongside [pytest-cov](https://github.com/pytest-dev/pytest-cov) to combine container coverage with your local test coverage.
|
|
35
|
+
|
|
36
|
+
Built for projects using [AWS SAM](https://docs.aws.amazon.com/serverless-application-model/) local testing, but works with any Docker-based test workflow.
|
|
37
|
+
|
|
38
|
+
## How It Works
|
|
39
|
+
|
|
40
|
+
1. **Before tests** — injects a coverage wrapper, `.coveragerc`, and `run.sh` into your SAM build directory
|
|
41
|
+
2. **During tests** — your containerized app runs under `coverage` via the injected wrapper
|
|
42
|
+
3. **After tests** — extracts `.coverage.*` files from containers and runs `coverage combine` to merge them with local results
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```console
|
|
47
|
+
pip install pytest-cov-container
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Requires Python 3.11+.
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
Add to your `pyproject.toml`:
|
|
55
|
+
|
|
56
|
+
```toml
|
|
57
|
+
[tool.pytest-cov-container]
|
|
58
|
+
image_pattern = "samcli/lambda*"
|
|
59
|
+
label = "pytest-cov-container"
|
|
60
|
+
|
|
61
|
+
[tool.pytest-cov-container.path_mapping]
|
|
62
|
+
"src/api" = "/var/task"
|
|
63
|
+
|
|
64
|
+
[tool.pytest-cov-container.python]
|
|
65
|
+
build_dir = ".aws-sam/build/ApiFunction"
|
|
66
|
+
entrypoint = "uvicorn app:app --host 0.0.0.0 --port 8080"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| Key | Description |
|
|
70
|
+
|-----|-------------|
|
|
71
|
+
| `image_pattern` | Glob pattern to match container image tags |
|
|
72
|
+
| `label` | Docker label to filter containers |
|
|
73
|
+
| `path_mapping` | Maps host source paths to container paths (used by `coverage combine`) |
|
|
74
|
+
| `build_dir` | SAM build output directory where coverage files are injected |
|
|
75
|
+
| `entrypoint` | The command your app runs inside the container |
|
|
76
|
+
| `language` | Language driver to use (default: `"python"`) |
|
|
77
|
+
| `enabled` | Set to `false` to disable (default: `true`) |
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
Run your tests with `--cov` as usual:
|
|
82
|
+
|
|
83
|
+
```console
|
|
84
|
+
pytest --cov=src/api tests/
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The plugin activates automatically when:
|
|
88
|
+
- pytest-cov is active (`--cov` flag present)
|
|
89
|
+
- `[tool.pytest-cov-container]` is configured in `pyproject.toml`
|
|
90
|
+
|
|
91
|
+
Disable it for a run with:
|
|
92
|
+
|
|
93
|
+
```console
|
|
94
|
+
pytest --cov=src/api --no-cov-container tests/
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Mid-Session Collection
|
|
98
|
+
|
|
99
|
+
If you need to collect coverage before containers stop (e.g., in a session-scoped fixture teardown), use the public API:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from pytest_cov_container import collect_container_coverage
|
|
103
|
+
|
|
104
|
+
@pytest.fixture(scope="session")
|
|
105
|
+
def sam_api():
|
|
106
|
+
proc = start_sam(...)
|
|
107
|
+
yield SAM_URL
|
|
108
|
+
collect_container_coverage()
|
|
109
|
+
proc.terminate()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
This sends `SIGUSR1` to running containers to flush coverage data, then extracts the files.
|
|
113
|
+
|
|
114
|
+
## Pluggable Drivers
|
|
115
|
+
|
|
116
|
+
Language support is pluggable via entry points. The built-in Python driver handles:
|
|
117
|
+
|
|
118
|
+
- Writing `.coveragerc` with `parallel = true` and `sigterm = true`
|
|
119
|
+
- Writing `_cov_wrapper.py` that starts coverage, handles `SIGTERM`/`SIGUSR1`, and runs your entrypoint
|
|
120
|
+
- Writing `run.sh` to bootstrap the wrapper
|
|
121
|
+
- Extracting `.coverage.*` files from `/tmp` in containers
|
|
122
|
+
|
|
123
|
+
To add a driver for another language, register an entry point:
|
|
124
|
+
|
|
125
|
+
```toml
|
|
126
|
+
[project.entry-points."pytest_cov_container.drivers"]
|
|
127
|
+
node = "my_package.drivers.node:NodeDriver"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Drivers must implement the `LanguageDriver` protocol from `pytest_cov_container.models`.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```console
|
|
135
|
+
# Run tests
|
|
136
|
+
hatch test
|
|
137
|
+
|
|
138
|
+
# Run across all Python versions
|
|
139
|
+
hatch test --all
|
|
140
|
+
|
|
141
|
+
# Format and lint
|
|
142
|
+
hatch fmt
|
|
143
|
+
|
|
144
|
+
# Type check
|
|
145
|
+
hatch run types:check
|
|
146
|
+
|
|
147
|
+
# Security scan
|
|
148
|
+
hatch run security:scan
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
`pytest-cov-container` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pytest_cov_container/__about__.py,sha256=_6K3JaHxGrAAf9QWvnngLAyAqs-pyQ0h7aHu_B2jDHE,123
|
|
2
|
+
pytest_cov_container/__init__.py,sha256=g82W1-dF7joWQG0ONmDBj2Hm3LnCLcz97HBDXZF4dJA,963
|
|
3
|
+
pytest_cov_container/config.py,sha256=O3gHl4ByejjgEB-nLzR-nH8sl_MpjvMZcvMXBB6ZS6o,1440
|
|
4
|
+
pytest_cov_container/docker_backend.py,sha256=06i9AZqrjK6IXraNihsXEbW4J34nN12D9aaJ9MlfQWc,3148
|
|
5
|
+
pytest_cov_container/models.py,sha256=liAkE_T9Dze7EuMWlhsHcwpTtACteuVh1SPXpYl5ptQ,1060
|
|
6
|
+
pytest_cov_container/plugin.py,sha256=ej1PidjQGEPrlH_fLiC-GasxMTa2uNH8Ly9MAz48w4A,4017
|
|
7
|
+
pytest_cov_container/resolver.py,sha256=nCg4e9UvmpphQ_CHicFt_dGpTMwSYPq5YPZ8Yve-y0s,250
|
|
8
|
+
pytest_cov_container/drivers/__init__.py,sha256=t6JmIvT7HfPrjzqbmOHY7tX9ihcTjDLBQ1eJifBzzsw,656
|
|
9
|
+
pytest_cov_container/drivers/python.py,sha256=U7XWmjnw2D3-LvuttJhl6dZGbtflqpwYY4mZbk06BHE,2484
|
|
10
|
+
pytest_cov_container-0.0.1.dist-info/METADATA,sha256=EuLk-us-f9Z-udCRC1N7-2tyxWL_ohg6wqiKm9H-xbI,5056
|
|
11
|
+
pytest_cov_container-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
pytest_cov_container-0.0.1.dist-info/entry_points.txt,sha256=S1jaxS9MKAETKV2JLzIAnvRwUFQsJXnJ5B0f0yKS4ao,145
|
|
13
|
+
pytest_cov_container-0.0.1.dist-info/licenses/LICENSE.txt,sha256=l-uCyVpFGHkbtBjhxUN2NnLtz05SMD9Np92H7SubkqE,1093
|
|
14
|
+
pytest_cov_container-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present tomaDev <genins21@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
15
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
16
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
18
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|