foamlib 0.4.4__py3-none-any.whl → 0.5.1__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.
@@ -0,0 +1,299 @@
1
+ import os
2
+ import shlex
3
+ import shutil
4
+ import sys
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+ from typing import (
8
+ IO,
9
+ Any,
10
+ Optional,
11
+ Tuple,
12
+ Union,
13
+ )
14
+
15
+ if sys.version_info >= (3, 9):
16
+ from collections.abc import (
17
+ Callable,
18
+ Collection,
19
+ Generator,
20
+ Mapping,
21
+ Sequence,
22
+ Set,
23
+ )
24
+ else:
25
+ from typing import AbstractSet as Set
26
+ from typing import (
27
+ Callable,
28
+ Collection,
29
+ Generator,
30
+ Mapping,
31
+ Sequence,
32
+ )
33
+
34
+ from ._base import FoamCaseBase
35
+ from ._subprocess import DEVNULL, STDOUT
36
+
37
+
38
+ class _FoamCaseRecipes(FoamCaseBase):
39
+ def _clean_paths(self) -> Set[Path]:
40
+ has_decompose_par_dict = (self.path / "system" / "decomposeParDict").is_file()
41
+ has_block_mesh_dict = (self.path / "system" / "blockMeshDict").is_file()
42
+
43
+ paths = set()
44
+
45
+ for p in self.path.iterdir():
46
+ if p.is_dir():
47
+ try:
48
+ t = float(p.name)
49
+ except ValueError:
50
+ pass
51
+ else:
52
+ if t != 0:
53
+ paths.add(p)
54
+
55
+ if has_decompose_par_dict and p.name.startswith("processor"):
56
+ paths.add(p)
57
+
58
+ if (self.path / "0.orig").is_dir() and (self.path / "0").is_dir():
59
+ paths.add(self.path / "0")
60
+
61
+ if has_block_mesh_dict and (self.path / "constant" / "polyMesh").exists():
62
+ paths.add(self.path / "constant" / "polyMesh")
63
+
64
+ if self._run_script() is not None:
65
+ paths.update(self.path.glob("log.*"))
66
+
67
+ return paths
68
+
69
+ def __delitem__(self, key: Union[int, float, str]) -> None:
70
+ shutil.rmtree(self[key].path)
71
+
72
+ def _clone_ignore(
73
+ self,
74
+ ) -> Callable[[Union["os.PathLike[str]", str], Collection[str]], Collection[str]]:
75
+ clean_paths = self._clean_paths()
76
+
77
+ def ignore(
78
+ path: Union["os.PathLike[str]", str], names: Collection[str]
79
+ ) -> Collection[str]:
80
+ paths = {Path(path) / name for name in names}
81
+ return {p.name for p in paths.intersection(clean_paths)}
82
+
83
+ return ignore
84
+
85
+ def _clean_script(self) -> Optional[Path]:
86
+ """Return the path to the (All)clean script, or None if no clean script is found."""
87
+ clean = self.path / "clean"
88
+ all_clean = self.path / "Allclean"
89
+
90
+ if clean.is_file():
91
+ script = clean
92
+ elif all_clean.is_file():
93
+ script = all_clean
94
+ else:
95
+ return None
96
+
97
+ if sys.argv and Path(sys.argv[0]).absolute() == script.absolute():
98
+ return None
99
+
100
+ return script if Path(sys.argv[0]).absolute() != script.absolute() else None
101
+
102
+ def _run_script(self, *, parallel: Optional[bool] = None) -> Optional[Path]:
103
+ """Return the path to the (All)run script, or None if no run script is found."""
104
+ run = self.path / "run"
105
+ run_parallel = self.path / "run-parallel"
106
+ all_run = self.path / "Allrun"
107
+ all_run_parallel = self.path / "Allrun-parallel"
108
+
109
+ if run.is_file() or all_run.is_file():
110
+ if run_parallel.is_file() or all_run_parallel.is_file():
111
+ if parallel:
112
+ script = (
113
+ run_parallel if run_parallel.is_file() else all_run_parallel
114
+ )
115
+ elif parallel is False:
116
+ script = run if run.is_file() else all_run
117
+ else:
118
+ raise ValueError(
119
+ "Both (All)run and (All)run-parallel scripts are present. Please specify parallel argument."
120
+ )
121
+ else:
122
+ script = run if run.is_file() else all_run
123
+ elif parallel is not False and (
124
+ run_parallel.is_file() or all_run_parallel.is_file()
125
+ ):
126
+ script = run_parallel if run_parallel.is_file() else all_run_parallel
127
+ else:
128
+ return None
129
+
130
+ if sys.argv and Path(sys.argv[0]).absolute() == script.absolute():
131
+ return None
132
+
133
+ return script
134
+
135
+ def _env(self, *, shell: bool) -> Optional[Mapping[str, str]]:
136
+ sip_workaround = os.environ.get(
137
+ "FOAM_LD_LIBRARY_PATH", ""
138
+ ) and not os.environ.get("DYLD_LIBRARY_PATH", "")
139
+
140
+ if not shell or sip_workaround:
141
+ env = os.environ.copy()
142
+
143
+ if not shell:
144
+ env["PWD"] = str(self.path)
145
+
146
+ if sip_workaround:
147
+ env["DYLD_LIBRARY_PATH"] = env["FOAM_LD_LIBRARY_PATH"]
148
+
149
+ return env
150
+ else:
151
+ return None
152
+
153
+ @contextmanager
154
+ def _output(
155
+ self, cmd: Union[Sequence[Union[str, "os.PathLike[str]"]], str], *, log: bool
156
+ ) -> Generator[Tuple[Union[int, IO[bytes]], Union[int, IO[bytes]]], None, None]:
157
+ if log:
158
+ if isinstance(cmd, str):
159
+ name = shlex.split(cmd)[0]
160
+ else:
161
+ if isinstance(cmd[0], os.PathLike):
162
+ name = Path(cmd[0]).name
163
+ else:
164
+ name = cmd[0]
165
+
166
+ with (self.path / f"log.{name}").open("ab") as stdout:
167
+ yield stdout, STDOUT
168
+ else:
169
+ yield DEVNULL, DEVNULL
170
+
171
+ def _copy_cmds(
172
+ self, dest: Union["os.PathLike[str]", str]
173
+ ) -> Generator[Tuple[str, Sequence[Any], Mapping[str, Any]], None, None]:
174
+ yield (
175
+ "_copytree",
176
+ (
177
+ self.path,
178
+ dest,
179
+ ),
180
+ {"symlinks": True},
181
+ )
182
+
183
+ def _clean_cmds(
184
+ self, *, script: bool = True, check: bool = False
185
+ ) -> Generator[Tuple[str, Sequence[Any], Mapping[str, Any]], None, None]:
186
+ script_path = self._clean_script() if script else None
187
+
188
+ if script_path is not None:
189
+ yield ("_run", ([script_path],), {"cpus": 0, "check": check, "log": False})
190
+ else:
191
+ for p in self._clean_paths():
192
+ if p.is_dir():
193
+ yield ("_rmtree", (p,), {})
194
+ else:
195
+ p.unlink()
196
+
197
+ def _clone_cmds(
198
+ self, dest: Union["os.PathLike[str]", str]
199
+ ) -> Generator[Tuple[str, Sequence[Any], Mapping[str, Any]], None, None]:
200
+ if self._clean_script() is not None:
201
+ yield ("copy", (dest,), {})
202
+ yield ("clean", (), {})
203
+ else:
204
+ yield (
205
+ "_copytree",
206
+ (
207
+ self.path,
208
+ dest,
209
+ ),
210
+ {"symlinks": True, "ignore": self._clone_ignore()},
211
+ )
212
+
213
+ def _restore_0_dir_cmds(
214
+ self,
215
+ ) -> Generator[Tuple[str, Sequence[Any], Mapping[str, Any]], None, None]:
216
+ yield ("_rmtree", (self.path / "0",), {"ignore_errors": True})
217
+ yield (
218
+ "_copytree",
219
+ (
220
+ self.path / "0.orig",
221
+ self.path / "0",
222
+ ),
223
+ {"symlinks": True},
224
+ )
225
+
226
+ def _run_cmds(
227
+ self,
228
+ cmd: Optional[Union[Sequence[Union[str, "os.PathLike[str]"]], str]] = None,
229
+ *,
230
+ script: bool = True,
231
+ parallel: Optional[bool] = None,
232
+ cpus: Optional[int] = None,
233
+ check: bool = True,
234
+ ) -> Generator[Tuple[str, Sequence[Any], Mapping[str, Any]], None, None]:
235
+ if cmd is not None:
236
+ if parallel:
237
+ if cpus is None:
238
+ cpus = max(self._nprocessors, 1)
239
+ else:
240
+ parallel = False
241
+ if cpus is None:
242
+ cpus = 1
243
+
244
+ yield ("_run", (cmd,), {"parallel": parallel, "cpus": cpus, "check": check})
245
+
246
+ else:
247
+ script_path = self._run_script(parallel=parallel) if script else None
248
+
249
+ if script_path is not None:
250
+ if parallel or parallel is None:
251
+ if cpus is None:
252
+ if self._nprocessors > 0:
253
+ cpus = self._nprocessors
254
+ elif (self.path / "system" / "decomposeParDict").is_file():
255
+ cpus = self._nsubdomains
256
+ else:
257
+ cpus = 1
258
+ else:
259
+ if cpus is None:
260
+ cpus = 1
261
+
262
+ yield (
263
+ "_run",
264
+ ([script_path],),
265
+ {"parallel": False, "cpus": cpus, "check": check},
266
+ )
267
+
268
+ else:
269
+ if not self and (self.path / "0.orig").is_dir():
270
+ yield ("restore_0_dir", (), {})
271
+
272
+ if (self.path / "system" / "blockMeshDict").is_file():
273
+ yield ("block_mesh", (), {"check": check})
274
+
275
+ if parallel is None:
276
+ parallel = (
277
+ (cpus is not None and cpus > 1)
278
+ or self._nprocessors > 0
279
+ or (self.path / "system" / "decomposeParDict").is_file()
280
+ )
281
+
282
+ if parallel:
283
+ if (
284
+ self._nprocessors == 0
285
+ and (self.path / "system" / "decomposeParDict").is_file()
286
+ ):
287
+ yield ("decompose_par", (), {"check": check})
288
+
289
+ if cpus is None:
290
+ cpus = max(self._nprocessors, 1)
291
+ else:
292
+ if cpus is None:
293
+ cpus = 1
294
+
295
+ yield (
296
+ "_run",
297
+ ([self.application],),
298
+ {"parallel": parallel, "cpus": cpus, "check": check},
299
+ )
@@ -0,0 +1,86 @@
1
+ import asyncio
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ from typing import IO, Optional, Union
6
+
7
+ if sys.version_info >= (3, 9):
8
+ from collections.abc import Mapping, Sequence
9
+ else:
10
+ from typing import Mapping, Sequence
11
+
12
+ CalledProcessError = subprocess.CalledProcessError
13
+ CompletedProcess = subprocess.CompletedProcess
14
+
15
+ DEVNULL = subprocess.DEVNULL
16
+ PIPE = subprocess.PIPE
17
+ STDOUT = subprocess.STDOUT
18
+
19
+
20
+ def run_sync(
21
+ cmd: Union[Sequence[Union[str, "os.PathLike[str]"]], str],
22
+ *,
23
+ check: bool = True,
24
+ cwd: Optional["os.PathLike[str]"] = None,
25
+ env: Optional[Mapping[str, str]] = None,
26
+ stdout: Optional[Union[int, IO[bytes]]] = None,
27
+ stderr: Optional[Union[int, IO[bytes]]] = None,
28
+ ) -> "CompletedProcess[bytes]":
29
+ if not isinstance(cmd, str) and sys.version_info < (3, 8):
30
+ cmd = [str(arg) for arg in cmd]
31
+
32
+ return subprocess.run(
33
+ cmd,
34
+ cwd=cwd,
35
+ env=env,
36
+ stdout=stdout,
37
+ stderr=stderr,
38
+ shell=isinstance(cmd, str),
39
+ check=check,
40
+ )
41
+
42
+
43
+ async def run_async(
44
+ cmd: Union[Sequence[Union[str, "os.PathLike[str]"]], str],
45
+ *,
46
+ check: bool = True,
47
+ cwd: Optional["os.PathLike[str]"] = None,
48
+ env: Optional[Mapping[str, str]] = None,
49
+ stdout: Optional[Union[int, IO[bytes]]] = None,
50
+ stderr: Optional[Union[int, IO[bytes]]] = None,
51
+ ) -> "CompletedProcess[bytes]":
52
+ if isinstance(cmd, str):
53
+ proc = await asyncio.create_subprocess_shell(
54
+ cmd,
55
+ cwd=cwd,
56
+ env=env,
57
+ stdout=stdout,
58
+ stderr=stderr,
59
+ )
60
+
61
+ else:
62
+ if sys.version_info < (3, 8):
63
+ cmd = [str(arg) for arg in cmd]
64
+ proc = await asyncio.create_subprocess_exec(
65
+ *cmd,
66
+ cwd=cwd,
67
+ env=env,
68
+ stdout=stdout,
69
+ stderr=stderr,
70
+ )
71
+
72
+ output, error = await proc.communicate()
73
+
74
+ assert proc.returncode is not None
75
+
76
+ if check and proc.returncode != 0:
77
+ raise CalledProcessError(
78
+ returncode=proc.returncode,
79
+ cmd=cmd,
80
+ output=output,
81
+ stderr=error,
82
+ )
83
+
84
+ return CompletedProcess(
85
+ cmd, returncode=proc.returncode, stdout=output, stderr=error
86
+ )
foamlib/_cases/_sync.py CHANGED
@@ -1,10 +1,13 @@
1
+ import os
1
2
  import shutil
