From 5e0140aa069df9dca0c2e582f37d50f40a1b6fbe Mon Sep 17 00:00:00 2001 From: Ray Osborn Date: Tue, 29 Sep 2026 14:56:05 -0500 Subject: [PATCH 1/7] Use file size and hash to reduce false-positive modification warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The is_modified() method on NXroot previously compared only the filesystem mtime to detect external changes. This caused false positives whenever a process opened the file in write mode without writing anything (e.g., nxcheck -a, h5py.File(path, 'a').close()), since the HDF5 library updates the superblock consistency flags — and therefore st_mtime — on every write-mode open. HDF5 provides no reliable internal API for detecting actual data changes: h5py.h5o.get_info().mtime always returns 0, and ctime tracks object creation only (and only if track_times=True was set at file creation). The fix adds a three-stage check to is_modified(): 1. If st_mtime has not advanced, return False immediately (unchanged fast path). 2. If st_mtime has advanced but st_size is unchanged, read the first 64 KB of the file and compare an Adler-32 checksum against a baseline recorded at last open/close. If the hash matches, treat the mtime bump as a false positive and return False. 3. Only if st_mtime advanced *and* either the size or the hash differs is the file reported as modified. New additions to support the check: - NXFile.size property (st_size from the same stat() call as mtime) - NXFile.file_hash(nbytes=65536) — zlib.adler32 of leading file bytes - NXroot._file_size and NXroot._file_hash, recorded alongside _mtime at every open(), close(), reload(), and nxfile setter call - NXroot.serialize() persists _file_size; deserialize() restores it Co-Authored-By: Claude Opus 5.5 (1M context) --- src/nexusformat/nexus/tree.py | 61 +++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/src/nexusformat/nexus/tree.py b/src/nexusformat/nexus/tree.py index edac8ca..79bce50 100644 --- a/src/nexusformat/nexus/tree.py +++ b/src/nexusformat/nexus/tree.py @@ -212,6 +212,7 @@ import re import sys import warnings +import zlib from copy import copy, deepcopy from pathlib import Path from pathlib import PurePosixPath as PurePath @@ -563,6 +564,23 @@ def mtime(self): """Return the modification time of the NeXus file.""" return Path(self._filename).stat().st_mtime + @property + def size(self): + """Return the size of the NeXus file in bytes.""" + return Path(self._filename).stat().st_size + + def file_hash(self, nbytes=65536): + """Return a fast hash of the leading bytes of the NeXus file. + + Computes an Adler-32 checksum over the first `nbytes` of the file + (default 64 KB). This is used as a secondary modification check when + the filesystem mtime has changed but the file size has not, which + can happen when a file is opened in write mode without any data being + written. + """ + with open(self._filename, 'rb') as f: + return zlib.adler32(f.read(nbytes)) + @property def lock(self): """ @@ -713,6 +731,7 @@ def open(self, **kwargs): self._file = self.h5.File(self._filename, self._mode, **kwargs) if self._root: self._root._mtime = self.mtime + self._root._file_size = self.size self.nxpath = '/' def close(self): @@ -730,6 +749,8 @@ def close(self): self.release_lock() try: self._root._mtime = self.mtime + self._root._file_size = self.size + self._root._file_hash = self.file_hash() except Exception: pass @@ -1424,6 +1445,7 @@ def reload(self): self._root._changed = True self._root._file_modified = False self._root._mtime = self.mtime + self._root._file_size = self.size def rename(self, old_path, new_path): """ @@ -6351,6 +6373,8 @@ def __init__(self, *args, **kwargs): self._class = 'NXroot' self._backup = None self._mtime = None + self._file_size = None + self._file_hash = None self._file_modified = False NXgroup.__init__(self, *args, **kwargs) @@ -6386,6 +6410,7 @@ def serialize(self): """Serialize the root group to a dictionary.""" serialized_root = super().serialize() serialized_root['mtime'] = self.mtime + serialized_root['file_size'] = self._file_size return serialized_root @classmethod @@ -6393,6 +6418,7 @@ def deserialize(self, serialized_root): """Deserialize the root group from a dictionary.""" obj = NXgroup.deserialize(serialized_root) obj._mtime = serialized_root['mtime'] + obj._file_size = serialized_root.get('file_size') if ('filename' in serialized_root and Path(serialized_root['filename']).is_file()): obj._file = NXFile(obj._filename, 'r') @@ -6409,13 +6435,40 @@ def reload(self): f"'{self.nxname}' has no associated file to reload") def is_modified(self): - """True if the file has been modified by an external process.""" + """True if the file has been modified by an external process. + + Uses a three-stage check to reduce false positives from operations + that update the filesystem mtime without changing the file content + (e.g., opening the file in write mode without writing anything): + + 1. If the filesystem mtime has not advanced, the file is not modified. + 2. If the mtime *has* advanced but the file size is unchanged *and* + the leading 64 KB of the file hash to the same value as when the + file was last opened or closed, the mtime change is treated as a + false positive and the file is considered unmodified. + 3. Only if the mtime advanced *and* either the size changed or the + leading-bytes hash differs is the file reported as modified. + """ if self._file is None: self._file_modified = False else: - _mtime = self._file.mtime + _stat = Path(self._file.filename).stat() + _mtime = _stat.st_mtime if self._mtime and _mtime > self._mtime: - self._file_modified = True + _size = _stat.st_size + if self._file_size is not None and _size == self._file_size: + try: + _hash = self._file.file_hash() + except PermissionError: + self._file_modified = True + return self._file_modified + if (self._file_hash is not None and + _hash == self._file_hash): + self._file_modified = False + else: + self._file_modified = True + else: + self._file_modified = True else: self._file_modified = False return self._file_modified @@ -6576,6 +6629,8 @@ def nxfile(self, filename): self._attrs._setattrs(root.attrs) self._file = NXFile(self._filename, self._mode) self._mtime = self._file.mtime + self._file_size = self._file.size + self._file_hash = self._file.file_hash() self.set_changed() else: raise NeXusError(f"'{Path(filename).resolve()}' does not exist") From f97bb479db30cf6d24e020f4ee27409050a007e7 Mon Sep 17 00:00:00 2001 From: Ray Osborn Date: Thu, 1 Oct 2026 19:14:10 -0500 Subject: [PATCH 2/7] Fix AttributeError and KeyError crashes in validate.py Three null-safety issues caused crashes on valid but edge-case NXDL structures: - GroupValidator.__init__() did not initialise self.symbols before the conditional block, so accessing it for a group whose nxclass is None (i.e. a group with no NX_class attribute) raised AttributeError. Fixed by setting self.symbols = {} unconditionally in __init__(). - GroupValidator.get_xml_dict() and ApplicationValidator.load_application() accessed xml_dict['symbols']['symbol'] directly. A block that contains only a child (and no elements) is valid NXDL but produced a KeyError. Fixed by using .get('symbol', {}). - GroupValidator.validate() accessed valid_groups[name]['@type'] without checking for its presence. Groups defined in a base class without an explicit type attribute caused a KeyError. Fixed by using .get('@type') and guarding the class-mismatch check with 'if cls is not None'. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/nexusformat/nexus/validate.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/nexusformat/nexus/validate.py b/src/nexusformat/nexus/validate.py index 867dbe7..5fd6cb3 100644 --- a/src/nexusformat/nexus/validate.py +++ b/src/nexusformat/nexus/validate.py @@ -232,6 +232,7 @@ def __init__(self, nxclass, definitions=None): """ super().__init__(definitions=definitions) self.nxclass = nxclass + self.symbols = {} if self.nxclass is None or self.nxclass == 'NXgroup': self.xml_dict = None self.valid_class = False @@ -293,7 +294,7 @@ def get_xml_dict(self): xml_extended_dict = parent_validator.get_xml_dict() xml_dict = merge_dicts(xml_dict, xml_extended_dict) if 'symbols' in xml_dict: - self.symbols = xml_dict['symbols']['symbol'] + self.symbols = xml_dict['symbols'].get('symbol', {}) else: self.symbols = {} else: @@ -561,8 +562,8 @@ def validate(self, group, parent=None, indent=0, level=None): definitions=self.definitions) parsed = False if group.nxname in parent_validator.valid_groups: - cls = parent_validator.valid_groups[group.nxname]['@type'] - if group.nxclass != cls: + 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 @@ -1057,7 +1058,7 @@ def load_application(self, application): 'does not contain the correct root tag.') symbols = xml_root.find('symbols') if symbols is not None: - self.symbols.update(xml_to_dict(symbols)['symbol']) + self.symbols.update(xml_to_dict(symbols).get('symbol', {})) xml_dict = xml_to_dict(xml_root.find('group')) if xml_root.attrib['extends'] != 'NXobject': xml_extended_dict = self.load_application( From f6f9921bcffc8a0d4c88e441960929ea4c41112d Mon Sep 17 00:00:00 2001 From: Ray Osborn Date: Thu, 1 Oct 2026 19:14:38 -0500 Subject: [PATCH 3/7] Detect and report broken chained internal links A link L1 that resolves to a second link L2 which itself is broken (its target no longer exists) was previously reported as valid because item.exists() returned True for L1. The validator now looks one level deeper for internal links: if the resolved object is another NXlink and that link does not exist, the validator logs a descriptive error naming both the intermediate link target and the dangling endpoint. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/nexusformat/nexus/validate.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/nexusformat/nexus/validate.py b/src/nexusformat/nexus/validate.py index 5fd6cb3..63bca9d 100644 --- a/src/nexusformat/nexus/validate.py +++ b/src/nexusformat/nexus/validate.py @@ -123,6 +123,19 @@ def is_valid_link(self, item): else: target = item._target if item.exists(): + if not item.is_external() and isinstance(item, NXlink): + try: + resolved = item.nxlink + if isinstance(resolved, NXlink) and not resolved.exists(): + chain_target = ( + f'{resolved._filename}[{resolved._target}]' + if resolved.is_external() else resolved._target) + self.log(f'This is a broken link to "{target}", ' + f'which itself links to "{chain_target}"', + level='error') + return False + except Exception: + pass if isinstance(item, NXfield): self.log(f'This field is linked to "{target}"', level='info') elif isinstance(item, NXgroup): From 7951015679c0fa8e41882f30d830afdccb15a7bb Mon Sep 17 00:00:00 2001 From: Ray Osborn Date: Thu, 1 Oct 2026 19:16:21 -0500 Subject: [PATCH 4/7] Add lint_nxdl function and improve validate_application robustness lint_nxdl() is a new function that validates the structural integrity of an NXDL file. It uses lxml to parse the file and walks the element tree to detect nested elements, which are a common authoring mistake that confuses the validator. If the bundled nxdl.xsd schema is present, it also runs a full XSD validation and reports any additional errors with namespace prefixes stripped for readability. validate_application() is updated in three ways: - An upfront Path.exists() check gives a clear error message instead of letting nxopen() raise an obscure exception for missing files. - The entire open/validate block is now wrapped in a try/except NeXusError so file-level errors (corrupt HDF5, permission denied, etc.) are logged and return gracefully instead of propagating. - Before validating, lint_nxdl() is called on the application definition; if it finds issues a warning is logged advising the user to run 'nxlint ' for details. Also fixes inspect_base_class(): when the NXDL file for a class does not exist, the fallback message incorrectly referenced validator.filepath (which is also None in that case) instead of validator.definitions. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/nexusformat/nexus/validate.py | 159 +++++++++++++++++++++++++----- 1 file changed, 135 insertions(+), 24 deletions(-) diff --git a/src/nexusformat/nexus/validate.py b/src/nexusformat/nexus/validate.py index 63bca9d..5dcf73e 100644 --- a/src/nexusformat/nexus/validate.py +++ b/src/nexusformat/nexus/validate.py @@ -6,6 +6,7 @@ # The full license is in the file COPYING, distributed with this software. # ----------------------------------------------------------------------------- import logging +import re import xml.etree.ElementTree as ET from pathlib import Path @@ -1212,36 +1213,146 @@ def validate_application(filename, path=None, application=None, A tuple containing the total number of warnings and errors encountered while validating the file. """ - with nxopen(filename) as root: - if path is None: - nxpath = root.NXentry[0].nxpath - else: - nxpath = path - entry = root[nxpath] - if not (isinstance(entry, NXentry) or isinstance(entry, NXsubentry)): - logger.error( - f'Path "{nxpath}" is not a NXentry or NXsubentry group') - return - elif application is None and 'definition' in entry: - application = entry['definition'].nxvalue - elif application is None: - logger.error(f'No application definition is defined in "{nxpath}"') - return + if not Path(filename).exists(): + logger.error(f'File "{filename}" does not exist') + return + try: + with nxopen(filename) as root: + if path is None: + nxpath = root.NXentry[0].nxpath + else: + nxpath = path + entry = root[nxpath] + if not (isinstance(entry, NXentry) + or isinstance(entry, NXsubentry)): + logger.error( + f'Path "{nxpath}" is not a NXentry or NXsubentry group') + return + elif application is None and 'definition' in entry: + application = entry['definition'].nxvalue + elif application is None: + logger.error( + f'No application definition is defined in "{nxpath}"') + return - try: - validator = ApplicationValidator(application, - definitions=definitions) - except NeXusError as e: - logger.error(e) - return + try: + validator = ApplicationValidator(application, + definitions=definitions) + except NeXusError as e: + logger.error(e) + return - log_header(validator, filename, nxpath, application) + log_header(validator, filename, nxpath, application) + + lint_results = lint_nxdl(validator.filepath, + definitions=definitions) + if lint_results: + n_errors = sum( + 1 for _, _, sev in lint_results if sev == 'error') + n_warnings = sum( + 1 for _, _, sev in lint_results if sev == 'warning') + issues = [] + if n_errors: + issues.append(f'{n_errors} error(s)') + if n_warnings: + issues.append(f'{n_warnings} warning(s)') + if Path(application).exists(): + nxlint_cmd = f'nxlint {application}' + elif definitions: + nxlint_cmd = f'nxlint -d {definitions} {application}' + else: + nxlint_cmd = f'nxlint {application}' + logger.warning( + f'The application definition has ' + f'{" and ".join(issues)} that may affect validation. ' + f'Run \'{nxlint_cmd}\' for details.') - validator.validate(entry) + validator.validate(entry) + except NeXusError as e: + logger.error(e) + return return log_summary() +def lint_nxdl(filepath, definitions=None): + """ + Checks an NXDL file for structural errors. + + Performs validation using lxml against the nxdl.xsd schema file, if + it is present in the definitions directory, and also checks for the + most common NXDL authoring errors: nested elements. + + Parameters + ---------- + filepath : str or Path + Path to the NXDL file to lint. + definitions : str or Path, optional + Path to the NeXus definitions directory (used to locate nxdl.xsd). + Defaults to the bundled definitions. + + Returns + ------- + list of tuple + A list of (message, location_hint, severity) tuples where severity is + 'error' or 'warning'. An empty list means no issues were found. + """ + filepath = Path(filepath) + if not filepath.exists(): + stem = filepath.stem if filepath.suffix else filepath.name + 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 + break + else: + return [(f'NXDL definition "{stem}" not found in the definitions ' + f'directory', str(defs), 'error')] + from lxml import etree as lxml_etree + + filepath = filepath.resolve() + results = [] + + try: + doc = lxml_etree.parse(str(filepath)) + except lxml_etree.XMLSyntaxError as e: + return [(f'XML syntax error: {e}', str(filepath), 'error')] + + def walk(elem): + local = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag + if local == 'field': + field_name = elem.get('name', '(unnamed)') + for child in elem: + child_local = (child.tag.split('}')[-1] + if '}' in child.tag else child.tag) + if child_local == 'field': + child_name = child.get('name', '(unnamed)') + results.append(( + f'Nested inside ' + f' is not allowed; ' + f'use instead', + f'line {child.sourceline}', 'error')) + for child in elem: + walk(child) + + walk(doc.getroot()) + + definitions_path = get_definitions(definitions=definitions) + xsd_path = definitions_path / 'nxdl.xsd' + if xsd_path.exists(): + schema = lxml_etree.XMLSchema(lxml_etree.parse(str(xsd_path))) + if 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])) + return results + + def inspect_base_class(base_class, definitions=None): """ Prints the valid components of a NeXus base class. @@ -1263,7 +1374,7 @@ def inspect_base_class(base_class, definitions=None): log(f"NXDL File: {truncate_path(validator.filepath)}\n") else: log(f'NXDL file for "{base_class}" does not exist') - log(f"Definitions: {truncate_path(validator.filepath)}\n") + log(f"Definitions: {truncate_path(validator.definitions)}\n") return tree = ET.parse(validator.filepath) From 40f9ddbc9eaf7d723c74dad0df27e28364dfe3ac Mon Sep 17 00:00:00 2001 From: Ray Osborn Date: Thu, 1 Oct 2026 19:16:47 -0500 Subject: [PATCH 5/7] Add nxlint and nxvalidate CLI scripts nxlint validates the structural integrity of one or more NXDL files and reports any errors found by lint_nxdl(). It accepts the same -d/ --definitions, -i/-w/-e verbosity, and -v/--version flags used by the other nxcheck-family scripts. nxvalidate is a dedicated CLI wrapper around validate_application(). It validates a NeXus file against an application definition, with the same interface as the -a flag of nxcheck but as a standalone command with cleaner help text. The application can be specified explicitly with -a, or read from the 'definition' field in the file's NXentry group. pyproject.toml changes: - Add lxml as a formal dependency (required by lint_nxdl). - Register nxlint and nxvalidate as installed console scripts. - Add 'nexusformat.definitions': ['*.xsd'] to package-data so that nxdl.xsd is included in the installed package and lint_nxdl can perform full XSD validation against it. Co-Authored-By: Claude Opus 5.5 (1M context) --- pyproject.toml | 4 ++ src/nexusformat/scripts/nxlint.py | 72 +++++++++++++++++++++++++++ src/nexusformat/scripts/nxvalidate.py | 71 ++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 src/nexusformat/scripts/nxlint.py create mode 100644 src/nexusformat/scripts/nxvalidate.py diff --git a/pyproject.toml b/pyproject.toml index 0ebcff8..dad0968 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "colored", "h5py", "hdf5plugin", + "lxml", "numpy", "packaging", "pygments", @@ -52,6 +53,8 @@ Changelog = "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/nexpy/nexusformat/releases" [project.scripts] nexusformat = "nexusformat.scripts.nexusformat:main" nxcheck = "nexusformat.scripts.nxcheck:main" +nxlint = "nexusformat.scripts.nxlint:main" +nxvalidate = "nexusformat.scripts.nxvalidate:main" nxconsolidate = "nexusformat.scripts.nxconsolidate:main" nxdir = "nexusformat.scripts.nxdir:main" nxduplicate = "nexusformat.scripts.nxduplicate:main" @@ -62,6 +65,7 @@ nxstack = "nexusformat.scripts.nxstack:main" version_file = "src/nexusformat/_version.py" [tool.setuptools.package-data] +"nexusformat.definitions" = ["*.xsd"] "nexusformat.definitions.applications" = ["*.nxdl.xml"] "nexusformat.definitions.base_classes" = ["*.nxdl.xml"] "nexusformat.definitions.contributed_definitions" = ["*.nxdl.xml"] diff --git a/src/nexusformat/scripts/nxlint.py b/src/nexusformat/scripts/nxlint.py new file mode 100644 index 0000000..fe18427 --- /dev/null +++ b/src/nexusformat/scripts/nxlint.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# ----------------------------------------------------------------------------- +# Copyright (c) 2025-2026, NeXpy Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING, distributed with this software. +# ----------------------------------------------------------------------------- + +import argparse +import logging + +import nexusformat +from nexusformat.nexus.validate import lint_nxdl, log, log_summary, logger + + +def main(): + parser = argparse.ArgumentParser( + prog="nxlint", + description="Check NXDL application definition files for structural " + "errors.") + parser.add_argument("filename", nargs='+', + help="NXDL file(s) to lint") + parser.add_argument("-d", "--definitions", nargs=1, + help="path to the directory containing NeXus definitions") + parser.add_argument("-i", "--info", action='store_true', + help="output info messages in addition to warnings and errors") + parser.add_argument("-w", "--warning", action='store_true', + help="output warning and error messages (default)") + parser.add_argument("-e", "--error", action='store_true', + help="output errors only") + parser.add_argument('-v', '--version', action='version', + version='%(prog)s v'+nexusformat.__version__) + args = parser.parse_args() + + if args.info: + logger.setLevel(logging.INFO) + elif args.warning: + logger.setLevel(logging.WARNING) + elif args.error: + logger.setLevel(logging.ERROR) + else: + logger.setLevel(logging.WARNING) + + if args.definitions: + definitions = args.definitions[0] + else: + definitions = None + + for filename in args.filename: + log("\n", level='all') + log(f"NXDL file: {filename}", level='all') + if definitions: + log(f"Definitions: {definitions}", level='all') + log("\n", level='all') + logger.total = {'warning': 0, 'error': 0} + + results = lint_nxdl(filename, definitions=definitions) + if results: + for message, location, severity in results: + log(f'[{location}] {message}', level=severity) + log('\nFor help interpreting these errors, consult the NXDL ' + 'reference at https://manual.nexusformat.org/nxdl.html', + level='all') + else: + log(f'No structural errors found in "{filename}"', level='all') + + log_summary() + + +if __name__ == "__main__": + main() diff --git a/src/nexusformat/scripts/nxvalidate.py b/src/nexusformat/scripts/nxvalidate.py new file mode 100644 index 0000000..4f80224 --- /dev/null +++ b/src/nexusformat/scripts/nxvalidate.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python +# ----------------------------------------------------------------------------- +# Copyright (c) 2025-2026, NeXpy Development Team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file COPYING, distributed with this software. +# ----------------------------------------------------------------------------- + +import argparse +import logging + +import nexusformat +from nexusformat.nexus.validate import logger, validate_application + + +def main(): + parser = argparse.ArgumentParser( + prog="nxvalidate", + description="Validate NeXus files against an application definition.") + parser.add_argument("filename", nargs=1, + help="name of the NeXus file to be validated") + parser.add_argument("-p", "--path", nargs=1, + help="path to NXentry group to be validated in the NeXus file") + parser.add_argument("-a", "--application", nargs='?', const=True, + help="application definition to validate against; if omitted, " + "the definition is read from the file") + parser.add_argument("-d", "--definitions", nargs=1, + help="path to the directory containing NeXus definitions") + parser.add_argument("-i", "--info", action='store_true', + help="output info messages in addition to warnings and errors") + parser.add_argument("-w", "--warning", action='store_true', + help="output warning and error messages (default)") + parser.add_argument("-e", "--error", action='store_true', + help="output errors only") + parser.add_argument('-v', '--version', action='version', + version='%(prog)s v'+nexusformat.__version__) + args = parser.parse_args() + + if args.info: + logger.setLevel(logging.INFO) + elif args.warning: + logger.setLevel(logging.WARNING) + elif args.error: + logger.setLevel(logging.ERROR) + else: + logger.setLevel(logging.WARNING) + + if args.definitions: + definitions = args.definitions[0] + else: + definitions = None + + if args.filename: + filename = args.filename[0] + if args.path: + path = args.path[0] + else: + path = None + if args.application is True or args.application is None: + application = None + else: + application = args.application + validate_application(filename, path=path, application=application, + definitions=definitions) + else: + parser.print_help() + + +if __name__ == "__main__": + main() From 712eb69aee2ec5de0019b6a4aa0f18d82f38d105 Mon Sep 17 00:00:00 2001 From: Ray Osborn Date: Thu, 1 Oct 2026 19:36:35 -0500 Subject: [PATCH 6/7] Skip lxml comment and PI nodes in lint_nxdl walk() lxml represents XML comments and processing instructions with a callable tag (e.g. lxml.etree.Comment) rather than a string. The walk() helper inside lint_nxdl() applied '}}' in child.tag to every child node, which raises TypeError when child.tag is a callable. Fix by guarding both the element dispatch and the inner field-child loop with a callable(tag) check, mirroring the standard lxml idiom for skipping non-element nodes. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/nexusformat/nexus/validate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/nexusformat/nexus/validate.py b/src/nexusformat/nexus/validate.py index 5dcf73e..8c0f451 100644 --- a/src/nexusformat/nexus/validate.py +++ b/src/nexusformat/nexus/validate.py @@ -1322,10 +1322,14 @@ def lint_nxdl(filepath, definitions=None): return [(f'XML syntax error: {e}', str(filepath), 'error')] def walk(elem): + if callable(elem.tag): # skip comments, PIs, and other non-elements + return local = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag if local == 'field': field_name = elem.get('name', '(unnamed)') for child in elem: + if callable(child.tag): + continue child_local = (child.tag.split('}')[-1] if '}' in child.tag else child.tag) if child_local == 'field': From 18565728cade147adcb60cf8386269b566aa8c59 Mon Sep 17 00:00:00 2001 From: Ray Osborn Date: Thu, 1 Oct 2026 19:53:12 -0500 Subject: [PATCH 7/7] Catch NeXusError raised during validate_file's validate call FileValidator.__init__ was already guarded with a try/except NeXusError, but validator.validate(path) was not. Files that open successfully at the Python level but fail when h5py tries to read them (e.g. HDF4 files, which h5py cannot open) raised an unhandled NeXusError from inside validate(). Wrapping the validate() call in the same pattern logs a clean error message and returns instead of propagating the exception. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/nexusformat/nexus/validate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/nexusformat/nexus/validate.py b/src/nexusformat/nexus/validate.py index 8c0f451..9a722e5 100644 --- a/src/nexusformat/nexus/validate.py +++ b/src/nexusformat/nexus/validate.py @@ -1011,7 +1011,11 @@ def validate_file(filename, path=None, definitions=None): log_header(validator, filename=filename, path=path) - validator.validate(path) + try: + validator.validate(path) + except NeXusError as e: + logger.error(e) + return return log_summary()