Skip to content
9 changes: 6 additions & 3 deletions src/pals/PALS.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict

from pydantic import model_validator
from typing import Self
Expand Down Expand Up @@ -40,9 +40,12 @@ class ExtensionLabels(BaseModel, extra="forbid"):
class PALSroot(BaseModel):
"""Represent the root PALS structure"""

# Preserve root-level standard metadata that is not modeled explicitly yet.
model_config = ConfigDict(extra="allow")

# 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
# examples also write bare numbers (e.g. `version: 1` or `version: 1.0`).
version: str | int | float | None = None

authors: list[Author] | None = None

Expand Down
114 changes: 109 additions & 5 deletions src/pals/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,24 @@
import os


def inspect_file_extensions(filename: str):
def inspect_file_extensions(filename: str, sub_level: bool = False):
"""Attempt to strip two levels of file extensions to determine the schema.

filename examples: fodo.pals.yaml, fodo.pals.json, ...

Sub-level files, spliced into another file by its `include` entries, use
the inner extension .subpals per the standard's File Formats section
(e.g. elements.subpals.yaml); .pals is accepted for them as well.
"""
file_noext, extension = os.path.splitext(filename)
file_noext_noext, extension_inner = os.path.splitext(file_noext)

if extension_inner != ".pals":
allowed_inner = (".pals", ".subpals") if sub_level else (".pals",)
if extension_inner not in allowed_inner:
expected = " or ".join(f"{inner}.yaml" for inner in allowed_inner)
raise RuntimeError(
f"inspect_file_extensions: No support for file {filename} with extension {extension}. "
f"PALS files must end in .pals.json or .pals.yaml or similar."
f"PALS files must end in {expected} or similar."
)

return {
Expand All @@ -25,11 +31,98 @@ def inspect_file_extensions(filename: str):
}


def load_file_to_dict(filename: str) -> dict:
def _load_included_file(include_file, base_dir: str, include_chain: tuple):
"""Load the target of one `include` entry, relative to the including file."""
if not isinstance(include_file, str):
raise TypeError(
f"process_includes: an 'include' value must be a file name string, "
f"but we got {include_file!r}"
)
filepath = os.path.join(base_dir, include_file)
return load_file_to_dict(filepath, sub_level=True, _include_chain=include_chain)


def process_includes(data, base_dir: str, include_chain: tuple = ()):
"""Recursively resolve `include` entries in the data structure.

Per the standard, included file data is included verbatim at the current
level of nesting: an `include` key in a mapping splices the included
mapping's entries into it (entries local to the mapping win), and a list
item holding only an `include` key splices the included sequence into the
list. Include file names are resolved relative to the including file.

Args:
data: The parsed data structure to resolve
base_dir: Directory of the file the data came from
include_chain: Files on the include path so far, for cycle detection

Returns:
The data structure with all includes resolved
"""
if isinstance(data, dict):
if "include" in data:
included_data = _load_included_file(
data["include"], base_dir, include_chain
)
if not isinstance(included_data, dict):
raise TypeError(
f"process_includes: file {data['include']!r} is included at a "
f"mapping level and must hold a mapping, "
f"but we got {type(included_data).__name__}"
)
local_data = {
key: process_includes(value, base_dir, include_chain)
for key, value in data.items()
if key != "include"
}
# Entries local to the including mapping win over included ones.
return {**included_data, **local_data}

return {
key: process_includes(value, base_dir, include_chain)
for key, value in data.items()
}

elif isinstance(data, list):
new_list = []
for item in data:
# A list item holding only an include splices in the included file
if isinstance(item, dict) and set(item) == {"include"}:
included_data = _load_included_file(
item["include"], base_dir, include_chain
)
if isinstance(included_data, list):
new_list.extend(included_data)
elif isinstance(included_data, dict):
new_list.append(included_data)
else:
raise TypeError(
f"process_includes: file {item['include']!r} is included at a "
f"sequence level and must hold a sequence or mapping, "
f"but we got {type(included_data).__name__}"
)
else:
new_list.append(process_includes(item, base_dir, include_chain))
return new_list

else:
return data


def load_file_to_dict(
filename: str, sub_level: bool = False, _include_chain: tuple = ()
) -> dict:
# Guard against include cycles: a file including itself through any chain.
# realpath canonicalizes symlinks so a cycle cannot hide behind one.
filepath = os.path.realpath(filename)
if filepath in _include_chain:
chain = " -> ".join(_include_chain + (filepath,))
raise RuntimeError(f"load_file_to_dict: circular include: {chain}")

