diff --git a/src/pals/PALS.py b/src/pals/PALS.py index c948b73..9354219 100644 --- a/src/pals/PALS.py +++ b/src/pals/PALS.py @@ -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 + 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: diff --git a/src/pals/__init__.py b/src/pals/__init__.py index 03b32c2..30548f9 100644 --- a/src/pals/__init__.py +++ b/src/pals/__init__.py @@ -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 diff --git a/src/pals/kinds/mixin/all_element_mixin.py b/src/pals/kinds/mixin/all_element_mixin.py index 70ceb6e..d67e5c8 100644 --- a/src/pals/kinds/mixin/all_element_mixin.py +++ b/src/pals/kinds/mixin/all_element_mixin.py @@ -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 @@ -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 diff --git a/tests/standard_examples_known_failures.txt b/tests/standard_examples_known_failures.txt index 4365661..73bf3dd 100644 --- a/tests/standard_examples_known_failures.txt +++ b/tests/standard_examples_known_failures.txt @@ -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