Shared run function for tracking algorithms#174
Conversation
|
@sjavis I will add a unit test for the run function to the same PR |
sjavis
left a comment
There was a problem hiding this comment.
This looks good. It is clear there were a few complications in putting it into a single function that I hadn't considered.
I have given some suggestions below. Mostly these are about condensing and simplifying the code.
| stdout_lines.append(line) # also collect it | ||
|
|
||
| stdout = "".join(stdout_lines) # ← combine lines into string | ||
| stderr, _ = process.communicate() |
There was a problem hiding this comment.
The order of the return values from process.communicate() is flipped here. stderr should be the second output.
| if not command_list: | ||
| msg = "command_list cannot be empty" | ||
| raise ValueError(msg) |
There was a problem hiding this comment.
This check should probably be with the initial checks at the start of the function. I don't think it should only be applied for verbosity==0.
| print(f"{command_name} completed successfully.") | ||
| print( | ||
| f"First 12 lines of output:\n" | ||
| f"{''.join(stdout.splitlines(True)[:12])}" | ||
| f"\n...\n\n" | ||
| f"Last 12 lines of output:\n" | ||
| f"{''.join(stdout.splitlines(True)[-12:])}" | ||
| ) |
There was a problem hiding this comment.
I think the printing of the output is the only difference between verbosity 0 and 1. So I think the better layout for this function to minimise repetition would be:
if verbosity == 2:
# The full verbosity code since it is different
else:
# Run the subprocess and get the outputs
if verbosity == 1:
# Print the output
| self._set_metadata() | ||
| # ── Level 1: Summary — first and last 12 lines ── | ||
| elif verbosity == 1: | ||
| print(f"Executing {command_name}...") |
There was a problem hiding this comment.
This should also be for verbosity == 2. Perhaps move it up to after the initial checks as long as verbosity isn't 0.
|
|
||
| # Store the global metadata, including the parameters in a json format | ||
| self._global_metadata = { | ||
| "tctrack_version": importlib.metadata.version("tctrack"), | ||
| "tctrack_tracker": type(self).__name__, | ||
| "tctrack_parameters": json.dumps(parameters), | ||
| } | ||
| try: | ||
| # ── Level 0: Silent — no output ── | ||
| if verbosity == 0: | ||
| stdin_file = open(input_file, "r") if input_file is not None else None # noqa: SIM115 | ||
| try: |
There was a problem hiding this comment.
Since stdin_file is defined in all cases (as stdin for verbosity=2) I think it can be moved above the outer try statement. Then only one finally statement is needed and it removes the additional try blocks
| class TestRunTrackerSubprocess: | ||
| """Test suite for run_tracker_subprocess.""" | ||
|
|
||
| @pytest.fixture | ||
| def tracker(self): | ||
| """Create a tracker instance for testing.""" | ||
| detect_params = TEDetectParameters(in_data=["dummy.nc"]) | ||
| return TETracker(detect_params) |
There was a problem hiding this comment.
I suggest removing this class and just use use self.ExampleTracker([]) instead of using the tracker fixture.
| with tempfile.NamedTemporaryFile( | ||
| mode="w", suffix=".txt", delete=False | ||
| ) as f: | ||
| f.write("test\n") | ||
| temp_file = f.name | ||
|
|
||
| try: | ||
| with pytest.raises(ValueError): | ||
| tracker.run_tracker_subprocess( | ||
| command_name="TestCommand", | ||
| command_list=["cat"], | ||
| input_file=temp_file, | ||
| verbosity=5, | ||
| ) | ||
| finally: | ||
| Path(temp_file).unlink() |
There was a problem hiding this comment.
This test can be simplified because it doesn't need the temporary file.
| with tempfile.NamedTemporaryFile( | |
| mode="w", suffix=".txt", delete=False | |
| ) as f: | |
| f.write("test\n") | |
| temp_file = f.name | |
| try: | |
| with pytest.raises(ValueError): | |
| tracker.run_tracker_subprocess( | |
| command_name="TestCommand", | |
| command_list=["cat"], | |
| input_file=temp_file, | |
| verbosity=5, | |
| ) | |
| finally: | |
| Path(temp_file).unlink() | |
| with pytest.raises(ValueError): | |
| tracker.run_tracker_subprocess( | |
| command_name="TestCommand", | |
| command_list=["cat"], | |
| verbosity=5, | |
| ) |
| detect_params = TEDetectParameters(in_data=["dummy.nc"]) | ||
| return TETracker(detect_params) | ||
|
|
||
| def test_run_tracker_subprocess_returns_dict(self, tracker): |
There was a problem hiding this comment.
It would be good to parametrise this to check the output is correct for different verbosity levels. Make sure to also update the value being passed to run_tracker_subprocess.
| def test_run_tracker_subprocess_returns_dict(self, tracker): | |
| @pytest.mark.parametrize("verbosity", [0, 1, 2]) | |
| def test_run_tracker_subprocess_returns_dict(self, verbosity): |
|
|
||
| finally: | ||
| Path(temp_file).unlink() | ||
|
|
There was a problem hiding this comment.
I suggest adding a test that the stderr is captured successfully. Eg:
| @pytest.mark.parametrize("verbosity", [0, 1, 2]) | |
| def test_run_tracker_subprocess_returns_stderr(self, verbosity): | |
| """Test that function returns correct stderr.""" | |
| result = self.ExampleTracker([]).run_tracker_subprocess( | |
| command_name="TestCommand", | |
| command_list=["sh", "-c", 'echo "test error" >&2'], | |
| verbosity=verbosity, | |
| ) | |
| assert isinstance(result, dict), "Should return a dict" | |
| assert "stderr" in result, "Dict should have stderr key" | |
| assert "test error" in result["stderr"], "stderr should contain input" | |
| @@ -0,0 +1,93 @@ | |||
| """Test script for verbosity=0 (silent mode)""" | |||
There was a problem hiding this comment.
As we discussed, this can be moved into the tests folder. Perhaps merge #199 into this branch and then move it there.
There was a problem hiding this comment.
Yes, will add test_run.py to #199 and then merge, just to keep it clean. It was added to this commit by mistake.
The PR aims to build a shared function to run the tracking algorithms. At present, each of the algorithm bindings (
tempestextremes.py, tracker.py, tstorms.py) have their own run functions. These functions are part of the python wrappers that take commands (built using make functions) from c++ based algorithms and pass them to OS usingsubprocess.runto run them. Thus is how we access the external processes.The commands contain path to the binaries of the algorithm related functions. which can be run subprocess.run
Update: