Skip to content

Commit feb7259

Browse files
authored
Merge pull request #7 from ycexiao/entry-point
feat: add `diffpy.app runmacro <`.dp-in` file>` entry point
2 parents 606bcfc + c0de591 commit feb7259

File tree

9 files changed

+412
-271
lines changed

9 files changed

+412
-271
lines changed

news/entry-point.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
**Added:**
2+
3+
* Add ``diffpy.app runmacro <file>`` command.
4+
5+
**Changed:**
6+
7+
* <news item>
8+
9+
**Deprecated:**
10+
11+
* <news item>
12+
13+
**Removed:**
14+
15+
* <news item>
16+
17+
**Fixed:**
18+
19+
* <news item>
20+
21+
**Security:**
22+
23+
* <news item>

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ exclude = [] # exclude packages matching these glob patterns (empty by default)
5151
namespaces = false # to disable scanning PEP 420 namespaces (true by default)
5252

5353
[project.scripts]
54-
diffpy-apps = "diffpy.apps.app:main"
54+
"diffpy.app" = "diffpy.apps.apps:main"
5555

5656
[tool.setuptools.dynamic]
5757
dependencies = {file = ["requirements/pip.txt"]}

src/diffpy/apps/app_runmacro.py

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import inspect
2+
from collections import OrderedDict
3+
from pathlib import Path
4+
5+
import yaml
6+
from textx import metamodel_from_str
7+
8+
from diffpy.apps.pdfadapter import PDFAdapter
9+
10+
grammar = r"""
11+
Program:
12+
commands*=Command
13+
variable=VariableBlock
14+
;
15+
16+
Command:
17+
LoadCommand | SetCommand | CreateCommand | SaveCommand
18+
;
19+
20+
LoadCommand:
21+
'load' component=ID name=ID 'from' source=STRING
22+
;
23+
24+
SetCommand:
25+
'set' name=ID attribute=ID 'as' value+=Value[eolterm]
26+
| 'set' name=ID 'as' value+=Value[eolterm]
27+
;
28+
29+
CreateCommand:
30+
'create' 'equation' 'variables' value+=Value[eolterm]
31+
;
32+
33+
SaveCommand:
34+
'save' 'to' source=STRING
35+
;
36+
37+
VariableBlock:
38+
'variables:' '---' content=/[\s\S]*?(?=---)/ '---'
39+
;
40+
41+
Value:
42+
STRICTFLOAT | INT | STRING | RawValue
43+
;
44+
45+
RawValue:
46+
/[^\s]+/
47+
;
48+
"""
49+
50+
51+
class MacroParser:
52+
def __init__(self):
53+
self.pdfadapter = PDFAdapter()
54+
self.meta_model = metamodel_from_str(grammar)
55+
self.meta_model.register_obj_processors(
56+
{
57+
"SetCommand": self.set_command_processor,
58+
"LoadCommand": self.load_command_processor,
59+
"VariableBlock": self.parameter_block_processor,
60+
"CreateCommand": self.create_command_processor,
61+
"SaveCommand": self.save_command_processor,
62+
}
63+
)
64+
# key: method_name.argument_name
65+
# value: argument_value
66+
self.inputs = {}
67+
# key: structure name or profile name set in the macro
68+
# value: 'structure' or 'profile'
69+
self.variables = OrderedDict()
70+
71+
def parse(self, code):
72+
self.meta_model.model_from_str(code)
73+
74+
def input_as_list(self, key, value):
75+
if key in self.inputs:
76+
if not isinstance(self.inputs[key], list):
77+
self.inputs[key] = [self.inputs[key]]
78+
else:
79+
self.inputs[key].append(value)
80+
else:
81+
if isinstance(value, list):
82+
self.inputs[key] = value
83+
else:
84+
self.inputs[key] = [value]
85+
86+
def load_command_processor(self, command):
87+
if command.component == "structure":
88+
# TODO: support multiple structures input in the future
89+
key = "initialize_structures.structure_paths"
90+
variable = "structure"
91+
elif command.component == "profile":
92+
key = "initialize_profile.profile_path"
93+
variable = "profile"
94+
else:
95+
raise ValueError(
96+
f"Unknown component type: {command.component} "
97+
"Please use 'structure' or 'profile'."
98+
)
99+
source_path = Path(command.source)
100+
if not source_path.exists():
101+
raise FileNotFoundError(
102+
f"{command.component} {source_path} not found. "
103+
"Please ensure the path is correct and the file exists."
104+
)
105+
self.inputs[key] = str(source_path)
106+
self.variables[command.name] = variable
107+
if variable == "structure":
108+
self.input_as_list("initialize_structures.names", command.name)
109+
110+
def set_command_processor(self, command):
111+
if command.name == "equation":
112+
key = "initialize_contribution.equation"
113+
elif command.name in self.variables:
114+
if self.variables[command.name] == "structure":
115+
if command.attribute == "spacegroup":
116+
key = "initialize_structures.spacegroups"
117+
else:
118+
key = "initialize_structures." + command.attribute
119+
elif self.variables[command.name] == "profile":
120+
key = "initialize_profile." + command.attribute
121+
else:
122+
raise ValueError(
123+
f"Unknown variable type for name: {command.name}. "
124+
"This is an internal error. Please report this issue to "
125+
"the developers."
126+
)
127+
else:
128+
raise ValueError(
129+
f"Unknown name in set command: {command.name}. "
130+
"Please ensure that it is typed correctly as 'equation' or "
131+
"it matches a previously loaded structure or "
132+
"profile. "
133+
)
134+
self.input_as_list(key, command.value)
135+
136+
def parameter_block_processor(self, variable_block):
137+
self.inputs["set_initial_variable_values.variable_name_to_value"] = {}
138+
self.inputs["refine_variables.variable_names"] = []
139+
parameters = yaml.safe_load(variable_block.content)
140+
if not isinstance(parameters, list):
141+
raise ValueError(
142+
"Parameter block should contain a list of parameters. "
143+
"Please use the following format:\n"
144+
"- param1 # use default initial value\n"
145+
"- param2: initial_value\n"
146+
)
147+
for item in parameters:
148+
if isinstance(item, str):
149+
self.inputs["refine_variables.variable_names"].append(
150+
item.replace(".", "_")
151+
)
152+
elif isinstance(item, dict):
153+
pname, pvalue = list(item.items())[0]
154+
self.inputs[
155+
"set_initial_variable_values.variable_name_to_value"
156+
][pname.replace(".", "_")] = pvalue
157+
self.inputs["refine_variables.variable_names"].append(
158+
pname.replace(".", "_")
159+
)
160+
else:
161+
raise ValueError(
162+
"Variables block items are not correctly formatted. "
163+
"Please use the following format:\n"
164+
"- param1 # use default initial value\n"
165+
"- param2: initial_value\n"
166+
)
167+
168+
def create_command_processor(self, command):
169+
self.inputs["add_contribution_variables.variable_names"] = (
170+
command.value
171+
)
172+
173+
def save_command_processor(self, command):
174+
self.inputs["save_results.result_path"] = command.source
175+
176+
def required_args(self, func):
177+
sig = inspect.signature(func)
178+
return [
179+
name
180+
for name, p in sig.parameters.items()
181+
if p.default is inspect.Parameter.empty
182+
and p.kind
183+
in (
184+
inspect.Parameter.POSITIONAL_ONLY,
185+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
186+
inspect.Parameter.KEYWORD_ONLY,
187+
)
188+
]
189+
190+
def call_pdfadapter_method(self, method_name, function_requirement):
191+
func = getattr(self.pdfadapter, method_name)
192+
required_arguments = self.required_args(func)
193+
arguments = {
194+
key.split(".")[1]: value
195+
for key, value in self.inputs.items()
196+
if key.startswith(method_name)
197+
}
198+
if not all(arg in arguments for arg in required_arguments):
199+
missing_args = [
200+
arg for arg in required_arguments if arg not in arguments
201+
]
202+
if function_requirement == "required":
203+
raise ValueError(
204+
"Missing required arguments for function "
205+
f"'{method_name}' {', '.join(missing_args)}. "
206+
"Please provide these arguments in the macro file."
207+
)
208+
elif function_requirement == "optional":
209+
print(
210+
"Missing required arguments for function "
211+
f"'{method_name}' {', '.join(missing_args)}. "
212+
"This function will be skipped. "
213+
"Please provide these arguments in the macro file "
214+
"to activate this function."
215+
)
216+
return
217+
func(**arguments)
218+
219+
def preprocess(self):
220+
methods_to_call = [
221+
("initialize_profile", "required"),
222+
("initialize_structures", "required"),
223+
("initialize_contribution", "required"),
224+
("initialize_recipe", "required"),
225+
("add_contribution_variables", "optional"),
226+
("set_initial_variable_values", "optional"),
227+
]
228+
for method in methods_to_call:
229+
self.call_pdfadapter_method(*method)
230+
231+
def run(self):
232+
methods_to_call = [
233+
("refine_variables", "required"),
234+
("save_results", "optional"),
235+
]
236+
for method in methods_to_call:
237+
self.call_pdfadapter_method(*method)
238+
return self.pdfadapter.get_results()
239+
240+
241+
def runmacro(args):
242+
dpin_path = Path(args.file)
243+
if not dpin_path.exists():
244+
raise FileNotFoundError(
245+
f"{str(dpin_path)} not found. Please check if this file "
246+
"exists and provide the correct path to it."
247+
)
248+
dsl_code = dpin_path.read_text()
249+
parser = MacroParser()
250+
parser.parse(dsl_code)
251+
parser.preprocess()
252+
return parser.run()
253+
254+
255+
if __name__ == "__main__":
256+
parser = MacroParser()
257+
code = f"""
258+
load structure G1 from "{str(Path(__file__).parents[3] / "tests/data/Ni.cif")}"
259+
load profile exp_ni from "{str(Path(__file__).parents[3] / "tests/data/Ni.gr")}"
260+
261+
set G1 spacegroup as auto
262+
set exp_ni q_range as 0.1 25
263+
set exp_ni calculation_range as 1.5 50 0.01
264+
create equation variables s0
265+
set equation as "s0*G1"
266+
save to "results.json"
267+
268+
variables:
269+
---
270+
- G1.a: 3.52
271+
- s0: 0.4
272+
- G1.Uiso_0: 0.005
273+
- G1.delta2: 2
274+
- qdamp: 0.04
275+
- qbroad: 0.02
276+
---
277+
""" # noqa: E501
278+
parser.parse(code)
279+
parser.preprocess()
280+
recipe = parser.pdfadapter.recipe
281+
for pname, param in recipe._parameters.items():
282+
print(f"{pname}: {param.value}")

