diff --git a/gui/src/components/DefinitionTitle.js b/gui/src/components/DefinitionTitle.js new file mode 100644 index 0000000000000000000000000000000000000000..9501ab9377e5c7d78462cb53e9d8d6829ea24093 --- /dev/null +++ b/gui/src/components/DefinitionTitle.js @@ -0,0 +1,106 @@ +/* + * Copyright The NOMAD Authors. + * + * This file is part of NOMAD. See https://nomad-lab.eu for further info. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react' +import { makeStyles } from '@material-ui/core/styles' +import { Typography, Tooltip } from '@material-ui/core' +import PropTypes from 'prop-types' +import clsx from 'clsx' +import Ellipsis from './visualization/Ellipsis' + +/** + * Simple component for displaying titles and corresponding tooltips for + * metainfo definitions. + */ +const useStaticStyles = makeStyles(theme => ({ + root: { + }, + text: { + }, + subtitle2: { + color: theme.palette.grey[800] + }, + right: { + overflow: 'hidden' + }, + down: { + overflow: 'hidden', + writingMode: 'vertical-rl', + textOrientation: 'mixed' + }, + up: { + overflow: 'hidden', + writingMode: 'vertical-rl', + textOrientation: 'mixed', + transform: 'rotate(-180deg)' + } +})) +export const DefinitionTitle = React.memo(({ + label, + description, + variant, + TooltipProps, + onMouseDown, + onMouseUp, + className, + classes, + rotation, + section, + noWrap +}) => { + const styles = useStaticStyles({classes}) + + return <Tooltip title={description || ''} interactive enterDelay={400} enterNextDelay={400} {...(TooltipProps || {})}> + <div className={clsx(className, styles.root, + rotation === 'right' && styles.right, + rotation === 'down' && styles.down, + rotation === 'up' && styles.up + )}> + <Typography + noWrap={noWrap} + className={clsx(styles.text, (!section) && (variant === "subtitle2") && styles.subtitle2)} + variant={variant} + onMouseDown={onMouseDown} + onMouseUp={onMouseUp} + > + <Ellipsis>{label}</Ellipsis> + </Typography> + </div> + </Tooltip> +}) + +DefinitionTitle.propTypes = { + quantity: PropTypes.string, + label: PropTypes.string, + description: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + variant: PropTypes.string, + className: PropTypes.string, + classes: PropTypes.object, + rotation: PropTypes.oneOf(['up', 'right', 'down']), + TooltipProps: PropTypes.object, // Properties forwarded to the Tooltip + onMouseDown: PropTypes.func, + onMouseUp: PropTypes.func, + placement: PropTypes.string, + noWrap: PropTypes.bool, + section: PropTypes.string +} + +DefinitionTitle.defaultProps = { + variant: 'body2', + rotation: 'right', + noWrap: true +} diff --git a/gui/src/components/plotting/PlotHistogram.js b/gui/src/components/plotting/PlotHistogram.js index d83861097ff6ccdf9e8119e80f53be470175fac8..d984bc0039012d292af7ff7eb5791266933953de 100644 --- a/gui/src/components/plotting/PlotHistogram.js +++ b/gui/src/components/plotting/PlotHistogram.js @@ -29,11 +29,11 @@ import { InputTextField } from '../search/input/InputText' import Placeholder from '../visualization/Placeholder' import PlotAxis from './PlotAxis' import PlotBar from './PlotBar' -import FilterTitle from '../search/FilterTitle' import { guiState } from '../GUIMenu' import PropTypes from 'prop-types' import { getScaler } from './common' import { dateFormat } from '../../config' +import { DefinitionTitle } from '../DefinitionTitle' /** * An interactive histogram for numeric values. @@ -210,7 +210,6 @@ const PlotHistogram = React.memo(({ } }) const dynamicStyles = useDynamicStyles() - const aggIndicator = useRecoilValue(guiState('aggIndicator')) const oldRangeRef = useRef() const artificialRange = 1 @@ -534,12 +533,11 @@ const PlotHistogram = React.memo(({ } const titleComp = <div className={styles.title}> - <FilterTitle + <DefinitionTitle variant="subtitle2" classes={titleClasses} - quantity={xAxis.quantity} label={xAxis.title} - unit={xAxis.unit} + description={xAxis.description} noWrap={false} /> </div> diff --git a/gui/src/components/plotting/PlotScatter.js b/gui/src/components/plotting/PlotScatter.js index d856bf9e2f2569068f8d54857fd1a2dc28dc5821..34b91b917b430228cb7c978930275b3933e7d63e 100644 --- a/gui/src/components/plotting/PlotScatter.js +++ b/gui/src/components/plotting/PlotScatter.js @@ -18,13 +18,25 @@ import React, {useState, useEffect, useMemo, useCallback, forwardRef} from 'react' import PropTypes from 'prop-types' import { makeStyles, useTheme } from '@material-ui/core' -import { hasWebGLSupport } from '../../utils' +import { hasWebGLSupport, DType } from '../../utils' import * as d3 from 'd3' -import FilterTitle from '../search/FilterTitle' +import { DefinitionTitle } from '../DefinitionTitle' import Plot from './Plot' import { useHistory } from 'react-router-dom' import { getUrl } from '../nav/Routes' +function getAxisType(type, scale) { + return type === DType.Timestamp && scale === 'linear' + ? 'date' + : scale +} + +function transformData(type, data) { + return type === DType.Timestamp + ? data.map((iso) => new Date(iso).getTime()) + : data +} + /** * A Plotly-based interactive scatter plot. */ @@ -86,7 +98,9 @@ const useStyles = makeStyles(theme => ({ axisTitle: { fontSize: '0.75rem' } + })) + const PlotScatter = React.memo(forwardRef(( { data, @@ -103,8 +117,8 @@ const PlotScatter = React.memo(forwardRef(( 'data-testid': testID }, canvas) => { const styles = useStyles() - const theme = useTheme() const titleClasses = {text: styles.axisTitle} + const theme = useTheme() const [finalData, setFinalData] = useState(!data ? data : undefined) const history = useHistory() @@ -118,6 +132,12 @@ const PlotScatter = React.memo(forwardRef(( return } + // Map the data depending on axis types. This manual transformation is + // needed because the plotly automatic axis type detection does not work + // when scaling option is read from the axis configuration. + data.x = transformData(xAxis.dtype, data.x) + data.y = transformData(yAxis.dtype, data.y) + const hoverTemplate = (xLabel, yLabel, colorLabel, xUnit, yUnit, colorUnit) => { let template = `<b>Click to go to entry page</b>` + `<br>` + @@ -251,7 +271,7 @@ const PlotScatter = React.memo(forwardRef(( }) } setFinalData(traces) - }, [colorAxis?.search_quantity, colorAxis?.title, colorAxis?.unit, data, discrete, theme, xAxis.title, xAxis.unit, yAxis.title, yAxis.unit]) + }, [colorAxis?.search_quantity, colorAxis?.title, colorAxis?.unit, data, discrete, theme, xAxis.dtype, xAxis.title, xAxis.unit, yAxis.dtype, yAxis.title, yAxis.unit]) const layout = useMemo(() => { return { @@ -272,12 +292,12 @@ const PlotScatter = React.memo(forwardRef(( y: 1 }, xaxis: { - type: xAxis.scale, + type: getAxisType(xAxis.dtype, xAxis.scale), fixedrange: false, autorange: autorange }, yaxis: { - type: yAxis.scale, + type: getAxisType(yAxis.dtype, yAxis.scale), fixedrange: false, autorange: autorange }, @@ -295,7 +315,7 @@ const PlotScatter = React.memo(forwardRef(( // both. This is a general problem in trying to 'reactify' a non-react library // like Plotly. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autorange, xAxis.scale, yAxis.scale]) + }, [autorange, xAxis.dtype, xAxis.scale, yAxis.dtype, yAxis.scale]) // Change dragmode useEffect(() => { @@ -317,13 +337,12 @@ const PlotScatter = React.memo(forwardRef(( return <div className={styles.root}> <div className={styles.yaxis}> - <FilterTitle - variant="subtitle2" - classes={titleClasses} - quantity={yAxis.quantity} + <DefinitionTitle label={yAxis.title} - unit={yAxis.unit} + description={yAxis.description} + variant="subtitle2" rotation="up" + classes={titleClasses} /> </div> <div className={styles.plot}> @@ -344,24 +363,21 @@ const PlotScatter = React.memo(forwardRef(( </div> <div className={styles.square} /> <div className={styles.xaxis}> - <FilterTitle + <DefinitionTitle + label={xAxis.title} + description={xAxis.description} variant="subtitle2" classes={titleClasses} - quantity={xAxis.quantity} - label={xAxis.title} - unit={xAxis.unit} /> </div> {!discrete && colorAxis && <div className={styles.color}> - <FilterTitle + <DefinitionTitle + label={colorAxis.title} + description={colorAxis.description} + rotation="down" variant="subtitle2" classes={titleClasses} - rotation="down" - quantity={colorAxis.quantity} - unit={colorAxis.unit} - label={colorAxis.title} - description="" /> </div> } diff --git a/gui/src/components/plotting/PlotScatter.spec.js b/gui/src/components/plotting/PlotScatter.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..95d43868cb00a8d104b330aa21bce48d6c988030 --- /dev/null +++ b/gui/src/components/plotting/PlotScatter.spec.js @@ -0,0 +1,52 @@ +/* + * Copyright The NOMAD Authors. + * + * This file is part of NOMAD. See https://nomad-lab.eu for further info. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useRef } from 'react' +import { renderNoAPI, screen } from '../conftest.spec' +import PlotScatter from './PlotScatter' +import { DType } from '../../utils' + +const ScatterPlotWrapper = React.memo((props) => { + const canvas = useRef() + return <PlotScatter {...props} ref={canvas}/> +}) + +test.each([ + [ + 'linear timestamp', + {x: ["2014-12-01T00:00:00+00:00"], y: ["2015-12-01T00:00:00+00:00"]}, + {title: 'Test', dtype: DType.Timestamp, scale: 'linear'}, + ['Dec 1, 2014', 'Dec 1, 2015'] + ], + [ + 'log float', + {x: [1, 10], y: [100, 1000]}, + {title: 'Test', dtype: DType.Float, scale: 'log'}, + ['1', '10', '100', '1000'] + ] +])('%s', async (id, data, axis, ticks) => { + renderNoAPI(<ScatterPlotWrapper + data={data} + xAxis={axis} + yAxis={axis} + />) + + // Check that the correct plot ticks are found + for (const tick of ticks) { + expect(await screen.findByText(tick)).toBeInTheDocument() + } +}) diff --git a/gui/src/components/plotting/common.js b/gui/src/components/plotting/common.js index 559fc3f8599d23565126b8e78cae0f11fb0c7941..1dd7a4a179469f9c8f5d353932d04d7a3cbb6f7f 100644 --- a/gui/src/components/plotting/common.js +++ b/gui/src/components/plotting/common.js @@ -15,7 +15,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import React from 'react' import { range, size } from 'lodash' +import { Typography } from '@material-ui/core' import { scalePow, scaleLog } from 'd3-scale' import { format, @@ -497,17 +499,41 @@ export function getPlotTracesVertical(plots, theme) { export function getAxisConfig(axis, filterData, units) { const {quantity} = parseJMESPath(axis?.search_quantity) const filter = filterData[quantity] - const title = axis.title || filter?.label || getDisplayLabel(filter) const dtype = filter?.dtype const unit = axis.unit ? new Unit(axis.unit) : new Unit(filter?.unit || 'dimensionless').toSystem(units) + // Create the final label + let title = axis.title || filter?.label || getDisplayLabel(filter) + let finalUnit + if (unit) { + finalUnit = new Unit(unit).label() + } else if (filter?.unit) { + finalUnit = new Unit(filter.unit).toSystem(units).label() + } + if (finalUnit) { + title = `${title} (${finalUnit})` + } + + // Determine the final description + let description = axis.description || filter?.description || '' + if (description && quantity) { + description = ( + <> + <Typography>{title}</Typography> + <b>Description: </b>{description}<br/> + <b>Path: </b>{quantity} + </> + ) + } + return { ...axis, + description, title, unit, dtype, - quantity: quantity + quantity } } diff --git a/gui/src/components/search/FilterTitle.js b/gui/src/components/search/FilterTitle.js index 941a3537eba1176cff1f280492255afca5140b3e..b31834863d7cf7284bd49822a808d19d26735367 100644 --- a/gui/src/components/search/FilterTitle.js +++ b/gui/src/components/search/FilterTitle.js @@ -16,61 +16,28 @@ * limitations under the License. */ import React, { useMemo, useContext } from 'react' -import { makeStyles } from '@material-ui/core/styles' -import { Typography, Tooltip } from '@material-ui/core' +import { Typography } from '@material-ui/core' import PropTypes from 'prop-types' -import clsx from 'clsx' import { useSearchContext } from './SearchContext' import { inputSectionContext } from './input/InputNestedObject' import { Unit } from '../units/Unit' import { useUnitContext } from '../units/UnitContext' -import Ellipsis from '../visualization/Ellipsis' +import { DefinitionTitle } from '../DefinitionTitle' /** - * Title for a metainfo quantity or section that is used in a search context. - * By default the label, description and unit are automatically retrieved from - * the filter config. + * Title for a metainfo quantity or section that is used inside a search + * context. By default the label, description and unit are automatically + * retrieved from the filter config. */ -const useStaticStyles = makeStyles(theme => ({ - root: { - }, - text: { - }, - subtitle2: { - color: theme.palette.grey[800] - }, - right: { - overflow: 'hidden' - }, - down: { - overflow: 'hidden', - writingMode: 'vertical-rl', - textOrientation: 'mixed' - }, - up: { - overflow: 'hidden', - writingMode: 'vertical-rl', - textOrientation: 'mixed', - transform: 'rotate(-180deg)' - } -})) const FilterTitle = React.memo(({ quantity, label, description, unit, - variant, - TooltipProps, - onMouseDown, - onMouseUp, - className, - classes, - rotation, disableUnit, - noWrap + ...rest }) => { - const styles = useStaticStyles({classes}) - const { filterData } = useSearchContext() + const {filterData} = useSearchContext() const sectionContext = useContext(inputSectionContext) const {units} = useUnitContext() const section = sectionContext?.section @@ -93,55 +60,31 @@ const FilterTitle = React.memo(({ }, [filterData, quantity, units, label, unit, disableUnit]) // Determine the final description - const finalDescription = description || (quantity && filterData[quantity]?.description) || '' - const tooltip = (quantity) - ? <> - <Typography>{finalLabel}</Typography> - <b>Description: </b>{finalDescription || '-'}<br/> - <b>Path: </b>{quantity} - </> - : finalDescription || '' + let finalDescription = description || filterData[quantity]?.description || '' + if (finalDescription && quantity) { + finalDescription = ( + <> + <Typography>{finalLabel}</Typography> + <b>Description: </b>{finalDescription}<br/> + <b>Path: </b>{quantity} + </> + ) + } - return <Tooltip title={tooltip} interactive enterDelay={400} enterNextDelay={400} {...(TooltipProps || {})}> - <div className={clsx(className, styles.root, - rotation === 'right' && styles.right, - rotation === 'down' && styles.down, - rotation === 'up' && styles.up - )}> - <Typography - noWrap={noWrap} - className={clsx(styles.text, (!section) && (variant === "subtitle2") && styles.subtitle2)} - variant={variant} - onMouseDown={onMouseDown} - onMouseUp={onMouseUp} - > - <Ellipsis>{finalLabel}</Ellipsis> - </Typography> - </div> - </Tooltip> + return <DefinitionTitle + label={finalLabel} + description={finalDescription} + section={section} + {...rest} + /> }) FilterTitle.propTypes = { quantity: PropTypes.string, label: PropTypes.string, - unit: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), description: PropTypes.string, - variant: PropTypes.string, - className: PropTypes.string, - classes: PropTypes.object, - rotation: PropTypes.oneOf(['up', 'right', 'down']), - disableUnit: PropTypes.bool, - TooltipProps: PropTypes.object, // Properties forwarded to the Tooltip - onMouseDown: PropTypes.func, - onMouseUp: PropTypes.func, - placement: PropTypes.string, - noWrap: PropTypes.bool -} - -FilterTitle.defaultProps = { - variant: 'body2', - rotation: 'right', - noWrap: true + unit: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + disableUnit: PropTypes.bool } export default FilterTitle diff --git a/gui/src/components/search/Query.js b/gui/src/components/search/Query.js index c2938193d88bddf254becc822372a6effdefc3bc..972de233c6d389712621db0cd6667f50703489b1 100644 --- a/gui/src/components/search/Query.js +++ b/gui/src/components/search/Query.js @@ -236,10 +236,10 @@ const createChips = (name, filterValue, onDelete, filterData, units) => { } } - createRangeChip('lte', '<=') - createRangeChip('lt', '<') createRangeChip('gte', '>=') createRangeChip('gt', '>') + createRangeChip('lte', '<=') + createRangeChip('lt', '<') } else { chips.push({ comp: createChip(serializer(filterValue), () => onDelete(undefined)), op }) } diff --git a/gui/src/components/search/input/InputHistogram.js b/gui/src/components/search/input/InputHistogram.js index 9a95c284a5db459dcf06ff28f3d01f8efdfe5124..d90e7e5b71887b378383da2a6fc898813ec1861f 100644 --- a/gui/src/components/search/input/InputHistogram.js +++ b/gui/src/components/search/input/InputHistogram.js @@ -246,12 +246,8 @@ export const Histogram = React.memo(({ setPlotData({ xAxis: { - search_quantity: x.search_quantity, - quantity: x.quantity, - unit: x.unit, + ...x, unitStorage: unitStorage, - dtype: x.dtype, - title: x.title, min: minLocal, max: maxLocal }, @@ -259,7 +255,7 @@ export const Histogram = React.memo(({ step: stepHistogram, data: agg.data }) - }, [loading, nBins, agg, minLocal, maxLocal, stepHistogram, unitStorage, x.search_quantity, x.quantity, x.unit, x.dtype, x.title, x.scale, y]) + }, [loading, nBins, agg, minLocal, maxLocal, stepHistogram, unitStorage, x, y]) // Function for converting search values into the currently selected unit // system. diff --git a/gui/src/components/search/widgets/WidgetScatterPlot.js b/gui/src/components/search/widgets/WidgetScatterPlot.js index 04fd99367bd566faf55a8b77264ca25c4fba8446..9e57ec90d996b64ae9e6cb19d1a38ccead8e12ed 100644 --- a/gui/src/components/search/widgets/WidgetScatterPlot.js +++ b/gui/src/components/search/widgets/WidgetScatterPlot.js @@ -264,10 +264,10 @@ export const WidgetScatterPlot = React.memo(( // Perform unit conversion, report errors const data = useMemo(() => { if (!dataRaw) return - const x = xAxis.type === DType.Timestamp + const x = xAxis.dtype === DType.Timestamp ? dataRaw.x : new Quantity(dataRaw.x, storageUnitX).to(xAxis.unit).value() - const y = yAxis.type === DType.Timestamp + const y = yAxis.dtype === DType.Timestamp ? dataRaw.y : new Quantity(dataRaw.y, storageUnitY).to(yAxis.unit).value() const color = dataRaw.color && (discrete @@ -275,7 +275,7 @@ export const WidgetScatterPlot = React.memo(( : new Quantity(dataRaw.color, storageUnitColor).to(colorAxis.unit).value() ) return {x, y, color, id: dataRaw.id} - }, [dataRaw, xAxis.type, xAxis.unit, storageUnitX, yAxis.type, yAxis.unit, storageUnitY, discrete, storageUnitColor, colorAxis.unit]) + }, [dataRaw, xAxis.dtype, xAxis.unit, storageUnitX, yAxis.dtype, yAxis.unit, storageUnitY, discrete, storageUnitColor, colorAxis.unit]) const handleEdit = useCallback(() => { setWidget(old => { return {...old, editing: true } }) diff --git a/nomad/search.py b/nomad/search.py index 6eef2d16e8988604c81a1ee5950e75c8850cfa33..1aeac19c2f6557f725ddf43e67a32bfddcfe8dd9 100644 --- a/nomad/search.py +++ b/nomad/search.py @@ -32,11 +32,8 @@ update the v1 materials index according to the performed changes. TODO this is o partially implemented. """ -import fnmatch import json import math -import re -import sys from enum import Enum from typing import ( Any, @@ -47,7 +44,6 @@ from typing import ( Iterator, List, Optional, - Sequence, Tuple, Union, cast, @@ -483,35 +479,6 @@ def _es_to_entry_dict( Translates an ES hit response into a response data object that is expected by the API. """ - - def filter_hit(hit, include, exclude): - """Used to filter the hit based on the required fields.""" - flattened_dict = utils.flatten_dict(hit, flatten_list=True) - keys = list(flattened_dict.keys()) - key_pattern_map = {key: re.sub(r'\.\d+\.', '.', key) for key in keys} - if include is not None: - keys = [ - key - for key in keys - if any( - fnmatch.fnmatch(key_pattern_map[key], pattern) - for pattern in include - ) - ] - if exclude is not None: - keys = [ - key - for key in keys - if not any( - fnmatch.fnmatch(key_pattern_map[key], pattern) - for pattern in exclude - ) - ] - filtered_dict = {key: flattened_dict[key] for key in keys} - hit = utils.rebuild_dict(filtered_dict) - - return hit - entry_dict = hit.to_dict() # Add metadata default values @@ -602,7 +569,7 @@ def _es_to_entry_dict( if required.exclude else None ) - entry_dict = filter_hit(entry_dict, include_patterns, exclude_patterns) + entry_dict = utils.prune_dict(entry_dict, include_patterns, exclude_patterns) return entry_dict diff --git a/nomad/utils/__init__.py b/nomad/utils/__init__.py index 1be80bfa98ba69ac8351e8f131e88e03e18647fa..757b6670737ba3e97f39879ad0706a9005464961 100644 --- a/nomad/utils/__init__.py +++ b/nomad/utils/__init__.py @@ -42,6 +42,7 @@ from typing import List, Iterable, Union, Any, Dict from collections import OrderedDict from functools import reduce from itertools import takewhile +import fnmatch import base64 from contextlib import contextmanager import json @@ -700,6 +701,108 @@ def rebuild_dict(src: dict, separator: str = '.'): return ret +def prune_dict(data, include_patterns=None, exclude_patterns=None): + """ + Prune a nested dictionary based on include and exclude branch patterns. + + Args: + data: The nested dictionary to prune. + include_patterns: List of branch patterns to include. Supports wildcards + like `root.child.*`. + exclude_patterns: List of branch patterns to exclude. Supports wildcards + like `root.child.*`. + parent_key: Used internally for recursion to track the full key path. + + Returns: + Pruned dictionary. + """ + + # Preprocess patterns + def process_patterns(patterns): + if patterns is None: + return [] + processed = [] + for pattern in patterns: + if pattern.endswith('*'): + parts = pattern.rstrip('*').removesuffix('.').split('.') + processed.append((parts, True)) + else: + parts = pattern.split('.') + processed.append((parts, False)) + return processed + + include_patterns = process_patterns(include_patterns) + exclude_patterns = process_patterns(exclude_patterns) + + def matches(path, patterns): + for pattern_parts, wildcard in patterns: + if wildcard: + if path[: len(pattern_parts)] == tuple(pattern_parts): + return True + else: + if path == tuple(pattern_parts): + return True + return False + + def has_descendant_pattern(path, patterns): + for pattern_parts, _ in patterns: + if ( + len(pattern_parts) > len(path) + and tuple(pattern_parts[: len(path)]) == path + ): + return True + return False + + def prune(data, path=()): + # Check exclude patterns + if matches(path, exclude_patterns): + return None # Excluded + + # Check include patterns + if include_patterns: + if matches(path, include_patterns): + include_current = True + elif has_descendant_pattern(path, include_patterns): + include_current = False # Need to check deeper + else: + return None # Excluded + else: + include_current = True + + if isinstance(data, dict): + new_dict = {} + for key, value in data.items(): + new_path = path + (str(key),) + pruned_value = prune(value, new_path) + if pruned_value is not None: + new_dict[key] = pruned_value + if new_dict: + return new_dict + elif include_patterns and matches(path, include_patterns): + return {} # Include empty dict if explicitly included + else: + return None # Exclude empty dicts + elif isinstance(data, list): + new_list = [] + for item in data: + pruned_item = prune(item, path) + if pruned_item is not None: + new_list.append(pruned_item) + if new_list: + return new_list + elif include_patterns and matches(path, include_patterns): + return [] # Include empty list if explicitly included + else: + return None # Exclude empty lists + else: + if include_current: + return data + else: + return None + + return prune(data) or {} + + def deep_get(dictionary, *keys): """ Helper that can be used to access nested dictionary-like containers using a diff --git a/tests/test_utils.py b/tests/test_utils.py index 91ca1f2c6aca9cbef408007c0045507c052f8d23..1853751a0b4e5c4d6cea5de11ae5e5ed1fe5580f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -30,6 +30,7 @@ from nomad.utils import ( structlogging, flatten_dict, rebuild_dict, + prune_dict, deep_get, dict_to_dataframe, dataframe_to_dict, @@ -167,6 +168,79 @@ def test_dict_flatten_rebuild(data, flatten_list): assert rebuilt == data +@pytest.mark.parametrize( + 'data, include, exclude, expected', + [ + pytest.param( + {'a': 1, 'b': {'c': 2}, 'd': [{'e': 3}, {'f': 4}]}, + None, + None, + {'a': 1, 'b': {'c': 2}, 'd': [{'e': 3}, {'f': 4}]}, + id='all', + ), + pytest.param( + {'a': 1, 'b': {'c': 2}, 'd': [{'e': 3}, {'f': 4}]}, + ['d.f'], + None, + {'d': [{'f': 4}]}, + id='include exact', + ), + pytest.param( + {'a': 1, 'b': {'c': 2}, 'd': [{'e': 3}, {'f': 4}]}, + ['d.*'], + None, + {'d': [{'e': 3}, {'f': 4}]}, + id='include wildcard', + ), + pytest.param( + {'a': 1, 'b': {'c': 2}, 'd': [{'e': 3}, {'f': 4}]}, + ['d*'], + None, + {'d': [{'e': 3}, {'f': 4}]}, + id='include wildcard no dot', + ), + pytest.param( + {'a': 1, 'b': {'c': 2}, 'd': [{'e': 3}, {'f': 4}]}, + None, + ['d.f'], + {'a': 1, 'b': {'c': 2}, 'd': [{'e': 3}]}, + id='exclude exact', + ), + pytest.param( + {'a': 1, 'b': {'c': 2}, 'd': [{'e': 3}, {'f': 4}]}, + None, + ['d.*'], + {'a': 1, 'b': {'c': 2}}, + id='exclude wildcard', + ), + pytest.param( + {'a': {'b': 1}}, + None, + ['a*'], + {}, + id='exclude wildcard no dot', + ), + pytest.param( + {'a': {'b': 1}}, + None, + ['a.*'], + {}, + id='exclude everything', + ), + pytest.param( + {'a': [], 'b': {}}, + ['a', 'b'], + None, + {'a': [], 'b': {}}, + id='preserve empty structures if they are included', + ), + ], +) +def test_prune_dict(data, include, exclude, expected): + pruned = prune_dict(data, include, exclude) + assert pruned == expected + + @pytest.mark.parametrize( 'data, path, value, exception', [