From 12b8930afe929f8c77946fd548f904f9bd3deb13 Mon Sep 17 00:00:00 2001 From: Alexander Berger Date: Tue, 7 Oct 2025 16:07:12 -0400 Subject: [PATCH 1/5] Implement json style arguments for behaviors in generate-tables command --- src/jabs_postprocess/cli/main.py | 84 ++++++---- src/jabs_postprocess/cli/utils.py | 75 +++++++++ tests/cli/test_generate_tables.py | 72 +++++--- tests/cli/test_load_json.py | 267 ++++++++++++++++++++++++++++++ 4 files changed, 445 insertions(+), 53 deletions(-) create mode 100644 src/jabs_postprocess/cli/utils.py create mode 100644 tests/cli/test_load_json.py diff --git a/src/jabs_postprocess/cli/main.py b/src/jabs_postprocess/cli/main.py index bab92e3..9180a99 100644 --- a/src/jabs_postprocess/cli/main.py +++ b/src/jabs_postprocess/cli/main.py @@ -19,6 +19,7 @@ DEFAULT_MIN_BOUT, DEFAULT_STITCH, ) +from jabs_postprocess.cli.utils import load_json app = typer.Typer() @@ -199,9 +200,6 @@ def generate_tables( help="Folder that contains the project with both pose files and behavior prediction files" ), ], - behavior: Annotated[ - List[str], typer.Option(help="Behavior(s) to produce table(s) for") - ], out_prefix: Annotated[ str, typer.Option( @@ -217,24 +215,6 @@ def generate_tables( help="If features were exported, include feature-based characteristics of bouts" ), ] = None, - interpolate_size: Annotated[ - Optional[int], - typer.Option( - help=f"Maximum number of frames in which missing data will be interpolated (default: {DEFAULT_INTERPOLATE})" - ), - ] = None, - stitch_gap: Annotated[ - Optional[int], - typer.Option( - help=f"Number of frames in which sequential behavior prediction bouts will be joined (default: {DEFAULT_STITCH})" - ), - ] = None, - min_bout_length: Annotated[ - Optional[int], - typer.Option( - help=f"Minimum number of frames in which a behavior prediction must be to be considered (default: {DEFAULT_MIN_BOUT})" - ), - ] = None, overwrite: Annotated[bool, typer.Option(help="Overwrites output files")] = False, add_statistics: Annotated[ bool, @@ -242,6 +222,12 @@ def generate_tables( help="Add bout statistics (count, duration stats, latency) to behavior tables", ), ] = True, + behavior_config: Path | None = typer.Option( + None, "--behavior-config", help="JSON file with behavior configurations" + ), + behaviors: List[str] | None = typer.Option( + None, "--behavior", help="Simple behavior names (uses defaults)" + ), ): """Generate behavior tables from JABS predictions. @@ -258,30 +244,62 @@ def generate_tables( # Convert Path to string feature_folder = feature_folder if feature_folder else None - behaviors = [] - for behavior_name in behavior: - behavior_config = { - "behavior": behavior_name, - "interpolate_size": interpolate_size, - "stitch_gap": stitch_gap, - "min_bout_length": min_bout_length, - } - behaviors.append(behavior_config) + behavior_args = [] + + if behavior_config: + try: + config = load_json(behavior_config) + if not isinstance(config, dict) or "behaviors" not in config: + raise ValueError("Config must be a JSON object with 'behaviors' key") + + for b in config["behaviors"]: + behavior_args.append( + { + "behavior": b["behavior"], + "interpolate_size": b.get( + "interpolate_size", DEFAULT_INTERPOLATE + ), + "stitch_gap": b.get("stitch_gap", DEFAULT_STITCH), + "min_bout_length": b.get("min_bout_length", DEFAULT_MIN_BOUT), + } + ) + except (ValueError, TypeError, KeyError) as e: + typer.echo(f"Error loading behavior config: {e}", err=True) + raise typer.Exit(1) + elif behaviors: + for behavior_name in behaviors: + behavior_args.append( + { + "behavior": behavior_name, + "interpolate_size": DEFAULT_INTERPOLATE, + "stitch_gap": DEFAULT_STITCH, + "min_bout_length": DEFAULT_MIN_BOUT, + } + ) + else: + typer.echo( + "Error: Must provide either --behavior-config or --behavior options", + err=True, + ) + raise typer.Exit(1) results = generate_behavior_tables.process_multiple_behaviors( project_folder=project_folder, - behaviors=behaviors, + behaviors=behavior_args, out_prefix=out_prefix, out_bin_size=out_bin_size, feature_folder=feature_folder, overwrite=overwrite, ) + # Extract behavior names for output + behavior_names = [b["behavior"] for b in behavior_args] + # Add bout statistics if requested if add_statistics: typer.echo("Adding bout statistics to generated tables...") for behavior_name, (bout_file, summary_file) in zip( - behavior, results, strict=True + behavior_names, results, strict=True ): try: # Load bout table and add statistics @@ -294,7 +312,7 @@ def generate_tables( f" Warning: Failed to add statistics to {bout_file}: {str(e)}" ) - for behavior_name, (bout_file, summary_file) in zip(behavior, results, strict=True): + for behavior_name, (bout_file, summary_file) in zip(behavior_names, results, strict=True): typer.echo(f"Generated tables for {behavior_name}:") typer.echo(f" Bout table: {bout_file}") typer.echo(f" Summary table: {summary_file}") diff --git a/src/jabs_postprocess/cli/utils.py b/src/jabs_postprocess/cli/utils.py new file mode 100644 index 0000000..d6bc033 --- /dev/null +++ b/src/jabs_postprocess/cli/utils.py @@ -0,0 +1,75 @@ +import json +from pathlib import Path +from typing import Dict, Any, List + + +def load_json(source: Path | str | None) -> Dict[str, Any] | List[Any] | None: + """Load JSON from a file path, JSON string, or return None. + + Args: + source: Can be: + - Path object pointing to a JSON file + - String containing a file path to a JSON file + - String containing raw JSON content + - None (returns None) + + Returns: + Parsed JSON (dict, list, or other JSON-serializable type), or None if input is None + + Raises: + ValueError: If source cannot be parsed as JSON or loaded from file + TypeError: If source is not a supported type + """ + if source is None: + return None + + if isinstance(source, Path): + # It's a Path object, read from file + try: + with open(source, "r", encoding="utf-8") as f: + return json.load(f) + except FileNotFoundError: + raise ValueError(f"File not found: {source}") + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in file {source}: {e}") + + elif isinstance(source, str): + # It's a string - could be a file path or JSON content + stripped = source.strip() + + # Check if it looks like JSON (starts with {, [, or is a quoted string) + if stripped and stripped[0] in '{["' or stripped in ("true", "false", "null"): + # Try as JSON first + try: + return json.loads(source) + except json.JSONDecodeError: + # If it fails, try as file path + try: + with open(source, "r", encoding="utf-8") as f: + return json.load(f) + except FileNotFoundError: + # Original JSON parse error is more relevant + raise ValueError( + f"Invalid JSON: {source[:100]}{'...' if len(source) > 100 else ''}" + ) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in file {source}: {e}") + else: + # Doesn't look like JSON, try as file path first + try: + with open(source, "r", encoding="utf-8") as f: + return json.load(f) + except FileNotFoundError: + # Last attempt: maybe it's JSON without typical start chars + try: + return json.loads(source) + except json.JSONDecodeError: + raise ValueError( + f"'{source}' is neither a valid file path nor valid JSON" + ) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in file {source}: {e}") + else: + raise TypeError( + f"source must be Path, str, or None, got {type(source).__name__}" + ) diff --git a/tests/cli/test_generate_tables.py b/tests/cli/test_generate_tables.py index 391ab0e..86b6250 100644 --- a/tests/cli/test_generate_tables.py +++ b/tests/cli/test_generate_tables.py @@ -10,12 +10,19 @@ 3. Parameter validation and error handling 4. File output and overwrite behavior 5. Integration with underlying processing modules +6. JSON config file loading for behavior parameters """ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, patch, mock_open import pytest +import json from jabs_postprocess.cli.main import app +from jabs_postprocess.utils.metadata import ( + DEFAULT_INTERPOLATE, + DEFAULT_MIN_BOUT, + DEFAULT_STITCH, +) class TestGenerateTables: @@ -89,11 +96,12 @@ def test_generate_tables_basic( assert call_args.kwargs["out_prefix"] == out_prefix assert len(call_args.kwargs["behaviors"]) == behavior_count + # When using --behavior (simple names), default values should be used for i, behavior_config in enumerate(call_args.kwargs["behaviors"]): assert behavior_config["behavior"] == f"behavior{i}" - assert "interpolate_size" in behavior_config - assert "stitch_gap" in behavior_config - assert "min_bout_length" in behavior_config + assert behavior_config["interpolate_size"] == DEFAULT_INTERPOLATE + assert behavior_config["stitch_gap"] == DEFAULT_STITCH + assert behavior_config["min_bout_length"] == DEFAULT_MIN_BOUT # Verify statistics handling if add_statistics or add_statistics is None: @@ -112,8 +120,10 @@ def test_generate_tables_basic( ], ) @patch("jabs_postprocess.cli.main.generate_behavior_tables") - def test_generate_tables_with_parameters( + @patch("jabs_postprocess.cli.main.load_json") + def test_generate_tables_with_json_config( self, + mock_load_json, mock_generate_module, runner, mock_project_folder, @@ -122,10 +132,12 @@ def test_generate_tables_with_parameters( stitch_gap, min_bout_length, out_bin_size, + tmp_path, ): - """Test table generation with various parameter combinations. + """Test table generation with JSON config file for custom parameters. Args: + mock_load_json: Mock load_json function mock_generate_module: Mock generate_behavior_tables module runner: CLI test runner mock_project_folder: Mock project directory @@ -134,6 +146,7 @@ def test_generate_tables_with_parameters( stitch_gap: Stitch gap parameter min_bout_length: Minimum bout length parameter out_bin_size: Output bin size parameter + tmp_path: Temporary directory for config file """ # Arrange behavior = "test_behavior" @@ -141,25 +154,39 @@ def test_generate_tables_with_parameters( ("bout.csv", "summary.csv") ] + # Create behavior config with optional parameters + behavior_config_data = { + "behaviors": [ + { + "behavior": behavior, + } + ] + } + if interpolate_size is not None: + behavior_config_data["behaviors"][0]["interpolate_size"] = interpolate_size + if stitch_gap is not None: + behavior_config_data["behaviors"][0]["stitch_gap"] = stitch_gap + if min_bout_length is not None: + behavior_config_data["behaviors"][0]["min_bout_length"] = min_bout_length + + # Mock the load_json function + mock_load_json.return_value = behavior_config_data + + # Create config file path + config_file = tmp_path / "behavior_config.json" + cmd_args = [ "generate-tables", "--project-folder", str(mock_project_folder), - "--behavior", - behavior, + "--behavior-config", + str(config_file), "--feature-folder", str(mock_feature_folder), "--out-bin-size", str(out_bin_size), ] - if interpolate_size is not None: - cmd_args.extend(["--interpolate-size", str(interpolate_size)]) - if stitch_gap is not None: - cmd_args.extend(["--stitch-gap", str(stitch_gap)]) - if min_bout_length is not None: - cmd_args.extend(["--min-bout-length", str(min_bout_length)]) - # Act result = runner.invoke(app, cmd_args) @@ -171,9 +198,14 @@ def test_generate_tables_with_parameters( assert call_args.kwargs["out_bin_size"] == out_bin_size behavior_config = call_args.kwargs["behaviors"][0] - assert behavior_config["interpolate_size"] == interpolate_size - assert behavior_config["stitch_gap"] == stitch_gap - assert behavior_config["min_bout_length"] == min_bout_length + # Check that parameters were set correctly (or use defaults if None) + expected_interpolate = interpolate_size if interpolate_size is not None else DEFAULT_INTERPOLATE + expected_stitch = stitch_gap if stitch_gap is not None else DEFAULT_STITCH + expected_min_bout = min_bout_length if min_bout_length is not None else DEFAULT_MIN_BOUT + + assert behavior_config["interpolate_size"] == expected_interpolate + assert behavior_config["stitch_gap"] == expected_stitch + assert behavior_config["min_bout_length"] == expected_min_bout @pytest.mark.parametrize("overwrite", [True, False]) @patch("jabs_postprocess.cli.main.generate_behavior_tables") @@ -333,5 +365,5 @@ def test_generate_tables_no_behaviors( result = runner.invoke(app, cmd_args) # Assert - assert result.exit_code != 0 - assert "Missing option" in result.stdout + assert result.exit_code == 1 + assert "Must provide either --behavior-config or --behavior options" in result.stdout diff --git a/tests/cli/test_load_json.py b/tests/cli/test_load_json.py new file mode 100644 index 0000000..5471db3 --- /dev/null +++ b/tests/cli/test_load_json.py @@ -0,0 +1,267 @@ +import json +import pytest +from pathlib import Path +from unittest.mock import mock_open, patch +from jabs_postprocess.cli.utils import load_json + + +class TestLoadJsonNoneInput: + """Test load_json with None input.""" + + def test_none_returns_none(self): + """None input should return None.""" + assert load_json(None) is None + + +class TestLoadJsonPathInput: + """Test load_json with Path objects.""" + + def test_path_valid_json_dict(self, tmp_path): + """Path to file with valid JSON dict should return dict.""" + json_file = tmp_path / "test.json" + test_data = {"key": "value", "number": 42} + json_file.write_text(json.dumps(test_data)) + + result = load_json(json_file) + assert result == test_data + + def test_path_valid_json_list(self, tmp_path): + """Path to file with valid JSON list should return list.""" + json_file = tmp_path / "test.json" + test_data = [1, 2, 3, "four"] + json_file.write_text(json.dumps(test_data)) + + result = load_json(json_file) + assert result == test_data + + def test_path_nonexistent_file(self, tmp_path): + """Path to non-existent file should raise ValueError.""" + json_file = tmp_path / "nonexistent.json" + + with pytest.raises(ValueError, match="File not found"): + load_json(json_file) + + def test_path_invalid_json(self, tmp_path): + """Path to file with invalid JSON should raise ValueError.""" + json_file = tmp_path / "invalid.json" + json_file.write_text("{ invalid json content") + + with pytest.raises(ValueError, match="Invalid JSON in file"): + load_json(json_file) + + def test_path_empty_file(self, tmp_path): + """Path to empty file should raise ValueError.""" + json_file = tmp_path / "empty.json" + json_file.write_text("") + + with pytest.raises(ValueError, match="Invalid JSON in file"): + load_json(json_file) + + +class TestLoadJsonStringAsJson: + """Test load_json with string containing JSON content.""" + + @pytest.mark.parametrize("json_str,expected", [ + ('{"key": "value"}', {"key": "value"}), + ('{"nested": {"data": [1, 2, 3]}}', {"nested": {"data": [1, 2, 3]}}), + ('[1, 2, 3]', [1, 2, 3]), + ('["a", "b", "c"]', ["a", "b", "c"]), + ('"simple string"', "simple string"), + ('true', True), + ('false', False), + ('null', None), + ('42', 42), + ('3.14', 3.14), + ]) + def test_valid_json_strings(self, json_str, expected): + """Various valid JSON strings should be parsed correctly.""" + result = load_json(json_str) + assert result == expected + + @pytest.mark.parametrize("json_str", [ + ' {"key": "value"} ', # with whitespace + '\n{\n "key": "value"\n}\n', # with newlines + '\t["a", "b"]\t', # with tabs + ]) + def test_json_with_whitespace(self, json_str): + """JSON strings with surrounding whitespace should parse correctly.""" + result = load_json(json_str) + assert result is not None + + def test_complex_nested_json(self): + """Complex nested JSON should parse correctly.""" + json_str = json.dumps({ + "users": [ + {"name": "Alice", "age": 30, "active": True}, + {"name": "Bob", "age": 25, "active": False} + ], + "meta": {"version": 1, "timestamp": None} + }) + result = load_json(json_str) + assert result["users"][0]["name"] == "Alice" + assert result["meta"]["timestamp"] is None + + +class TestLoadJsonStringAsFilePath: + """Test load_json with string as file path.""" + + def test_string_path_to_valid_json(self, tmp_path): + """String path to valid JSON file should work.""" + json_file = tmp_path / "test.json" + test_data = {"from": "file"} + json_file.write_text(json.dumps(test_data)) + + result = load_json(str(json_file)) + assert result == test_data + + def test_string_path_nonexistent(self): + """String that looks like path but file doesn't exist should raise ValueError.""" + # Use a path that doesn't start with JSON chars + with pytest.raises(ValueError, match="neither a valid file path nor valid JSON"): + load_json("nonexistent_file.json") + + def test_string_path_with_invalid_json(self, tmp_path): + """String path to file with invalid JSON should raise ValueError.""" + json_file = tmp_path / "bad.json" + json_file.write_text("not valid json") + + with pytest.raises(ValueError, match="Invalid JSON in file"): + load_json(str(json_file)) + + +class TestLoadJsonAmbiguousCases: + """Test load_json with ambiguous strings that could be JSON or paths.""" + + def test_json_like_string_parsed_as_json_first(self): + """String starting with { should be tried as JSON first.""" + # This looks like JSON and is valid JSON, so it should parse + result = load_json('{"file.json": "value"}') + assert result == {"file.json": "value"} + + def test_invalid_json_fallback_to_file(self, tmp_path): + """Invalid JSON string might fallback to file path.""" + # Create a file with a name that could be confused with JSON + json_file = tmp_path / '{"incomplete"' + test_data = {"actual": "content"} + json_file.write_text(json.dumps(test_data)) + + # This should fail JSON parsing and try as file + with pytest.raises(ValueError): + # The string starts with { so it's tried as JSON first + # When that fails, it tries as file path, which also fails (file doesn't exist) + load_json('{"incomplete"') + + def test_file_path_not_starting_with_json_char(self, tmp_path): + """File path that doesn't look like JSON should try file first.""" + json_file = tmp_path / "data.json" + test_data = {"data": "value"} + json_file.write_text(json.dumps(test_data)) + + result = load_json(str(json_file)) + assert result == test_data + + +class TestLoadJsonErrorCases: + """Test load_json error handling.""" + + def test_invalid_type_int(self): + """Integer input should raise TypeError.""" + with pytest.raises(TypeError, match="source must be Path, str, or None"): + load_json(42) # type: ignore + + def test_invalid_type_list(self): + """List input should raise TypeError.""" + with pytest.raises(TypeError, match="source must be Path, str, or None"): + load_json([1, 2, 3]) # type: ignore + + def test_invalid_type_dict(self): + """Dict input should raise TypeError.""" + with pytest.raises(TypeError, match="source must be Path, str, or None"): + load_json({"key": "value"}) # type: ignore + + def test_invalid_json_string_not_file(self): + """Invalid JSON that's also not a file should raise ValueError.""" + with pytest.raises(ValueError, match="neither a valid file path nor valid JSON"): + load_json("definitely not json or a file path") + + def test_truncated_error_message_for_long_json(self): + """Long invalid JSON should have truncated error message.""" + long_invalid = "{" + "x" * 200 + with pytest.raises(ValueError, match=r"Invalid JSON.*\.\.\.$"): + load_json(long_invalid) + + +class TestLoadJsonEdgeCases: + """Test edge cases and special scenarios.""" + + def test_empty_string(self): + """Empty string should raise ValueError.""" + with pytest.raises(ValueError): + load_json("") + + def test_whitespace_only_string(self): + """Whitespace-only string should raise ValueError.""" + with pytest.raises(ValueError): + load_json(" \n\t ") + + def test_json_with_escaped_quotes(self): + """JSON with escaped quotes should parse correctly.""" + json_str = '{"quote": "He said \\"hello\\""}' + result = load_json(json_str) + assert result["quote"] == 'He said "hello"' + + def test_deeply_nested_json(self): + """Deeply nested JSON should parse correctly.""" + nested = {"level": 1} + current = nested + for i in range(2, 20): + current["nested"] = {"level": i} + current = current["nested"] + + json_str = json.dumps(nested) + result = load_json(json_str) + assert result["level"] == 1 + assert result["nested"]["nested"]["level"] == 3 + + +class TestLoadJsonReturnTypes: + """Test that load_json returns correct types.""" + + def test_returns_dict_type(self): + """Dict JSON should return dict type.""" + result = load_json('{"key": "value"}') + assert isinstance(result, dict) + + def test_returns_list_type(self): + """List JSON should return list type.""" + result = load_json('[1, 2, 3]') + assert isinstance(result, list) + + def test_returns_none_type(self): + """null JSON should return None.""" + result = load_json('null') + assert result is None + + def test_returns_bool_type(self): + """Boolean JSON should return bool.""" + result = load_json('true') + assert isinstance(result, bool) + assert result is True + + def test_returns_int_type(self): + """Integer JSON should return int.""" + result = load_json('42') + assert isinstance(result, int) + assert result == 42 + + def test_returns_float_type(self): + """Float JSON should return float.""" + result = load_json('3.14') + assert isinstance(result, float) + assert result == 3.14 + + def test_returns_string_type(self): + """String JSON should return str.""" + result = load_json('"hello"') + assert isinstance(result, str) + assert result == "hello" \ No newline at end of file From 4365a0ce91efb709189c8d2f4dc7239b20c2251e Mon Sep 17 00:00:00 2001 From: Alexander Berger Date: Tue, 7 Oct 2025 18:34:25 -0400 Subject: [PATCH 2/5] Fix formatting and linting --- tests/cli/test_generate_tables.py | 16 +++++-- tests/cli/test_load_json.py | 78 +++++++++++++++++-------------- 2 files changed, 55 insertions(+), 39 deletions(-) diff --git a/tests/cli/test_generate_tables.py b/tests/cli/test_generate_tables.py index 86b6250..7b31deb 100644 --- a/tests/cli/test_generate_tables.py +++ b/tests/cli/test_generate_tables.py @@ -13,9 +13,8 @@ 6. JSON config file loading for behavior parameters """ -from unittest.mock import MagicMock, patch, mock_open +from unittest.mock import MagicMock, patch import pytest -import json from jabs_postprocess.cli.main import app from jabs_postprocess.utils.metadata import ( @@ -199,9 +198,13 @@ def test_generate_tables_with_json_config( behavior_config = call_args.kwargs["behaviors"][0] # Check that parameters were set correctly (or use defaults if None) - expected_interpolate = interpolate_size if interpolate_size is not None else DEFAULT_INTERPOLATE + expected_interpolate = ( + interpolate_size if interpolate_size is not None else DEFAULT_INTERPOLATE + ) expected_stitch = stitch_gap if stitch_gap is not None else DEFAULT_STITCH - expected_min_bout = min_bout_length if min_bout_length is not None else DEFAULT_MIN_BOUT + expected_min_bout = ( + min_bout_length if min_bout_length is not None else DEFAULT_MIN_BOUT + ) assert behavior_config["interpolate_size"] == expected_interpolate assert behavior_config["stitch_gap"] == expected_stitch @@ -366,4 +369,7 @@ def test_generate_tables_no_behaviors( # Assert assert result.exit_code == 1 - assert "Must provide either --behavior-config or --behavior options" in result.stdout + assert ( + "Must provide either --behavior-config or --behavior options" + in result.stdout + ) diff --git a/tests/cli/test_load_json.py b/tests/cli/test_load_json.py index 5471db3..d3f4a3e 100644 --- a/tests/cli/test_load_json.py +++ b/tests/cli/test_load_json.py @@ -1,7 +1,5 @@ import json import pytest -from pathlib import Path -from unittest.mock import mock_open, patch from jabs_postprocess.cli.utils import load_json @@ -61,28 +59,34 @@ def test_path_empty_file(self, tmp_path): class TestLoadJsonStringAsJson: """Test load_json with string containing JSON content.""" - @pytest.mark.parametrize("json_str,expected", [ - ('{"key": "value"}', {"key": "value"}), - ('{"nested": {"data": [1, 2, 3]}}', {"nested": {"data": [1, 2, 3]}}), - ('[1, 2, 3]', [1, 2, 3]), - ('["a", "b", "c"]', ["a", "b", "c"]), - ('"simple string"', "simple string"), - ('true', True), - ('false', False), - ('null', None), - ('42', 42), - ('3.14', 3.14), - ]) + @pytest.mark.parametrize( + "json_str,expected", + [ + ('{"key": "value"}', {"key": "value"}), + ('{"nested": {"data": [1, 2, 3]}}', {"nested": {"data": [1, 2, 3]}}), + ("[1, 2, 3]", [1, 2, 3]), + ('["a", "b", "c"]', ["a", "b", "c"]), + ('"simple string"', "simple string"), + ("true", True), + ("false", False), + ("null", None), + ("42", 42), + ("3.14", 3.14), + ], + ) def test_valid_json_strings(self, json_str, expected): """Various valid JSON strings should be parsed correctly.""" result = load_json(json_str) assert result == expected - @pytest.mark.parametrize("json_str", [ - ' {"key": "value"} ', # with whitespace - '\n{\n "key": "value"\n}\n', # with newlines - '\t["a", "b"]\t', # with tabs - ]) + @pytest.mark.parametrize( + "json_str", + [ + ' {"key": "value"} ', # with whitespace + '\n{\n "key": "value"\n}\n', # with newlines + '\t["a", "b"]\t', # with tabs + ], + ) def test_json_with_whitespace(self, json_str): """JSON strings with surrounding whitespace should parse correctly.""" result = load_json(json_str) @@ -90,13 +94,15 @@ def test_json_with_whitespace(self, json_str): def test_complex_nested_json(self): """Complex nested JSON should parse correctly.""" - json_str = json.dumps({ - "users": [ - {"name": "Alice", "age": 30, "active": True}, - {"name": "Bob", "age": 25, "active": False} - ], - "meta": {"version": 1, "timestamp": None} - }) + json_str = json.dumps( + { + "users": [ + {"name": "Alice", "age": 30, "active": True}, + {"name": "Bob", "age": 25, "active": False}, + ], + "meta": {"version": 1, "timestamp": None}, + } + ) result = load_json(json_str) assert result["users"][0]["name"] == "Alice" assert result["meta"]["timestamp"] is None @@ -117,7 +123,9 @@ def test_string_path_to_valid_json(self, tmp_path): def test_string_path_nonexistent(self): """String that looks like path but file doesn't exist should raise ValueError.""" # Use a path that doesn't start with JSON chars - with pytest.raises(ValueError, match="neither a valid file path nor valid JSON"): + with pytest.raises( + ValueError, match="neither a valid file path nor valid JSON" + ): load_json("nonexistent_file.json") def test_string_path_with_invalid_json(self, tmp_path): @@ -181,7 +189,9 @@ def test_invalid_type_dict(self): def test_invalid_json_string_not_file(self): """Invalid JSON that's also not a file should raise ValueError.""" - with pytest.raises(ValueError, match="neither a valid file path nor valid JSON"): + with pytest.raises( + ValueError, match="neither a valid file path nor valid JSON" + ): load_json("definitely not json or a file path") def test_truncated_error_message_for_long_json(self): @@ -234,29 +244,29 @@ def test_returns_dict_type(self): def test_returns_list_type(self): """List JSON should return list type.""" - result = load_json('[1, 2, 3]') + result = load_json("[1, 2, 3]") assert isinstance(result, list) def test_returns_none_type(self): """null JSON should return None.""" - result = load_json('null') + result = load_json("null") assert result is None def test_returns_bool_type(self): """Boolean JSON should return bool.""" - result = load_json('true') + result = load_json("true") assert isinstance(result, bool) assert result is True def test_returns_int_type(self): """Integer JSON should return int.""" - result = load_json('42') + result = load_json("42") assert isinstance(result, int) assert result == 42 def test_returns_float_type(self): """Float JSON should return float.""" - result = load_json('3.14') + result = load_json("3.14") assert isinstance(result, float) assert result == 3.14 @@ -264,4 +274,4 @@ def test_returns_string_type(self): """String JSON should return str.""" result = load_json('"hello"') assert isinstance(result, str) - assert result == "hello" \ No newline at end of file + assert result == "hello" From 5f3a7279e04e520d15f98a9d87500e3ba982bea0 Mon Sep 17 00:00:00 2001 From: Alexander Berger Date: Tue, 7 Oct 2025 18:35:54 -0400 Subject: [PATCH 3/5] Fix formatting linting --- src/jabs_postprocess/cli/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jabs_postprocess/cli/main.py b/src/jabs_postprocess/cli/main.py index 9180a99..ddedabd 100644 --- a/src/jabs_postprocess/cli/main.py +++ b/src/jabs_postprocess/cli/main.py @@ -312,7 +312,9 @@ def generate_tables( f" Warning: Failed to add statistics to {bout_file}: {str(e)}" ) - for behavior_name, (bout_file, summary_file) in zip(behavior_names, results, strict=True): + for behavior_name, (bout_file, summary_file) in zip( + behavior_names, results, strict=True + ): typer.echo(f"Generated tables for {behavior_name}:") typer.echo(f" Bout table: {bout_file}") typer.echo(f" Summary table: {summary_file}") From c763173b7d18e941beb0a14ce62d38fb464856cb Mon Sep 17 00:00:00 2001 From: Alexander Berger Date: Tue, 7 Oct 2025 18:53:19 -0400 Subject: [PATCH 4/5] Bump version --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a2e5fd2..9bbce79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "jabs-postprocess" -version = "0.3.1" +version = "0.4.0" description = "A python library for JABS postprocessing utilities." readme = "README.md" license = "LicenseRef-PLATFORM-LICENSE-AGREEMENT-FOR-NON-COMMERCIAL-USE" diff --git a/uv.lock b/uv.lock index b2d9251..414a513 100644 --- a/uv.lock +++ b/uv.lock @@ -340,7 +340,7 @@ wheels = [ [[package]] name = "jabs-postprocess" -version = "0.3.1" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "black" }, From bfec829cba6d870dc2718a731b6f477094f831d7 Mon Sep 17 00:00:00 2001 From: Alexander Berger Date: Wed, 8 Oct 2025 11:33:51 -0400 Subject: [PATCH 5/5] Add example json to docstring for generate_tables command --- src/jabs_postprocess/cli/main.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/jabs_postprocess/cli/main.py b/src/jabs_postprocess/cli/main.py index ddedabd..f85c60e 100644 --- a/src/jabs_postprocess/cli/main.py +++ b/src/jabs_postprocess/cli/main.py @@ -234,6 +234,19 @@ def generate_tables( This command transforms behavior predictions from a JABS project into tabular format, creating both bout-level and summary tables. + Example JSON for behavior_config argument: + { + "behaviors": [ + {"behavior": "Behavior_1_Name", "interpolate_size": 1}, + {"behavior": "Behavior_2_Name", "stitch_gap": 30, "min_bout_length": 150} + {"behavior": "Behavior_2_Name", + "stitch_gap": 30, + "min_bout_length": 150, + "interpolate_size": 1 + } + ] + } + The --add-statistics option adds additional columns with bout-level statistics: - total_bout_count: Number of behavior bouts per animal - avg_bout_duration: Average bout duration per animal