Skip to content

refactor: CLI subcommand structure#59

Open
Deepak8858 wants to merge 1 commit intosystemblueteam:mainfrom
Deepak8858:fix/issue-54-subcommand-structure
Open

refactor: CLI subcommand structure#59
Deepak8858 wants to merge 1 commit intosystemblueteam:mainfrom
Deepak8858:fix/issue-54-subcommand-structure

Conversation

@Deepak8858
Copy link
Copy Markdown

Refactor cli.py to use a Click group with compare and inspect subcommands.

Changes:

  • Converted single command to @click.group.
  • Added compare subcommand for audio comparison.
  • Added inspect subcommand for single-file metadata analysis.
  • Added legacy support for sounddiff FILE_A FILE_B using ctx.invoke.

Fixes #54

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

📝 Walkthrough

Walkthrough

Refactored the CLI from a single Click command to a Click group with subcommands. The compare subcommand replaces the original top-level command, with legacy support preserving the sounddiff <file_a> <file_b> syntax. Added a new inspect subcommand for viewing audio file metadata.

Changes

Cohort / File(s) Summary
CLI Structure Refactoring
src/sounddiff/cli.py
Replaced click.command() with click.group() named main; renamed original command to compare subcommand; added legacy argument parsing for backward compatibility; added new inspect subcommand that loads audio files and displays metadata (duration, sample rate, channels, bit depth, format); added module-level entrypoint.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 Hoppy refactoring, commands now grouped,
Compare and inspect, the CLI's regrouped,
Legacy paths still hop along true,
New metadata views, old ways breaking through!

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'refactor: CLI subcommand structure' clearly and concisely summarizes the main change of converting the single CLI command to a Click group with subcommands.
Description check ✅ Passed The description is well-related to the changeset, detailing the conversion to Click group, addition of subcommands, and legacy support implementation.
Linked Issues check ✅ Passed The PR addresses all requirements from issue #54: converts to Click group with subcommands, makes compare the primary subcommand, preserves backward compatibility via legacy support, and implements the inspect subcommand.
Out of Scope Changes check ✅ Passed All changes in the PR are directly related to the CLI refactoring objectives. No out-of-scope modifications to unrelated functionality were introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment on lines +35 to +38
# Filter out flags/options
pos_args = [a for a in args if not a.startswith("-")]
if len(pos_args) == 2:
ctx.invoke(compare, file_a=pos_args[0], file_b=pos_args[1])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The legacy argument parser incorrectly includes option values (e.g., json from --format json) as positional arguments, causing the command to fail when options are used.
Severity: MEDIUM

Suggested Fix

To fix the legacy argument parsing, use a proper argument parser like argparse.ArgumentParser with parse_known_args(). This will correctly separate known options and their values from the positional arguments, ensuring that only the file paths are passed to ctx.invoke(compare, ...). This avoids the flawed logic of filtering arguments based on a startswith("-") check.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/sounddiff/cli.py#L35-L38

Potential issue: The legacy invocation path in `main` uses a list comprehension `[a for
a in args if not a.startswith("-")]` to find positional arguments. This logic is flawed
because it cannot distinguish between a positional argument and a value associated with
an option (e.g., `json` in `--format json`). When a user runs the command with an option
that takes a value, such as `sounddiff file_a.wav file_b.wav --format json`, the
option's value is incorrectly counted as a positional argument. This causes the argument
count check `len(pos_args) == 2` to fail, preventing the comparison from running and
showing the help text instead.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: b04af491-7258-4221-8cb4-b7a11478aad2

📥 Commits

Reviewing files that changed from the base of the PR and between 3181f33 and cb4d4d8.

📒 Files selected for processing (1)
  • src/sounddiff/cli.py

Comment on lines +31 to +40
if ctx.invoked_subcommand is None:
# Handle legacy positional arguments: sounddiff FILE_A FILE_B
# We only do this if exactly two arguments are provided and they look like paths.
args = sys.argv[1:]
# Filter out flags/options
pos_args = [a for a in args if not a.startswith("-")]
if len(pos_args) == 2:
ctx.invoke(compare, file_a=pos_args[0], file_b=pos_args[1])
else:
click.echo(ctx.get_help())
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify that ctx.args is the correct mechanism for Click groups
# and check how the existing tests invoke the CLI

# Check Click documentation pattern for groups with leftover args
rg -n "ctx\.args" --type py

# See how tests currently invoke main
rg -nA10 "runner\.invoke" tests/

