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.
foamlib/__init__.py CHANGED
@@ -1,11 +1,10 @@
1
1
  """A Python interface for interacting with OpenFOAM."""
2
2
 
3
- __version__ = "0.4.4"
3
+ __version__ = "0.5.1"
4
4
 
5
5
  from ._cases import (
6
6
  AsyncFoamCase,
7
7
  CalledProcessError,
8
- CalledProcessWarning,
9
8
  FoamCase,
10
9
  FoamCaseBase,
11
10
  )
@@ -19,5 +18,4 @@ __all__ = [
19
18
  "FoamFieldFile",
20
19
  "FoamFileBase",
21
20
  "CalledProcessError",
22
- "CalledProcessWarning",
23
21
  ]
@@ -1,12 +1,11 @@
1
1
  from ._async import AsyncFoamCase
2
2
  from ._base import FoamCaseBase
3
+ from ._subprocess import CalledProcessError
3
4
  from ._sync import FoamCase
4
- from ._util import CalledProcessError, CalledProcessWarning
5
5
 
6
6
  __all__ = [
7
7
  "FoamCaseBase",
8
8
  "FoamCase",
9
9
  "AsyncFoamCase",
10
10
  "CalledProcessError",
11
- "CalledProcessWarning",
12
11
  ]
foamlib/_cases/_async.py CHANGED
@@ -1,27 +1,39 @@
1
1
  import asyncio
2
2
  import multiprocessing
3
+ import os
3
4
  import sys
5
+ import tempfile
4
6
  from contextlib import asynccontextmanager
5
7
  from pathlib import Path
6
- from typing import (
7
- Callable,
8
- Optional,
9
- Union,
10
- )
8
+ from typing import Callable, Optional, TypeVar, Union
11
9
 
12
10
  if sys.version_info >= (3, 9):
13
- from collections.abc import AsyncGenerator, Collection, Sequence
11
+ from collections.abc import (
12
+ AsyncGenerator,
13
+ Awaitable,
14
+ Collection,
15
+ Iterable,
16
+ Sequence,
17
+ )
14
18
  else:
15
- from typing import AsyncGenerator, Collection, Sequence
19
+ from typing import AsyncGenerator, Awaitable, Collection, Iterable, Sequence
20
+
21
+ if sys.version_info >= (3, 11):
22
+ from typing import Self
23
+ else:
24
+ from typing_extensions import Self
16
25
 
17
26
  import aioshutil
18
27
 
19
- from .._util import is_sequence
20
- from ._base import FoamCaseBase
21
- from ._util import check_returncode
28
+ from ._recipes import _FoamCaseRecipes
29
+ from ._subprocess import run_async
30
+ from ._util import awaitableasynccontextmanager
22
31
 
32
+ X = TypeVar("X")
33
+ Y = TypeVar("Y")
23
34
 
