2020from collections import defaultdict
2121from functools import reduce
2222from operator import getitem
23- from typing import Any , Iterable , List , Mapping , MutableMapping , Optional , Tuple , Union
23+ from typing import (
24+ Any ,
25+ Iterable ,
26+ List ,
27+ Mapping ,
28+ MutableMapping ,
29+ Optional ,
30+ Tuple ,
31+ Union ,
32+ Dict ,
33+ Literal ,
34+ )
2435
2536import h5py
2637import lxml .etree as ET
@@ -515,6 +526,9 @@ def handle_field(node: NexusNode, keys: Mapping[str, Any], prev_path: str):
515526 f"{ prev_path } /{ variant } " ,
516527 )
517528
529+ _ = check_reserved_suffix (f"{ prev_path } /{ variant } " , mapping )
530+ _ = check_reserved_prefix (f"{ prev_path } /{ variant } " , mapping , "field" )
531+
518532 # Check unit category
519533 if node .unit is not None :
520534 remove_from_not_visited (f"{ prev_path } /{ variant } /@units" )
@@ -532,9 +546,6 @@ def handle_field(node: NexusNode, keys: Mapping[str, Any], prev_path: str):
532546 prev_path = f"{ prev_path } /{ variant } " ,
533547 )
534548
535- # TODO: Build variadic map for fields and attributes
536- # Introduce variadic siblings in NexusNode?
537-
538549 def handle_attribute (node : NexusNode , keys : Mapping [str , Any ], prev_path : str ):
539550 full_path = remove_from_not_visited (f"{ prev_path } /@{ node .name } " )
540551 variants = get_variations_of (node , keys )
@@ -559,6 +570,7 @@ def handle_attribute(node: NexusNode, keys: Mapping[str, Any], prev_path: str):
559570 node .open_enum ,
560571 f"{ prev_path } /{ variant if variant .startswith ('@' ) else f'@{ variant } ' } " ,
561572 )
573+ _ = check_reserved_prefix (f"{ prev_path } /{ variant } " , mapping , "attribute" )
562574
563575 def handle_choice (node : NexusNode , keys : Mapping [str , Any ], prev_path : str ):
564576 global collector
@@ -847,6 +859,146 @@ def startswith_with_variations(
847859 # default
848860 return (False , 0 )
849861
862+ def check_reserved_suffix (key : str , mapping : MutableMapping [str , Any ]) -> bool :
863+ """
864+ Check if an associated field exists for a key with a reserved suffix.
865+
866+ Reserved suffixes imply the presence of an associated base field (e.g.,
867+ "temperature_errors" implies "temperature" must exist in the mapping).
868+
869+ Args:
870+ key (str):
871+ The full key path (e.g., "/ENTRY[entry1]/sample/temperature_errors").
872+ mapping (MutableMapping[str, Any]):
873+ The mapping containing the data to validate.
874+ This should be a dict of `/` separated paths.
875+
876+ Returns:
877+ bool:
878+ True if the suffix usage is valid or not applicable.
879+ False if the suffix is used without the expected associated base field.
880+ """
881+ reserved_suffixes = (
882+ "_end" ,
883+ "_increment_set" ,
884+ "_errors" ,
885+ "_indices" ,
886+ "_mask" ,
887+ "_set" ,
888+ "_weights" ,
889+ "_scaling_factor" ,
890+ "_offset" ,
891+ )
892+
893+ parent_path , name = key .rsplit ("/" , 1 )
894+ concept_name , instance_name = split_class_and_name_of (name )
895+
896+ for suffix in reserved_suffixes :
897+ if instance_name .endswith (suffix ):
898+ associated_field = instance_name .rsplit (suffix , 1 )[0 ]
899+
900+ if not any (
901+ k .startswith (parent_path + "/" )
902+ and (
903+ k .endswith (associated_field )
904+ or k .endswith (f"[{ associated_field } ]" )
905+ )
906+ for k in mapping
907+ ):
908+ collector .collect_and_log (
909+ key ,
910+ ValidationProblem .ReservedSuffixWithoutField ,
911+ associated_field ,
912+ suffix ,
913+ )
914+ return False
915+ break # We found the suffix and it passed
916+
917+ return True
918+
919+ def check_reserved_prefix (
920+ key : str ,
921+ mapping : MutableMapping [str , Any ],
922+ nx_type : Literal ["group" , "field" , "attribute" ],
923+ ) -> bool :
924+ """
925+ Check if a reserved prefix was used in the correct context.
926+
927+ Args:
928+ key (str): The full key path (e.g., "/ENTRY[entry1]/instrument/detector/@DECTRIS_config").
929+ mapping (MutableMapping[str, Any]):
930+ The mapping containing the data to validate.
931+ This should be a dict of `/` separated paths.
932+ Attributes are denoted with `@` in front of the last element.
933+ nx_type (Literal["group", "field", "attribute"]):
934+ The NeXus type the key represents. Determines which reserved prefixes are relevant.
935+
936+
937+ Returns:
938+ bool:
939+ True if the prefix usage is valid or not applicable.
940+ False if an invalid or misapplied reserved prefix is detected.
941+ """
942+ reserved_prefixes = {
943+ "attribute" : {
944+ "@BLUESKY_" : None , # do not use anywhere
945+ "@DECTRIS_" : "NXmx" ,
946+ "@IDF_" : None , # do not use anywhere
947+ "@NDAttr" : None ,
948+ "@NX_" : "all" ,
949+ "@PDBX_" : None , # do not use anywhere
950+ "@SAS_" : "NXcanSAS" ,
951+ "@SILX_" : None , # do not use anywhere
952+ },
953+ "field" : {
954+ "DECTRIS_" : "NXmx" ,
955+ },
956+ }
957+
958+ prefixes = reserved_prefixes .get (nx_type )
959+ if not prefixes :
960+ return True
961+
962+ name = key .rsplit ("/" , 1 )[- 1 ]
963+
964+ if not name .startswith (tuple (prefixes )):
965+ return False # Irrelevant prefix, no check needed
966+
967+ for prefix , allowed_context in prefixes .items ():
968+ if not name .startswith (prefix ):
969+ continue
970+
971+ if allowed_context is None :
972+ # This prefix is disallowed entirely
973+ collector .collect_and_log (
974+ prefix ,
975+ ValidationProblem .ReservedPrefixInWrongContext ,
976+ None ,
977+ key ,
978+ )
979+ return False
980+ if allowed_context == "all" :
981+ # We can freely use this prefix everywhere.
982+ return True
983+
984+ # Check that the prefix is used in the correct context.
985+ match = re .match (r"(/ENTRY\[[^]]+])" , key )
986+ definition_value = None
987+ if match :
988+ definition_key = f"{ match .group (1 )} /definition"
989+ definition_value = mapping .get (definition_key )
990+
991+ if definition_value != allowed_context :
992+ collector .collect_and_log (
993+ prefix ,
994+ ValidationProblem .ReservedPrefixInWrongContext ,
995+ allowed_context ,
996+ key ,
997+ )
998+ return False
999+
1000+ return True
1001+
8501002 missing_type_err = {
8511003 "field" : ValidationProblem .MissingRequiredField ,
8521004 "group" : ValidationProblem .MissingRequiredGroup ,
@@ -937,6 +1089,16 @@ def startswith_with_variations(
9371089 keys_to_remove .append (not_visited_key )
9381090 continue
9391091
1092+ if "@" not in not_visited_key .rsplit ("/" , 1 )[- 1 ]:
1093+ check_reserved_suffix (not_visited_key , mapping )
1094+ check_reserved_prefix (not_visited_key , mapping , "field" )
1095+
1096+ else :
1097+ associated_field = not_visited_key .rsplit ("/" , 1 )[- 2 ]
1098+ # Check the prefix both for this attribute and the field it belongs to
1099+ check_reserved_prefix (not_visited_key , mapping , "attribute" )
1100+ check_reserved_prefix (associated_field , mapping , "field" )
1101+
9401102 if is_documented (not_visited_key , tree ):
9411103 continue
9421104
0 commit comments