2
- import subprocess
3
3
  import sys
4
+ import tempfile
4
5
  from pathlib import Path
6
+ from types import TracebackType
5
7
  from typing import (
6
8
  Callable,
7
9
  Optional,
10
+ Type,
8
11
  Union,
9
12
  )
10
13
 
@@ -13,12 +16,16 @@ if sys.version_info >= (3, 9):
13
16
  else:
14
17
  from typing import Collection, Sequence
15
18
 
16
- from .._util import is_sequence
17
- from ._base import FoamCaseBase
18
- from ._util import check_returncode
19
+ if sys.version_info >= (3, 11):
20
+ from typing import Self
21
+ else:
22
+ from typing_extensions import Self
23
+
24
+ from ._recipes import _FoamCaseRecipes
25
+ from ._subprocess import run_sync
19
26
 
20
27
 
21
- class FoamCase(FoamCaseBase):
28
+ class FoamCase(_FoamCaseRecipes):
22
29
  """
23
30
  An OpenFOAM case.
24
31
 
@@ -29,22 +36,51 @@ class FoamCase(FoamCaseBase):
29
36
  :param path: The path to the case directory.
30
37
  """
31
38
 
39
+ def __init__(self, path: Union["os.PathLike[str]", str] = Path()):
40
+ super().__init__(path)
41
+ self._tmp: Optional[bool] = None
42
+
32
43
  @staticmethod
