Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions mpcontribs-api/src/mpcontribs_api/_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
Hierarchical, JSON-object data for the contribution. Nesting deeper than 7 levels is rejected. Lists
are allowed; any dictionaries inside them have their keys coerced and validated like every other key.

**Key coercion (important):** every dictionary key is coerced to `snake_case` on write. Casing is
lowercased, `camelCase`/`PascalCase` boundaries are split, and any run of spaces/hyphens/punctuation
collapses to a single underscore. So `"bandGap"`, `"Band Gap"`, and `"band-gap"` are all stored as
`"band_gap"`, and the **keys you read back may differ from the keys you submitted**. Keys must be
ASCII and must not reduce to an empty string (e.g. `"***"` is rejected).
**Key coercion (important):** every dictionary key is coerced to `camelCase` on write. Word
boundaries are detected from casing, acronyms, and any run of spaces/hyphens/punctuation, then joined
so the first word is lowercase and each subsequent word is capitalized. So `"band_gap"`, `"Band Gap"`,
and `"band-gap"` are all stored as `"bandGap"`, and the **keys you read back may differ from the keys
you submitted**. Keys must be ASCII and must not reduce to an empty string (e.g. `"***"` is rejected).

**Reserved keys:** `si_value`, `si_unit`, `value`, `unit`, `si_error`, `error`,
`precision`, and `display` are reserved for the stored value-leaf shape and may **not** be used as
Expand All @@ -31,7 +31,7 @@

Annotation rules: the single token without an `=` is the **unit** (e.g. `eV`, `S/cm`, `K`), left
verbatim so it round-trips through Pint; each `k=v` token is a **condition** (names coerced to
`snake_case`); `name` may be a dotted path (`"transport.conductivity (S/cm)"`) to nest the value; a
`camelCase`); `name` may be a dotted path (`"transport.conductivity (S/cm)"`) to nest the value; a
key with no parentheses is a plain key. If a unit is given in **both** the key and the value and they
differ, the **key's unit wins** — the value is converted into it (a dimensional mismatch is rejected).

