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.
+
+
+
+
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..ba2c7f5 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', '', 'warning'))
break
else:
return [(f'NXDL definition "{stem}" not found in the definitions '
- f'directory', str(defs), 'error')]
+ f'directory', '', 'error')]
from lxml import etree as lxml_etree
filepath = filepath.resolve()
- results = []
try:
doc = lxml_etree.parse(str(filepath))
@@ -1426,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
@@ -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/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')
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')]