24
- class AsyncFoamCase(FoamCaseBase):
35
+
36
+ class AsyncFoamCase(_FoamCaseRecipes):
25
37
  """
26
38
  An OpenFOAM case with asynchronous support.
27
39
 
@@ -63,17 +75,19 @@ class AsyncFoamCase(FoamCaseBase):
63
75
  AsyncFoamCase._cpus_cond.notify(cpus)
64
76
 
65
77
  @staticmethod
66
- async def _rmtree(path: Path, ignore_errors: bool = False) -> None:
78
+ async def _rmtree(
79
+ path: Union["os.PathLike[str]", str], ignore_errors: bool = False
80
+ ) -> None:
67
81
  await aioshutil.rmtree(path, ignore_errors=ignore_errors) # type: ignore [call-arg]
68
82
 
69
83
  @staticmethod
70
84
  async def _copytree(
71
- src: Path,
72
- dest: Path,
85
+ src: Union["os.PathLike[str]", str],
86
+ dest: Union["os.PathLike[str]", str],
73
87
  *,
74
88
  symlinks: bool = False,
75
89
  ignore: Optional[
76
- Callable[[Union[Path, str], Collection[str]], Collection[str]]
90
+ Callable[[Union["os.PathLike[str]", str], Collection[str]], Collection[str]]
77
91
  ] = None,
78
92
  ) -> None:
79
93
  await aioshutil.copytree(src, dest, symlinks=symlinks, ignore=ignore)
@@ -95,60 +109,54 @@ class AsyncFoamCase(FoamCaseBase):
95
109
 
96
110
  async def _run(
97
111
  self,
98
- cmd: Union[Sequence[Union[str, Path]], str, Path],
112
+ cmd: Union[Sequence[Union[str, "os.PathLike[str]"]], str],
99
113
  *,
100
114
  parallel: bool = False,
101
115
  cpus: int = 1,
102
116
  check: bool = True,
117
+ log: bool = True,
103
118
  ) -> None:
104
- async with self._cpus(cpus):
105
- if not is_sequence(cmd):
106
- if parallel:
107
- cmd = f"mpiexec -np {cpus} {cmd} -parallel"
108
-
109
- proc = await asyncio.create_subprocess_shell(
110
- str(cmd),
119
+ with self._output(cmd, log=log) as (stdout, stderr):
120
+ if parallel:
121
+ if isinstance(cmd, str):
122
+ cmd = [
123
+ "mpiexec",
124
+ "-n",
125
+ str(cpus),
126
+ "/bin/sh",
127
+ "-c",
128
+ f"{cmd} -parallel",
129
+ ]
130
+ else:
131
+ cmd = ["mpiexec", "-n", str(cpus), *cmd, "-parallel"]
132
+
133
+ async with self._cpus(cpus):
134
+ await run_async(
135
+ cmd,
136
+ check=check,
111
137
  cwd=self.path,
112
- env=self._env(shell=True),
113
- stdout=asyncio.subprocess.DEVNULL,
114
- stderr=asyncio.subprocess.PIPE
115
- if check
116
- else asyncio.subprocess.DEVNULL,
138
+ env=self._env(shell=isinstance(cmd, str)),
139
+ stdout=stdout,
140
+ stderr=stderr,
117
141
  )
118
142
 
119
- else:
120
- if parallel:
121
- cmd = ["mpiexec", "-np", str(cpus), *cmd, "-parallel"]
122
-
123
- if sys.version_info < (3, 8):
124
- cmd = (str(arg) for arg in cmd)
125
- proc = await asyncio.create_subprocess_exec(
126
- *cmd,
127
- cwd=self.path,
128
- env=self._env(shell=False),
129
- stdout=asyncio.subprocess.DEVNULL,
130
- stderr=asyncio.subprocess.PIPE
131
- if check
132
- else asyncio.subprocess.DEVNULL,
133
- )
134
-
135
- stdout, stderr = await proc.communicate()
136
-
137
- assert stdout is None
138
- assert proc.returncode is not None
139
-
140
- if check:
141
- check_returncode(proc.returncode, cmd, stderr.decode())
142
-
143
143
  async def run(
144
144
  self,
145
- cmd: Optional[Union[Sequence[Union[str, Path]], str, Path]] = None,
145
+ cmd: Optional[Union[Sequence[Union[str, "os.PathLike[str]"]], str]] = None,
146
146
  *,
147
147
  script: bool = True,
148
148
  parallel: Optional[bool] = None,
149
149
  cpus: Optional[int] = None,
150
150
  check: bool = True,
151
151
  ) -> None:
152
+ """
153
+ Run this case, or a specified command in the context of this case.
154
+
155
+ :param cmd: The command to run. If None, run the case. If a sequence, the first element is the command and the rest are arguments. If a string, `cmd` is executed in a shell.
156
+ :param script: If True and `cmd` is None, use an (All)run(-parallel) script if it exists for running the case. If False or no run script is found, autodetermine the command(s) needed to run the case.
157
+ :param parallel: If True, run in parallel using MPI. If None, autodetect whether to run in parallel.
158
+ :param check: If True, raise a CalledProcessError if any command returns a non-zero exit code.
159
+ """
152
160
  for name, args, kwargs in self._run_cmds(
153
161
  cmd=cmd, script=script, parallel=parallel, cpus=cpus, check=check
154
162
  ):
@@ -171,24 +179,67 @@ class AsyncFoamCase(FoamCaseBase):
171
179
  for name, args, kwargs in self._restore_0_dir_cmds():
172
180
  await getattr(self, name)(*args, **kwargs)
173
181
 
174
- async def copy(self, dest: Union[Path, str]) -> "AsyncFoamCase":
182
+ @awaitableasynccontextmanager
183
+ @asynccontextmanager
184
+ async def copy(
185
+ self, dst: Optional[Union["os.PathLike[str]", str]] = None
186
+ ) -> "AsyncGenerator[Self]":
175
187
  """
176
188
  Make a copy of this case.
177
189
 