# Attempt to strip two levels of file extensions to determine the schema.
# Examples: fodo.pals.yaml, fodo.pals.json, ...
file_noext, extension, file_noext_noext, extension_inner = inspect_file_extensions(
filename
filename, sub_level=sub_level
).values()

# examples: fodo.pals.yaml, fodo.pals.json
Expand All @@ -51,6 +144,17 @@ def load_file_to_dict(filename: str) -> dict:
f"load_file_to_dict: No support for PALS file {filename} with extension {extension} yet."
)

# Resolve include entries, tracking this file for cycle detection. In a
# full document, include statements must be within the PALS root node;
# information outside of it is outside the standard and is not touched.
base_dir = os.path.dirname(filename)
include_chain = _include_chain + (filepath,)
if isinstance(pals_data, dict) and "PALS" in pals_data:
pals_data = dict(pals_data)
pals_data["PALS"] = process_includes(pals_data["PALS"], base_dir, include_chain)
else:
pals_data = process_includes(pals_data, base_dir, include_chain)

return pals_data


Expand Down
40 changes: 39 additions & 1 deletion src/pals/kinds/Lattice.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,46 @@ def model_dump(self, *args, **kwargs):

@staticmethod
def from_file(filename: str) -> Self:
"""Load a Lattice from a text file"""
"""Load a Lattice from a text file.

The file can hold either a single Lattice or a full PALS document.
Per the standard's use statement, the lattice instantiated from a full
document is the last one defined, unless a `use` entry selects another.
"""
pals_dict = load_file_to_dict(filename)

if isinstance(pals_dict, dict) and "PALS" in pals_dict:
from pals.PALS import PALSroot
from pals.kinds.PlaceholderName import PlaceholderName

pals_root = PALSroot(**pals_dict)
facility = pals_root.facility or []
lattices = [item for item in facility if isinstance(item, Lattice)]
by_name = {lattice.name: lattice for lattice in lattices}

# A `use` entry overrides the last-lattice default; with several,
# the last one wins. It must name a Lattice the document defines.
use_entries = [
item
for item in facility
if isinstance(item, PlaceholderName) and item.is_use
]
if use_entries:
selected = use_entries[-1].name
if selected not in by_name:
raise ValueError(
f"PALS root document {filename!r} selects {selected!r} "
f"with its use entry, but defines no Lattice of that "
f"name; defined Lattices: {sorted(by_name)}"
)
return by_name[selected]

if not lattices:
raise ValueError(
f"PALS root document {filename!r} does not define a Lattice"
)
return lattices[-1]

return Lattice(**pals_dict)

def to_file(self, filename: str):
Expand Down
11 changes: 9 additions & 2 deletions src/pals/kinds/PlaceholderName.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,21 @@ class PlaceholderName(BaseModel):
"BaseElement | None",
Field(default=None, description="Reference to the resolved element object"),
] = None
is_use: bool = Field(
default=False,
description="True when this reference was written as a `use:` entry",
)

@model_serializer(mode="plain")
def _serialize_as_name(self) -> str:
"""Serialize this reference as just its name.
def _serialize_as_name(self) -> str | dict[str, str]:
"""Serialize this reference as its name, or its `use:` entry form.

