# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
#
# SPDX-License-Identifier: GPL-3.0-or-later
r"""Parameter paths: the canonical name of every value in a recipe.
A path is a keyword, then any number of steps: ``.name`` for a keyword of an object,
``[i]`` for an element of an array and ``["key"]`` for a key of a mapping, as in
``pressure``, ``pulses[1].energy`` or ``launch["HE1,1:even"].share``. Paths are what
``Recipe.set`` and ``Recipe.get`` take, what errors name, what a scan varies and what a
pin sets.
In the tree a TOML document reads into, an object and a mapping are both dictionaries,
so ``.name`` and ``["name"]`` address the same entry; the parsed path keeps them apart
(:class:`Attribute` and :class:`Key`) because they are different texts, and the
canonical text is the one :func:`format_path` writes. An index is a non-negative
integer with no leading zero, and a key is a JSON string, so ``["a\"b"]`` is the key
``a"b``.
"""
import json
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from solprop.recipe.errors import RecipeError
__all__ = [
"Attribute",
"Index",
"Key",
"Step",
"format_path",
"get_path",
"parse_path",
"set_path",
]
[docs]
@dataclass(frozen=True)
class Attribute:
"""A keyword, or a keyword of an object: ``name`` or ``.name`` in a path.
Attributes
----------
name : str
A Python identifier.
"""
name: str
[docs]
@dataclass(frozen=True)
class Index:
"""An element of an array: ``[i]`` in a path.
Attributes
----------
position : int
The zero-based position, at least 0.
"""
position: int
[docs]
@dataclass(frozen=True)
class Key:
"""A key of a mapping: ``["key"]`` in a path.
Attributes
----------
key : str
The key, any string.
"""
key: str
type Step = Attribute | Index | Key
"""One step of a parameter path."""
_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
_INDEX = re.compile(r"\[(0|[1-9][0-9]*)\]")
_KEY = re.compile(r'\[("(?:[^"\\]|\\.)*")\]', re.DOTALL)
def _refuse(text: str, column: int, reason: str) -> RecipeError:
"""Return the error for a path text that cannot be read.
Parameters
----------
text : str
The path text.
column : int
The zero-based position where reading stopped.
reason : str
What was expected there.
Returns
-------
RecipeError
The error, to raise; it gives the column counted from 1.
"""
return RecipeError(
f"cannot read the parameter path {text!r} at column {column + 1}: {reason}"
)
[docs]
def parse_path(text: str) -> tuple[Step, ...]:
"""Read a parameter path into its steps.
Parameters
----------
text : str
The path, such as ``'pulses[1].energy'``.
Returns
-------
tuple of Attribute, Index or Key
The steps; the first is always an :class:`Attribute`, the keyword.
Raises
------
RecipeError
If ``text`` is not a path, naming the column where reading stopped.
Examples
--------
>>> parse_path('launch["HE1,1:even"].share')
(Attribute(name='launch'), Key(key='HE1,1:even'), Attribute(name='share'))
>>> parse_path("pulses[1].energy")
(Attribute(name='pulses'), Index(position=1), Attribute(name='energy'))
"""
match = _NAME.match(text)
if match is None:
raise _refuse(text, 0, "a path starts with a keyword")
steps: list[Step] = [Attribute(match[0])]
column = match.end()
while column < len(text):
if text.startswith(".", column):
match = _NAME.match(text, column + 1)
if match is None:
raise _refuse(text, column + 1, "expected a name after '.'")
steps.append(Attribute(match[0]))
elif (match := _INDEX.match(text, column)) is not None:
steps.append(Index(int(match[1])))
elif (match := _KEY.match(text, column)) is not None:
try:
key: object = json.loads(match[1])
except json.JSONDecodeError as error:
reason = f"the key is not a JSON string: {error}"
raise _refuse(text, column, reason) from error
assert isinstance(key, str) # the pattern admits only a JSON string
steps.append(Key(key))
else:
raise _refuse(
text,
column,
'expected .name, [index] or ["key"], and an index has no sign or '
"leading zero",
)
column = match.end()
return tuple(steps)
def _steps(path: str | Sequence[Step]) -> tuple[tuple[Step, ...], str]:
"""Return the steps of a path given as text or steps, and its canonical text.
Parameters
----------
path : str or sequence of Attribute, Index or Key
The path.
Returns
-------
tuple
The steps, and the canonical text.
Raises
------
RecipeError
If the path cannot be read or written.
"""
steps = parse_path(path) if isinstance(path, str) else tuple(path)
return steps, format_path(steps)
def _step_text(step: Step) -> str:
"""Write one step as it appears after the keyword.
Parameters
----------
step : Attribute, Index or Key
The step.
Returns
-------
str
``.name``, ``[i]`` or ``["key"]``.
"""
if isinstance(step, Attribute):
return f".{step.name}"
if isinstance(step, Index):
return f"[{step.position}]"
return f"[{json.dumps(step.key, ensure_ascii=False)}]"
def _child(node: object, step: Step, path: str, reached: Sequence[Step]) -> object:
"""Return the entry of a node that one step addresses.
Parameters
----------
node : object
The table or array the step is taken in.
step : Attribute, Index or Key
The step.
path : str
The whole path's canonical text, for the error.
reached : sequence of Attribute, Index or Key
The steps that led to ``node``; empty at the top, where the step is a
keyword.
Returns
-------
object
The entry.
Raises
------
RecipeError
If ``node`` has no such entry, naming what was reached and the step.
"""
if not reached:
assert isinstance(step, Attribute) # parse_path and format_path ensure it
if not isinstance(node, Mapping) or step.name not in node:
raise RecipeError(f"there is no keyword {step.name!r}", path=path)
return node[step.name]
owner = format_path(reached)
if isinstance(step, Index):
if not isinstance(node, list):
raise RecipeError(
f"{owner} is not an array, so it has no {_step_text(step)}", path=path
)
if step.position >= len(node):
raise RecipeError(
f"{owner} has {len(node)} elements, so no {_step_text(step)}",
path=path,
)
return node[step.position]
name = step.name if isinstance(step, Attribute) else step.key
if not isinstance(node, Mapping):
raise RecipeError(
f"{owner} is not a table, so it has no {_step_text(step)}", path=path
)
if name not in node:
raise RecipeError(f"{owner} has no {_step_text(step)}", path=path)
return node[name]
[docs]
def get_path(tree: Mapping[str, object], path: str | Sequence[Step]) -> object:
"""Return the value at a parameter path of a recipe table.
Parameters
----------
tree : Mapping
The table the path starts in (a recipe's ``[build]`` table), as
:mod:`tomllib` reads it: dictionaries, lists and values.
path : str or sequence of Attribute, Index or Key
The path, as text or steps. ``.name`` and ``["name"]`` both address the key
``name`` of a dictionary.
Returns
-------
object
The value; a table or array is returned itself, not a copy.
Raises
------
RecipeError
If a step finds no entry, naming the path and the step.
Examples
--------
>>> tree = {"pulses": [{"energy": "1 uJ"}, {"energy": "10 uJ"}]}
>>> get_path(tree, "pulses[1].energy")
'10 uJ'
"""
steps, text = _steps(path)
node: object = tree
reached: list[Step] = []
for step in steps:
node = _child(node, step, text, reached)
reached.append(step)
return node
def _replace(
node: object,
steps: tuple[Step, ...],
value: object,
path: str,
reached: tuple[Step, ...],
) -> object:
"""Return a copy of a node with a value set at the end of some steps.
Parameters
----------
node : object
The table or array the steps start in.
steps : tuple of Attribute, Index or Key
The steps from ``node``, at least one.
value : object
The value to set.
path : str
The whole path's canonical text, for the error.
reached : tuple of Attribute, Index or Key
The steps that led to ``node``.
Returns
-------
object
The copy; the tables and arrays on the path are new and the rest shared.
Raises
------
RecipeError
If a step before the last finds no entry, or an index is past the end.
"""
step, rest = steps[0], steps[1:]
if rest:
child = _child(node, step, path, reached)
value = _replace(child, rest, value, path, (*reached, step))
if isinstance(step, Index):
_child(node, step, path, reached) # an element is replaced, never appended
assert isinstance(node, list)
copy = list(node)
copy[step.position] = value
return copy
if not isinstance(node, Mapping):
owner = format_path(reached)
raise RecipeError(
f"{owner} is not a table, so it has no {_step_text(step)}", path=path
)
name = step.name if isinstance(step, Attribute) else step.key
return {**node, name: value}
[docs]
def set_path(
tree: Mapping[str, object], path: str | Sequence[Step], value: object
) -> dict[str, object]:
"""Return a copy of a recipe table with the value at a parameter path replaced.
The tables and arrays on the path are copied and everything else is shared, so
``tree`` is not changed. The last step may add a key to a table, but every
earlier step must exist, and an index must name an existing element: an array
is never extended.
Parameters
----------
tree : Mapping
The table the path starts in, as for :func:`get_path`.
path : str or sequence of Attribute, Index or Key
The path, as text or steps.
value : object
The new value.
Returns
-------
dict
The new table.
Raises
------
RecipeError
If a step before the last finds no entry, or an index is past the end, naming
the path and the step.
Examples
--------
>>> tree = {"pulses": [{"energy": "1 uJ"}]}
>>> set_path(tree, "pulses[0].energy", "2 uJ")
{'pulses': [{'energy': '2 uJ'}]}
>>> tree
{'pulses': [{'energy': '1 uJ'}]}
"""
steps, text = _steps(path)
result = _replace(dict(tree), steps, value, text, ())
assert isinstance(result, dict)
return result