Skip to content

Commit eaa2dbd

Browse files
committed
feat: execute runnable if new input files are detected
1 parent 5cbfa6b commit eaa2dbd

File tree

2 files changed

+81
-38
lines changed

2 files changed

+81
-38
lines changed

src/py_app_dev/core/runnable.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class RunInfoStatus(Enum):
4242
NO_INFO = (True, "No previous execution info found.")
4343
FILE_NOT_FOUND = (True, "File not found.")
4444
FILE_CHANGED = (True, "File has changed.")
45+
INPUT_FILES_CHANGED = (True, "Current input files have changed (added or removed).")
4546
NOTHING_TO_CHECK = (True, "Nothing to be checked. Assume it shall always run.")
4647
FORCED_RUN = (True, "Forced run. Ignore previous execution info.")
4748
CONFIG_CHANGED = (True, "Configuration has changed.")
@@ -122,6 +123,12 @@ def previous_run_info_matches(self, runnable: Runnable) -> RunInfoStatus:
122123
if current_config != previous_info["config"]:
123124
return RunInfoStatus.CONFIG_CHANGED
124125

126+
# Check if the list of inputs has changed
127+
current_inputs = {str(path) for path in runnable.get_inputs()}
128+
previous_inputs = set(previous_info.get("inputs", {}).keys())
129+
if current_inputs != previous_inputs:
130+
return RunInfoStatus.INPUT_FILES_CHANGED
131+
125132
# Check if there is anything to be checked
126133
if any(len(previous_info[file_type]) for file_type in ["inputs", "outputs"]):
127134
for file_type in ["inputs", "outputs"]:

tests/test_runnable.py

Lines changed: 74 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,17 @@ def get_outputs(self) -> list[Path]:
3535

3636
@pytest.fixture
3737
def executor(tmp_path: Path) -> Executor:
38-
"""Fixture for creating an Executor with a cache directory."""
3938
cache_dir = tmp_path / "cache"
4039
cache_dir.mkdir()
4140
return Executor(cache_dir=cache_dir)
4241

4342

4443
def test_no_previous_info(executor: Executor) -> None:
45-
"""Test that Executor correctly detects that a runnable has not been executed before."""
4644
runnable = MyRunnable()
4745
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.NO_INFO
4846

4947

5048
def test_previous_info_matches(executor: Executor, tmp_path: Path) -> None:
51-
"""Test that Executor correctly skips execution when previous info matches."""
5249
input_path = tmp_path / "input.txt"
5350
output_path = tmp_path / "output.txt"
5451
input_path.write_text("input")
@@ -61,7 +58,6 @@ def test_previous_info_matches(executor: Executor, tmp_path: Path) -> None:
6158

6259

6360
def test_file_changed(executor: Executor, tmp_path: Path) -> None:
64-
"""Test that Executor correctly detects when a file has changed."""
6561
input_path = tmp_path / "input.txt"
6662
input_path.write_text("input")
6763
runnable = MyRunnable(inputs=[input_path])
@@ -71,7 +67,6 @@ def test_file_changed(executor: Executor, tmp_path: Path) -> None:
7167

7268

7369
def test_file_removed(executor: Executor, tmp_path: Path) -> None:
74-
"""Test that Executor correctly detects when a file has been removed."""
7570
output_path = tmp_path / "output.txt"
7671
output_path.write_text("output")
7772
runnable = MyRunnable(outputs=[output_path])
@@ -81,7 +76,6 @@ def test_file_removed(executor: Executor, tmp_path: Path) -> None:
8176

8277

8378
def test_directory_exists(executor: Executor, tmp_path: Path) -> None:
84-
"""Test that Executor correctly handles existing directories."""
8579
input_dir = tmp_path / "input_dir"
8680
output_dir = tmp_path / "output_dir"
8781
input_dir.mkdir()
@@ -92,7 +86,6 @@ def test_directory_exists(executor: Executor, tmp_path: Path) -> None:
9286

9387

9488
def test_directory_removed(executor: Executor, tmp_path: Path) -> None:
95-
"""Test that Executor correctly detects when a directory has been removed."""
9689
input_dir = tmp_path / "input_dir"
9790
output_dir = tmp_path / "output_dir"
9891
input_dir.mkdir()
@@ -104,7 +97,6 @@ def test_directory_removed(executor: Executor, tmp_path: Path) -> None:
10497

10598

10699
def test_mixed_files_and_directories(executor: Executor, tmp_path: Path) -> None:
107-
"""Test that Executor correctly handles a mix of files and directories."""
108100
input_file = tmp_path / "input.txt"
109101
input_dir = tmp_path / "input_dir"
110102
output_file = tmp_path / "output.txt"
@@ -119,14 +111,12 @@ def test_mixed_files_and_directories(executor: Executor, tmp_path: Path) -> None
119111

120112

121113
def test_no_inputs_and_no_outputs(executor: Executor) -> None:
122-
"""Test that Executor correctly handles a runnable with no inputs and no outputs."""
123114
runnable = MyRunnable()
124115
executor.execute(runnable)
125116
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.NOTHING_TO_CHECK
126117

127118

128119
def test_dry_run(executor: Executor) -> None:
129-
"""Test that Executor does not execute the run method when dry_run is True."""
130120
runnable = MyRunnable(return_code=1)
131121
executor.dry_run = True
132122
assert executor.execute(runnable) == 0
@@ -135,30 +125,29 @@ def test_dry_run(executor: Executor) -> None:
135125

