diff --git a/dash/_callback.py b/dash/_callback.py index 3785df7166..47aa863191 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -304,6 +304,13 @@ def insert_callback( "optional": optional, "hidden": hidden, } + # Include output metadata if any output uses partial matching + output_list = output if isinstance(output, (list, tuple)) else [output] + if any(getattr(o, "partial", False) for o in output_list): + callback_spec["outputs_meta"] = [ + {"partial": True} if getattr(o, "partial", False) else {} + for o in output_list + ] if running: callback_spec["running"] = running diff --git a/dash/dash-renderer/src/actions/dependencies.js b/dash/dash-renderer/src/actions/dependencies.js index 7b5d1665f0..7b53ba1406 100644 --- a/dash/dash-renderer/src/actions/dependencies.js +++ b/dash/dash-renderer/src/actions/dependencies.js @@ -34,7 +34,8 @@ import { INDIRECT, mergeMax, makeResolvedCallback, - resolveDeps + resolveDeps, + resolvePartialDeps } from './dependencies_ts'; import {computePaths, getPath} from './paths'; @@ -190,7 +191,40 @@ function addPattern(patterns, idSpec, prop, dependency) { let valMatch = valueMap.get(valuesKey); if (!valMatch) { - valMatch = {keys, values, callbacks: []}; + valMatch = {keys, values, callbacks: [], partial: false}; + valueMap.set(valuesKey, valMatch); + } + valMatch.callbacks.push(dependency); +} + +// Add a partial pattern - stored with partial flag so lookup knows to do +// subset key matching. +function addPartialPattern(patterns, idSpec, prop, dependency) { + const keys = Object.keys(idSpec).sort(); + const keyStr = keys.join(','); + const values = props(keys, idSpec); + const valuesKey = values + .map(v => + typeof v === 'object' && v !== null + ? v.wild + ? v.wild + : JSON.stringify(v) + : String(v) + ) + .join('|'); + + if (!patterns.has(keyStr)) { + patterns.set(keyStr, new Map()); + } + const propMap = patterns.get(keyStr); + if (!propMap.has(prop)) { + propMap.set(prop, new Map()); + } + const valueMap = propMap.get(prop); + + let valMatch = valueMap.get(valuesKey); + if (!valMatch) { + valMatch = {keys, values, callbacks: [], partial: true}; valueMap.set(valuesKey, valMatch); } valMatch.callbacks.push(dependency); @@ -207,6 +241,7 @@ function offloadPatterns(patternsMap, targetMap) { targetMap[keyStr][prop] = Array.from(valueMap.values()); } } + return targetMap; } function validateDependencies(parsedDependencies, dispatchError) { @@ -674,19 +709,28 @@ export function computeGraphs(dependencies, dispatchError, config) { const fixIds = map(evolve({id: parseIfWildcard})); const parsedDependencies = map(dep => { - const {output, no_output} = dep; + const {output, no_output, outputs_meta} = dep; const out = evolve({inputs: fixIds, state: fixIds}, dep); if (no_output) { // No output case out.outputs = []; out.noOutput = true; } else { - out.outputs = map( - outi => assoc('out', true, splitIdAndProp(outi)), - isMultiOutputProp(output) - ? parseMultipleOutputs(output) - : [output] - ); + const outputStrs = isMultiOutputProp(output) + ? parseMultipleOutputs(output) + : [output]; + out.outputs = outputStrs.map((outi, idx) => { + const parsed = assoc('out', true, splitIdAndProp(outi)); + // Attach partial flag from outputs_meta if available + if ( + outputs_meta && + outputs_meta[idx] && + outputs_meta[idx].partial + ) { + parsed.partial = true; + } + return parsed; + }); } return out; @@ -730,12 +774,15 @@ export function computeGraphs(dependencies, dispatchError, config) { let outputPatterns = {}; let inputPatterns = {}; + let hasPartialPatterns = false; + const finalGraphs = { MultiGraph: multiGraph, outputMap, inputMap, outputPatterns, inputPatterns, + hasPartialPatterns, callbacks: parsedDependencies }; @@ -898,14 +945,14 @@ export function computeGraphs(dependencies, dispatchError, config) { const {matchKeys} = findWildcardKeys( outputs.length ? outputs[0].id : undefined ); - const firstSingleOutput = findIndex(o => !isMultiValued(o.id), outputs); + const firstSingleOutput = findIndex(o => !isMultiValued(o), outputs); const finalDependency = mergeRight( {matchKeys, firstSingleOutput, outputs}, dependency ); outputs.forEach(outIdProp => { - const {id: outId, property} = outIdProp; + const {id: outId, property, partial: outPartial} = outIdProp; // check if this output is also an input to the same callback let alsoInput; if (config.validate_callbacks) { @@ -925,7 +972,21 @@ export function computeGraphs(dependencies, dispatchError, config) { addOutputToMulti(id, outIdName); }); } - addPattern(outputPatternMap, outId, property, finalDependency); + if (outPartial) { + addPartialPattern( + outputPatternMap, + outId, + property, + finalDependency + ); + } else { + addPattern( + outputPatternMap, + outId, + property, + finalDependency + ); + } } else { if (config.validate_callbacks) { let outIdName = combineIdAndProp(outIdProp); @@ -941,9 +1002,22 @@ export function computeGraphs(dependencies, dispatchError, config) { }); inputs.forEach(inputObject => { - const {id: inId, property: inProp} = inputObject; + const { + id: inId, + property: inProp, + partial: inPartial + } = inputObject; if (typeof inId === 'object') { - addPattern(inputPatternMap, inId, inProp, finalDependency); + if (inPartial) { + addPartialPattern( + inputPatternMap, + inId, + inProp, + finalDependency + ); + } else { + addPattern(inputPatternMap, inId, inProp, finalDependency); + } } else { addMap(inputMap, inId, inProp, finalDependency); } @@ -951,6 +1025,13 @@ export function computeGraphs(dependencies, dispatchError, config) { }); outputPatterns = offloadPatterns(outputPatternMap, outputPatterns); inputPatterns = offloadPatterns(inputPatternMap, inputPatterns); + hasPartialPatterns = parsedDependencies.some( + dep => + dep.outputs.some(o => o.partial) || dep.inputs.some(i => i.partial) + ); + finalGraphs.outputPatterns = outputPatterns; + finalGraphs.inputPatterns = inputPatterns; + finalGraphs.hasPartialPatterns = hasPartialPatterns; // second pass for adding new output nodes as dependencies where needed duplicateOutputs.forEach(dupeOutIdProp => { @@ -1056,6 +1137,73 @@ export function idMatch( return true; } +/* + * Partial id matching: check if the pattern (with fewer keys) matches + * a component id (with more keys). Only keys present in the pattern + * are checked; extra keys in the component id are ignored. + * + * `patternKeys` and `patternVals` describe the pattern. + * `componentKeys` and `componentVals` describe the actual component id. + * The component must have all the pattern's keys (superset). + */ +export function partialIdMatch( + patternKeys, + patternVals, + componentKeys, + componentVals, + refKeys, + refVals, + refPatternVals +) { + for (let i = 0; i < patternKeys.length; i++) { + const patternKey = patternKeys[i]; + const patternVal = patternVals[i]; + // Find this key in the component + const compIndex = componentKeys.indexOf(patternKey); + if (compIndex === -1) { + // Component doesn't have this key - no match + return false; + } + const val = componentVals[compIndex]; + if (patternVal.wild) { + if (refKeys && patternVal !== ALL) { + const refIndex = refKeys.indexOf(patternKey); + if (refIndex === -1) { + continue; + } + const refPatternVal = refPatternVals[refIndex]; + if (patternVal === ALLSMALLER && refPatternVal === ALLSMALLER) { + throw new Error( + 'invalid wildcard id pair (partial): ' + + JSON.stringify({ + patternKeys, + patternVals, + componentKeys, + componentVals, + refKeys, + refPatternVals, + refVals + }) + ); + } + if ( + idValSort(val, refVals[refIndex]) !== + (patternVal === ALLSMALLER + ? -1 + : refPatternVal === ALLSMALLER + ? 1 + : 0) + ) { + return false; + } + } + } else if (val !== patternVal) { + return false; + } + } + return true; +} + export function getAnyVals(patternVals, vals) { const matches = []; for (let i = 0; i < patternVals.length; i++) { @@ -1068,10 +1216,22 @@ export function getAnyVals(patternVals, vals) { /* * Does this item (input / output / state) support multiple values? - * string IDs do not; wildcard IDs only do if they contain ALL or ALLSMALLER + * string IDs do not; wildcard IDs only do if they contain ALL or ALLSMALLER. + * Partial patterns with no wildcards are implicitly multi-valued since they + * can match multiple components with different key sets. */ -export function isMultiValued({id}) { - return typeof id === 'object' && any(v => v.multi, values(id)); +export function isMultiValued({id, partial = false}) { + if (typeof id !== 'object') { + return false; + } + if (any(v => v.multi, values(id))) { + return true; + } + // partial with no wildcards: implicitly multi-valued + if (partial && !any(v => v && v.wild, values(id))) { + return true; + } + return false; } /* @@ -1113,21 +1273,84 @@ function getCallbackByOutput(graphs, paths, id, prop) { } } else { // wildcard version - const keys = Object.keys(id).sort(); - const vals = props(keys, id); - const keyStr = keys.join(','); + const _keys = Object.keys(id).sort(); + const vals = props(_keys, id); + const keyStr = _keys.join(','); const patterns = (graphs.outputPatterns[keyStr] || {})[prop]; if (patterns) { for (let i = 0; i < patterns.length; i++) { const patternVals = patterns[i].values; - if (idMatch(keys, vals, patternVals)) { + if (patterns[i].partial) { + if ( + partialIdMatch( + patterns[i].keys, + patternVals, + _keys, + vals + ) + ) { + callback = patterns[i].callbacks[0]; + resolve = resolvePartialDeps( + patterns[i].keys, + vals, + patternVals, + _keys + ); + anyVals = getAnyVals(patternVals, vals); + break; + } + } else if (idMatch(_keys, vals, patternVals)) { callback = patterns[i].callbacks[0]; - resolve = resolveDeps(keys, vals, patternVals); + resolve = resolveDeps(_keys, vals, patternVals); anyVals = getAnyVals(patternVals, vals); break; } } } + // If not found, also check partial patterns with fewer keys + if (!resolve && graphs.hasPartialPatterns) { + for (const patKeyStr in graphs.outputPatterns) { + if (patKeyStr === keyStr) { + continue; + } + const patKeys = patKeyStr.split(','); + if (!patKeys.every(k => _keys.indexOf(k) !== -1)) { + continue; + } + const patPropPatterns = (graphs.outputPatterns[patKeyStr] || + {})[prop]; + if (!patPropPatterns) { + continue; + } + for (let i = 0; i < patPropPatterns.length; i++) { + if (!patPropPatterns[i].partial) { + continue; + } + const patternVals = patPropPatterns[i].values; + if ( + partialIdMatch( + patPropPatterns[i].keys, + patternVals, + _keys, + vals + ) + ) { + callback = patPropPatterns[i].callbacks[0]; + resolve = resolvePartialDeps( + patPropPatterns[i].keys, + vals, + patternVals, + _keys + ); + anyVals = getAnyVals(patternVals, vals); + break; + } + } + if (resolve) { + break; + } + } + } } if (!resolve) { return false; @@ -1233,15 +1456,45 @@ export function getWatchedKeys(id, newProps, graphs) { const vals = props(keys, id); const keyStr = keys.join(','); const keyPatterns = graphs.inputPatterns[keyStr]; - if (!keyPatterns) { - return []; - } + return newProps.filter(prop => { - const patterns = keyPatterns[prop]; - return ( - patterns && - patterns.some(pattern => idMatch(keys, vals, pattern.values)) - ); + // Check exact keyStr patterns + if (keyPatterns) { + const patterns = keyPatterns[prop]; + if ( + patterns && + patterns.some(pattern => idMatch(keys, vals, pattern.values)) + ) { + return true; + } + } + // Check partial patterns whose keys are a subset of this component's keys + if (!graphs.hasPartialPatterns) { + return false; + } + for (const patKeyStr in graphs.inputPatterns) { + if (patKeyStr === keyStr) { + continue; + } + const patKeys = patKeyStr.split(','); + if (!patKeys.every(k => keys.indexOf(k) !== -1)) { + continue; + } + const patPropPatterns = graphs.inputPatterns[patKeyStr][prop]; + if (!patPropPatterns) { + continue; + } + if ( + patPropPatterns.some( + pattern => + pattern.partial && + partialIdMatch(patKeys, pattern.values, keys, vals) + ) + ) { + return true; + } + } + return false; }); } @@ -1363,11 +1616,63 @@ export function getUnfilteredLayoutCallbacks(graphs, paths, layoutChunk, opts) { handleOneId(id, graphs.outputMap[id], graphs.inputMap[id]); } else { const keyStr = Object.keys(id).sort().join(','); + const _keys = Object.keys(id).sort(); handleOneId( id, !removedArrayInputsOnly && graphs.outputPatterns[keyStr], graphs.inputPatterns[keyStr] ); + // Also check partial patterns whose keys are a subset of + // this component's keys + if (graphs.hasPartialPatterns) { + for (const patKeyStr in graphs.inputPatterns) { + if (patKeyStr === keyStr) { + continue; + } + const patKeys = patKeyStr.split(','); + if (!patKeys.every(k => _keys.indexOf(k) !== -1)) { + continue; + } + // Check if any patterns under this keyStr are partial + const patProps = graphs.inputPatterns[patKeyStr]; + const partialProps = {}; + for (const prop in patProps) { + const partialPats = patProps[prop].filter( + p => p.partial + ); + if (partialPats.length) { + partialProps[prop] = partialPats; + } + } + if (Object.keys(partialProps).length) { + handleOneId(id, null, partialProps); + } + } + } // end hasPartialPatterns guard (input) + if (!removedArrayInputsOnly && graphs.hasPartialPatterns) { + for (const patKeyStr in graphs.outputPatterns) { + if (patKeyStr === keyStr) { + continue; + } + const patKeys = patKeyStr.split(','); + if (!patKeys.every(k => _keys.indexOf(k) !== -1)) { + continue; + } + const patProps = graphs.outputPatterns[patKeyStr]; + const partialProps = {}; + for (const prop in patProps) { + const partialPats = patProps[prop].filter( + p => p.partial + ); + if (partialPats.length) { + partialProps[prop] = partialPats; + } + } + if (Object.keys(partialProps).length) { + handleOneId(id, partialProps, null); + } + } + } } } }); diff --git a/dash/dash-renderer/src/actions/dependencies_ts.ts b/dash/dash-renderer/src/actions/dependencies_ts.ts index 33f968cf91..8bee8af757 100644 --- a/dash/dash-renderer/src/actions/dependencies_ts.ts +++ b/dash/dash-renderer/src/actions/dependencies_ts.ts @@ -29,6 +29,7 @@ import { getUnfilteredLayoutCallbacks, idMatch, isMultiValued, + partialIdMatch, splitIdAndProp, stringifyId } from './dependencies'; @@ -68,27 +69,103 @@ export function getCallbacksByInput( const vals = props(_keys, id); const keyStr = _keys.join(','); const patterns: any[] = (graphs.inputPatterns[keyStr] || {})[prop]; - if (!patterns) { - return []; + if (patterns) { + patterns.forEach((pattern: any) => { + if (pattern.partial) { + // Partial patterns stored under same keyStr won't happen + // normally, but handle it for completeness + if ( + partialIdMatch( + pattern.keys, + pattern.values, + _keys, + vals + ) + ) { + const triggerAnyVals = getAnyVals(pattern.values, vals); + // Extract vals for pattern keys from the component + const patternRefVals = pattern.keys.map( + (k: string) => vals[_keys.indexOf(k)] + ); + pattern.callbacks.forEach( + addAllResolvedFromOutputs( + resolvePartialDeps( + pattern.keys, + patternRefVals, + pattern.values, + _keys + ), + paths, + matches, + triggerAnyVals + ) + ); + } + } else if (idMatch(_keys, vals, pattern.values)) { + const triggerAnyVals = getAnyVals(pattern.values, vals); + pattern.callbacks.forEach( + addAllResolvedFromOutputs( + resolveDeps(_keys, vals, pattern.values), + paths, + matches, + triggerAnyVals + ) + ); + } + }); } - patterns.forEach(pattern => { - if (idMatch(_keys, vals, pattern.values)) { - // When a callback's Outputs have no MATCH keys, the - // triggering Input's MATCH values are what uniquify each - // firing's resolvedId (see addAllResolvedFromOutputs). - // Callbacks whose Outputs do carry MATCH keys ignore this - // value since the Output pattern drives resolution. - const triggerAnyVals = getAnyVals(pattern.values, vals); - pattern.callbacks.forEach( - addAllResolvedFromOutputs( - resolveDeps(_keys, vals, pattern.values), - paths, - matches, - triggerAnyVals - ) - ); + + // Also check partial patterns whose keys are a subset of this + // component's keys + if (graphs.hasPartialPatterns) { + for (const patKeyStr in graphs.inputPatterns) { + if (patKeyStr === keyStr) { + continue; // already handled above + } + const patKeys = patKeyStr.split(','); + // Check if pattern keys are a subset of component keys + if (!patKeys.every((k: string) => _keys.indexOf(k) !== -1)) { + continue; + } + const patPropPatterns: any[] = + graphs.inputPatterns[patKeyStr][prop]; + if (!patPropPatterns) { + continue; + } + patPropPatterns.forEach((pattern: any) => { + if (!pattern.partial) { + return; + } + if ( + partialIdMatch( + pattern.keys, + pattern.values, + _keys, + vals + ) + ) { + const triggerAnyVals = getAnyVals(pattern.values, vals); + // Extract vals for pattern keys from the component + const patternRefVals = pattern.keys.map( + (k: string) => vals[_keys.indexOf(k)] + ); + pattern.callbacks.forEach( + addAllResolvedFromOutputs( + resolvePartialDeps( + pattern.keys, + patternRefVals, + pattern.values, + _keys + ), + paths, + matches, + triggerAnyVals + ) + ); + } + }); } - }); + } // end hasPartialPatterns guard } matches.forEach(match => { match.changedPropIds[idAndProp] = changeType || DIRECT; @@ -437,7 +514,7 @@ export function resolveDeps( refPatternVals?: string ) { return (paths: any) => - ({id: idPattern, property}: ICallbackProperty) => { + ({id: idPattern, property, partial: isPartial}: any) => { if (typeof idPattern === 'string') { const path = getPath(paths, idPattern); return path ? [{id: idPattern, property, path}] : []; @@ -446,24 +523,159 @@ export function resolveDeps( const patternVals = props(_keys, idPattern); const keyStr = _keys.join(','); const keyPaths = paths.objs[keyStr]; - if (!keyPaths) { - return []; + + if (keyPaths) { + const result: ILayoutCallbackProperty[] = []; + keyPaths.forEach(({values: vals, path}: any) => { + if ( + idMatch( + _keys, + vals, + patternVals, + refKeys, + refVals, + refPatternVals + ) + ) { + result.push({id: zipObj(_keys, vals), property, path}); + } + }); + return result; + } + + // If no exact keyStr match and input is partial, search for + // superset keyStrs (same logic as resolvePartialDeps) + if (isPartial) { + const result: ILayoutCallbackProperty[] = []; + for (const objKeyStr in paths.objs) { + const objKeys = objKeyStr.split(','); + if ( + !_keys.every((k: string) => objKeys.indexOf(k) !== -1) + ) { + continue; + } + const objKeyPaths = paths.objs[objKeyStr]; + objKeyPaths.forEach(({values: vals, path}: any) => { + if ( + partialIdMatch( + _keys, + patternVals, + objKeys, + vals, + refKeys, + refVals, + refPatternVals + ) + ) { + result.push({ + id: zipObj(objKeys, vals), + property, + path + }); + } + }); + } + return result; + } + + return []; + }; +} + +/* + * resolvePartialDeps: like resolveDeps but for partial patterns. + * The pattern may have fewer keys than the components in the layout. + * We search all paths.objs entries whose keys are a superset of the + * pattern's keys. + * + * patternKeys: the keys defined in the triggering pattern + * refVals: the triggering component's values for the pattern keys + * refPatternVals: the pattern values (wildcards/literals) for the pattern keys + * componentKeys: the full keys of the triggering component (superset) + */ +export function resolvePartialDeps( + patternKeys: string[], + refVals?: any, + refPatternVals?: any, + _componentKeys?: string[] +) { + return (paths: any) => + ({id: idPattern, property, partial: isPartial}: any) => { + if (typeof idPattern === 'string') { + const path = getPath(paths, idPattern); + return path ? [{id: idPattern, property, path}] : []; } + const _keys = Object.keys(idPattern).sort(); + const patternVals = props(_keys, idPattern); + + if (!isPartial) { + // Non-partial dependency in the same callback - use exact match + const keyStr = _keys.join(','); + const keyPaths = paths.objs[keyStr]; + if (!keyPaths) { + return []; + } + const result: ILayoutCallbackProperty[] = []; + // Map refVals from patternKeys to _keys ordering + const mappedRefVals = refVals + ? _keys.map((k: string) => { + const idx = patternKeys.indexOf(k); + return idx !== -1 ? refVals[idx] : undefined; + }) + : undefined; + const mappedRefPatternVals = refPatternVals + ? _keys.map((k: string) => { + const idx = patternKeys.indexOf(k); + return idx !== -1 ? refPatternVals[idx] : undefined; + }) + : undefined; + keyPaths.forEach(({values: vals, path}: any) => { + if ( + idMatch( + _keys, + vals, + patternVals, + patternKeys, + mappedRefVals, + mappedRefPatternVals + ) + ) { + result.push({id: zipObj(_keys, vals), property, path}); + } + }); + return result; + } + + // Partial: search all paths.objs entries whose keys are a + // superset of this pattern's keys const result: ILayoutCallbackProperty[] = []; - keyPaths.forEach(({values: vals, path}: any) => { - if ( - idMatch( - _keys, - vals, - patternVals, - refKeys, - refVals, - refPatternVals - ) - ) { - result.push({id: zipObj(_keys, vals), property, path}); + for (const objKeyStr in paths.objs) { + const objKeys = objKeyStr.split(','); + // Check if objKeys is a superset of _keys + if (!_keys.every((k: string) => objKeys.indexOf(k) !== -1)) { + continue; } - }); + const keyPaths = paths.objs[objKeyStr]; + keyPaths.forEach(({values: vals, path}: any) => { + if ( + partialIdMatch( + _keys, + patternVals, + objKeys, + vals, + patternKeys, + refVals, + refPatternVals + ) + ) { + result.push({ + id: zipObj(objKeys, vals), + property, + path + }); + } + }); + } return result; }; } diff --git a/dash/dash-renderer/src/observers/requestedCallbacks.ts b/dash/dash-renderer/src/observers/requestedCallbacks.ts index c1d57e7f4a..edcce0162a 100644 --- a/dash/dash-renderer/src/observers/requestedCallbacks.ts +++ b/dash/dash-renderer/src/observers/requestedCallbacks.ts @@ -367,7 +367,7 @@ const observer: IStoreObserverDefinition = { const res = isEmpty(intersection(inputs, updated)) && isEmpty(difference(inputs, allProps)) && - !all(isMultiValued, cb.callback.inputs); + !all(dep => isMultiValued(dep), cb.callback.inputs); return res; }, readyCallbacks); diff --git a/dash/dependencies.py b/dash/dependencies.py index 9ac13934e2..8206e1e0fd 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -43,6 +43,7 @@ class DashDependency: # pylint: disable=too-few-public-methods component_property: str allowed_wildcards: Sequence[Wildcard] allow_optional: bool + partial: bool def __init__(self, component_id: ComponentIdType, component_property: str): @@ -54,6 +55,7 @@ def __init__(self, component_id: ComponentIdType, component_property: str): self.component_property = component_property self.allow_duplicate = False self.allow_optional = False + self.partial = False def __str__(self): return f"{self.component_id_str()}.{self.component_property}" @@ -71,6 +73,8 @@ def to_dict(self) -> dict: } if self.allow_optional: specs["allow_optional"] = True + if self.partial: + specs["partial"] = True return specs def __eq__(self, other): @@ -94,29 +98,34 @@ def _id_matches(self, other) -> bool: if self_dict != other_dict: return False - if self_dict: - if set(my_id.keys()) != set(other_id.keys()): # type: ignore - return False + if not self_dict: + return my_id == other_id + + my_keys = set(my_id.keys()) # type: ignore + other_keys = set(other_id.keys()) # type: ignore + partial = self.partial or getattr(other, "partial", False) - for k, v in my_id.items(): # type: ignore - other_v = other_id[k] - if v == other_v: - continue - v_wild = isinstance(v, Wildcard) - other_wild = isinstance(other_v, Wildcard) - if v_wild or other_wild: - if not (v_wild and other_wild): - continue # one wild, one not - if v is ALL or other_v is ALL: - continue # either ALL - if v is MATCH or other_v is MATCH: - return False # one MATCH, one ALLSMALLER - else: - return False - return True - - # both strings - return my_id == other_id + keys_ok = ( + (my_keys <= other_keys or other_keys <= my_keys) + if partial + else my_keys == other_keys + ) + if not keys_ok: + return False + common_keys = my_keys & other_keys if partial else my_keys + + for k in common_keys: + v = my_id[k] # type: ignore + other_v = other_id[k] # type: ignore + if v == other_v: + continue + v_wild = isinstance(v, Wildcard) + other_wild = isinstance(other_v, Wildcard) + if not (v_wild or other_wild): + return False + if v_wild and other_wild and not (v is ALL or other_v is ALL): + return False + return True def __hash__(self): return hash(str(self)) @@ -142,9 +151,11 @@ def __init__( component_id: ComponentIdType, component_property: str, allow_duplicate: bool = False, + partial: bool = False, ): super().__init__(component_id, component_property) self.allow_duplicate = allow_duplicate + self.partial = partial class Input(DashDependency): # pylint: disable=too-few-public-methods @@ -155,9 +166,11 @@ def __init__( component_id: ComponentIdType, component_property: str, allow_optional: bool = False, + partial: bool = False, ): super().__init__(component_id, component_property) self.allow_optional = allow_optional + self.partial = partial allowed_wildcards = (MATCH, ALL, ALLSMALLER) @@ -170,9 +183,11 @@ def __init__( component_id: ComponentIdType, component_property: str, allow_optional: bool = False, + partial: bool = False, ): super().__init__(component_id, component_property) self.allow_optional = allow_optional + self.partial = partial allowed_wildcards = (MATCH, ALL, ALLSMALLER) diff --git a/tests/integration/callbacks/test_partial_wildcards.py b/tests/integration/callbacks/test_partial_wildcards.py new file mode 100644 index 0000000000..d9c16eaef5 --- /dev/null +++ b/tests/integration/callbacks/test_partial_wildcards.py @@ -0,0 +1,179 @@ +"""Tests for partial pattern matching in callbacks.""" + +import json + +from dash import Dash, Input, Output, ALL, html + + +def stringify_id(id_): + if isinstance(id_, dict): + return json.dumps(id_, sort_keys=True, separators=(",", ":")) + return id_ + + +def test_partial_match_basic(dash_duo): + """A callback with partial=True on Input matches components with extra keys. + Literal-only partial patterns are implicitly multi-valued (return a list). + """ + app = Dash(__name__, suppress_callback_exceptions=True) + + app.layout = html.Div( + [ + # Components with extra keys beyond what the callback pattern specifies + html.Button( + "Button A", + id={"type": "btn", "index": 1, "page": "home"}, + ), + html.Button( + "Button B", + id={"type": "btn", "index": 2, "page": "settings"}, + ), + html.Div("initial", id="output"), + ] + ) + + @app.callback( + Output("output", "children"), + Input({"type": "btn"}, "n_clicks", partial=True), + prevent_initial_call=True, + ) + def on_click(n_clicks_list): + # Literal-only partial is multi-valued: receives a list + total = sum(c or 0 for c in n_clicks_list) + return f"total clicks: {total}" + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#output", "initial") + + # Click the first button - should trigger partial match + dash_duo.find_element('[id=\'{"index":1,"page":"home","type":"btn"}\']').click() + dash_duo.wait_for_text_to_equal("#output", "total clicks: 1") + + assert dash_duo.get_logs() == [] + + +def test_partial_match_all(dash_duo): + """A callback with partial=True and ALL collects all matching components.""" + app = Dash(__name__, suppress_callback_exceptions=True) + + app.layout = html.Div( + [ + html.Button( + "Btn 1", + id={"type": "btn", "index": 1, "section": "alpha"}, + ), + html.Button( + "Btn 2", + id={"type": "btn", "index": 2, "section": "beta"}, + ), + html.Button( + "Btn 3", + id={"type": "other", "index": 3, "section": "gamma"}, + ), + html.Div("initial", id="output"), + ] + ) + + @app.callback( + Output("output", "children"), + Input({"type": ALL}, "n_clicks", partial=True), + ) + def on_click(n_clicks_list): + return f"clicks: {n_clicks_list}" + + dash_duo.start_server(app) + + # Should collect all 3 buttons (all have "type" key) + dash_duo.wait_for_text_to_equal("#output", "clicks: [None, None, None]") + + dash_duo.find_element('[id=\'{"index":1,"section":"alpha","type":"btn"}\']').click() + dash_duo.wait_for_text_to_equal("#output", "clicks: [1, None, None]") + + assert dash_duo.get_logs() == [] + + +def test_partial_match_with_literal_value(dash_duo): + """Partial matching with a literal value filters to specific matches. + Only components where type='btn' are collected (multi-valued). + """ + app = Dash(__name__, suppress_callback_exceptions=True) + + app.layout = html.Div( + [ + html.Button( + "Btn A", + id={"type": "btn", "index": 1, "page": "home"}, + ), + html.Button( + "Btn B", + id={"type": "btn", "index": 2, "page": "settings"}, + ), + html.Button( + "Other", + id={"type": "link", "index": 3, "page": "home"}, + ), + html.Div("initial", id="output"), + ] + ) + + @app.callback( + Output("output", "children"), + Input({"type": "btn"}, "n_clicks", partial=True), + prevent_initial_call=True, + ) + def on_btn_click(n_clicks_list): + # Only type="btn" components are collected (not type="link") + total = sum(c or 0 for c in n_clicks_list) + return f"btn total: {total}, count: {len(n_clicks_list)}" + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#output", "initial") + + # Click a "btn" type - should trigger, collecting 2 btn components + dash_duo.find_element('[id=\'{"index":1,"page":"home","type":"btn"}\']').click() + dash_duo.wait_for_text_to_equal("#output", "btn total: 1, count: 2") + + assert dash_duo.get_logs() == [] + + +def test_partial_match_mixed_keys(dash_duo): + """Components with different extra keys all match the same partial pattern. + Both components have type='action' but different other keys. + """ + app = Dash(__name__, suppress_callback_exceptions=True) + + app.layout = html.Div( + [ + # Different components with different key sets, all having "type" + html.Button( + "A", + id={"type": "action", "index": 1}, + ), + html.Button( + "B", + id={"type": "action", "page": "main", "tab": "first"}, + ), + html.Div("none", id="output"), + ] + ) + + @app.callback( + Output("output", "children"), + Input({"type": "action"}, "n_clicks", partial=True), + prevent_initial_call=True, + ) + def on_action(n_clicks_list): + # Multi-valued: collects from all components with type="action" + total = sum(c or 0 for c in n_clicks_list) + return f"actions: {len(n_clicks_list)}, total: {total}" + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#output", "none") + + # Click first button + dash_duo.find_element('[id=\'{"index":1,"type":"action"}\']').click() + dash_duo.wait_for_text_to_equal("#output", "actions: 2, total: 1") + + assert dash_duo.get_logs() == [] diff --git a/tests/unit/test_partial_matching.py b/tests/unit/test_partial_matching.py new file mode 100644 index 0000000000..53a1ee5edb --- /dev/null +++ b/tests/unit/test_partial_matching.py @@ -0,0 +1,114 @@ +"""Unit tests for partial pattern matching in dependencies.""" +from dash.dependencies import Input, Output, State, MATCH, ALL + + +class TestPartialIdMatches: + """Test _id_matches with partial=True.""" + + def test_partial_subset_keys_match(self): + """Pattern with fewer keys should match component with more keys.""" + pattern = Input({"type": "btn"}, "n_clicks", partial=True) + component = Input({"type": "btn", "index": 1}, "n_clicks") + assert pattern == component + + def test_partial_subset_keys_no_match_value(self): + """Pattern with a literal value that doesn't match should fail.""" + pattern = Input({"type": "btn"}, "n_clicks", partial=True) + component = Input({"type": "link", "index": 1}, "n_clicks") + assert pattern != component + + def test_partial_wildcard_match(self): + """Partial pattern with MATCH wildcard should match any type value.""" + pattern = Input({"type": MATCH}, "n_clicks", partial=True) + component = Input({"type": "btn", "index": 1}, "n_clicks") + assert pattern == component + + def test_partial_all_wildcard(self): + """Partial pattern with ALL wildcard should match.""" + pattern = Input({"type": ALL}, "n_clicks", partial=True) + component = Input({"type": "btn", "index": 1, "page": "home"}, "n_clicks") + assert pattern == component + + def test_partial_no_common_keys(self): + """Pattern with no common keys should not match.""" + pattern = Input({"category": "nav"}, "n_clicks", partial=True) + component = Input({"type": "btn", "index": 1}, "n_clicks") + assert pattern != component + + def test_partial_exact_same_keys(self): + """Partial with exact same keys should still work like normal.""" + pattern = Input({"type": MATCH, "index": 1}, "n_clicks", partial=True) + component = Input({"type": "btn", "index": 1}, "n_clicks") + assert pattern == component + + def test_non_partial_different_keys_no_match(self): + """Without partial, different keys should not match (existing behavior).""" + pattern = Input({"type": "btn"}, "n_clicks") + component = Input({"type": "btn", "index": 1}, "n_clicks") + assert pattern != component + + def test_partial_multiple_pattern_keys(self): + """Partial with multiple keys, component has even more.""" + pattern = Input({"type": "btn", "page": "home"}, "n_clicks", partial=True) + component = Input( + {"type": "btn", "page": "home", "index": 1, "section": "main"}, + "n_clicks", + ) + assert pattern == component + + def test_partial_multiple_pattern_keys_one_mismatch(self): + """Partial with multiple keys where one doesn't match.""" + pattern = Input({"type": "btn", "page": "home"}, "n_clicks", partial=True) + component = Input( + {"type": "btn", "page": "settings", "index": 1}, + "n_clicks", + ) + assert pattern != component + + def test_partial_output(self): + """Output with partial=True should work.""" + pattern = Output({"type": "display"}, "children", partial=True) + component = Output({"type": "display", "index": 1}, "children") + assert pattern == component + + def test_partial_state(self): + """State with partial=True should work.""" + pattern = State({"type": "store"}, "data", partial=True) + component = State({"type": "store", "page": "main"}, "data") + assert pattern == component + + def test_partial_different_property_no_match(self): + """Even with partial, different properties should not match.""" + pattern = Input({"type": "btn"}, "n_clicks", partial=True) + component = Input({"type": "btn", "index": 1}, "value") + assert pattern != component + + def test_partial_to_dict_includes_flag(self): + """to_dict() should include partial: True.""" + dep = Input({"type": MATCH}, "n_clicks", partial=True) + d = dep.to_dict() + assert d["partial"] is True + + def test_non_partial_to_dict_excludes_flag(self): + """to_dict() should not include partial when False.""" + dep = Input({"type": MATCH}, "n_clicks") + d = dep.to_dict() + assert "partial" not in d + + +class TestPartialMatchSymmetry: + """Test that partial matching is directional - the partial-flagged dep + is the one that allows subset matching.""" + + def test_other_side_partial(self): + """If the 'other' side has partial=True, subset matching works.""" + pattern = Input({"type": "btn", "index": 1}, "n_clicks") + component = Input({"type": "btn"}, "n_clicks", partial=True) + # Either side having partial should enable subset matching + assert pattern == component + + def test_component_superset_of_pattern(self): + """Component has more keys than pattern - should match with partial.""" + pattern = Input({"type": MATCH}, "value", partial=True) + component = Input({"type": "input", "index": 5, "tab": "first"}, "value") + assert pattern == component