From 58c1b36077a447d3a2d23a6a478efefecba24264 Mon Sep 17 00:00:00 2001 From: Omkar Kabde Date: Tue, 26 May 2026 04:29:58 +0530 Subject: [PATCH 1/5] add cli for export (onnx and tflite), move training cli --- pyproject.toml | 2 +- src/rfdetr/cli/__init__.py | 80 ++++++++++++- src/rfdetr/cli/export.py | 120 +++++++++++++++++++ src/rfdetr/{training/cli.py => cli/train.py} | 0 src/rfdetr/export/main.py | 7 +- src/rfdetr/training/__init__.py | 18 ++- tests/training/test_cli.py | 117 ------------------ 7 files changed, 218 insertions(+), 126 deletions(-) create mode 100644 src/rfdetr/cli/export.py rename src/rfdetr/{training/cli.py => cli/train.py} (100%) delete mode 100644 tests/training/test_cli.py diff --git a/pyproject.toml b/pyproject.toml index 81d9cee1e..d849edd62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -315,7 +315,7 @@ overrides = [ "rfdetr.training.callbacks.coco_eval", "rfdetr.training.callbacks.drop_schedule", "rfdetr.training.callbacks.ema", - "rfdetr.training.cli", + "rfdetr.cli.train", "rfdetr.training.drop_schedule", "rfdetr.training.model_ema", "rfdetr.training.module_data", diff --git a/src/rfdetr/cli/__init__.py b/src/rfdetr/cli/__init__.py index 0971cb2e1..3dde5abfc 100644 --- a/src/rfdetr/cli/__init__.py +++ b/src/rfdetr/cli/__init__.py @@ -3,12 +3,84 @@ # Copyright (c) 2025 Roboflow. All Rights Reserved. # Licensed under the Apache License, Version 2.0 [see LICENSE for details] # ------------------------------------------------------------------------ -"""RF-DETR CLI package. +"""RF-DETR command-line interface. -The ``rfdetr`` console script and ``python -m rfdetr`` both invoke :func:`main`, which runs -:class:`~rfdetr.training.cli.RFDETRCli` (Lightning CLI with jsonargparse). +``rfdetr`` is the root CLI. It owns the top-level help and dispatches each +command to its backend: + +* ``fit`` / ``validate`` / ``test`` / ``predict`` → :mod:`rfdetr.cli.train` + (``RFDETRCli``, a :class:`pytorch_lightning.cli.LightningCLI` subclass). +* ``export`` → :mod:`rfdetr.cli.export` (jsonargparse wrapper over + :meth:`rfdetr.detr.RFDETR.export`). + +Backends are imported lazily and render their own ``rfdetr --help``, +so ``rfdetr export`` and ``rfdetr --help`` work without the ``[train]`` extra +(no PyTorch Lightning required). """ -from rfdetr.training.cli import main +from __future__ import annotations + +import importlib +import sys +from typing import TextIO + +# Training commands are delegated to ``rfdetr.cli.train`` (LightningCLI); their +# argv is passed through unchanged because LightningCLI expects the command token. +_TRAIN_COMMANDS: dict[str, str] = { + "fit": "Train a model", + "validate": "Run the validation loop", + "test": "Run the test loop", + "predict": "Run the prediction loop", +} +# Standalone commands live in ``rfdetr.cli.`` (each exposing ``main()``) +# and avoid importing the training stack. +_STANDALONE_COMMANDS: dict[str, str] = { + "export": "Export a checkpoint to ONNX or TFLite", +} + + +def _print_root_help(stream: TextIO | None = None) -> None: + """Render the unified top-level ``rfdetr`` help.""" + out = stream if stream is not None else sys.stdout + print("usage: rfdetr [options]", file=out) + print("\nRF-DETR command-line interface.\n", file=out) + print("commands:", file=out) + for name, summary in {**_TRAIN_COMMANDS, **_STANDALONE_COMMANDS}.items(): + print(f" {name:<10} {summary}", file=out) + print("\nRun 'rfdetr --help' for command-specific options.", file=out) + + +def main() -> None: + """Dispatch ``rfdetr `` to its backend. + + The root renders the top-level help itself (it is not delegated to a + backend); ``rfdetr --help`` is rendered by the backend that owns + the command. + """ + argv = sys.argv[1:] + if not argv or argv[0] in ("-h", "--help"): + _print_root_help() + return + + command = argv[0] + if command in _STANDALONE_COMMANDS: + # Strip the command so the backend parser sees only its own flags. + sys.argv.pop(1) + importlib.import_module(f"rfdetr.cli.{command}").main() + return + # Training commands, or a LightningCLI root-level option that precedes one + # (e.g. ``rfdetr -c config.yaml fit`` or ``rfdetr --print_config fit``), are + # delegated to LightningCLI. It reads sys.argv and parses the root options + # and command token natively, so argv is left unchanged. + if command in _TRAIN_COMMANDS or command.startswith("-"): + from rfdetr.cli.train import main as train_main + + train_main() + return + + print(f"rfdetr: error: invalid command {command!r}\n", file=sys.stderr) + _print_root_help(stream=sys.stderr) + raise SystemExit(2) + __all__ = ["main"] diff --git a/src/rfdetr/cli/export.py b/src/rfdetr/cli/export.py new file mode 100644 index 000000000..333936569 --- /dev/null +++ b/src/rfdetr/cli/export.py @@ -0,0 +1,120 @@ +# ------------------------------------------------------------------------ +# RF-DETR +# Copyright (c) 2025 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +"""``rfdetr export`` subcommand. + +Thin wrapper around :meth:`rfdetr.detr.RFDETR.export` so its full surface +is reachable from the shell. ``jsonargparse.CLI`` introspects +:func:`export_main` to build the parser, so flag names, types, and help +text stay in lockstep with the function signature and its Google-style +docstring. + +YAML config support is automatic via jsonargparse: pass +``--config path/to/export.yaml`` and any keys matching the parameters +below are loaded from the file. +""" + +from __future__ import annotations + +from typing import Literal, Optional + +from rfdetr.utilities.logger import get_logger + +logger = get_logger() + + +def export_main( + checkpoint: str, + *, + output_dir: str = "output", + format: Literal["onnx", "tflite"] = "onnx", + quantization: Optional[Literal["fp32", "fp16", "int8"]] = None, + calibration_data: Optional[str] = None, + max_images: int = 100, + shape: Optional[tuple[int, int]] = None, + batch_size: int = 1, + opset_version: int = 17, + backbone_only: bool = False, + dynamic_batch: bool = False, + patch_size: Optional[int] = None, + infer_dir: Optional[str] = None, + notes: Optional[str] = None, + verbose: bool = True, +) -> None: + """Export an RF-DETR checkpoint to ONNX or TFLite. + + Loads the checkpoint with :func:`rfdetr.from_checkpoint`, which + auto-resolves the correct ``RFDETR`` subclass (Nano, Small, ..., Seg*) + from the checkpoint metadata, then calls :meth:`RFDETR.export`. + + Args: + checkpoint: Path to the ``.pt`` / ``.pth`` checkpoint to load. + output_dir: Directory to write the exported artifacts to. + format: Export format. ``"onnx"`` writes an ``.onnx`` file; + ``"tflite"`` additionally converts via ``onnx2tf`` and writes + FP32 / FP16 / INT8 ``.tflite`` variants per *quantization*. + quantization: TFLite quantization mode. Ignored when + ``format="onnx"``. ``None`` / ``"fp32"`` / ``"fp16"`` keep float + weights; ``"int8"`` produces a dynamic-range int8 model. + calibration_data: Directory of representative JPEG/PNG images or + path to a ``.npy`` array of shape ``(N, H, W, 3)``. Used for + INT8 quantization and ``onnx2tf`` output validation. The + ``ndarray`` form accepted by :meth:`RFDETR.export` is not + reachable from the shell; pass a directory or ``.npy`` path. + max_images: Maximum number of images to load from a + *calibration_data* directory. + shape: ``(height, width)`` tuple baked into the exported graph. + Both dimensions must be divisible by ``patch_size * + num_windows``. Defaults to the model's native resolution. + batch_size: Static batch size baked into the ONNX graph. + opset_version: ONNX opset version to target. + backbone_only: Export the backbone (feature extractor) only. + dynamic_batch: If ``True``, export with a dynamic batch dimension + so the artifact accepts variable batch sizes at runtime. + patch_size: Backbone patch size. Defaults to the checkpoint's + stored ``model_config.patch_size``. + infer_dir: Optional directory of sample images for dynamic-axes + inference during export tracing. + notes: Optional free-form metadata embedded in the ONNX file + under the ``"rfdetr_notes"`` metadata property. + verbose: Print export progress information. + """ + from rfdetr import from_checkpoint + + logger.info("Loading checkpoint from %s", checkpoint) + model = from_checkpoint(checkpoint) + model.export( + output_dir=output_dir, + format=format, + quantization=quantization, + calibration_data=calibration_data, + max_images=max_images, + shape=shape, + batch_size=batch_size, + opset_version=opset_version, + backbone_only=backbone_only, + dynamic_batch=dynamic_batch, + patch_size=patch_size, + infer_dir=infer_dir, + notes=notes, + verbose=verbose, + ) + + +def main() -> None: + """Entry point for ``rfdetr export``.""" + try: + from jsonargparse import CLI + except ImportError as exc: # pragma: no cover - guarded by [cli] extra + raise ImportError( + "`rfdetr export` requires jsonargparse. Install the cli extra: " + "`pip install 'rfdetr[cli]'` (or include cli alongside other extras)." + ) from exc + + CLI(export_main, as_positional=False) + + +if __name__ == "__main__": + main() diff --git a/src/rfdetr/training/cli.py b/src/rfdetr/cli/train.py similarity index 100% rename from src/rfdetr/training/cli.py rename to src/rfdetr/cli/train.py diff --git a/src/rfdetr/export/main.py b/src/rfdetr/export/main.py index a4bcb2c3c..677d7b06f 100644 --- a/src/rfdetr/export/main.py +++ b/src/rfdetr/export/main.py @@ -186,6 +186,7 @@ def main(args): if args.tensorrt: output_file = trtexec(onnx_path, args) - # TODO: register --tflite, --quantization, --calibration-data, --max-images in the - # argparser to enable TFLite export via CLI. Until then, use RFDETR.export(format="tflite"). - _ = onnx_path # referenced above; suppress unused-variable warning until CLI is wired up + # TFLite export is available through the ``rfdetr export --format tflite`` + # CLI subcommand (see ``rfdetr.cli.export``), which calls + # ``RFDETR.export(format="tflite")``. This ``args``-driven entry point + # handles ONNX export and the optional TensorRT step. diff --git a/src/rfdetr/training/__init__.py b/src/rfdetr/training/__init__.py index 5d7488d1e..28bf4392f 100644 --- a/src/rfdetr/training/__init__.py +++ b/src/rfdetr/training/__init__.py @@ -13,6 +13,8 @@ build_trainer: Factory that assembles a PTL Trainer from RF-DETR configs. """ +from typing import TYPE_CHECKING, Any + from pytorch_lightning import seed_everything from rfdetr.training.callbacks import ( @@ -23,14 +25,28 @@ RFDETREMACallback, ) from rfdetr.training.checkpoint import convert_legacy_checkpoint -from rfdetr.training.cli import RFDETRCli from rfdetr.training.module_data import RFDETRDataModule from rfdetr.training.module_model import RFDETRModelModule from rfdetr.training.trainer import build_trainer from rfdetr.utilities.logger import get_logger +if TYPE_CHECKING: + from rfdetr.cli.train import RFDETRCli + _logger = get_logger() + +def __getattr__(name: str) -> Any: + # ``RFDETRCli`` is defined in ``rfdetr.cli.train`` and re-exported here. It + # is imported lazily to avoid a circular import: ``rfdetr.cli.train`` imports + # ``rfdetr.training`` submodules at module load time. + if name == "RFDETRCli": + from rfdetr.cli.train import RFDETRCli + + return RFDETRCli + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = [ "BestModelCallback", "COCOEvalCallback", diff --git a/tests/training/test_cli.py b/tests/training/test_cli.py deleted file mode 100644 index 3e27f89b7..000000000 --- a/tests/training/test_cli.py +++ /dev/null @@ -1,117 +0,0 @@ -# ------------------------------------------------------------------------ -# RF-DETR -# Copyright (c) 2025 Roboflow. All Rights Reserved. -# Licensed under the Apache License, Version 2.0 [see LICENSE for details] -# ------------------------------------------------------------------------ -"""Tests for RFDETRCli — PTL Ch4/T4. - -Verifies that the CLI module is correctly structured: importable, subclasses LightningCLI, overrides -add_arguments_to_parser, and exposes a callable main() entry point. CLI integration / smoke tests (--help subprocess, -YAML roundtrip) live in T4-7. -""" - -import pytest - -# --------------------------------------------------------------------------- -# Structure and importability -# --------------------------------------------------------------------------- - - -class TestRFDETRCliStructure: - """RFDETRCli is correctly structured and importable.""" - - def test_cli_module_importable(self): - """rfdetr.training.cli imports without error.""" - import rfdetr.training.cli # noqa: F401 - - def test_rfdetr_cli_importable(self): - """RFDETRCli can be imported from rfdetr.training.cli.""" - from rfdetr.training.cli import RFDETRCli # noqa: F401 - - def test_main_importable(self): - """Main() can be imported from rfdetr.training.cli.""" - from rfdetr.training.cli import main # noqa: F401 - - def test_rfdetr_cli_is_lightning_cli_subclass(self): - """RFDETRCli must subclass pytorch_lightning LightningCLI.""" - from pytorch_lightning.cli import LightningCLI - - from rfdetr.training.cli import RFDETRCli - - assert issubclass(RFDETRCli, LightningCLI) - - def test_main_is_callable(self): - """Main must be a callable (function, not e.g. a string).""" - from rfdetr.training.cli import main - - assert callable(main) - - def test_add_arguments_to_parser_is_overridden(self): - """RFDETRCli overrides add_arguments_to_parser from LightningCLI.""" - from pytorch_lightning.cli import LightningCLI - - from rfdetr.training.cli import RFDETRCli - - assert RFDETRCli.add_arguments_to_parser is not LightningCLI.add_arguments_to_parser - - def test_exported_from_lit_package(self): - """RFDETRCli is exported from rfdetr.training (appears in __all__).""" - import rfdetr.training as lit - - assert hasattr(lit, "RFDETRCli") - assert "RFDETRCli" in lit.__all__ - - -# --------------------------------------------------------------------------- -# Argument linking -# --------------------------------------------------------------------------- - - -class TestRFDETRCliArgumentLinking: - """add_arguments_to_parser registers the expected argument links.""" - - def _collect_links(self): - """Instantiate a minimal parser and collect registered link sources.""" - import unittest.mock as mock - - from rfdetr.training.cli import RFDETRCli - - captured = [] - - class _FakeParser: - def link_arguments(self, source, target, **kwargs): - captured.append({"source": source, "target": target, **kwargs}) - - # LightningArgumentParser methods that may be called during setup - def __getattr__(self, name): - return mock.MagicMock() - - cli = RFDETRCli.__new__(RFDETRCli) - cli.add_arguments_to_parser(_FakeParser()) - return captured - - def test_model_config_link_registered(self): - """model.model_config is linked to data.model_config.""" - links = self._collect_links() - sources = [lnk["source"] for lnk in links] - assert "model.model_config" in sources - - def test_train_config_link_registered(self): - """model.train_config is linked to data.train_config.""" - links = self._collect_links() - sources = [lnk["source"] for lnk in links] - assert "model.train_config" in sources - - @pytest.mark.parametrize( - "source, expected_target", - [ - pytest.param("model.model_config", "data.model_config", id="model_config"), - pytest.param("model.train_config", "data.train_config", id="train_config"), - ], - ) - def test_link_target(self, source, expected_target): - """Each link points to the correct data.* target.""" - links = self._collect_links() - match = next((lnk for lnk in links if lnk["source"] == source), None) - assert match is not None, f"No link registered for source {source!r}" - assert match["target"] == expected_target From e9b03934aad56d865ebe7d3f6f0e1c865ff90f6f Mon Sep 17 00:00:00 2001 From: Omkar Kabde Date: Tue, 26 May 2026 04:30:14 +0530 Subject: [PATCH 2/5] update docs --- docs/learn/export.md | 33 +++++++++++++++++++++++++++++++++ docs/reference/training.md | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/learn/export.md b/docs/learn/export.md index bd546715a..4e3f32ab7 100644 --- a/docs/learn/export.md +++ b/docs/learn/export.md @@ -72,6 +72,39 @@ The `export()` method accepts several parameters to customize the export process | `shape` | `None` | Input shape as tuple `(height, width)`. Each dimension must be divisible by the selected model's block size (`patch_size * num_windows`). If not provided, uses the model's default resolution. | | `batch_size` | `1` | Batch size for the exported model. | +## Command-Line Export + +The same export is available from the shell via the `rfdetr export` subcommand. It loads a checkpoint with `rfdetr.from_checkpoint` (which auto-resolves the model variant) and calls `RFDETR.export`. Install the CLI extra alongside the format you need: + +```bash +pip install "rfdetr[cli,onnx]" # ONNX +pip install "rfdetr[cli,onnx,tflite]" # TFLite +``` + +Then export a checkpoint: + +=== "ONNX" + + ```bash + rfdetr export --checkpoint path/to/checkpoint.pth --format onnx --output_dir output + ``` + +=== "TFLite (FP32 + FP16)" + + ```bash + rfdetr export --checkpoint path/to/checkpoint.pth --format tflite --output_dir output + ``` + +=== "TFLite (INT8 with calibration)" + + ```bash + rfdetr export --checkpoint path/to/checkpoint.pth --format tflite --quantization int8 --calibration_data path/to/val_images/ --max_images 100 --output_dir output + ``` + +Every parameter from [Export Parameters](#export-parameters) is exposed as a flag (with an underscore, e.g. `--calibration_data`, `--max_images`). Run `rfdetr export --help` for the full list. Flags can also be supplied from a YAML file with `--config export.yaml`. + +The export subcommand does not require the `[train]` extra (no PyTorch Lightning), so `rfdetr export` and `rfdetr --help` work in an inference-only environment. + ## Advanced Export Examples ### Export with Custom Output Directory diff --git a/docs/reference/training.md b/docs/reference/training.md index da163e344..9ca996178 100644 --- a/docs/reference/training.md +++ b/docs/reference/training.md @@ -111,7 +111,7 @@ Both `model_config` and `train_config` are specified once; `RFDETRCli` automatically links them to the datamodule so you do not need to repeat the same arguments under `--data.*`. -::: rfdetr.training.cli.RFDETRCli +::: rfdetr.cli.train.RFDETRCli options: show_source: false members: From 1badba4de1b3ec0c7db32334d8d18bb7758fe2fa Mon Sep 17 00:00:00 2001 From: Omkar Kabde Date: Tue, 26 May 2026 04:30:36 +0530 Subject: [PATCH 3/5] update tests --- tests/cli/test_export.py | 135 +++++++++++++++++++++++++++++++++++++++ tests/cli/test_smoke.py | 2 +- tests/cli/test_train.py | 117 +++++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 tests/cli/test_export.py create mode 100644 tests/cli/test_train.py diff --git a/tests/cli/test_export.py b/tests/cli/test_export.py new file mode 100644 index 000000000..b8e9e60fa --- /dev/null +++ b/tests/cli/test_export.py @@ -0,0 +1,135 @@ +# ------------------------------------------------------------------------ +# RF-DETR +# Copyright (c) 2025 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +"""Tests for the ``rfdetr export`` CLI subcommand and dispatcher. + +Covers: +* ``export_main()`` forwards flags (including the TFLite quantization / + calibration flags) to ``RFDETR.export``. +* The ``rfdetr`` dispatcher routes ``export`` to ``rfdetr.cli.export`` + without importing the training stack. +""" + +from __future__ import annotations + +import sys +from unittest import mock + +import pytest + + +class TestExportMainForwarding: + """``export_main`` loads the checkpoint and forwards flags to ``export``.""" + + @staticmethod + def _run(**overrides) -> dict: + """Call export_main with mocked from_checkpoint; return export kwargs.""" + captured: dict = {} + fake_model = mock.MagicMock() + fake_model.export.side_effect = lambda **kw: captured.update(kw) + + from rfdetr.cli.export import export_main + + with mock.patch("rfdetr.from_checkpoint", return_value=fake_model) as from_ckpt: + export_main("ckpt.pth", **overrides) + captured["__from_checkpoint_arg__"] = from_ckpt.call_args.args[0] + return captured + + def test_checkpoint_is_loaded_via_from_checkpoint(self) -> None: + """The positional checkpoint path is passed to from_checkpoint.""" + captured = self._run() + assert captured["__from_checkpoint_arg__"] == "ckpt.pth" + + def test_tflite_quantization_flags_forwarded(self) -> None: + """TFLite quantization/calibration flags reach RFDETR.export.""" + captured = self._run( + format="tflite", + quantization="int8", + calibration_data="images/", + max_images=50, + ) + assert captured["format"] == "tflite" + assert captured["quantization"] == "int8" + assert captured["calibration_data"] == "images/" + assert captured["max_images"] == 50 + + def test_onnx_is_default_format(self) -> None: + """Format defaults to onnx when not specified.""" + captured = self._run() + assert captured["format"] == "onnx" + + +class TestDispatcher: + """The ``rfdetr`` console entry point routes subcommands correctly.""" + + def test_export_subcommand_routes_to_export_module(self, monkeypatch: pytest.MonkeyPatch) -> None: + """``rfdetr export ...`` invokes rfdetr.cli.export.main and strips the subcommand.""" + import rfdetr.cli as cli + + seen_argv: dict = {} + monkeypatch.setattr( + "rfdetr.cli.export.main", + lambda: seen_argv.update(argv=list(sys.argv)), + ) + monkeypatch.setattr(sys, "argv", ["rfdetr", "export", "--checkpoint", "x.pth"]) + + cli.main() + + # The "export" token is stripped so jsonargparse sees only the flags. + assert seen_argv["argv"] == ["rfdetr", "--checkpoint", "x.pth"] + + def test_export_routing_does_not_import_pytorch_lightning(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Routing to export must not import the training stack (pytorch_lightning).""" + import rfdetr.cli as cli + + monkeypatch.setattr("rfdetr.cli.export.main", lambda: None) + monkeypatch.setattr(sys, "argv", ["rfdetr", "export"]) + # Make ``rfdetr.cli.train`` un-importable so an accidental eager import would raise. + monkeypatch.setitem(sys.modules, "rfdetr.cli.train", None) + + cli.main() # must not raise + + def test_root_level_option_before_command_routes_to_train(self, monkeypatch: pytest.MonkeyPatch) -> None: + """LightningCLI root-option ordering (e.g. `rfdetr -c cfg fit`) delegates to the train backend with argv left + intact.""" + import rfdetr.cli as cli + + seen_argv: dict = {} + monkeypatch.setattr("rfdetr.cli.train.main", lambda: seen_argv.update(argv=list(sys.argv))) + monkeypatch.setattr(sys, "argv", ["rfdetr", "-c", "cfg.yaml", "fit"]) + + cli.main() + + # LightningCLI parses root options + command itself, so argv is unchanged. + assert seen_argv["argv"] == ["rfdetr", "-c", "cfg.yaml", "fit"] + + def test_top_level_help_is_root_owned( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """`rfdetr --help` is rendered by the RF-DETR root: it lists every command and does not stitch in LightningCLI's + root usage.""" + import rfdetr.cli as cli + + monkeypatch.setattr(sys, "argv", ["rfdetr", "--help"]) + + cli.main() + + out = capsys.readouterr().out + for command in ("fit", "validate", "test", "predict", "export"): + assert command in out, f"{command!r} missing from root help" + # LightningCLI's stitched root usage must not leak into the top-level help. + assert "{fit,validate,test,predict}" not in out + + def test_unknown_command_exits_2(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + """An unrecognized command prints an error to stderr and exits with code 2.""" + import rfdetr.cli as cli + + monkeypatch.setattr(sys, "argv", ["rfdetr", "bogus"]) + + with pytest.raises(SystemExit) as exc_info: + cli.main() + + assert exc_info.value.code == 2 + assert "invalid command" in capsys.readouterr().err diff --git a/tests/cli/test_smoke.py b/tests/cli/test_smoke.py index d4ad6dec5..d993c2554 100644 --- a/tests/cli/test_smoke.py +++ b/tests/cli/test_smoke.py @@ -42,7 +42,7 @@ def _run_cli(*args: str) -> int: """Run RFDETRCli in-process with the given args; return the SystemExit code.""" - from rfdetr.training.cli import RFDETRCli + from rfdetr.cli.train import RFDETRCli from rfdetr.training.module_data import RFDETRDataModule from rfdetr.training.module_model import RFDETRModelModule diff --git a/tests/cli/test_train.py b/tests/cli/test_train.py new file mode 100644 index 000000000..09230a8d8 --- /dev/null +++ b/tests/cli/test_train.py @@ -0,0 +1,117 @@ +# ------------------------------------------------------------------------ +# RF-DETR +# Copyright (c) 2025 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ +"""Tests for RFDETRCli — PTL Ch4/T4. + +Verifies that the CLI module is correctly structured: importable, subclasses LightningCLI, overrides +add_arguments_to_parser, and exposes a callable main() entry point. CLI integration / smoke tests (--help subprocess, +YAML roundtrip) live in T4-7. +""" + +import pytest + +# --------------------------------------------------------------------------- +# Structure and importability +# --------------------------------------------------------------------------- + + +class TestRFDETRCliStructure: + """RFDETRCli is correctly structured and importable.""" + + def test_cli_module_importable(self): + """rfdetr.cli.train imports without error.""" + import rfdetr.cli.train # noqa: F401 + + def test_rfdetr_cli_importable(self): + """RFDETRCli can be imported from rfdetr.cli.train.""" + from rfdetr.cli.train import RFDETRCli # noqa: F401 + + def test_main_importable(self): + """Main() can be imported from rfdetr.cli.train.""" + from rfdetr.cli.train import main # noqa: F401 + + def test_rfdetr_cli_is_lightning_cli_subclass(self): + """RFDETRCli must subclass pytorch_lightning LightningCLI.""" + from pytorch_lightning.cli import LightningCLI + + from rfdetr.cli.train import RFDETRCli + + assert issubclass(RFDETRCli, LightningCLI) + + def test_main_is_callable(self): + """Main must be a callable (function, not e.g. a string).""" + from rfdetr.cli.train import main + + assert callable(main) + + def test_add_arguments_to_parser_is_overridden(self): + """RFDETRCli overrides add_arguments_to_parser from LightningCLI.""" + from pytorch_lightning.cli import LightningCLI + + from rfdetr.cli.train import RFDETRCli + + assert RFDETRCli.add_arguments_to_parser is not LightningCLI.add_arguments_to_parser + + def test_exported_from_lit_package(self): + """RFDETRCli is exported from rfdetr.training (appears in __all__).""" + import rfdetr.training as lit + + assert hasattr(lit, "RFDETRCli") + assert "RFDETRCli" in lit.__all__ + + +# --------------------------------------------------------------------------- +# Argument linking +# --------------------------------------------------------------------------- + + +class TestRFDETRCliArgumentLinking: + """add_arguments_to_parser registers the expected argument links.""" + + def _collect_links(self): + """Instantiate a minimal parser and collect registered link sources.""" + import unittest.mock as mock + + from rfdetr.cli.train import RFDETRCli + + captured = [] + + class _FakeParser: + def link_arguments(self, source, target, **kwargs): + captured.append({"source": source, "target": target, **kwargs}) + + # LightningArgumentParser methods that may be called during setup + def __getattr__(self, name): + return mock.MagicMock() + + cli = RFDETRCli.__new__(RFDETRCli) + cli.add_arguments_to_parser(_FakeParser()) + return captured + + def test_model_config_link_registered(self): + """model.model_config is linked to data.model_config.""" + links = self._collect_links() + sources = [lnk["source"] for lnk in links] + assert "model.model_config" in sources + + def test_train_config_link_registered(self): + """model.train_config is linked to data.train_config.""" + links = self._collect_links() + sources = [lnk["source"] for lnk in links] + assert "model.train_config" in sources + + @pytest.mark.parametrize( + "source, expected_target", + [ + pytest.param("model.model_config", "data.model_config", id="model_config"), + pytest.param("model.train_config", "data.train_config", id="train_config"), + ], + ) + def test_link_target(self, source, expected_target): + """Each link points to the correct data.* target.""" + links = self._collect_links() + match = next((lnk for lnk in links if lnk["source"] == source), None) + assert match is not None, f"No link registered for source {source!r}" + assert match["target"] == expected_target From abcfd697b23f90a5cb3d7793a8f53b51dbce475d Mon Sep 17 00:00:00 2001 From: Omkar Kabde Date: Tue, 26 May 2026 04:39:44 +0530 Subject: [PATCH 4/5] fix mypy error --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index d849edd62..af247a952 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -316,6 +316,7 @@ overrides = [ "rfdetr.training.callbacks.drop_schedule", "rfdetr.training.callbacks.ema", "rfdetr.cli.train", + "rfdetr.cli.export", "rfdetr.training.drop_schedule", "rfdetr.training.model_ema", "rfdetr.training.module_data", From fc750aef79ea86ac140bc0a170cac31458be1750 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:38:04 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(pre-commit):=20=F0=9F=8E=A8=20auto=20fo?= =?UTF-8?q?rmat=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rfdetr/cli/__init__.py | 5 ++--- src/rfdetr/cli/export.py | 15 ++++++--------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/rfdetr/cli/__init__.py b/src/rfdetr/cli/__init__.py index 3dde5abfc..f2be1d557 100644 --- a/src/rfdetr/cli/__init__.py +++ b/src/rfdetr/cli/__init__.py @@ -53,9 +53,8 @@ def _print_root_help(stream: TextIO | None = None) -> None: def main() -> None: """Dispatch ``rfdetr `` to its backend. - The root renders the top-level help itself (it is not delegated to a - backend); ``rfdetr --help`` is rendered by the backend that owns - the command. + The root renders the top-level help itself (it is not delegated to a backend); ``rfdetr --help`` is + rendered by the backend that owns the command. """ argv = sys.argv[1:] if not argv or argv[0] in ("-h", "--help"): diff --git a/src/rfdetr/cli/export.py b/src/rfdetr/cli/export.py index 333936569..e361b3432 100644 --- a/src/rfdetr/cli/export.py +++ b/src/rfdetr/cli/export.py @@ -5,15 +5,12 @@ # ------------------------------------------------------------------------ """``rfdetr export`` subcommand. -Thin wrapper around :meth:`rfdetr.detr.RFDETR.export` so its full surface -is reachable from the shell. ``jsonargparse.CLI`` introspects -:func:`export_main` to build the parser, so flag names, types, and help -text stay in lockstep with the function signature and its Google-style -docstring. - -YAML config support is automatic via jsonargparse: pass -``--config path/to/export.yaml`` and any keys matching the parameters -below are loaded from the file. +Thin wrapper around :meth:`rfdetr.detr.RFDETR.export` so its full surface is reachable from the shell. +``jsonargparse.CLI`` introspects :func:`export_main` to build the parser, so flag names, types, and help text stay in +lockstep with the function signature and its Google-style docstring. + +YAML config support is automatic via jsonargparse: pass ``--config path/to/export.yaml`` and any keys matching the +parameters below are loaded from the file. """ from __future__ import annotations