diff --git a/docs/graphql.md b/docs/graphql.md index 2735c9a2..e334870d 100644 --- a/docs/graphql.md +++ b/docs/graphql.md @@ -1,73 +1,361 @@ # Vspec Graphql Exporter -This exporter allows to automatically generate a graphql schema that can represent VSS data. -The resulting schema does only allow querying information. Mutations are not supported. +This exporter generates a valid GraphQL schema out of the VSS specification. +The schema is constructed according to the [GraphQL Schema Language](https://graphql.org/learn/schema/). -The resulting schema will look something like this: +## VSS to GraphQL Mapping +### VSS elements in a nutshell +VSS resembles a hierarchical tree, where a concept of interest is a node in it. +The actual data point appears always as a `leaf` in the tree (i.e., one of type `attribute`, `sensor`, or `actuator`). +The context for those `leaf` nodes is implicitly captured by grouping them under particular `branch` nodes. +For example, in the following diagram, the window position results in the name `Vehicle.Cabin.Door.Row1.DriverSide.Window.Position`. Here, `Row1.DriverSide` refers to a particular instance of the concept `Door`. +```mermaid +graph TD + Vehicle --> Cabin + Vehicle --> OtherBranch + Cabin --> Door + Door --> Row1 + Row1 --> DriverSide + DriverSide --> Window + Door --> OtherInstance + Window --> Position + Window --> OtherLeaf + OtherBranch --> SomeLeaf + + classDef white fill:#FFFFFF,stroke:#000000,stroke-width:2px,color:#000000; + classDef gray fill:#F5F5F5,stroke:#666666,stroke-width:2px,color:#000000; + + classDef blue fill:#DAE8FC,stroke:#6C8EBF,stroke-width:2px,color:#000000; + + classDef green fill:#D5E8D4,stroke:#82B366,stroke-width:2px,color:#000000; + + classDef orange fill:#FFE6CC,stroke:#D79B00,stroke-width:2px,color:#000000; + + classDef yellow fill:#FFF2CC,stroke:#D6B656,stroke-width:2px,color:#000000; + + classDef red fill:#F8CECC,stroke:#B85450,stroke-width:2px,color:#000000; + + classDef purple fill:#E1D5E7,stroke:#9673A6,stroke-width:2px,color:#000000; + + class Vehicle,Cabin,Door,Window,OtherBranch blue; + class Row1,DriverSide,OtherInstance yellow; + class Position,OtherLeaf,SomeLeaf red; +``` + +### VSS metadata +The following table shows the possible metadata for VSS elements: +| VSS metadata item | Description | Mandatory? | In `branch`? | In `leaf`? | +|--------------|--------------------------------------------|------------|--------------|------------| +| `fqn` | Fully qualified name of the element (aka., VSS `Signal`) | Yes | Yes | Yes | +| `type` | One of `branch`, `attribute`, `sensor`, or `actuator` | Yes | Yes | Yes | +| `description`| Description of the element | Yes | Yes | Yes | +| `comment` | Additional comments about the element | No | Yes | Yes | +| `deprecation`| Deprecation status of the element | No | Yes | Yes | +| `instances` | Labels identifying multiple occurrences | No | Yes | No | +| `datatype` | Data type of the leaf element | Yes | No | Yes | +| `unit` | Unit of measurement for the leaf element | No | No | Yes | +| `min` | Minimum value for the leaf element | No | No | Yes | +| `max` | Maximum value for the leaf element | No | No | Yes | +| `allowed` | Allowed values for the leaf element | No | No | Yes | +| `default` | Default value for the leaf element | No | No | Yes | + + +### Mapping rules for VSS `branch` nodes +* `fqn` + * The `fqn` is used to construct the name of a GraphQL `type`. +* `type: branch` + * We distiguish a pure VSS `branch` from those that are `instances`. The aim is to focus the modeling process only on the abstract concepts that capture the intended meaning without repetition of entries. + * Only if the `branch` is not an instance of another branch, a `GraphQL type` whith name based on the `fqn` is created. +* `description` + * A doc string above the GraphQL `type` definition. +* `comment` + * Included in the doc string with the prefix `@comment`. +* `deprecation` + * Using the GraphQL `@deprecated` directive. +* `instances` + * An `instance:` GraphQL `field` is created inside the `type` definition. This field points to a particular `enum`. + * A GraphQL `enum` named as `InstanceEnum` is created with the values specified in the vspec. + * The parent node to which the instantiatable branch belongs will contain a field for that in the **plural** form. Also, the assotiated type for the value is created as an array to indicate the possibility of having multiple instances of such an element. For example, if the type `Cabin` is expected to have multiple doors, then a field `doors: [Door]` will appear there. + +#### Example in vspec +For example, considering the concepts `Vehicle`, `Cabin`, `Door`, and `Window`. +They are specified in `vspec` as follows: +```yaml +Vehicle: + description: High-level vehicle data. + type: branch + +Vehicle.Cabin: + description: All in-cabin components, including doors. + type: branch + +Vehicle.Cabin.Door: + type: branch + instances: + - Row[1,2] + - ["DriverSide","PassengerSide"] + description: All doors, including windows and switches. + +Vehicle.Cabin.Door.Window: + type: branch + description: Door window status. Start position for Window is Closed. +``` +If we directly expand the tree, it will result in unnecesary repetition of the specification for the instantiated branches. + +```mermaid +graph TD + Vehicle --> Cabin + Cabin --> Door + Door --> Row1.DriverSide + Door --> Row2.DriverSide + Door --> Row1.PassengerSide + Door --> Row2.PassengerSide + + Row1.PassengerSide --> Row1.PassengerSide.Window + Row2.PassengerSide --> Row2.PassengerSide.Window + Row1.DriverSide --> Row1.DriverSide.Window + Row2.DriverSide --> Row2.DriverSide.Window + + + classDef blue fill:#DAE8FC,stroke:#6C8EBF,stroke-width:2px,color:#000000; + + classDef yellow fill:#FFF2CC,stroke:#D6B656,stroke-width:2px,color:#000000; + + class Vehicle,Cabin,Door,Row1.PassengerSide.Window,Row2.PassengerSide.Window,Row1.DriverSide.Window,Row2.DriverSide.Window blue; + class Row1.PassengerSide,Row2.PassengerSide,Row1.DriverSide,Row2.DriverSide yellow; +``` + +
+ +Click here to expand an example YAML export + +#### Example of repeated concepts in the yaml export. + +```yaml +Vehicle: + description: High-level vehicle data. + type: branch + +Vehicle.Cabin: + description: All in-cabin components, including doors. + type: branch + +Vehicle.Cabin.Door: + description: All doors, including windows and switches. + type: branch + +Vehicle.Cabin.Door.Row1: + description: All doors, including windows and switches. + type: branch + +Vehicle.Cabin.Door.Row1.DriverSide: + description: All doors, including windows and switches. + type: branch + +Vehicle.Cabin.Door.Row1.DriverSide.Window: + description: Door window status. Start position for Window is Closed. + type: branch + +Vehicle.Cabin.Door.Row1: + description: All doors, including windows and switches. + type: branch + +Vehicle.Cabin.Door.Row1.DriverSide: + description: All doors, including windows and switches. + type: branch + +Vehicle.Cabin.Door.Row1.DriverSide.Window: + description: Door window status. Start position for Window is Closed. + type: branch + +Vehicle.Cabin.Door.Row2: + description: All doors, including windows and switches. + type: branch + +Vehicle.Cabin.Door.Row2.DriverSide: + description: All doors, including windows and switches. + type: branch + +Vehicle.Cabin.Door.Row2.DriverSide.Window: + description: Door window status. Start position for Window is Closed. + type: branch +``` + +
+ + +#### Example in GraphQL. + + +To convey the same information in a compact way, the GraphQL correspondance would be: ```graphql -type Query { - vehicle( - """VIN of the vehicle that you want to request data for.""" - id: String! - - """ - Filter data to only provide information that was sent from the vehicle after that timestamp. - """ - after: String - ): Vehicle +"""All in-cabin components, including doors.""" +type Cabin { + doors: [Door] } -"""Highlevel vehicle data.""" -type Vehicle { - """Attributes that identify a vehicle""" - vehicleIdentification: Vehicle_VehicleIdentification - ... +"""All doors, including windows and switches.""" +type Door { + instanceLabel: doorInstanceEnum + window: Window } -type Vehicle_VehicleIdentification { - ... +"""Door window status. Start position for Window is Closed.""" +type Door { + window: Window } -... -``` -Leaves look like this: -```graphql -"""Vehicle brand or manufacturer""" -type Vehicle_VehicleIdentification_Brand { - """Value: Vehicle brand or manufacturer""" - value: String - - """Timestamp: Vehicle brand or manufacturer""" - timestamp: String +"""Set of possible values for the instance name of a Door.""" +type doorInstanceEnum { + ROW1_DRIVERSIDE + ROW1_PASSENGERSIDE + ROW2_DRIVERSIDE + ROW2_PASSENGERSIDE } ``` -Every leaf has a timestamp. This is supposed to contain the date of the last modification of the value. -Queries can then filter data that has been recorded after a given timestamp. +### Mapping rules for VSS `leaf` nodes +* `fqn` + * The last part of the `fqn` (i.e., the node's name itself) becomes the name of a GraphQL `field` inside a particular GraphQL `type`. +* `type` + * Since GraphQL specifies a contract between the data producer and data consumer. The specified data can be made readable (via `Query`) and/or writtable (via `Mutation`). + * Optional: Regardless of the `VSS type`, every leaf can have a field in a `Query` to indicate that the value can be read. This is listed as optional because some concepts might not be desired to be queried atomically. + * Optional: If `VSS type` is `actuator`, a `Mutation` for that concept could be specified. This is listed as optional because some concepts might not be desired to be modifiable atomically. +* `description` + * A doc string above the `GraphQL field` definition. +* `comment` + * Included in the doc string with the prefix `@comment`. +* `deprecation` + * Using the GraphQL `@deprecated` directive. +* `datatype` + * Using the built-in `scalar`. + * Custom `scalar` are also provided to cover other datatypes. +* `unit` + * Used as an attribute in a particular `GraphQL field` +* `min` + * Added as info to the doc string `@min:`. +* `max` + * Added as info to the doc string `@max:`. +* `allowed` + * A `GraphQL Enum` is created to hold the set of possible values expected for that `GraphQL Field` +* `default` + * Added as info to the doc string as `@default:`. -### Additional leaf parameters -As for `timestamp` in some scenarios it makes sense to add certain metadata like the `source` of a -served signal or additional privacy information. Therefore the tool has an additional calling parameter -`--gqlfield `, which takes the name and description of the additional field, like: +#### Example in vspec +Considering the `Position` and `Switch` properties of the `Row1.DriverSide.Window`, its specification looks like: +```yaml +Vehicle.Cabin.Door.Row1.DriverSide.Window.Position: + comment: Relationship between Open/Close and Start/End position is item dependent. + datatype: uint8 + description: Item position. 0 = Start position 100 = End position. + max: 100 + min: 0 + type: actuator + unit: percent -```bash ---gqlfield "source" "Source System" +Vehicle.Cabin.Door.Row1.DriverSide.Window.Switch: + allowed: + - INACTIVE + - CLOSE + - OPEN + - ONE_SHOT_CLOSE + - ONE_SHOT_OPEN + datatype: string + description: Switch controlling sliding action such as window, sunroof, or blind. + type: actuator ``` -Resulting in the following leaf in the schema: - +#### Example in GraphQL ```graphql -"""Vehicle brand or manufacturer""" -type Vehicle_VehicleIdentification_Brand { - """Value: Vehicle brand or manufacturer.""" - value: String +"""Door window status. Start position for Window is Closed.""" +type Window { + """ + Item position. 0 = Start position 100 = End position. + @comment: Relationship between Open/Close and Start/End position is item dependent. + @min: 0 + @max: 100 + """ + position(unit: RelationUnit = PERCENT): UInt8 - """Timestamp: Vehicle brand or manufacturer.""" - timestamp: String + """ + Switch controlling sliding action such as window, sunroof, or blind. + """ + switch: WindowSwitchEnum +} - """ Source System: Vehicle brand or manufacturer.""" - source: String +enum WindowSwitchEnum { + INACTIVE + CLOSE + OPEN + ONE_SHOT_CLOSE + ONE_SHOT_OPEN } ``` + +### Mapping references +The exporter has the option to save the mapping references to a `.json` file by using the `--legacy-mapping-output`. For example: +```shell +vspec export graphql --vspec path_to_spec.vspec --output path_to_output_schema.graphql --legacy-mapping-output path_to_mapping_file.json +``` + +The mapping reference file will look like follows: +```json +{ + "quantity_kinds_and_units": { + "info": "Mappings of vspec quantity kind and their units to the corresponding names in GraphQL.", + "mappings": { + "acceleration": { + "gql_unit_enum": "AccelerationUnit_Enum", + "units": { + "centimeters per second squared": "CENTIMETERS_PER_SECOND_SQUARED", + "meters per second squared": "METERS_PER_SECOND_SQUARED" + } + }, + ... + }, + }, + + "vspec_branches": { + "info": "Mappings of vspec branches to the corresponding names in GraphQL.", + "mappings": { + "Vehicle.Body.Lights.Beam": { + "gql_type": "Vehicle_Body_Lights_Beam", + "gql_instance_enum": "Vehicle_Body_Lights_Beam_Instance_Enum", + "instance_labels": { + "Low": "LOW", + "High": "HIGH" + } + }, + ... + }, + }, + + "vspec_leaves": { + "info": "Mappings of vspec leaves to the corresponding names in GraphQL.", + "mappings": { + "Vehicle.ADAS.ActiveAutonomyLevel": { + "gql_field": "activeAutonomyLevel", + "in_gql_type": "Vehicle_ADAS", + "gql_allowed_enum": "Vehicle_ADAS_ActiveAutonomyLevel_Enum", + "allowed_values": { + "SAE_0": "SAE_0", + "SAE_1": "SAE_1", + "SAE_2_DISENGAGING": "SAE_2_DISENGAGING", + "SAE_2": "SAE_2", + "SAE_3_DISENGAGING": "SAE_3_DISENGAGING", + "SAE_3": "SAE_3", + "SAE_4_DISENGAGING": "SAE_4_DISENGAGING", + "SAE_4": "SAE_4", + "SAE_5_DISENGAGING": "SAE_5_DISENGAGING", + "SAE_5": "SAE_5" + } + }, + ... + } + + + + +``` \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index ab92299a..541225f5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -209,6 +209,27 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.11.0,<2.12.0" pyflakes = ">=3.1.0,<3.2.0" +[[package]] +name = "graphene" +version = "3.4.1" +description = "GraphQL Framework for Python" +optional = false +python-versions = "*" +files = [ + {file = "graphene-3.4.1-py2.py3-none-any.whl", hash = "sha256:ca98f853201293871cdc7f55faf2523a9bc077181fe0f4947db5a243e5c67083"}, + {file = "graphene-3.4.1.tar.gz", hash = "sha256:828a8d7b1bce450566a72cc8733716c20f3acfc659960de73dd38f46dc302040"}, +] + +[package.dependencies] +graphql-core = ">=3.1,<3.3" +graphql-relay = ">=3.1,<3.3" +python-dateutil = ">=2.7.0,<3" +typing-extensions = ">=4.7.1,<5" + +[package.extras] +dev = ["coveralls (>=3.3,<5)", "mypy (>=1.10,<2)", "pytest (>=8,<9)", "pytest-asyncio (>=0.16,<2)", "pytest-benchmark (>=4,<5)", "pytest-cov (>=5,<6)", "pytest-mock (>=3,<4)", "ruff (==0.5.0)", "types-python-dateutil (>=2.8.1,<3)"] +test = ["coveralls (>=3.3,<5)", "pytest (>=8,<9)", "pytest-asyncio (>=0.16,<2)", "pytest-benchmark (>=4,<5)", "pytest-cov (>=5,<6)", "pytest-mock (>=3,<4)"] + [[package]] name = "graphql-core" version = "3.2.4" @@ -220,6 +241,20 @@ files = [ {file = "graphql_core-3.2.4-py3-none-any.whl", hash = "sha256:1604f2042edc5f3114f49cac9d77e25863be51b23a54a61a23245cf32f6476f0"}, ] +[[package]] +name = "graphql-relay" +version = "3.2.0" +description = "Relay library for graphql-core" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "graphql-relay-3.2.0.tar.gz", hash = "sha256:1ff1c51298356e481a0be009ccdff249832ce53f30559c1338f22a0e0d17250c"}, + {file = "graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5"}, +] + +[package.dependencies] +graphql-core = ">=3.2,<3.3" + [[package]] name = "identify" version = "2.6.1" @@ -667,6 +702,20 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "pyyaml" version = "6.0.2" @@ -886,4 +935,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "c93cfbe20f6f7d404dcf9a8a6d7cd9fb630838e7d4f5f718e32c87856106c30c" +content-hash = "d23bd3ce7f1b325efb0f9c35660ae737155ba8c5a61fa26f4610a3c40e9b94f6" diff --git a/pyproject.toml b/pyproject.toml index 392f4537..2c1105c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,8 @@ importlib-metadata = "^7.0" click = "^8.1.7" rich-click = "^1.8.3" pydantic = "^2.8.2" +graphene = "^3.4.1" +pandas = "^2.2.3" [tool.poetry.group.dev.dependencies] mypy = "*" diff --git a/src/vss_tools/datatypes.py b/src/vss_tools/datatypes.py index e3ed5e94..bceb5494 100644 --- a/src/vss_tools/datatypes.py +++ b/src/vss_tools/datatypes.py @@ -5,15 +5,20 @@ # https://www.mozilla.org/en-US/MPL/2.0/ # # SPDX-License-Identifier: MPL-2.0 -from typing import Any, Callable, Set +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, Set from vss_tools import log +if TYPE_CHECKING: + from vss_tools.model import VSSUnit + # Global objects to be extended by other code parts dynamic_datatypes: Set[str] = set() dynamic_quantities: list[str] = [] -# This one contains the unit name as well as the list of allowed-datatypes -dynamic_units: dict[str, list] = {} +# Map of unit name and VSSUnit +dynamic_units: dict[str, VSSUnit] = {} class DatatypesException(Exception): diff --git a/src/vss_tools/exporters/go.py b/src/vss_tools/exporters/go.py index 7671ba19..ab2c16ac 100644 --- a/src/vss_tools/exporters/go.py +++ b/src/vss_tools/exporters/go.py @@ -18,6 +18,7 @@ import vss_tools.cli_options as clo from vss_tools import log from vss_tools.datatypes import Datatypes, is_array +from vss_tools.exporters.utils import get_instance_mapping from vss_tools.main import get_trees from vss_tools.model import VSSDataBranch, VSSDataDatatype, VSSDataStruct from vss_tools.tree import VSSNode @@ -43,58 +44,6 @@ } -class NoInstanceRootException(Exception): - pass - - -def get_instance_root(root: VSSNode, depth: int = 1) -> tuple[VSSNode, int]: - """ - Getting the root node of a given instance node. - Going the tree upwards - """ - if root.parent is None: - raise NoInstanceRootException() - if isinstance(root.parent.data, VSSDataBranch): - if root.parent.data.is_instance: - return get_instance_root(root.parent, depth + 1) - else: - return root.parent, depth - else: - raise NoInstanceRootException() - - -def add_children_map_entries(root: VSSNode, fqn: str, replace: str, map: dict[str, str]) -> None: - """ - Adding rename map entries for children of a given node - """ - child: VSSNode - for child in root.children: - child_fqn = child.get_fqn() - new_name = child_fqn.replace(fqn, replace) - map[child_fqn] = new_name - add_children_map_entries(child, fqn, replace, map) - - -def get_instance_mapping(root: VSSNode | None) -> dict[str, str]: - """ - Constructing a rename map of fqn->new_name. - The new name has instances stripped and appending "I" instead - where N is the depth of the instance - """ - if root is None: - return {} - instance_map: dict[str, str] = {} - for node in PreOrderIter(root): - if isinstance(node.data, VSSDataBranch): - if node.data.is_instance: - instance_root, depth = get_instance_root(node) - new_name = instance_root.get_fqn() + "." + "I" + str(depth) - fqn = node.get_fqn() - instance_map[fqn] = new_name - add_children_map_entries(node, fqn, instance_root.get_fqn(), instance_map) - return instance_map - - def get_datatype(node: VSSNode) -> str | None: """ Gets the datatype string of a node. diff --git a/src/vss_tools/exporters/graphql.py b/src/vss_tools/exporters/graphql.py index e7a0d342..3bb16bc3 100644 --- a/src/vss_tools/exporters/graphql.py +++ b/src/vss_tools/exporters/graphql.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022 Contributors to COVESA +# 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 @@ -6,129 +6,553 @@ # # SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import json +import keyword +import re +import sys +from enum import Enum +from itertools import product from pathlib import Path -from typing import Dict +from typing import Any, Dict +import graphene +import pandas as pd import rich_click as click -from graphql import ( - GraphQLArgument, - GraphQLBoolean, - GraphQLField, - GraphQLFloat, - GraphQLInt, - GraphQLList, - GraphQLNonNull, - GraphQLObjectType, - GraphQLSchema, - GraphQLString, - print_schema, -) +from anytree import PreOrderIter +from graphene import Field, Scalar import vss_tools.cli_options as clo from vss_tools import log -from vss_tools.datatypes import Datatypes +from vss_tools.datatypes import Datatypes, dynamic_units, is_array from vss_tools.main import get_trees -from vss_tools.model import VSSDataDatatype -from vss_tools.tree import VSSNode -from vss_tools.utils.misc import camel_back - -GRAPHQL_TYPE_MAPPING = { - Datatypes.INT8[0]: GraphQLInt, - Datatypes.INT8_ARRAY[0]: GraphQLList(GraphQLInt), - Datatypes.UINT8[0]: GraphQLInt, - Datatypes.UINT8_ARRAY[0]: GraphQLList(GraphQLInt), - Datatypes.INT16[0]: GraphQLInt, - Datatypes.INT16_ARRAY[0]: GraphQLList(GraphQLInt), - Datatypes.UINT16[0]: GraphQLInt, - Datatypes.UINT16_ARRAY[0]: GraphQLList(GraphQLInt), - Datatypes.INT32[0]: GraphQLInt, - Datatypes.INT32_ARRAY[0]: GraphQLList(GraphQLInt), - Datatypes.UINT32[0]: GraphQLFloat, - Datatypes.UINT32_ARRAY[0]: GraphQLList(GraphQLFloat), - Datatypes.INT64[0]: GraphQLFloat, - Datatypes.INT64_ARRAY[0]: GraphQLList(GraphQLFloat), - Datatypes.UINT64[0]: GraphQLFloat, - Datatypes.UINT64_ARRAY[0]: GraphQLList(GraphQLFloat), - Datatypes.FLOAT[0]: GraphQLFloat, - Datatypes.FLOAT_ARRAY[0]: GraphQLList(GraphQLFloat), - Datatypes.DOUBLE[0]: GraphQLFloat, - Datatypes.DOUBLE_ARRAY[0]: GraphQLList(GraphQLFloat), - Datatypes.BOOLEAN[0]: GraphQLBoolean, - Datatypes.BOOLEAN_ARRAY[0]: GraphQLList(GraphQLBoolean), - Datatypes.STRING[0]: GraphQLString, - Datatypes.STRING_ARRAY[0]: GraphQLList(GraphQLString), -} +from vss_tools.tree import VSSNode, get_expected_parent +from vss_tools.utils.misc import getattr_nn -class GraphQLFieldException(Exception): +# ========= Custom GraphQL scalar types ========= +class Int8(Scalar): + """8-bit integer""" + pass -def get_schema_from_tree(root: VSSNode, additional_leaf_fields: list) -> str: - """Takes a VSSNode and additional fields for the leafs. Returns a graphql schema as string.""" - args = dict( - id=GraphQLArgument( - GraphQLNonNull(GraphQLString), - description="VIN of the vehicle that you want to request data for.", - ), - after=GraphQLArgument( - GraphQLString, - description=( - "Filter data to only provide information that was sent " "from the vehicle after that timestamp." - ), - ), - ) +class UInt8(Scalar): + """8-bit unsigned integer""" - root_query = GraphQLObjectType( - "Query", - lambda: {"vehicle": GraphQLField(to_gql_type(root, additional_leaf_fields), args)}, - ) - return print_schema(GraphQLSchema(root_query)) + pass + + +class Int16(Scalar): + """16-bit integer""" + + pass + + +class UInt16(Scalar): + """16-bit unsigned integer""" + + pass + + +class UInt32(Scalar): + """32-bit unsigned integer""" + + pass + + +class Int64(Scalar): + """64-bit integer""" + + pass + + +class UInt64(Scalar): + """64-bit unsigned integer""" + + pass + + +# ========= Mapping aids ========= +class GQLElementType(Enum): + """Enum of GraphQL elements to better handle naming conventions during the export process""" + + TYPE = "type" + FIELD = "field" + ARGUMENT = "argument" + DIRECTIVE = "directive" + ENUM = "enum" + INTERFACE = "interface" + UNION = "union" + SCALAR = "scalar" + ENUM_VALUE = "enum_value" -def to_gql_type(node: VSSNode, additional_leaf_fields: list) -> GraphQLObjectType: - if isinstance(node.data, VSSDataDatatype): - fields = leaf_fields(node, additional_leaf_fields) +datatype_map = { + Datatypes.INT8[0]: Int8, + Datatypes.INT8_ARRAY[0]: graphene.List(Int8), + Datatypes.UINT8[0]: UInt8, + Datatypes.UINT8_ARRAY[0]: graphene.List(UInt8), + Datatypes.INT16[0]: Int16, + Datatypes.INT16_ARRAY[0]: graphene.List(Int16), + Datatypes.UINT16[0]: UInt16, + Datatypes.UINT16_ARRAY[0]: graphene.List(UInt16), + Datatypes.INT32[0]: graphene.Int, + Datatypes.INT32_ARRAY[0]: graphene.List(graphene.Int), + Datatypes.UINT32[0]: UInt32, + Datatypes.UINT32_ARRAY[0]: graphene.List(UInt32), + Datatypes.INT64[0]: Int64, + Datatypes.INT64_ARRAY[0]: graphene.List(Int64), + Datatypes.UINT64[0]: UInt64, + Datatypes.UINT64_ARRAY[0]: graphene.List(UInt64), + Datatypes.FLOAT[0]: graphene.Float, + Datatypes.FLOAT_ARRAY[0]: graphene.List(graphene.Float), + Datatypes.DOUBLE[0]: graphene.Float, + Datatypes.DOUBLE_ARRAY[0]: graphene.List(graphene.Float), + Datatypes.BOOLEAN[0]: graphene.Boolean, + Datatypes.BOOLEAN_ARRAY[0]: graphene.List(graphene.Boolean), + Datatypes.STRING[0]: graphene.String, + Datatypes.STRING_ARRAY[0]: graphene.List(graphene.String), +} + +# ========= Global variables ========= +vss_metadata_df = pd.DataFrame() +vss_branches_df = pd.DataFrame() +vss_leaves_df = pd.DataFrame() +gql_allowed_enums: Dict[str, graphene.Enum] = {} +gql_instance_enums: Dict[str, graphene.Enum] = {} +gql_unit_enums: Dict[str, graphene.Enum] = {} +include_descriptions = True + +mapping_quantity_kinds_df = pd.DataFrame(columns=["vspec_quantity_kind", "gql_unit_enum", "units"]).set_index( + "vspec_quantity_kind" +) +mapping_branches_df = pd.DataFrame(columns=["vspec_fqn", "gql_type", "gql_instance_enum", "instance_labels"]).set_index( + "vspec_fqn" +) +mapping_leaves_df = pd.DataFrame( + columns=["vspec_fqn", "gql_field", "in_gql_type", "gql_allowed_enum", "allowed_values"] +).set_index("vspec_fqn") + + +# ========= String converters ========= +def to_camel_case(text: str) -> str: + """ + Converts a string to camelCase. + If text == "ACB.Def.ghi", then the result is "abcDefGhi". + If text == "ACB_DEF_GHI", then the result is "abcDefGhi". + If text == "abc.def.ghi", then the result is "abcDefGhi". + If text == "AbcdefGhi", then the result is "abcdefGhi". + """ + if text.isupper(): + return text.lower() else: - fields = branch_fields(node, additional_leaf_fields) - return GraphQLObjectType( - name=node.get_fqn("_"), - fields=fields, - description=node.get_vss_data().description, - ) + return text[0].lower() + text[1:] + + +def to_pascal_case(text: str) -> str: + """Converts a string to PascalCase""" + if text.isupper() and len(text.split()) == 1: + return text + words = re.sub(r"[^a-zA-Z0-9]", " ", text).split() + return "".join(word[0].upper() + word[1:] for word in words) + + +def to_screaming_snake_case(text: str) -> str: + """Converts a string to screaming snake case (i.e., CAPITAL LETTERS)""" + text = re.sub(r"[^a-zA-Z0-9]", " ", text) + words = text.split() + return "_".join(word.upper() for word in words) + + +def get_gql_name(text: str, gql_type: GQLElementType) -> str: + """Applies the appropriate naming convention to a given string""" + + if gql_type in {GQLElementType.FIELD, GQLElementType.ARGUMENT, GQLElementType.DIRECTIVE}: + text = to_camel_case(text) + return text if not keyword.iskeyword(text) else f"{text}_" + elif gql_type in { + GQLElementType.TYPE, + GQLElementType.ENUM, + GQLElementType.INTERFACE, + GQLElementType.UNION, + GQLElementType.SCALAR, + }: + text = text.replace(".", "_") + text = f"{text}_Enum" if gql_type == GQLElementType.ENUM else text + return text if not keyword.iskeyword(text) else f"{text}_" + elif gql_type in {GQLElementType.ENUM_VALUE}: + text = to_screaming_snake_case(text) + if text[0].isdigit(): + text = f"_{text}" + return text if not keyword.iskeyword(text) else f"{text}_" + raise ValueError(f"Invalid GQLElementType: {gql_type}") + + +def get_unit_enum_name(quantity_kind: str) -> str: + """Get the name for the GraphQL unit enum""" + text = f"{quantity_kind}_Unit" + return get_gql_name(to_pascal_case(text), GQLElementType.ENUM) + + +# ========= DataFrames for VSS metadata ========= + + +def get_metadata_df(root: VSSNode) -> pd.DataFrame: + """Traverses the tree and returns a DataFrame with the metadata of all the nodes.""" + headers = [ + "fqn", + "parent", + "name", + "type", + "description", + "comment", + "deprecated", + "datatype", + "unit", + "min", + "max", + "allowed", + "default", + "instances", + ] + + df = pd.DataFrame(columns=headers) + + for node in PreOrderIter(root): + data = node.get_vss_data() + fqn = node.get_fqn() + parent = get_expected_parent(fqn) + name = node.name + vss_type = data.type.value + metadata = {} + for header in headers[2:]: + metadata[header] = getattr_nn(data, header, "") + metadata["fqn"] = fqn + metadata["parent"] = parent + metadata["name"] = name + metadata["type"] = vss_type + df = pd.concat([df, pd.DataFrame([metadata])], ignore_index=True) + + # Check that the fqn is unique in the metadata + try: + if df["fqn"].duplicated().any(): + duplicates = df[df["fqn"].duplicated()]["fqn"].tolist() + raise ValueError(f"Duplicate FQN values found in the metadata: {duplicates}") + df.sort_values(by="fqn", inplace=True) + return df + except ValueError as e: + log.error(e) + sys.exit(1) + + +def get_vss_branches_df(df: pd.DataFrame) -> pd.DataFrame: + """Returns a DataFrame with the metadata of all the branches.""" + branch_headers = ["fqn", "parent", "name", "type", "description", "comment", "deprecated", "instances"] + + df = df[df["type"].isin(["branch"])] + return df[branch_headers].set_index("fqn") + + +def get_vss_leaves_df(df: pd.DataFrame) -> pd.DataFrame: + """Returns a DataFrame with the metadata of all the leaves.""" + leaf_headers = [ + "fqn", + "parent", + "name", + "type", + "description", + "comment", + "deprecated", + "datatype", + "unit", + "min", + "max", + "allowed", + "default", + ] + df = df[df["type"].isin(["attribute", "sensor", "actuator"])] + return df[leaf_headers].set_index("fqn") -def leaf_fields(node: VSSNode, additional_leaf_fields: list) -> Dict[str, GraphQLField]: - field_dict = {} - datatype = getattr(node.data, "datatype", None) - if datatype is not None: - field_dict["value"] = field(node, "Value: ", GRAPHQL_TYPE_MAPPING[datatype]) - field_dict["timestamp"] = field(node, "Timestamp: ") - if additional_leaf_fields: - for additional_field in additional_leaf_fields: - if len(additional_field) == 2: - field_dict[additional_field[0]] = field(node, f" {str(additional_field[1])}: ") +def get_gql_unit_enums() -> Dict[str, graphene.Enum]: + """Get GraphQL enums for VSS units and quantity kinds.""" + global mapping_quantity_kinds_df + + spec_quantity_kinds = get_quantity_kinds_and_units() + unit_enums: Dict[str, graphene.Enum] = {} + + # Create a graphene enum for each key in the spec_quantity_kinds + for quantity_kind, units in spec_quantity_kinds.items(): + enum_name = get_unit_enum_name(quantity_kind) + enum_values = {} + unit_mappings = {} + for unit in units: + unit_name = get_gql_name(unit, GQLElementType.ENUM_VALUE) + unit_mappings[unit] = unit_name + enum_values[unit_name] = unit_name + + unit_enums[enum_name] = type(enum_name, (graphene.Enum,), enum_values) # type: ignore + mapping_quantity_kinds_df.loc[quantity_kind] = [enum_name, sort_dict_by_key(unit_mappings)] + + return unit_enums + + +def get_quantity_kinds_and_units() -> dict[str, set[str]]: + """Get the quantity kinds and their units as specified in VSS.""" + spec_quantity_kinds: Dict[str, set[str]] = {} + for unit_data in dynamic_units.values(): + quantity_kind_name = unit_data.quantity + if unit_data.unit: + unit_name = unit_data.unit + if quantity_kind_name not in spec_quantity_kinds: + spec_quantity_kinds[quantity_kind_name] = set() + spec_quantity_kinds[quantity_kind_name].add(unit_name) + + return dict(sorted(spec_quantity_kinds.items())) + + +def get_branches_with_specified_instances(): + return vss_branches_df[vss_branches_df["instances"].astype(str) != "[]"] + + +def get_instances_enums() -> Dict[str, graphene.Enum]: + """Create a GraphQL enum for each branch that has instances specified.""" + enums: Dict[str, graphene.Enum] = {} + branches_with_instances = get_branches_with_specified_instances() + + for fqn, row in branches_with_instances.iterrows(): + instance_labels = expand_instances(row["instances"]) + mapping_instance_labels = {} + + enum_name = get_gql_name(f"{fqn}.Instance", GQLElementType.ENUM) # TODO: Todo pass a shorter name! + enum_values = {} + + for label in instance_labels: + value = get_gql_name(label, GQLElementType.ENUM_VALUE) + enum_values[value] = value + mapping_instance_labels[label] = value + + enums[fqn] = type(enum_name, (graphene.Enum,), enum_values) # type: ignore + + global mapping_branches_df + mapping_branches_df.loc[fqn, ["gql_instance_enum", "instance_labels"]] = [enum_name, mapping_instance_labels] # type: ignore + + return enums + + +def expand_instances(instances: list) -> list[str]: + """Expand the instances specified in the VSS to a list of labels.""" + inst_levels = [] + for element in instances: + if isinstance(element, list): + inst_levels.append(element) + elif isinstance(element, str): + match = re.match(r"(\w+)\[(\d+),(\d+)\]", element) + if match: + label, start, end = match.groups() + start, end = int(start), int(end) + labels = [f"{label}{i}" for i in range(start, end + 1)] + inst_levels.append(labels) else: - raise GraphQLFieldException("", "", "Incorrect format of graphql field specification") - unit = getattr(node.data, "unit", None) - if unit: - field_dict["unit"] = field(node, "Unit of ") - return field_dict + inst_levels.append(instances) + break + combined_labels = [".".join(item) for item in product(*inst_levels)] + return combined_labels + + +def get_allowed_enums() -> Dict[str, graphene.Enum]: + """Create a GraphQL enum for each leaf that has allowed values specified.""" + gql_allowed_enums: Dict[str, graphene.Enum] = {} + + leaves_with_allowed = vss_leaves_df[vss_leaves_df["allowed"].astype(str) != ""] + + for fqn, row in leaves_with_allowed.iterrows(): + allowed_list = eval(row["allowed"]) if isinstance(row["allowed"], str) else row["allowed"] + enum_values = {} + mapping_allowed_values = {} + for allowed_value in allowed_list: + enum_name = get_gql_name(str(fqn), GQLElementType.ENUM) + value = get_gql_name(str(allowed_value), GQLElementType.ENUM_VALUE) + enum_values[value] = value + mapping_allowed_values[allowed_value] = value + + gql_allowed_enums[fqn] = type(enum_name, (graphene.Enum,), enum_values) # type: ignore + global mapping_leaves_df + mapping_leaves_df.loc[fqn, ["gql_allowed_enum", "allowed_values"]] = [enum_name, mapping_allowed_values] # type: ignore + + return gql_allowed_enums + + +def get_gql_object_types() -> Dict[str, graphene.ObjectType]: + """Create a GraphQL object type for each branch in the VSS.""" + gql_object_types: Dict[str, graphene.ObjectType] = {} + + for fqn, _ in vss_branches_df.iterrows(): + gql_object_types[str(fqn)] = create_gql_object_type(fqn) + + return gql_object_types + + +def get_description(fqn): + description = None + if fqn in vss_branches_df.index: + description = str(vss_branches_df.loc[fqn, "description"]) + elif fqn in vss_leaves_df.index: + description = str(vss_leaves_df.loc[fqn, "description"]) + if vss_leaves_df.loc[fqn, "min"]: + description += f"\n@min: {str(vss_leaves_df.loc[fqn, "min"])}" + if vss_leaves_df.loc[fqn, "max"]: + description += f"\n@max: {str(vss_leaves_df.loc[fqn, "max"])}" + if vss_leaves_df.loc[fqn, "default"]: + description += f"\n@default: {str(vss_leaves_df.loc[fqn, "default"])}" + return description + +def create_gql_object_type(fqn) -> graphene.ObjectType: + """Create a GraphQL object type for a given Fully Qualified Name (fqn) fqn the VSS.""" + # Select the children of the current branch + child_leaves = vss_leaves_df[vss_leaves_df["parent"] == fqn] + child_branches = vss_branches_df[vss_branches_df["parent"] == fqn] + branches_with_instances = get_branches_with_specified_instances() -def branch_fields(node: VSSNode, additional_leaf_fields: list) -> Dict[str, GraphQLField]: - # we only consider nodes that either have children or are datatype leafs - valid = (c for c in node.children if len(c.children) or hasattr(c.data, "datatype")) - return {camel_back(c.name): field(c, type=to_gql_type(c, additional_leaf_fields)) for c in valid} + gql_fields: Dict[str, graphene.Field] = {} + gql_type_name = get_gql_name(fqn, GQLElementType.TYPE) + gql_type_description = get_description(fqn) + if fqn in branches_with_instances.index: + gql_fields["instance"] = Field(name="instance", type_=gql_instance_enums[fqn]) -def field(node: VSSNode, description_prefix="", type=GraphQLString) -> GraphQLField: - data = node.get_vss_data() - return GraphQLField( - type, - deprecation_reason=data.deprecation, - description=f"{description_prefix}{data.description}", + # Create a GraphQL field for each leaf that belongs to the current branch + for child_fqn, child_leaf_metadata_row in child_leaves.iterrows(): + field_name = get_gql_name(child_leaf_metadata_row["name"], GQLElementType.FIELD) + field_type = None + field_description = get_description(child_fqn) + unit = child_leaf_metadata_row["unit"] + allowed = child_leaf_metadata_row["allowed"] + extra_args = {} + + if allowed == "": + # No allowed values are specified, then use the scalars from datatype_map as the field type + field_type = datatype_map[child_leaf_metadata_row["datatype"]] + + else: + # Allowed values are specified + allowed_enum = gql_allowed_enums[child_fqn] + + datatype = child_leaf_metadata_row["datatype"] + if datatype and is_array(datatype): + field_type = graphene.List(allowed_enum) + else: + field_type = allowed_enum + + if unit: + try: + quantity_kind = dynamic_units[unit].quantity # Quantity kind name as described in VSS + enum_name = get_unit_enum_name(quantity_kind) # GraphQL enum name for the quantity kind + unit_enum = gql_unit_enums[enum_name] + unit_value = dynamic_units[unit].unit + + if unit_value is not None: + unit_enum_value = get_gql_name(unit_value, GQLElementType.ENUM_VALUE) + else: + raise ValueError(f"Unit value for '{unit}' is None") + + if unit_enum_value not in unit_enum._meta.enum.__members__: + raise ValueError(f"Unit enum value '{unit_enum_value}' not found in enum '{unit_enum}'") + else: + extra_args["unit"] = graphene.Argument(type_=unit_enum, default_value=unit_enum_value) # type: ignore + + gql_fields[field_name] = Field(name=field_name, type_=field_type, description=field_description, args=extra_args) + except ValueError as e: + print(e) + else: + gql_fields[field_name] = Field(name=field_name, description=field_description, type_=field_type) + + global mapping_leaves_df + mapping_leaves_df.loc[child_fqn, ["gql_field", "in_gql_type"]] = [field_name, gql_type_name] + + # Create a field for each sub branch and call the creation of the GraphQL type recursively + for child_fqn, child_branch_metadata_row in child_branches.iterrows(): + field_name = get_gql_name(child_branch_metadata_row["name"], GQLElementType.FIELD) + field_type = create_gql_object_type(child_fqn) + if child_fqn in branches_with_instances.index: + field_name += "_s" + field_type = graphene.List(field_type) + gql_fields[field_name] = Field(name=field_name, type_=field_type) + + global mapping_branches_df + mapping_branches_df.loc[fqn, "gql_type"] = gql_type_name + return type(gql_type_name, (graphene.ObjectType,), gql_fields, description=gql_type_description) # type: ignore + + +def get_graphql_schema(tree: VSSNode) -> graphene.Schema: + """Create a GraphQL schema from the VSS tree.""" + global vss_metadata_df, vss_branches_df, vss_leaves_df, gql_unit_enums, gql_allowed_enums, gql_instance_enums + + # TODO: Add flag to enable descriptions in the schema + + # Get pandas DataFrame for all the metadata in the vspec + vss_metadata_df = get_metadata_df(tree) + + # Split the metadata into branches and leaves + vss_branches_df = get_vss_branches_df(vss_metadata_df) + vss_leaves_df = get_vss_leaves_df(vss_metadata_df) + + # Include the custom scalar types even if they are not used by any type in the schema + custom_scalars = [Int8, UInt8, Int16, UInt16, UInt32, Int64, UInt64] + + # Create enums for the instances specified + gql_instance_enums = get_instances_enums() + + # Get GraphQL enums for the units and quantities + gql_unit_enums = get_gql_unit_enums() + + # In the leaves DataFrame, get the entries that have allowed values and create enums for them + gql_allowed_enums = get_allowed_enums() + + # In branches DataFrame, create a GraphQL type for each pure branch (not for instance branches) + gql_branch_types = get_gql_object_types() + + class Query(graphene.ObjectType): + vehicle = graphene.Field(gql_branch_types[get_gql_name(tree.name, gql_type=GQLElementType.TYPE)]) + + # Order the schema as desired + ordered_types = ( + [Query] + + custom_scalars + + list(gql_branch_types.values()) + + list(gql_unit_enums.values()) + + list(gql_instance_enums.values()) + + list(gql_allowed_enums.values()) ) + return graphene.Schema(types=ordered_types, auto_camelcase=False) + + +def export_mappings() -> Dict[str, Dict[str, Any]]: + """Export the mappings of the VSS to the GraphQL schema.""" + mappings = { + "quantity_kinds_and_units": { + "info": "Mappings of vspec quantity kind and their units to the corresponding names in GraphQL.", + "mappings": sort_dict_by_key(mapping_quantity_kinds_df.to_dict(orient="index")), + }, + "vspec_branches": { + "info": "Mappings of vspec branches to the corresponding names in GraphQL.", + "mappings": sort_dict_by_key(mapping_branches_df.fillna("").to_dict(orient="index")), + }, + "vspec_leaves": { + "info": "Mappings of vspec leaves to the corresponding names in GraphQL.", + "mappings": sort_dict_by_key(mapping_leaves_df.fillna("").to_dict(orient="index")), + }, + } + return mappings + + +def sort_dict_by_key(dictionary: dict) -> dict: + """Sorts a dictionary by its keys in a case-insensitive manner but preserves the original key.""" + return dict(sorted(dictionary.items(), key=lambda item: item[0].lower())) @click.command() @@ -142,13 +566,9 @@ def field(node: VSSNode, description_prefix="", type=GraphQLString) -> GraphQLFi @clo.quantities_opt @clo.units_opt @click.option( - "--gql-fields", - "-g", - multiple=True, - help=""" - Add additional fields to the nodes in the graphql schema. - Usage: ','", - """, + "--legacy-mapping-output", + type=click.Path(dir_okay=False, writable=True, path_type=Path), + help="Output json file of the legacy units and quantities", ) def cli( vspec: Path, @@ -160,10 +580,10 @@ def cli( overlays: tuple[Path], quantities: tuple[Path], units: tuple[Path], - gql_fields: list[str], + legacy_mapping_output: Path | None, ): """ - Export as GraphQL. + Export a VSS specification to a GraphQL schema. """ tree, _ = get_trees( vspec=vspec, @@ -174,12 +594,16 @@ def cli( quantities=quantities, units=units, overlays=overlays, + expand=False, ) - log.info("Generating graphql output...") - outfile = open(output, "w") - gqlfields: list[list[str]] = [] - for field in gql_fields: - gqlfields.append(field.split(",")) - outfile.write(get_schema_from_tree(tree, gqlfields)) - outfile.write("\n") - outfile.close() + + log.info("Generating GraphQL output...") + gql_schema = get_graphql_schema(tree) + mappings = export_mappings() + + with open(output, "w") as outfile: + outfile.write(str(gql_schema)) + + if legacy_mapping_output: + with open(legacy_mapping_output, "w") as mapping_outfile: + mapping_outfile.write(json.dumps(mappings, indent=4)) diff --git a/src/vss_tools/exporters/utils.py b/src/vss_tools/exporters/utils.py new file mode 100644 index 00000000..f44f55fa --- /dev/null +++ b/src/vss_tools/exporters/utils.py @@ -0,0 +1,105 @@ +# 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 + +from anytree import PreOrderIter, PostOrderIter + +from vss_tools.model import VSSDataBranch, VSSDataDatatype +from vss_tools.tree import VSSNode + +from rich.pretty import pretty_repr + + +class NoInstanceRootException(Exception): + pass + + +def get_instance_root(root: VSSNode, depth: int = 1) -> tuple[VSSNode, int]: + """ + Getting the root node of a given instance node. + Going the tree upwards + """ + if root.parent is None: + raise NoInstanceRootException() + if isinstance(root.parent.data, VSSDataBranch): + if root.parent.data.is_instance: # Is the inmmediate parent node also a VSS instance branch? + return get_instance_root(root.parent, depth + 1) + else: + return root.parent, depth + else: + raise NoInstanceRootException() + + +def add_children_map_entries(root: VSSNode, fqn: str, replace: str, map: dict[str, str]) -> None: + """ + Adding rename map entries for children of a given node + """ + child: VSSNode + for child in root.children: + child_fqn = child.get_fqn() + new_name = child_fqn.replace(fqn, replace) + map[child_fqn] = new_name + add_children_map_entries(child, fqn, replace, map) + + +def get_instance_mapping(root: VSSNode | None) -> dict[str, str]: + """ + Constructing a rename map of fqn->new_name. + The new name has instances stripped and appending "I" instead + where N is the depth of the instance + """ + if root is None: + return {} + instance_map: dict[str, str] = {} + for node in PreOrderIter(root): + if isinstance(node.data, VSSDataBranch): + if node.data.is_instance: + instance_root, depth = get_instance_root(node) + new_name = instance_root.get_fqn() + "." + "I" + str(depth) + fqn = node.get_fqn() + instance_map[fqn] = new_name + add_children_map_entries(node, fqn, instance_root.get_fqn(), instance_map) + return instance_map + +def get_instances_meta(root: VSSNode) -> dict[str, list[str]]: + """ + Constructing metadata of instance root nodes fqns and a list of generated nodes + """ + meta = {} + for node in PreOrderIter(root, filter_=lambda n: isinstance(n.data, VSSDataBranch) and n.data.is_instance): + if any(c.data.is_instance for c in node.children if isinstance(c.data, VSSDataBranch)): + continue + instance_root, _ = get_instance_root(node) + instance_root_fqn = instance_root.get_fqn() + + instance_name = node.get_fqn().removeprefix(instance_root_fqn+".") + + if instance_root_fqn in meta: + meta[instance_root_fqn].append(instance_name) + else: + meta[instance_root_fqn] = [instance_name] + return meta + +def is_VSS_leaf(node: VSSNode) -> bool: + """Check if the node is a VSS leaf (i.e., one of VSS sensor, attribute, or actuator)""" + if isinstance(node.data, VSSDataDatatype): + return True + return False + +def is_VSS_branch(node: VSSNode) -> bool: + """Check if the node is a VSS branch (and not an instance branch)""" + if isinstance(node.data, VSSDataBranch): + if not node.data.is_instance: + return True + return False + +def is_VSS_branch_instance(node: VSSNode) -> bool: + """Check if the node is a VSS branch instance)""" + if isinstance(node.data, VSSDataBranch): + if node.data.is_instance: + return True + return False \ No newline at end of file diff --git a/src/vss_tools/main.py b/src/vss_tools/main.py index d8ccb5ab..2afc035d 100644 --- a/src/vss_tools/main.py +++ b/src/vss_tools/main.py @@ -69,12 +69,9 @@ def load_quantities_and_units(quantities: tuple[Path, ...], units: tuple[Path, . dynamic_quantities.extend(list(quantity_data.keys())) unit_data = load_units(list(units)) for k, v in unit_data.items(): - allowed_datatypes = [] - if v.allowed_datatypes is not None: - allowed_datatypes = v.allowed_datatypes if v.unit is not None: - dynamic_units[v.unit] = allowed_datatypes - dynamic_units[k] = allowed_datatypes + dynamic_units[v.unit] = v + dynamic_units[k] = v def check_name_violations(root: VSSNode, strict: bool, aborts: tuple[str, ...]) -> None: diff --git a/src/vss_tools/model.py b/src/vss_tools/model.py index b25bd886..58e32e68 100644 --- a/src/vss_tools/model.py +++ b/src/vss_tools/model.py @@ -272,8 +272,11 @@ def check_datatype_matching_allowed_unit_datatypes(self) -> Self: """ if self.unit: assert Datatypes.get_type(self.datatype), f"Cannot use 'unit' with complex datatype: '{self.datatype}'" + allowed_datatypes = dynamic_units[self.unit].allowed_datatypes + if allowed_datatypes is None: + allowed_datatypes = [] assert any( - Datatypes.is_subtype_of(self.datatype.rstrip("[]"), a) for a in dynamic_units[self.unit] + Datatypes.is_subtype_of(self.datatype.rstrip("[]"), a) for a in allowed_datatypes ), f"'{self.datatype}' is not allowed for unit '{self.unit}'" return self diff --git a/tests/vspec/test_allowed/expected.graphql b/tests/vspec/test_allowed/expected.graphql index 97eff20d..2c288d17 100644 --- a/tests/vspec/test_allowed/expected.graphql +++ b/tests/vspec/test_allowed/expected.graphql @@ -1,62 +1,87 @@ type Query { - vehicle( - """VIN of the vehicle that you want to request data for.""" - id: String! - - """ - Filter data to only provide information that was sent from the vehicle after that timestamp. - """ - after: String - ): A + vehicle: A } +"""8-bit integer""" +scalar Int8 + +"""8-bit unsigned integer""" +scalar UInt8 + +"""16-bit integer""" +scalar Int16 + +"""16-bit unsigned integer""" +scalar UInt16 + +"""32-bit unsigned integer""" +scalar UInt32 + +"""64-bit integer""" +scalar Int64 + +"""64-bit unsigned integer""" +scalar UInt64 + """Branch A.""" type A { + """A float""" + float: A_Float_Enum + + """An int""" + int: A_Int_Enum + """A string""" - string: A_String + string: A_String_Enum """A string array""" - stringArray: A_StringArray - - """An int""" - int: A_Int + stringArray: [A_StringArray_Enum] +} - """A float""" - float: A_Float +enum AngularSpeedUnit_Enum { + DEGREE_PER_SECOND } -"""A string""" -type A_String { - """Value: A string""" - value: String +enum DistanceUnit_Enum { + METER + MILLIMETER +} - """Timestamp: A string""" - timestamp: String +enum LengthUnit_Enum { + KILOMETER } -"""A string array""" -type A_StringArray { - """Value: A string array""" - value: [String] +enum RelationUnit_Enum { + PERCENT +} - """Timestamp: A string array""" - timestamp: String +enum RotationalSpeedUnit_Enum { + REVOLUTIONS_PER_MINUTE } -"""An int""" -type A_Int { - """Value: An int""" - value: Int +enum TemperatureUnit_Enum { + DEGREE_CELSIUS +} - """Timestamp: An int""" - timestamp: String +enum A_Float_Enum { + _1_1 + _2_54 + _3 } -"""A float""" -type A_Float { - """Value: A float""" - value: Float +enum A_Int_Enum { + _1 + _2 + _3 +} - """Timestamp: A float""" - timestamp: String +enum A_String_Enum { + JANUARY + FEBRUARY } + +enum A_StringArray_Enum { + JANUARY + FEBRUARY + MARCH +} \ No newline at end of file