178
- :param dest: The destination path.
190
+ Use as an async context manager to automatically delete the copy when done.
191
+
192
+ :param dst: The destination path. If None, copy to a temporary directory.
179
193
  """
180
- for name, args, kwargs in self._copy_cmds(dest):
194
+ if dst is None:
195
+ dst = Path(tempfile.mkdtemp(), self.name)
196
+ tmp = True
197
+ else:
198
+ tmp = False
199
+
200
+ for name, args, kwargs in self._copy_cmds(dst):
181
201
  await getattr(self, name)(*args, **kwargs)
182
202
 
183
- return AsyncFoamCase(dest)
203
+ yield type(self)(dst)
204
+
205
+ if tmp:
206
+ assert isinstance(dst, Path)
207
+ await self._rmtree(dst.parent)
208
+ else:
209
+ await self._rmtree(dst)
184
210
 
185
- async def clone(self, dest: Union[Path, str]) -> "AsyncFoamCase":
211
+ @awaitableasynccontextmanager
212
+ @asynccontextmanager
213
+ async def clone(
214
+ self, dst: Optional[Union["os.PathLike[str]", str]] = None
215
+ ) -> "AsyncGenerator[Self]":
186
216
  """
187
217
  Clone this case (make a clean copy).
188
218
 
189
- :param dest: The destination path.
219
+ Use as an async context manager to automatically delete the clone when done.
220
+
221
+ :param dst: The destination path. If None, clone to a temporary directory.
190
222
  """
191
- for name, args, kwargs in self._clone_cmds(dest):
223
+ if dst is None:
224
+ dst = Path(tempfile.mkdtemp(), self.name)
225
+ tmp = True
226
+ else:
227
+ tmp = False
228
+
229
+ for name, args, kwargs in self._clone_cmds(dst):
192
230
  await getattr(self, name)(*args, **kwargs)
193
231
 
194
- return AsyncFoamCase(dest)
232
+ yield type(self)(dst)
233
+
234
+ if tmp:
235
+ assert isinstance(dst, Path)
236
+ await self._rmtree(dst.parent)
237
+ else:
238
+ await self._rmtree(dst)
239
+
240
+ @staticmethod
241
+ def map(coro: Callable[[X], Awaitable[Y]], iterable: Iterable[X]) -> Iterable[Y]:
242
+ """Run an async function on each element of an iterable concurrently."""
243
+ return asyncio.get_event_loop().run_until_complete(
244
+ asyncio.gather(*(coro(arg) for arg in iterable))
245
+ )
foamlib/_cases/_base.py CHANGED
@@ -3,40 +3,29 @@ import shutil
3
3
  import sys
4
4
  from pathlib import Path
5
5
  from typing import (
6
- Any,
7
6
  Optional,
8
- Tuple,
9
7
  Union,
10
8
  overload,
11
9
  )
12
10
 
13
11
  if sys.version_info >= (3, 9):
14
12
  from collections.abc import (
15
- Callable,
16
- Collection,
17
- Generator,
18
13
  Iterator,
19
- Mapping,
20
14
  Sequence,
21
15
  Set,
22
16
  )
23
17
  else:
24
18
  from typing import AbstractSet as Set
25
19
  from typing import (
26
- Callable,
27
- Collection,
28
- Generator,
29
20
  Iterator,
30
- Mapping,
31
21
  Sequence,
32
22
  )
33
23
 
34
-
35
24
  from .._files import FoamFieldFile, FoamFile
36
25
 
37
26
 
38
27
  class FoamCaseBase(Sequence["FoamCaseBase.TimeDirectory"]):
39
- def __init__(self, path: Union[Path, str, "FoamCaseBase"] = Path()):
28
+ def __init__(self, path: Union["os.PathLike[str]", str] = Path()):
40
29
  self.path = Path(path).absolute()
41
30
 
42
31
  class TimeDirectory(Set[FoamFieldFile]):
@@ -48,7 +37,7 @@ class FoamCaseBase(Sequence["FoamCaseBase.TimeDirectory"]):
48
37
  :param path: The path to the time directory.
49
38
  """
50
39
 
51
- def __init__(self, path: Union[Path, str]):
40
+ def __init__(self, path: Union["os.PathLike[str]", str]):
52
41
  self.path = Path(path).absolute()
53
42
 
54
43
  @property
@@ -141,254 +130,15 @@ class FoamCaseBase(Sequence["FoamCaseBase.TimeDirectory"]):
141
130
  def __len__(self) -> int:
142
131
  return len(self._times)
