pytest-api-cov 0.1.0__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_api_cov/__init__.py +1 -0
- pytest_api_cov/cli.py +192 -0
- pytest_api_cov/config.py +77 -0
- pytest_api_cov/frameworks.py +80 -0
- pytest_api_cov/models.py +122 -0
- pytest_api_cov/plugin.py +270 -0
- pytest_api_cov/pytest_flags.py +68 -0
- pytest_api_cov/report.py +185 -0
- pytest_api_cov-0.1.0.dist-info/METADATA +330 -0
- pytest_api_cov-0.1.0.dist-info/RECORD +13 -0
- pytest_api_cov-0.1.0.dist-info/WHEEL +4 -0
- pytest_api_cov-0.1.0.dist-info/entry_points.txt +5 -0
- pytest_api_cov-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1 @@
|
|
1
|
+
# This file makes the pytest_api_cov directory a Python package
|
pytest_api_cov/cli.py
ADDED
@@ -0,0 +1,192 @@
|
|
1
|
+
"""CLI commands for setup and configuration."""
|
2
|
+
|
3
|
+
import argparse
|
4
|
+
import os
|
5
|
+
import sys
|
6
|
+
from typing import Optional
|
7
|
+
|
8
|
+
|
9
|
+
def detect_framework_and_app() -> Optional[tuple[str, str, str]]:
|
10
|
+
"""
|
11
|
+
Detect framework and app location.
|
12
|
+
Returns (framework, file_path, app_variable) or None.
|
13
|
+
"""
|
14
|
+
common_patterns = [
|
15
|
+
("app.py", ["app", "application", "main"]),
|
16
|
+
("main.py", ["app", "application", "main"]),
|
17
|
+
("server.py", ["app", "application", "server"]),
|
18
|
+
("wsgi.py", ["app", "application"]),
|
19
|
+
("asgi.py", ["app", "application"]),
|
20
|
+
]
|
21
|
+
|
22
|
+
for filename, attr_names in common_patterns:
|
23
|
+
if os.path.exists(filename):
|
24
|
+
try:
|
25
|
+
with open(filename, "r") as f:
|
26
|
+
content = f.read()
|
27
|
+
|
28
|
+
if "from fastapi import" in content or "import fastapi" in content:
|
29
|
+
framework = "FastAPI"
|
30
|
+
elif "from flask import" in content or "import flask" in content:
|
31
|
+
framework = "Flask"
|
32
|
+
else:
|
33
|
+
continue
|
34
|
+
|
35
|
+
for attr_name in attr_names:
|
36
|
+
if f"{attr_name} = " in content:
|
37
|
+
return framework, filename, attr_name
|
38
|
+
|
39
|
+
except Exception:
|
40
|
+
continue
|
41
|
+
|
42
|
+
return None
|
43
|
+
|
44
|
+
|
45
|
+
def generate_conftest_content(framework: str, file_path: str, app_variable: str) -> str:
|
46
|
+
"""Generate conftest.py content based on detected framework."""
|
47
|
+
module_name = file_path[:-3] # Remove .py
|
48
|
+
|
49
|
+
return f'''"""conftest.py - Auto-generated by pytest-api-cov init"""
|
50
|
+
|
51
|
+
import pytest
|
52
|
+
|
53
|
+
# Import your {framework} app
|
54
|
+
from {module_name} import {app_variable}
|
55
|
+
|
56
|
+
|
57
|
+
@pytest.fixture
|
58
|
+
def app():
|
59
|
+
"""Provide the {framework} app for API coverage testing."""
|
60
|
+
return {app_variable}
|
61
|
+
'''
|
62
|
+
|
63
|
+
|
64
|
+
def generate_pyproject_config(framework: str) -> str:
|
65
|
+
"""Generate pyproject.toml configuration section."""
|
66
|
+
return """
|
67
|
+
# pytest-api-cov configuration
|
68
|
+
[tool.pytest_api_cov]
|
69
|
+
# Fail if coverage is below this percentage (optional)
|
70
|
+
# fail_under = 80.0
|
71
|
+
|
72
|
+
# Show different endpoint types in report
|
73
|
+
show_uncovered_endpoints = true
|
74
|
+
show_covered_endpoints = false
|
75
|
+
show_excluded_endpoints = false
|
76
|
+
|
77
|
+
# Exclude endpoints matching these patterns (optional)
|
78
|
+
# exclusion_patterns = [
|
79
|
+
# "/health",
|
80
|
+
# "/metrics",
|
81
|
+
# "/docs",
|
82
|
+
# ]
|
83
|
+
|
84
|
+
# Save JSON report to file (optional)
|
85
|
+
# report_path = "api_coverage.json"
|
86
|
+
|
87
|
+
# Force Unicode symbols in terminal output (optional)
|
88
|
+
# force_sugar = true
|
89
|
+
"""
|
90
|
+
|
91
|
+
|
92
|
+
def cmd_init() -> int:
|
93
|
+
"""Initialize pytest-api-cov setup in current directory."""
|
94
|
+
print("đ pytest-api-cov Setup Wizard")
|
95
|
+
print("=" * 40)
|
96
|
+
|
97
|
+
detection_result = detect_framework_and_app()
|
98
|
+
|
99
|
+
if detection_result:
|
100
|
+
framework, file_path, app_variable = detection_result
|
101
|
+
print(f"â
Detected {framework} app in {file_path} (variable: {app_variable})")
|
102
|
+
|
103
|
+
conftest_exists = os.path.exists("conftest.py")
|
104
|
+
if conftest_exists:
|
105
|
+
print("â ī¸ conftest.py already exists")
|
106
|
+
create_conftest = input("Do you want to overwrite it? (y/N): ").lower().startswith("y")
|
107
|
+
else:
|
108
|
+
create_conftest = True
|
109
|
+
|
110
|
+
if create_conftest:
|
111
|
+
conftest_content = generate_conftest_content(framework, file_path, app_variable)
|
112
|
+
with open("conftest.py", "w") as f:
|
113
|
+
f.write(conftest_content)
|
114
|
+
print("â
Created conftest.py")
|
115
|
+
|
116
|
+
pyproject_exists = os.path.exists("pyproject.toml")
|
117
|
+
if pyproject_exists:
|
118
|
+
print("âšī¸ pyproject.toml already exists")
|
119
|
+
print("Add this configuration to your pyproject.toml:")
|
120
|
+
print(generate_pyproject_config(framework))
|
121
|
+
else:
|
122
|
+
create_pyproject = input("Create pyproject.toml with pytest-api-cov config? (Y/n): ").lower()
|
123
|
+
if not create_pyproject.startswith("n"):
|
124
|
+
pyproject_content = f"""[project]
|
125
|
+
name = "your-project"
|
126
|
+
version = "0.1.0"
|
127
|
+
|
128
|
+
{generate_pyproject_config(framework)}
|
129
|
+
|
130
|
+
[tool.pytest.ini_options]
|
131
|
+
testpaths = ["tests"]
|
132
|
+
"""
|
133
|
+
with open("pyproject.toml", "w") as f:
|
134
|
+
f.write(pyproject_content)
|
135
|
+
print("â
Created pyproject.toml")
|
136
|
+
|
137
|
+
print()
|
138
|
+
print("đ Setup complete!")
|
139
|
+
print()
|
140
|
+
print("Next steps:")
|
141
|
+
print("1. Write your tests using the 'client' fixture")
|
142
|
+
print("2. Run: pytest --api-cov-report")
|
143
|
+
print()
|
144
|
+
print("Example test:")
|
145
|
+
print("""
|
146
|
+
def test_root_endpoint(client):
|
147
|
+
response = client.get("/")
|
148
|
+
assert response.status_code == 200
|
149
|
+
""")
|
150
|
+
|
151
|
+
else:
|
152
|
+
print("â No FastAPI or Flask app detected in common locations")
|
153
|
+
print()
|
154
|
+
print("Please ensure you have one of these files with a Flask/FastAPI app:")
|
155
|
+
print("âĸ app.py")
|
156
|
+
print("âĸ main.py")
|
157
|
+
print("âĸ server.py")
|
158
|
+
print()
|
159
|
+
print("Example app.py:")
|
160
|
+
print("""
|
161
|
+
from fastapi import FastAPI
|
162
|
+
|
163
|
+
app = FastAPI()
|
164
|
+
|
165
|
+
@app.get("/")
|
166
|
+
def read_root():
|
167
|
+
return {"message": "Hello World"}
|
168
|
+
""")
|
169
|
+
return 1
|
170
|
+
|
171
|
+
return 0
|
172
|
+
|
173
|
+
|
174
|
+
def main() -> int:
|
175
|
+
"""Main CLI entry point."""
|
176
|
+
parser = argparse.ArgumentParser(prog="pytest-api-cov", description="pytest API coverage plugin CLI tools")
|
177
|
+
|
178
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
179
|
+
|
180
|
+
subparsers.add_parser("init", help="Initialize pytest-api-cov setup")
|
181
|
+
|
182
|
+
args = parser.parse_args()
|
183
|
+
|
184
|
+
if args.command == "init":
|
185
|
+
return cmd_init()
|
186
|
+
else:
|
187
|
+
parser.print_help()
|
188
|
+
return 1
|
189
|
+
|
190
|
+
|
191
|
+
if __name__ == "__main__":
|
192
|
+
sys.exit(main())
|
pytest_api_cov/config.py
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
"""Configuration handling for the API coverage report."""
|
2
|
+
|
3
|
+
import sys
|
4
|
+
from typing import Any, Dict, List, Optional
|
5
|
+
|
6
|
+
import tomli
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
8
|
+
|
9
|
+
|
10
|
+
class ApiCoverageReportConfig(BaseModel):
|
11
|
+
"""Configuration model for API coverage reporting."""
|
12
|
+
|
13
|
+
model_config = ConfigDict(populate_by_name=True)
|
14
|
+
|
15
|
+
fail_under: Optional[float] = Field(None, alias="api-cov-fail-under")
|
16
|
+
show_uncovered_endpoints: bool = Field(True, alias="api-cov-show-uncovered-endpoints")
|
17
|
+
show_covered_endpoints: bool = Field(False, alias="api-cov-show-covered-endpoints")
|
18
|
+
show_excluded_endpoints: bool = Field(False, alias="api-cov-show-excluded-endpoints")
|
19
|
+
exclusion_patterns: List[str] = Field([], alias="api-cov-exclusion-patterns")
|
20
|
+
report_path: Optional[str] = Field(None, alias="api-cov-report-path")
|
21
|
+
force_sugar: bool = Field(False, alias="api-cov-force-sugar")
|
22
|
+
force_sugar_disabled: bool = Field(False, alias="api-cov-force-sugar-disabled")
|
23
|
+
|
24
|
+
|
25
|
+
def read_toml_config() -> Dict[str, Any]:
|
26
|
+
"""Read the [tool.pytest_api_cov] section from pyproject.toml."""
|
27
|
+
try:
|
28
|
+
with open("pyproject.toml", "rb") as f:
|
29
|
+
toml_config = tomli.load(f)
|
30
|
+
return toml_config.get("tool", {}).get("pytest_api_cov", {}) # type: ignore[no-any-return]
|
31
|
+
except (FileNotFoundError, tomli.TOMLDecodeError):
|
32
|
+
return {}
|
33
|
+
|
34
|
+
|
35
|
+
def read_session_config(session_config: Any) -> Dict[str, Any]:
|
36
|
+
"""Read configuration from pytest session config (command-line flags)."""
|
37
|
+
cli_options = {
|
38
|
+
"api-cov-fail-under": "fail_under",
|
39
|
+
"api-cov-show-uncovered-endpoints": "show_uncovered_endpoints",
|
40
|
+
"api-cov-show-covered-endpoints": "show_covered_endpoints",
|
41
|
+
"api-cov-show-excluded-endpoints": "show_excluded_endpoints",
|
42
|
+
"api-cov-exclusion-patterns": "exclusion_patterns",
|
43
|
+
"api-cov-report-path": "report_path",
|
44
|
+
"api-cov-force-sugar": "force_sugar",
|
45
|
+
"api-cov-force-sugar-disabled": "force_sugar_disabled",
|
46
|
+
}
|
47
|
+
config = {}
|
48
|
+
for opt, key in cli_options.items():
|
49
|
+
value = session_config.getoption(f"--{opt}")
|
50
|
+
if value is not None and value != [] and value is not False:
|
51
|
+
config[key] = value
|
52
|
+
return config
|
53
|
+
|
54
|
+
|
55
|
+
def supports_unicode() -> bool:
|
56
|
+
"""Check if the environment supports Unicode characters."""
|
57
|
+
if not sys.stdout.isatty():
|
58
|
+
return False
|
59
|
+
return bool(sys.stdout) and sys.stdout.encoding.lower() in ["utf-8", "utf8"]
|
60
|
+
|
61
|
+
|
62
|
+
def get_pytest_api_cov_report_config(session_config: Any) -> ApiCoverageReportConfig:
|
63
|
+
"""
|
64
|
+
Get the final API coverage configuration by merging sources.
|
65
|
+
Priority: CLI > pyproject.toml > Defaults
|
66
|
+
"""
|
67
|
+
toml_config = read_toml_config()
|
68
|
+
cli_config = read_session_config(session_config)
|
69
|
+
|
70
|
+
final_config = {**toml_config, **cli_config}
|
71
|
+
|
72
|
+
if final_config.get("force_sugar_disabled"):
|
73
|
+
final_config["force_sugar"] = False
|
74
|
+
elif "force_sugar" not in final_config:
|
75
|
+
final_config["force_sugar"] = supports_unicode()
|
76
|
+
|
77
|
+
return ApiCoverageReportConfig.model_validate(final_config)
|
@@ -0,0 +1,80 @@
|
|
1
|
+
"""Framework adapters for Flask and FastAPI."""
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Any, List, Optional
|
4
|
+
|
5
|
+
if TYPE_CHECKING:
|
6
|
+
from .models import ApiCallRecorder
|
7
|
+
|
8
|
+
|
9
|
+
class BaseAdapter:
|
10
|
+
def __init__(self, app: Any):
|
11
|
+
self.app = app
|
12
|
+
|
13
|
+
def get_endpoints(self) -> List[str]:
|
14
|
+
"""Return a list of all endpoint paths."""
|
15
|
+
raise NotImplementedError
|
16
|
+
|
17
|
+
def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: str) -> Any:
|
18
|
+
"""Return a patched test client that records calls."""
|
19
|
+
raise NotImplementedError
|
20
|
+
|
21
|
+
|
22
|
+
class FlaskAdapter(BaseAdapter):
|
23
|
+
def get_endpoints(self) -> List[str]:
|
24
|
+
excluded_rules = ("/static/<path:filename>",)
|
25
|
+
return sorted([rule.rule for rule in self.app.url_map.iter_rules() if rule.rule not in excluded_rules])
|
26
|
+
|
27
|
+
def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: str) -> Any:
|
28
|
+
from flask.testing import FlaskClient
|
29
|
+
|
30
|
+
if recorder is None:
|
31
|
+
return self.app.test_client()
|
32
|
+
|
33
|
+
class TrackingFlaskClient(FlaskClient):
|
34
|
+
def open(self, *args: Any, **kwargs: Any) -> Any:
|
35
|
+
path = kwargs.get("path") or (args[0] if args else None)
|
36
|
+
if path and hasattr(self.application.url_map, "bind"):
|
37
|
+
try:
|
38
|
+
endpoint_name, _ = self.application.url_map.bind("").match(path, method=kwargs.get("method"))
|
39
|
+
endpoint_rule_string = next(self.application.url_map.iter_rules(endpoint_name)).rule
|
40
|
+
recorder.record_call(endpoint_rule_string, test_name) # type: ignore[union-attr]
|
41
|
+
except Exception:
|
42
|
+
pass
|
43
|
+
return super().open(*args, **kwargs)
|
44
|
+
|
45
|
+
return TrackingFlaskClient(self.app, self.app.response_class)
|
46
|
+
|
47
|
+
|
48
|
+
class FastAPIAdapter(BaseAdapter):
|
49
|
+
def get_endpoints(self) -> List[str]:
|
50
|
+
from fastapi.routing import APIRoute
|
51
|
+
|
52
|
+
return sorted([route.path for route in self.app.routes if isinstance(route, APIRoute)])
|
53
|
+
|
54
|
+
def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: str) -> Any:
|
55
|
+
from starlette.testclient import TestClient
|
56
|
+
|
57
|
+
if recorder is None:
|
58
|
+
return TestClient(self.app)
|
59
|
+
|
60
|
+
class TrackingFastAPIClient(TestClient):
|
61
|
+
def send(self, *args: Any, **kwargs: Any) -> Any:
|
62
|
+
request = args[0]
|
63
|
+
if recorder is not None:
|
64
|
+
recorder.record_call(request.url.path, test_name)
|
65
|
+
return super().send(*args, **kwargs)
|
66
|
+
|
67
|
+
return TrackingFastAPIClient(self.app)
|
68
|
+
|
69
|
+
|
70
|
+
def get_framework_adapter(app: Any) -> BaseAdapter:
|
71
|
+
"""Detects the framework and returns the appropriate adapter."""
|
72
|
+
app_type = type(app).__name__
|
73
|
+
module_name = getattr(type(app), "__module__", "").split(".")[0]
|
74
|
+
|
75
|
+
if module_name == "flask" and app_type == "Flask":
|
76
|
+
return FlaskAdapter(app)
|
77
|
+
elif module_name == "fastapi" and app_type == "FastAPI":
|
78
|
+
return FastAPIAdapter(app)
|
79
|
+
|
80
|
+
raise TypeError(f"Unsupported application type: {app_type}. pytest-api-coverage supports Flask and FastAPI.")
|
pytest_api_cov/models.py
ADDED
@@ -0,0 +1,122 @@
|
|
1
|
+
"""Data models for pytest-api-cov."""
|
2
|
+
|
3
|
+
from typing import Any, Dict, Iterable, List, Set, Tuple
|
4
|
+
|
5
|
+
from pydantic import BaseModel, Field
|
6
|
+
|
7
|
+
|
8
|
+
class ApiCallRecorder(BaseModel):
|
9
|
+
"""Model for tracking API endpoint calls during testing."""
|
10
|
+
|
11
|
+
model_config = {"arbitrary_types_allowed": True}
|
12
|
+
|
13
|
+
calls: Dict[str, Set[str]] = Field(default_factory=dict)
|
14
|
+
|
15
|
+
def record_call(self, endpoint: str, test_name: str) -> None:
|
16
|
+
"""Record that a test called an endpoint."""
|
17
|
+
if endpoint not in self.calls:
|
18
|
+
self.calls[endpoint] = set()
|
19
|
+
self.calls[endpoint].add(test_name)
|
20
|
+
|
21
|
+
def get_called_endpoints(self) -> List[str]:
|
22
|
+
"""Get list of all endpoints that have been called."""
|
23
|
+
return list(self.calls.keys())
|
24
|
+
|
25
|
+
def get_callers(self, endpoint: str) -> Set[str]:
|
26
|
+
"""Get the set of test names that called a specific endpoint."""
|
27
|
+
return self.calls.get(endpoint, set())
|
28
|
+
|
29
|
+
def merge(self, other: "ApiCallRecorder") -> None:
|
30
|
+
"""Merge another recorder's data into this one."""
|
31
|
+
for endpoint, callers in other.calls.items():
|
32
|
+
if endpoint not in self.calls:
|
33
|
+
self.calls[endpoint] = set()
|
34
|
+
self.calls[endpoint].update(callers)
|
35
|
+
|
36
|
+
def to_serializable(self) -> Dict[str, List[str]]:
|
37
|
+
"""Convert to a serializable format (sets -> lists) for worker communication."""
|
38
|
+
return {endpoint: list(callers) for endpoint, callers in self.calls.items()}
|
39
|
+
|
40
|
+
@classmethod
|
41
|
+
def from_serializable(cls, data: Dict[str, List[str]]) -> "ApiCallRecorder":
|
42
|
+
"""Create from serializable format (lists -> sets)."""
|
43
|
+
calls = {endpoint: set(callers) for endpoint, callers in data.items()}
|
44
|
+
return cls(calls=calls)
|
45
|
+
|
46
|
+
def __len__(self) -> int:
|
47
|
+
"""Return number of endpoints recorded."""
|
48
|
+
return len(self.calls)
|
49
|
+
|
50
|
+
def __contains__(self, endpoint: str) -> bool:
|
51
|
+
"""Check if an endpoint has been recorded."""
|
52
|
+
return endpoint in self.calls
|
53
|
+
|
54
|
+
def items(self) -> Iterable[Tuple[str, Set[str]]]:
|
55
|
+
"""Iterate over endpoint, callers pairs."""
|
56
|
+
return self.calls.items()
|
57
|
+
|
58
|
+
def keys(self) -> Iterable[str]:
|
59
|
+
"""Get all recorded endpoints."""
|
60
|
+
return self.calls.keys()
|
61
|
+
|
62
|
+
def values(self) -> Iterable[Set[str]]:
|
63
|
+
"""Get all caller sets."""
|
64
|
+
return self.calls.values()
|
65
|
+
|
66
|
+
|
67
|
+
class EndpointDiscovery(BaseModel):
|
68
|
+
"""Model for discovered API endpoints."""
|
69
|
+
|
70
|
+
endpoints: List[str] = Field(default_factory=list)
|
71
|
+
discovery_source: str = Field(default="unknown")
|
72
|
+
|
73
|
+
def add_endpoint(self, endpoint: str) -> None:
|
74
|
+
"""Add a discovered endpoint."""
|
75
|
+
if endpoint not in self.endpoints:
|
76
|
+
self.endpoints.append(endpoint)
|
77
|
+
|
78
|
+
def merge(self, other: "EndpointDiscovery") -> None:
|
79
|
+
"""Merge another discovery's endpoints into this one."""
|
80
|
+
for endpoint in other.endpoints:
|
81
|
+
self.add_endpoint(endpoint)
|
82
|
+
|
83
|
+
def __len__(self) -> int:
|
84
|
+
"""Return number of discovered endpoints."""
|
85
|
+
return len(self.endpoints)
|
86
|
+
|
87
|
+
def __iter__(self) -> Iterable[str]: # type: ignore[override]
|
88
|
+
"""Iterate over discovered endpoints."""
|
89
|
+
return iter(self.endpoints)
|
90
|
+
|
91
|
+
|
92
|
+
class SessionData(BaseModel):
|
93
|
+
"""Model for session-level API coverage data."""
|
94
|
+
|
95
|
+
recorder: ApiCallRecorder = Field(default_factory=ApiCallRecorder)
|
96
|
+
discovered_endpoints: EndpointDiscovery = Field(default_factory=EndpointDiscovery)
|
97
|
+
|
98
|
+
def record_call(self, endpoint: str, test_name: str) -> None:
|
99
|
+
"""Record an API call."""
|
100
|
+
self.recorder.record_call(endpoint, test_name)
|
101
|
+
|
102
|
+
def add_discovered_endpoint(self, endpoint: str, source: str = "unknown") -> None:
|
103
|
+
"""Add a discovered endpoint."""
|
104
|
+
if not self.discovered_endpoints.endpoints:
|
105
|
+
self.discovered_endpoints.discovery_source = source
|
106
|
+
self.discovered_endpoints.add_endpoint(endpoint)
|
107
|
+
|
108
|
+
def merge_worker_data(self, worker_recorder: Dict[str, Any], worker_endpoints: List[str]) -> None:
|
109
|
+
"""Merge data from a worker process."""
|
110
|
+
if isinstance(worker_recorder, dict):
|
111
|
+
all_lists = worker_recorder and all(isinstance(v, list) for v in worker_recorder.values())
|
112
|
+
if all_lists:
|
113
|
+
worker_api_recorder = ApiCallRecorder.from_serializable(worker_recorder)
|
114
|
+
else:
|
115
|
+
calls = {k: set(v) if isinstance(v, (list, set)) else {v} for k, v in worker_recorder.items()}
|
116
|
+
worker_api_recorder = ApiCallRecorder(calls=calls)
|
117
|
+
|
118
|
+
self.recorder.merge(worker_api_recorder)
|
119
|
+
|
120
|
+
if worker_endpoints:
|
121
|
+
worker_discovery = EndpointDiscovery(endpoints=worker_endpoints, discovery_source="worker")
|
122
|
+
self.discovered_endpoints.merge(worker_discovery)
|