Skip to content

Commit 6c0dedd

Browse files
authored
Atomic improvements and null driver executable (#973)
1 parent 43dbf76 commit 6c0dedd

9 files changed

Lines changed: 115 additions & 30 deletions

File tree

src/uwtools/drivers/driver.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from uwtools.strings import STR
3232
from uwtools.utils.file import writable
3333
from uwtools.utils.processing import run_shell_cmd
34+
from uwtools.utils.tasks import poison
3435

3536
if TYPE_CHECKING:
3637
from datetime import datetime, timedelta
@@ -373,6 +374,9 @@ def run(self):
373374
A run.
374375
"""
375376
yield self.taskname(STR.run)
377+
if self.config[STR.execution][STR.executable] is None: # e.g. from a YAML null
378+
msg = "%s must define 'executable' or implement custom run() method"
379+
yield poison(msg % self.__class__.__name__)
376380
yield self._run_via_batch_submission() if self._batch else self._run_via_local_execution()
377381

378382
@task

src/uwtools/resources/jsonschema/execution-parallel.jsonschema

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
"type": "array"
1212
},
1313
"executable": {
14-
"type": "string"
14+
"type": [
15+
"null",
16+
"string"
17+
]
1518
},
1619
"incantation": {
1720
"type": "string"

src/uwtools/resources/jsonschema/execution-serial.jsonschema

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
"type": "array"
1212
},
1313
"executable": {
14-
"type": "string"
14+
"type": [
15+
"null",
16+
"string"
17+
]
1518
},
1619
"incantation": {
1720
"type": "string"

src/uwtools/tests/drivers/test_driver.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -445,9 +445,11 @@ def test_Driver_run(batch, driverobj, node):
445445
driverobj._batch = batch
446446
executable = Path(driverobj.config["execution"]["executable"])
447447
executable.touch()
448-
with patch.object(driverobj, "_run_via_batch_submission", return_value=node) as rvbs:
449-
with patch.object(driverobj, "_run_via_local_execution", return_value=node) as rvle:
450-
driverobj.run()
448+
with (
449+
patch.object(driverobj, "_run_via_batch_submission", return_value=node) as rvbs,
450+
patch.object(driverobj, "_run_via_local_execution", return_value=node) as rvle,
451+
):
452+
driverobj.run()
451453
if batch:
452454
rvbs.assert_called_once_with()
453455
rvle.assert_not_called()
@@ -456,6 +458,21 @@ def test_Driver_run(batch, driverobj, node):
456458
rvle.assert_called_once_with()
457459

458460

461+
def test_Driver_run__no_executable(driverobj, node, uwcaplog):
462+
# Replace driverobj's run() with Driver's, as if it was never overriden:
463+
driverobj.run = driver.Driver.run.__get__(driverobj)
464+
driverobj._config["execution"]["executable"] = None
465+
with (
466+
patch.object(driverobj, "_run_via_batch_submission", return_value=node) as rvbs,
467+
patch.object(driverobj, "_run_via_local_execution", return_value=node) as rvle,
468+
):
469+
node = driverobj.run()
470+
assert not node.ready
471+
assert "must define 'executable' or implement custom run() method" in uwcaplog.text
472+
rvbs.assert_not_called()
473+
rvle.assert_not_called()
474+
475+
459476
@mark.parametrize(
460477
("arg", "type_"),
461478
[("envcmds", list), ("envvars", dict), ("execution", list), ("scheduler", Slurm)],

src/uwtools/tests/test_schemas.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,14 +1310,16 @@ def test_schema_esg_grid_rundir(esg_grid_prop):
13101310
# execution-parallel
13111311

13121312

1313-
def test_schema_parallel_execution():
1313+
def test_schema_execution_parallel():
13141314
config = {"executable": "fv3"}
13151315
batchargs = {"batchargs": {"queue": "string", "walltime": "string"}}
13161316
mpiargs = {"mpiargs": ["--flag1", "--flag2"]}
13171317
threads = {"threads": 32}
13181318
errors = schema_validator("execution-parallel")
13191319
# Basic correctness:
13201320
assert not errors(config)
1321+
# execution is required:
1322+
assert "'executable' is a required property" in errors({})
13211323
# batchargs may optionally be specified:
13221324
assert not errors({**config, **batchargs})
13231325
# mpiargs may be optionally specified:
@@ -1332,15 +1334,17 @@ def test_schema_parallel_execution():
13321334
)
13331335

13341336

1335-
def test_schema_parallel_execution_executable():
1337+
def test_schema_execution_parallel__executable():
13361338
errors = schema_validator("execution-parallel", "properties", "executable")
13371339
# String value is ok:
13381340
assert not errors("fv3.exe")
1341+
# Null value is ok:
1342+
assert not errors(None)
13391343
# Anything else is not:
1340-
assert "42 is not of type 'string'\n" in errors(42)
1344+
assert "42 is not of type 'null', 'string'\n" in errors(42)
13411345

13421346

1343-
def test_schema_parallel_execution_mpiargs():
1347+
def test_schema_execution_parallel__mpiargs():
13441348
errors = schema_validator("execution-parallel", "properties", "mpiargs")
13451349
# Basic correctness:
13461350
assert not errors(["string1", "string2"])
@@ -1350,7 +1354,7 @@ def test_schema_parallel_execution_mpiargs():
13501354
assert "42 is not of type 'string'\n" in errors(["string1", 42])
13511355

13521356

1353-
def test_schema_parallel_execution_threads():
1357+
def test_schema_execution_parallel__threads():
13541358
errors = schema_validator("execution-parallel", "properties", "threads")
13551359
# threads must be non-negative, and an integer:
13561360
assert not errors(1)
@@ -1368,6 +1372,8 @@ def test_schema_execution_serial():
13681372
errors = schema_validator("execution-serial")
13691373
# Basic correctness:
13701374
assert not errors(config)
1375+
# execution is required:
1376+
assert "'executable' is a required property" in errors({})
13711377
# batchargs may optionally be specified:
13721378
assert not errors({**config, **batchargs})
13731379
# All properties are ok:
@@ -1376,6 +1382,16 @@ def test_schema_execution_serial():
13761382
assert "Additional properties are not allowed" in errors({**config, "foo": "bar"})
13771383

13781384

1385+
def test_schema_execution_serial__executable():
1386+
errors = schema_validator("execution-serial", "properties", "executable")
1387+
# String value is ok:
1388+
assert not errors("fv3.exe")
1389+
# Null value is ok:
1390+
assert not errors(None)
1391+
# Anything else is not:
1392+
assert "42 is not of type 'null', 'string'\n" in errors(42)
1393+
1394+
13791395
# files-to-stage
13801396

13811397

src/uwtools/tests/utils/test_file.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ def test_atomic(tmp_path, uwcaplog):
5757
assert "Atomically renaming %s -> %s" % (tmp, path) in uwcaplog.text
5858

5959

60+
def test_atomic__fail(tmp_path, uwcaplog):
61+
path = tmp_path / "foo"
62+
with file.atomic(path) as tmp:
63+
assert str(tmp).startswith(str(path))
64+
assert not tmp.is_file()
65+
# Avoid creating file.
66+
assert not path.is_file()
67+
assert not tmp.is_file()
68+
assert not path.is_file()
69+
assert "Skipping atomic rename: %s not found" % tmp in uwcaplog.text
70+
71+
6072
@mark.parametrize(
6173
("ext", "file_type"),
6274
{

src/uwtools/tests/utils/test_tasks.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,8 @@ def test_utils_tasks_filecopy__simple(tmp_path):
200200
assert dst.is_file()
201201

202202

203-
def test_utils_tasks_filecopy_hsi(logged, ready_task, tmp_path):
203+
@mark.parametrize("success", [True, False])
204+
def test_utils_tasks_filecopy_hsi(logged, ready_task, success, tmp_path):
204205
src = "/path/to/src"
205206
dst = tmp_path / "dst"
206207
tmp = tmp_path / "tmp"
@@ -210,15 +211,19 @@ def test_utils_tasks_filecopy_hsi(logged, ready_task, tmp_path):
210211
patch.object(tasks, "run_shell_cmd") as run_shell_cmd,
211212
):
212213
atomic.return_value.__enter__.return_value = tmp
213-
run_shell_cmd.side_effect = lambda *_a, **_kw: (dst.touch(), (True, "msg1\nmsg2\n"))[1]
214+
action = lambda: dst.touch() if success else dst.exists()
215+
run_shell_cmd.side_effect = lambda *_a, **_k: (action(), (success, "msg1\nmsg2\n"))[1]
214216
tasks.filecopy_hsi(src=src, dst=Path(dst))
215217
existing_hpss.assert_called_once_with(src)
216218
atomic.assert_called_once_with(dst)
217219
taskname = f"HSI {src} -> {dst}"
218220
run_shell_cmd.assert_called_once_with(f"hsi -q get '{tmp}' : '{src}'", taskname=taskname)
219221
assert logged(f"{taskname}: => msg1")
220222
assert logged(f"{taskname}: => msg2")
221-
assert dst.exists()
223+
if success:
224+
assert dst.exists()
225+
else:
226+
assert not dst.exists()
222227

223228

224229
def test_utils_tasks_filecopy_htar(logged, ready_task, tmp_path):
@@ -320,6 +325,18 @@ def test_utils_tasks_link_target(tmp_path, wrapper):
320325
assert not tasks.link_target(path=tmp_path / "foo").ready
321326

322327

328+
def test_utils_tasks_poison():
329+
node = tasks.poison(taskname="Unfulfilled requirement")
330+
assert not node.ready
331+
assert node.ref is None
332+
333+
334+
def test_utils_tasks__bad_scheme():
335+
with raises(UWConfigError) as e:
336+
tasks._bad_scheme(path="foo://x/y/z", scheme="foo")
337+
assert str(e.value) == "Scheme 'foo' in 'foo://x/y/z' not supported"
338+
339+
323340
def test_utils_tasks__local__path_fail():
324341
path = "foo://bucket/a/b"
325342
with patch.object(tasks, "_bad_scheme") as _bad_scheme:

src/uwtools/utils/file.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,15 @@ def atomic(path: Path) -> Iterator[Path]:
5656
:yieldtype: Path.
5757
"""
5858
path.parent.mkdir(parents=True, exist_ok=True)
59-
with NamedTemporaryFile(dir=path.parent, prefix="%s." % path.name) as ntf:
59+
with NamedTemporaryFile(dir=path.parent, prefix="%s.tmp." % path.name) as ntf:
60+
ntf.close() # also deletes: some callers may balk at an existing file
6061
tmp = Path(ntf.name)
6162
yield tmp
62-
log.debug("Atomically renaming %s -> %s", str(tmp), str(path))
63-
tmp.rename(path)
63+
if tmp.is_file():
64+
log.debug("Atomically renaming %s -> %s", tmp, path)
65+
tmp.rename(path)
66+
else:
67+
log.debug("Skipping atomic rename: %s not found", tmp)
6468

6569

6670
def get_config_format(path: str | Path | None, desc: str | None = None) -> str:

src/uwtools/utils/tasks.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,10 @@ def filecopy_hsi(src: str, dst: Path, check: bool = True):
141141
dst.parent.mkdir(parents=True, exist_ok=True)
142142
with atomic(dst) as tmp:
143143
cmd = f"{STR.hsi} -q get '{tmp}' : '{src}'"
144-
_, output = run_shell_cmd(cmd, taskname=taskname)
144+
success, output = run_shell_cmd(cmd, taskname=taskname)
145+
if not success:
146+
log.error("Failed to copy %s via HSI", src)
147+
tmp.unlink(missing_ok=True)
145148
for line in output.strip().split("\n"):
146149
log.info("%s: => %s", taskname, line)
147150

@@ -245,6 +248,25 @@ def hardlink(
245248
raise UWError("Could not hardlink %s -> %s" % (dst, src)) from e
246249

247250

251+
@external
252+
def link_target(path: Path | str):
253+
"""
254+
An existing file, link, or directory.
255+
256+
:param path: Path to the file, link, or directory.
257+
:param context: Optional additional context for the file.
258+
"""
259+
path = _local_path(path)
260+
yield "Target %s" % path
261+
yield Asset(path, path.exists)
262+
263+
264+
@external
265+
def poison(taskname: str):
266+
yield taskname
267+
yield Asset(None, lambda: False)
268+
269+
248270
@task
249271
def symlink(target: Path | str, linkname: Path | str, check: bool = True):
250272
"""
@@ -264,19 +286,6 @@ def symlink(target: Path | str, linkname: Path | str, check: bool = True):
264286
Path(dst).symlink_to(src)
265287

266288

267-
@external
268-
def link_target(path: Path | str):
269-
"""
270-
An existing file, link, or directory.
271-
272-
:param path: Path to the file, link, or directory.
273-
:param context: Optional additional context for the file.
274-
"""
275-
path = _local_path(path)
276-
yield "Target %s" % path
277-
yield Asset(path, path.exists)
278-
279-
280289
# Private helpers
281290

282291

0 commit comments

Comments
 (0)