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
116 changes: 103 additions & 13 deletions src/pals/PALS.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,120 @@
Facility = list[get_all_elements_as_annotation()]


class Author(BaseModel):
"""An author associated with a PALS file.

Authors are optional, but recommended to enable data provenance and
contacts. Only the name is required.
"""

name: str
orcid: str | None = None
affiliation: str | None = None
email: str | None = None


class ExtensionLabels(BaseModel, extra="forbid"):
"""Registered extension labels, see the standard's Extensions chapter.

Key names or enum values in a PALS file matching one of the registered
names, prefixes, or suffixes are extensions and are excluded from
validation. Each dict maps a label to its short description.
"""

names: dict[str, str] | None = None
prefixes: dict[str, str] | None = None
suffixes: dict[str, str] | None = None


class PALSroot(BaseModel):
"""Represent the roo PALS structure"""
"""Represent the root PALS structure"""

# The standard documents `version` as a string, but the standard's own
# examples also write bare numbers (e.g. `version: 1`).
version: str | int | None = None

authors: list[Author] | None = None

version: str | None = None
notes: list[str] | None = None

facility: Facility
# Unlike notes, reminders are meant to be communicated to the user every
# time the file is read.
reminders: list[str] | None = None

# The standard documents ANGLE_AND_ENERGY, ANGLE_AND_MOMENTUM,
# KINETIC_AND_ENERGY, and KINETIC_AND_MOMENTUM (the default), but its
# examples also use other values, so any string is accepted.
phase_space_coordinates: str | None = None

# The registered form has names/prefixes/suffixes sections; the flat
# label -> description dict form appears in the standard's examples.
extension_labels: ExtensionLabels | dict[str, str] | None = None

# Files whose PALS contents combine with this one, in order, with the
# literal entry SELF marking where this file's own contents go. Entries
# are recorded but not yet resolved into a combined document.
load: list[str] | None = None

facility: Facility | None = None

@model_validator(mode="before")
@classmethod
def unpack_json_structure(cls, data):
"""Deserialize the JSON/YAML/...-like dict for facility elements"""
from pals.kinds.mixin.all_element_mixin import unpack_element_list_structure
"""Deserialize the JSON/YAML/...-like dict for the root node"""
from pals.kinds.mixin.all_element_mixin import unpack_element_items

if not isinstance(data, dict):
return data

# Unwrap the `PALS` document node; per the standard, information
# outside of it is ignored.
if "PALS" in data:
inner = data["PALS"]
if not isinstance(inner, dict):
raise TypeError(
f"Value for the 'PALS' root key must be a dict, but we got {inner!r}"
)
data = inner
data = dict(data)

# Authors are written as one-key `author:` dicts; unwrap them.
if isinstance(data.get("authors"), list):
data["authors"] = [
entry["author"]
if isinstance(entry, dict) and set(entry) == {"author"}
else entry
Comment thread
ax3l marked this conversation as resolved.
for entry in data["authors"]
]

# Unpack each facility element's name; facility is optional.
if data.get("facility") is not None:
if not isinstance(data["facility"], list):
raise TypeError("'facility' must be a list")
data["facility"] = unpack_element_items(data["facility"], "facility")

return unpack_element_list_structure(data, "facility", "facility")
return data

def model_dump(self, *args, **kwargs):
"""Custom model dump for facility to handle element list formatting"""
from pals.kinds.mixin.all_element_mixin import dump_element_list
"""Custom model dump wrapping the contents in the PALS root node"""
kwargs.setdefault("exclude_none", True)
data = super().model_dump(*args, **kwargs)

data = {}
data["PALS"] = {}
data["PALS"]["version"] = self.version
data["PALS"] = dump_element_list(self, "facility", *args, **kwargs)
return data
# Keep `version` in the output even when unset.
data = {"version": self.version, **data}

# Restore the one-key `author:` form of each authors entry.
if self.authors is not None:
data["authors"] = [
{"author": author.model_dump(*args, **kwargs)}
for author in self.authors
]

# Reformat facility elements into their one-key named form.
if self.facility is not None:
data["facility"] = [elem.model_dump(**kwargs) for elem in self.facility]

return {"PALS": data}

@staticmethod
def from_file(filename: str) -> Self:
Expand Down
2 changes: 1 addition & 1 deletion src/pals/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from .kinds import * # noqa
from .parameters import * # noqa
from .PALS import PALSroot, load, store # noqa
from .PALS import Author, ExtensionLabels, PALSroot, load, store # noqa


# Rebuild pydantic models that depend on other classes
Expand Down
70 changes: 43 additions & 27 deletions src/pals/kinds/mixin/all_element_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,41 +8,22 @@
from ..PlaceholderName import PlaceholderName


def unpack_element_list_structure(
data: dict, field_name: str, container_type: str
) -> dict:
"""Deserialize the JSON/YAML/...-like dict for element lists.
def unpack_element_items(items: list, container_type: str) -> list:
"""Deserialize the JSON/YAML/...-like items of an element list.

This handles both the top-level unpacking and the field-level unpacking
for elements that contain lists of other elements.
Each item can be a reference string, a `use:` reference, a one-key dict
holding the element's name, or an already-built element instance.

Args:
data: The input data dictionary
field_name: Name of the field containing the element list (e.g., "line" or "elements")
items: The list of raw element entries
container_type: Type of container for error messages (e.g., "line" or "union")

Returns:
Modified data dictionary with unpacked structure
A new list with each entry unpacked to a dict, PlaceholderName, or element
"""
# Handle the top-level one-key dict: unpack the container's name
if isinstance(data, dict) and len(data) == 1:
name, value = list(data.items())[0]
if not isinstance(value, dict):
raise TypeError(
f"Value for {container_type} key {name!r} must be a dict, but we got {value!r}"
)
value["name"] = name
data = value

# Handle the field: unpack each element's name
if field_name not in data:
raise ValueError(f"'{field_name}' field is missing")
if not isinstance(data[field_name], list):
raise TypeError(f"'{field_name}' must be a list")

new_list = []
# Loop over all elements in the list
for item in data[field_name]:
for item in items:
# An element can be a string that refers to another element
if isinstance(item, str):
# Wrap the string in a Placeholder name object
Expand Down Expand Up @@ -90,7 +71,42 @@ def unpack_element_list_structure(
f"Value must be a reference string, PlaceholderName, or a dict, but we got {item!r}"
)

data[field_name] = new_list
return new_list


def unpack_element_list_structure(
data: dict, field_name: str, container_type: str
) -> dict:
"""Deserialize the JSON/YAML/...-like dict for element lists.

This handles both the top-level unpacking and the field-level unpacking
for elements that contain lists of other elements.

Args:
data: The input data dictionary
field_name: Name of the field containing the element list (e.g., "line" or "elements")
container_type: Type of container for error messages (e.g., "line" or "union")

Returns:
Modified data dictionary with unpacked structure
"""
# Handle the top-level one-key dict: unpack the container's name
if isinstance(data, dict) and len(data) == 1:
name, value = list(data.items())[0]
if not isinstance(value, dict):
raise TypeError(
f"Value for {container_type} key {name!r} must be a dict, but we got {value!r}"
)
value["name"] = name
data = value

# Handle the field: unpack each element's name
if field_name not in data:
raise ValueError(f"'{field_name}' field is missing")
if not isinstance(data[field_name], list):
raise TypeError(f"'{field_name}' must be a list")

data[field_name] = unpack_element_items(data[field_name], container_type)
return data


Expand Down
28 changes: 0 additions & 28 deletions tests/standard_examples_known_failures.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,6 @@
# to load as an error, so this list shrinks as support lands. Blank lines and
# `#` comments are ignored.

# PALSroot models only `version` and `facility`, and `facility` is required:
# additional attributes (notes, extension labels, or a version
# only) are rejected.
unit_tests/loading/basic/joiner.pals.yaml
unit_tests/loading/diamond/joiner.pals.yaml
unit_tests/loading/diamond/left.pals.yaml
unit_tests/loading/diamond/right.pals.yaml
unit_tests/loading/diamond/shared.pals.yaml
unit_tests/loading/extension_labels/a.pals.yaml
unit_tests/loading/extension_labels/joiner.pals.yaml
unit_tests/loading/implicit_self/a.pals.yaml
unit_tests/loading/implicit_self/joiner.pals.yaml
unit_tests/loading/nested/a.pals.yaml
unit_tests/loading/nested/inner.pals.yaml
unit_tests/loading/nested/joiner.pals.yaml
unit_tests/loading/relative_paths/joiner.pals.yaml
unit_tests/loading/relative_paths/sub/inner.pals.yaml
unit_tests/loading/relative_paths/top.pals.yaml
unit_tests/loading/self_position/a.pals.yaml
unit_tests/loading/self_position/b.pals.yaml
unit_tests/loading/self_position/joiner.pals.yaml
unit_tests/loading/version_match/a.pals.yaml
unit_tests/loading/version_match/joiner.pals.yaml

# An integer `version` (root_keys writes `version: 1`) is rejected; the model
# wants a string or null.
unit_tests/document/root_keys.pals.yaml

# `include` entries inside a facility are not resolved.
machine/machine.pals.yaml
unit_tests/loading/include/sub/layout.pals.yaml
Expand Down