Skip to content

Commit d0c3615

Browse files
authored
py2fgen: don't recompile if unchanged (#1110)
py2fgen will avoid recompilation (and write of the f90 and py file) if there are no changes. This allows for better integration with tools that detect changes by timestamp. `--regenerate` will force regeneration/compilation.
1 parent 2d5eafc commit d0c3615

File tree

4 files changed

+137
-7
lines changed

4 files changed

+137
-7
lines changed

tools/src/icon4py/tools/py2fgen/_cli.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# SPDX-License-Identifier: BSD-3-Clause
88

99
import pathlib
10+
import sysconfig
1011

1112
import click
1213

@@ -37,12 +38,19 @@
3738
default="",
3839
help="Specify an rpath for the compiled library. If not set, no rpath is added.",
3940
)
41+
@click.option(
42+
"--regenerate",
43+
is_flag=True,
44+
default=False,
45+
help="Force regeneration of all files and recompilation, even if they are up to date.",
46+
)
4047
def main(
4148
module_import_path: str,
4249
functions: list[str],
4350
library_name: str,
4451
output_path: pathlib.Path,
4552
rpath: str,
53+
regenerate: bool,
4654
) -> None:
4755
"""Generate C and F90 wrappers and C library for embedding a Python module in C and Fortran."""
4856
output_path.mkdir(exist_ok=True, parents=True)
@@ -52,15 +60,34 @@ def main(
5260
c_header = _codegen.generate_c_header(plugin)
5361
logger.info("Generating Python wrapper...")
5462
python_wrapper = _codegen.generate_python_wrapper(plugin)
55-
_utils.write_file(python_wrapper, output_path, f"{plugin.library_name}.py")
5663
logger.info("Generating Fortran interface...")
5764
f90_interface = _codegen.generate_f90_interface(plugin)
58-
_utils.write_file(f90_interface, output_path, f"{plugin.library_name}.f90")
5965

60-
logger.info("Compiling CFFI dynamic library...")
61-
_generator.generate_and_compile_cffi_plugin(
62-
plugin.library_name, c_header, python_wrapper, output_path, rpath
63-
)
66+
if regenerate:
67+
logger.info("Force regeneration requested.")
68+
69+
any_changed = False
70+
for content, fname, label in [
71+
(python_wrapper, f"{plugin.library_name}.py", "Python wrapper"),
72+
(f90_interface, f"{plugin.library_name}.f90", "Fortran interface"),
73+
]:
74+
changed = _utils.write_file_if_changed(content, output_path, fname, force=regenerate)
75+
logger.info("%s %s.", label, "changed" if changed else "is up to date")
76+
any_changed |= changed
77+
78+
compilation_outputs_exist = (output_path / f"{plugin.library_name}.h").exists() and (
79+
output_path / f"lib{plugin.library_name}{sysconfig.get_config_var('SHLIB_SUFFIX')}"
80+
).exists()
81+
if not compilation_outputs_exist:
82+
logger.info("Compilation outputs missing.")
83+
84+
if any_changed or not compilation_outputs_exist:
85+
logger.info("Compiling CFFI dynamic library...")
86+
_generator.generate_and_compile_cffi_plugin(
87+
plugin.library_name, c_header, python_wrapper, output_path, rpath
88+
)
89+
else:
90+
logger.info("All generated files are up to date. Skipping compilation.")
6491

6592

6693
if __name__ == "__main__":

tools/src/icon4py/tools/py2fgen/_utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,24 @@ def write_file(string: str, outdir: pathlib.Path, fname: str) -> None:
4343
f.write(string)
4444

4545

46+
def write_file_if_changed(
47+
content: str, outdir: pathlib.Path, fname: str, *, force: bool = False
48+
) -> bool:
49+
"""Write file only if its content differs from what is already on disk.
50+
51+
Args:
52+
force: If True, always write the file regardless of current content.
53+
54+
Returns True if the file was written (content changed, file was new, or force=True),
55+
False if the existing file already has the same content.
56+
"""
57+
path = outdir / fname
58+
if not force and path.exists() and path.read_text() == content:
59+
return False
60+
write_file(content, outdir, fname)
61+
return True
62+
63+
4664
def setup_logger(name: str, log_level: int = logging.INFO) -> logging.Logger:
4765
"""Set up a logger with a given name and log level."""
4866
logger = logging.getLogger(name)

tools/tests/tools/py2fgen/test_cli.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# Please, refer to the LICENSE file in the root directory.
77
# SPDX-License-Identifier: BSD-3-Clause
88

9+
import logging
910
import os
1011
import pathlib
1112
import subprocess
@@ -84,12 +85,15 @@ def run_test_case(
8485
)
8586

8687

87-
def invoke_cli(cli, module, function, library_name):
88+
def invoke_cli(cli, module, function, library_name, extra_args=None):
8889
rpath = utils.get_prefix_lib_path()
8990

9091
cli_args = [module, function, library_name, "-r", rpath]
92+
if extra_args:
93+
cli_args.extend(extra_args)
9194
result = cli.invoke(main, cli_args)
9295
assert result.exit_code == 0, result.output
96+
return result
9397

9498

9599
def compile_and_run_fortran(
@@ -284,3 +288,50 @@ def test_py2fgen_compilation_and_execution_dycore_gpu(cli_runner, samples_path,
284288
("-acc", "-Minfo=acc"),
285289
env_vars={"ICON4PY_BACKEND": "GPU"},
286290
)
291+
292+
293+
def test_py2fgen_incremental_skips_compilation_when_unchanged(
294+
cli_runner, square_wrapper_module, test_temp_dir, caplog
295+
):
296+
"""Test that running py2fgen twice without changes skips compilation on the second run."""
297+
with cli_runner.isolated_filesystem(temp_dir=test_temp_dir):
298+
# First run: should generate and compile
299+
with caplog.at_level(logging.INFO, logger="py2fgen"):
300+
caplog.clear()
301+
invoke_cli(cli_runner, square_wrapper_module, "square_from_function", "square_plugin")
302+
first_log = caplog.text
303+
assert "Compiling CFFI dynamic library" in first_log
304+
305+
# Second run: all files should be up to date, compilation should be skipped
306+
with caplog.at_level(logging.INFO, logger="py2fgen"):
307+
caplog.clear()
308+
invoke_cli(cli_runner, square_wrapper_module, "square_from_function", "square_plugin")
309+
second_log = caplog.text
310+
assert "Python wrapper is up to date" in second_log
311+
assert "Fortran interface is up to date" in second_log
312+
assert "Skipping compilation" in second_log
313+
assert "Compiling CFFI dynamic library" not in second_log
314+
315+
316+
def test_py2fgen_regenerate_forces_recompilation(
317+
cli_runner, square_wrapper_module, test_temp_dir, caplog
318+
):
319+
"""Test that --regenerate forces recompilation even if files are up to date."""
320+
with cli_runner.isolated_filesystem(temp_dir=test_temp_dir):
321+
# First run: generate and compile
322+
invoke_cli(cli_runner, square_wrapper_module, "square_from_function", "square_plugin")
323+
324+
# Second run with --regenerate: should recompile
325+
with caplog.at_level(logging.INFO, logger="py2fgen"):
326+
caplog.clear()
327+
invoke_cli(
328+
cli_runner,
329+
square_wrapper_module,
330+
"square_from_function",
331+
"square_plugin",
332+
extra_args=["--regenerate"],
333+
)
334+
regen_log = caplog.text
335+
assert "Force regeneration requested" in regen_log
336+
assert "Compiling CFFI dynamic library" in regen_log
337+
assert "Skipping compilation" not in regen_log
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# ICON4Py - ICON inspired code in Python and GT4Py
2+
#
3+
# Copyright (c) 2022-2024, ETH Zurich and MeteoSwiss
4+
# All rights reserved.
5+
#
6+
# Please, refer to the LICENSE file in the root directory.
7+
# SPDX-License-Identifier: BSD-3-Clause
8+
9+
import pathlib
10+
11+
from icon4py.tools.py2fgen._utils import write_file_if_changed
12+
13+
14+
def test_write_file_if_changed_creates_new_file(tmp_path: pathlib.Path):
15+
assert write_file_if_changed("hello", tmp_path, "test.txt") is True
16+
assert (tmp_path / "test.txt").read_text() == "hello"
17+
18+
19+
def test_write_file_if_changed_skips_unchanged(tmp_path: pathlib.Path):
20+
write_file_if_changed("hello", tmp_path, "test.txt")
21+
assert write_file_if_changed("hello", tmp_path, "test.txt") is False
22+
assert (tmp_path / "test.txt").read_text() == "hello"
23+
24+
25+
def test_write_file_if_changed_writes_when_changed(tmp_path: pathlib.Path):
26+
write_file_if_changed("hello", tmp_path, "test.txt")
27+
assert write_file_if_changed("world", tmp_path, "test.txt") is True
28+
assert (tmp_path / "test.txt").read_text() == "world"
29+
30+
31+
def test_write_file_if_changed_force_rewrites_unchanged(tmp_path: pathlib.Path):
32+
write_file_if_changed("hello", tmp_path, "test.txt")
33+
assert write_file_if_changed("hello", tmp_path, "test.txt", force=True) is True
34+
assert (tmp_path / "test.txt").read_text() == "hello"

0 commit comments

Comments
 (0)