33
- def _rmtree(path: Path, *, ignore_errors: bool = False) -> None:
44
+ def _rmtree(
45
+ path: Union["os.PathLike[str]", str], *, ignore_errors: bool = False
46
+ ) -> None:
34
47
  shutil.rmtree(path, ignore_errors=ignore_errors)
35
48
 
36
49
  @staticmethod
37
50
  def _copytree(
38
- src: Path,
39
- dest: Path,
51
+ src: Union["os.PathLike[str]", str],
52
+ dest: Union["os.PathLike[str]", str],
40
53
  *,
41
54
  symlinks: bool = False,
42
55
  ignore: Optional[
43
- Callable[[Union[Path, str], Collection[str]], Collection[str]]
56
+ Callable[[Union["os.PathLike[str]", str], Collection[str]], Collection[str]]
44
57
  ] = None,
45
58
  ) -> None:
46
59
  shutil.copytree(src, dest, symlinks=symlinks, ignore=ignore)
47
60
 
61
+ def __enter__(self) -> "FoamCase":
62
+ if self._tmp is None:
63
+ raise RuntimeError(
64
+ "Cannot use a non-copied/cloned case as a context manager"
65
+ )
66
+ return self
67
+
68
+ def __exit__(
69
+ self,
70
+ exc_type: Optional[Type[BaseException]],
71
+ exc_val: Optional[BaseException],
72
+ exc_tb: Optional[TracebackType],
73
+ ) -> None:
74
+ if self._tmp is not None:
75
+ if self._tmp:
76
+ self._rmtree(self.path.parent)
77
+ else:
78
+ self._rmtree(self.path)
79
+ else:
80
+ raise RuntimeError(
81
+ "Cannot use a non-copied/cloned case as a context manager"
82
+ )
83
+
48
84
  def clean(
49
85
  self,
50
86
  *,
@@ -62,43 +98,39 @@ class FoamCase(FoamCaseBase):
62
98
 
63
99
  def _run(
64
100
  self,
65
- cmd: Union[Sequence[Union[str, Path]], str, Path],
101
+ cmd: Union[Sequence[Union[str, "os.PathLike[str]"]], str],
66
102
  *,
67
103
  parallel: bool = False,
68
104
  cpus: int = 1,
69
105
  check: bool = True,
106
+ log: bool = True,
70
107
  ) -> None:
71
- shell = not is_sequence(cmd)
72
-
73
- if parallel:
74
- if shell:
75
- cmd = f"mpiexec -np {cpus} {cmd} -parallel"
76
- else:
77
- assert is_sequence(cmd)
78
- cmd = ["mpiexec", "-np", str(cpus), *cmd, "-parallel"]
79
-
80
- if sys.version_info < (3, 8):
81
- if shell:
82
- cmd = str(cmd)
83
- else:
84
- cmd = (str(arg) for arg in cmd)
85
-
86
- proc = subprocess.run(
87
- cmd,
88
- cwd=self.path,
89
- env=self._env(shell=shell),
90
- stdout=subprocess.DEVNULL,
91
- stderr=subprocess.PIPE if check else subprocess.DEVNULL,
92
- text=True,
93
- shell=shell,
94
- )
95
-
96
- if check:
97
- check_returncode(proc.returncode, cmd, proc.stderr)
108
+ with self._output(cmd, log=log) as (stdout, stderr):
109
+ if parallel:
110
+ if isinstance(cmd, str):
111
+ cmd = [
112
+ "mpiexec",
113
+ "-n",
114
+ str(cpus),
115
+ "/bin/sh",
116
+ "-c",
117
+ f"{cmd} -parallel",
118
+ ]
119
+ else:
120
+ cmd = ["mpiexec", "-n", str(cpus), *cmd, "-parallel"]
121
+
122
+ run_sync(
123
+ cmd,
124
+ check=check,
125
+ cwd=self.path,
126
+ env=self._env(shell=isinstance(cmd, str)),
127
+ stdout=stdout,
128
+ stderr=stderr,
129
+ )
98
130
 
99
131
  def run(
100
132
  self,
101
- cmd: Optional[Union[Sequence[Union[str, Path]], str, Path]] = None,
133
+ cmd: Optional[Union[Sequence[Union[str, "os.PathLike[str]"]], str]] = None,
102
134
  *,
103
135
  script: bool = True,
104
136
  parallel: Optional[bool] = None,
@@ -134,24 +166,46 @@ class FoamCase(FoamCaseBase):
134
166
  for name, args, kwargs in self._restore_0_dir_cmds():
135
167
  getattr(self, name)(*args, **kwargs)
136
168
 
137
- def copy(self, dest: Union[Path, str]) -> "FoamCase":
169
+ def copy(self, dst: Optional[Union["os.PathLike[str]", str]] = None) -> "Self":
138
170
  """
139
171
  Make a copy of this case.
140
172
 
141
- :param dest: The destination path.
173
+ Use as a context manager to automatically delete the copy when done.
174
+
175
+ :param dst: The destination path. If None, copy to a temporary directory.
142
176
  """
143
- for name, args, kwargs in self._copy_cmds(dest):
177
+ if dst is None:
178
+ dst = Path(tempfile.mkdtemp(), self.name)
179
+ tmp = True
180
+ else:
181
+ tmp = False
182
+
183
+ for name, args, kwargs in self._copy_cmds(dst):
144
184
  getattr(self, name)(*args, **kwargs)
145
185
 
146
- return FoamCase(dest)
186
+ ret = type(self)(dst)
187
+ ret._tmp = tmp
188
+
189
+ return ret
147
190
 
148
- def clone(self, dest: Union[Path, str]) -> "FoamCase":
191
+ def clone(self, dst: Optional[Union["os.PathLike[str]", str]] = None) -> "Self":
149
192
  """
150
193
  Clone this case (make a clean copy).
151
194
 
152
- :param dest: The destination path.
195
+ Use as a context manager to automatically delete the clone when done.
196
+
197
+ :param dst: The destination path. If None, clone to a temporary directory.
153
198
  """
154
- for name, args, kwargs in self._clone_cmds(dest):
199
+ if dst is None:
200
+ dst = Path(tempfile.mkdtemp(), self.name)
201
+ tmp = True
202
+ else:
203
+ tmp = False
204
+
205
+ for name, args, kwargs in self._clone_cmds(dst):
155
206
  getattr(self, name)(*args, **kwargs)
156
207
 
157
- return FoamCase(dest)
208
+ ret = type(self)(dst)
209
+ ret._tmp = tmp
210
+
211
+ return ret
foamlib/_cases/_util.py CHANGED
@@ -1,35 +1,49 @@
1
- import subprocess
1
+ import functools
2
2
  import sys
3
- from pathlib import Path
4
- from typing import Optional, Union
5
- from warnings import warn
3
+ from types import TracebackType
4
+ from typing import (
5
+ Any,
6
+ AsyncContextManager,
7
+ Callable,
8
+ Generic,
9
+ Optional,
10
+ Type,
11
+ TypeVar,
12
+ )
6
13
 
7
14
  if sys.version_info >= (3, 9):
8
- from collections.abc import Sequence
15
+ from collections.abc import Generator
9
16
  else:
10
- from typing import Sequence
17
+ from typing import Generator
11
18
 
12
19
 
13
- class CalledProcessError(subprocess.CalledProcessError):
14
- """Exception raised when a process fails and `check=True`."""
20
+ R = TypeVar("R")
15
21
 
16
- def __str__(self) -> str:
17
- msg = super().__str__()
18
- if self.stderr:
19
- msg += f"\n{self.stderr}"
20
- return msg
21
22
 
23
+ class _AwaitableAsyncContextManager(Generic[R]):
24
+ def __init__(self, cm: "AsyncContextManager[R]"):
25
+ self._cm = cm
22
26
 
23
- class CalledProcessWarning(Warning):
24
- """Warning raised when a process prints to stderr and `check=True`."""
27
+ def __await__(self) -> Generator[Any, Any, R]:
28
+ return self._cm.__aenter__().__await__()
25
29
 
30
+ async def __aenter__(self) -> R:
31
+ return await self._cm.__aenter__()
26
32
 
27
- def check_returncode(
28
- retcode: int,
29
- cmd: Union[Sequence[Union[str, Path]], str, Path],
30
- stderr: Optional[str],
31
- ) -> None:
32
- if retcode != 0:
33
- raise CalledProcessError(retcode, cmd, None, stderr)
34
- elif stderr:
35
- warn(f"Command {cmd} printed to stderr.\n{stderr}", CalledProcessWarning)
33
+ async def __aexit__(
34
+ self,
35
+ exc_type: Optional[Type[BaseException]],
36
+ exc_val: Optional[BaseException],
37
+ exc_tb: Optional[TracebackType],
38
+ ) -> Optional[bool]:
39
+ return await self._cm.__aexit__(exc_type, exc_val, exc_tb)
40
+
41
+
42
+ def awaitableasynccontextmanager(
43
+ cm: Callable[..., "AsyncContextManager[R]"],
44
+ ) -> Callable[..., _AwaitableAsyncContextManager[R]]:
45
+ @functools.wraps(cm)
46
+ def f(*args: Any, **kwargs: Any) -> _AwaitableAsyncContextManager[R]:
47
+ return _AwaitableAsyncContextManager(cm(*args, **kwargs))
48
+
49
+ return f