From 3707e751b50f35d77d72b7a4d38814d3ae786f61 Mon Sep 17 00:00:00 2001 From: Ray Osborn Date: Wed, 14 Oct 2026 12:04:12 -0500 Subject: [PATCH 1/3] Support contributed base classes and report base class violations Add contributed base classes to nxclasses via get_contributed_base_classes() and a 'contributed' keyword in get_base_classes(). Let lint_nxdl find and lint base classes in the contributed definitions, warning that they are contributed. Warn when a group used by an application definition is not allowed in its parent by the base class, and add inspect_application(). Co-Authored-By: Claude Opus 5.5 --- src/nexusformat/nexus/utils.py | 53 ++++++++++- src/nexusformat/nexus/validate.py | 153 +++++++++++++++++++++++------- tests/test_validate.py | 37 +++++++- 3 files changed, 206 insertions(+), 37 deletions(-) diff --git a/src/nexusformat/nexus/utils.py b/src/nexusformat/nexus/utils.py index c9a89c1..722eacb 100644 --- a/src/nexusformat/nexus/utils.py +++ b/src/nexusformat/nexus/utils.py @@ -526,7 +526,7 @@ def match_strings(pattern_string, target_string): return False -def get_base_classes(definitions=None): +def get_base_classes(definitions=None, contributed=True): """ Return a list of all base class names. @@ -534,16 +534,63 @@ def get_base_classes(definitions=None): ---------- definitions : str or Path or None The path to the NeXus definitions directory (default is None). + contributed : bool, optional + If True, the base classes in the 'contributed_definitions' directory + are appended to the standard base classes (default is True). Returns ------- list of str - A list of NeXus base class names. + A list of NeXus base class names. The standard base classes are + listed first, followed by the contributed base classes, if requested. """ definitions = get_definitions(definitions=definitions) base_class_path = definitions.joinpath('base_classes') - return sorted( + base_classes = sorted( [str(c.stem[:-5]) for c in base_class_path.glob('*.nxdl.xml')]) + if contributed: + base_classes += [c for c in get_contributed_base_classes(definitions) + if c not in base_classes] + return base_classes + + +def get_contributed_base_classes(definitions=None): + """ + Return a list of the base class names in the contributed definitions. + + The 'contributed_definitions' directory contains both base classes and + application definitions. Base classes are identified by the 'category' + attribute of the root 'definition' element of each NXDL file. + + Parameters + ---------- + definitions : str or Path or None + The path to the NeXus definitions directory (default is None). + + Returns + ------- + list of str + A sorted list of contributed NeXus base class names. + """ + definitions = get_definitions(definitions=definitions) + contributed_path = definitions.joinpath('contributed_definitions') + definition_tag = re.compile(rb']*>') + category = re.compile(rb'\bcategory\s*=\s*["\']([^"\']*)["\']') + base_classes = [] + for path in contributed_path.glob('*.nxdl.xml'): + # The root element is within the first few hundred bytes of the file + with open(path, 'rb') as f: + tag = definition_tag.search(f.read(16384)) + if tag: + match = category.search(tag.group(0)) + if match and match.group(1) == b'base': + base_classes.append(str(path.stem[:-5])) + else: + from xml.etree import ElementTree + root = ElementTree.parse(path).getroot() + if root.get('category') == 'base': + base_classes.append(str(path.stem[:-5])) + return sorted(base_classes) def get_definitions(definitions=None): diff --git a/src/nexusformat/nexus/validate.py b/src/nexusformat/nexus/validate.py index c5560e8..04784be 100644 --- a/src/nexusformat/nexus/validate.py +++ b/src/nexusformat/nexus/validate.py @@ -11,7 +11,8 @@ from pathlib import Path from . import NeXusError, NXentry, NXfield, NXgroup, NXlink, NXsubentry, nxopen -from .utils import (check_dimension_sizes, check_nametype, get_definitions, +from .utils import (check_dimension_sizes, check_nametype, + get_contributed_base_classes, get_definitions, get_log_level, get_logger, is_valid_bool, is_valid_char, is_valid_char_or_number, is_valid_complex, is_valid_float, is_valid_int, is_valid_iso8601, is_valid_name, @@ -571,6 +572,59 @@ def check_symbols(self, indent=None): level='warning') self.indent -= 1 + def check_in_parent(self, group): + """ + Checks whether a group is allowed in its parent group by the + parent's base class. + + Parameters + ---------- + group : NXgroup + The group whose name and class are to be checked. + + Returns + ------- + list of tuple + A list of (level, message) tuples describing the result. It + is empty if the group has no parent or is an NXroot group. + """ + results = [] + parent_group = group.nxgroup + if group.nxclass == 'NXroot' or parent_group is None: + return results + parent_validator = get_validator(parent_group.nxclass, + definitions=self.definitions) + parsed = False + if group.nxname in parent_validator.valid_groups: + cls = parent_validator.valid_groups[group.nxname].get('@type') + if cls is not None and group.nxclass != cls: + results.append(('error', + f'{group.nxname} should have a class of ' + f'{cls}, not {group.nxclass}')) + parsed = True + elif parent_validator.partial_groups: + for partial_name in parent_validator.partial_groups: + if match_strings(partial_name, group.nxname): + results.append(('info', + f'This group name matches "{partial_name}"' + f', which is allowed in ' + f'{parent_group.nxclass}')) + parsed = True + if not parsed: + if group.nxclass in parent_validator.valid_groups: + results.append(('info', 'This group is a valid class in ' + f'{parent_group.nxclass}')) + elif parent_validator.ignoreExtraGroups: + results.append(('info', + f'{group.nxclass} is not defined in ' + f'{parent_group.nxclass}. ' + 'Additional classes are allowed.')) + else: + results.append(('error', + f'{group.nxclass} is an invalid class in ' + f'{parent_group.nxclass}')) + return results + def validate(self, group, parent=None, indent=0, level=None): """ Validates a given group against the NeXus standard. @@ -616,36 +670,8 @@ def validate(self, group, parent=None, indent=0, level=None): self.output_log() return - parent_group = group.nxgroup - if group.nxclass != 'NXroot' and group.nxgroup is not None: - parent_group = group.nxgroup - parent_validator = get_validator(parent_group.nxclass, - definitions=self.definitions) - parsed = False - if group.nxname in parent_validator.valid_groups: - cls = parent_validator.valid_groups[group.nxname].get('@type') - if cls is not None and group.nxclass != cls: - self.log(f'{group.nxname} should have a class of ' - f'{cls}, not {group.nxclass}', level='error') - parsed = True - elif parent_validator.partial_groups: - for partial_name in parent_validator.partial_groups: - if match_strings(partial_name, group.nxname): - self.log(f'This group name matches "{partial_name}", ' - f'which is allowed in {parent_group.nxclass}') - parsed = True - if not parsed: - if group.nxclass in parent_validator.valid_groups: - self.log('This group is a valid class in ' - f'{parent_group.nxclass}') - elif group.nxclass not in parent_validator.valid_groups: - if parent_validator.ignoreExtraGroups: - self.log(f'{group.nxclass} is not defined in ' - f'{parent_group.nxclass}. ' - 'Additional classes are allowed.') - else: - self.log(f'{group.nxclass} is an invalid class in ' - f'{parent_group.nxclass}', level='error') + for level, message in self.check_in_parent(group): + self.log(message, level=level) for attribute in group.attrs: if attribute == 'target': @@ -1144,6 +1170,24 @@ def load_application(self, application, seen=None): self.filepath = app_path.resolve() return xml_dict + def check_base_class(self, group_validator, nxgroup): + """ + Reports a warning if a group required by the application + definition is not allowed in its parent by the parent's base + class. + + Parameters + ---------- + group_validator : GroupValidator + A group validator used to check the group against its parent. + nxgroup : NXgroup + The group in the NeXus file to be checked. + """ + for level, message in group_validator.check_in_parent(nxgroup): + if level == 'error': + self.log(f'{message}, but it is used by the application ' + 'definition', level='warning') + def validate_group(self, xml_dict, nxgroup, indent=0): """ Validates a NeXus group against an XML definition. @@ -1192,12 +1236,14 @@ def validate_group(self, xml_dict, nxgroup, indent=0): if i != 0: self.log(f'Group: {name}: {group}', level='all', indent=self.indent) + self.check_base_class(group_validator, nxsubgroup) self.validate_group(value[name], nxsubgroup, indent=self.indent) else: if i != 0: self.log(f'Group: {group}', level='all', indent=self.indent) + self.check_base_class(group_validator, nxsubgroup) self.validate_group(tag, nxsubgroup, indent=self.indent) self.indent -= 1 @@ -1372,8 +1418,11 @@ def lint_nxdl(filepath, definitions=None): 'error' or 'warning'. An empty list means no issues were found. """ filepath = Path(filepath) + results = [] if not filepath.exists(): stem = filepath.stem if filepath.suffix else filepath.name + if stem.endswith('.nxdl'): + stem = stem[:-5] defs = get_definitions(definitions=definitions) for search_dir in [defs / 'applications', defs / 'contributed_definitions', @@ -1381,14 +1430,18 @@ def lint_nxdl(filepath, definitions=None): candidate = search_dir / f'{stem}.nxdl.xml' if candidate.exists(): filepath = candidate + if (search_dir.name == 'contributed_definitions' and + stem in get_contributed_base_classes(defs)): + results.append( + (f'The base class "{stem}" is in the contributed ' + f'definitions', 'line 0', 'warning')) break else: return [(f'NXDL definition "{stem}" not found in the definitions ' - f'directory', str(defs), 'error')] + f'directory', 'line 0', 'error')] from lxml import etree as lxml_etree filepath = filepath.resolve() - results = [] try: doc = lxml_etree.parse(str(filepath)) @@ -1479,6 +1532,40 @@ def inspect_base_class(base_class, definitions=None): TerminalFormatter())) +def inspect_application(application, definitions=None): + """ + Prints the NXDL file of a NeXus application definition. + + Parameters + ---------- + application : str or Path + The name of the NeXus application definition to inspect or the + path to its NXDL file. + definitions : str or Path, optional + The path to the directory containing the NeXus definitions + (default is None). + """ + logger.setLevel(logging.INFO) + + try: + validator = ApplicationValidator(application, definitions=definitions) + root = parse_nxdl(validator.filepath) + except NeXusError as e: + logger.error(e) + return + + log(f"\nContents of the {Path(validator.filepath).stem[:-5]} " + "application definition") + log(f"NXDL File: {truncate_path(validator.filepath)}\n") + + from pygments import highlight + from pygments.formatters import TerminalFormatter + from pygments.lexers import XmlLexer + + log(highlight(ET.tostring(root, encoding='unicode'), XmlLexer(), + TerminalFormatter())) + + def log(message, level='info', indent=0): """ Logs a message at a specified level with optional indentation. diff --git a/tests/test_validate.py b/tests/test_validate.py index 23daeeb..f05aa74 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -4,7 +4,8 @@ import numpy as np import pytest -from nexusformat.nexus import NeXusError, NXfield +from nexusformat.nexus import (NeXusError, NXdata, NXentry, NXfield, + NXsource) from nexusformat.nexus.utils import xml_to_dict from nexusformat.nexus.validate import (ApplicationValidator, FieldValidator, GroupValidator) @@ -219,3 +220,37 @@ def test_xml_to_dict_tolerates_malformed_items(): assert result['enumeration'] == ['a'] assert result['dimensions']['dim'] == {} + + +def test_group_not_allowed_in_parent_is_reported(definitions): + write_nxdl(definitions, 'NXentry', content='') + write_nxdl(definitions, 'NXdata') + write_nxdl(definitions, 'NXsource') + entry = NXentry(NXdata(), NXsource()) + validator = GroupValidator('NXentry', definitions=definitions) + + allowed = validator.check_in_parent(entry['data']) + invalid = validator.check_in_parent(entry['source']) + + assert allowed == [('info', 'This group is a valid class in NXentry')] + assert invalid == [('error', 'NXsource is an invalid class in NXentry')] + + +def test_application_reports_base_class_violation_as_warning(definitions): + write_nxdl(definitions, 'NXentry', content='') + write_nxdl(definitions, 'NXdata') + write_nxdl(definitions, 'NXsource') + write_nxdl(definitions, 'NXapp', folder='applications', + content='', attributes='') + entry = NXentry(NXdata(), NXsource()) + validator = ApplicationValidator('NXapp', definitions=definitions) + group_validator = GroupValidator('NXentry', definitions=definitions) + + validator.check_base_class(group_validator, entry['data']) + assert validator.logged_messages == [] + + validator.check_base_class(group_validator, entry['source']) + assert [(level, message) for message, level, _ + in validator.logged_messages] == [ + ('warning', 'NXsource is an invalid class in NXentry, but it is ' + 'used by the application definition')] From 0925ae4aaa1b04f89c1067036c1ae04f63183db1 Mon Sep 17 00:00:00 2001 From: Ray Osborn Date: Wed, 14 Oct 2026 13:53:37 -0500 Subject: [PATCH 2/3] Use an empty location for lint messages without a line number Co-Authored-By: Claude Opus 5.5 --- src/nexusformat/nexus/validate.py | 8 ++++---- src/nexusformat/scripts/nxlint.py | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/nexusformat/nexus/validate.py b/src/nexusformat/nexus/validate.py index 04784be..ba2c7f5 100644 --- a/src/nexusformat/nexus/validate.py +++ b/src/nexusformat/nexus/validate.py @@ -1434,11 +1434,11 @@ def lint_nxdl(filepath, definitions=None): stem in get_contributed_base_classes(defs)): results.append( (f'The base class "{stem}" is in the contributed ' - f'definitions', 'line 0', 'warning')) + f'definitions', '', 'warning')) break else: return [(f'NXDL definition "{stem}" not found in the definitions ' - f'directory', 'line 0', 'error')] + f'directory', '', 'error')] from lxml import etree as lxml_etree filepath = filepath.resolve() @@ -1479,14 +1479,14 @@ def walk(elem): except (lxml_etree.XMLSyntaxError, lxml_etree.XMLSchemaParseError, OSError) as e: results.append((f'Unable to load the schema "{xsd_path}": {e}', - 'line 0', 'warning')) + '', 'warning')) schema = None if schema is not None and not schema.validate(doc): for err in schema.error_log: msg = re.sub(r'\{[^}]+\}', '', err.message) results.append((msg, f'line {err.line}', 'error')) - results.sort(key=lambda r: int(r[1].split()[-1])) + results.sort(key=lambda r: int(r[1].split()[-1]) if r[1] else 0) return results diff --git a/src/nexusformat/scripts/nxlint.py b/src/nexusformat/scripts/nxlint.py index fe18427..ec4c72c 100644 --- a/src/nexusformat/scripts/nxlint.py +++ b/src/nexusformat/scripts/nxlint.py @@ -58,7 +58,8 @@ def main(): results = lint_nxdl(filename, definitions=definitions) if results: for message, location, severity in results: - log(f'[{location}] {message}', level=severity) + log(f'[{location}] {message}' if location else message, + level=severity) log('\nFor help interpreting these errors, consult the NXDL ' 'reference at https://manual.nexusformat.org/nxdl.html', level='all') From 3a4e7cfad905784799cc68f239cc4b1cdf45e674 Mon Sep 17 00:00:00 2001 From: Ray Osborn Date: Wed, 14 Oct 2026 13:54:14 -0500 Subject: [PATCH 3/3] Add NXgoniometer to the contributed definitions Co-Authored-By: Claude Opus 5.5 --- .../NXgoniometer.nxdl.xml | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100755 src/nexusformat/definitions/contributed_definitions/NXgoniometer.nxdl.xml diff --git a/src/nexusformat/definitions/contributed_definitions/NXgoniometer.nxdl.xml b/src/nexusformat/definitions/contributed_definitions/NXgoniometer.nxdl.xml new file mode 100755 index 0000000..1b609fe --- /dev/null +++ b/src/nexusformat/definitions/contributed_definitions/NXgoniometer.nxdl.xml @@ -0,0 +1,67 @@ + + + + + + + Goniometer rotation angles and sample translations used during a + measurement. The goniometers used at neutron and x-ray + facilities are typically four-circle or six-circle goniometers, + or variants of either one. There is no universally recognized + naming convention for angles and translations, so the group + should contain a :ref:`NXcite` group, which describes how the + angles are defined. + + + + + A reference to a publication or online documentation that + describe the convention used in this group to define the + names of angles and sample translations. + + + + + The common name for the type of goniometer used in these + measurements. + + + + + One of the goniometer rotation angles defined by the + specified convention. + + + + + The translation of the sample from the centre of rotation of + the goniometer angles along one of the coordinate axes. + + + +