136126

137127
def test_no_dependency_management(executor: Executor) -> None:
138-
"""Test that Executor executes runnables without dependency management directly."""
139128
runnable = MyRunnable(needs_dependency_management=False, return_code=2)
140129
assert executor.execute(runnable) == 2
141130
# Ensure it doesn't store or check run info
142131
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.NO_INFO
143132

144133

134+
class ConfigurableRunnable(MyRunnable):
135+
def __init__(
136+
self,
137+
config: dict[str, str],
138+
inputs: list[Path] | None = None,
139+
) -> None:
140+
super().__init__(inputs=inputs)
141+
self._config = config
142+
143+
def get_config(self) -> dict[str, str] | None:
144+
return self._config
145+
146+
145147
def test_config_changed(executor: Executor, tmp_path: Path) -> None:
146-
"""Test that Executor detects when the configuration has changed."""
147148
input_path = tmp_path / "input.txt"
148149
input_path.write_text("input")
149150

150-
class ConfigurableRunnable(MyRunnable):
151-
def __init__(
152-
self,
153-
config: dict[str, str],
154-
inputs: list[Path] | None = None,
155-
) -> None:
156-
super().__init__(inputs=inputs)
157-
self._config = config
158-
159-
def get_config(self) -> dict[str, str] | None:
160-
return self._config
161-
162151
runnable = ConfigurableRunnable(config={"key": "value"}, inputs=[input_path])
163152
executor.execute(runnable)
164153

@@ -171,22 +160,9 @@ def get_config(self) -> dict[str, str] | None:
171160

172161

173162
def test_config_stored(executor: Executor, tmp_path: Path) -> None:
174-
"""Test that Executor stores the configuration alongside inputs and outputs."""
175163
input_path = tmp_path / "input.txt"
176164
input_path.write_text("input")
177165

178-
class ConfigurableRunnable(MyRunnable):
179-
def __init__(
180-
self,
181-
config: dict[str, str],
182-
inputs: list[Path] | None = None,
183-
) -> None:
184-
super().__init__(inputs=inputs)
185-
self._config = config
186-
187-
def get_config(self) -> dict[str, str] | None:
188-
return self._config
189-
190166
config = {"key": "value"}
191167
runnable = ConfigurableRunnable(config=config, inputs=[input_path])
192168
executor.execute(runnable)
@@ -199,7 +175,6 @@ def get_config(self) -> dict[str, str] | None:
199175

200176

201177
def test_config_not_stored_if_none(executor: Executor, tmp_path: Path) -> None:
202-
"""Test that Executor does not store a config if the runnable has no config."""
203178
input_path = tmp_path / "input.txt"
204179
input_path.write_text("input")
205180

@@ -211,3 +186,64 @@ def test_config_not_stored_if_none(executor: Executor, tmp_path: Path) -> None:
211186
with run_info_path.open() as f:
212187
run_info = json.load(f)
213188
assert "config" not in run_info
189+
190+
191+
class DynamicInputRunnable(MyRunnable):
192+
def __init__(self, input_dir: Path) -> None:
193+
super().__init__()
194+
self.input_dir = input_dir
195+
196+
def get_inputs(self) -> list[Path]:
197+
# Simulates a runnable that parses all .yaml files from a directory
198+
return list(self.input_dir.glob("*.yaml"))
199+
200+
201+
def test_new_input_files_trigger_execution(executor: Executor, tmp_path: Path) -> None:
202+
input_dir = tmp_path / "configs"
203+
input_dir.mkdir()
204+
205+
# Create initial yaml file
206+
yaml_file1 = input_dir / "config1.yaml"
207+
yaml_file1.write_text("config: value1")
208+
209+
runnable = DynamicInputRunnable(input_dir)
210+
211+
# First execution should run (no previous info)
212+
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.NO_INFO
213+
executor.execute(runnable)
214+
215+
# Second execution should be skipped (nothing changed)
216+
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.MATCH
217+
218+
# Create a new yaml file - this should trigger re-execution
219+
yaml_file2 = input_dir / "config2.yaml"
220+
yaml_file2.write_text("config: value2")
221+
222+
# This should detect the new input file and require re-execution
223+
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.INPUT_FILES_CHANGED
224+
225+
226+
def test_removed_input_files_trigger_execution(executor: Executor, tmp_path: Path) -> None:
227+
input_dir = tmp_path / "configs"
228+
input_dir.mkdir()
229+
230+
# Create initial yaml files
231+
yaml_file1 = input_dir / "config1.yaml"
232+
yaml_file1.write_text("config: value1")
233+
yaml_file2 = input_dir / "config2.yaml"
234+
yaml_file2.write_text("config: value2")
235+
236+
runnable = DynamicInputRunnable(input_dir)
237+
238+
# First execution should run (no previous info)
239+
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.NO_INFO
240+
executor.execute(runnable)
241+
242+
# Second execution should be skipped (nothing changed)
243+
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.MATCH
244+
245+
# Remove one yaml file - this should trigger re-execution
246+
yaml_file2.unlink()
247+
248+
# This should detect the missing input file and require re-execution
249+
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.INPUT_FILES_CHANGED

0 commit comments

Comments
 (0)