Skip to content

Commit ffd096b

Browse files
committed
[Draft] Literal Include
Quick draft w/ Cursor on include support.
1 parent c924be1 commit ffd096b

3 files changed

Lines changed: 233 additions & 7 deletions

File tree

src/pals/functions.py

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
import os
44

55

6-
def inspect_file_extensions(filename: str):
6+
def inspect_file_extensions(filename: str, check_extension: bool = True):
77
"""Attempt to strip two levels of file extensions to determine the schema.
88
99
filename examples: fodo.pals.yaml, fodo.pals.json, ...
1010
"""
1111
file_noext, extension = os.path.splitext(filename)
1212
file_noext_noext, extension_inner = os.path.splitext(file_noext)
1313

14-
if extension_inner != ".pals":
14+
if check_extension and extension_inner != ".pals":
1515
raise RuntimeError(
1616
f"inspect_file_extensions: No support for file {filename} with extension {extension}. "
1717
f"PALS files must end in .pals.json or .pals.yaml or similar."
@@ -25,11 +25,94 @@ def inspect_file_extensions(filename: str):
2525
}
2626

2727

28-
def load_file_to_dict(filename: str) -> dict:
28+
def process_includes(data, base_dir: str):
29+
"""Recursively process 'include' directives in the data structure."""
30+
if isinstance(data, dict):
31+
# Handle 'include' key in dictionary
32+
if "include" in data:
33+
include_file = data["include"]
34+
# Check if include_file is a string (filename)
35+
if isinstance(include_file, str):
36+
filepath = os.path.join(base_dir, include_file)
37+
# Load included file without strict extension check
38+
included_data = load_file_to_dict(filepath, check_extension=False)
39+
40+
# Remove 'include' key
41+
local_data = data.copy()
42+
del local_data["include"]
43+
44+
# Recursively process local data
45+
local_data = {
46+
k: process_includes(v, base_dir) for k, v in local_data.items()
47+
}
48+
49+
# Merge logic
50+
# If included data is a list of single-key dicts (PALS special case), try to merge as dict
51+
if isinstance(included_data, list):
52+
try:
53+
merged_included = {}
54+
all_dicts = True
55+
for item in included_data:
56+
if isinstance(item, dict) and len(item) == 1:
57+
merged_included.update(item)
58+
else:
59+
all_dicts = False
60+
break
61+
if all_dicts:
62+
included_data = merged_included
63+
except Exception:
64+
pass
65+
66+
if isinstance(included_data, dict):
67+
# Merge included data with local data (local overrides included?)
68+
# Spec: "Included file data will be included verbatim at the current level of nesting."
69+
# Usually specific (local) overrides generic (included).
70+
# So we take included, update with local.
71+
result = included_data.copy()
72+
result.update(local_data)
73+
return result
74+
else:
75+
# If included data is not a dict, we can't merge it into a dict.
76+
# Unless the dict was JUST the include?
77+
if not local_data:
78+
return included_data
79+
# Fallback: return local data (ignore include) or error?
80+
# For now, let's return local_data but maybe warn?
81+
# Or maybe return included_data if local_data is empty?
82+
return local_data
83+
84+
# Recurse on values if no include or after processing
85+
return {k: process_includes(v, base_dir) for k, v in data.items()}
86+
87+
elif isinstance(data, list):
88+
new_list = []
89+
for item in data:
90+
# Check if item is a dict with ONLY 'include' key
91+
if isinstance(item, dict) and "include" in item and len(item) == 1:
92+
include_file = item["include"]
93+
if isinstance(include_file, str):
94+
filepath = os.path.join(base_dir, include_file)
95+
included_data = load_file_to_dict(filepath, check_extension=False)
96+
97+
if isinstance(included_data, list):
98+
new_list.extend(included_data)
99+
else:
100+
new_list.append(included_data)
101+
else:
102+
new_list.append(process_includes(item, base_dir))
103+
else:
104+
new_list.append(process_includes(item, base_dir))
105+
return new_list
106+
107+
else:
108+
return data
109+
110+
111+
def load_file_to_dict(filename: str, check_extension: bool = True) -> dict:
29112
# Attempt to strip two levels of file extensions to determine the schema.
30113
# Examples: fodo.pals.yaml, fodo.pals.json, ...
31114
file_noext, extension, file_noext_noext, extension_inner = inspect_file_extensions(
32-
filename
115+
filename, check_extension=check_extension
33116
).values()
34117

35118
# examples: fodo.pals.yaml, fodo.pals.json
@@ -51,6 +134,9 @@ def load_file_to_dict(filename: str) -> dict:
51134
f"load_file_to_dict: No support for PALS file {filename} with extension {extension} yet."
52135
)
53136

137+
# Process includes
138+
pals_data = process_includes(pals_data, base_dir=os.path.dirname(filename))
139+
54140
return pals_data
55141

56142

src/pals/kinds/Lattice.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
from pydantic import model_validator, Field
2-
from typing import Annotated, List, Literal, Union
1+
from pydantic import model_validator
2+
from typing import List, Literal, Union
33

