powerconf 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
powerconf/__init__.py ADDED
File without changes
powerconf/cli.py ADDED
@@ -0,0 +1,51 @@
1
+ from pathlib import Path
2
+ from typing import Annotated, List
3
+
4
+ import rich
5
+ import typer
6
+ import yaml
7
+ from fspathtree import fspathtree
8
+
9
+ from . import rendering
10
+
11
+ app = typer.Typer()
12
+ console = rich.console.Console()
13
+
14
+
15
+ def load_config(config_file: Path):
16
+ """load config from a file."""
17
+ configs = []
18
+ if not config_file.exists():
19
+ raise typer.Exit(f"File '{config_file}' not found.")
20
+ config_text = config_file.read_text()
21
+ config_docs = config_text.split("---")
22
+ for doc in config_docs:
23
+ config = fspathtree(yaml.safe_load(doc))
24
+ configs.append(config)
25
+
26
+ return configs
27
+
28
+
29
+ @app.command()
30
+ def render(config_file: Path, output: Path = None):
31
+ if output is None:
32
+ output = config_file.parent / (config_file.name + ".rendered")
33
+ configs = load_config(config_file)
34
+ config_renderer = rendering.ConfigRenderer()
35
+ rendered_configs = []
36
+ for config in configs:
37
+ config = config_renderer.expand_and_render(config)
38
+ rendered_configs += config
39
+ if len(rendered_configs) == 1:
40
+ output.write_text(yaml.dump(rendered_configs[0].tree))
41
+
42
+
43
+ @app.command()
44
+ def help():
45
+ print(
46
+ """
47
+ The `powerconf` command is a CLI for the powerconf python module. It allows you to read
48
+ a configuration file, evaluate all expression, expand all batch nodes, etc, and write
49
+ the "rendered" configurations to a file(s).
50
+ """
51
+ )
@@ -0,0 +1,8 @@
1
+ class UndefinedVariableInExpression(RuntimeError):
2
+ def __init__(self, msg):
3
+ super().__init__(msg)
4
+
5
+
6
+ class SyntaxErrorInExpression(RuntimeError):
7
+ def __init__(self, msg):
8
+ super().__init__(msg)
@@ -0,0 +1,49 @@
1
+ import pickle
2
+ import subprocess
3
+ import sys
4
+
5
+ from fspathtree import fspathtree
6
+
7
+
8
+ class ExpressionEvaluator:
9
+ """A class for evaluting expressions in a separate environement."""
10
+
11
+ def __init__(self):
12
+ pass
13
+
14
+ def start(self):
15
+ pass
16
+
17
+ def eval(self, text: str):
18
+ pass
19
+
20
+ def stop(self):
21
+ pass
22
+
23
+
24
+ class ExecExpressionEvaluator(ExpressionEvaluator):
25
+ """A class for evaluating expressions using the builtin exec(). BE CAREFUL!"""
26
+
27
+ def __init__(self):
28
+ self.globals = {}
29
+ self.locals = {}
30
+ self.token = "_configurator_xyz"
31
+
32
+ import math
33
+
34
+ import numpy
35
+
36
+ self.add_global("numpy", numpy)
37
+ self.add_global("math", math)
38
+
39
+ def add_global(self, name, obj):
40
+ self.globals[name] = obj
41
+
42
+ def eval(self, text: str):
43
+ for forbidden_text in ["import", "open"]:
44
+ if forbidden_text in text.replace(" ", ""):
45
+ raise RuntimeError(
46
+ f"Expressions are not allowed to contain the text '{forbidden_text}'."
47
+ )
48
+ exec(f"{self.token}_result = " + text, self.globals, self.locals)
49
+ return self.locals[self.token + "_result"]
powerconf/graphs.py ADDED
@@ -0,0 +1,17 @@
1
+ from fspathtree import fspathtree
2
+ import networkx as nx
3
+
4
+
5
+ DependencyGraph = nx.DiGraph
6
+
7
+ def get_dependency_chains(graph: DependencyGraph, node:fspathtree.PathType):
8
+ """Given a node, return the chain of nodes that depend on it. If multiple paths exists, each is returned. If node does not have any predecessors, an empty list is returned."""
9
+
10
+ chains = []
11
+ for pred in graph.predecessors(node):
12
+ this_chain = []
13
+ this_chain.append(node)
14
+
15
+
16
+
17
+ pass
powerconf/parsing.py ADDED
@@ -0,0 +1,8 @@
1
+ from pyparsing import *
2
+
3
+ unbraced_variable = Combine(Literal("$") + Word(alphanums + "/_")("variable name"))
4
+ braced_variable = Combine(
5
+ Literal("$") + QuotedString(quote_char="{", end_quote_char="}")("variable name")
6
+ )
7
+ variable = unbraced_variable | braced_variable
8
+ expression = Combine(Literal("$") + original_text_for(nested_expr())("expression body"))
powerconf/readers.py ADDED
@@ -0,0 +1,18 @@
1
+ import pathlib
2
+
3
+ import yaml
4
+ from fspathtree import fspathtree
5
+
6
+
7
+ def load_yaml_docs(text_or_file: pathlib.Path | str):
8
+ """Load all documents in YAML file."""
9
+ if type(text_or_file) == str:
10
+ text = text_or_file
11
+ else:
12
+ text = text_or_file.read_text()
13
+
14
+ configs = []
15
+ for doc in text.split("---"):
16
+ configs.append(fspathtree(yaml.safe_load(doc)))
17
+
18
+ return configs
powerconf/rendering.py ADDED
@@ -0,0 +1,327 @@
1
+ import copy
2
+ import itertools
3
+ from os.path import normpath
4
+ from typing import Any, Dict, List
5
+
6
+ import pint
7
+ from fspathtree import fspathtree
8
+
9
+ from . import expressions, graphs, parsing
10
+
11
+
12
+ def expand_partial_configs(configs: List[fspathtree], include_base=False):
13
+ """
14
+ Give a list of configuration trees, treat the frist tree
15
+ as a "base" configuration and all other trees as partial configurations
16
+ that should be merged with the base config to create instances.
17
+
18
+ @param include_base : if true, the base config will be included in the returned set.
19
+ """
20
+ assert len(configs) > 0
21
+
22
+ if len(configs) == 1:
23
+ return configs
24
+
25
+ full_configs = []
26
+ if include_base:
27
+ full_configs.append(copy.deepcopy(configs[0]))
28
+ for c in configs[1:]:
29
+ full_configs.append(copy.deepcopy(configs[0]))
30
+ full_configs[-1].update(c)
31
+
32
+ return full_configs
33
+
34
+
35
+ class ConfigRenderer:
36
+ def __init__(self, expression_evaluator=None):
37
+ if expression_evaluator is None:
38
+ expression_evaluator = expressions.ExecExpressionEvaluator()
39
+
40
+ self.expression_evaluator = expression_evaluator
41
+ self.ureg = pint.UnitRegistry()
42
+ self.Quantity = self.ureg.Quantity
43
+
44
+ def expand_and_render(self, config):
45
+ """Expand batch configurations and render each instance."""
46
+ configs = self.expand_batch_nodes(config)
47
+ for i in range(len(configs)):
48
+ configs[i] = self.render(configs[i])
49
+
50
+ return configs
51
+
52
+ def expand_batch_nodes(self, config: fspathtree):
53
+ """Expand @batch nodes in a configuration tree into multiple configuration trees."""
54
+ configs = []
55
+
56
+ batch_leaves = self._get_batch_leaves(config)
57
+
58
+ for vals in itertools.product(
59
+ *[config[leaf + "/@batch"] for leaf in batch_leaves.keys()]
60
+ ):
61
+ instance = copy.deepcopy(config)
62
+ for i, leaf in enumerate(batch_leaves.keys()):
63
+ instance[leaf] = vals[i]
64
+ configs.append(instance)
65
+
66
+ return configs
67
+
68
+ def render(self, config: fspathtree, make_copy=True):
69
+ """Render a configuration tree, constructing all quantities, expanding all variables, and evaluating all expressions."""
70
+ # create a graph of the dependencies
71
+ G = graphs.DependencyGraph()
72
+ # add all leaf node paths as nodes of the graph
73
+ G.add_nodes_from(config.get_all_leaf_node_paths())
74
+ # draw edges between nodes representing dependencies.
75
+ # edges point from a node to its dependency.
76
+ # if a node has incomming edges, other nodes depend on it
77
+ # if a node has outgoing edges, it depends on others.
78
+ for node in list(G.nodes):
79
+ variables = [
80
+ fspathtree.PathType(r["variable name"])
81
+ for r in parsing.variable.search_string(config[node])
82
+ ]
83
+ for v in variables:
84
+ dep = fspathtree.PathType(
85
+ normpath(v if v.is_absolute() else node.parent / v)
86
+ )
87
+ G.add_edge(node, dep)
88
+ # detect circular dependencies
89
+ cycles = sorted(graphs.nx.simple_cycles(G))
90
+ if len(cycles) > 0:
91
+ msg = "Circular dependencies detected."
92
+ for cycle in cycles:
93
+ msg += "(" + " -> ".join(map(str, cycle)) + ")"
94
+ raise RuntimeError(msg)
95
+
96
+ # make a copy to work with
97
+
98
+ if make_copy:
99
+ rendered_config = copy.deepcopy(config)
100
+ else:
101
+ rendered_config = config
102
+
103
+ rendered_config = self._construct_all_quantities(rendered_config)
104
+ rendered_config = self._expand_all_variables(rendered_config)
105
+ rendered_config = self._evaluate_all_expressions(rendered_config, G)
106
+
107
+ return rendered_config
108
+
109
+ def _evaluate_all_expressions(
110
+ self, config: fspathtree, graph: graphs.DependencyGraph
111
+ ):
112
+ """Evaluate the expressions in a configuration tree, using a graph of the tree dependencies to determine the render order."""
113
+ # We need to determine which the order to evaluate evaluate the nodes of the config gree.
114
+ # We have a graph that describes the dependencies. each node is the graph is a leaf node
115
+ # in the tree, and the edges represent dependencies between nodes.
116
+ # Consider an example,
117
+ #
118
+ # A X
119
+ # / \ / \
120
+ # B C Y Z
121
+ # / \ /
122
+ # D E
123
+ #
124
+ # Assume all edgest are directed DOWN.
125
+ # B, D, E and Z then have no dependencies. All of their edges are "in" edges,
126
+ # they have no "out" edges.
127
+ #
128
+ # With networkx we can easily get a list of all nodes with no dependencies, first
129
+ # get the number of in and out edges for each node.
130
+ out_degrees = dict(graph.out_degree)
131
+ in_degrees = dict(graph.in_degree)
132
+ # any nodes that have zero out edges (out_degrees = 0) have no dependencies, and we can go ahead and render these.
133
+ config = self._evaluate_expressions(
134
+ config,
135
+ paths=map(
136
+ lambda item: item[0],
137
+ filter(lambda item: item[1] == 0, out_degrees.items()),
138
+ ),
139
+ )
140
+ # Now we need to render nodes that have dependencies, but we need to determine the order that this can be
141
+ # done first. In the above example, we can evaluate A unit B and C are evaluated. We can evaluate C unilt D and E
142
+ # are evaluated.
143
+ #
144
+ # With networkx, we can easily get a list of all ancestors of a node. For node E, this would be [C,Y,A,X]
145
+ # Note: with the direction of our edges, an ancestor _depends_ on the node. So C is an ancestor of E, even
146
+ # though it seems more natural to consider E the ancestor of C. If we wanted E to be the ancestor of C,
147
+ # we would need to direct our edgest to point from dependencies to dependences.
148
+
149
+ # find all root dependencies. those nodes that don't depend on any others, but are depended on by others
150
+ # thse are all nodes with out_degrees == 0 and in_degrees > 0
151
+ root_dependencies = list(
152
+ filter(
153
+ lambda k: in_degrees[k] > 0,
154
+ filter(lambda k: out_degrees[k] == 0, out_degrees.keys()),
155
+ )
156
+ )
157
+
158
+ # For each root node, get a list of all ancestors, and then find every path that connects the ancestor to the root.
159
+ # in this example above, node E would have 4 ancestors, [C,Y,A,X]. And the list of paths connecting E to its ancestors
160
+ # would be E -> C, E -> Y, E -> C -> A, and E -> Y -> X.
161
+ # However, we can't simply take one of these paths and start evaluating nodes. If we tried to evaluate E -> C -> A for example,
162
+ # we would get an error if B had not already been determined.
163
+ #
164
+ # What we really need to be able to do is find A and X, then work backward from there...
165
+ #
166
+ # Find all leaf node dependencies. These are nodes that only have dependencies, no dependents (out_degree > 0, in_nodes = 0)
167
+ leaf_dependencies = list(
168
+ filter(
169
+ lambda k: in_degrees[k] == 0,
170
+ filter(lambda k: out_degrees[k] > 0, out_degrees.keys()),
171
+ )
172
+ )
173
+ # now get a set of paths that connect each leaf node to its children
174
+ for leaf in leaf_dependencies:
175
+ all_paths = []
176
+ for d in graphs.nx.descendants(graph, leaf):
177
+ all_paths += graphs.nx.all_simple_paths(graph, leaf, d)
178
+
179
+ # node batches are sets of nodes that can all be rendered
180
+ # at the same time.
181
+ node_batches = [
182
+ set(filter(lambda n: n is not None, nodes))
183
+ for nodes in itertools.zip_longest(*all_paths)
184
+ ]
185
+ node_batches.reverse()
186
+ for batch in node_batches[1:]:
187
+ config = self._evaluate_expressions(config, paths=batch)
188
+
189
+ return config
190
+
191
+ for root in root_dependencies:
192
+ all_chains = []
193
+ for a in graphs.nx.ancestors(graph, root):
194
+ all_chains += graphs.nx.all_simple_paths(graph, a, root)
195
+ nodes_to_evaluate = []
196
+
197
+ # get all paths in the graph that end on roots
198
+ all_chains = []
199
+ for root in root_dependencies:
200
+ for a in graphs.nx.ancestors(graph, root):
201
+ all_chains += graphs.nx.all_simple_paths(graph, a, root)
202
+ # prune paths that are included in ohters,
203
+ # i.e. we only want to keep paths from each root to it's
204
+ # oldest ancestors.
205
+ longest_chains = []
206
+ for p1 in all_chains:
207
+ save = True
208
+ for p2 in all_chains:
209
+ if p1 == p2:
210
+ continue
211
+ if p2[-len(p1) :] == p1:
212
+ save = False
213
+ break
214
+ if save:
215
+ longest_chains.append(p1)
216
+
217
+ # determine the order to evaluate dependencies.
218
+
219
+ for chain in longest_chains:
220
+ chain.reverse() # paths start with oldest ancestor and end with root node.
221
+ config = self._evaluate_expressions(config, paths=chain[1:])
222
+
223
+ return config
224
+
225
+ def _evaluate_expressions(self, config: fspathtree, paths: List[Any]):
226
+ """Evaluate expression in the config at the paths listed. Uses as a utility function on _evaluate_all_expressions(...)"""
227
+ for path in paths:
228
+ if not self._contains_expression(config[path]):
229
+ continue
230
+
231
+ self.expression_evaluator.globals["ctx"] = config[path.parent]
232
+ expressions = parsing.expression.search_string(config[path])
233
+ if (
234
+ len(expressions) == 1
235
+ and "$" + expressions[0]["expression body"] == config[path]
236
+ ):
237
+ # the value of the element is a single expression with no surrounding text
238
+ # we want to replace the expression with the evaluation
239
+ e = expressions[0]
240
+ value = self.expression_evaluator.eval(e["expression body"][1:-1])
241
+ config[path] = value
242
+ else:
243
+ # we have more than one expression or the expression is surrounded by text
244
+ # we want to evaluate each expression and replace it with a str of its value
245
+ old_text = config[path]
246
+ new_text = ""
247
+ i = 0
248
+ for tokens, start, end in parsing.expression.scan_string(old_text):
249
+ new_text += old_text[i:start]
250
+ i = end
251
+ new_text += str(
252
+ self.expression_evaluator.eval(tokens["expression body"][1:-1])
253
+ )
254
+ new_text += old_text[i:]
255
+ config[path] = new_text
256
+ return config
257
+
258
+ def _construct_all_quantities(self, config: fspathtree):
259
+ """Replace strings in the tree representing quantities with pint.Quantity objects."""
260
+ for path in config.get_all_leaf_node_paths():
261
+ if type(config[path]) == str:
262
+ if self._contains_expression(config[path]) or self._contains_variable(
263
+ config[path]
264
+ ):
265
+ continue
266
+ try:
267
+ q = self.Quantity(config[path])
268
+ config[path] = q
269
+ except:
270
+ pass
271
+
272
+ return config
273
+
274
+ def _expand_all_variables(
275
+ self, config: fspathtree, template: str = "ctx['{name}']"
276
+ ):
277
+ """
278
+ Expand all shell-style variables into python variables in the entire tree.
279
+ """
280
+ for path in config.get_all_leaf_node_paths():
281
+ if type(config[path]) == str:
282
+ config[path] = self._expand_variables(config[path])
283
+
284
+ return config
285
+
286
+ def _expand_variables(self, text: Any, template: str = "ctx['{name}']"):
287
+ """
288
+ Expand shell-style variables into python variables
289
+
290
+ ${x} -> c['x']
291
+ ${/grid/x} -> c['/grid/x']
292
+ """
293
+ i = 0
294
+ new_text = ""
295
+ for tokens, start, end in parsing.variable.scan_string(text):
296
+ new_text += text[i:start]
297
+ i = end
298
+ new_text += template.format(name=tokens["variable name"])
299
+ new_text += text[i:]
300
+ return new_text
301
+
302
+ def _contains_expression(self, text: Any):
303
+ if type(text) is not str:
304
+ return False
305
+
306
+ results = parsing.expression.search_string(text)
307
+ return len(results) > 0
308
+
309
+ def _contains_variable(self, text: Any):
310
+ if type(text) is not str:
311
+ return False
312
+
313
+ results = parsing.variable.search_string(text)
314
+ return len(results) > 0
315
+
316
+ def _get_batch_leaves(self, config: fspathtree):
317
+ """
318
+ Return a list of keys in a fpathtree (nested dict/list) that are marked
319
+ as batch.
320
+ """
321
+ batch_leaves = dict()
322
+ for leaf in config.get_all_leaf_node_paths():
323
+ if leaf.parent.parts[-1] == "@batch":
324
+ batch_leaves[str(leaf.parent.parent)] = (
325
+ batch_leaves.get(str(leaf.parent.parent), 0) + 1
326
+ )
327
+ return batch_leaves
powerconf/utils.py ADDED
@@ -0,0 +1,24 @@
1
+ import hashlib
2
+ import json
3
+
4
+ from fspathtree import fspathtree
5
+
6
+
7
+ def get_id(config: fspathtree, strip_keys=None):
8
+ """Return a unique id for the given configuration object."""
9
+ if strip_keys is None:
10
+ strip_keys = []
11
+ # make a copy of the config with only keys not in the strip list
12
+ c = fspathtree()
13
+
14
+ def filt(path):
15
+ if str(path) in strip_keys:
16
+ return False
17
+ return True
18
+
19
+ # we use a filter on the get_all_leaf_node_paths(...) here for more flexability
20
+ for p in config.get_all_leaf_node_paths(predicate=filt):
21
+ c[p] = str(config[p])
22
+
23
+ text = json.dumps(c.tree, sort_keys=True).replace(" ", "")
24
+ return hashlib.md5(text.encode("utf-8")).hexdigest()
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.1
2
+ Name: powerconf
3
+ Version: 0.1.0
4
+ Summary:
5
+ Author: CD Clark III
6
+ Author-email: clifton.clark@gmail.com
7
+ Requires-Python: >=3.10,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Requires-Dist: fspathtree (>=0.9,<0.10)
13
+ Requires-Dist: networkx (>=3.3,<4.0)
14
+ Requires-Dist: numpy (>=2.0.0,<3.0.0)
15
+ Requires-Dist: pint (>=0.24,<0.25)
16
+ Requires-Dist: pyparsing (>=3.1.2,<4.0.0)
17
+ Description-Content-Type: text/markdown
18
+
19
+ # powerconf
20
+
21
+ Powerful configuration tools for numerical simulation.
22
+
23
+ `powerconf` allows you to write configuration files for things like physics simulations
24
+ with support for variable interpolation and expression evaluation. Consider a simulation
25
+ will solve some partial differential equation on a 2-dimensional Cartesian grid. Perhaps
26
+ the simulation itself requires us to set the min and max range and the number of points
27
+ to use along each axis. A simple YAML configuration for the simulation might look something
28
+ like this
29
+
30
+ ```yaml
31
+ grid:
32
+ x:
33
+ min: 0 cm
34
+ max: 1.5 cm
35
+ N: 151
36
+ y:
37
+ min: 0 cm
38
+ max: 1.0 cm
39
+ N: 101
40
+ ```
41
+ This is fine, but it might be useful to specify the resolution to use instead of the number of points.
42
+ With `powerconf`, we can write a configuration file that looks like this
43
+
44
+ ```yaml
45
+ grid:
46
+ resolution: 1 um
47
+ x:
48
+ min: 0 cm
49
+ max: 1.5 cm
50
+ N: $( (${max} - ${min})/${../resolution} + 1)
51
+ y:
52
+ min: 0 cm
53
+ max: 1.0 cm
54
+ N: $( (${max} - ${min})/${../resolution} + 1)
55
+ ```
56
+ In this example, we give a resolution to use for both x and y directions and then calculate the number
57
+ of points to use with an expression.
58
+
59
+
@@ -0,0 +1,13 @@
1
+ powerconf/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ powerconf/cli.py,sha256=nGGxNrM9qmraMzHtx0E-trGOQzKhZDS-FS5kLN1QySc,1384
3
+ powerconf/exceptions.py,sha256=yw43FCBdvnNZJA_SLb2v1IVu4WggLluIOrikpjXjLgw,216
4
+ powerconf/expressions.py,sha256=gL6ShbFsz1qUXFTtsZeuS-Rzfkw7FLLDCeIvzrYo_f4,1186
5
+ powerconf/graphs.py,sha256=xIog0XKMpCskxZ7CNg8M9XFgKNyhWtIh02xRvPQEt6w,472
6
+ powerconf/parsing.py,sha256=zd7_6UNjRBesf9vwEhznGaPNygFXETQTHK1gBGXt41A,359
7
+ powerconf/readers.py,sha256=-rs3-eDUKhvFthVXNwTjnCgUrrdOgPBSgiiogAN3nL8,401
8
+ powerconf/rendering.py,sha256=7UK2_UrKQrl_SF6HLTfiGObQCY7mo-pQylpY9FbYdDM,13291
9
+ powerconf/utils.py,sha256=0aox4OgvKlI74lCXDXfE7nWcW7eFR_7_tJP5Bm76QvU,718
10
+ powerconf-0.1.0.dist-info/METADATA,sha256=6HCM2NNoITXlWvuh3nGz-NKufbTIa88m3yRfsuiQUvg,1805
11
+ powerconf-0.1.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
12
+ powerconf-0.1.0.dist-info/entry_points.txt,sha256=_W1WDvYgt6lCJXcTO6kjkEP2E7udwLYnS1C_5q7ncho,47
13
+ powerconf-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ powerconf=powerconf.cli:app
3
+