port-ocean 0.5.5__py3-none-any.whl → 0.17.8__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/Dockerfile.Deb +56 -0
- integrations/_infra/Dockerfile.alpine +108 -0
- integrations/_infra/Dockerfile.base.builder +26 -0
- integrations/_infra/Dockerfile.base.runner +13 -0
- integrations/_infra/Dockerfile.dockerignore +94 -0
- {port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}} → integrations/_infra}/Makefile +21 -8
- integrations/_infra/grpcio.sh +18 -0
- integrations/_infra/init.sh +5 -0
- port_ocean/bootstrap.py +1 -1
- port_ocean/cli/commands/defaults/clean.py +3 -1
- port_ocean/cli/commands/new.py +42 -7
- port_ocean/cli/commands/sail.py +7 -1
- port_ocean/cli/cookiecutter/cookiecutter.json +3 -0
- port_ocean/cli/cookiecutter/hooks/post_gen_project.py +20 -3
- port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.env.example +6 -0
- port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.port/resources/blueprints.json +41 -0
- port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.port/resources/port-app-config.yml +16 -0
- port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.port/spec.yaml +6 -7
- port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/CHANGELOG.md +1 -1
- port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/CONTRIBUTING.md +7 -0
- port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/changelog/.gitignore +1 -0
- port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/main.py +16 -1
- port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/pyproject.toml +21 -10
- port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/tests/test_sample.py +2 -0
- port_ocean/clients/port/authentication.py +16 -4
- port_ocean/clients/port/client.py +17 -0
- port_ocean/clients/port/mixins/blueprints.py +7 -8
- port_ocean/clients/port/mixins/entities.py +108 -53
- port_ocean/clients/port/mixins/integrations.py +23 -34
- port_ocean/clients/port/retry_transport.py +0 -5
- port_ocean/clients/port/utils.py +9 -3
- port_ocean/config/base.py +16 -16
- port_ocean/config/dynamic.py +2 -0
- port_ocean/config/settings.py +79 -11
- port_ocean/context/event.py +18 -5
- port_ocean/context/ocean.py +14 -3
- port_ocean/core/defaults/clean.py +10 -3
- port_ocean/core/defaults/common.py +25 -9
- port_ocean/core/defaults/initialize.py +111 -100
- port_ocean/core/event_listener/__init__.py +8 -0
- port_ocean/core/event_listener/base.py +49 -10
- port_ocean/core/event_listener/factory.py +9 -1
- port_ocean/core/event_listener/http.py +11 -3
- port_ocean/core/event_listener/kafka.py +24 -5
- port_ocean/core/event_listener/once.py +96 -4
- port_ocean/core/event_listener/polling.py +16 -14
- port_ocean/core/event_listener/webhooks_only.py +41 -0
- port_ocean/core/handlers/__init__.py +1 -2
- port_ocean/core/handlers/entities_state_applier/base.py +4 -1
- port_ocean/core/handlers/entities_state_applier/port/applier.py +29 -87
- port_ocean/core/handlers/entities_state_applier/port/order_by_entities_dependencies.py +5 -2
- port_ocean/core/handlers/entity_processor/base.py +26 -22
- port_ocean/core/handlers/entity_processor/jq_entity_processor.py +253 -45
- port_ocean/core/handlers/port_app_config/base.py +55 -15
- port_ocean/core/handlers/port_app_config/models.py +24 -5
- port_ocean/core/handlers/resync_state_updater/__init__.py +5 -0
- port_ocean/core/handlers/resync_state_updater/updater.py +84 -0
- port_ocean/core/integrations/base.py +5 -7
- port_ocean/core/integrations/mixins/events.py +3 -1
- port_ocean/core/integrations/mixins/sync.py +4 -2
- port_ocean/core/integrations/mixins/sync_raw.py +209 -74
- port_ocean/core/integrations/mixins/utils.py +1 -1
- port_ocean/core/models.py +44 -0
- port_ocean/core/ocean_types.py +29 -11
- port_ocean/core/utils/entity_topological_sorter.py +90 -0
- port_ocean/core/utils/utils.py +109 -0
- port_ocean/debug_cli.py +5 -0
- port_ocean/exceptions/core.py +4 -0
- port_ocean/exceptions/port_defaults.py +0 -2
- port_ocean/helpers/retry.py +85 -24
- port_ocean/log/handlers.py +23 -2
- port_ocean/log/logger_setup.py +8 -1
- port_ocean/log/sensetive.py +25 -10
- port_ocean/middlewares.py +10 -2
- port_ocean/ocean.py +57 -24
- port_ocean/run.py +10 -5
- port_ocean/tests/__init__.py +0 -0
- port_ocean/tests/clients/port/mixins/test_entities.py +53 -0
- port_ocean/tests/conftest.py +4 -0
- port_ocean/tests/core/defaults/test_common.py +166 -0
- port_ocean/tests/core/handlers/entity_processor/test_jq_entity_processor.py +350 -0
- port_ocean/tests/core/handlers/mixins/test_sync_raw.py +552 -0
- port_ocean/tests/core/test_utils.py +73 -0
- port_ocean/tests/core/utils/test_entity_topological_sorter.py +99 -0
- port_ocean/tests/helpers/__init__.py +0 -0
- port_ocean/tests/helpers/fake_port_api.py +191 -0
- port_ocean/tests/helpers/fixtures.py +46 -0
- port_ocean/tests/helpers/integration.py +31 -0
- port_ocean/tests/helpers/ocean_app.py +66 -0
- port_ocean/tests/helpers/port_client.py +21 -0
- port_ocean/tests/helpers/smoke_test.py +82 -0
- port_ocean/tests/log/test_handlers.py +71 -0
- port_ocean/tests/test_smoke.py +74 -0
- port_ocean/tests/utils/test_async_iterators.py +45 -0
- port_ocean/tests/utils/test_cache.py +189 -0
- port_ocean/utils/async_iterators.py +109 -0
- port_ocean/utils/cache.py +37 -1
- port_ocean/utils/misc.py +22 -4
- port_ocean/utils/queue_utils.py +88 -0
- port_ocean/utils/signal.py +1 -4
- port_ocean/utils/time.py +54 -0
- {port_ocean-0.5.5.dist-info → port_ocean-0.17.8.dist-info}/METADATA +27 -19
- port_ocean-0.17.8.dist-info/RECORD +164 -0
- {port_ocean-0.5.5.dist-info → port_ocean-0.17.8.dist-info}/WHEEL +1 -1
- port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.dockerignore +0 -94
- port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/Dockerfile +0 -15
- port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/config.yaml +0 -17
- port_ocean/core/handlers/entities_state_applier/port/validate_entity_relations.py +0 -40
- port_ocean/core/utils.py +0 -65
- port_ocean-0.5.5.dist-info/RECORD +0 -129
- {port_ocean-0.5.5.dist-info → port_ocean-0.17.8.dist-info}/LICENSE.md +0 -0
- {port_ocean-0.5.5.dist-info → port_ocean-0.17.8.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from port_ocean.core.models import Entity
|
|
2
|
+
from port_ocean.core.utils.entity_topological_sorter import EntityTopologicalSorter
|
|
3
|
+
from unittest.mock import MagicMock
|
|
4
|
+
from port_ocean.exceptions.core import (
|
|
5
|
+
OceanAbortException,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_entity(
|
|
10
|
+
identifier: str, buleprint: str, dependencies: dict[str, str] = {}
|
|
11
|
+
) -> Entity:
|
|
12
|
+
entity = MagicMock()
|
|
13
|
+
entity.identifier = identifier
|
|
14
|
+
entity.blueprint = buleprint
|
|
15
|
+
entity.relations = dependencies or {}
|
|
16
|
+
return entity
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_handle_failed_with_dependencies() -> None:
|
|
20
|
+
# processed_order:list[str] = []
|
|
21
|
+
entity_a = create_entity(
|
|
22
|
+
"entity_a",
|
|
23
|
+
"buleprint_a",
|
|
24
|
+
) # No dependencies
|
|
25
|
+
entity_b = create_entity(
|
|
26
|
+
"entity_b", "buleprint_a", {"dep_name_1": "entity_a"}
|
|
27
|
+
) # Depends on entity_a
|
|
28
|
+
entity_c = create_entity(
|
|
29
|
+
"entity_c", "buleprint_b", {"dep_name_2": "entity_b"}
|
|
30
|
+
) # Depends on entity_b
|
|
31
|
+
|
|
32
|
+
entity_topological_sort = EntityTopologicalSorter()
|
|
33
|
+
# Register fails with unsorted order
|
|
34
|
+
entity_topological_sort.register_entity(entity_c)
|
|
35
|
+
entity_topological_sort.register_entity(entity_a)
|
|
36
|
+
entity_topological_sort.register_entity(entity_b)
|
|
37
|
+
|
|
38
|
+
processed_order = [
|
|
39
|
+
f"{entity.identifier}-{entity.blueprint}"
|
|
40
|
+
for entity in list(entity_topological_sort.get_entities())
|
|
41
|
+
]
|
|
42
|
+
assert processed_order == [
|
|
43
|
+
"entity_a-buleprint_a",
|
|
44
|
+
"entity_b-buleprint_a",
|
|
45
|
+
"entity_c-buleprint_b",
|
|
46
|
+
], f"Processed order: {processed_order}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_handle_failed_with_self_dependencies() -> None:
|
|
50
|
+
entity_a = create_entity(
|
|
51
|
+
"entity_a", "buleprint_a", {"dep_name_1": "entity_a"}
|
|
52
|
+
) # Self dependency
|
|
53
|
+
entity_b = create_entity(
|
|
54
|
+
"entity_b", "buleprint_a", {"dep_name_1": "entity_a"}
|
|
55
|
+
) # Depends on entity_a
|
|
56
|
+
entity_c = create_entity(
|
|
57
|
+
"entity_c", "buleprint_b", {"dep_name_2": "entity_b"}
|
|
58
|
+
) # Depends on entity_b
|
|
59
|
+
|
|
60
|
+
entity_topological_sort = EntityTopologicalSorter()
|
|
61
|
+
|
|
62
|
+
# Register fails with unsorted order
|
|
63
|
+
entity_topological_sort.register_entity(entity_c)
|
|
64
|
+
entity_topological_sort.register_entity(entity_a)
|
|
65
|
+
entity_topological_sort.register_entity(entity_b)
|
|
66
|
+
|
|
67
|
+
processed_order = [
|
|
68
|
+
f"{entity.identifier}-{entity.blueprint}"
|
|
69
|
+
for entity in list(entity_topological_sort.get_entities())
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
assert processed_order == [
|
|
73
|
+
"entity_a-buleprint_a",
|
|
74
|
+
"entity_b-buleprint_a",
|
|
75
|
+
"entity_c-buleprint_b",
|
|
76
|
+
], f"Processed order: {processed_order}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_handle_failed_with_circular_dependencies() -> None:
|
|
80
|
+
# processed_order:list[str] = []
|
|
81
|
+
entity_a = create_entity(
|
|
82
|
+
"entity_a", "buleprint_a", {"dep_name_1": "entity_b"}
|
|
83
|
+
) # Self dependency
|
|
84
|
+
entity_b = create_entity(
|
|
85
|
+
"entity_b", "buleprint_a", {"dep_name_1": "entity_a"}
|
|
86
|
+
) # Depends on entity_a
|
|
87
|
+
|
|
88
|
+
entity_topological_sort = EntityTopologicalSorter()
|
|
89
|
+
try:
|
|
90
|
+
entity_topological_sort.register_entity(entity_a)
|
|
91
|
+
entity_topological_sort.register_entity(entity_b)
|
|
92
|
+
entity_topological_sort.get_entities()
|
|
93
|
+
|
|
94
|
+
except OceanAbortException as e:
|
|
95
|
+
assert isinstance(e, OceanAbortException)
|
|
96
|
+
assert (
|
|
97
|
+
e.args[0]
|
|
98
|
+
== "Cannot order entities due to cyclic dependencies. \nIf you do want to have cyclic dependencies, please make sure to set the keys 'createMissingRelatedEntities' and 'deleteDependentEntities' in the integration config in Port."
|
|
99
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import uvicorn
|
|
2
|
+
import os
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
from fastapi import FastAPI, Request
|
|
5
|
+
|
|
6
|
+
SMOKE_TEST_SUFFIX = os.environ.get("SMOKE_TEST_SUFFIX", "smoke")
|
|
7
|
+
|
|
8
|
+
app = FastAPI()
|
|
9
|
+
|
|
10
|
+
FAKE_DEPARTMENT_BLUEPRINT = {
|
|
11
|
+
"identifier": f"fake-department-{SMOKE_TEST_SUFFIX}",
|
|
12
|
+
"title": "Fake Department",
|
|
13
|
+
"icon": "Blueprint",
|
|
14
|
+
"schema": {"properties": {"name": {"type": "string"}, "id": {"type": "string"}}},
|
|
15
|
+
"relations": {},
|
|
16
|
+
}
|
|
17
|
+
FAKE_PERSON_BLUEPRINT = {
|
|
18
|
+
"identifier": f"fake-person-{SMOKE_TEST_SUFFIX}",
|
|
19
|
+
"title": "Fake Person",
|
|
20
|
+
"icon": "Blueprint",
|
|
21
|
+
"schema": {
|
|
22
|
+
"properties": {
|
|
23
|
+
"status": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"enum": ["WORKING", "NOPE"],
|
|
26
|
+
"enumColors": {"WORKING": "green", "NOPE": "red"},
|
|
27
|
+
"title": "Status",
|
|
28
|
+
},
|
|
29
|
+
"email": {"type": "string", "format": "email", "title": "Email"},
|
|
30
|
+
"age": {"type": "number", "title": "Age"},
|
|
31
|
+
"bio": {"type": "string", "title": "Bio"},
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"relations": {
|
|
35
|
+
"department": {
|
|
36
|
+
"title": "Department",
|
|
37
|
+
"description": "Fake Department",
|
|
38
|
+
"target": f"fake-department-{SMOKE_TEST_SUFFIX}",
|
|
39
|
+
"required": False,
|
|
40
|
+
"many": False,
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.router.get("/v1/blueprints/{blueprint_id}")
|
|
47
|
+
@app.router.patch("/v1/blueprints/{blueprint_id}")
|
|
48
|
+
async def get_blueprint(blueprint_id: str) -> Dict[str, Any]:
|
|
49
|
+
return {
|
|
50
|
+
"blueprint": (
|
|
51
|
+
FAKE_PERSON_BLUEPRINT
|
|
52
|
+
if blueprint_id.startswith("fake-person")
|
|
53
|
+
else FAKE_DEPARTMENT_BLUEPRINT
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.router.post("/v1/entities/search")
|
|
59
|
+
async def search_entities() -> Dict[str, Any]:
|
|
60
|
+
return {"ok": True, "entities": []}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.router.get("/v1/integration/{integration_id}")
|
|
64
|
+
@app.router.patch("/v1/integration/{integration_id}")
|
|
65
|
+
@app.router.patch("/v1/integration/{integration_id}/resync-state")
|
|
66
|
+
async def get_integration(integration_id: str) -> Dict[str, Any]:
|
|
67
|
+
return {
|
|
68
|
+
"integration": {
|
|
69
|
+
"identifer": integration_id,
|
|
70
|
+
"resyncState": {
|
|
71
|
+
"status": "completed",
|
|
72
|
+
"lastResyncEnd": "2024-11-20T12:01:54.225362+00:00",
|
|
73
|
+
"lastResyncStart": "2024-11-20T12:01:45.483844+00:00",
|
|
74
|
+
"nextResync": None,
|
|
75
|
+
"intervalInMinuets": None,
|
|
76
|
+
"updatedAt": "2024-11-20T12:01:54.355Z",
|
|
77
|
+
},
|
|
78
|
+
"config": {
|
|
79
|
+
"deleteDependentEntities": True,
|
|
80
|
+
"createMissingRelatedEntities": True,
|
|
81
|
+
"enableMergeEntity": True,
|
|
82
|
+
"resources": [
|
|
83
|
+
{
|
|
84
|
+
"kind": "fake-department",
|
|
85
|
+
"selector": {"query": "true"},
|
|
86
|
+
"port": {
|
|
87
|
+
"entity": {
|
|
88
|
+
"mappings": {
|
|
89
|
+
"identifier": ".id",
|
|
90
|
+
"title": ".name",
|
|
91
|
+
"blueprint": f'"fake-department-{SMOKE_TEST_SUFFIX}"',
|
|
92
|
+
"properties": {"name": ".name", "id": ".id"},
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"kind": "fake-person",
|
|
99
|
+
"selector": {"query": "true"},
|
|
100
|
+
"port": {
|
|
101
|
+
"entity": {
|
|
102
|
+
"mappings": {
|
|
103
|
+
"identifier": ".id",
|
|
104
|
+
"title": ".name",
|
|
105
|
+
"blueprint": f'"fake-person-{SMOKE_TEST_SUFFIX}"',
|
|
106
|
+
"properties": {
|
|
107
|
+
"name": ".name",
|
|
108
|
+
"email": ".email",
|
|
109
|
+
"status": ".status",
|
|
110
|
+
"age": ".age",
|
|
111
|
+
"department": ".department.name",
|
|
112
|
+
},
|
|
113
|
+
"relations": {"department": ".department.id"},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
"installationType": "OnPrem",
|
|
121
|
+
"_orgId": "org_ZOMGMYUNIQUEID",
|
|
122
|
+
"_id": "integration_0dOOhnlJQDjMPnfe",
|
|
123
|
+
"identifier": f"smoke-test-integration-{SMOKE_TEST_SUFFIX}",
|
|
124
|
+
"integrationType": "smoke-test",
|
|
125
|
+
"createdBy": "APSQAYsYoIwPXqjn6XpwCAgnPakkNO67",
|
|
126
|
+
"updatedBy": "APSQAYsYoIwPXqjn6XpwCAgnPakkNO67",
|
|
127
|
+
"createdAt": "2024-11-20T12:01:42.651Z",
|
|
128
|
+
"updatedAt": "2024-11-20T12:01:54.355Z",
|
|
129
|
+
"clientId": "",
|
|
130
|
+
"logAttributes": {
|
|
131
|
+
"ingestId": "DOHSAIDHOMER",
|
|
132
|
+
"ingestUrl": "http://localhost:5555/logs/integration/DOHSAIDHOMER",
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.router.post("/v1/blueprints/{blueprint_id}/entities")
|
|
139
|
+
async def upsert_entities(blueprint_id: str, request: Request) -> Dict[str, Any]:
|
|
140
|
+
json = await request.json()
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
"ok": True,
|
|
144
|
+
"entity": json,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.router.post("/v1/auth/access_token")
|
|
149
|
+
async def auth_token() -> Dict[str, Any]:
|
|
150
|
+
return {
|
|
151
|
+
"accessToken": "ZOMG",
|
|
152
|
+
"expiresIn": 1232131231,
|
|
153
|
+
"tokenType": "adadad",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.router.delete("/v1/blueprints/{blueprint_id}/all-entities")
|
|
158
|
+
async def delete_blueprint(blueprint_id: str, request: Request) -> Dict[str, Any]:
|
|
159
|
+
return {"migrationId": "ZOMG"}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.router.get("/v1/migrations/{migration_id}")
|
|
163
|
+
async def migration(migration_id: str, request: Request) -> Dict[str, Any]:
|
|
164
|
+
return {
|
|
165
|
+
"migration": {
|
|
166
|
+
"id": migration_id,
|
|
167
|
+
"status": "COMPLETE",
|
|
168
|
+
"actor": "Dwayne Scissors Johnson",
|
|
169
|
+
"sourceBlueprint": "leBlue",
|
|
170
|
+
"mapping": {},
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
CATCH_ALL = "/{full_path:path}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.router.get(CATCH_ALL)
|
|
179
|
+
@app.router.post(CATCH_ALL)
|
|
180
|
+
@app.router.patch(CATCH_ALL)
|
|
181
|
+
@app.router.delete(CATCH_ALL)
|
|
182
|
+
async def catch_all(full_path: str, request: Request) -> str:
|
|
183
|
+
return f"Hello there from fake Port API - {full_path}, thanks for accessing me with {request.method}"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def start() -> None:
|
|
187
|
+
uvicorn.run(app, host="0.0.0.0", port=5555)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
start()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from os import path
|
|
2
|
+
from typing import Any, Callable, Dict, List, Tuple, Union
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import pytest_asyncio
|
|
6
|
+
|
|
7
|
+
from port_ocean.clients.port.client import PortClient
|
|
8
|
+
from port_ocean.core.handlers.port_app_config.models import ResourceConfig
|
|
9
|
+
from port_ocean.ocean import Ocean
|
|
10
|
+
from port_ocean.tests.helpers.ocean_app import (
|
|
11
|
+
get_integation_resource_configs,
|
|
12
|
+
get_integration_ocean_app,
|
|
13
|
+
)
|
|
14
|
+
from port_ocean.tests.helpers.smoke_test import (
|
|
15
|
+
SmokeTestDetails,
|
|
16
|
+
get_port_client_for_fake_integration,
|
|
17
|
+
get_smoke_test_details,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def port_client_for_fake_integration() -> Tuple[SmokeTestDetails, PortClient]:
|
|
23
|
+
smoke_test_details = get_smoke_test_details()
|
|
24
|
+
port_client = get_port_client_for_fake_integration()
|
|
25
|
+
|
|
26
|
+
return smoke_test_details, port_client
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest_asyncio.fixture
|
|
30
|
+
def get_mocked_ocean_app(request: Any) -> Callable[[], Ocean]:
|
|
31
|
+
test_dir = path.join(path.dirname(request.module.__file__), "..")
|
|
32
|
+
|
|
33
|
+
def get_ocean_app(config_overrides: Union[Dict[str, Any], None] = None) -> Ocean:
|
|
34
|
+
return get_integration_ocean_app(test_dir, config_overrides)
|
|
35
|
+
|
|
36
|
+
return get_ocean_app
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest_asyncio.fixture
|
|
40
|
+
def get_mock_ocean_resource_configs(request: Any) -> Callable[[], List[ResourceConfig]]:
|
|
41
|
+
module_dir = path.join(path.dirname(request.module.__file__), "..")
|
|
42
|
+
|
|
43
|
+
def get_ocean_resource_configs() -> List[ResourceConfig]:
|
|
44
|
+
return get_integation_resource_configs(module_dir)
|
|
45
|
+
|
|
46
|
+
return get_ocean_resource_configs
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from loguru import logger
|
|
4
|
+
|
|
5
|
+
from port_ocean.clients.port.client import PortClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def cleanup_integration(client: PortClient, blueprints: List[str]) -> None:
|
|
9
|
+
for blueprint in blueprints:
|
|
10
|
+
try:
|
|
11
|
+
bp = await client.get_blueprint(blueprint)
|
|
12
|
+
if bp is not None:
|
|
13
|
+
migration_id = await client.delete_blueprint(
|
|
14
|
+
identifier=blueprint, delete_entities=True
|
|
15
|
+
)
|
|
16
|
+
if migration_id:
|
|
17
|
+
await client.wait_for_migration_to_complete(
|
|
18
|
+
migration_id=migration_id
|
|
19
|
+
)
|
|
20
|
+
except Exception as bp_e:
|
|
21
|
+
logger.info(f"Skipping missing blueprint ({blueprint}): {bp_e}")
|
|
22
|
+
headers = await client.auth.headers()
|
|
23
|
+
try:
|
|
24
|
+
await client.client.delete(
|
|
25
|
+
f"{client.auth.api_url}/integrations/{client.integration_identifier}",
|
|
26
|
+
headers=headers,
|
|
27
|
+
)
|
|
28
|
+
except Exception as int_e:
|
|
29
|
+
logger.info(
|
|
30
|
+
f"Failed to delete integration ({client.integration_identifier}): {int_e}"
|
|
31
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from inspect import getmembers
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List, Tuple, Union
|
|
5
|
+
|
|
6
|
+
from yaml import safe_load
|
|
7
|
+
|
|
8
|
+
from port_ocean.bootstrap import create_default_app
|
|
9
|
+
from port_ocean.config.dynamic import default_config_factory
|
|
10
|
+
from port_ocean.core.handlers.port_app_config.models import ResourceConfig
|
|
11
|
+
from port_ocean.core.ocean_types import RESYNC_RESULT
|
|
12
|
+
from port_ocean.ocean import Ocean
|
|
13
|
+
from port_ocean.utils.misc import get_spec_file, load_module
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_integration_ocean_app(
|
|
17
|
+
integration_path: str, config_overrides: Union[Dict[str, Any], None] = None
|
|
18
|
+
) -> Ocean:
|
|
19
|
+
spec_file = get_spec_file(Path(integration_path))
|
|
20
|
+
config_factory = None
|
|
21
|
+
if spec_file is not None:
|
|
22
|
+
config_factory = default_config_factory(spec_file.get("configurations", []))
|
|
23
|
+
|
|
24
|
+
default_app = create_default_app(
|
|
25
|
+
integration_path,
|
|
26
|
+
config_factory,
|
|
27
|
+
{
|
|
28
|
+
**(config_overrides or {}),
|
|
29
|
+
**{
|
|
30
|
+
"port": {
|
|
31
|
+
"client_id": "bla",
|
|
32
|
+
"client_secret": "bla",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
main_path = f"{integration_path}/main.py"
|
|
39
|
+
sys.path.append(integration_path)
|
|
40
|
+
app_module = load_module(main_path)
|
|
41
|
+
app: Ocean = {name: item for name, item in getmembers(app_module)}.get(
|
|
42
|
+
"app", default_app
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return app
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_integation_resource_configs(integration_path: str) -> List[ResourceConfig]:
|
|
49
|
+
config_file_path = f"{integration_path}/.port/resources/port-app-config."
|
|
50
|
+
if not Path(f"{config_file_path}yml").exists():
|
|
51
|
+
config_file_path = f"{config_file_path}yaml"
|
|
52
|
+
else:
|
|
53
|
+
config_file_path = f"{config_file_path}yml"
|
|
54
|
+
|
|
55
|
+
with open(config_file_path) as port_app_config_file:
|
|
56
|
+
resource_configs = safe_load(port_app_config_file)
|
|
57
|
+
|
|
58
|
+
return [ResourceConfig(**item) for item in resource_configs["resources"]]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def get_raw_result_on_integration_sync_resource_config(
|
|
62
|
+
app: Ocean, resource_config: ResourceConfig
|
|
63
|
+
) -> Tuple[RESYNC_RESULT, List[Exception]]:
|
|
64
|
+
resource_result = await app.integration._get_resource_raw_results(resource_config)
|
|
65
|
+
|
|
66
|
+
return resource_result
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
|
|
3
|
+
from port_ocean.clients.port.client import PortClient
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_port_client_for_integration(
|
|
7
|
+
client_id: str,
|
|
8
|
+
client_secret: str,
|
|
9
|
+
integration_identifier: str,
|
|
10
|
+
integration_type: str,
|
|
11
|
+
integration_version: str,
|
|
12
|
+
base_url: Union[str, None],
|
|
13
|
+
) -> PortClient:
|
|
14
|
+
return PortClient(
|
|
15
|
+
base_url=base_url or "https://api.getport/io",
|
|
16
|
+
client_id=client_id,
|
|
17
|
+
client_secret=client_secret,
|
|
18
|
+
integration_identifier=integration_identifier,
|
|
19
|
+
integration_type=integration_type,
|
|
20
|
+
integration_version=integration_version,
|
|
21
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from os import environ
|
|
2
|
+
from port_ocean.clients.port.client import PortClient
|
|
3
|
+
|
|
4
|
+
from loguru import logger
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from port_ocean.tests.helpers.integration import cleanup_integration
|
|
8
|
+
from port_ocean.tests.helpers.port_client import get_port_client_for_integration
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SmokeTestDetails(BaseModel):
|
|
12
|
+
integration_identifier: str
|
|
13
|
+
blueprint_department: str
|
|
14
|
+
blueprint_person: str
|
|
15
|
+
integration_type: str
|
|
16
|
+
integration_version: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_smoke_test_details() -> SmokeTestDetails:
|
|
20
|
+
blueprint_department = "fake-department"
|
|
21
|
+
blueprint_person = "fake-person"
|
|
22
|
+
integration_identifier = "smoke-test-integration"
|
|
23
|
+
smoke_test_suffix = environ.get("SMOKE_TEST_SUFFIX")
|
|
24
|
+
if smoke_test_suffix is not None:
|
|
25
|
+
integration_identifier = f"{integration_identifier}-{smoke_test_suffix}"
|
|
26
|
+
blueprint_person = f"{blueprint_person}-{smoke_test_suffix}"
|
|
27
|
+
blueprint_department = f"{blueprint_department}-{smoke_test_suffix}"
|
|
28
|
+
|
|
29
|
+
return SmokeTestDetails(
|
|
30
|
+
integration_identifier=integration_identifier,
|
|
31
|
+
blueprint_person=blueprint_person,
|
|
32
|
+
blueprint_department=blueprint_department,
|
|
33
|
+
integration_version="0.1.4-dev",
|
|
34
|
+
integration_type="smoke-test",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def cleanup_smoke_test() -> None:
|
|
39
|
+
smoke_test_details = get_smoke_test_details()
|
|
40
|
+
client_id = environ.get("PORT_CLIENT_ID")
|
|
41
|
+
client_secret = environ.get("PORT_CLIENT_SECRET")
|
|
42
|
+
|
|
43
|
+
if not client_secret or not client_id:
|
|
44
|
+
assert False, "Missing port credentials"
|
|
45
|
+
|
|
46
|
+
base_url = environ.get("PORT_BASE_URL")
|
|
47
|
+
client = get_port_client_for_integration(
|
|
48
|
+
client_id,
|
|
49
|
+
client_secret,
|
|
50
|
+
smoke_test_details.integration_identifier,
|
|
51
|
+
smoke_test_details.integration_type,
|
|
52
|
+
smoke_test_details.integration_version,
|
|
53
|
+
base_url,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
logger.info("Cleaning up fake integration")
|
|
57
|
+
await cleanup_integration(
|
|
58
|
+
client,
|
|
59
|
+
[smoke_test_details.blueprint_department, smoke_test_details.blueprint_person],
|
|
60
|
+
)
|
|
61
|
+
logger.info("Cleaning up fake integration complete")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_port_client_for_fake_integration() -> PortClient:
|
|
65
|
+
smoke_test_details = get_smoke_test_details()
|
|
66
|
+
client_id = environ.get("PORT_CLIENT_ID")
|
|
67
|
+
client_secret = environ.get("PORT_CLIENT_SECRET")
|
|
68
|
+
|
|
69
|
+
if not client_secret or not client_id:
|
|
70
|
+
assert False, "Missing port credentials"
|
|
71
|
+
|
|
72
|
+
base_url = environ.get("PORT_BASE_URL")
|
|
73
|
+
client = get_port_client_for_integration(
|
|
74
|
+
client_id,
|
|
75
|
+
client_secret,
|
|
76
|
+
smoke_test_details.integration_identifier,
|
|
77
|
+
smoke_test_details.integration_type,
|
|
78
|
+
smoke_test_details.integration_version,
|
|
79
|
+
base_url,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return client
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from port_ocean.log.handlers import _serialize_record
|
|
2
|
+
from loguru import logger
|
|
3
|
+
from logging import LogRecord
|
|
4
|
+
from queue import Queue
|
|
5
|
+
from logging.handlers import QueueHandler
|
|
6
|
+
from typing import Callable, Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
log_message = "This is a test log message."
|
|
10
|
+
exception_grouop_message = "Test Exception group"
|
|
11
|
+
exception_message = "Test Exception"
|
|
12
|
+
expected_keys = ["message", "level", "timestamp", "extra"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_serialize_record_log_shape() -> None:
|
|
16
|
+
record = log_record(
|
|
17
|
+
lambda: logger.exception(
|
|
18
|
+
log_message,
|
|
19
|
+
exc_info=None,
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
serialized_record = _serialize_record(record)
|
|
23
|
+
assert all(key in serialized_record for key in expected_keys)
|
|
24
|
+
assert log_message in serialized_record.get("message", None)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_serialize_record_exc_info_single_exception() -> None:
|
|
28
|
+
record = log_record(
|
|
29
|
+
lambda: logger.exception(
|
|
30
|
+
log_message,
|
|
31
|
+
exc_info=ExceptionGroup(
|
|
32
|
+
exception_grouop_message, [Exception(exception_message)]
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
serialized_record = _serialize_record(record)
|
|
37
|
+
exc_info = assert_extra(serialized_record.get("extra", {}))
|
|
38
|
+
assert exception_grouop_message in exc_info
|
|
39
|
+
assert exception_message in exc_info
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_serialize_record_exc_info_group_exception() -> None:
|
|
43
|
+
record = log_record(
|
|
44
|
+
lambda: logger.exception(log_message, exc_info=Exception(exception_message))
|
|
45
|
+
)
|
|
46
|
+
serialized_record = _serialize_record(record)
|
|
47
|
+
exc_info = assert_extra(serialized_record.get("extra", {}))
|
|
48
|
+
assert exception_message in exc_info
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def assert_extra(extra: dict[str, Any]) -> str:
|
|
52
|
+
exc_info = extra.get("exc_info", None)
|
|
53
|
+
assert isinstance(exc_info, str)
|
|
54
|
+
return exc_info
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def log_record(cb: Callable[[], None]) -> LogRecord:
|
|
58
|
+
queue = Queue[LogRecord]()
|
|
59
|
+
queue_handler = QueueHandler(queue)
|
|
60
|
+
logger_id = logger.add(
|
|
61
|
+
queue_handler,
|
|
62
|
+
level="DEBUG",
|
|
63
|
+
format="{message}",
|
|
64
|
+
diagnose=False,
|
|
65
|
+
enqueue=True,
|
|
66
|
+
)
|
|
67
|
+
cb()
|
|
68
|
+
logger.complete()
|
|
69
|
+
logger.remove(logger_id)
|
|
70
|
+
record = queue.get()
|
|
71
|
+
return record
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from os import environ
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from port_ocean.clients.port.client import PortClient
|
|
6
|
+
from port_ocean.clients.port.types import UserAgentType
|
|
7
|
+
from port_ocean.tests.helpers.smoke_test import SmokeTestDetails
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
pytestmark = pytest.mark.smoke
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.skipif(
|
|
14
|
+
environ.get("SMOKE_TEST_SUFFIX", None) is None,
|
|
15
|
+
reason="You need to run the fake integration once",
|
|
16
|
+
)
|
|
17
|
+
async def test_valid_fake_integration(
|
|
18
|
+
port_client_for_fake_integration: Tuple[SmokeTestDetails, PortClient],
|
|
19
|
+
) -> None:
|
|
20
|
+
_, port_client = port_client_for_fake_integration
|
|
21
|
+
current_integration = await port_client.get_current_integration()
|
|
22
|
+
assert current_integration is not None
|
|
23
|
+
assert current_integration.get("resyncState") is not None
|
|
24
|
+
assert current_integration.get("resyncState", {}).get("status") == "completed"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.skipif(
|
|
28
|
+
environ.get("SMOKE_TEST_SUFFIX", None) is None,
|
|
29
|
+
reason="You need to run the fake integration once",
|
|
30
|
+
)
|
|
31
|
+
async def test_valid_fake_departments(
|
|
32
|
+
port_client_for_fake_integration: Tuple[SmokeTestDetails, PortClient],
|
|
33
|
+
) -> None:
|
|
34
|
+
details, port_client = port_client_for_fake_integration
|
|
35
|
+
entities = await port_client.search_entities(user_agent_type=UserAgentType.exporter)
|
|
36
|
+
assert len(entities)
|
|
37
|
+
departments = [
|
|
38
|
+
x for x in entities if f"{x.blueprint}" == details.blueprint_department
|
|
39
|
+
]
|
|
40
|
+
assert len(departments) == 5
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.skipif(
|
|
44
|
+
environ.get("SMOKE_TEST_SUFFIX", None) is None,
|
|
45
|
+
reason="You need to run the fake integration once",
|
|
46
|
+
)
|
|
47
|
+
async def test_valid_fake_persons(
|
|
48
|
+
port_client_for_fake_integration: Tuple[SmokeTestDetails, PortClient],
|
|
49
|
+
) -> None:
|
|
50
|
+
details, port_client = port_client_for_fake_integration
|
|
51
|
+
headers = await port_client.auth.headers()
|
|
52
|
+
fake_person_entities_result = await port_client.client.get(
|
|
53
|
+
f"{port_client.auth.api_url}/blueprints/{details.blueprint_person}/entities",
|
|
54
|
+
headers=headers,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
fake_person_entities = fake_person_entities_result.json()["entities"]
|
|
58
|
+
assert len(fake_person_entities)
|
|
59
|
+
|
|
60
|
+
fake_departments_result = await port_client.client.get(
|
|
61
|
+
f"{port_client.auth.api_url}/blueprints/{details.blueprint_department}/entities",
|
|
62
|
+
headers=headers,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
departments = [x["identifier"] for x in fake_departments_result.json()["entities"]]
|
|
66
|
+
|
|
67
|
+
for department in departments:
|
|
68
|
+
assert len(
|
|
69
|
+
[
|
|
70
|
+
x
|
|
71
|
+
for x in fake_person_entities
|
|
72
|
+
if x["relations"]["department"] == department
|
|
73
|
+
]
|
|
74
|
+
)
|