Expand All @@ -58,7 +58,7 @@
"""

CONTRIBUTION_DATA_OUTPUT_DESCRIPTION = """\
Hierarchical contribution data. Keys are stored in `snake_case` (see the write schema for the
Hierarchical contribution data. Keys are stored in `camelCase` (see the write schema for the
coercion rules), so they may differ from the keys originally submitted.

Any value that reads as a number is stored as a **quantity leaf** object:
Expand All @@ -76,7 +76,7 @@
`precision` reproduce the submitted form exactly, so a client can format the value however it likes.

Values that are not numeric (words, booleans, lists) keep whatever JSON shape they were submitted
with (after `snake_case` key coercion).
with (after `camelCase` key coercion).
"""

openapi_tags = [
Expand All @@ -96,7 +96,7 @@
"Each contribution uses `mp-id` or composition as identifier to associate its data with the according entries "
"on MP. Only admins or users on the project can create, update or delete contributions, and while unpublished, "
"retrieve its data or view it on the Portal. Contribution components (tables, structures, and attachments) are "
"deleted along with a contribution. **Note:** `data` keys are coerced to `snake_case` on write and may carry "
"deleted along with a contribution. **Note:** `data` keys are coerced to `camelCase` on write and may carry "
"unit/condition annotations of the form `name (unit, cond=value, ...)`; keys with conditions cause the "
"submission to pivot into one contribution per condition signature. See the `data` field on the request and "
"response schemas for the full grammar and the annotated-value shape.",
Expand Down
84 changes: 78 additions & 6 deletions mpcontribs-api/src/mpcontribs_api/domains/_shared/types.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import re
import unicodedata
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from enum import StrEnum
from typing import Annotated, Any, Self
from typing import Annotated, Any, Literal, Self

import polars as pl
from fastapi import Query
Expand Down Expand Up @@ -339,9 +340,30 @@ def to_snake_case(name: str) -> str:
return s.strip("_").lower()


def to_camel_case(name: str) -> str:
"""Coerce a single key token to canonical ``camelCase``.

Uses ``to_snake_case`` as an easy way to coerce.
Examples: ``"band_gap"``/``"Band Gap"`` -> ``"bandGap"``, ``"pH-Value"`` -> ``"phValue"``,
``"bandGap"`` -> ``"bandGap"``.
"""
snake = to_snake_case(name)
if not snake:
return ""
# to_snake_case never emits empty or doubled-underscore segments, so every tail word is a
# non-empty, already-lowercase token.
head, *tail = snake.split("_")
return head + "".join(word[:1].upper() + word[1:] for word in tail)


CANONICAL_KEY_COERCION: Callable[[str], str] = to_camel_case

# Converts strs to snake case
SnakeCaseStr = Annotated[str, BeforeValidator(func=to_snake_case)]

# Converts strs to camel case
CamelCaseStr = Annotated[str, BeforeValidator(func=to_camel_case)]

# Converts strs to searchable form (NFKC compatibility fold + casefold)
SearchStr = Annotated[str, BeforeValidator(func=_nfkc_casefold)]

Expand Down Expand Up @@ -369,25 +391,34 @@ def _validate_slug(v: str) -> str:
Slug = Annotated[str, Field(min_length=3, max_length=50), BeforeValidator(_validate_slug)]


def coerce_key(key: Any, *, require_ascii: bool = False, reserved: frozenset[str] | None = None) -> str:
"""Coerce one dict key to canonical ``snake_case``, enforcing the shared write-path key guards.
def coerce_key(
key: Any,
*,
require_ascii: bool = True,
reserved: frozenset[str] | None = None,
coercion_method: Callable[[str], str] = CANONICAL_KEY_COERCION,
) -> str:
"""Coerce one dict key to canonical case using ``coercion_method``, enforcing the shared write-path key guards.

Always rejects a key that reduces to an empty string after coercion. The extra guards are opt-in
per call site, since not every caller wants them (e.g. the post-validation write path skips the
ASCII check because keys were already validated):

- ``require_ascii``: reject a non-``str`` or non-ASCII key before coercion.
- ``reserved``: reject a coerced key that lands in the reserved-leaf-key set.
- ``coercion_method``: the method to use to coerce a key

Raises:
DataKeyError: on a non-ASCII (when required), empty-after-coercion, or reserved key. It is a
:class:`ValidationError` subclass, so callers catching either still see it.
"""
if not key:
raise DataKeyError(message="Key must be truthy", key=key)
if require_ascii and (not isinstance(key, str) or not key.isascii()):
raise DataKeyError("Non-ASCII key found in Contribution.data. All dict keys must be only ASCII")
coerced = to_snake_case(key)
raise DataKeyError("Non-ASCII key found. All dict keys must be only ASCII", key=key)
coerced = coercion_method(key)
if not coerced:
raise DataKeyError(f"data key '{key}' reduces to an empty string after snake_case coercion")
raise DataKeyError(f"data key '{key}' reduces to an empty string after key coercion", key=key)
if reserved is not None and coerced in reserved:
raise DataKeyError(
f"data key '{key}' is reserved for annotated-value leaves and may not be used",
Expand All @@ -397,6 +428,47 @@ def coerce_key(key: Any, *, require_ascii: bool = False, reserved: frozenset[str
return coerced


@dataclass(frozen=True, slots=True)
class KeyOffense:
"""One ``data`` key that is not already in the expected canonical form.

``suggestion`` is the canonical spelling the caller should use, or ``None`` when there is no
clean suggestion (a non-ASCII key, a key that reduces to an empty string, or a reserved leaf
name that is already canonical). ``reason`` is a stable, machine-readable tag.
"""

key: Any
suggestion: str | None
reason: Literal["not_camel_case", "non_ascii", "empty_after_coercion", "reserved"]

@classmethod
def from_key(
cls,
key: Any,
*,
reserved: frozenset[str] | None = None,
coercion_method: Callable[[str], str] = CANONICAL_KEY_COERCION,
) -> KeyOffense | None:
"""Return a :class:`KeyOffense` when ``key`` is not already an acceptable canonical data key, else ``None``.

A key is acceptable iff it is a non-empty ASCII string that equals its own canonical form
(``coercion_method(key) == key``) and is not a reserved leaf name.
"""
if not isinstance(key, str) or not key.isascii():
return cls(key=key, suggestion=None, reason="non_ascii")
canonical = coercion_method(key)
if not canonical:
return cls(key=key, suggestion=None, reason="empty_after_coercion")
# Check reserved against the canonical form (not the raw key), so a non-canonical key whose
# canonical spelling is reserved (e.g. "Value" -> "value") is reported as reserved rather than
# suggesting a reserved name the caller could never use.
if reserved is not None and canonical in reserved:
return cls(key=key, suggestion=None, reason="reserved")
if canonical != key:
return cls(key=key, suggestion=canonical, reason="not_camel_case")
return None


def map_keys(value: Any, *, coerce: Callable[[Any], str], on_scalar: Callable[[Any], Any] = lambda x: x) -> Any:
"""Recursively rebuild ``value`` with every dict key coerced via ``coerce``.

Expand Down
108 changes: 57 additions & 51 deletions mpcontribs-api/src/mpcontribs_api/domains/contributions/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pydantic import BeforeValidator

from mpcontribs_api.config import get_settings
from mpcontribs_api.domains._shared.types import coerce_key
from mpcontribs_api.domains._shared.types import KeyOffense
from mpcontribs_api.domains._shared.units import QuantityLeaf
from mpcontribs_api.exceptions import DataKeyError, ValidationError

Expand Down Expand Up @@ -99,97 +99,101 @@ def _validate_data_depth(data: dict[str, Any] | None) -> dict[str, Any] | None:
return data


def _validate_plain_key(key: Any) -> None:
"""Validate a single plain key token (a path segment or a condition name).

Punctuation, spaces, and casing are no longer rejected: keys are coerced to ``snake_case`` on the
write path (see :func:`to_snake_case`), which folds ``*``/``/``/``|`` and any other non-alphanumeric
run to ``_``. This only rejects keys that cannot be coerced into a usable token: non-ASCII, empty,
or ones that reduce to an empty string after coercion (e.g. ``"***"``).
"""
if key == "":
raise ValidationError("Empty key found in Contribution.data. Keys must be non-empty.")
coerce_key(key, require_ascii=True, reserved=QuantityLeaf.reserved_keys())
def _check_key(raw_key: Any, offenses: list[KeyOffense]) -> None:
"""Append a :class:`KeyOffense` for ``raw_key`` unless it is already an acceptable canonical key."""
offense = KeyOffense.from_key(raw_key, reserved=QuantityLeaf.reserved_keys())
if offense is not None:
offenses.append(offense)


def _validate_nested_keys(value: Any, *, allow_leaf_fragments: bool = False) -> None:
def _collect_nested_offenses(value: Any, offenses: list[KeyOffense], *, allow_leaf_fragments: bool) -> None:
if isinstance(value, dict):
_validate_keys(value, allow_leaf_fragments=allow_leaf_fragments)
_collect_plain_offenses(value, offenses, allow_leaf_fragments=allow_leaf_fragments)
elif isinstance(value, list):
for item in value:
_validate_nested_keys(item, allow_leaf_fragments=allow_leaf_fragments)
_collect_nested_offenses(item, offenses, allow_leaf_fragments=allow_leaf_fragments)


def _validate_keys(data: dict[str, Any] | None, *, allow_leaf_fragments: bool = False) -> dict[str, Any] | None:
"""Strict plain-key validation for a single dict level (used for nested levels).
def _collect_plain_offenses(
data: dict[str, Any] | None, offenses: list[KeyOffense], *, allow_leaf_fragments: bool
) -> None:
"""Collect non-canonical keys for a single dict level (strict plain keys, used for nested levels).

With ``allow_leaf_fragments`` (the patch/merge path), a dict whose keys are all reserved leaf
keys is accepted as a terminal fragment addressing fields *inside* a stored quantity leaf (e.g.
``{'unit': 'kg'}``); the strict insert path leaves this off and rejects reserved keys as plain keys.
"""
if data is None:
return None
# A server-built quantity leaf legitimately uses the reserved key names; do not descend into it
# (re-validation after normalization/expansion would otherwise reject its own keys).
if QuantityLeaf.is_leaf(data):
return data
if allow_leaf_fragments and QuantityLeaf.is_fragment(data):
return data
if data is None or QuantityLeaf.is_leaf(data) or allow_leaf_fragments and QuantityLeaf.is_fragment(data):
return
for key in data:
_validate_plain_key(key)
_check_key(key, offenses)
# Recurse into nested dicts, including dicts nested inside lists.
for v in data.values():
_validate_nested_keys(v, allow_leaf_fragments=allow_leaf_fragments)
return data
_collect_nested_offenses(v, offenses, allow_leaf_fragments=allow_leaf_fragments)


def _validate_data_keys(data: dict[str, Any] | None, *, allow_leaf_fragments: bool = False) -> dict[str, Any] | None:
"""Top-level ``data`` key validation, allowing the annotated pattern.
def _collect_data_offenses(
data: dict[str, Any] | None, offenses: list[KeyOffense], *, allow_leaf_fragments: bool
) -> None:
"""Collect non-canonical keys for the top ``data`` level, allowing the annotated pattern.

Each top-level key may be either a plain key or the annotated form
``name (unit, cond1=..., cond2=...)``. The name's dotted segments and every condition name are
held to the same plain-key rules (units are unconstrained); nested levels stay strictly plain.
Expansion (see :mod:`mpcontribs_api.domains.contributions.pivot`) later rewrites annotated keys
into plain ones, so stored keys always satisfy :func:`_validate_keys`.
Each top-level key may be either a plain key or the annotated form ``name (unit, cond1=..., cond2=...)``.
The name's dotted segments and every condition name are held to the same canonical-key rules (units
are unconstrained); nested levels stay strictly plain. A malformed annotation is a syntax error and is
raised eagerly (it is not a format-mismatch we can suggest a spelling for).

``allow_leaf_fragments`` is threaded to nested levels only (the patch/merge path); top-level keys
stay strict, since a reserved key at the root addresses no leaf.
"""
if data is None:
return None
return
for raw_key in data:
if not isinstance(raw_key, str):
raise ValidationError("Non-ASCII key found in Contribution.data. All dict keys must be only ASCII")
_check_key(raw_key, offenses)
continue
try:
parsed = parse_annotated_key(raw_key)
except ValidationError as err:
raise ValidationError(f"Malformed annotated key in Contribution.data: {err}") from err
if not parsed.is_annotated:
# A plain key keeps the original strict rule (no '.' nesting); only annotated keys may
# use dotted paths, whose segments are validated individually below.
_validate_plain_key(raw_key)
_check_key(raw_key, offenses)
continue
for segment in parsed.segments:
_validate_plain_key(segment)
_check_key(segment, offenses)
for condition_name in parsed.conditions:
_validate_plain_key(condition_name)
_check_key(condition_name, offenses)
for v in data.values():
_validate_nested_keys(v, allow_leaf_fragments=allow_leaf_fragments)
return data
_collect_nested_offenses(v, offenses, allow_leaf_fragments=allow_leaf_fragments)


def _raise_on_offenses(offenses: list[KeyOffense]) -> None:
if offenses:
raise DataKeyError(
"Contribution.data contains keys not in the expected canonical (camelCase) format",
offending_keys=[{"key": o.key, "suggestion": o.suggestion, "reason": o.reason} for o in offenses],
)


def validate_contribution_data(
data: dict[str, Any] | None, *, allow_leaf_fragments: bool = False
) -> dict[str, Any] | None:
"""Run the write-path ``data`` validation (depth + annotated/plain keys).

Keys are validated but never rewritten: a key not already in the expected canonical form is rejected, and
every offending key is collected so the error lists them all at once (with a suggested spelling) rather than
failing on the first.

``allow_leaf_fragments`` (the merge-patch path) additionally accepts a nested dict of only reserved
leaf keys as a terminal fragment addressing a field inside a stored quantity leaf (e.g. ``{'bandgap':
leaf keys as a terminal fragment addressing a field inside a stored quantity leaf (e.g. ``{'bandGap':
{'unit': 'kg'}}``). The strict insert path rejects reserved keys as plain keys; a whole-dict
overwrite (``replace_data=True``) re-runs this strictly, since the payload becomes a full document.
"""
_validate_data_depth(data)
_validate_data_keys(data, allow_leaf_fragments=allow_leaf_fragments)
offenses: list[KeyOffense] = []
_collect_data_offenses(data, offenses, allow_leaf_fragments=allow_leaf_fragments)
_raise_on_offenses(offenses)
return data


Expand All @@ -198,20 +202,22 @@ def validate_stored_contribution_data(data: dict[str, Any] | None) -> dict[str,

This is the stored-document counterpart to :func:`validate_contribution_data`. By the time a
:class:`~mpcontribs_api.domains.contributions.models.Contribution` is built, pivot/expansion has
already coerced every key to canonical snake_case, so the stored payload must satisfy the plain-key
rules at *every* level — the annotated-key grammar (``name (unit, cond=...)``) is no longer allowed
even at the top level. The depth bound is the same settings-driven limit as the input path, so the
two cannot drift.
unwrapped any annotated keys, so the stored payload must satisfy the canonical plain-key rules at
*every* level — the annotated-key grammar (``name (unit, cond=...)``) is no longer allowed even at
the top level. The depth bound is the same settings-driven limit as the input path, so the two
cannot drift.
"""
_validate_data_depth(data)
_validate_keys(data)
offenses: list[KeyOffense] = []
_collect_plain_offenses(data, offenses, allow_leaf_fragments=False)
_raise_on_offenses(offenses)
return data


# Three field types over one shared validation core, so the input, patch, and stored paths cannot
# drift on depth or key rules: inserts/whole-document writes are strict; a merge patch additionally
# permits leaf fragments (see ``allow_leaf_fragments`` above); the stored document requires fully
# coerced plain keys (no annotated-key grammar).
# permits leaf fragments (see ``allow_leaf_fragments`` above); the stored document requires canonical
# plain keys at every level (no annotated-key grammar).
ContributionData = Annotated[dict[str, Any] | None, BeforeValidator(validate_contribution_data)]
ContributionPatchData = Annotated[
dict[str, Any] | None,
Expand Down
Loading
Loading