This is an automated email from the ASF dual-hosted git repository.
ka94 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git
The following commit(s) were added to refs/heads/main by this push:
new 3c4774392 Test azuredevops plugin (#4812)
3c4774392 is described below
commit 3c47743925c4591fab956502250e875fc8eab591
Author: Camille Teruel <[email protected]>
AuthorDate: Wed Mar 29 22:07:57 2023 +0200
Test azuredevops plugin (#4812)
* fix: Fix leftover reference to connection.pat
* feat: Add support for field aliases
Message field names are serialized by alias.
This allow to have python attributes that are snake_cased that end up being
serialized by alias, typically camelCased.
* feat: Add parentId to RemoteScope
* feat: Add more testing facilities for plugins
Add many assert methods to check a specific plugin implementation is
correct.
* test: Use pydevlake testing facilities to test AzureDevOps plugin
---------
Co-authored-by: Camille Teruel <[email protected]>
---
.../python/plugins/azuredevops/azuredevops/main.py | 14 ++-
.../azuredevops/tests/plugin_test.py} | 24 +++-
.../plugins/azuredevops/tests/streams_test.py | 10 +-
.../pydevlake/pydevlake/domain_layer/code.py | 4 +-
.../pydevlake/pydevlake/domain_layer/devops.py | 4 +-
backend/python/pydevlake/pydevlake/ipc.py | 2 +-
backend/python/pydevlake/pydevlake/message.py | 8 +-
backend/python/pydevlake/pydevlake/model.py | 2 +-
backend/python/pydevlake/pydevlake/plugin.py | 24 ++--
.../python/pydevlake/pydevlake/testing/__init__.py | 8 +-
.../python/pydevlake/pydevlake/testing/testing.py | 131 ++++++++++++++++++++-
.../services/remote/plugin/remote_scope_api.go | 9 +-
backend/test/integration/helper/models.go | 5 +-
.../test/integration/remote/python_plugin_test.go | 2 +
14 files changed, 203 insertions(+), 44 deletions(-)
diff --git a/backend/python/plugins/azuredevops/azuredevops/main.py
b/backend/python/plugins/azuredevops/azuredevops/main.py
index 8648c2bbc..23e0b0751 100644
--- a/backend/python/plugins/azuredevops/azuredevops/main.py
+++ b/backend/python/plugins/azuredevops/azuredevops/main.py
@@ -46,8 +46,7 @@ class AzureDevOpsPlugin(Plugin):
yield Repo(
name=git_repo.name,
url=git_repo.url,
- forked_from=git_repo.parentRepositoryUrl,
- deleted=git_repo.isDisabled,
+ forked_from=git_repo.parentRepositoryUrl
)
yield CicdScope(
@@ -75,8 +74,11 @@ class AzureDevOpsPlugin(Plugin):
api = AzureDevOpsAPI(connection)
for raw_repo in api.git_repos(org, proj):
url = urlparse(raw_repo['remoteUrl'])
- url =
url._replace(netloc=f'{url.username}:{connection.pat}@{url.hostname}')
- repo = GitRepository(**raw_repo, project_id=proj, org_id=org,
url=url.geturl())
+ url =
url._replace(netloc=f'{url.username}:{connection.token}@{url.hostname}')
+ raw_repo['url'] = url.geturl()
+ raw_repo['project_id'] = proj
+ raw_repo['org_id'] = org
+ repo = GitRepository(**raw_repo)
if not repo.defaultBranch:
return None
if "parentRepository" in raw_repo:
@@ -88,13 +90,13 @@ class AzureDevOpsPlugin(Plugin):
if resp.status != 200:
raise Exception(f"Invalid token: {connection.token}")
- def extra_tasks(self, scope: GitRepository, entity_types: list[str],
connection: AzureDevOpsConnection):
+ def extra_tasks(self, scope: GitRepository, tx_rule:
AzureDevOpsTransformationRule, entity_types: list[str], connection:
AzureDevOpsConnection):
if DomainType.CODE in entity_types:
return [gitextractor(scope.url, scope.id, connection.proxy)]
else:
return []
- def extra_stages(self, scope_tx_rule_pairs: list[ScopeTxRulePair],
entity_types: list[str], connection_id: int):
+ def extra_stages(self, scope_tx_rule_pairs: list[ScopeTxRulePair],
entity_types: list[str], _):
if DomainType.CODE in entity_types:
for scope, tx_rule in scope_tx_rule_pairs:
options = tx_rule.refdiff_options if tx_rule else None
diff --git a/backend/python/pydevlake/pydevlake/testing/__init__.py
b/backend/python/plugins/azuredevops/tests/plugin_test.py
similarity index 51%
copy from backend/python/pydevlake/pydevlake/testing/__init__.py
copy to backend/python/plugins/azuredevops/tests/plugin_test.py
index 987641ae5..2351f277f 100644
--- a/backend/python/pydevlake/pydevlake/testing/__init__.py
+++ b/backend/python/plugins/azuredevops/tests/plugin_test.py
@@ -13,7 +13,27 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import os
import pytest
-pytest.register_assert_rewrite('pydevlake.testing')
-from .testing import assert_convert, ContextBuilder
+from pydevlake.testing import assert_valid_plugin, assert_plugin_run
+
+from azuredevops.models import AzureDevOpsConnection,
AzureDevOpsTransformationRule
+from azuredevops.main import AzureDevOpsPlugin
+
+
+def test_valid_plugin():
+ assert_valid_plugin(AzureDevOpsPlugin())
+
+
+def test_valid_plugin_and_connection():
+ # TODO: Set AZURE_DEVOPS_TOKEN env variable in CI
+ token = os.environ.get('AZURE_DEVOPS_TOKEN')
+ if not(token):
+ pytest.skip("No Azure DevOps token provided")
+
+ plugin = AzureDevOpsPlugin()
+ connection = AzureDevOpsConnection(id=1, name='test_connection',
token=token)
+ tx_rule = AzureDevOpsTransformationRule(id=1, name='test_rule')
+
+ assert_plugin_run(plugin, connection, tx_rule)
diff --git a/backend/python/plugins/azuredevops/tests/streams_test.py
b/backend/python/plugins/azuredevops/tests/streams_test.py
index 47fb6cc3f..f76584174 100644
--- a/backend/python/plugins/azuredevops/tests/streams_test.py
+++ b/backend/python/plugins/azuredevops/tests/streams_test.py
@@ -15,7 +15,7 @@
import pytest
-from pydevlake.testing import assert_convert, ContextBuilder
+from pydevlake.testing import assert_stream_convert, ContextBuilder
import pydevlake.domain_layer.code as code
import pydevlake.domain_layer.devops as devops
@@ -133,7 +133,7 @@ def test_builds_stream():
)
]
- assert_convert(AzureDevOpsPlugin, 'builds', raw, expected)
+ assert_stream_convert(AzureDevOpsPlugin, 'builds', raw, expected)
def test_jobs_stream():
@@ -190,7 +190,7 @@ def test_jobs_stream():
environment=devops.CICDEnvironment.PRODUCTION,
cicd_scope_id='johndoe/test-repo'
)
- assert_convert(AzureDevOpsPlugin, 'jobs', raw, expected, ctx)
+ assert_stream_convert(AzureDevOpsPlugin, 'jobs', raw, expected, ctx)
def test_pull_requests_stream():
@@ -290,7 +290,7 @@ def test_pull_requests_stream():
base_commit_sha='4bc26d92b5dbee7837a4d221035a4e2f8df120b2'
)
- assert_convert(AzureDevOpsPlugin, 'gitpullrequests', raw, expected)
+ assert_stream_convert(AzureDevOpsPlugin, 'gitpullrequests', raw, expected)
def test_pull_request_commits_stream():
@@ -316,4 +316,4 @@ def test_pull_request_commits_stream():
pull_request_id="azuredevops:gitpullrequest:1:12345",
)
- assert_convert(AzureDevOpsPlugin, 'gitpullrequestcommits', raw, expected)
+ assert_stream_convert(AzureDevOpsPlugin, 'gitpullrequestcommits', raw,
expected)
diff --git a/backend/python/pydevlake/pydevlake/domain_layer/code.py
b/backend/python/pydevlake/pydevlake/domain_layer/code.py
index 9fca7d256..aa8f1909b 100644
--- a/backend/python/pydevlake/pydevlake/domain_layer/code.py
+++ b/backend/python/pydevlake/pydevlake/domain_layer/code.py
@@ -19,7 +19,7 @@ from typing import Optional
from sqlmodel import Field
-from pydevlake.model import DomainModel, NoPKModel
+from pydevlake.model import DomainModel, DomainScope, NoPKModel
class PullRequest(DomainModel, table=True):
@@ -143,7 +143,7 @@ class RefsPrCherryPick(DomainModel, table=True):
parent_pr_id: str = Field(primary_key=True)
-class Repo(DomainModel, table=True):
+class Repo(DomainScope, table=True):
__tablename__ = "repos"
name: str
url: str
diff --git a/backend/python/pydevlake/pydevlake/domain_layer/devops.py
b/backend/python/pydevlake/pydevlake/domain_layer/devops.py
index 934ac5b2b..f23ddc3db 100644
--- a/backend/python/pydevlake/pydevlake/domain_layer/devops.py
+++ b/backend/python/pydevlake/pydevlake/domain_layer/devops.py
@@ -20,7 +20,7 @@ from enum import Enum
from sqlmodel import Field
-from pydevlake.model import DomainModel, NoPKModel
+from pydevlake.model import DomainModel, NoPKModel, DomainScope
class CICDResult(Enum):
@@ -70,7 +70,7 @@ class CiCDPipelineCommit(NoPKModel, table=True):
repo: str
-class CicdScope(DomainModel):
+class CicdScope(DomainScope):
__tablename__ = 'cicd_scopes'
name: str
description: Optional[str]
diff --git a/backend/python/pydevlake/pydevlake/ipc.py
b/backend/python/pydevlake/pydevlake/ipc.py
index e9542fe97..f28c3af67 100644
--- a/backend/python/pydevlake/pydevlake/ipc.py
+++ b/backend/python/pydevlake/pydevlake/ipc.py
@@ -35,7 +35,7 @@ def plugin_method(func):
def send_output(send_ch: TextIO, obj: object):
if not isinstance(obj, Message):
raise Exception(f"Not a message: {obj}")
- send_ch.write(obj.json(exclude_none=True))
+ send_ch.write(obj.json(exclude_none=True, by_alias=True))
send_ch.write('\n')
send_ch.flush()
diff --git a/backend/python/pydevlake/pydevlake/message.py
b/backend/python/pydevlake/pydevlake/message.py
index 1985ef967..9d2021087 100644
--- a/backend/python/pydevlake/pydevlake/message.py
+++ b/backend/python/pydevlake/pydevlake/message.py
@@ -23,7 +23,8 @@ from pydevlake.model import ToolScope
class Message(BaseModel):
- pass
+ class Config:
+ allow_population_by_field_name = True
class SubtaskMeta(BaseModel):
@@ -84,9 +85,7 @@ class RemoteProgress(Message):
class PipelineTask(Message):
plugin: str
- # Do not snake_case this attribute,
- # it must match the json tag name in PipelineTask go struct
- skipOnFail: bool = False
+ skip_on_fail: bool = Field(default=False, alias="skipOnFail")
subtasks: list[str] = Field(default_factory=list)
options: dict[str, object] = Field(default_factory=dict)
@@ -112,6 +111,7 @@ class RemoteScopeGroup(RemoteScopeTreeNode):
class RemoteScope(RemoteScopeTreeNode):
type: str = Field("scope", const=True)
+ parent_id: str = Field(..., alias="parentId")
scope: ToolScope
diff --git a/backend/python/pydevlake/pydevlake/model.py
b/backend/python/pydevlake/pydevlake/model.py
index 852b62077..7d49bcf67 100644
--- a/backend/python/pydevlake/pydevlake/model.py
+++ b/backend/python/pydevlake/pydevlake/model.py
@@ -124,7 +124,7 @@ class ToolModel(ToolTable, NoPKModel):
class DomainModel(NoPKModel):
- id: str = Field(primary_key=True)
+ id: Optional[str] = Field(primary_key=True)
class ToolScope(ToolModel):
diff --git a/backend/python/pydevlake/pydevlake/plugin.py
b/backend/python/pydevlake/pydevlake/plugin.py
index aba5234ee..c6395fc13 100644
--- a/backend/python/pydevlake/pydevlake/plugin.py
+++ b/backend/python/pydevlake/pydevlake/plugin.py
@@ -108,18 +108,20 @@ class Plugin(ABC):
def make_remote_scopes(self, connection: Connection, group_id:
Optional[str] = None) -> msg.RemoteScopes:
if group_id:
- scopes = [
- msg.RemoteScope(
- id=tool_scope.id,
- name=tool_scope.name,
- scope=tool_scope
+ remote_scopes = []
+ for tool_scope in self.remote_scopes(connection, group_id):
+ tool_scope.connection_id = connection.id
+ remote_scopes.append(
+ msg.RemoteScope(
+ id=tool_scope.id,
+ parent_id=group_id,
+ name=tool_scope.name,
+ scope=tool_scope
+ )
)
- for tool_scope
- in self.remote_scopes(connection, group_id)
- ]
else:
- scopes = self.remote_scope_groups(connection)
- return msg.RemoteScopes(__root__=scopes)
+ remote_scopes = self.remote_scope_groups(connection)
+ return msg.RemoteScopes(__root__=remote_scopes)
def make_pipeline(self, scope_tx_rule_pairs: list[ScopeTxRulePair],
entity_types: list[str], connection: Connection):
@@ -168,7 +170,7 @@ class Plugin(ABC):
return [
msg.PipelineTask(
plugin=self.name,
- skipOnFail=False,
+ skip_on_fail=False,
subtasks=self.select_subtasks(scope, entity_types),
options={
"scopeId": scope.id,
diff --git a/backend/python/pydevlake/pydevlake/testing/__init__.py
b/backend/python/pydevlake/pydevlake/testing/__init__.py
index 987641ae5..01d7fa9e8 100644
--- a/backend/python/pydevlake/pydevlake/testing/__init__.py
+++ b/backend/python/pydevlake/pydevlake/testing/__init__.py
@@ -16,4 +16,10 @@
import pytest
pytest.register_assert_rewrite('pydevlake.testing')
-from .testing import assert_convert, ContextBuilder
+from .testing import (
+ assert_stream_convert,
+ assert_stream_run,
+ assert_valid_plugin,
+ assert_plugin_run,
+ ContextBuilder
+)
diff --git a/backend/python/pydevlake/pydevlake/testing/testing.py
b/backend/python/pydevlake/pydevlake/testing/testing.py
index 02004fb7b..4c36ddb51 100644
--- a/backend/python/pydevlake/pydevlake/testing/testing.py
+++ b/backend/python/pydevlake/pydevlake/testing/testing.py
@@ -15,11 +15,13 @@
import pytest
-from typing import Union, Type, Iterable, Generator
+from typing import Union, Type, Iterable, Generator, Optional
from pydevlake.context import Context
from pydevlake.plugin import Plugin
-from pydevlake.model import DomainModel
+from pydevlake.message import RemoteScopeGroup, PipelineTask
+from pydevlake.model import DomainModel, Connection, DomainScope, ToolModel,
ToolScope, TransformationRule
+from pydevlake.stream import DomainType, Stream
class ContextBuilder:
@@ -52,7 +54,7 @@ class ContextBuilder:
)
-def assert_convert(plugin: Union[Plugin, Type[Plugin]], stream_name: str,
+def assert_stream_convert(plugin: Union[Plugin, Type[Plugin]], stream_name:
str,
raw: dict, expected: Union[DomainModel,
Iterable[DomainModel]],
ctx=None):
if isinstance(plugin, type):
@@ -66,3 +68,126 @@ def assert_convert(plugin: Union[Plugin, Type[Plugin]],
stream_name: str,
domain_models = [domain_models]
for res, exp in zip(domain_models, expected):
assert res == exp
+
+
+def assert_stream_run(stream: Stream, connection: Connection, scope:
ToolScope, transformation_rule: Optional[TransformationRule] = None):
+ """
+ Test that a stream can run all 3 steps without error.
+ """
+ ctx = Context(db_url='sqlite:///:memory:', connection=connection,
scope=scope, transformation_rule=transformation_rule)
+ stream.collector.run(ctx)
+ stream.extractor.run(ctx)
+ stream.convertor.run(ctx)
+
+
+def assert_valid_name(plugin: Plugin):
+ name = plugin.name
+ assert isinstance(name, str), 'name must be a string'
+ assert name.isalnum(), 'name must be alphanumeric'
+
+
+def assert_valid_description(plugin: Plugin):
+ name = plugin.description
+ assert isinstance(name, str), 'description must be a string'
+
+
+def assert_valid_connection_type(plugin: Plugin):
+ connection_type = plugin.connection_type
+ assert issubclass(connection_type, Connection), 'connection_type must be a
subclass of Connection'
+
+
+def assert_valid_tool_scope_type(plugin: Plugin):
+ tool_scope_type = plugin.tool_scope_type
+ assert issubclass(tool_scope_type, ToolScope), 'tool_scope_type must be a
subclass of ToolScope'
+
+
+def assert_valid_transformation_rule_type(plugin: Plugin):
+ transformation_rule_type = plugin.transformation_rule_type
+ assert issubclass(transformation_rule_type, TransformationRule),
'transformation_rule_type must be a subclass of TransformationRule'
+
+
+def assert_valid_streams(plugin: Plugin):
+ streams = plugin.streams
+ assert isinstance(streams, list), 'streams must be a list'
+ assert len(streams) > 0, 'this plugin has no stream'
+ for stream in streams:
+ if isinstance(stream, type):
+ stream = stream(plugin.name)
+ assert isinstance(stream, Stream), 'stream must be a stream class or
instance'
+ assert_valid_stream(stream)
+
+
+def assert_valid_stream(stream: Stream):
+ assert isinstance(stream.name, str), 'name must be a string'
+ assert issubclass(stream.tool_model, ToolModel), 'tool_model must be a
subclass of ToolModel'
+ domain_types = stream.domain_types
+ assert len(domain_types) > 0, 'stream must have at least one domain type'
+ for domain_type in domain_types:
+ assert isinstance(domain_type, DomainType), 'domain type must be a
DomainType'
+
+
+def assert_valid_connection(plugin: Plugin, connection: Connection):
+ try:
+ plugin.test_connection(connection)
+ except Exception as e:
+ pytest.fail(f'Connection is not valid: {e}')
+
+
+def assert_valid_domain_scopes(plugin: Plugin, tool_scope: ToolScope) ->
list[DomainScope]:
+ domain_scopes = list(plugin.domain_scopes(tool_scope))
+ assert len(domain_scopes) > 0, 'No domain scope generated for given tool
scope'
+ for domain_scope in domain_scopes:
+ assert isinstance(domain_scope, DomainScope), 'Domain scope must be a
DomainScope'
+ return domain_scopes
+
+
+def assert_valid_remote_scope_groups(plugin: Plugin, connection: Connection)
-> list[RemoteScopeGroup]:
+ scope_groups = list(plugin.remote_scope_groups(connection))
+ assert len(scope_groups) > 0, 'This connection has no scope groups'
+ for scope_group in scope_groups:
+ assert isinstance(scope_group, RemoteScopeGroup), 'Scope group must be
a RemoteScopeGroup'
+ assert scope_group.id is not None, 'Scope group id must not be None'
+ assert bool(scope_group.name), 'Scope group name must not be empty'
+ return scope_groups
+
+
+def assert_valid_remote_scopes(plugin: Plugin, connection: Connection,
group_id: str) -> list[ToolScope]:
+ tool_scopes = list(plugin.remote_scopes(connection, group_id))
+ assert len(tool_scopes) > 0, 'This connection has no scopes'
+ for tool_scope in tool_scopes:
+ assert isinstance(tool_scope, ToolScope), 'Remote scope must be a
ToolScope'
+ return tool_scopes
+
+
+def assert_valid_pipeline_plan(plugin: Plugin, connection: Connection,
tool_scope: ToolScope, transformation_rule: Optional[TransformationRule] =
None) -> list[list[PipelineTask]]:
+ plan = plugin.make_pipeline_plan(
+ [(tool_scope, transformation_rule)],
+ [domain_type.value for domain_type in DomainType],
+ connection
+ )
+ assert len(plan) > 0, 'Pipeline plan has no stage'
+ for stage in plan:
+ assert len(stage) > 0, 'Pipeline stage has no task'
+ return plan
+
+
+def assert_valid_plugin(plugin: Plugin):
+ assert_valid_name(plugin)
+ assert_valid_description(plugin)
+ assert_valid_connection_type(plugin)
+ assert_valid_tool_scope_type(plugin)
+ assert_valid_transformation_rule_type(plugin)
+ assert_valid_streams(plugin)
+
+
+def assert_plugin_run(plugin: Plugin, connection: Connection,
transformation_rule: Optional[TransformationRule] = None):
+ assert_valid_plugin(plugin)
+ assert_valid_connection(plugin, connection)
+ groups = assert_valid_remote_scope_groups(plugin, connection)
+ scope = assert_valid_remote_scopes(plugin, connection, groups[0].id)[0]
+ assert_valid_domain_scopes(plugin, scope)
+ assert_valid_pipeline_plan(plugin, connection, scope, transformation_rule)
+ for stream in plugin.streams:
+ if isinstance(stream, type):
+ stream = stream(plugin.name)
+ assert_stream_run(stream, connection, scope, transformation_rule)
diff --git a/backend/server/services/remote/plugin/remote_scope_api.go
b/backend/server/services/remote/plugin/remote_scope_api.go
index 0e5485739..3ae77a907 100644
--- a/backend/server/services/remote/plugin/remote_scope_api.go
+++ b/backend/server/services/remote/plugin/remote_scope_api.go
@@ -31,10 +31,11 @@ type RemoteScopesOutput struct {
}
type RemoteScopesTreeNode struct {
- Type string `json:"type"`
- Id string `json:"id"`
- Name string `json:"name"`
- Data interface{} `json:"data"`
+ Type string `json:"type"`
+ ParentId string `json:"parentId"`
+ Id string `json:"id"`
+ Name string `json:"name"`
+ Data interface{} `json:"data"`
}
func (pa *pluginAPI) GetRemoteScopes(input *plugin.ApiResourceInput)
(*plugin.ApiResourceOutput, errors.Error) {
diff --git a/backend/test/integration/helper/models.go
b/backend/test/integration/helper/models.go
index 29d2f2840..37da37282 100644
--- a/backend/test/integration/helper/models.go
+++ b/backend/test/integration/helper/models.go
@@ -18,9 +18,10 @@ limitations under the License.
package helper
import (
+ "time"
+
"github.com/apache/incubator-devlake/core/plugin"
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
- "time"
)
type (
@@ -50,7 +51,7 @@ type BlueprintV2Config struct {
}
type RemoteScopesChild struct {
Type string `json:"type"`
- ParentId *string `json:"parentId"`
+ ParentId string `json:"parentId"`
Id string `json:"id"`
Name string `json:"name"`
Data interface{} `json:"data"`
diff --git a/backend/test/integration/remote/python_plugin_test.go
b/backend/test/integration/remote/python_plugin_test.go
index 67bf76140..077005c32 100644
--- a/backend/test/integration/remote/python_plugin_test.go
+++ b/backend/test/integration/remote/python_plugin_test.go
@@ -50,6 +50,7 @@ func TestRemoteScopeGroups(t *testing.T) {
scope := scopeGroups[0]
require.Equal(t, "Group 1", scope.Name)
require.Equal(t, "group1", scope.Id)
+ require.Equal(t, "", scope.ParentId)
require.Equal(t, "group", scope.Type)
}
@@ -68,6 +69,7 @@ func TestRemoteScopes(t *testing.T) {
scope := scopes[0]
require.Equal(t, "Project 1", scope.Name)
require.Equal(t, "p1", scope.Id)
+ require.Equal(t, "group1", scope.ParentId)
require.Equal(t, "scope", scope.Type)
}