Skip to content

Commit

Permalink
Add support for handling of RegularExpression definitions for string …
Browse files Browse the repository at this point in the history
…based properties

Signed-off-by: Kostadin Ivanov (BD/TBC-BG) <[email protected]>
  • Loading branch information
Kostadin-Ivanov authored and erikbosch committed Nov 5, 2024
1 parent 8105691 commit 7f57a87
Show file tree
Hide file tree
Showing 11 changed files with 226 additions and 11 deletions.
2 changes: 2 additions & 0 deletions src/vss_tools/exporters/samm/helpers/samm_concepts.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class SammConcepts(Enum):
DESCRIPTION = "description"
ENTITY = "Entity"
EVENTS = "events"
VALUE = "value"
EXAMPLE_VALUE = "exampleValue"
NAME = "name"
OPERATIONS = "operations"
Expand Down Expand Up @@ -92,6 +93,7 @@ class SammCConcepts(Enum):
MIN_VALUE = "minValue"
QUANTIFIABLE = "Quantifiable"
RANGE_CONSTRAINT = "RangeConstraint"
REG_EXP_CONSTRAINT = "RegularExpressionConstraint"
SINGLE_ENTITY = "SingleEntity"
STATE = "State"
TIMESTAMP = "Timestamp"
Expand Down
19 changes: 18 additions & 1 deletion src/vss_tools/exporters/samm/helpers/ttl_builder_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,14 @@ def add_node_leaf_constraint(graph: Graph, node_char_name: str, node_char_uri: U
constraint_name = str_to_uc_first_camel_case(vss_node.ttl_name + "Constraint")
constraint_node_uri = get_vspec_uri(constraint_name)

__add_node_tuple(graph, constraint_node_uri, RDF.type, SammCConcepts.RANGE_CONSTRAINT.uri)
# Default Constraint URI is for Range (min/max) constraints
constraint_uri = SammCConcepts.RANGE_CONSTRAINT.uri

if hasattr(vss_node.data, "pattern") and vss_node.data.pattern is not None:
# Pattern property is used for Regular Expression constraints of STRING based data nodes
constraint_uri = SammCConcepts.REG_EXP_CONSTRAINT.uri

__add_node_tuple(graph, constraint_node_uri, RDF.type, constraint_uri)
__add_node_tuple(graph, constraint_node_uri, SammConcepts.NAME.uri, Literal(constraint_name))

# Workaround since doubles are serialized as scientific numbers
Expand All @@ -354,6 +361,16 @@ def add_node_leaf_constraint(graph: Graph, node_char_name: str, node_char_uri: U
Literal(vss_node.data.min, datatype=data_type), # type: ignore
)

if vss_node.data.pattern is not None: # type: ignore
__add_node_tuple(
graph,
constraint_node_uri,
SammConcepts.VALUE.uri,
Literal(vss_node.data.pattern, datatype=data_type), # type: ignore
)

# Set the RegExp value for constraint_node_uri

base_c_name = str_to_uc_first_camel_case(vss_node.ttl_name + "BaseCharacteristic")
base_c_uri = get_vspec_uri(base_c_name)

Expand Down
14 changes: 4 additions & 10 deletions src/vss_tools/exporters/samm/helpers/vss_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from rdflib import URIRef
from vss_tools import log
from vss_tools.datatypes import Datatypes
from vss_tools.model import NodeType, VSSDataBranch
from vss_tools.model import NodeType, VSSDataBranch, VSSDataDatatype
from vss_tools.tree import VSSNode

from ..config import config as cfg
Expand Down Expand Up @@ -260,15 +260,9 @@ def get_node_description(vss_node: VSSNode) -> str:


def has_constraints(vss_node: VSSNode) -> bool:
return (
hasattr(vss_node.data, "type")
and vss_node.data.type in [NodeType.ACTUATOR, NodeType.SENSOR]
and (
hasattr(vss_node.data, "max")
and vss_node.data.max is not None
or hasattr(vss_node.data, "min")
and vss_node.data.min is not None
)
return bool(
isinstance(vss_node.data, VSSDataDatatype)
and (vss_node.data.max is not None or vss_node.data.min is not None or vss_node.data.pattern is not None)
)


Expand Down
40 changes: 40 additions & 0 deletions src/vss_tools/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ class VSSDataDatatype(VSSData):
arraysize: int | None = None
min: int | float | None = None
max: int | float | None = None
# Field, used to allow definition of Regular Expression constraints
# for string based property nodes.
# Example: VSS - VehicleIdentification.VIN property
pattern: str | None = None
unit: str | None = None
allowed: list[str | int | float | bool] | None = None
default: Any = None
Expand Down Expand Up @@ -311,6 +315,42 @@ def check_datatype_matching_allowed_unit_datatypes(self) -> Self:
), f"'{self.datatype}' is not allowed for unit '{self.unit}'"
return self

@model_validator(mode="after")
def check_datatype_pattern(self) -> Self:
"""
Checks that regular expression datatype 'pattern' field is:
1. defined only for string typed nodes i.e., STRING and STRING_ARRAY
2. if default value(s) is provided, each default matching the specified pattern
3. if allowed value(s) is provided, each allowed matching the specified pattern
"""
if self.pattern:
# Datatypes.TUPLE[0] is the string name of the type.
allowed_for = f"Allowed types: {[Datatypes.STRING[0], Datatypes.STRING_ARRAY[0]]}"
assert Datatypes.get_type(self.datatype) in [
Datatypes.STRING,
Datatypes.STRING_ARRAY,
], f"Field 'pattern' is not allowed for type: '{self.datatype}'. {allowed_for}"

def check_value_match(value_to_check: Any, value_type: str, reg_exp: str) -> None:
check_values = [value_to_check]

if type(value_to_check) is list:
check_values = value_to_check

for def_val in check_values:
assert re.match(
reg_exp, def_val
), f"Specified '{value_type}' value: '{def_val}' must match defined pattern: '{self.pattern}'"

if self.default:
check_value_match(self.default, "default", self.pattern)

if self.allowed:
check_value_match(self.allowed, "allowed", self.pattern)

return self


class VSSDataProperty(VSSDataDatatype):
pass
Expand Down
84 changes: 84 additions & 0 deletions tests/vspec/test_datatypes_pattern/test_datatypes_pattern.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Copyright (c) 2024 Contributors to COVESA
#
# This program and the accompanying materials are made available under the
# terms of the Mozilla Public License 2.0 which is available at
# https://www.mozilla.org/en-US/MPL/2.0/
#
# SPDX-License-Identifier: MPL-2.0

import subprocess
from pathlib import Path

HERE = Path(__file__).resolve().parent
TEST_UNITS = HERE / ".." / "test_units.yaml"
TEST_QUANT = HERE / ".." / "test_quantities.yaml"


# TODO-Kosta:
# Test that:
#
# FAILS when other than string and string[] datatypes have defined pattern
#
# FAILS when pattern is defined and default is defined and default value does not match pattern
# - there should be test for default and allowed values,
# where allowed is mainly for string[] datatypes
#
# Add above three fail tests and 1 success test


def test_datatype_pattern_wrong_type(tmp_path):
spec = HERE / "test_pattern_wrong_datatype.vspec"
output = tmp_path / "out.json"
log = tmp_path / "log.txt"
cmd = (
f"vspec --log-file {log} export json --pretty -u {TEST_UNITS} -q {TEST_QUANT} --vspec {spec} --output {output}"
)
process = subprocess.run(cmd.split())
assert process.returncode != 0

print(process.stdout)
assert "Field 'pattern' is not allowed for type: 'uint16'. Allowed types: ['string', 'string[]']" in log.read_text()


def test_datatype_pattern_no_match(tmp_path):
def get_log_msg(value_type: str, value: str):
return f"Specified '{value_type}' value: '{value}' must match defined pattern: '^[a-z]+$'"

no_pattern_match_tests_to_run = {
# Test #1: test no match of defined DEFAULT value
"test_pattern_no_default_match.vspec": get_log_msg("default", "WrongLabel1"),
# Test #2: test no match of defined DEFAULT list of values
"test_pattern_no_default_array_match.vspec": get_log_msg("default", "Red"),
# Test #3: test no match of defined ALLOWED value
"test_pattern_no_allowed_match.vspec": get_log_msg("allowed", "Red"),
# Test #4: test no match of defined ALLOWED list of values
"test_pattern_no_allowed_array_match.vspec": get_log_msg("allowed", "Red"),
}

def run_no_match_pattern_test(test_name: str, log_message: str):
spec = HERE / test_name
output = tmp_path / "out.json"
log = tmp_path / "log.txt"
cmd = f"vspec --log-file {log} export json --pretty -u {TEST_UNITS} -q {TEST_QUANT}"
cmd += f" --vspec {spec} --output {output} --strict"
process = subprocess.run(cmd.split())

# Tested command should fail
assert process.returncode != 0
# Check logged error message
assert log_message in log.read_text()

# Run no_pattern_match_tests_to_run
for tst_name, tst_msg in no_pattern_match_tests_to_run.items():
run_no_match_pattern_test(tst_name, tst_msg)


def test_datatype_pattern_ok(tmp_path):
# Test #1: test no match of defined DEFAULT value
spec = HERE / "test_pattern_ok.vspec"
output = tmp_path / "out.json"
log = tmp_path / "log.txt"
cmd = f"vspec --log-file {log} export json --pretty -u {TEST_UNITS} -q {TEST_QUANT}"
cmd += f" --vspec {spec} --output {output} --strict"
process = subprocess.run(cmd.split())
assert process.returncode == 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#
A:
type: branch
description: Branch A - used to test no match of 'allowed' values with specified 'pattern' field.

A.Colors:
datatype: string
type: attribute
description: Simple Label for colors, which should be a simple collection of all lower case strings.
Should fail on the 'allowed' 'Red' color label.
pattern: ^[a-z]+$
allowed: [white, green, Red]
default: white
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#
A:
type: branch
description: Branch A - used to test no match of 'allowed' value with specified 'pattern' field.

A.ErrorColors:
datatype: string[]
type: attribute
description: Simple Label for ErrorColors, which should be a simple all lower case string.
Should fail on the 'allowed' (only) 'Red' color label.
pattern: ^[a-z]+$
allowed: [Red]
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#
A:
type: branch
description: Branch A - used to test no match of 'default' values with specified 'pattern' field.

A.Colors:
datatype: string[]
type: attribute
description: Simple Label for colors, which should be a simple collection of all lower case strings.
Should fail on the 'Red' color label.
pattern: ^[a-z]+$
default: [white, green, Red]
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#
A:
type: branch
description: Branch A - used to test no match of 'default' value with specified 'pattern' field.

A.Label:
datatype: string
type: attribute
description: Simple Label, which should be a simple all lower case string.
pattern: ^[a-z]+$
default: WrongLabel1
19 changes: 19 additions & 0 deletions tests/vspec/test_datatypes_pattern/test_pattern_ok.vspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#
A:
type: branch
description: Branch A - used to test correct attribute (property) with defined 'pattern' field.

A.Label:
datatype: string
type: attribute
description: Simple Label, which should be a simple all lower case string with a number.
pattern: ^[a-z0-9]+$
default: label1

A.Colors:
datatype: string[]
type: attribute
description: Simple collection with colors, where each color should be a simple lower case string.
pattern: ^[a-z]+$
allowed: [white, green, red, black, yellow, blue]
default: [white, green, red]
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#
A:
type: branch
description: Branch A - used to test wrong usage of 'pattern' property for other than a string datatype.

A.Year:
datatype: uint16
type: attribute
description: Unsigned Integer (number) for an Year attribute.
Should fail for defined 'pattern' field because 'pattern' is allowed only for string types.
pattern: ^[0-9]+$

0 comments on commit 7f57a87

Please sign in to comment.