143
132
 
144
- def _clean_paths(self) -> Set[Path]:
145
- has_decompose_par_dict = (self.path / "system" / "decomposeParDict").is_file()
146
- has_block_mesh_dict = (self.path / "system" / "blockMeshDict").is_file()
147
-
148
- paths = set()
149
-
150
- for p in self.path.iterdir():
151
- if p.is_dir():
152
- try:
153
- t = float(p.name)
154
- except ValueError:
155
- pass
156
- else:
157
- if t != 0:
158
- paths.add(p)
159
-
160
- if has_decompose_par_dict and p.name.startswith("processor"):
161
- paths.add(p)
162
-
163
- if (self.path / "0.orig").is_dir() and (self.path / "0").is_dir():
164
- paths.add(self.path / "0")
165
-
166
- if has_block_mesh_dict and (self.path / "constant" / "polyMesh").exists():
167
- paths.add(self.path / "constant" / "polyMesh")
168
-
169
- if self._run_script() is not None:
170
- paths.update(self.path.glob("log.*"))
171
-
172
- return paths
173
-
174
133
  def __delitem__(self, key: Union[int, float, str]) -> None:
175
134
  shutil.rmtree(self[key].path)
176
135
 
177
- def _clone_ignore(
178
- self,
179
- ) -> Callable[[Union[Path, str], Collection[str]], Collection[str]]:
180
- clean_paths = self._clean_paths()
181
-
182
- def ignore(path: Union[Path, str], names: Collection[str]) -> Collection[str]:
183
- paths = {Path(path) / name for name in names}
184
- return {p.name for p in paths.intersection(clean_paths)}
185
-
186
- return ignore
187
-
188
- def _clean_script(self) -> Optional[Path]:
189
- """Return the path to the (All)clean script, or None if no clean script is found."""
190
- clean = self.path / "clean"
191
- all_clean = self.path / "Allclean"
192
-
193
- if clean.is_file():
194
- script = clean
195
- elif all_clean.is_file():
196
- script = all_clean
197
- else:
198
- return None
199
-
200
- if sys.argv and Path(sys.argv[0]).absolute() == script.absolute():
201
- return None
202
-
203
- return script if Path(sys.argv[0]).absolute() != script.absolute() else None
204
-
205
- def _run_script(self, *, parallel: Optional[bool] = None) -> Optional[Path]:
206
- """Return the path to the (All)run script, or None if no run script is found."""
207
- run = self.path / "run"
208
- run_parallel = self.path / "run-parallel"
209
- all_run = self.path / "Allrun"
210
- all_run_parallel = self.path / "Allrun-parallel"
211
-
212
- if run.is_file() or all_run.is_file():
213
- if run_parallel.is_file() or all_run_parallel.is_file():
214
- if parallel:
215
- script = (
216
- run_parallel if run_parallel.is_file() else all_run_parallel
217
- )
218
- elif parallel is False:
219
- script = run if run.is_file() else all_run
220
- else:
221
- raise ValueError(
222
- "Both (All)run and (All)run-parallel scripts are present. Please specify parallel argument."
223
- )
224
- else:
225
- script = run if run.is_file() else all_run
226
- elif parallel is not False and (
227
- run_parallel.is_file() or all_run_parallel.is_file()
228
- ):
229
- script = run_parallel if run_parallel.is_file() else all_run_parallel
230
- else:
231
- return None
232
-
233
- if sys.argv and Path(sys.argv[0]).absolute() == script.absolute():
234
- return None
235
-
236
- return script
237
-
238
- def _env(self, *, shell: bool) -> Optional[Mapping[str, str]]:
239
- sip_workaround = os.environ.get(
240
- "FOAM_LD_LIBRARY_PATH", ""
241
- ) and not os.environ.get("DYLD_LIBRARY_PATH", "")
242
-
243
- if not shell or sip_workaround:
244
- env = os.environ.copy()
245
-
246
- if not shell:
247
- env["PWD"] = str(self.path)
248
-
249
- if sip_workaround:
250
- env["DYLD_LIBRARY_PATH"] = env["FOAM_LD_LIBRARY_PATH"]
251
-
252
- return env
253
- else:
254
- return None
255
-
256
- def _copy_cmds(
257
- self, dest: Union[Path, str]
258
- ) -> Generator[Tuple[str, Sequence[Any], Mapping[str, Any]], None, None]:
259
- yield (
260
- "_copytree",
261
- (
262
- self.path,
263
- dest,
264
- ),
265
- {"symlinks": True},
266
- )
267
-
268
- def _clean_cmds(
269
- self, *, script: bool = True, check: bool = False
270
- ) -> Generator[Tuple[str, Sequence[Any], Mapping[str, Any]], None, None]:
271
- script_path = self._clean_script() if script else None
272
-
273
- if script_path is not None:
274
- yield ("_run", ([script_path],), {"cpus": 0, "check": check})
275
- else:
276
- for p in self._clean_paths():
277
- if p.is_dir():
278
- yield ("_rmtree", (p,), {})
279
- else:
280
- p.unlink()
281
-
282
- def _clone_cmds(
283
- self, dest: Union[Path, str]
284
- ) -> Generator[Tuple[str, Sequence[Any], Mapping[str, Any]], None, None]:
285
- if self._clean_script() is not None:
286
- yield ("copy", (dest,), {})
287
- yield ("clean", (), {})
288
- else:
289
- yield (
290
- "_copytree",
291
- (
292
- self.path,
293
- dest,
294
- ),
295
- {"symlinks": True, "ignore": self._clone_ignore()},
296
- )
297
-
298
- def _restore_0_dir_cmds(
299
- self,
300
- ) -> Generator[Tuple[str, Sequence[Any], Mapping[str, Any]], None, None]:
301
- yield ("_rmtree", (self.path / "0",), {"ignore_errors": True})
302
- yield (
303
- "_copytree",
304
- (
305
- self.path / "0.orig",
306
- self.path / "0",
307
- ),
308
- {"symlinks": True},
309
- )
310
-
311
- def _run_cmds(
312
- self,
313
- cmd: Optional[Union[Sequence[Union[str, Path]], str, Path]] = None,
314
- *,
315
- script: bool = True,
316
- parallel: Optional[bool] = None,
317
- cpus: Optional[int] = None,
318
- check: bool = True,
319
- ) -> Generator[Tuple[str, Sequence[Any], Mapping[str, Any]], None, None]:
320
- if cmd is not None:
321
- if parallel:
322
- if cpus is None:
323
- cpus = max(self._nprocessors, 1)
324
- else:
325
- parallel = False
326
- if cpus is None:
327
- cpus = 1
328
-
329
- yield ("_run", (cmd,), {"parallel": parallel, "cpus": cpus, "check": check})
330
-
331
- else:
332
- script_path = self._run_script(parallel=parallel) if script else None
333
-
334
- if script_path is not None:
335
- if parallel or parallel is None:
336
- if cpus is None:
337
- if self._nprocessors > 0:
338
- cpus = self._nprocessors
339
- elif (self.path / "system" / "decomposeParDict").is_file():
340
- cpus = self._nsubdomains
341
- else:
342
- cpus = 1
343
- else:
344
- if cpus is None:
345
- cpus = 1
346
-
347
- yield (
348
- "_run",
349
- ([script_path],),
350
- {"parallel": False, "cpus": cpus, "check": check},
351
- )
352
-
353
- else:
354
- if not self and (self.path / "0.orig").is_dir():
355
- yield ("restore_0_dir", (), {})
356
-
357
- if (self.path / "system" / "blockMeshDict").is_file():
358
- yield ("block_mesh", (), {"check": check})
359
-
360
- if parallel is None:
361
- parallel = (
362
- (cpus is not None and cpus > 1)
363
- or self._nprocessors > 0
364
- or (self.path / "system" / "decomposeParDict").is_file()
365
- )
366
-
367
- if parallel:
368
- if (
369
- self._nprocessors == 0
370
- and (self.path / "system" / "decomposeParDict").is_file()
371
- ):
372
- yield ("decompose_par", (), {"check": check})
373
-
374
- if cpus is None:
375
- cpus = max(self._nprocessors, 1)
376
- else:
377
- if cpus is None:
378
- cpus = 1
379
-
380
- yield (
381
- "_run",
382
- ([self.application],),
383
- {"parallel": parallel, "cpus": cpus, "check": check},
384
- )
385
-
386
136
  @property
387
137
  def name(self) -> str:
388
138
  """The name of the case."""
389
139
  return self.path.name
390
140
 
391
- def file(self, path: Union[Path, str]) -> FoamFile:
141
+ def file(self, path: Union["os.PathLike[str]", str]) -> FoamFile:
392
142
  """Return a FoamFile object for the given path in the case."""
393
143
  return FoamFile(self.path / path)
394
144