Files
superset2/superset/versioning/changes/normalization.py
T

301 lines
10 KiB
Python

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
"""Types and bounds for chart normalization change summaries."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Final, NotRequired, TypeAlias, TypedDict, TypeGuard
from uuid import uuid4
from sqlalchemy.orm import Session
from superset.utils import json
from superset.versioning.diff import ChangeRecord
JsonScalar: TypeAlias = None | bool | int | float | str
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
MAX_NORMALIZATION_TRANSITIONS: Final[int] = 256
MAX_CONTROL_NAME_BYTES: Final[int] = 256
MAX_NORMALIZATION_METADATA_BYTES: Final[int] = 256 * 1024
MAX_NORMALIZATION_VALUE_DEPTH: Final[int] = 20
NORMALIZATION_CONTEXT_KEY: Final[str] = "_versioning_chart_normalization_context"
logger: logging.Logger = logging.getLogger(__name__)
class NormalizationTransitionPayload(TypedDict):
"""Presence-aware transition received as advisory request metadata."""
control: str
from_present: bool
from_value: NotRequired[JsonValue]
to_present: bool
to_value: NotRequired[JsonValue]
@dataclass(frozen=True)
class NormalizationTransition:
"""Validated top-level chart params transition."""
control: str
from_present: bool
from_value: JsonValue
to_present: bool
to_value: JsonValue
@dataclass(frozen=True)
class NormalizationContext:
"""Consume-once evidence scoped to one chart update operation."""
chart_id: int
operation_token: str
transitions: tuple[NormalizationTransition, ...]
@dataclass
class NormalizationContextRegistry:
"""Operation-token registry retained for the active transaction."""
contexts: dict[tuple[int, str], NormalizationContext]
active_tokens: dict[int, str | None]
class _InvalidNormalizationEnvelopeError(ValueError):
"""Advisory metadata whose ambiguity requires rejecting all transitions."""
def _json_depth(value: JsonValue) -> int:
if isinstance(value, list):
return 1 + max((_json_depth(item) for item in value), default=0)
if isinstance(value, dict):
return 1 + max((_json_depth(item) for item in value.values()), default=0)
return 0
def _is_json_value(value: object) -> TypeGuard[JsonValue]:
if value is None or isinstance(value, (bool, int, float, str)):
return True
if isinstance(value, list):
return all(_is_json_value(item) for item in value)
if isinstance(value, dict):
return all(
isinstance(key, str) and _is_json_value(item) for key, item in value.items()
)
return False
def _json_equal(left: JsonValue, right: JsonValue) -> bool:
"""Compare JSON values without Python's ``True == 1`` coercion."""
if type(left) is not type(right):
return False
if isinstance(left, list) and isinstance(right, list):
return len(left) == len(right) and all(
_json_equal(a, b) for a, b in zip(left, right, strict=False)
)
if isinstance(left, dict) and isinstance(right, dict):
return left.keys() == right.keys() and all(
_json_equal(left[key], right[key]) for key in left
)
return left == right
def _parse_normalization_transition(
item: object,
) -> NormalizationTransition | None:
"""Parse one transition, skipping malformed entries without ambiguity."""
if not isinstance(item, dict):
return None
control: object = item.get("control")
from_present: object = item.get("from_present")
to_present: object = item.get("to_present")
if (
not isinstance(control, str)
or not control
or len(control.encode()) > MAX_CONTROL_NAME_BYTES
or not isinstance(from_present, bool)
or not isinstance(to_present, bool)
):
return None
if (from_present != ("from_value" in item)) or (to_present != ("to_value" in item)):
return None
from_value: object = item.get("from_value")
to_value: object = item.get("to_value")
if not _is_json_value(from_value) or not _is_json_value(to_value):
return None
if (
_json_depth(from_value) > MAX_NORMALIZATION_VALUE_DEPTH
or _json_depth(to_value) > MAX_NORMALIZATION_VALUE_DEPTH
):
raise _InvalidNormalizationEnvelopeError
return NormalizationTransition(
control=control,
from_present=from_present,
from_value=from_value,
to_present=to_present,
to_value=to_value,
)
def sanitize_normalization_changes(
raw: object,
) -> tuple[NormalizationTransition, ...]:
"""Return bounded valid entries, or no exclusions for an invalid envelope."""
try:
encoded: bytes = json.dumps(
raw, ensure_ascii=False, separators=(",", ":")
).encode()
if (
not isinstance(raw, list)
or len(raw) > MAX_NORMALIZATION_TRANSITIONS
or len(encoded) > MAX_NORMALIZATION_METADATA_BYTES
):
return ()
transitions: list[NormalizationTransition] = []
controls: set[str] = set()
for item in raw:
transition: NormalizationTransition | None = (
_parse_normalization_transition(item)
)
if transition is None:
continue
if transition.control in controls:
return ()
controls.add(transition.control)
transitions.append(transition)
return tuple(transitions)
except (
_InvalidNormalizationEnvelopeError,
TypeError,
ValueError,
UnicodeError,
RecursionError,
):
return ()
def matching_normalization_context(
chart_id: int,
raw: object,
before_params: dict[str, JsonValue],
after_params: dict[str, JsonValue],
) -> NormalizationContext | None:
"""Match sanitized advisory transitions against exact params states."""
matching: list[NormalizationTransition] = []
for transition in sanitize_normalization_changes(raw):
before_present: bool = transition.control in before_params
after_present: bool = transition.control in after_params
if (
before_present != transition.from_present
or after_present != transition.to_present
):
continue
if before_present and not _json_equal(
before_params[transition.control], transition.from_value
):
continue
if after_present and not _json_equal(
after_params[transition.control], transition.to_value
):
continue
matching.append(transition)
if not matching:
return None
return NormalizationContext(chart_id, str(uuid4()), tuple(matching))
def register_matching_normalization_context(
session: Session,
chart_id: int,
raw: object,
before_params_json: str | bytes | bytearray | None,
after_params_json: str | bytes | bytearray | None,
) -> None:
"""Validate and register advisory evidence for one chart update."""
if raw is None:
return
try:
before_params: object = json.loads(before_params_json or "{}")
after_params: object = json.loads(after_params_json or "{}")
if not isinstance(before_params, dict) or not isinstance(after_params, dict):
return
context: NormalizationContext | None = matching_normalization_context(
chart_id, raw, before_params, after_params
)
if context is not None:
store_normalization_context(session, context)
except Exception: # pylint: disable=broad-except
logger.exception(
"Ignoring chart normalization metadata for chart id=%s", chart_id
)
def store_normalization_context(
session: Session, context: NormalizationContext
) -> None:
"""Store one operation's evidence, invalidating ambiguous same-chart evidence."""
registry: NormalizationContextRegistry = session.info.setdefault(
NORMALIZATION_CONTEXT_KEY,
NormalizationContextRegistry(contexts={}, active_tokens={}),
)
existing_token: str | None = registry.active_tokens.get(context.chart_id)
if existing_token is not None:
registry.contexts.pop((context.chart_id, existing_token), None)
registry.active_tokens[context.chart_id] = None
return
if context.chart_id in registry.active_tokens:
return
registry.contexts[(context.chart_id, context.operation_token)] = context
registry.active_tokens[context.chart_id] = context.operation_token
def consume_normalization_context(
session: Session, chart_id: int
) -> NormalizationContext | None:
"""Consume chart-scoped evidence at most once."""
registry: NormalizationContextRegistry | None = session.info.get(
NORMALIZATION_CONTEXT_KEY
)
if registry is None:
return None
operation_token: str | None = registry.active_tokens.pop(chart_id, None)
if operation_token is None:
return None
return registry.contexts.pop((chart_id, operation_token), None)
def filter_normalization_records(
records: list[ChangeRecord], context: NormalizationContext | None
) -> list[ChangeRecord]:
"""Return a fresh readable diff with exact normalization controls omitted."""
if context is None:
return list(records)
controls: set[str] = {transition.control for transition in context.transitions}
return [
record
for record in records
if not (
len(record.path) >= 2
and record.path[0] == "params"
and record.path[1] in controls
)
]