Skip to content

Commit 057155b

Browse files
committed
feat: add runnable config dependency
1 parent c52529e commit 057155b

File tree

2 files changed

+110
-12
lines changed

2 files changed

+110
-12
lines changed

src/py_app_dev/core/runnable.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ def get_inputs(self) -> List[Path]:
2929
def get_outputs(self) -> List[Path]:
3030
"""Get runnable outputs."""
3131

32+
def get_config(self) -> Optional[dict[str, str]]:
33+
"""
34+
Get runnable configuration.
35+
36+
(!) Do NOT put sensitive information in the configuration. It will be stored in a file.
37+
"""
38+
return None
39+
3240

3341
class RunInfoStatus(Enum):
3442
MATCH = (False, "Nothing changed. Previous execution info matches.")
@@ -37,6 +45,7 @@ class RunInfoStatus(Enum):
3745
FILE_CHANGED = (True, "File has changed.")
3846
NOTHING_TO_CHECK = (True, "Nothing to be checked. Assume it shall always run.")
3947
FORCED_RUN = (True, "Forced run. Ignore previous execution info.")
48+
CONFIG_CHANGED = (True, "Configuration has changed.")
4049

4150
def __init__(self, should_run: bool, message: str) -> None:
4251
self.should_run = should_run
@@ -84,6 +93,11 @@ def file_hash_to_str(file_hash: Optional[str]) -> str:
8493
"outputs": {str(path): file_hash_to_str(self.get_file_hash(path)) for path in runnable.get_outputs()},
8594
}
8695

96+
# Only store config if the runnable has a config
97+
config = runnable.get_config()
98+
if config is not None:
99+
file_info["config"] = config
100+
87101
run_info_path = self.get_runnable_run_info_file(runnable)
88102
run_info_path.parent.mkdir(parents=True, exist_ok=True)
89103
with run_info_path.open("w") as f:
@@ -103,6 +117,12 @@ def previous_run_info_matches(self, runnable: Runnable) -> RunInfoStatus:
103117
with run_info_path.open() as f:
104118
previous_info = json.load(f)
105119

120+
# Check if configuration has changed
121+
current_config = runnable.get_config()
122+
if "config" in previous_info:
123+
if current_config != previous_info["config"]:
124+
return RunInfoStatus.CONFIG_CHANGED
125+
106126
# Check if there is anything to be checked
107127
if any(len(previous_info[file_type]) for file_type in ["inputs", "outputs"]):
108128
for file_type in ["inputs", "outputs"]:

tests/test_runnable.py

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import os
23
from pathlib import Path
34
from typing import List, Optional
@@ -8,7 +9,13 @@
89

910

1011
class MyRunnable(Runnable):
11-
def __init__(self, inputs: Optional[List[Path]] = None, outputs: Optional[List[Path]] = None, return_code: int = 0, needs_dependency_management: bool = True):
12+
def __init__(
13+
self,
14+
inputs: Optional[List[Path]] = None,
15+
outputs: Optional[List[Path]] = None,
16+
return_code: int = 0,
17+
needs_dependency_management: bool = True,
18+
) -> None:
1219
super().__init__(needs_dependency_management=needs_dependency_management)
1320
self._inputs = inputs if inputs is not None else []
1421
self._outputs = outputs if outputs is not None else []
@@ -28,20 +35,20 @@ def get_outputs(self) -> List[Path]:
2835

2936

3037
@pytest.fixture
31-
def executor(tmp_path):
38+
def executor(tmp_path: Path) -> Executor:
3239
"""Fixture for creating an Executor with a cache directory."""
3340
cache_dir = tmp_path / "cache"
3441
cache_dir.mkdir()
3542
return Executor(cache_dir=cache_dir)
3643

3744

38-
def test_no_previous_info(executor):
45+
def test_no_previous_info(executor: Executor) -> None:
3946
"""Test that Executor correctly detects that a runnable has not been executed before."""
4047
runnable = MyRunnable()
4148
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.NO_INFO
4249

4350

44-
def test_previous_info_matches(executor, tmp_path):
51+
def test_previous_info_matches(executor: Executor, tmp_path: Path) -> None:
4552
"""Test that Executor correctly skips execution when previous info matches."""
4653
input_path = tmp_path / "input.txt"
4754
output_path = tmp_path / "output.txt"
@@ -54,7 +61,7 @@ def test_previous_info_matches(executor, tmp_path):
5461
assert new_executor.previous_run_info_matches(runnable) == RunInfoStatus.FORCED_RUN
5562

5663

57-
def test_file_changed(executor, tmp_path):
64+
def test_file_changed(executor: Executor, tmp_path: Path) -> None:
5865
"""Test that Executor correctly detects when a file has changed."""
5966
input_path = tmp_path / "input.txt"
6067
input_path.write_text("input")
@@ -64,7 +71,7 @@ def test_file_changed(executor, tmp_path):
6471
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.FILE_CHANGED
6572

6673

67-
def test_file_removed(executor, tmp_path):
74+
def test_file_removed(executor: Executor, tmp_path: Path) -> None:
6875
"""Test that Executor correctly detects when a file has been removed."""
6976
output_path = tmp_path / "output.txt"
7077
output_path.write_text("output")
@@ -74,7 +81,7 @@ def test_file_removed(executor, tmp_path):
7481
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.FILE_NOT_FOUND
7582

