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.
- multiphasepy/__init__.py +30 -0
- multiphasepy/assets/header_templates/c++_foundation.jinja +30 -0
- multiphasepy/assets/header_templates/c++_hzdr.jinja +46 -0
- multiphasepy/assets/header_templates/config_openfoam_foundation.jinja +17 -0
- multiphasepy/assets/header_templates/config_openfoam_hzdr.jinja +17 -0
- multiphasepy/assets/header_templates/shell_foundation.jinja +35 -0
- multiphasepy/assets/header_templates/shell_hzdr.jinja +50 -0
- multiphasepy/auxiliary.py +423 -0
- multiphasepy/case.py +680 -0
- multiphasepy/cfd/__init__.py +11 -0
- multiphasepy/cfd/foam.py +309 -0
- multiphasepy/cfd/foamlexer.py +598 -0
- multiphasepy/cli/__init__.py +15 -0
- multiphasepy/cli/callbacks.py +185 -0
- multiphasepy/cli/cliutils.py +138 -0
- multiphasepy/cli/convert.py +72 -0
- multiphasepy/cli/copy.py +90 -0
- multiphasepy/cli/decorators.py +353 -0
- multiphasepy/cli/docker.py +264 -0
- multiphasepy/cli/fuzzy.py +131 -0
- multiphasepy/cli/hooks.py +326 -0
- multiphasepy/cli/identify.py +106 -0
- multiphasepy/cli/post.py +219 -0
- multiphasepy/cli/publish.py +164 -0
- multiphasepy/cli/rpcmp.py +82 -0
- multiphasepy/cli/rpdiff.py +86 -0
- multiphasepy/cli/shrun.py +76 -0
- multiphasepy/cli/test.py +91 -0
- multiphasepy/cli/visualize.py +210 -0
- multiphasepy/cli/watch.py +366 -0
- multiphasepy/cli/workflow.py +154 -0
- multiphasepy/exceptions.py +115 -0
- multiphasepy/fuzzy.py +411 -0
- multiphasepy/hooks/__init__.py +30 -0
- multiphasepy/hooks/copyright.py +523 -0
- multiphasepy/hooks/header_ifndef.py +59 -0
- multiphasepy/hooks/keywords.py +128 -0
- multiphasepy/hooks/line_length.py +32 -0
- multiphasepy/hooks/nonstandardcode.py +44 -0
- multiphasepy/hooks/preview_encoding.py +41 -0
- multiphasepy/hooks/tabs.py +36 -0
- multiphasepy/io.py +656 -0
- multiphasepy/logutils.py +21 -0
- multiphasepy/metrics.py +93 -0
- multiphasepy/oci/__init__.py +32 -0
- multiphasepy/oci/apptainer.py +491 -0
- multiphasepy/oci/docker.py +706 -0
- multiphasepy/oci/oci.py +515 -0
- multiphasepy/post.py +398 -0
- multiphasepy/rodare.py +563 -0
- multiphasepy/rpcmp.py +285 -0
- multiphasepy/stream.py +92 -0
- multiphasepy/testing.py +44 -0
- multiphasepy/visualization.py +457 -0
- multiphasepy/workflow.py +333 -0
- multiphasepy-4.0.0.dist-info/METADATA +172 -0
- multiphasepy-4.0.0.dist-info/RECORD +59 -0
- multiphasepy-4.0.0.dist-info/WHEEL +4 -0
- multiphasepy-4.0.0.dist-info/entry_points.txt +16 -0
multiphasepy/__init__.py
ADDED
|
@@ -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)
|