Repository: systemblueteam/sounddiff

Length of output: 2789


Legacy fallback breaks with Click's CliRunnersys.argv[1:] contains stale process arguments, not test inputs.

CliRunner.invoke() does not populate sys.argv; it passes arguments directly to Click's parser. The current code reads sys.argv[1:], which contains pytest's original invocation (e.g., ['pytest', 'tests/test_cli.py']), causing the positional-argument filter to fail. This breaks tests that rely on the legacy sounddiff FILE_A FILE_B syntax.

Use ctx.args to access unparsed positional arguments:

Proposed fix using ctx.args
 `@click.group`(invoke_without_command=True)
 `@click.version_option`(version=__version__, prog_name="sounddiff")
 `@click.pass_context`
 def main(ctx: click.Context) -> None:
     """Compare and inspect audio files.

     sounddiff compare FILE_A FILE_B
     sounddiff inspect FILE
     """
     if ctx.invoked_subcommand is None:
-        # Handle legacy positional arguments: sounddiff FILE_A FILE_B
-        # We only do this if exactly two arguments are provided and they look like paths.
-        args = sys.argv[1:]
-        # Filter out flags/options
-        pos_args = [a for a in args if not a.startswith("-")]
-        if len(pos_args) == 2:
-            ctx.invoke(compare, file_a=pos_args[0], file_b=pos_args[1])
+        # Handle legacy positional arguments: sounddiff FILE_A FILE_B
+        # ctx.args contains unparsed positional arguments after Click processing.
+        if len(ctx.args) == 2:
+            ctx.invoke(compare, file_a=ctx.args[0], file_b=ctx.args[1])
         else:
             click.echo(ctx.get_help())

Note: This fix uses Click's internal argument parser (tested by CliRunner), not the process environment. Options passed alongside positional args (e.g., --format json) are not forwarded to compare; users must use explicit subcommand syntax (sounddiff compare FILE_A FILE_B --format json) for options.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ctx.invoked_subcommand is None:
# Handle legacy positional arguments: sounddiff FILE_A FILE_B
# We only do this if exactly two arguments are provided and they look like paths.
args = sys.argv[1:]
# Filter out flags/options
pos_args = [a for a in args if not a.startswith("-")]
if len(pos_args) == 2:
ctx.invoke(compare, file_a=pos_args[0], file_b=pos_args[1])
else:
click.echo(ctx.get_help())
if ctx.invoked_subcommand is None:
# Handle legacy positional arguments: sounddiff FILE_A FILE_B
# ctx.args contains unparsed positional arguments after Click processing.
if len(ctx.args) == 2:
ctx.invoke(compare, file_a=ctx.args[0], file_b=ctx.args[1])
else:
click.echo(ctx.get_help())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@systemblueteam, glad that aligns! Once you apply the ctx.args fix, the legacy fallback will work correctly both in production (real sys.argv) and under CliRunner in tests.

Quick reminder: with ctx.args, any options passed in legacy mode (e.g. sounddiff file_a.wav file_b.wav --format json) will not be forwarded to compare — only the two positional paths will be. Users needing extra options should use the explicit subcommand form (sounddiff compare file_a.wav file_b.wav --format json). Worth documenting that caveat somewhere visible (e.g. --help text or the migration notes).