This makes `model_dump()` return a string (the element name), so nested
serialization (e.g. inside BeamLine.line) produces plain strings too.
References written as `use:` entries keep that form.
"""
if self.is_use:
return {"use": self.name}
return self.name

def __init__(self, name: str | None = None, /, **data):
Expand Down
2 changes: 1 addition & 1 deletion src/pals/kinds/mixin/all_element_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def unpack_element_items(items: list, container_type: str) -> list:
# can resolve it.
if not isinstance(fields, dict):
if name == "use" and isinstance(fields, str):
new_list.append(PlaceholderName(fields))
new_list.append(PlaceholderName(fields, is_use=True))
continue
raise TypeError(
f"Value for element key {name!r} must be a dict (the element's properties), "
Expand Down
3 changes: 3 additions & 0 deletions tests/pals_files/include/circular/a.pals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Includes b.subpals.yaml, which includes this file again: a cycle.
PALS:
include: "b.subpals.yaml"
2 changes: 2 additions & 0 deletions tests/pals_files/include/circular/b.subpals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Includes a.pals.yaml, closing the include cycle.
include: "a.pals.yaml"
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Included into q01.pals.yaml, from the standard's include example.
MagneticMultipoleP:
Kn3L: 0.3
5 changes: 5 additions & 0 deletions tests/pals_files/include/element/q01.pals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# The standard's element-level include example: the element pulls a
# parameter group in from include-Q-params.subpals.yaml.
Q01:
kind: Quadrupole
include: "include-Q-params.subpals.yaml"
6 changes: 6 additions & 0 deletions tests/pals_files/include/globals.subpals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Included at the root level of main.pals.yaml: version and notes per the
# standard's example, plus an unmodeled key that must be preserved.
version: 1.0
notes:
- "included note"
my_extension_data: "kept"
28 changes: 28 additions & 0 deletions tests/pals_files/include/main.pals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# A FODO document assembled with includes: a root-level include contributes
# document metadata (globals.subpals.yaml) and a facility-level include
# splices in element definitions (sub/quads.subpals.yaml, which itself
# includes parts/extra.subpals.yaml relative to its own directory).
PALS:
include: "globals.subpals.yaml"
facility:
- drift1:
kind: Drift
length: 0.25

- include: "sub/quads.subpals.yaml"

- fodo_cell:
kind: BeamLine
line:
- drift1
- quad1
- drift2
- quad2
- drift1

- fodo_lattice:
kind: Lattice
branches:
- fodo_cell

- use: fodo_lattice
3 changes: 3 additions & 0 deletions tests/pals_files/include/mismatch/elements.subpals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# A sequence, included by q01.pals.yaml at a mapping level: an error.
- a:
kind: Drift
4 changes: 4 additions & 0 deletions tests/pals_files/include/mismatch/q01.pals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Structural error: includes a file holding a sequence at a mapping level.
Q01:
kind: Quadrupole
include: "elements.subpals.yaml"
3 changes: 3 additions & 0 deletions tests/pals_files/include/nested/leaf.subpals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Included by middle.subpals.yaml; its `shared` entry is overridden.
leaf: val
shared: included
5 changes: 5 additions & 0 deletions tests/pals_files/include/nested/middle.subpals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Included by root.pals.yaml; includes leaf.subpals.yaml itself. Its own
# `shared` entry wins over the one from the leaf.
middle: val
shared: local
include: "leaf.subpals.yaml"
5 changes: 5 additions & 0 deletions tests/pals_files/include/nested/root.pals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# A chain of includes: this file includes middle.subpals.yaml, which
# includes leaf.subpals.yaml. Keys local to an including file win over
# included ones.
root:
include: "middle.subpals.yaml"
2 changes: 2 additions & 0 deletions tests/pals_files/include/outside/globals.subpals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Included within the PALS node of main.pals.yaml.
version: "1.0"
9 changes: 9 additions & 0 deletions tests/pals_files/include/outside/main.pals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Information outside the PALS root node is outside the standard and is
# ignored: the top-level include names a file that does not exist and must
# not be resolved. The include inside the PALS node is.
include: "missing.subpals.yaml"
not_pals_data: true
PALS:
include: "globals.subpals.yaml"
notes:
- "the PALS node itself"
6 changes: 6 additions & 0 deletions tests/pals_files/include/parts/extra.subpals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Spliced into sub/quads.subpals.yaml by its include entry.
- quad2:
kind: Quadrupole
MagneticMultipoleP:
Bn1: -1.0
length: 1.0
13 changes: 13 additions & 0 deletions tests/pals_files/include/sub/quads.subpals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Spliced into the facility of main.pals.yaml; includes a further file
# relative to its own directory (not the directory of the main file).
- quad1:
kind: Quadrupole
MagneticMultipoleP:
Bn1: 1.0
length: 1.0

- drift2:
kind: Drift
length: 0.5

- include: "../parts/extra.subpals.yaml"
18 changes: 18 additions & 0 deletions tests/pals_files/lattice_use/bad_use.pals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# A use entry naming no defined Lattice: since use overrides the default
# lattice selection, this is an error, not a fallback to the last lattice.
PALS:
facility:
- line1:
kind: BeamLine
line:
- m1:
kind: Marker
- lat1:
kind: Lattice
branches:
- line1
- lat2:
kind: Lattice
branches:
- line1
- use: "typo"
4 changes: 4 additions & 0 deletions tests/pals_files/lattice_use/no_lattice.pals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# A document defining no Lattice: Lattice.from_file reports an error.
PALS:
notes:
- "no facility here"
Loading