multiphasepy 4.0.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.
Files changed (59) hide show
  1. multiphasepy/__init__.py +30 -0
  2. multiphasepy/assets/header_templates/c++_foundation.jinja +30 -0
  3. multiphasepy/assets/header_templates/c++_hzdr.jinja +46 -0
  4. multiphasepy/assets/header_templates/config_openfoam_foundation.jinja +17 -0
  5. multiphasepy/assets/header_templates/config_openfoam_hzdr.jinja +17 -0
  6. multiphasepy/assets/header_templates/shell_foundation.jinja +35 -0
  7. multiphasepy/assets/header_templates/shell_hzdr.jinja +50 -0
  8. multiphasepy/auxiliary.py +423 -0
  9. multiphasepy/case.py +680 -0
  10. multiphasepy/cfd/__init__.py +11 -0
  11. multiphasepy/cfd/foam.py +309 -0
  12. multiphasepy/cfd/foamlexer.py +598 -0
  13. multiphasepy/cli/__init__.py +15 -0
  14. multiphasepy/cli/callbacks.py +185 -0
  15. multiphasepy/cli/cliutils.py +138 -0
  16. multiphasepy/cli/convert.py +72 -0
  17. multiphasepy/cli/copy.py +90 -0
  18. multiphasepy/cli/decorators.py +353 -0
  19. multiphasepy/cli/docker.py +264 -0
  20. multiphasepy/cli/fuzzy.py +131 -0
  21. multiphasepy/cli/hooks.py +326 -0
  22. multiphasepy/cli/identify.py +106 -0
  23. multiphasepy/cli/post.py +219 -0
  24. multiphasepy/cli/publish.py +164 -0
  25. multiphasepy/cli/rpcmp.py +82 -0
  26. multiphasepy/cli/rpdiff.py +86 -0
  27. multiphasepy/cli/shrun.py +76 -0
  28. multiphasepy/cli/test.py +91 -0
  29. multiphasepy/cli/visualize.py +210 -0
  30. multiphasepy/cli/watch.py +366 -0
  31. multiphasepy/cli/workflow.py +154 -0
  32. multiphasepy/exceptions.py +115 -0
  33. multiphasepy/fuzzy.py +411 -0
  34. multiphasepy/hooks/__init__.py +30 -0
  35. multiphasepy/hooks/copyright.py +523 -0
  36. multiphasepy/hooks/header_ifndef.py +59 -0
  37. multiphasepy/hooks/keywords.py +128 -0
  38. multiphasepy/hooks/line_length.py +32 -0
  39. multiphasepy/hooks/nonstandardcode.py +44 -0
  40. multiphasepy/hooks/preview_encoding.py +41 -0
  41. multiphasepy/hooks/tabs.py +36 -0
  42. multiphasepy/io.py +656 -0
  43. multiphasepy/logutils.py +21 -0
  44. multiphasepy/metrics.py +93 -0
  45. multiphasepy/oci/__init__.py +32 -0
  46. multiphasepy/oci/apptainer.py +491 -0
  47. multiphasepy/oci/docker.py +706 -0
  48. multiphasepy/oci/oci.py +515 -0
  49. multiphasepy/post.py +398 -0
  50. multiphasepy/rodare.py +563 -0
  51. multiphasepy/rpcmp.py +285 -0
  52. multiphasepy/stream.py +92 -0
  53. multiphasepy/testing.py +44 -0
  54. multiphasepy/visualization.py +457 -0
  55. multiphasepy/workflow.py +333 -0
  56. multiphasepy-4.0.0.dist-info/METADATA +172 -0
  57. multiphasepy-4.0.0.dist-info/RECORD +59 -0
  58. multiphasepy-4.0.0.dist-info/WHEEL +4 -0
  59. multiphasepy-4.0.0.dist-info/entry_points.txt +16 -0
