Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More utility functions for the kernelci-core api helper #2378

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ test: \
validate-yaml

mypy:
mypy \
mypy --ignore-missing-imports \
--follow-imports=skip \
--strict-optional \
-m kernelci.api \
-m kernelci.api.latest \
-m kernelci.api.helper
Expand Down
69 changes: 61 additions & 8 deletions kernelci/api/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import json
import requests

from . import API
from . import API, models


def merge(primary: dict, secondary: dict):
Expand Down Expand Up @@ -157,17 +157,30 @@ def create_job_node(self, job_config, input_node,
except requests.exceptions.HTTPError as error:
raise RuntimeError(json.loads(error.response.text)) from error

def submit_regression(self, regression):
"""Post a regression object
def submit_regression(self, fail_node: dict, pass_node: dict):
"""Creates and submits a regression object from two existing
nodes (test or kbuilds) passed as parameters:

[TODO] Leave this function in place in case we'll need any other
processing or formatting before submitting the regression node
Arguments:
fail_node: dict describing the node that failed and triggered
the regression creation.
pass_node: dict describing the previous passing run of the same
test/build

General use case: failure detected, the fail_node and pass_node
are retrieved from the API and passed to this function to define
and submit a regression.

Returns the API request response.
"""
# pylint: disable=protected-access
fail_node_obj = self.get_node_obj(fail_node)
pass_node_obj = self.get_node_obj(pass_node)
regression_dict = models.Regression.create_regression(
fail_node_obj, pass_node_obj, as_dict=True)
try:
return self.api._post('node', regression)
return self.api.node.add(regression_dict)
except requests.exceptions.HTTPError as error:
raise RuntimeError(error.response.text) from error
raise RuntimeError(json.loads(error.response.text)) from error

def _prepare_results(self, results, parent, base):
node = results['node'].copy()
Expand Down Expand Up @@ -246,6 +259,46 @@ def submit_results(self, results, root):
except requests.exceptions.HTTPError as error:
raise RuntimeError(json.loads(error.response.text)) from error

def get_node_obj(self, node_dict, get_linked=False):
JenySadadia marked this conversation as resolved.
Show resolved Hide resolved
"""Takes a dict defining a Node and returns it as a concrete
Node object (or Node subtype object). If get_linked is set to
True, linked nodes are vivified (not recursively).

It will also accept a Node (or subclass) object instead of a
dict. This can be used to fetch and vivify the linked objects if
get_linked is set to True.
"""
# pylint: disable=protected-access
def get_attr(obj, attr):
"""Similar behavior to the builtin getattr, but it can be
used with nested attributes in.dot.notation
"""
fields = attr.split('.')
if len(fields) == 1:
return getattr(obj, fields[0])
return get_attr(getattr(obj, fields[0]), '.'.join(fields[1:]))

def set_attr(obj, attr, value):
"""Similar behavior to the builtin setattr, but it can be
used with nested attributes in.dot.notation
"""
fields = attr.split('.')
if len(fields) == 1:
setattr(obj, fields[0], value)
return
obj = get_attr(obj, '.'.join(fields[:-1]))
setattr(obj, fields[-1], value)

node_obj = models.parse_node_obj(node_dict)
if get_linked:
for linked_node_attr in node_obj._OBJECT_ID_FIELDS:
node_id = get_attr(node_obj, linked_node_attr)
if node_id:
resp = self.api.node.get(node_id)
linked_obj = models.parse_node_obj(resp)
set_attr(node_obj, linked_node_attr, linked_obj)
return node_obj

@classmethod
def load_json(cls, json_path, encoding='utf-8'):
"""Read content from JSON file"""
Expand Down
19 changes: 17 additions & 2 deletions kernelci/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@
description='Type of the object',
const=True
)
data: CheckoutData = Field(

Check failure on line 317 in kernelci/api/models.py

View workflow job for this annotation

GitHub Actions / Lint

Incompatible types in assignment (expression has type "CheckoutData", base class "Node" defined the type as "Optional[Dict[str, Any]]") [assignment]
description="Checkout details"
)

Expand Down Expand Up @@ -365,7 +365,7 @@
description='Type of the object',
const=True
)
data: KbuildData = Field(

Check failure on line 368 in kernelci/api/models.py

View workflow job for this annotation

GitHub Actions / Lint

Incompatible types in assignment (expression has type "KbuildData", base class "Node" defined the type as "Optional[Dict[str, Any]]") [assignment]
description="Kbuild details"
)

Expand Down Expand Up @@ -422,10 +422,16 @@
description='Type of the object',
const=True
)
data: TestData = Field(

Check failure on line 425 in kernelci/api/models.py

View workflow job for this annotation

GitHub Actions / Lint

Incompatible types in assignment (expression has type "TestData", base class "Node" defined the type as "Optional[Dict[str, Any]]") [assignment]
description="Test details"
)

def is_test_suite(self):
"""Returns True if the node represents a test suite, false
otherwise (test case)
"""
return self.name == self.group


class RegressionData(BaseModel):
"""Model for the data field of a Regression node"""
Expand Down Expand Up @@ -460,7 +466,7 @@
description='Type of the object',
const=True
)
data: RegressionData = Field(

Check failure on line 469 in kernelci/api/models.py

View workflow job for this annotation

GitHub Actions / Lint

Incompatible types in assignment (expression has type "RegressionData", base class "Node" defined the type as "Optional[Dict[str, Any]]") [assignment]
description="Regression details"
)

Expand Down Expand Up @@ -566,11 +572,20 @@
)


def parse_node_obj(node: Node):
def parse_node_obj(node: Node | dict):
"""Parses a generic Node object using the appropriate Node submodel
depending on its 'kind'.

If the node is passed as a Node object, it returns the appropriate
submodel object.
If it's passed as a dict, it's converted to the appropriate Node
submodel object.
If it's passed as a concrete Node submodel object, it's returned as
is.
"""
for submodel in type(node).__subclasses__():
if isinstance(node, dict):
node = Node.parse_obj(node)
for submodel in Node.__subclasses__():
if node.kind == submodel.class_kind:
return submodel.parse_obj(node)
raise ValueError(f"Unsupported node kind: {node.kind}")
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ pyyaml==6.0
requests==2.31.0
scp==0.14.5
toml==0.10.2
pymongo-stubs==0.2.0
151 changes: 121 additions & 30 deletions tests/api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,30 +54,115 @@ def __init__(self):
"timeout": "2022-09-28T11:05:25.814000",
"holdoff": None
}
self._regression_node = {
"kind": "regression",
"name": "kver",
self._pass_node = {
"kind": "test",
"name": "login",
"path": [
"checkout",
"kver"
"kbuild-gcc-10-x86",
"baseline-x86",
"login"
],
"group": "kver",
"data": {
"fail_node": "636143c38f94e20c6826b0b6",
"pass_node": "636143c38f94e20c6826b0b5"
},
"parent": "6361440f8f94e20c6826b0b7",
"group": "baseline-x86",
"state": "done",
"result": "pass",
"data": {
"kernel_revision": {
"tree": "kernelci",
"url": "https://github.com/kernelci/linux.git",
"branch": "staging-mainline",
"commit": "7f036eb8d7a5ff2f655c5d949343bac6a2928bce",
"describe": "staging-mainline-20220927.0",
"version": {
"version": 6,
"patchlevel": 0,
"sublevel": None,
"extra": "-rc7-36-g7f036eb8d7a5",
"name": None
}
},
"arch": "x86_64",
"defconfig": "x86_64_defconfig",
"platform": "gcc-10",
},
"artifacts": {
"tarball": "http://staging.kernelci.org:9080/linux-kernelci\
-staging-mainline-staging-mainline-20221101.1.tar.gz"
"tarball": ("http://staging.kernelci.org:9080/linux-kernelci"
"-staging-mainline-staging-mainline-20221101.1.tar.gz")
},
"created": "2022-11-01T16:07:09.770000",
"updated": "2022-11-01T16:07:09.770000",
"timeout": "2022-11-02T16:07:09.770000",
"holdoff": None
}
self._fail_node = {
"kind": "test",
"name": "login",
"path": [
"checkout",
"kbuild-gcc-10-x86",
"baseline-x86",
"login"
],
"group": "baseline-x86",
"state": "done",
"result": "fail",
"data": {
"kernel_revision": {
"tree": "kernelci",
"url": "https://github.com/kernelci/linux.git",
"branch": "staging-mainline",
"commit": "7f036eb8d7a5ff2f655c5d949343bac6a2928bce",
"describe": "staging-mainline-20220927.0",
"version": {
"version": 6,
"patchlevel": 0,
"sublevel": None,
"extra": "-rc7-36-g7f036eb8d7a5",
"name": None
}
},
"arch": "x86_64",
"defconfig": "x86_64_defconfig",
"platform": "gcc-10",
},
"artifacts": {
"tarball": ("http://staging.kernelci.org:9080/linux-kernelci"
"-staging-mainline-staging-mainline-20221101.1.tar.gz")
},
"created": "2022-11-02T16:07:09.770000",
"updated": "2022-11-02T16:07:09.770000",
"timeout": "2022-11-03T16:07:09.770000",
"holdoff": None
}
self._expected_regression_node = {
'kind': 'regression',
'name': 'login',
'path': [
'checkout',
'kbuild-gcc-10-x86',
'baseline-x86',
'login'
],
'group': 'baseline-x86',
'state': 'done',
'data': {
'failed_kernel_revision': {
'tree': 'kernelci',
'url': 'https://github.com/kernelci/linux.git',
'branch': 'staging-mainline',
'commit': '7f036eb8d7a5ff2f655c5d949343bac6a2928bce',
'describe': 'staging-mainline-20220927.0',
'version': {
'version': 6,
'patchlevel': 0,
'extra': '-rc7-36-g7f036eb8d7a5'
}
},
'arch': 'x86_64',
'defconfig': 'x86_64_defconfig',
'platform': 'gcc-10'
}
}
self._kunit_node = {
"id": "6332d92f1a45d41c279e7a06",
"kind": "node",
Expand Down Expand Up @@ -129,9 +214,19 @@ def checkout_node(self):
return self._checkout_node

@property
def regression_node(self):
"""Get the regression node"""
return self._regression_node
def fail_node(self):
"""Get the input fail node for a regression"""
return self._fail_node

@property
def pass_node(self):
"""Get the input pass node for a regression"""
return self._pass_node

@property
def expected_regression_node(self):
"""Get the expected regression node"""
return self._expected_regression_node

@property
def kunit_node(self):
Expand All @@ -143,13 +238,6 @@ def kunit_child_node(self):
"""Get the kunit sample child node"""
return self._kunit_child_node

def get_regression_node_with_id(self):
"""Get regression node with node ID"""
self._regression_node.update({
"id": "6361442d8f94e20c6826b0b9"
})
return self._regression_node

def update_kunit_node(self):
"""Update kunit node with timestamp fields"""
self._kunit_node.update({
Expand Down Expand Up @@ -227,16 +315,19 @@ def mock_api_get_node_from_id(mocker):


@pytest.fixture
def mock_api_post_regression(mocker):
"""Mocks call to LatestAPI class method used to submit regression node"""
resp = Response()
resp.status_code = 200
resp._content = json.dumps( # pylint: disable=protected-access
APIHelperTestData().get_regression_node_with_id()).encode('utf-8')
def mock_api_node_add(mocker):
"""Mocks call to LatestAPI Node add so that it returns the sent node
as a response (echo)
"""
def return_node_response(input_node):
resp = Response()
resp.status_code = 200
resp._content = json.dumps(input_node).encode('utf-8') # pylint: disable=protected-access
return resp

mocker.patch(
'kernelci.api.API._post',
return_value=resp,
'kernelci.api.latest.LatestAPI.Node.add',
side_effect=return_node_response
)


Expand Down
28 changes: 9 additions & 19 deletions tests/api/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,31 +81,21 @@ def test_get_node_from_event(get_api_config, mock_api_get_node_from_id):
}


def test_submit_regression(get_api_config, mock_api_post_regression):
"""Test method to submit regression object to API"""
def test_submit_regression(get_api_config, mock_api_node_add):
"""Tests the regression generation and submission done by
helper.submit_regression()
"""
for _, api_config in get_api_config.items():
api = kernelci.api.get_api(api_config)
helper = kernelci.api.helper.APIHelper(api)
resp = helper.submit_regression(
regression=APIHelperTestData().regression_node
APIHelperTestData().fail_node,
APIHelperTestData().pass_node
)
assert resp.status_code == 200
assert resp.json().keys() == {
'id',
'artifacts',
'created',
'data',
'group',
'holdoff',
'kind',
'name',
'path',
'parent',
'result',
'state',
'timeout',
'updated',
}
created_regression = resp.json()
for field, expected_val in APIHelperTestData().expected_regression_node.items():
assert created_regression[field] == expected_val


def test_pubsub_event_filter_positive(get_api_config, mock_api_subscribe):
Expand Down
Loading