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

Support YAML tags #415

Merged
merged 3 commits into from
Aug 17, 2023
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
- run: sudo apt -y install hlint cppcheck shellcheck checkstyle
- run: curl -sSLO https://github.com/pinterest/ktlint/releases/download/0.48.2/ktlint && chmod a+x ktlint
- run: echo "${GITHUB_WORKSPACE}" >> $GITHUB_PATH
- run: pipenv run pytest -n auto --cov=tested tests/
- run: pipenv run pytest -n auto --cov=tested --cov-report=xml tests/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
lint:
Expand Down
1 change: 0 additions & 1 deletion tested/dsl/ast_translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
from decimal import Decimal
from typing import Literal, Optional, cast, overload

import attrs
from attrs import evolve

from tested.datatypes import (
Expand Down
11 changes: 1 addition & 10 deletions tested/dsl/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,16 +147,7 @@
}
},
"return" : {
"description" : "Expected return value",
"type" : [
"array",
"boolean",
"integer",
"null",
"number",
"object",
"string"
]
"description" : "Expected return value"
},
"return_raw" : {
"description" : "Value string to parse to the expected return value",
Expand Down
118 changes: 95 additions & 23 deletions tested/dsl/translate_parser.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
import json
from decimal import Decimal
from logging import getLogger
from pathlib import Path
from typing import Callable, Dict, List, Optional, TextIO, TypeVar, Union, cast
from typing import Any, Callable, Dict, List, Optional, TextIO, TypeVar, Union, cast

import yaml
from attrs import define
from jsonschema import Draft7Validator

from tested.datatypes import (
AdvancedNumericTypes,
AllTypes,
BasicBooleanTypes,
BasicNothingTypes,
BasicNumericTypes,
BasicObjectTypes,
BasicSequenceTypes,
BasicStringTypes,
BooleanTypes,
NothingTypes,
NumericTypes,
ObjectTypes,
SequenceTypes,
StringTypes,
resolve_to_basic,
)
from tested.dsl.ast_translator import parse_string
from tested.parsing import suite_to_json
from tested.parsing import get_converter, suite_to_json
from tested.serialisation import (
BooleanType,
NothingType,
Expand Down Expand Up @@ -44,7 +56,7 @@
TextOutputChannel,
ValueOutputChannel,
)
from tested.utils import recursive_dict_merge
from tested.utils import get_args, recursive_dict_merge

logger = getLogger(__name__)

Expand All @@ -53,11 +65,33 @@
YamlObject = Union[YamlDict, list, bool, float, int, str, None]


@define
class TestedType:
value: Any
type: str | AllTypes


def custom_type_constructors(loader: yaml.Loader, node: yaml.Node):
tested_tag = node.tag[1:]
if isinstance(node, yaml.MappingNode):
base_result = loader.construct_mapping(node)
elif isinstance(node, yaml.SequenceNode):
base_result = loader.construct_sequence(node)
else:
assert isinstance(node, yaml.ScalarNode)
base_result = loader.construct_scalar(node)
return TestedType(type=tested_tag, value=base_result)


def _parse_yaml(yaml_stream: Union[str, TextIO]) -> YamlObject:
"""
Parse a string or stream to YAML.
"""
return yaml.load(yaml_stream, Loader=yaml.CSafeLoader)
loader: type[yaml.Loader] = cast(type[yaml.Loader], yaml.CSafeLoader)
for types in get_args(AllTypes):
for actual_type in types:
yaml.add_constructor("!" + actual_type, custom_type_constructors, loader)
return yaml.load(yaml_stream, loader)


def _load_schema_validator():
Expand Down Expand Up @@ -113,32 +147,70 @@ def _deepen_config_level(
return recursive_dict_merge(current_level, new_level_object["config"])


def _convert_value(value: YamlObject) -> Value:
if value is None:
return NothingType()
elif isinstance(value, str):
return StringType(type=BasicStringTypes.TEXT, data=value)
elif isinstance(value, bool):
return BooleanType(type=BasicBooleanTypes.BOOLEAN, data=value)
elif isinstance(value, int):
return NumberType(type=BasicNumericTypes.INTEGER, data=value)
elif isinstance(value, float):
return NumberType(type=BasicNumericTypes.REAL, data=value)
elif isinstance(value, list):
def _tested_type_to_value(tested_type: TestedType) -> Value:
type_enum = get_converter().structure(tested_type.type, AllTypes)
if isinstance(type_enum, NumericTypes):
# Some special cases for advanced numeric types.
if type_enum == AdvancedNumericTypes.FIXED_PRECISION:
value = Decimal(tested_type.value)
else:
basic_type = resolve_to_basic(type_enum)
if basic_type == BasicNumericTypes.INTEGER:
value = int(tested_type.value)
elif basic_type == BasicNumericTypes.REAL:
value = float(tested_type.value)
else:
raise ValueError(f"Unknown basic numeric type {type_enum}")
return NumberType(type=type_enum, data=value)
elif isinstance(type_enum, StringTypes):
return StringType(type=type_enum, data=tested_type.value)
elif isinstance(type_enum, BooleanTypes):
return BooleanType(type=type_enum, data=bool(tested_type.value))
elif isinstance(type_enum, NothingTypes):
return NothingType(type=type_enum, data=None)
elif isinstance(type_enum, SequenceTypes):
return SequenceType(
type=BasicSequenceTypes.SEQUENCE,
data=[_convert_value(part_value) for part_value in value],
type=type_enum,
data=[_convert_value(part_value) for part_value in tested_type.value],
)
else:
elif isinstance(type_enum, ObjectTypes):
data = []
for key, val in value.items():
for key, val in tested_type.value.items():
data.append(
ObjectKeyValuePair(
key=StringType(type=BasicStringTypes.TEXT, data=key),
key=_convert_value(key),
value=_convert_value(val),
)
)
return ObjectType(type=BasicObjectTypes.MAP, data=data)
return ObjectType(type=type_enum, data=data)
raise ValueError(f"Unknown type {tested_type.type} with value {tested_type.value}")


def _convert_value(value: YamlObject) -> Value:
if isinstance(value, TestedType):
tested_type = value
else:
# Convert the value into a "TESTed" type.
if value is None:
tested_type = TestedType(value=None, type=BasicNothingTypes.NOTHING)
elif isinstance(value, str):
tested_type = TestedType(value=value, type=BasicStringTypes.TEXT)
elif isinstance(value, bool):
tested_type = TestedType(type=BasicBooleanTypes.BOOLEAN, value=value)
elif isinstance(value, int):
tested_type = TestedType(type=BasicNumericTypes.INTEGER, value=value)
elif isinstance(value, float):
tested_type = TestedType(type=BasicNumericTypes.REAL, value=value)
elif isinstance(value, list):
tested_type = TestedType(type=BasicSequenceTypes.SEQUENCE, value=value)
elif isinstance(value, set):
tested_type = TestedType(type=BasicSequenceTypes.SET, value=value)
elif isinstance(value, dict):
tested_type = TestedType(type=BasicObjectTypes.MAP, value=value)
else:
raise ValueError(f"Unknown type for value {value}.")

return _tested_type_to_value(tested_type)


def _convert_file(link_file: YamlDict) -> FileUrl:
Expand Down Expand Up @@ -350,7 +422,7 @@ def parse_dsl(dsl_string: str, validate: bool = True) -> Suite:
"""
dsl_object = _parse_yaml(dsl_string)
if validate and not _validate_dsl(dsl_object):
raise ValueError("Cannot parse invalid DSL.")
raise ValueError("DSL does not adhere to the JSON schema")
return _convert_dsl(dsl_object)


Expand Down
73 changes: 73 additions & 0 deletions tests/test_dsl_yaml.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from pathlib import Path

import pytest
Expand All @@ -9,6 +10,12 @@
BasicObjectTypes,
BasicSequenceTypes,
BasicStringTypes,
BooleanTypes,
NothingTypes,
NumericTypes,
ObjectTypes,
SequenceTypes,
StringTypes,
)
from tested.dsl import translate_to_test_suite
from tested.serialisation import (
Expand All @@ -27,6 +34,7 @@
ValueOutputChannel,
parse_test_suite,
)
from tested.utils import get_args


def test_parse_one_tab_ctx():
Expand Down Expand Up @@ -693,3 +701,68 @@ def test_value_custom_checks_correct():
],
),
]


def test_yaml_set_tag_is_supported():
yaml_str = """
- tab: 'Test'
contexts:
- testcases:
- statement: 'test()'
return: !!set {5, 6}
"""
json_str = translate_to_test_suite(yaml_str)
suite = parse_test_suite(json_str)
assert len(suite.tabs) == 1
tab = suite.tabs[0]
assert len(tab.contexts) == 1
testcases = tab.contexts[0].testcases
assert len(testcases) == 1
test = testcases[0]
assert isinstance(test.input, FunctionCall)
assert isinstance(test.output.result, ValueOutputChannel)
value = test.output.result.value
assert isinstance(value, SequenceType)
assert value == SequenceType(
type=BasicSequenceTypes.SET,
data=[
NumberType(type=BasicNumericTypes.INTEGER, data=5),
NumberType(type=BasicNumericTypes.INTEGER, data=6),
],
)


@pytest.mark.parametrize(
"all_types,value",
[
(NumericTypes, 5),
(StringTypes, "hallo"),
(BooleanTypes, True),
(NothingTypes, None),
(SequenceTypes, [5, 6]),
(ObjectTypes, {"test": 6}),
],
)
def test_yaml_custom_tags_are_supported(all_types, value):
json_type = json.dumps(value)
for types in get_args(all_types):
for the_type in types:
yaml_str = f"""
- tab: 'Test'
contexts:
- testcases:
- statement: 'test()'
return: !{the_type} {json_type}
"""
json_str = translate_to_test_suite(yaml_str)
suite = parse_test_suite(json_str)
assert len(suite.tabs) == 1
tab = suite.tabs[0]
assert len(tab.contexts) == 1
testcases = tab.contexts[0].testcases
assert len(testcases) == 1
test = testcases[0]
assert isinstance(test.input, FunctionCall)
assert isinstance(test.output.result, ValueOutputChannel)
value = test.output.result.value
assert value.type == the_type