7683

77-
def test_directory_exists(executor, tmp_path):
84+
def test_directory_exists(executor: Executor, tmp_path: Path) -> None:
7885
"""Test that Executor correctly handles existing directories."""
7986
input_dir = tmp_path / "input_dir"
8087
output_dir = tmp_path / "output_dir"
@@ -85,7 +92,7 @@ def test_directory_exists(executor, tmp_path):
8592
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.MATCH
8693

8794

88-
def test_directory_removed(executor, tmp_path):
95+
def test_directory_removed(executor: Executor, tmp_path: Path) -> None:
8996
"""Test that Executor correctly detects when a directory has been removed."""
9097
input_dir = tmp_path / "input_dir"
9198
output_dir = tmp_path / "output_dir"
@@ -97,7 +104,7 @@ def test_directory_removed(executor, tmp_path):
97104
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.FILE_NOT_FOUND
98105

99106

100-
def test_mixed_files_and_directories(executor, tmp_path):
107+
def test_mixed_files_and_directories(executor: Executor, tmp_path: Path) -> None:
101108
"""Test that Executor correctly handles a mix of files and directories."""
102109
input_file = tmp_path / "input.txt"
103110
input_dir = tmp_path / "input_dir"
@@ -112,14 +119,14 @@ def test_mixed_files_and_directories(executor, tmp_path):
112119
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.MATCH
113120

114121

115-
def test_no_inputs_and_no_outputs(executor):
122+
def test_no_inputs_and_no_outputs(executor: Executor) -> None:
116123
"""Test that Executor correctly handles a runnable with no inputs and no outputs."""
117124
runnable = MyRunnable()
118125
executor.execute(runnable)
119126
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.NOTHING_TO_CHECK
120127

121128

122-
def test_dry_run(executor):
129+
def test_dry_run(executor: Executor) -> None:
123130
"""Test that Executor does not execute the run method when dry_run is True."""
124131
runnable = MyRunnable(return_code=1)
125132
executor.dry_run = True
@@ -128,9 +135,80 @@ def test_dry_run(executor):
128135
assert executor.execute(runnable) == 1
129136

130137

131-
def test_no_dependency_management(executor):
138+
def test_no_dependency_management(executor: Executor) -> None:
132139
"""Test that Executor executes runnables without dependency management directly."""
133140
runnable = MyRunnable(needs_dependency_management=False, return_code=2)
134141
assert executor.execute(runnable) == 2
135142
# Ensure it doesn't store or check run info
136143
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.NO_INFO
144+
145+
146+
def test_config_changed(executor: Executor, tmp_path: Path) -> None:
147+
"""Test that Executor detects when the configuration has changed."""
148+
input_path = tmp_path / "input.txt"
149+
input_path.write_text("input")
150+
151+
class ConfigurableRunnable(MyRunnable):
152+
def __init__(
153+
self,
154+
config: dict[str, str],
155+
inputs: Optional[List[Path]] = None,
156+
) -> None:
157+
super().__init__(inputs=inputs)
158+
self._config = config
159+
160+
def get_config(self) -> Optional[dict[str, str]]:
161+
return self._config
162+
163+
runnable = ConfigurableRunnable(config={"key": "value"}, inputs=[input_path])
164+
executor.execute(runnable)
165+
166+
# Ensure it matches initially
167+
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.MATCH
168+
169+
# Change the configuration
170+
runnable = ConfigurableRunnable(config={"key": "new_value"}, inputs=[input_path])
171+
assert executor.previous_run_info_matches(runnable) == RunInfoStatus.CONFIG_CHANGED
172+
173+
174+
def test_config_stored(executor: Executor, tmp_path: Path) -> None:
175+
"""Test that Executor stores the configuration alongside inputs and outputs."""
176+
input_path = tmp_path / "input.txt"
177+
input_path.write_text("input")
178+
179+
class ConfigurableRunnable(MyRunnable):
180+
def __init__(
181+
self,
182+
config: dict[str, str],
183+
inputs: Optional[List[Path]] = None,
184+
) -> None:
185+
super().__init__(inputs=inputs)
186+
self._config = config
187+
188+
def get_config(self) -> Optional[dict[str, str]]:
189+
return self._config
190+
191+
config = {"key": "value"}
192+
runnable = ConfigurableRunnable(config=config, inputs=[input_path])
193+
executor.execute(runnable)
194+
195+
# Verify the stored run info contains the configuration
196+
run_info_path = executor.get_runnable_run_info_file(runnable)
197+
with run_info_path.open() as f:
198+
run_info = json.load(f)
199+
assert run_info["config"] == config
200+
201+
202+
def test_config_not_stored_if_none(executor: Executor, tmp_path: Path) -> None:
203+
"""Test that Executor does not store a config if the runnable has no config."""
204+
input_path = tmp_path / "input.txt"
205+
input_path.write_text("input")
206+
207+
runnable = MyRunnable(inputs=[input_path])
208+
executor.execute(runnable)
209+
210+
# Verify the stored run info does not contain a config field
211+
run_info_path = executor.get_runnable_run_info_file(runnable)
212+
with run_info_path.open() as f:
213+
run_info = json.load(f)
214+
assert "config" not in run_info

0 commit comments

Comments
 (0)