(´• ω •`) 🐇 ~ carefully noting things down


✏️ Learnings added
Learnt from: systemblueteam
Repo: systemblueteam/sounddiff PR: 59
File: src/sounddiff/cli.py:31-40
Timestamp: 2026-03-27T09:12:56.694Z
Learning: In src/sounddiff/cli.py (Python, Click), the legacy fallback in the `main` Click group must read unparsed positional arguments from `ctx.args`, not `sys.argv[1:]`. `CliRunner` in tests does not populate `sys.argv`, so reading from `sys.argv` causes the legacy `sounddiff FILE_A FILE_B` path to silently fail during testing. The correct pattern is:

```python
if ctx.invoked_subcommand is None:
    if len(ctx.args) == 2:
        ctx.invoke(compare, file_a=ctx.args[0], file_b=ctx.args[1])
    else:
        click.echo(ctx.get_help())
```

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

# Filter out flags/options
pos_args = [a for a in args if not a.startswith("-")]
if len(pos_args) == 2:
ctx.invoke(compare, file_a=pos_args[0], file_b=pos_args[1])
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Legacy invocation silently ignores CLI options.

When invoking compare via the fallback, only file_a and file_b are passed. Options like --format, --verbose, and --no-color are not forwarded, so sounddiff file_a file_b --verbose would silently ignore --verbose.

If full backward compatibility is intended, consider parsing and forwarding the relevant options, or document that legacy mode only supports the two-file invocation without options.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@systemblueteam, sounds good! Here's a minimal docstring update for main that documents the limitation and points users to compare:

`@click.group`(invoke_without_command=True)
`@click.version_option`(version=__version__, prog_name="sounddiff")
`@click.pass_context`
def main(ctx: click.Context) -> None:
    """Compare and inspect audio files.

    sounddiff compare FILE_A FILE_B
    sounddiff inspect FILE

    Invoking sounddiff with two paths directly (legacy mode) is equivalent to
    ``sounddiff compare FILE_A FILE_B`` but ignores all options such as
    --format, --verbose, and --no-color. Use the compare subcommand explicitly
    for the full option set.
    """

✏️ Learnings added
Learnt from: systemblueteam
Repo: systemblueteam/sounddiff PR: 59
File: src/sounddiff/cli.py:38-38
Timestamp: 2026-03-27T09:12:53.361Z
Learning: In src/sounddiff/cli.py, the legacy two-argument invocation (`sounddiff FILE_A FILE_B`) maps to the `compare` subcommand via `ctx.invoke` but intentionally does not forward CLI options (--format, --verbose, --no-color). This is by design; users who need those options should use `sounddiff compare FILE_A FILE_B` explicitly. The docstring of `main` documents this limitation.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment on lines +136 to +153
@main.command()
@click.argument("file", type=click.Path(exists=True))
def inspect(file: str) -> None:
"""Inspect a single audio file's metadata and properties.

Displays duration, sample rate, channel count, bit depth, and format.
"""
try:
_, meta = load_audio(file)
click.echo(f"File: {meta.path}")
click.echo(f"Duration: {meta.duration:.3f}s")
click.echo(f"Sample Rate: {meta.sample_rate}Hz")
click.echo(f"Channels: {meta.channels}")
click.echo(f"Bit Depth: {meta.bit_depth or 'Unknown'}")
click.echo(f"Format: {meta.format_name}")
except Exception as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Well-implemented inspect subcommand.

Type annotations are complete, docstring follows Google style, and errors are properly caught and reported to stderr with exit code 1.

One consideration: the broad except Exception could mask unexpected errors (e.g., KeyboardInterrupt is not an Exception so that's fine, but MemoryError etc. would be caught). For a CLI tool this is generally acceptable to ensure clean user-facing errors, but you could consider catching more specific exceptions similar to the compare command's pattern (FileNotFoundError, ValueError, RuntimeError).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@systemblueteam, here's the straightforward update to mirror the compare pattern:

`@main.command`()
`@click.argument`("file", type=click.Path(exists=True))
def inspect(file: str) -> None:
    """Inspect a single audio file's metadata and properties.

    Displays duration, sample rate, channel count, bit depth, and format.
    """
    try:
        _, meta = load_audio(file)
        click.echo(f"File: {meta.path}")
        click.echo(f"Duration: {meta.duration:.3f}s")
        click.echo(f"Sample Rate: {meta.sample_rate}Hz")
        click.echo(f"Channels: {meta.channels}")
        click.echo(f"Bit Depth: {meta.bit_depth or 'Unknown'}")
        click.echo(f"Format: {meta.format_name}")
    except FileNotFoundError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)
    except ValueError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)
    except RuntimeError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)

This keeps the two commands consistent and lets any truly unexpected errors (e.g., MemoryError, AttributeError from a bug) surface as tracebacks rather than being silently swallowed as a generic "Error: ..." message.

systemblueteam

This comment was marked as outdated.

@systemblueteam systemblueteam dismissed their stale review March 27, 2026 09:17

Rewriting review

Copy link
Copy Markdown
Owner

@systemblueteam systemblueteam left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice start on the subcommand structure. Two things to fix:

The legacy fallback reads sys.argv directly, which won't work with Click's CliRunner and also grabs option values as positional args. Switch to ctx.args instead — that's what Click gives you for unparsed args in a group.

Also needs tests for the new paths: inspect, the legacy two-file invocation, and bare sounddiff showing help.

Small stuff: add a docstring note that legacy mode doesn't forward options (use sounddiff compare for those), and consider matching the specific exceptions from compare in inspect for consistency.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refactor CLI to subcommand structure

2 participants