Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="nxdlformat.xsl" ?>
<!--
# NeXus - Neutron and X-ray Common Data Format
#
# Copyright (C) 2025 NeXus International Advisory Committee (NIAC)
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# For further information, see http://www.nexusformat.org
-->
<definition xmlns="http://definition.nexusformat.org/nxdl/3.1" category="base"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://definition.nexusformat.org/nxdl/3.1 ../nxdl.xsd"
name="NXgoniometer"
type="group" extends="NXcomponent"
>

<doc>
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.
</doc>

<group name="convention" type="NXcite">
<doc>
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.
</doc>
</group>
<field name="goniometer">
<doc>
The common name for the type of goniometer used in these
measurements.
</doc>
</field>
<field name="ROTATION_ANGLE" type="NX_FLOAT" units="NX_ANGLE" nameType="any">
<doc>
One of the goniometer rotation angles defined by the
specified convention.
</doc>
</field>
<field name="SAMPLE_TRANSLATION" type="NX_FLOAT" units="NX_LENGTH" nameType="any">
<doc>
The translation of the sample from the centre of rotation of
the goniometer angles along one of the coordinate axes.
</doc>
</field>

</definition>
53 changes: 50 additions & 3 deletions src/nexusformat/nexus/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,24 +526,71 @@ 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.

Parameters
----------
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'<definition\b[^>]*>')
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):
Expand Down
157 changes: 122 additions & 35 deletions src/nexusformat/nexus/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1372,23 +1418,30 @@ 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',
defs / 'base_classes']:
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))
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/nexusformat/scripts/nxlint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading
Loading