@@ -0,0 +1,30 @@
1
+ # Multiphase Python Repository by HZDR
2
+ #
3
+ # SPDX-FileCopyrightText: 2025 Helmholtz-Zentrum Dresden-Rossendorf e.V. (HZDR)
4
+ #
5
+ # SPDX-License-Identifier: GPL-3.0-or-later
6
+
7
+ """Mark this directory as multiphasepy Python package.
8
+
9
+ Attributes:
10
+ pkgversion (str): Installed version of the package
11
+ """
12
+
13
+ from importlib.metadata import version as get_installed_version
14
+
15
+ from packaging.version import Version
16
+
17
+ pkgversion = get_installed_version(str(__package__))
18
+
19
+
20
+ def minimum_version(minversion: str):
21
+ """Check that the installed version meets the minimum required version.
22
+
23
+ Args:
24
+ minversion: Minimum required version.
25
+ """
26
+ if Version(pkgversion) < Version(minversion):
27
+ raise RuntimeError(
28
+ f"{__package__} >= {minversion} is required, "
29
+ f"but version {pkgversion} is installed."
30
+ )
@@ -0,0 +1,30 @@
1
+ /*--------------------------------{{file_type}}----------------------------------*\
2
+ ========= |
3
+ \\ / F ield | OpenFOAM: The Open Source CFD Toolbox
4
+ \\ / O peration | Website: https://openfoam.org
5
+ \\ / A nd | Copyright (C) {% for org in copyrightHolder %}{% if org.name == "OpenFOAM Foundation" %}{{- org.year -}}{% endif %}{% endfor %} OpenFOAM Foundation
6
+ \\/ M anipulation |
7
+ -------------------------------------------------------------------------------
8
+ License
9
+ This file is part of OpenFOAM.
10
+
11
+ OpenFOAM is free software: you can redistribute it and/or modify it
12
+ under the terms of the GNU General Public License as published by
13
+ the Free Software Foundation, either version 3 of the License, or
14
+ (at your option) any later version.
15
+
16
+ OpenFOAM is distributed in the hope that it will be useful, but WITHOUT
17
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
18
+ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
19
+ for more details.
20
+
21
+ You should have received a copy of the GNU General Public License
22
+ along with OpenFOAM. If not, see <http://www.gnu.org/licenses/>.
23
+
24
+ {% for category, text in header.items() -%}
25
+ {{ category }}
26
+ {{ text | wordwrap(76, wrapstring='\n ', break_long_words=False) | replace('\n \n', '\n\n') }}
27
+
28
+ {% endfor -%}
29
+ \*---------------------------------------------------------------------------*/
30
+ {# Keep a tailing new line to separate body from header #}
@@ -0,0 +1,46 @@
1
+ /*--------------------------------{{file_type}}----------------------------------*\
2
+ == == ====== ==== ==== |
3
+ \\ || | {{name | truncate(47, True, '...') }}
4
+ ====== // || || ===// | Website: {{url}}
5
+ || || // || // || \\ | License: GPL-3.0-or-later
6
+ == == ====== ==== == == |
7
+ -------------------------------------------------------------------------------
8
+ License
9
+ {%- filter wordwrap(76, wrapstring='\n ', break_long_words=False) | replace('\n \n', '\n\n') %}
10
+ This file is part of the {{name}}.
11
+ {% for org in copyrightHolder %}
12
+ Copyright (C) {{ org.year }} by {{ org.name }}, Website: {{ org.url }}
13
+ {% endfor %}
14
+ {% filter replace('\n', ' ')-%}
15
+ If you are interested in which files are original OpenFOAM Foundation files,
16
+ which OpenFOAM Foundation files were modified, and which files were newly
17
+ created, see FILES.md.
18
+ {%- endfilter %}
19
+ banana
20
+
21
+ {% filter replace('\n', ' ')-%}
22
+ {{name}} is free software: you can redistribute it and/or modify it
23
+ under the terms of the GNU General Public License as published by the Free
24
+ Software Foundation, either version 3 of the License, or (at your option) any
25
+ later version.
26
+ {%- endfilter %}
27
+
28
+ {% filter replace('\n', ' ')-%}
29
+ {{name}} is distributed in the hope that it will be useful, but WITHOUT
30
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
31
+ FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
32
+ {%- endfilter %}
33
+
34
+ {% filter replace('\n', ' ')-%}
35
+ You should have received a copy of the GNU General Public License along with
36
+ the {{name}}. If not, see <http://www.gnu.org/licenses/>.
37
+ {%- endfilter %}
38
+ {% endfilter %}
39
+
40
+ {% for category, text in header.items() -%}
41
+ {{ category }}
42
+ {{ text | wordwrap(76, wrapstring='\n ', break_long_words=False) | replace('\n \n', '\n\n') }}
43
+
44
+ {% endfor -%}
45
+ \*---------------------------------------------------------------------------*/
46
+ {# Keep a tailing new line to separate body from header #}
@@ -0,0 +1,17 @@
1
+ /*--------------------------------{{file_type}}----------------------------------*\
2
+ ========= |
3
+ \\ / F ield | OpenFOAM: The Open Source CFD Toolbox
4
+ \\ / O peration | Website: https://openfoam.org
5
+ \\ / A nd | Version: dev
6
+ \\/ M anipulation |
7
+ {% if header -%}
8
+ {% for category, text in header.items() -%}
9
+ {% if loop.first -%}
10
+ -------------------------------------------------------------------------------
11
+ {%- endif %}
12
+ {{ category }}
13
+ {{ text | wordwrap(76, wrapstring='\n ', break_long_words=False) | replace('\n \n', '\n\n') }}
14
+
15
+ {% endfor %}
16
+ {% endif -%}
17
+ \*---------------------------------------------------------------------------*/
@@ -0,0 +1,17 @@
1
+ /*--------------------------------{{file_type}}----------------------------------*\
2
+ == == ====== ==== ==== |
3
+ \\ || | {{name | truncate(47, True, '...') }}
4
+ ====== // || || ===// | Website: {{url}}
5
+ || || // || // || \\ | License: GPL-3.0-or-later
6
+ == == ====== ==== == == |
7
+ {% if header -%}
8
+ {% for category, text in header.items() -%}
9
+ {% if loop.first -%}
10
+ -------------------------------------------------------------------------------
11
+ {%- endif %}
12
+ {{ category }}
13
+ {{ text | wordwrap(76, wrapstring='\n ', break_long_words=False) | replace('\n \n', '\n\n') }}
14
+
15
+ {% endfor -%}
16
+ {% endif -%}
17
+ \*---------------------------------------------------------------------------*/
@@ -0,0 +1,35 @@
1
+ {% if shebang -%}
2
+ {{ shebang }}
3
+ #---------------------------------{{file_type}}------------------------------------
4
+ {%- else -%}
5
+ #---------------------------------{{file_type}}------------------------------------
6
+ {%- endif %}
7
+ # ========= |
8
+ # \\ / F ield | OpenFOAM: The Open Source CFD Toolbox
9
+ # \\ / O peration | Website: https://openfoam.org
10
+ # \\ / A nd | Copyright (C) {% for org in copyrightHolder %}{% if org.name == "OpenFOAM Foundation" %}{{- org.year -}}{% endif %}{% endfor %} OpenFOAM Foundation
11
+ # \\/ M anipulation |
12
+ #------------------------------------------------------------------------------
13
+ # License
14
+ # This file is part of OpenFOAM.
15
+ #
16
+ # OpenFOAM is free software: you can redistribute it and/or modify it
17
+ # under the terms of the GNU General Public License as published by
18
+ # the Free Software Foundation, either version 3 of the License, or
19
+ # (at your option) any later version.
20
+ #
21
+ # OpenFOAM is distributed in the hope that it will be useful, but WITHOUT
22
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
23
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
24
+ # for more details.
25
+ #
26
+ # You should have received a copy of the GNU General Public License
27
+ # along with OpenFOAM. If not, see <http://www.gnu.org/licenses/>.
28
+ #
29
+ {% for category, text in header.items() -%}
30
+ {{ category | indent(width='# ', first=True) }}
31
+ # {{ text | wordwrap(74, wrapstring='\n# ', break_long_words=False) }}
32
+ #
33
+ {% endfor -%}
34
+ #------------------------------------------------------------------------------
35
+ {# Keep a tailing new line to separate body from header #}
@@ -0,0 +1,50 @@
1
+ {% if shebang -%}
2
+ {{ shebang }}
3
+ #---------------------------------{{file_type}}------------------------------------
4
+ {%- else -%}
5
+ #---------------------------------{{file_type}}------------------------------------
6
+ {%- endif %}
7
+ # == == ====== ==== ==== |
8
+ # \\ || | {{name | truncate(47, True, '...') }}
9
+ # ====== // || || ===// | Website: {{url}}
10
+ # || || // || // || \\ | License: GPL-3.0-or-later
11
+ # == == ====== ==== == == |
12
+ #------------------------------------------------------------------------------
13
+ # License
14
+ {%- filter wordwrap(74, wrapstring='\n# ', break_long_words=False) %}
15
+ This file is part of the {{name}}.
16
+ {% for org in copyrightHolder %}
17
+ Copyright (C) {{ org.year }} by {{ org.name }}, Website: {{ org.url }}
18
+ {% endfor %}
19
+ {% filter replace('\n', ' ')-%}
20
+ If you are interested in which files are original OpenFOAM Foundation files,
21
+ which OpenFOAM Foundation files were modified, and which files were newly
22
+ created, see FILES.md.
23
+ {%- endfilter %}
24
+
25
+ {% filter replace('\n', ' ')-%}
26
+ {{name}} is free software: you can redistribute it and/or modify it
27
+ under the terms of the GNU General Public License as published by the Free
28
+ Software Foundation, either version 3 of the License, or (at your option) any
29
+ later version.
30
+ {%- endfilter %}
31
+
32
+ {% filter replace('\n', ' ')-%}
33
+ {{name}} is distributed in the hope that it will be useful, but WITHOUT
34
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
35
+ FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
36
+ {%- endfilter %}
37
+
38
+ {% filter replace('\n', ' ')-%}
39
+ You should have received a copy of the GNU General Public License along with
40
+ the {{name}}. If not, see <http://www.gnu.org/licenses/>.
41
+ {%- endfilter %}
42
+ {% endfilter %}
43
+ #
44
+ {% for category, text in header.items() -%}
45
+ {{ category | indent(width='# ', first=True) }}
46
+ # {{ text | wordwrap(74, wrapstring='\n# ', break_long_words=False) }}
47
+ #
48
+ {% endfor -%}
49
+ #------------------------------------------------------------------------------
50
+ {# Keep a tailing new line to separate body from header #}
@@ -0,0 +1,423 @@
1
+ # Multiphase Python Repository by HZDR
2
+ #
3
+ # SPDX-FileCopyrightText: 2026 Helmholtz-Zentrum Dresden-Rossendorf e.V. (HZDR)
4
+ #
5
+ # SPDX-License-Identifier: GPL-3.0-or-later
6
+
7
+ """Module containing common functions for system operations."""
8
+
9
+ import subprocess
10
+ from difflib import SequenceMatcher
11
+ from functools import partial
12
+ from importlib.metadata import metadata
13
+ from pathlib import Path
14
+ from typing import Callable, Dict, List, Tuple
15
+
16
+ import dask.bag as db
17
+ from fabric import Connection
18
+ from invoke.exceptions import UnexpectedExit
19
+ from jinja2 import Environment, FileSystemLoader, meta
20
+
21
+ from .exceptions import FileIdentificationError
22
+ from .io import DictContainer, filetags
23
+ from .logutils import log
24
+
25
+
26
+ def project_urls() -> dict:
27
+ """Retrieve the project URLs from the package metadata.
28
+
29
+ Returns:
30
+ A dictionary containing the project URLs or an empty dictionary if no
31
+ URLs are found.
32
+ """
33
+ urls = metadata("multiphasepy").get_all("Project-URL")
34
+
35
+ if urls is not None:
36
+ return dict(url.split(", ", 1) for url in urls)
37
+ else:
38
+ return {}
39
+
40
+
41
+ def runcommand(
42
+ cmd: List[str],
43
+ host: str = "localhost",
44
+ raise_on_error: bool = True,
45
+ hide: bool = True,
46
+ warn: bool = True,
47
+ ) -> Tuple[int, str | None]:
48
+ """Execute a command locally or on a remote host.
49
+
50
+ The given command is executed either locally if host is 'localhost', or on
51
+ the specified remote host using SSH. The function returns a tuple of the
52
+ exit code and stdout obtained from command execution.
53
+
54
+ Args:
55
+ cmd: command sequence as a list.
56
+ host: 'localhost' or a remote host.
57
+ raise_on_error: raise subprocess.CalledProcessError or
58
+ UnexpectedExit.
59
+ hide: for remote runs, whether to hide output
60
+ warn: add warning to logger in case an error occurs
61
+
62
+ Returns:
63
+ exit code and stdout obtained from command execution.
64
+ """
65
+ # Local execution
66
+ if host == "localhost":
67
+ try:
68
+ log.debug(f"Running command locally: '{' '.join(cmd)}'")
69
+ # cmd is expected to be a list of argv elements
70
+ res = subprocess.run(
71
+ cmd,
72
+ text=hide,
73
+ capture_output=hide,
74
+ check=raise_on_error,
75
+ )
76
+ return (res.returncode, res.stdout)
77
+ except subprocess.CalledProcessError as e:
78
+ msg = f"Command '{cmd}' failed with exit code {e.returncode}"
79
+ if raise_on_error:
80
+ log.error(msg)
81
+ raise
82
+ elif warn:
83
+ log.warning(msg)
84
+ except FileNotFoundError as e:
85
+ msg = f"Command '{cmd}' not found: {e}"
86
+ if raise_on_error:
87
+ log.error(msg)
88
+ raise
89
+ elif warn:
90
+ log.warning(msg)
91
+ # Remote execution
92
+ else:
93
+ try:
94
+ log.debug(f"Running command on {host}: {' '.join(cmd)}")
95
+ with Connection(host) as c:
96
+ remote_cmd = " ".join(map(str, cmd))
97
+ res = c.run(remote_cmd, hide=hide, warn=not raise_on_error)
98
+ return (res.exited, res.stdout)
99
+ except UnexpectedExit as e:
100
+ msg = f"Command '{cmd}' failed with exit code {e.result.exited}"
101
+ if raise_on_error:
102
+ log.error(msg)
103
+ log.debug(f"stderr: {e.result.stderr}")
104
+ raise
105
+ else:
106
+ log.warning(msg)
107
+
108
+ return (1, None)
109
+
110
+
111
+ def osname(host: str = "localhost") -> str:
112
+ """Get name of operating system.
113
+
114
+ Args:
115
+ host: hostname or IP address of the remote host (default: localhost)
116
+
117
+ Returns:
118
+ Name of the operating system, e.g., darwin or linux
119
+ """
120
+ _, stdout = runcommand(["uname", "-s"], host=host)
121
+ if stdout is not None:
122
+ osname = stdout.strip().lower()
123
+ else:
124
+ osname = "unknown"
125
+ log.debug(f"Operating system: {osname}")
126
+
127
+ return osname
128
+
129
+
130
+ def hostname(host: str = "localhost") -> str:
131
+ """Get name of the host system in network.
132
+
133
+ Args:
134
+ host: hostname or IP address of the remote host (default: localhost)
135
+
136
+ Returns:
137
+ Hostname
138
+ """
139
+ _, stdout = runcommand(["hostname"], host=host)
140
+ if stdout is not None:
141
+ hostname = stdout.strip()
142
+ else:
143
+ hostname = "unknown"
144
+ log.debug(f"Hostname: {hostname}")
145
+ return hostname
146
+
147
+
148
+ def userinfo(host: str = "localhost") -> dict:
149
+ """Get user-specific information from host.
150
+
151
+ Args:
152
+ host: hostname or IP address of the remote host (default: localhost)
153
+
154
+ Returns:
155
+ Dictionary with user information (username, userid, groupname, groupid,
156
+ home)
157
+ """
158
+ _exit_code, stdout = runcommand(["id", "-u"], host=host)
159
+ if stdout is not None:
160
+ uid = stdout.strip()
161
+ else:
162
+ uid = "unknown"
163
+
164
+ _exit_code, stdout = runcommand(["id", "-g"], host=host)
165
+ if stdout is not None:
166
+ gid = stdout.strip()
167
+ else:
168
+ gid = "unknown"
169
+
170
+ _exit_code, stdout = runcommand(["id", "-un"], host=host)
171
+ if stdout is not None:
172
+ uname = stdout.strip()
173
+ else:
174
+ uname = "unknown"
175
+
176
+ _exit_code, stdout = runcommand(["id", "-gn"], host=host)
177
+ if stdout is not None:
178
+ gname = stdout.strip()
179
+ else:
180
+ gname = "unknown"
181
+
182
+ _exit_code, stdout = runcommand(["echo", "$HOME"], host=host)
183
+ if stdout is not None:
184
+ home = stdout.strip()
185
+ else:
186
+ home = "unknown"
187
+
188
+ log.debug(f"User info: {uname} ({uid}), {gname} ({gid}), {home}")
189
+
190
+ return {
191
+ "username": uname,
192
+ "userid": uid,
193
+ "groupname": gname,
194
+ "groupid": gid,
195
+ "home": home,
196
+ }
197
+
198
+
199
+ def run_parallel_on_files(
200
+ func: Callable, flist: List[str], ntask: int, fkwargs: dict | None = None
201
+ ):
202
+ """Run function for all files in a list in parallel.
203
+
204
+ Wrapper function to run a callable for all files in a list in parallel.
205
+ The function uses dask to manage parallel execution. The function is
206
+ optimized to use ntask in parallel, or the number of files, if the number
207
+ is less than the given ntasks. The function is optimized for job running
208
+ independently on each file.
209
+
210
+ Args:
211
+ func: Callable that accepts a filename as first positional argument.
212
+ flist: Iterable of filenames.
213
+ ntask: Maximum number of partitions / parallel workers to use.
214
+ fkwargs: Optional dict of keyword arguments forwarded to `func`.
215
+
216
+ Returns:
217
+ A list with results from all calls (order follows input sequence).
218
+ """
219
+ npartitions = min(len(flist), max(1, int(ntask)))
220
+ log.info(f"Running on {npartitions} partitions in parallel.")
221
+
222
+ # Create dask bag and map the callable. If func_kwargs are given, bind
223
+ # them with functools.partial. The resulting callable receives the
224
+ # filename as the first argument when mapped over the bag.
225
+ b = db.from_sequence(flist, npartitions=npartitions)
226
+ if fkwargs:
227
+ mapped_callable = partial(func, **fkwargs)
228
+ else:
229
+ mapped_callable = func
230
+
231
+ results = b.map(mapped_callable).compute()
232
+ return results
233
+
234
+
235
+ def run_serial_on_files(
236
+ func: Callable, flist: List[str] | List[Path], fkwargs: dict | None = None
237
+ ):
238
+ """Run function for all files in a list in serial.
239
+
240
+ Wrapper function to run a callable for all files in a list in serial.
241
+
242
+ Args:
243
+ func: Callable that accepts a filename as first positional argument.
244
+ flist: Iterable of filenames.
245
+ fkwargs: Optional dict of keyword arguments forwarded to `func`.
246
+
247
+ Returns:
248
+ A list with results from all calls (order follows input sequence).
249
+ """
250
+ results = []
251
+ for filename in flist:
252
+ if fkwargs:
253
+ result = func(filename, **fkwargs)
254
+ else:
255
+ result = func(filename)
256
+ results.append(result)
257
+ return results
258
+
259
+
260
+ def merge_dicts_by_key(
261
+ dicts: list[dict], key: str = "name", threshold: float = 0.85
262
+ ) -> list[dict]:
263
+ """Group and deduplicate a list of dictionaries by fuzzy-matching a key.
264
+
265
+ Uses the standard library's difflib.SequenceMatcher to compute a similarity
266
+ ratio between key values. Dictionaries with a similarity ratio
267
+ greater or equal to `threshold` are considered duplicates and are merged.
268
+
269
+ Args:
270
+ dicts: List of dictionaries to deduplicate.
271
+ key: Key in the dictionaries to compare (default: 'name').
272
+ threshold: Similarity threshold in range [0, 1] (default: 0.85).
273
+
274
+ Returns:
275
+ A list of merged dictionaries (order of first appearance).
276
+ """
277
+ # Prepare name list safely (use .get to avoid KeyError) and normalize inline
278
+ names = [d.get(key, "") if isinstance(d, dict) else "" for d in dicts]
279
+ norm = [str(n).lower().strip() for n in names]
280
+
281
+ result: list[dict] = []
282
+ used = set()
283
+
284
+ # Loop over normalized keys in original order
285
+ for i, n in enumerate(norm):
286
+ if i in used:
287
+ continue
288
+
289
+ # Collect all indices with similar (non-empty) keys
290
+ group = [
291
+ j
292
+ for j in range(len(norm))
293
+ if j not in used
294
+ and norm[j]
295
+ and SequenceMatcher(None, n, norm[j]).ratio() >= threshold
296
+ ]
297
+
298
+ # Merge dictionaries with similar keys, keep first occurrence's values
299
+ # when keys conflict
300
+ merged: dict = {}
301
+ log.debug(f"Merging keys: {', '.join([names[idx] for idx in group])}")
302
+
303
+ for idx in group:
304
+ for k, v in dicts[idx].items():
305
+ merged.setdefault(k, v)
306
+
307
+ result.append(merged)
308
+ used.update(group)
309
+
310
+ return result
311
+
312
+
313
+ def filter_tags_from_file_list(
314
+ flist: list[Path],
315
+ tags: list[str] | None = None,
316
+ tags_or: list[str] | None = None,
317
+ ) -> tuple[list[Path], list[str]]:
318
+ """Filter filelist according to file tags.
319
+
320
+ The file tags are checked with the io.filetags function. The tags
321
+ returned are compared to the tags defined the tags list. If all tags match
322
+ the file is added to the result list. In case no filter types are given,
323
+ all files are returned.
324
+
325
+ Args:
326
+ flist: List of filenames to filter.
327
+ tags: List of tags that must be present.
328
+ tags_or: List of tags where at least one must be present.
329
+
330
+ Returns:
331
+ Two lists, one list of matching files, and a second one with
332
+ corresponding tags.
333
+ """
334
+ results = []
335
+ restags = []
336
+
337
+ log.debug(f"Filtering files by tags: {tags} (AND), {tags_or} (OR)")
338
+
339
+ for f in flist:
340
+ try:
341
+ ftags = filetags(f)
342
+ except FileIdentificationError as e:
343
+ log.warning(f"{e}")
344
+ continue
345
+
346
+ # NO filter condition: add all files
347
+ if not tags and not tags_or:
348
+ results.append(f)
349
+ restags.append(ftags)
350
+ continue
351
+
352
+ # AND condition: all tags must be present
353
+ if tags:
354
+ if set(tags) <= ftags:
355
+ results.append(f)
356
+ restags.append(ftags)
357
+ continue
358
+
359
+ # OR condition: at least one tag must be present
360
+ if tags_or:
361
+ if set(tags_or) & ftags:
362
+ results.append(f)
363
+ restags.append(ftags)
364
+ continue
365
+
366
+ return (results, restags)
367
+
368
+
369
+ def validate_template_variables(template: str, keys: set) -> None:
370
+ """Check if all variables required for rendering a template are present.
371
+
372
+ The function makes sure that all variables required to render the template
373
+ are present as a key in the data dictionary.
374
+
375
+ Args:
376
+ template: template to render with Jinja.
377
+ keys: keys in the data dictionary.
378
+
379
+ Raises:
380
+ KeyError: Lists the missing keys if variables are missing.
381
+ """
382
+ env = Environment()
383
+ required_variables = meta.find_undeclared_variables(env.parse(template))
384
+
385
+ missing = required_variables - keys
386
+ if missing:
387
+ error_msg = (
388
+ f"Missing variables for template: {', '.join(sorted(missing))}"
389
+ )
390
+ raise KeyError(error_msg)
391
+
392
+
393
+ def render_jinja_template(
394
+ tpname: str,
395
+ tpdir: Path,
396
+ tpdata: Dict | DictContainer,
397
+ validate: bool = True,
398
+ ) -> str:
399
+ """Render a given Jinja template.
400
+
401
+ The function looks for a Jinja template in a template directory. The
402
+ template is then rendered with the given data.
403
+
404
+ Args:
405
+ tpname: name of the header template.
406
+ tpdir: directory containing the template.
407
+ tpdata: data to render the template with.
408
+ validate: check the template data before rendering.
409
+
410
+ Returns:
411
+ A str with the rendered header.
412
+ """
413
+ env = Environment(
414
+ loader=FileSystemLoader(tpdir), keep_trailing_newline=True
415
+ )
416
+ template = env.get_template(tpname)
417
+
418
+ if validate:
419
+ source, _, _ = env.loader.get_source(env, tpname)
420
+ validate_template_variables(source, set(tpdata.keys()))
421
+
422
+ log.debug(f"Rendering template '{tpname}' with data: {tpdata}")
423
+ return template.render(tpdata)