port-ocean 0.21.5__py3-none-any.whl → 0.22.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.
Potentially problematic release.
This version of port-ocean might be problematic. Click here for more details.
- integrations/_infra/Makefile +2 -0
- port_ocean/cli/cookiecutter/cookiecutter.json +2 -2
- port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/README.md +1 -1
- port_ocean/clients/port/mixins/entities.py +11 -12
- port_ocean/config/settings.py +22 -0
- port_ocean/context/event.py +1 -0
- port_ocean/context/ocean.py +5 -0
- port_ocean/context/resource.py +3 -4
- port_ocean/core/defaults/common.py +1 -0
- port_ocean/core/defaults/initialize.py +1 -1
- port_ocean/core/event_listener/base.py +6 -3
- port_ocean/core/handlers/entities_state_applier/port/applier.py +23 -21
- port_ocean/core/handlers/entity_processor/base.py +0 -2
- port_ocean/core/handlers/port_app_config/models.py +1 -1
- port_ocean/core/handlers/resync_state_updater/updater.py +9 -0
- port_ocean/core/integrations/mixins/sync_raw.py +61 -10
- port_ocean/core/models.py +6 -2
- port_ocean/core/ocean_types.py +1 -0
- port_ocean/core/utils/utils.py +10 -2
- port_ocean/helpers/metric/metric.py +238 -0
- port_ocean/helpers/metric/utils.py +30 -0
- port_ocean/helpers/retry.py +2 -1
- port_ocean/ocean.py +17 -4
- port_ocean/tests/clients/port/mixins/test_entities.py +12 -9
- port_ocean/tests/core/conftest.py +187 -0
- port_ocean/tests/core/handlers/entities_state_applier/test_applier.py +154 -6
- port_ocean/tests/core/handlers/mixins/test_sync_raw.py +29 -164
- port_ocean/tests/core/utils/test_resolve_entities_diff.py +52 -0
- port_ocean/tests/test_metric.py +180 -0
- port_ocean/utils/async_http.py +4 -1
- {port_ocean-0.21.5.dist-info → port_ocean-0.22.1.dist-info}/METADATA +2 -1
- {port_ocean-0.21.5.dist-info → port_ocean-0.22.1.dist-info}/RECORD +35 -31
- {port_ocean-0.21.5.dist-info → port_ocean-0.22.1.dist-info}/LICENSE.md +0 -0
- {port_ocean-0.21.5.dist-info → port_ocean-0.22.1.dist-info}/WHEEL +0 -0
- {port_ocean-0.21.5.dist-info → port_ocean-0.22.1.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from typing import Any, TYPE_CHECKING, Optional, Dict, List, Tuple
|
|
2
|
+
from fastapi import APIRouter
|
|
3
|
+
from port_ocean.exceptions.context import ResourceContextNotFoundError
|
|
4
|
+
import prometheus_client
|
|
5
|
+
from httpx import AsyncClient
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
from port_ocean.context import resource
|
|
9
|
+
from prometheus_client import Gauge
|
|
10
|
+
import prometheus_client.openmetrics
|
|
11
|
+
import prometheus_client.openmetrics.exposition
|
|
12
|
+
import prometheus_client.parser
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from port_ocean.config.settings import MetricsSettings, IntegrationSettings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MetricPhase:
|
|
19
|
+
EXTRACT = "extract"
|
|
20
|
+
TRANSFORM = "transform"
|
|
21
|
+
LOAD = "load"
|
|
22
|
+
RESYNC = "resync"
|
|
23
|
+
DELETE = "delete"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MetricType:
|
|
27
|
+
# Define metric names as constants
|
|
28
|
+
DURATION_NAME = "duration_seconds"
|
|
29
|
+
OBJECT_COUNT_NAME = "object_count"
|
|
30
|
+
ERROR_COUNT_NAME = "error_count"
|
|
31
|
+
SUCCESS_NAME = "success"
|
|
32
|
+
RATE_LIMIT_WAIT_NAME = "rate_limit_wait_seconds"
|
|
33
|
+
DELETION_COUNT_NAME = "deletion_count"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Registry for core and custom metrics
|
|
37
|
+
_metrics_registry: Dict[str, Tuple[str, str, List[str]]] = {
|
|
38
|
+
MetricType.DURATION_NAME: (
|
|
39
|
+
MetricType.DURATION_NAME,
|
|
40
|
+
"duration description",
|
|
41
|
+
["kind", "phase"],
|
|
42
|
+
),
|
|
43
|
+
MetricType.OBJECT_COUNT_NAME: (
|
|
44
|
+
MetricType.OBJECT_COUNT_NAME,
|
|
45
|
+
"object_count description",
|
|
46
|
+
["kind", "phase"],
|
|
47
|
+
),
|
|
48
|
+
MetricType.ERROR_COUNT_NAME: (
|
|
49
|
+
MetricType.ERROR_COUNT_NAME,
|
|
50
|
+
"error_count description",
|
|
51
|
+
["kind", "phase"],
|
|
52
|
+
),
|
|
53
|
+
MetricType.SUCCESS_NAME: (
|
|
54
|
+
MetricType.SUCCESS_NAME,
|
|
55
|
+
"success description",
|
|
56
|
+
["kind", "phase"],
|
|
57
|
+
),
|
|
58
|
+
MetricType.RATE_LIMIT_WAIT_NAME: (
|
|
59
|
+
MetricType.RATE_LIMIT_WAIT_NAME,
|
|
60
|
+
"rate_limit_wait description",
|
|
61
|
+
["kind", "phase", "endpoint"],
|
|
62
|
+
),
|
|
63
|
+
MetricType.DELETION_COUNT_NAME: (
|
|
64
|
+
MetricType.DELETION_COUNT_NAME,
|
|
65
|
+
"deletion_count description",
|
|
66
|
+
["kind", "phase"],
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def register_metric(name: str, description: str, labels: List[str]) -> None:
|
|
72
|
+
"""Register a custom metric that will be available for use.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
name (str): The metric name to register
|
|
76
|
+
description (str): Description of what the metric measures
|
|
77
|
+
labels (list[str]): Labels to apply to the metric
|
|
78
|
+
"""
|
|
79
|
+
_metrics_registry[name] = (name, description, labels)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class EmptyMetric:
|
|
83
|
+
def set(self, *args: Any) -> None:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def labels(self, *args: Any) -> None:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Metrics:
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
metrics_settings: "MetricsSettings",
|
|
94
|
+
integration_configuration: "IntegrationSettings",
|
|
95
|
+
) -> None:
|
|
96
|
+
self.metrics_settings = metrics_settings
|
|
97
|
+
self.integration_configuration = integration_configuration
|
|
98
|
+
self.registry = prometheus_client.CollectorRegistry()
|
|
99
|
+
self.metrics: dict[str, Gauge] = {}
|
|
100
|
+
self.load_metrics()
|
|
101
|
+
self._integration_version: Optional[str] = None
|
|
102
|
+
self._ocean_version: Optional[str] = None
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def integration_version(self) -> str:
|
|
106
|
+
if self._integration_version is None:
|
|
107
|
+
from port_ocean.version import __integration_version__
|
|
108
|
+
|
|
109
|
+
self._integration_version = __integration_version__
|
|
110
|
+
return self._integration_version
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def ocean_version(self) -> str:
|
|
114
|
+
if self._ocean_version is None:
|
|
115
|
+
from port_ocean.version import __version__
|
|
116
|
+
|
|
117
|
+
self._ocean_version = __version__
|
|
118
|
+
return self._ocean_version
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def enabled(self) -> bool:
|
|
122
|
+
return self.metrics_settings.enabled
|
|
123
|
+
|
|
124
|
+
def load_metrics(self) -> None:
|
|
125
|
+
if not self.enabled:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
# Load all registered metrics
|
|
129
|
+
for name, (_, description, labels) in _metrics_registry.items():
|
|
130
|
+
self.metrics[name] = Gauge(
|
|
131
|
+
name, description, labels, registry=self.registry
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def get_metric(self, name: str, labels: list[str]) -> Gauge | EmptyMetric:
|
|
135
|
+
if not self.enabled:
|
|
136
|
+
return EmptyMetric()
|
|
137
|
+
metrics = self.metrics.get(name)
|
|
138
|
+
if not metrics:
|
|
139
|
+
return EmptyMetric()
|
|
140
|
+
return metrics.labels(*labels)
|
|
141
|
+
|
|
142
|
+
def set_metric(self, name: str, labels: list[str], value: float) -> None:
|
|
143
|
+
"""Set a metric value in a single method call.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
name (str): The metric name to set.
|
|
147
|
+
labels (list[str]): The labels to apply to the metric.
|
|
148
|
+
value (float): The value to set.
|
|
149
|
+
"""
|
|
150
|
+
if not self.enabled:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
self.get_metric(name, labels).set(value)
|
|
154
|
+
|
|
155
|
+
def create_mertic_router(self) -> APIRouter:
|
|
156
|
+
if not self.enabled:
|
|
157
|
+
return APIRouter()
|
|
158
|
+
router = APIRouter()
|
|
159
|
+
|
|
160
|
+
@router.get("/")
|
|
161
|
+
async def prom_metrics() -> str:
|
|
162
|
+
return self.generate_latest()
|
|
163
|
+
|
|
164
|
+
return router
|
|
165
|
+
|
|
166
|
+
def current_resource_kind(self) -> str:
|
|
167
|
+
try:
|
|
168
|
+
return f"{resource.resource.kind}-{resource.resource.index}"
|
|
169
|
+
except ResourceContextNotFoundError:
|
|
170
|
+
return "__runtime__"
|
|
171
|
+
|
|
172
|
+
def generate_latest(self) -> str:
|
|
173
|
+
return prometheus_client.openmetrics.exposition.generate_latest(
|
|
174
|
+
self.registry
|
|
175
|
+
).decode()
|
|
176
|
+
|
|
177
|
+
async def flush(
|
|
178
|
+
self, metric_name: Optional[str] = None, kind: Optional[str] = None
|
|
179
|
+
) -> None:
|
|
180
|
+
if not self.enabled:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
if not self.metrics_settings.webhook_url:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
latest_raw = self.generate_latest()
|
|
188
|
+
metric_families = prometheus_client.parser.text_string_to_metric_families(
|
|
189
|
+
latest_raw
|
|
190
|
+
)
|
|
191
|
+
metrics_dict: dict[str, Any] = {}
|
|
192
|
+
for family in metric_families:
|
|
193
|
+
for sample in family.samples:
|
|
194
|
+
# Skip if a specific metric name was requested and this isn't it
|
|
195
|
+
if metric_name and sample.name != metric_name:
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
current_level = metrics_dict
|
|
199
|
+
if sample.labels:
|
|
200
|
+
# Skip if a specific kind was requested and this isn't it
|
|
201
|
+
if kind and sample.labels.get("kind") != kind:
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
# Create nested dictionary structure based on labels
|
|
205
|
+
for key, value in sample.labels.items():
|
|
206
|
+
if key not in current_level:
|
|
207
|
+
current_level[key] = {}
|
|
208
|
+
current_level = current_level[key]
|
|
209
|
+
if value not in current_level:
|
|
210
|
+
current_level[value] = {}
|
|
211
|
+
current_level = current_level[value]
|
|
212
|
+
|
|
213
|
+
current_level[sample.name] = sample.value
|
|
214
|
+
|
|
215
|
+
# If no metrics were filtered, exit early
|
|
216
|
+
if not metrics_dict.get("kind", {}):
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
for kind_key, metrics in metrics_dict.get("kind", {}).items():
|
|
220
|
+
# Skip if we're filtering by kind and this isn't the requested kind
|
|
221
|
+
if kind and kind_key != kind:
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
event = {
|
|
225
|
+
"integration_type": self.integration_configuration.type,
|
|
226
|
+
"integration_identifier": self.integration_configuration.identifier,
|
|
227
|
+
"integration_version": self.integration_version,
|
|
228
|
+
"ocean_version": self.ocean_version,
|
|
229
|
+
"kind_identifier": kind_key,
|
|
230
|
+
"kind": "-".join(kind_key.split("-")[:-1]),
|
|
231
|
+
"metrics": metrics,
|
|
232
|
+
}
|
|
233
|
+
logger.info(f"Sending metrics to webhook {kind_key}: {event}")
|
|
234
|
+
await AsyncClient().post(
|
|
235
|
+
url=self.metrics_settings.webhook_url, json=event
|
|
236
|
+
)
|
|
237
|
+
except Exception as e:
|
|
238
|
+
logger.error(f"Error sending metrics to webhook: {e}")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from port_ocean.context.ocean import ocean
|
|
6
|
+
from port_ocean.helpers.metric.metric import MetricType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def TimeMetric(phase: str) -> Any:
|
|
10
|
+
def decorator(func: Callable[..., Any]) -> Any:
|
|
11
|
+
|
|
12
|
+
@wraps(func)
|
|
13
|
+
async def wrapper(*args: Any, **kwargs: dict[Any, Any]) -> Any:
|
|
14
|
+
if not ocean.metrics.enabled:
|
|
15
|
+
return await func(*args, **kwargs)
|
|
16
|
+
start = time.monotonic()
|
|
17
|
+
res = await func(*args, **kwargs)
|
|
18
|
+
end = time.monotonic()
|
|
19
|
+
duration = end - start
|
|
20
|
+
ocean.metrics.set_metric(
|
|
21
|
+
name=MetricType.DURATION_NAME,
|
|
22
|
+
labels=[ocean.metrics.current_resource_kind(), phase],
|
|
23
|
+
value=duration,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return res
|
|
27
|
+
|
|
28
|
+
return wrapper
|
|
29
|
+
|
|
30
|
+
return decorator
|
port_ocean/helpers/retry.py
CHANGED
|
@@ -5,7 +5,6 @@ from datetime import datetime
|
|
|
5
5
|
from functools import partial
|
|
6
6
|
from http import HTTPStatus
|
|
7
7
|
from typing import Any, Callable, Coroutine, Iterable, Mapping, Union
|
|
8
|
-
|
|
9
8
|
import httpx
|
|
10
9
|
from dateutil.parser import isoparse
|
|
11
10
|
|
|
@@ -172,6 +171,7 @@ class RetryTransport(httpx.AsyncBaseTransport, httpx.BaseTransport):
|
|
|
172
171
|
response = await self._retry_operation_async(request, send_method)
|
|
173
172
|
else:
|
|
174
173
|
response = await transport.handle_async_request(request)
|
|
174
|
+
|
|
175
175
|
return response
|
|
176
176
|
except Exception as e:
|
|
177
177
|
# Retyable methods are logged via _log_error
|
|
@@ -340,6 +340,7 @@ class RetryTransport(httpx.AsyncBaseTransport, httpx.BaseTransport):
|
|
|
340
340
|
attempts_made = 0
|
|
341
341
|
response: httpx.Response | None = None
|
|
342
342
|
error: Exception | None = None
|
|
343
|
+
|
|
343
344
|
while True:
|
|
344
345
|
if attempts_made > 0:
|
|
345
346
|
sleep_time = self._calculate_sleep(attempts_made, {})
|
port_ocean/ocean.py
CHANGED
|
@@ -4,7 +4,10 @@ from contextlib import asynccontextmanager
|
|
|
4
4
|
import threading
|
|
5
5
|
from typing import Any, AsyncIterator, Callable, Dict, Type
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
import port_ocean.helpers.metric.metric
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, APIRouter
|
|
10
|
+
|
|
8
11
|
from loguru import logger
|
|
9
12
|
from pydantic import BaseModel
|
|
10
13
|
from starlette.types import Receive, Scope, Send
|
|
@@ -49,12 +52,15 @@ class Ocean:
|
|
|
49
52
|
_integration_config_model=config_factory,
|
|
50
53
|
**(config_override or {}),
|
|
51
54
|
)
|
|
52
|
-
|
|
53
55
|
# add the integration sensitive configuration to the sensitive patterns to mask out
|
|
54
56
|
sensitive_log_filter.hide_sensitive_strings(
|
|
55
57
|
*self.config.get_sensitive_fields_data()
|
|
56
58
|
)
|
|
57
59
|
self.integration_router = integration_router or APIRouter()
|
|
60
|
+
self.metrics = port_ocean.helpers.metric.metric.Metrics(
|
|
61
|
+
metrics_settings=self.config.metrics,
|
|
62
|
+
integration_configuration=self.config.integration,
|
|
63
|
+
)
|
|
58
64
|
|
|
59
65
|
self.webhook_manager = LiveEventsProcessorManager(
|
|
60
66
|
self.integration_router,
|
|
@@ -91,8 +97,12 @@ class Ocean:
|
|
|
91
97
|
await self.resync_state_updater.update_before_resync()
|
|
92
98
|
logger.info("Starting a new scheduled resync")
|
|
93
99
|
try:
|
|
94
|
-
await self.integration.sync_raw_all()
|
|
95
|
-
await self.resync_state_updater.update_after_resync(
|
|
100
|
+
successed = await self.integration.sync_raw_all()
|
|
101
|
+
await self.resync_state_updater.update_after_resync(
|
|
102
|
+
IntegrationStateStatus.Completed
|
|
103
|
+
if successed
|
|
104
|
+
else IntegrationStateStatus.Failed
|
|
105
|
+
)
|
|
96
106
|
except asyncio.CancelledError:
|
|
97
107
|
logger.warning(
|
|
98
108
|
"resync was cancelled by the scheduled resync, skipping state update"
|
|
@@ -148,6 +158,9 @@ class Ocean:
|
|
|
148
158
|
|
|
149
159
|
def initialize_app(self) -> None:
|
|
150
160
|
self.fast_api_app.include_router(self.integration_router, prefix="/integration")
|
|
161
|
+
self.fast_api_app.include_router(
|
|
162
|
+
self.metrics.create_mertic_router(), prefix="/metrics"
|
|
163
|
+
)
|
|
151
164
|
|
|
152
165
|
@asynccontextmanager
|
|
153
166
|
async def lifecycle(_: FastAPI) -> AsyncIterator[None]:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from typing import Any
|
|
2
|
-
from unittest.mock import MagicMock
|
|
2
|
+
from unittest.mock import MagicMock, patch
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
5
|
|
|
@@ -37,17 +37,20 @@ async def entity_client(monkeypatch: Any) -> EntityClientMixin:
|
|
|
37
37
|
async def test_batch_upsert_entities_read_timeout_should_raise_false(
|
|
38
38
|
entity_client: EntityClientMixin,
|
|
39
39
|
) -> None:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
with patch("port_ocean.context.event.event", MagicMock()):
|
|
41
|
+
result_entities = await entity_client.batch_upsert_entities(
|
|
42
|
+
entities=all_entities, request_options=MagicMock(), should_raise=False
|
|
43
|
+
)
|
|
44
|
+
entities_only = [entity for _, entity in result_entities]
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
assert entities_only == expected_result_entities
|
|
45
47
|
|
|
46
48
|
|
|
47
49
|
async def test_batch_upsert_entities_read_timeout_should_raise_true(
|
|
48
50
|
entity_client: EntityClientMixin,
|
|
49
51
|
) -> None:
|
|
50
|
-
with
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
with patch("port_ocean.context.event.event", MagicMock()):
|
|
53
|
+
with pytest.raises(ReadTimeout):
|
|
54
|
+
await entity_client.batch_upsert_entities(
|
|
55
|
+
entities=all_entities, request_options=MagicMock(), should_raise=True
|
|
56
|
+
)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
from typing import Any, AsyncGenerator
|
|
3
|
+
from unittest.mock import MagicMock, AsyncMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from httpx import Response
|
|
7
|
+
|
|
8
|
+
from port_ocean.clients.port.client import PortClient
|
|
9
|
+
from port_ocean.context.event import EventContext
|
|
10
|
+
from port_ocean.context.ocean import PortOceanContext, ocean
|
|
11
|
+
from port_ocean.core.handlers.entities_state_applier.port.applier import (
|
|
12
|
+
HttpEntitiesStateApplier,
|
|
13
|
+
)
|
|
14
|
+
from port_ocean.core.handlers.entity_processor.jq_entity_processor import (
|
|
15
|
+
JQEntityProcessor,
|
|
16
|
+
)
|
|
17
|
+
from port_ocean.core.handlers.port_app_config.models import (
|
|
18
|
+
EntityMapping,
|
|
19
|
+
MappingsConfig,
|
|
20
|
+
PortAppConfig,
|
|
21
|
+
PortResourceConfig,
|
|
22
|
+
ResourceConfig,
|
|
23
|
+
Selector,
|
|
24
|
+
)
|
|
25
|
+
from port_ocean.core.models import Entity
|
|
26
|
+
from port_ocean.ocean import Ocean
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def mock_http_client() -> MagicMock:
|
|
31
|
+
mock_http_client = MagicMock()
|
|
32
|
+
mock_upserted_entities = []
|
|
33
|
+
|
|
34
|
+
async def post(url: str, *args: Any, **kwargs: Any) -> Response:
|
|
35
|
+
entity = kwargs.get("json", {})
|
|
36
|
+
if entity.get("properties", {}).get("mock_is_to_fail", {}):
|
|
37
|
+
return Response(
|
|
38
|
+
404, headers=MagicMock(), json={"ok": False, "error": "not_found"}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
mock_upserted_entities.append(
|
|
42
|
+
f"{entity.get('identifier')}-{entity.get('blueprint')}"
|
|
43
|
+
)
|
|
44
|
+
return Response(
|
|
45
|
+
200,
|
|
46
|
+
json={
|
|
47
|
+
"entity": {
|
|
48
|
+
"identifier": entity.get("identifier"),
|
|
49
|
+
"blueprint": entity.get("blueprint"),
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
mock_http_client.post = AsyncMock(side_effect=post)
|
|
55
|
+
return mock_http_client
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture
|
|
59
|
+
def mock_port_client(mock_http_client: MagicMock) -> PortClient:
|
|
60
|
+
mock_port_client = PortClient(
|
|
61
|
+
MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock()
|
|
62
|
+
)
|
|
63
|
+
mock_port_client.auth = AsyncMock()
|
|
64
|
+
mock_port_client.auth.headers = AsyncMock(
|
|
65
|
+
return_value={
|
|
66
|
+
"Authorization": "test",
|
|
67
|
+
"User-Agent": "test",
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
mock_port_client.search_entities = AsyncMock(return_value=[]) # type: ignore
|
|
72
|
+
mock_port_client.client = mock_http_client
|
|
73
|
+
return mock_port_client
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
def mock_ocean(mock_port_client: PortClient) -> Ocean:
|
|
78
|
+
with patch("port_ocean.ocean.Ocean.__init__", return_value=None):
|
|
79
|
+
ocean_mock = Ocean(
|
|
80
|
+
MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock()
|
|
81
|
+
)
|
|
82
|
+
ocean_mock.config = MagicMock()
|
|
83
|
+
ocean_mock.config.port = MagicMock()
|
|
84
|
+
ocean_mock.config.port.port_app_config_cache_ttl = 60
|
|
85
|
+
ocean_mock.port_client = mock_port_client
|
|
86
|
+
ocean_mock.metrics = MagicMock()
|
|
87
|
+
ocean_mock.metrics.flush = AsyncMock()
|
|
88
|
+
|
|
89
|
+
return ocean_mock
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.fixture
|
|
93
|
+
def mock_context(mock_ocean: Ocean) -> PortOceanContext:
|
|
94
|
+
context = PortOceanContext(mock_ocean)
|
|
95
|
+
ocean._app = context.app
|
|
96
|
+
return context
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.fixture
|
|
100
|
+
def mock_port_app_config() -> PortAppConfig:
|
|
101
|
+
return PortAppConfig(
|
|
102
|
+
enable_merge_entity=True,
|
|
103
|
+
delete_dependent_entities=True,
|
|
104
|
+
create_missing_related_entities=False,
|
|
105
|
+
resources=[
|
|
106
|
+
ResourceConfig(
|
|
107
|
+
kind="project",
|
|
108
|
+
selector=Selector(query="true"),
|
|
109
|
+
port=PortResourceConfig(
|
|
110
|
+
entity=MappingsConfig(
|
|
111
|
+
mappings=EntityMapping(
|
|
112
|
+
identifier=".id | tostring",
|
|
113
|
+
title=".name",
|
|
114
|
+
blueprint='"service"',
|
|
115
|
+
properties={"url": ".web_url"},
|
|
116
|
+
relations={},
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.fixture
|
|
126
|
+
def mock_port_app_config_handler(mock_port_app_config: PortAppConfig) -> MagicMock:
|
|
127
|
+
handler = MagicMock()
|
|
128
|
+
|
|
129
|
+
async def get_config(use_cache: bool = True) -> Any:
|
|
130
|
+
return mock_port_app_config
|
|
131
|
+
|
|
132
|
+
handler.get_port_app_config = get_config
|
|
133
|
+
return handler
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@pytest.fixture
|
|
137
|
+
def mock_entity_processor(mock_context: PortOceanContext) -> JQEntityProcessor:
|
|
138
|
+
return JQEntityProcessor(mock_context)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.fixture
|
|
142
|
+
def mock_resource_config() -> ResourceConfig:
|
|
143
|
+
resource = ResourceConfig(
|
|
144
|
+
kind="service",
|
|
145
|
+
selector=Selector(query="true"),
|
|
146
|
+
port=PortResourceConfig(
|
|
147
|
+
entity=MappingsConfig(
|
|
148
|
+
mappings=EntityMapping(
|
|
149
|
+
identifier=".id",
|
|
150
|
+
title=".name",
|
|
151
|
+
blueprint='"service"',
|
|
152
|
+
properties={"url": ".web_url"},
|
|
153
|
+
relations={},
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
return resource
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@pytest.fixture
|
|
162
|
+
def mock_entities_state_applier(
|
|
163
|
+
mock_context: PortOceanContext,
|
|
164
|
+
) -> HttpEntitiesStateApplier:
|
|
165
|
+
return HttpEntitiesStateApplier(mock_context)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@asynccontextmanager
|
|
169
|
+
async def no_op_event_context(
|
|
170
|
+
existing_event: EventContext,
|
|
171
|
+
) -> AsyncGenerator[EventContext, None]:
|
|
172
|
+
yield existing_event
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def create_entity(
|
|
176
|
+
id: str,
|
|
177
|
+
blueprint: str,
|
|
178
|
+
relation: dict[str, str] | None = None,
|
|
179
|
+
is_to_fail: bool = False,
|
|
180
|
+
team: str | list[str] | dict[str, Any] | None = None,
|
|
181
|
+
) -> Entity:
|
|
182
|
+
if relation is None:
|
|
183
|
+
relation = {}
|
|
184
|
+
entity = Entity(identifier=id, blueprint=blueprint, team=team)
|
|
185
|
+
entity.relations = relation
|
|
186
|
+
entity.properties = {"mock_is_to_fail": is_to_fail}
|
|
187
|
+
return entity
|