diff --git a/src/chexus/nexus_base_classes.py b/src/chexus/nexus_base_classes.py new file mode 100644 index 0000000..afd9326 --- /dev/null +++ b/src/chexus/nexus_base_classes.py @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +"""NeXus base class names. + +Source: https://github.com/nexusformat/definitions/tree/main/base_classes +Regenerated by ``tools/refresh_nexus_base_classes.py``; do not edit by hand. + +Includes deprecated classes (NXgeometry, NXorientation, NXshape, NXtranslation), +which are flagged separately by NX_class_is_legacy. +""" + +nexus_base_classes = frozenset( + [ + "NXaberration", + "NXactivity", + "NXactuator", + "NXaperture", + "NXapm_charge_state_analysis", + "NXapm_event_data", + "NXapm_instrument", + "NXapm_measurement", + "NXapm_ranging", + "NXapm_reconstruction", + "NXapm_simulation", + "NXatom", + "NXattenuator", + "NXbeam", + "NXbeam_stop", + "NXbeam_transfer_matrix_table", + "NXbending_magnet", + "NXcalibration", + "NXcapillary", + "NXcg_alpha_complex", + "NXcg_cylinder", + "NXcg_ellipsoid", + "NXcg_face_list_data_structure", + "NXcg_grid", + "NXcg_half_edge_data_structure", + "NXcg_hexahedron", + "NXcg_parallelogram", + "NXcg_point", + "NXcg_polygon", + "NXcg_polyhedron", + "NXcg_polyline", + "NXcg_primitive", + "NXcg_roi", + "NXcg_tetrahedron", + "NXcg_triangle", + "NXcg_unit_normal", + "NXchemical_composition", + "NXcircuit", + "NXcite", + "NXcollection", + "NXcollectioncolumn", + "NXcollimator", + "NXcomponent", + "NXcoordinate_system", + "NXcorrector_cs", + "NXcrystal", + "NXcs_computer", + "NXcs_filter_boolean_mask", + "NXcs_memory", + "NXcs_prng", + "NXcs_processor", + "NXcs_profiling", + "NXcs_profiling_event", + "NXcs_storage", + "NXcylindrical_geometry", + "NXdata", + "NXdeflector", + "NXdetector", + "NXdetector_channel", + "NXdetector_group", + "NXdetector_module", + "NXdisk_chopper", + "NXdistortion", + "NXebeam_column", + "NXelectromagnetic_lens", + "NXelectron_detector", + "NXelectronanalyzer", + "NXem_ebsd", + "NXem_eds", + "NXem_eels", + "NXem_event_data", + "NXem_img", + "NXem_instrument", + "NXem_interaction_volume", + "NXem_measurement", + "NXem_optical_system", + "NXem_simulation", + "NXenergydispersion", + "NXentry", + "NXenvironment", + "NXevent_data", + "NXfabrication", + "NXfermi_chopper", + "NXfilter", + "NXfit", + "NXfit_function", + "NXflipper", + "NXfresnel_zone_plate", + "NXgeometry", + "NXgrating", + "NXguide", + "NXhistory", + "NXibeam_column", + "NXimage", + "NXinsertion_device", + "NXinstrument", + "NXlog", + "NXmanipulator", + "NXmirror", + "NXmoderator", + "NXmonitor", + "NXmonochromator", + "NXnote", + "NXobject", + "NXoff_geometry", + "NXoptical_lens", + "NXoptical_window", + "NXorientation", + "NXparameters", + "NXpdb", + "NXpeak", + "NXphase", + "NXpid_controller", + "NXpinhole", + "NXpolarizer", + "NXpositioner", + "NXprocess", + "NXprogram", + "NXpump", + "NXreflections", + "NXregistration", + "NXresolution", + "NXroi_process", + "NXroot", + "NXrotations", + "NXsample", + "NXsample_component", + "NXscan_controller", + "NXsensor", + "NXshape", + "NXslit", + "NXsource", + "NXspectrum", + "NXspindispersion", + "NXsubentry", + "NXtransformations", + "NXtranslation", + "NXunit_cell", + "NXuser", + "NXvelocity_selector", + "NXwaveplate", + "NXxraylens", + ] +) diff --git a/src/chexus/validators.py b/src/chexus/validators.py index ca71b85..5a0ae23 100644 --- a/src/chexus/validators.py +++ b/src/chexus/validators.py @@ -1,7 +1,8 @@ # SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2023 Scipp contributors (https://github.com/scipp) +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) import numpy as np +from .nexus_base_classes import nexus_base_classes from .tree import Dataset, Group from .validate import Validator, Violation @@ -73,6 +74,30 @@ def validate(self, node: Dataset | Group) -> Violation | None: return Violation(node.name, f"NX_class {nx_class} is deprecated") +# Application/contributed classes expected in raw ESS files. +# Add to this set if a new application class is encountered. +nexus_application_classes = frozenset(["NXcanSAS"]) + +valid_nx_classes = nexus_base_classes | nexus_application_classes + + +class NX_class_invalid(Validator): + def __init__(self) -> None: + super().__init__( + "NX_class_invalid", + "NX_class is not a known NeXus class name " + "(typos like NXPositioner instead of NXpositioner)", + ) + + def applies_to(self, node: Dataset | Group) -> bool: + return isinstance(node, Group) and "NX_class" in node.attrs + + def validate(self, node: Dataset | Group) -> Violation | None: + nx_class = node.attrs["NX_class"] + if nx_class not in valid_nx_classes: + return Violation(node.name, f"Unknown NX_class {nx_class!r}") + + class group_has_units(Validator): def __init__(self) -> None: super().__init__("group_has_units", "Group should not have units attribute") @@ -497,6 +522,7 @@ def base_validators(*, has_scipp=True): mask_has_units(), non_numeric_dataset_has_units(), NX_class_attr_missing(), + NX_class_invalid(), NX_class_is_legacy(), transformation_depends_on_missing(), transformation_offset_units_missing(), diff --git a/tests/validators_test.py b/tests/validators_test.py index 1c75b41..8cdcde6 100644 --- a/tests/validators_test.py +++ b/tests/validators_test.py @@ -178,6 +178,33 @@ def test_NX_class_attr_missing(): assert result.name == "x" +@pytest.mark.parametrize( + "nx_class", + ["NXpositioner", "NXdisk_chopper", "NXgeometry", "NXcanSAS"], +) +def test_NX_class_invalid_accepts_known(nx_class: str): + group = chexus.Group(name="x", attrs={"NX_class": nx_class}) + assert chexus.validators.NX_class_invalid().applies_to(group) + assert chexus.validators.NX_class_invalid().validate(group) is None + + +@pytest.mark.parametrize( + "nx_class", + ["NXPositioner", "NXDetector", "NXfoo", "positioner"], +) +def test_NX_class_invalid_rejects_unknown(nx_class: str): + group = chexus.Group(name="x", attrs={"NX_class": nx_class}) + assert chexus.validators.NX_class_invalid().applies_to(group) + result = chexus.validators.NX_class_invalid().validate(group) + assert isinstance(result, chexus.Violation) + assert result.name == "x" + + +def test_NX_class_invalid_does_not_apply_without_attr(): + group = chexus.Group(name="x", attrs={}) + assert not chexus.validators.NX_class_invalid().applies_to(group) + + def test_NX_class_is_legacy(): good = chexus.Group(name="x", attrs={"NX_class": "NXtransformations"}) assert chexus.validators.NX_class_is_legacy().validate(good) is None diff --git a/tools/refresh_nexus_base_classes.py b/tools/refresh_nexus_base_classes.py new file mode 100644 index 0000000..1209f9d --- /dev/null +++ b/tools/refresh_nexus_base_classes.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +"""Regenerate ``src/chexus/nexus_base_classes.py`` from the upstream NeXus +definitions repository. + +Requires the GitHub CLI (``gh``) to be installed and authenticated. + +Usage: + python tools/refresh_nexus_base_classes.py +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO = "nexusformat/definitions" +DIRECTORY = "base_classes" +SUFFIX = ".nxdl.xml" +TARGET = ( + Path(__file__).resolve().parent.parent / "src" / "chexus" / "nexus_base_classes.py" +) + +HEADER = '''# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +"""NeXus base class names. + +Source: https://github.com/nexusformat/definitions/tree/main/base_classes +Regenerated by ``tools/refresh_nexus_base_classes.py``; do not edit by hand. + +Includes deprecated classes (NXgeometry, NXorientation, NXshape, NXtranslation), +which are flagged separately by NX_class_is_legacy. +""" + +nexus_base_classes = frozenset( + [ +''' + +FOOTER = """ ] +) +""" + + +def fetch_names() -> list[str]: + result = subprocess.run( + ["gh", "api", f"repos/{REPO}/contents/{DIRECTORY}", "--jq", ".[].name"], + capture_output=True, + text=True, + check=True, + ) + names = [ + line.removesuffix(SUFFIX) + for line in result.stdout.splitlines() + if line.endswith(SUFFIX) + ] + return sorted(names) + + +def render(names: list[str]) -> str: + body = "".join(f' "{name}",\n' for name in names) + return HEADER + body + FOOTER + + +def main() -> int: + names = fetch_names() + if not names: + print("No base classes returned from gh api", file=sys.stderr) + return 1 + TARGET.write_text(render(names)) + print(f"Wrote {len(names)} base classes to {TARGET}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())