Skip to content

Commit a63e402

Browse files
committed
feat(s2dm): add extended attributes metadata annotations
Signed-off-by: JD Alvarez <8550265+jdacoello@users.noreply.github.comt -c core.editor=true rebase --continue t status --short >
1 parent 530737a commit a63e402

File tree

6 files changed

+187
-16
lines changed

6 files changed

+187
-16
lines changed

docs/s2dm.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,37 @@ Likewise, the `driverPosition` was derived from `Vehicle.Cabin.DriverPosition` a
4646

4747
The `@vspec` directive is also used to annotate original names when they have been modified for GraphQL compliance (for example, see [Enum Value Sanitization](#enum-value-sanitization)).
4848

49+
#### Extended Attributes
50+
51+
If the VSpec model uses extended attributes (custom metadata), the exporter add them to `@vspec` metadata annotations.
52+
53+
**Example VSS with extended attributes `source` and `quality`:**
54+
```yaml
55+
Vehicle.Speed:
56+
datatype: float
57+
type: sensor
58+
unit: km/h
59+
source: ecu0xAA # Extended attribute
60+
quality: 100 # Extended attribute
61+
```
62+
63+
**Generated GraphQL:**
64+
```graphql
65+
type Vehicle {
66+
speed(unit: VelocityUnitEnum = KILOMETERS_PER_HOUR): Float
67+
@vspec(
68+
element: SENSOR,
69+
fqn: "Vehicle.Speed",
70+
metadata: [
71+
{key: "source", value: "ecu0xAA"},
72+
{key: "quality", value: "100"}
73+
]
74+
)
75+
}
76+
```
77+
78+
Extended attributes work on all VSS elements: branches, sensors, actuators, attributes, and structs.
79+
4980
### VSS Data Types Support
5081
The exporter handles all `vspec` data types as follows:
5182
- **Strings** → GraphQL String

src/vss_tools/exporters/s2dm/schema_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def generate_s2dm_schema(
100100
for fqn in branches_df.index:
101101
if fqn not in types_registry:
102102
types_registry[fqn] = create_object_type(
103-
fqn, branches_df, leaves_df, types_registry, unit_enums, vspec_comments
103+
fqn, branches_df, leaves_df, types_registry, unit_enums, vspec_comments, extended_attributes
104104
)
105105

106106
# Assemble complete schema

src/vss_tools/exporters/s2dm/type_builders.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,24 @@
4646
from .metadata_tracker import build_field_path
4747

4848

49+
def _extract_extended_attributes(row: pd.Series, extended_attributes: tuple[str, ...]) -> dict[str, Any]:
50+
"""
51+
Extract extended attributes from a DataFrame row.
52+
53+
Args:
54+
row: pandas Series (DataFrame row) containing VSS node data
55+
extended_attributes: Tuple of extended attribute names to extract
56+
57+
Returns:
58+
Dictionary containing only the extended attributes that exist and are not NA
59+
"""
60+
extracted = {}
61+
for ext_attr in extended_attributes:
62+
if ext_attr in row.index and pd.notna(row.get(ext_attr)):
63+
extracted[ext_attr] = row[ext_attr]
64+
return extracted
65+
66+
4967
def create_unit_enums() -> tuple[dict[str, GraphQLEnumType], dict[str, dict[str, dict[str, str]]]]:
5068
"""
5169
Create GraphQL enum types for VSS units grouped by quantity.
@@ -307,7 +325,12 @@ def create_struct_types(
307325
fields[field_name] = GraphQLField(GraphQLNonNull(base_type), description=prop_row.get("description", ""))
308326

309327
field_path = build_field_path(type_name, field_name)
310-
vspec_comments["field_vss_types"][field_path] = {"element": "STRUCT_PROPERTY", "fqn": prop_fqn}
328+
field_metadata = {"element": "STRUCT_PROPERTY", "fqn": prop_fqn}
329+
330+
# Capture extended attributes if present
331+
field_metadata.update(_extract_extended_attributes(prop_row, extended_attributes))
332+
333+
vspec_comments["field_vss_types"][field_path] = field_metadata
311334

312335
if pd.notna(prop_row.get("min")) or pd.notna(prop_row.get("max")):
313336
vspec_comments["field_ranges"][field_path] = {
@@ -321,7 +344,11 @@ def create_struct_types(
321344
struct_types[type_name] = GraphQLObjectType(
322345
name=type_name, fields=fields, description=struct_row.get("description", "")
323346
)
324-
vspec_comments["vss_types"][type_name] = {"element": "STRUCT", "fqn": fqn}
347+
348+
# Store type-level metadata including extended attributes
349+
type_metadata = {"element": "STRUCT", "fqn": fqn}
350+
type_metadata.update(_extract_extended_attributes(struct_row, extended_attributes))
351+
vspec_comments["vss_types"][type_name] = type_metadata
325352

326353
return struct_types
327354

@@ -367,6 +394,7 @@ def create_object_type(
367394
types_registry: dict[str, Any],
368395
unit_enums: dict[str, GraphQLEnumType],
369396
vspec_comments: dict[str, dict[str, Any]],
397+
extended_attributes: tuple[str, ...] = (),
370398
) -> GraphQLObjectType:
371399
"""
372400
Create GraphQL object type for a VSS branch.
@@ -381,6 +409,7 @@ def create_object_type(
381409
types_registry: Registry of already-created types
382410
unit_enums: Unit enum types
383411
vspec_comments: Metadata tracking dictionary
412+
extended_attributes: Extended attribute names from CLI
384413
385414
Returns:
386415
GraphQL object type for the branch
@@ -415,7 +444,12 @@ def get_fields() -> dict[str, GraphQLField]:
415444
field_path = build_field_path(type_name, field_name)
416445

417446
if leaf_type := _get_vss_type_if_valid(leaf_row):
418-
vspec_comments["field_vss_types"][field_path] = {"element": leaf_type, "fqn": child_fqn}
447+
field_metadata = {"element": leaf_type, "fqn": child_fqn}
448+
449+
# Capture extended attributes if present
450+
field_metadata.update(_extract_extended_attributes(leaf_row, extended_attributes))
451+
452+
vspec_comments["field_vss_types"][field_path] = field_metadata
419453

420454
if pd.notna(leaf_row.get("min")) or pd.notna(leaf_row.get("max")):
421455
vspec_comments["field_ranges"][field_path] = {
@@ -434,12 +468,12 @@ def get_fields() -> dict[str, GraphQLField]:
434468
for child_fqn, child_row in branches_df[branches_df["parent"] == fqn].iterrows():
435469
field_name = convert_name_for_graphql_schema(child_row["name"], GraphQLElementType.FIELD, S2DM_CONVERSIONS)
436470
child_type = types_registry.get(child_fqn) or create_object_type(
437-
child_fqn, branches_df, leaves_df, types_registry, unit_enums, vspec_comments
471+
child_fqn, branches_df, leaves_df, types_registry, unit_enums, vspec_comments, extended_attributes
438472
)
439473
types_registry[child_fqn] = child_type
440474

441475
hoisted_fields = get_hoisted_fields(
442-
child_fqn, child_row, leaves_df, types_registry, unit_enums, vspec_comments
476+
child_fqn, child_row, leaves_df, types_registry, unit_enums, vspec_comments, extended_attributes
443477
)
444478
fields.update(hoisted_fields)
445479

@@ -450,7 +484,10 @@ def get_fields() -> dict[str, GraphQLField]:
450484

451485
return fields
452486

453-
vspec_comments["vss_types"][type_name] = {"element": "BRANCH", "fqn": fqn}
487+
# Store type-level metadata including extended attributes
488+
type_metadata = {"element": "BRANCH", "fqn": fqn}
489+
type_metadata.update(_extract_extended_attributes(branch_row, extended_attributes))
490+
vspec_comments["vss_types"][type_name] = type_metadata
454491

455492
return GraphQLObjectType(name=type_name, fields=get_fields, description=branch_row.get("description", ""))
456493

@@ -462,6 +499,7 @@ def get_hoisted_fields(
462499
types_registry: dict[str, Any],
463500
unit_enums: dict[str, GraphQLEnumType],
464501
vspec_comments: dict[str, dict[str, Any]],
502+
extended_attributes: tuple[str, ...] = (),
465503
) -> dict[str, GraphQLField]:
466504
"""Get fields to hoist from instantiated child branch to parent."""
467505
hoisted: dict[str, GraphQLField] = {}
@@ -492,12 +530,17 @@ def get_hoisted_fields(
492530
field_path = build_field_path(parent_type_name, hoisted_field_name)
493531

494532
if leaf_type := _get_vss_type_if_valid(leaf_row):
495-
vspec_comments["field_vss_types"][field_path] = {
533+
field_metadata = {
496534
"element": leaf_type,
497535
"fqn": leaf_fqn,
498536
"instantiate": False,
499537
}
500538

539+
# Capture extended attributes if present
540+
field_metadata.update(_extract_extended_attributes(leaf_row, extended_attributes))
541+
542+
vspec_comments["field_vss_types"][field_path] = field_metadata
543+
501544
if pd.notna(leaf_row.get("min")) or pd.notna(leaf_row.get("max")):
502545
vspec_comments["field_ranges"][field_path] = {
503546
"min": leaf_row.get("min") if pd.notna(leaf_row.get("min")) else None,

src/vss_tools/utils/graphql_directive_processor.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -237,13 +237,24 @@ def _process_field_directives(self, lines: list[str], vspec_comments: dict) -> l
237237
continue
238238

239239
if in_type and line.strip().startswith(f"{field_name}") and "@vspec" not in line:
240-
# Build directive with element (mandatory), fqn, and optional metadata
240+
# Build metadata array from extended attributes
241+
metadata_entries = []
242+
243+
# Add instantiate metadata if present
241244
if instantiate is False:
242-
# Add metadata for hoisted non-instantiated fields
243-
directive = (
244-
f'@vspec(element: {element}, fqn: "{fqn}", '
245-
f'metadata: [{{key: "instantiate", value: "false"}}])'
246-
)
245+
metadata_entries.append('{key: "instantiate", value: "false"}')
246+
247+
# Add extended attributes metadata
248+
for key, value in vss_info.items():
249+
if key not in ["element", "fqn", "instantiate"]:
250+
# Escape quotes in value
251+
escaped_value = str(value).replace('"', '\\\\"')
252+
metadata_entries.append(f'{{key: "{key}", value: "{escaped_value}"}}')
253+
254+
# Build directive
255+
if metadata_entries:
256+
metadata_str = ", ".join(metadata_entries)
257+
directive = f'@vspec(element: {element}, fqn: "{fqn}", metadata: [{metadata_str}])'
247258
else:
248259
# Standard directive without metadata
249260
directive = f'@vspec(element: {element}, fqn: "{fqn}")'
@@ -375,11 +386,25 @@ def _process_type_directives(self, lines: list[str], vspec_comments: dict) -> li
375386
)
376387
new_line += f" {directive}"
377388
elif needs_vss_type and "@vspec" not in line:
378-
# Regular types get element + fqn only
389+
# Regular types get element + fqn + extended attributes metadata
379390
vss_info = vspec_comments["vss_types"][type_name]
380391
element = vss_info["element"]
381392
fqn = vss_info["fqn"]
382-
directive = f'@vspec(element: {element}, fqn: "{fqn}")'
393+
394+
# Build metadata array from extended attributes
395+
metadata_entries = []
396+
for key, value in vss_info.items():
397+
if key not in ["element", "fqn"]:
398+
# Escape quotes in value
399+
escaped_value = str(value).replace('"', '\\\\"')
400+
metadata_entries.append(f'{{key: "{key}", value: "{escaped_value}"}}')
401+
402+
# Build directive
403+
if metadata_entries:
404+
metadata_str = ", ".join(metadata_entries)
405+
directive = f'@vspec(element: {element}, fqn: "{fqn}", metadata: [{metadata_str}])'
406+
else:
407+
directive = f'@vspec(element: {element}, fqn: "{fqn}")'
383408
new_line += f" {directive}"
384409

385410
new_line += " {"

tests/test_s2dm_exporter.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,3 +702,42 @@ def test_instance_dimension_enum_sanitization(self):
702702
assert 'FRONT_RIGHT @vspec(metadata: [{key: "originalName", value: "FrontRight"}])' in schema_str
703703
assert 'REAR_LEFT @vspec(metadata: [{key: "originalName", value: "RearLeft"}])' in schema_str
704704
assert 'REAR_RIGHT @vspec(metadata: [{key: "originalName", value: "RearRight"}])' in schema_str
705+
706+
def test_extended_attributes_in_metadata(self):
707+
"""Test that extended attributes are captured and added to @vspec metadata."""
708+
# Load the test vspec with extended attributes
709+
tree, _ = get_trees(
710+
vspec=Path("tests/vspec/test_s2dm/test_extended_attributes.vspec"),
711+
include_dirs=(),
712+
aborts=(),
713+
strict=False,
714+
extended_attributes=("source", "quality", "calibration", "customMetadata", "anotherAttribute"),
715+
quantities=(Path("tests/vspec/test_s2dm/test_quantities.yaml"),),
716+
units=(Path("tests/vspec/test_s2dm/test_units.yaml"),),
717+
overlays=(),
718+
expand=False,
719+
)
720+
721+
schema, unit_metadata, allowed_metadata, vspec_comments = generate_s2dm_schema(
722+
tree, extended_attributes=("source", "quality", "calibration", "customMetadata", "anotherAttribute")
723+
)
724+
schema_str = print_schema_with_vspec_directives(schema, unit_metadata, allowed_metadata, vspec_comments)
725+
726+
# Check Vehicle.Speed has source and quality in metadata
727+
assert "speed(unit: RelationUnitEnum = PERCENT): Float" in schema_str
728+
assert '@vspec(element: SENSOR, fqn: "Vehicle.Speed"' in schema_str
729+
assert '{key: "source", value: "ecu0xAA"}' in schema_str
730+
assert '{key: "quality", value: "100"}' in schema_str
731+
732+
# Check Vehicle.Temperature has source, quality, and calibration
733+
assert "temperature(unit: AngleUnitEnum = DEGREE): Int16" in schema_str
734+
assert '@vspec(element: SENSOR, fqn: "Vehicle.Temperature"' in schema_str
735+
assert '{key: "source", value: "ecu0xBB"}' in schema_str
736+
assert '{key: "quality", value: "95"}' in schema_str
737+
assert '{key: "calibration", value: "factory"}' in schema_str
738+
739+
# Check Vehicle.Info.Model has customMetadata and anotherAttribute
740+
assert "model: String" in schema_str
741+
assert '@vspec(element: ATTRIBUTE, fqn: "Vehicle.Info.Model"' in schema_str
742+
assert '{key: "customMetadata", value: "test_value"}' in schema_str
743+
assert '{key: "anotherAttribute", value: "42"}' in schema_str
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Test vspec for extended attributes in S2DM exporter
2+
3+
Vehicle:
4+
type: branch
5+
description: High-level vehicle data.
6+
7+
Vehicle.Speed:
8+
datatype: float
9+
type: sensor
10+
unit: percent
11+
description: Vehicle speed.
12+
source: ecu0xAA
13+
quality: 100
14+
15+
Vehicle.Temperature:
16+
datatype: int16
17+
type: sensor
18+
unit: degrees
19+
description: Ambient temperature.
20+
source: ecu0xBB
21+
quality: 95
22+
calibration: factory
23+
24+
Vehicle.Info:
25+
type: branch
26+
description: Vehicle information.
27+
28+
Vehicle.Info.Model:
29+
datatype: string
30+
type: attribute
31+
description: Vehicle model.
32+
customMetadata: test_value
33+
anotherAttribute: 42

0 commit comments

Comments
 (0)