44
from .BeamLine import BeamLine
5+
from .PlaceholderName import PlaceholderName
56
from .mixin import BaseElement
67
from ..functions import load_file_to_dict, store_dict_to_file
78

@@ -11,7 +12,7 @@ class Lattice(BaseElement):
1112

1213
kind: Literal["Lattice"] = "Lattice"
1314

14-
branches: List[Annotated[Union[BeamLine], Field(discriminator="kind")]]
15+
branches: List[Union[BeamLine, PlaceholderName]]
1516

1617
@model_validator(mode="before")
1718
@classmethod

tests/test_include.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import pals
2+
3+
4+
def test_include(tmp_path):
5+
main_file = tmp_path / "main.pals.yaml"
6+
root_included_file = tmp_path / "included.pals.yaml"
7+
facility_included_file = tmp_path / "facility.pals.yaml"
8+
facility_nested_file = tmp_path / "facility_nested.pals.yaml"
9+
10+
main_content = f"""
11+
PALS:
12+
include: "{root_included_file.name}"
13+
facility:
14+
- drift1:
15+
kind: Drift
16+
length: 0.25
17+
18+
- include: "{facility_included_file.name}"
19+
20+
- fodo_cell:
21+
kind: BeamLine
22+
line:
23+
- drift1
24+
- quad1
25+
- drift2
26+
- quad2
27+
- drift1
28+
29+
- fodo_lattice:
30+
kind: Lattice
31+
branches:
32+
- fodo_cell
33+
34+
- use: fodo_lattice
35+
"""
36+
37+
root_included_content = """
38+
author: "Some One <name@email.com>"
39+
version: 1.0
40+
"""
41+
42+
facility_included_content = f"""
43+
- quad1:
44+
kind: Quadrupole
45+
MagneticMultipoleP:
46+
Bn1: 1.0
47+
length: 1.0
48+
49+
- drift2:
50+
kind: Drift
51+
length: 0.5
52+
53+
- include: "{facility_nested_file.name}"
54+
"""
55+
56+
facility_nested_content = """
57+
- quad2:
58+
kind: Quadrupole
59+
MagneticMultipoleP:
60+
Bn1: -1.0
61+
length: 1.0
62+
"""
63+
64+
main_file.write_text(main_content)
65+
root_included_file.write_text(root_included_content)
66+
facility_included_file.write_text(facility_included_content)
67+
facility_nested_file.write_text(facility_nested_content)
68+
69+
data = pals.Lattice.from_file(main_file)
70+
71+
assert data["PALS"]["version"] == 1.0
72+
assert data["PALS"]["other"] == "value"
73+
assert data["PALS"]["author"] == "Some One <name@email.com>"
74+
assert "include" not in data["PALS"]
75+
76+
77+
def test_nested_include(tmp_path):
78+
root_file = tmp_path / "root.pals.yaml"
79+
middle_file = tmp_path / "middle.pals.yaml"
80+
leaf_file = tmp_path / "leaf.pals.yaml"
81+
82+
root_content = f"""
83+
root:
84+
include: "{middle_file.name}"
85+
"""
86+
87+
middle_content = f"""
88+
middle: val
89+
include: "{leaf_file.name}"
90+
"""
91+
92+
leaf_content = """
93+
leaf: val
94+
"""
95+
96+
root_file.write_text(root_content)
97+
middle_file.write_text(middle_content)
98+
leaf_file.write_text(leaf_content)
99+
100+
data = pals.functions.load_file_to_dict(str(root_file))
101+
102+
assert data["root"]["middle"] == "val"
103+
assert data["root"]["leaf"] == "val"
104+
assert "include" not in data["root"]
105+
106+
107+
def test_include_list_into_dict_conversion(tmp_path):
108+
# This tests the spec example where a list of properties is included into a dict (element)
109+
main_file = tmp_path / "element.pals.yaml"
110+
params_file = tmp_path / "params.pals.yaml"
111+
112+
main_content = f"""
113+
element:
114+
kind: Quadrupole
115+
include: "{params_file.name}"
116+
"""
117+
118+
# params file content is a list of single-key dicts
119+
params_content = """
120+
- MagneticMultipoleP:
121+
- Kn3L: 0.3
122+
- ApertureP:
123+
x_limits: [-0.1, 0.1]
124+
"""
125+
126+
main_file.write_text(main_content)
127+
params_file.write_text(params_content)
128+
129+
data = pals.functions.load_file_to_dict(str(main_file))
130+
131+
elem = data["element"]
132+
assert elem["kind"] == "Quadrupole"
133+
134+
# Check if keys are correctly merged from the list
135+
assert "MagneticMultipoleP" in elem
136+
assert elem["MagneticMultipoleP"] == [{"Kn3L": 0.3}]
137+
138+
assert "ApertureP" in elem
139+
assert elem["ApertureP"] == {"x_limits": [-0.1, 0.1]}

0 commit comments

Comments
 (0)