src/diffpy/apps/apps.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import argparse
2+
3+
from diffpy.apps.app_runmacro import runmacro
4+
from diffpy.apps.version import __version__ # noqa
5+
6+
7+
class DiffpyHelpFormatter(argparse.RawDescriptionHelpFormatter):
8+
"""Format subcommands without showing an extra placeholder entry."""
9+
10+
def _format_action(self, action):
11+
if isinstance(action, argparse._SubParsersAction):
12+
return "".join(
13+
self._format_action(subaction)
14+
for subaction in self._iter_indented_subactions(action)
15+
)
16+
return super()._format_action(action)
17+
18+
19+
def main():
20+
parser = argparse.ArgumentParser(
21+
prog="diffpy.apps",
22+
description=(
23+
"User applications to help with tasks using diffpy packages\n\n"
24+
"For more information, visit: "
25+
"https://github.com/diffpy/diffpy.apps/"
26+
),
27+
formatter_class=DiffpyHelpFormatter,
28+
)
29+
30+
parser.add_argument(
31+
"--version",
32+
action="store_true",
33+
help="Show the program's version number and exit",
34+
)
35+
apps_parsers = parser.add_subparsers(
36+
title="Available applications",
37+
dest="application",
38+
)
39+
runmacro_parser = apps_parsers.add_parser(
40+
"runmacro",
41+
help="Run a macro `<.dp-in>` file",
42+
)
43+
runmacro_parser.add_argument(
44+
"file",
45+
type=str,
46+
help="Path to the `<.dp-in>` macro file to be run",
47+
)
48+
runmacro_parser.set_defaults(func=runmacro)
49+
args = parser.parse_args()
50+
if args.application is None:
51+
parser.print_help()
52+
else:
53+
args.func(args)
54+
55+
56+
if __name__ == "__main__":
57+
main()

0 commit comments

Comments
 (0)