From 908c3101034c99750a7ef45722504ab2ed66ab37 Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Wed, 14 Oct 2026 17:11:09 -0600 Subject: [PATCH 01/11] Use less time on the GPU runner in the CI GitHub bills the GPU runner by the minute. Most of the work on it does not need a GPU, and some suites ran when they could not find a new failure. - Move the integration tests that do not train, tune or quantize a model to the new `cpu` suite. This suite also holds the unit tests, and it runs on a free runner. The tests that train have the new `gpu` marker and stay in the `misc` suite on the GPU runner. - Archive the model of the combinations test with one backbone only. The archive does not depend on the backbone, and its xz compression used two thirds of the time of the suite. - Add the `plan` job, which selects the test suites. When a pull request changes only the documentation, only the `cpu` suite runs. A draft pull request skips the `predefined_heavy` suite. When the pull request becomes ready for review, all the suites run. - Describe the suites and the `gpu` marker in CONTRIBUTING.md. --- .github/workflows/ci.yaml | 58 ++++++++++- CONTRIBUTING.md | 22 ++++ scripts/only_docstrings_changed.py | 118 ++++++++++++++++++++++ tests/conftest.py | 26 ++++- tests/integration/test_callbacks.py | 3 + tests/integration/test_cli_commands.py | 1 + tests/integration/test_combinations.py | 29 +++++- tests/integration/test_core.py | 2 + tests/integration/test_custom_model.py | 3 + tests/integration/test_freezing.py | 2 + tests/integration/test_loader.py | 2 + tests/integration/test_mlflow_logging.py | 2 + tests/integration/test_overfit_batches.py | 2 + tests/integration/test_resume_training.py | 2 + tests/integration/test_scheduler.py | 2 + 15 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 scripts/only_docstrings_changed.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8dbd2ed2f..b6522c662 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -53,6 +53,8 @@ on: description: "HubAI API key" required: true pull_request: + # `ready_for_review` runs the suites that a draft skips. + types: [opened, synchronize, reopened, ready_for_review] paths: - ".python-version" - "pyproject.toml" @@ -74,6 +76,47 @@ permissions: contents: read jobs: + # The GPU runner is billed by the minute. This job selects the test + # suites, so that a pull request runs a GPU suite only when the suite + # can find something new: + # - a pull request that changes only the documentation runs the + # CPU suite, see `scripts/only_docstrings_changed.py`; + # - a draft pull request skips the heavy suite. + # A dispatch and a call from another workflow run every suite. + plan: + runs-on: ubuntu-latest + outputs: + suites: ${{ steps.plan.outputs.suites }} + code_changed: ${{ steps.plan.outputs.code_changed }} + steps: + - name: Checkout + if: ${{ github.event_name == 'pull_request' && !inputs.train_ref }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + persist-credentials: false + + - name: Select the test suites + id: plan + env: + PULL_REQUEST: ${{ github.event_name == 'pull_request' && !inputs.train_ref }} + DRAFT: ${{ github.event.pull_request.draft == true }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + code_changed=true + suites='["cpu", "predefined_light", "predefined_heavy", "combinations", "misc"]' + if [[ "$PULL_REQUEST" == true ]]; then + if python3 scripts/only_docstrings_changed.py "$BASE_SHA"; then + code_changed=false + suites='["cpu"]' + elif [[ "$DRAFT" == true ]]; then + suites='["cpu", "predefined_light", "combinations", "misc"]' + fi + fi + echo "code_changed=$code_changed" >> "$GITHUB_OUTPUT" + echo "suites=$suites" >> "$GITHUB_OUTPUT" + pre-commit: runs-on: ubuntu-latest steps: @@ -286,14 +329,18 @@ jobs: tests: needs: + - plan - config-test - doctests + # The `cpu` suite holds the unit tests and the integration tests + # that do not train, so it runs on a free runner. See + # `pytest_collection_modifyitems` in `tests/conftest.py`. strategy: matrix: - suite: [unit, predefined_light, predefined_heavy, combinations, misc] + suite: ${{ fromJSON(needs.plan.outputs.suites) }} - runs-on: ${{ matrix.suite == 'unit' && 'ubuntu-latest' || 'ubuntu-t4-4core' }} + runs-on: ${{ matrix.suite == 'cpu' && 'ubuntu-latest' || 'ubuntu-t4-4core' }} steps: - name: Checkout @@ -365,8 +412,13 @@ jobs: # Coverage is uploaded once, after all the suites have finished. Uploading # each suite on its own makes Codecov report the partial runs as the total. + # For the same reason, a change of the documentation, which runs the CPU + # suite only, uploads nothing. It cannot change the coverage. coverage: - needs: tests + needs: + - plan + - tests + if: ${{ needs.plan.outputs.code_changed == 'true' }} runs-on: ubuntu-latest permissions: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b08227533..2fea944ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,6 +123,11 @@ This command will run all tests and generate HTML coverage report. > [!TIP] > You can choose to run only the unit-tests or only the integration tests by adding `-m unit` or `-m integration` to the `pytest` command. +> [!IMPORTANT] +> Mark an integration test with `@pytest.mark.gpu` when it trains, tunes or quantizes a model. +> CI runs these tests on the GPU runner, and all the other tests on a free CPU runner. +> A test without the marker still passes on the CPU, but it no longer covers the GPU code of the training, such as mixed precision. + > [!IMPORTANT] > If a new feature is added, a new test should be added to cover it. > The minimum overall test coverage for a PR to be merged is 90%. @@ -136,6 +141,23 @@ Our GitHub Actions workflow is run when a new PR is opened. 1. Next, the [type checking](#type-checking) is run. 1. If all previous checks pass, the [tests](#tests) are run. +The tests run in five suites, one job each. +The GPU runner is billed by the minute, so only the suites that need a GPU run there: + +| Suite | Tests | Runner | +| -------------------------------------- | ------------------------------------------------------------------ | --------------- | +| `cpu` | The unit tests, and the integration tests without the `gpu` marker | Free CPU runner | +| `misc` | The integration tests with the `gpu` marker | GPU runner | +| `predefined_light`, `predefined_heavy` | `test_predefined_models.py`, the light and the heavy variants | GPU runner | +| `combinations` | `test_combinations.py` | GPU runner | + +Two rules keep the GPU suites from running when they cannot find anything new: + +- A pull request that changes only docstrings, comments, Markdown files or the settings of the linters and of the API docs runs the `cpu` suite only. + `scripts/only_docstrings_changed.py` decides this. +- A draft pull request skips `predefined_heavy`. + The suite runs when you mark the pull request as ready for review. + > [!TIP] > Review the GitHub Actions output if your PR fails. diff --git a/scripts/only_docstrings_changed.py b/scripts/only_docstrings_changed.py new file mode 100644 index 000000000..5a6cc2b98 --- /dev/null +++ b/scripts/only_docstrings_changed.py @@ -0,0 +1,118 @@ +"""Tell whether the changes since a base commit touch only +documentation. + +The CI runs the GPU test suites only when the code changes. The script +exits with 0 when each changed file is one of these: + + - a Python file whose syntax tree without docstrings stays the same. + Comments and formatting never reach the syntax tree; + - C{pyproject.toml} with changes in the tables of the linters and the + API docs only, see C{DOC_TABLES}; + - a Markdown file, C{.gitignore} or C{.pre-commit-config.yaml}. + +Any other change, an added or a removed Python file included, exits +with 1 and names the file. + +Usage: C{python scripts/only_docstrings_changed.py } + +""" + +import ast +import subprocess +import sys +from collections.abc import Callable + +DOC_TABLES = ( + "tool.docformatter", + "tool.pydoctor", + "tool.pyright", + "tool.ruff", +) +IGNORED = {".gitignore", ".pre-commit-config.yaml"} + + +def git(*args: str) -> str: + """Run git and return its output.""" + return subprocess.run( + ["git", *args], # noqa: S607 + check=True, + capture_output=True, + text=True, + ).stdout + + +def is_bare_string(statement: ast.stmt) -> bool: + """Tell whether a statement is a string literal and nothing else.""" + return ( + isinstance(statement, ast.Expr) + and isinstance(statement.value, ast.Constant) + and isinstance(statement.value.value, str) + ) + + +def code_of(source: str) -> str: + """Dump the syntax tree of C{source} without its docstrings. + + A string literal that stands as a statement of its own has no + effect, wherever it is. This covers the docstrings of attributes + too. + + """ + tree = ast.parse(source) + for node in ast.walk(tree): + for field in ("body", "orelse", "finalbody"): + statements = getattr(node, field, None) + if isinstance(statements, list): + setattr( + node, + field, + [s for s in statements if not is_bare_string(s)], + ) + return ast.dump(tree) + + +def without_doc_tables(pyproject: str) -> list[str]: + """Drop the tables of C{DOC_TABLES} from C{pyproject.toml}. + + A table header always stands on a line of its own, at the start. + + """ + kept = [] + keep = True + for line in pyproject.splitlines(): + if line.startswith("["): + keep = not line.strip("[] ").startswith(DOC_TABLES) + if keep: + kept.append(line) + return kept + + +def main(base: str) -> int: + merge_base = git("merge-base", base, "HEAD").strip() + changes = git("diff", "--name-status", "--no-renames", merge_base, "HEAD") + for line in changes.splitlines(): + status, path = line.split("\t") + if path.endswith(".md") or path in IGNORED: + continue + compare: Callable[[str], object] + if path == "pyproject.toml": + compare = without_doc_tables + elif path.endswith(".py"): + compare = code_of + else: + print(f"{path}: not a documentation file") + return 1 + if status != "M": + print(f"{path}: added or removed") + return 1 + before = git("show", f"{merge_base}:{path}") + after = git("show", f"HEAD:{path}") + if compare(before) != compare(after): + print(f"{path}: the code changed") + return 1 + print("Only the documentation changed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1])) diff --git a/tests/conftest.py b/tests/conftest.py index fc0420d0d..ca4932c22 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -427,10 +427,20 @@ def current_opset() -> int: def pytest_collection_modifyitems(items: list[Function]): + """Sort the tests into the CI suites, one marker for each suite. + + The suite of an integration test outside the model suites depends on + the ``gpu`` marker. A test that trains, tunes or quantizes a model + carries it and goes to ``misc``, which runs on the GPU runner. Every + other test goes to ``cpu``, together with the unit tests, and runs + on a free runner. + + """ for item in items: path = str(item.fspath) if "/unittests/" in path: item.add_marker(pytest.mark.unit) + item.add_marker(pytest.mark.cpu) item.add_marker(pytest.mark.order(0)) elif "test_predefined_models.py" in path: item.add_marker(pytest.mark.predefined) @@ -441,13 +451,24 @@ def pytest_collection_modifyitems(items: list[Function]): elif "test_overfit_convergence.py" in path: item.add_marker(pytest.mark.overfit_convergence) item.add_marker(pytest.mark.order(4)) - else: + elif item.get_closest_marker("gpu"): item.add_marker(pytest.mark.misc) item.add_marker(pytest.mark.order(1)) + else: + item.add_marker(pytest.mark.cpu) + item.add_marker(pytest.mark.order(1)) def pytest_configure(config: Config): config.addinivalue_line("markers", "unit: mark test as a unit test") + config.addinivalue_line( + "markers", + "gpu: the test trains, tunes or quantizes a model, " + "so it belongs to the misc suite on the GPU runner", + ) + config.addinivalue_line( + "markers", "cpu: mark test as a test of the CPU suite" + ) config.addinivalue_line( "markers", "predefined: mark test as a predefined model test" ) @@ -463,7 +484,8 @@ def pytest_configure(config: Config): "markers", "combinations: mark test as a combinations test" ) config.addinivalue_line( - "markers", "misc: mark test as a miscellaneous test" + "markers", + "misc: mark test as a training test outside the model suites", ) config.addinivalue_line( "markers", diff --git a/tests/integration/test_callbacks.py b/tests/integration/test_callbacks.py index 32670a0e3..223a73c74 100644 --- a/tests/integration/test_callbacks.py +++ b/tests/integration/test_callbacks.py @@ -1,6 +1,7 @@ import json from pathlib import Path +import pytest import torch from luxonis_ml.data import LuxonisDataset from luxonis_ml.typing import Params @@ -8,6 +9,8 @@ from luxonis_train.config import Config from luxonis_train.core import LuxonisModel +pytestmark = pytest.mark.gpu + def test_callbacks(coco_dataset: LuxonisDataset, opts: Params, save_dir: Path): config_file = "luxonis_train/configs/segmentation_light_model.yaml" diff --git a/tests/integration/test_cli_commands.py b/tests/integration/test_cli_commands.py index cffb6ab44..58127a19c 100644 --- a/tests/integration/test_cli_commands.py +++ b/tests/integration/test_cli_commands.py @@ -34,6 +34,7 @@ def skip_if_no_aimet() -> None: pytest.skip("AIMET is not installed") +@pytest.mark.gpu def test_cli_command_success( coco_dataset: LuxonisDataset, tmp_path: Path, diff --git a/tests/integration/test_combinations.py b/tests/integration/test_combinations.py index 066dc4a00..618e6720b 100644 --- a/tests/integration/test_combinations.py +++ b/tests/integration/test_combinations.py @@ -157,7 +157,30 @@ def test_combinations( with subtests.test("export"): model.export() - with subtests.test("archive"): - model.archive() - model.finalize_run() + + +def test_combinations_archive( + parking_lot_dataset: LuxonisDataset, opts: Params +): + """Archive the model of `test_combinations` with one backbone. + + The archive used to run for every backbone, and it took two thirds + of the time of the suite: it compresses the ONNX file of a model + with eight heads with xz, which is slow. The backbone does not + change what the archive tests. The archive reads the inputs and + the outputs from the ONNX file, and the head configs from the + heads. The backbone changes only the output shapes, not the code + that runs. The export, which the backbone does change, still runs + for every backbone in `test_combinations`. + + EfficientRep is the backbone of the predefined detection models. + The test does not train, because the archive does not depend on + the weights. + + """ + opts |= { + "loader.params.dataset_name": parking_lot_dataset.identifier, + } + model = LuxonisModel(get_config("EfficientRep"), opts) + assert model.archive().exists() diff --git a/tests/integration/test_core.py b/tests/integration/test_core.py index ebdc88ff0..f5f4e6439 100644 --- a/tests/integration/test_core.py +++ b/tests/integration/test_core.py @@ -13,6 +13,7 @@ from luxonis_train.core import LuxonisModel +@pytest.mark.gpu @pytest.mark.skipif( sys.platform == "win32", reason="Tuning not supported on Windows" ) @@ -76,6 +77,7 @@ def test_weights_loading(cifar10_dataset: LuxonisDataset, opts: Params): ) +@pytest.mark.gpu def test_checkpoint( tmp_path: Path, opts: Params, coco_dataset: LuxonisDataset ): diff --git a/tests/integration/test_custom_model.py b/tests/integration/test_custom_model.py index 4e546319d..9916accc8 100644 --- a/tests/integration/test_custom_model.py +++ b/tests/integration/test_custom_model.py @@ -2,6 +2,7 @@ import tarfile from pathlib import Path +import pytest import torch from luxonis_ml.typing import Params from pytest_subtests import SubTests @@ -15,6 +16,8 @@ from luxonis_train.tasks import Tasks from luxonis_train.typing import Packet +pytestmark = pytest.mark.gpu + class CustomMultiInputLoader(BaseLoaderTorch): def __init__(self, **kwargs): diff --git a/tests/integration/test_freezing.py b/tests/integration/test_freezing.py index 9affe1c95..3e5535d44 100644 --- a/tests/integration/test_freezing.py +++ b/tests/integration/test_freezing.py @@ -6,6 +6,8 @@ from luxonis_train.core import LuxonisModel +pytestmark = pytest.mark.gpu + # The frozen head owns a parameter group for the whole run, so both # series are logged from the first epoch. Only the head's group is diff --git a/tests/integration/test_loader.py b/tests/integration/test_loader.py index 3a2c4656e..66582928e 100644 --- a/tests/integration/test_loader.py +++ b/tests/integration/test_loader.py @@ -48,6 +48,7 @@ def test_splits( assert len(model.pytorch_loaders[key]) == exp_len +@pytest.mark.gpu def test_parsing(opts: Params): opts |= { "loader.params.dataset_name": "parsed_coco_test", @@ -62,6 +63,7 @@ def test_parsing(opts: Params): model.train() +@pytest.mark.gpu def test_dummy_loader(opts: Params): config_file = "luxonis_train/configs/complex_model.yaml" opts = opts | { diff --git a/tests/integration/test_mlflow_logging.py b/tests/integration/test_mlflow_logging.py index e1a888c90..aaa6363bb 100644 --- a/tests/integration/test_mlflow_logging.py +++ b/tests/integration/test_mlflow_logging.py @@ -81,6 +81,7 @@ def forward(self, x: Tensor) -> Tensor: return self.model(x) +@pytest.mark.gpu @pytest.mark.timeout(TIMEOUT) def test_mlflow_logging(xor_dataset: LuxonisDataset, subtests: SubTests): model = LuxonisModel( @@ -329,6 +330,7 @@ def get_config(trainer_overrides: Params | None = None) -> Params: ) +@pytest.mark.gpu @pytest.mark.timeout(TIMEOUT) def test_mlflow_logging_with_first_epoch_validation( xor_dataset: LuxonisDataset, subtests: SubTests diff --git a/tests/integration/test_overfit_batches.py b/tests/integration/test_overfit_batches.py index 2613d30d5..9df318c38 100644 --- a/tests/integration/test_overfit_batches.py +++ b/tests/integration/test_overfit_batches.py @@ -7,6 +7,8 @@ from luxonis_train.core import LuxonisModel +pytestmark = pytest.mark.gpu + @pytest.fixture(autouse=True) def reset_deterministic_state() -> Generator[None]: diff --git a/tests/integration/test_resume_training.py b/tests/integration/test_resume_training.py index a508ec97f..5ba7b8643 100644 --- a/tests/integration/test_resume_training.py +++ b/tests/integration/test_resume_training.py @@ -49,6 +49,7 @@ def _get_detection_light_train_opts( } +@pytest.mark.gpu def test_resume_training_with_ema_does_not_crash( parking_lot_dataset: LuxonisDataset, strict_loading_original_ckpt: Path, @@ -121,6 +122,7 @@ def test_training_with_unexpected_checkpoint_key_fails_when_strict( model.train(weights=strict_loading_modified_model_ckpt) +@pytest.mark.gpu def test_training_with_unexpected_checkpoint_key_succeeds_when_not_strict( parking_lot_dataset: LuxonisDataset, strict_loading_modified_model_ckpt: Path, diff --git a/tests/integration/test_scheduler.py b/tests/integration/test_scheduler.py index 4460738b5..941e303d9 100644 --- a/tests/integration/test_scheduler.py +++ b/tests/integration/test_scheduler.py @@ -6,6 +6,8 @@ from luxonis_train.core import LuxonisModel +pytestmark = pytest.mark.gpu + def sequential_scheduler() -> dict[str, Any]: return { From bfe076abd2614f782c80dc389c372c0b48d984f9 Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Wed, 14 Oct 2026 17:42:37 -0600 Subject: [PATCH 02/11] Let the RVC2 HubAI conversion test fail for now Since 2026-10-08, the RVC2 conversion job on HubAI fails and gives no error. The test is now in the `cpu` suite, which stops at the first failure, so this failure stops the suite and cancels the GPU suites. Mark the `rvc2` case of `test_convert_with_hubai` as an expected failure with `HubApiError`. Any other error still fails the test. The marker is not strict, so the CI does not fail when HubAI works again. Remove the marker then. --- tests/integration/test_convert.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_convert.py b/tests/integration/test_convert.py index 8981c3adc..68b722177 100644 --- a/tests/integration/test_convert.py +++ b/tests/integration/test_convert.py @@ -1,6 +1,7 @@ from pathlib import Path import pytest +from hubai_sdk.errors import HubApiError from luxonis_ml.data import LuxonisDataset from luxonis_ml.typing import Params @@ -62,7 +63,21 @@ def test_convert_with_blobconverter( # TODO: reintroduce Hailo conversion when modelconv is released and hub-ai is updated accordingly # TODO: reintroduce RVC3 conversion when remote-side conversion issue is resolved -@pytest.mark.parametrize("platform", ["rvc2", "rvc4"]) +# TODO: remove the xfail when the RVC2 conversion on HubAI works again +@pytest.mark.parametrize( + "platform", + [ + pytest.param( + "rvc2", + marks=pytest.mark.xfail( + raises=HubApiError, + reason="Since 2026-10-08, the RVC2 conversion job on " + "HubAI fails and gives no error.", + ), + ), + "rvc4", + ], +) def test_convert_with_hubai( coco_dataset: LuxonisDataset, opts: Params, tmp_path: Path, platform: str ): From 8ab034c7957fb4d2cac96fdcaf11eb8ffba932f7 Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Wed, 14 Oct 2026 19:54:31 -0600 Subject: [PATCH 03/11] Fix the tests that fail on main Two tests started to fail after 2026-10-07. The causes are outside this repository. - The RVC4 conversion on HubAI fails in the same way as the RVC2 conversion. Mark both cases of `test_convert_with_hubai` as expected failures with `HubApiError`. The marker is not strict. Remove it when HubAI works again. - LuxonisML added `resize_mode` to the NN Archive config. Add the value to the expected config of `test_custom_model`. The value is `LETTERBOX`, because `keep_aspect_ratio` is on by default. --- tests/files/custom_archive_config.json | 3 ++- tests/integration/test_convert.py | 20 ++++++-------------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/tests/files/custom_archive_config.json b/tests/files/custom_archive_config.json index c82f005b8..78d83f7a4 100644 --- a/tests/files/custom_archive_config.json +++ b/tests/files/custom_archive_config.json @@ -31,7 +31,8 @@ ], "reverse_channels": null, "interleaved_to_planar": null, - "dai_type": "RGB888p" + "dai_type": "RGB888p", + "resize_mode": "LETTERBOX" } } ], diff --git a/tests/integration/test_convert.py b/tests/integration/test_convert.py index 68b722177..fd03f63c8 100644 --- a/tests/integration/test_convert.py +++ b/tests/integration/test_convert.py @@ -63,21 +63,13 @@ def test_convert_with_blobconverter( # TODO: reintroduce Hailo conversion when modelconv is released and hub-ai is updated accordingly # TODO: reintroduce RVC3 conversion when remote-side conversion issue is resolved -# TODO: remove the xfail when the RVC2 conversion on HubAI works again -@pytest.mark.parametrize( - "platform", - [ - pytest.param( - "rvc2", - marks=pytest.mark.xfail( - raises=HubApiError, - reason="Since 2026-10-08, the RVC2 conversion job on " - "HubAI fails and gives no error.", - ), - ), - "rvc4", - ], +# TODO: remove the xfail when the conversion on HubAI works again +@pytest.mark.xfail( + raises=HubApiError, + reason="Since 2026-10-08, the conversion jobs on HubAI fail and give " + "no error.", ) +@pytest.mark.parametrize("platform", ["rvc2", "rvc4"]) def test_convert_with_hubai( coco_dataset: LuxonisDataset, opts: Params, tmp_path: Path, platform: str ): From 46d61dce10f2d16df3b58138c5ede64fa498de3c Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Wed, 14 Oct 2026 20:37:51 -0600 Subject: [PATCH 04/11] Start the GPU suites after the CPU suite passes When the `cpu` suite failed, fail-fast cancelled the GPU suites. They had already run for some minutes, and GitHub bills these minutes. Now the GPU suites wait for the `cpu` suite, so a failure that it finds costs no time on the GPU runner. The run takes about 15 minutes longer, because this is the time of the `cpu` suite. The `tests` job runs the `cpu` suite, and the new `gpu-tests` job runs the GPU suites. A YAML anchor gives the two jobs the same steps. The `plan` job now selects the GPU suites only. The names of the checks do not change. --- .github/workflows/ci.yaml | 42 +++++++++++++++++++++++++++++---------- CONTRIBUTING.md | 3 ++- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b6522c662..4f9d345ac 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -76,17 +76,17 @@ permissions: contents: read jobs: - # The GPU runner is billed by the minute. This job selects the test + # The GPU runner is billed by the minute. This job selects the GPU test # suites, so that a pull request runs a GPU suite only when the suite # can find something new: - # - a pull request that changes only the documentation runs the - # CPU suite, see `scripts/only_docstrings_changed.py`; + # - a pull request that changes only the documentation runs no GPU + # suite, see `scripts/only_docstrings_changed.py`; # - a draft pull request skips the heavy suite. # A dispatch and a call from another workflow run every suite. plan: runs-on: ubuntu-latest outputs: - suites: ${{ steps.plan.outputs.suites }} + gpu_suites: ${{ steps.plan.outputs.gpu_suites }} code_changed: ${{ steps.plan.outputs.code_changed }} steps: - name: Checkout @@ -105,17 +105,17 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | code_changed=true - suites='["cpu", "predefined_light", "predefined_heavy", "combinations", "misc"]' + gpu_suites='["predefined_light", "predefined_heavy", "combinations", "misc"]' if [[ "$PULL_REQUEST" == true ]]; then if python3 scripts/only_docstrings_changed.py "$BASE_SHA"; then code_changed=false - suites='["cpu"]' + gpu_suites='[]' elif [[ "$DRAFT" == true ]]; then - suites='["cpu", "predefined_light", "combinations", "misc"]' + gpu_suites='["predefined_light", "combinations", "misc"]' fi fi echo "code_changed=$code_changed" >> "$GITHUB_OUTPUT" - echo "suites=$suites" >> "$GITHUB_OUTPUT" + echo "gpu_suites=$gpu_suites" >> "$GITHUB_OUTPUT" pre-commit: runs-on: ubuntu-latest @@ -338,11 +338,12 @@ jobs: # `pytest_collection_modifyitems` in `tests/conftest.py`. strategy: matrix: - suite: ${{ fromJSON(needs.plan.outputs.suites) }} + suite: [cpu] - runs-on: ${{ matrix.suite == 'cpu' && 'ubuntu-latest' || 'ubuntu-t4-4core' }} + runs-on: ubuntu-latest - steps: + # `gpu-tests` runs the same steps. + steps: &test-steps - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -410,6 +411,24 @@ jobs: if-no-files-found: ignore overwrite: true + # The GPU suites start only after the `cpu` suite passes. A failure that + # the `cpu` suite finds then costs no time on the GPU runner. The price + # is a longer run: the `cpu` suite takes about 15 minutes. + gpu-tests: + name: tests (${{ matrix.suite }}) + needs: + - plan + - tests + if: ${{ needs.plan.outputs.gpu_suites != '[]' }} + + strategy: + matrix: + suite: ${{ fromJSON(needs.plan.outputs.gpu_suites) }} + + runs-on: ubuntu-t4-4core + + steps: *test-steps + # Coverage is uploaded once, after all the suites have finished. Uploading # each suite on its own makes Codecov report the partial runs as the total. # For the same reason, a change of the documentation, which runs the CPU @@ -418,6 +437,7 @@ jobs: needs: - plan - tests + - gpu-tests if: ${{ needs.plan.outputs.code_changed == 'true' }} runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fea944ad..0ca5066b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,7 +142,8 @@ Our GitHub Actions workflow is run when a new PR is opened. 1. If all previous checks pass, the [tests](#tests) are run. The tests run in five suites, one job each. -The GPU runner is billed by the minute, so only the suites that need a GPU run there: +The GPU runner is billed by the minute, so only the suites that need a GPU run there. +The GPU suites start only after the `cpu` suite passes, so that a failure it finds costs no time on the GPU runner: | Suite | Tests | Runner | | -------------------------------------- | ------------------------------------------------------------------ | --------------- | From 52e80042326d58f2aa3876911519e461b13c3d27 Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Wed, 14 Oct 2026 21:45:52 -0600 Subject: [PATCH 05/11] Show a clean name for a skipped GPU test job A skipped matrix job shows its name without the expressions filled in. When a change of the documentation only skips the GPU suites, the check showed `tests (${{ matrix.suite }})`. Name the job `tests` instead. GitHub adds the suite to the name of each matrix job, so the names of the checks do not change. --- .github/workflows/ci.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4f9d345ac..16e87f2a5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -415,7 +415,9 @@ jobs: # the `cpu` suite finds then costs no time on the GPU runner. The price # is a longer run: the `cpu` suite takes about 15 minutes. gpu-tests: - name: tests (${{ matrix.suite }}) + # GitHub adds the suite, as in "tests (misc)". A skipped job shows + # "tests" instead of the raw expression of a name with the suite in it. + name: tests needs: - plan - tests From c3c19b0b5f4dd2a6a92c75094e7a2acdf2681118 Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Thu, 15 Oct 2026 00:57:08 -0600 Subject: [PATCH 06/11] Clean up the comments and docstrings of the CI test split - Check out the head commit of the pull request in the `plan` job, not the head branch. A pull request from a fork has no head branch in this repository. - Remove the workflow comments that repeat the code. Make the other comments and the docstrings shorter. - Merge the two branches of the suite hook that add the same order. - Name the runners in CONTRIBUTING.md, and tell that CI does not run the `overfit_convergence` suite. - Use Google docstring markup in `scripts/only_docstrings_changed.py`, as the package does. --- .github/workflows/ci.yaml | 27 ++++++-------------- CONTRIBUTING.md | 21 ++++++++-------- scripts/only_docstrings_changed.py | 35 ++++++++------------------ tests/conftest.py | 16 ++++-------- tests/integration/test_combinations.py | 21 ++++++---------- 5 files changed, 40 insertions(+), 80 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 16e87f2a5..cf5e715ac 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -53,7 +53,6 @@ on: description: "HubAI API key" required: true pull_request: - # `ready_for_review` runs the suites that a draft skips. types: [opened, synchronize, reopened, ready_for_review] paths: - ".python-version" @@ -76,13 +75,8 @@ permissions: contents: read jobs: - # The GPU runner is billed by the minute. This job selects the GPU test - # suites, so that a pull request runs a GPU suite only when the suite - # can find something new: - # - a pull request that changes only the documentation runs no GPU - # suite, see `scripts/only_docstrings_changed.py`; - # - a draft pull request skips the heavy suite. - # A dispatch and a call from another workflow run every suite. + # Selects the GPU suites. A change of the documentation only runs none + # (see scripts/only_docstrings_changed.py), and a draft skips the heavy one. plan: runs-on: ubuntu-latest outputs: @@ -93,7 +87,7 @@ jobs: if: ${{ github.event_name == 'pull_request' && !inputs.train_ref }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 persist-credentials: false @@ -333,16 +327,13 @@ jobs: - config-test - doctests - # The `cpu` suite holds the unit tests and the integration tests - # that do not train, so it runs on a free runner. See - # `pytest_collection_modifyitems` in `tests/conftest.py`. + # The suites come from `pytest_collection_modifyitems` in tests/conftest.py. strategy: matrix: suite: [cpu] runs-on: ubuntu-latest - # `gpu-tests` runs the same steps. steps: &test-steps - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -411,12 +402,9 @@ jobs: if-no-files-found: ignore overwrite: true - # The GPU suites start only after the `cpu` suite passes. A failure that - # the `cpu` suite finds then costs no time on the GPU runner. The price - # is a longer run: the `cpu` suite takes about 15 minutes. + # Waits for the `cpu` suite, so that its failures cost no GPU time. gpu-tests: - # GitHub adds the suite, as in "tests (misc)". A skipped job shows - # "tests" instead of the raw expression of a name with the suite in it. + # GitHub appends the suite. A skipped job then shows "tests". name: tests needs: - plan @@ -433,8 +421,7 @@ jobs: # Coverage is uploaded once, after all the suites have finished. Uploading # each suite on its own makes Codecov report the partial runs as the total. - # For the same reason, a change of the documentation, which runs the CPU - # suite only, uploads nothing. It cannot change the coverage. + # For the same reason, a change of the documentation only uploads nothing. coverage: needs: - plan diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ca5066b1..9c544198c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,7 +125,7 @@ This command will run all tests and generate HTML coverage report. > [!IMPORTANT] > Mark an integration test with `@pytest.mark.gpu` when it trains, tunes or quantizes a model. -> CI runs these tests on the GPU runner, and all the other tests on a free CPU runner. +> CI runs these tests on the GPU runner and all the other tests on a CPU runner. > A test without the marker still passes on the CPU, but it no longer covers the GPU code of the training, such as mixed precision. > [!IMPORTANT] @@ -142,15 +142,16 @@ Our GitHub Actions workflow is run when a new PR is opened. 1. If all previous checks pass, the [tests](#tests) are run. The tests run in five suites, one job each. -The GPU runner is billed by the minute, so only the suites that need a GPU run there. -The GPU suites start only after the `cpu` suite passes, so that a failure it finds costs no time on the GPU runner: - -| Suite | Tests | Runner | -| -------------------------------------- | ------------------------------------------------------------------ | --------------- | -| `cpu` | The unit tests, and the integration tests without the `gpu` marker | Free CPU runner | -| `misc` | The integration tests with the `gpu` marker | GPU runner | -| `predefined_light`, `predefined_heavy` | `test_predefined_models.py`, the light and the heavy variants | GPU runner | -| `combinations` | `test_combinations.py` | GPU runner | +The GPU suites start only after the `cpu` suite passes, so that a failure there costs no GPU time: + +| Suite | Tests | Runner | +| -------------------------------------- | ------------------------------------------------------------------ | ----------------- | +| `cpu` | The unit tests, and the integration tests without the `gpu` marker | `ubuntu-latest` | +| `misc` | The integration tests with the `gpu` marker | `ubuntu-t4-4core` | +| `predefined_light`, `predefined_heavy` | `test_predefined_models.py`, the light and the heavy variants | `ubuntu-t4-4core` | +| `combinations` | `test_combinations.py` | `ubuntu-t4-4core` | + +CI does not run `test_overfit_convergence.py`, which has its own `overfit_convergence` marker. Two rules keep the GPU suites from running when they cannot find anything new: diff --git a/scripts/only_docstrings_changed.py b/scripts/only_docstrings_changed.py index 5a6cc2b98..1dc502e3e 100644 --- a/scripts/only_docstrings_changed.py +++ b/scripts/only_docstrings_changed.py @@ -1,26 +1,17 @@ -"""Tell whether the changes since a base commit touch only -documentation. +"""Tell whether a branch changes only its documentation. -The CI runs the GPU test suites only when the code changes. The script -exits with 0 when each changed file is one of these: +Exit with 0 when each changed file is a Markdown file, ``.gitignore``, +``.pre-commit-config.yaml``, ``pyproject.toml`` with changes in +``DOC_TABLES`` only, or a Python file whose syntax tree without +docstrings stays the same. Otherwise exit with 1 and name the file. - - a Python file whose syntax tree without docstrings stays the same. - Comments and formatting never reach the syntax tree; - - C{pyproject.toml} with changes in the tables of the linters and the - API docs only, see C{DOC_TABLES}; - - a Markdown file, C{.gitignore} or C{.pre-commit-config.yaml}. - -Any other change, an added or a removed Python file included, exits -with 1 and names the file. - -Usage: C{python scripts/only_docstrings_changed.py } +Usage: ``python scripts/only_docstrings_changed.py `` """ import ast import subprocess import sys -from collections.abc import Callable DOC_TABLES = ( "tool.docformatter", @@ -51,11 +42,10 @@ def is_bare_string(statement: ast.stmt) -> bool: def code_of(source: str) -> str: - """Dump the syntax tree of C{source} without its docstrings. + """Dump the syntax tree of ``source`` without its docstrings. - A string literal that stands as a statement of its own has no - effect, wherever it is. This covers the docstrings of attributes - too. + A string literal that stands as a statement has no effect, so the + docstrings of attributes go too. """ tree = ast.parse(source) @@ -72,11 +62,7 @@ def code_of(source: str) -> str: def without_doc_tables(pyproject: str) -> list[str]: - """Drop the tables of C{DOC_TABLES} from C{pyproject.toml}. - - A table header always stands on a line of its own, at the start. - - """ + """Drop the tables of ``DOC_TABLES`` from ``pyproject.toml``.""" kept = [] keep = True for line in pyproject.splitlines(): @@ -94,7 +80,6 @@ def main(base: str) -> int: status, path = line.split("\t") if path.endswith(".md") or path in IGNORED: continue - compare: Callable[[str], object] if path == "pyproject.toml": compare = without_doc_tables elif path.endswith(".py"): diff --git a/tests/conftest.py b/tests/conftest.py index ca4932c22..90dc1561f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -429,11 +429,8 @@ def current_opset() -> int: def pytest_collection_modifyitems(items: list[Function]): """Sort the tests into the CI suites, one marker for each suite. - The suite of an integration test outside the model suites depends on - the ``gpu`` marker. A test that trains, tunes or quantizes a model - carries it and goes to ``misc``, which runs on the GPU runner. Every - other test goes to ``cpu``, together with the unit tests, and runs - on a free runner. + An integration test outside the model suites goes to ``misc`` when + it has the ``gpu`` marker, and to ``cpu`` otherwise. """ for item in items: @@ -451,11 +448,9 @@ def pytest_collection_modifyitems(items: list[Function]): elif "test_overfit_convergence.py" in path: item.add_marker(pytest.mark.overfit_convergence) item.add_marker(pytest.mark.order(4)) - elif item.get_closest_marker("gpu"): - item.add_marker(pytest.mark.misc) - item.add_marker(pytest.mark.order(1)) else: - item.add_marker(pytest.mark.cpu) + gpu = item.get_closest_marker("gpu") + item.add_marker(pytest.mark.misc if gpu else pytest.mark.cpu) item.add_marker(pytest.mark.order(1)) @@ -463,8 +458,7 @@ def pytest_configure(config: Config): config.addinivalue_line("markers", "unit: mark test as a unit test") config.addinivalue_line( "markers", - "gpu: the test trains, tunes or quantizes a model, " - "so it belongs to the misc suite on the GPU runner", + "gpu: mark test as a test that trains, tunes or quantizes a model", ) config.addinivalue_line( "markers", "cpu: mark test as a test of the CPU suite" diff --git a/tests/integration/test_combinations.py b/tests/integration/test_combinations.py index 618e6720b..fdfa9d962 100644 --- a/tests/integration/test_combinations.py +++ b/tests/integration/test_combinations.py @@ -163,20 +163,13 @@ def test_combinations( def test_combinations_archive( parking_lot_dataset: LuxonisDataset, opts: Params ): - """Archive the model of `test_combinations` with one backbone. - - The archive used to run for every backbone, and it took two thirds - of the time of the suite: it compresses the ONNX file of a model - with eight heads with xz, which is slow. The backbone does not - change what the archive tests. The archive reads the inputs and - the outputs from the ONNX file, and the head configs from the - heads. The backbone changes only the output shapes, not the code - that runs. The export, which the backbone does change, still runs - for every backbone in `test_combinations`. - - EfficientRep is the backbone of the predefined detection models. - The test does not train, because the archive does not depend on - the weights. + """Archive the model of `test_combinations` once. + + The archive reads the inputs and the outputs from the ONNX file and + the head configs from the heads. The backbone changes only the + output shapes, so one backbone is enough. `test_combinations` + exports every backbone. The archive does not depend on the weights, + so the test does not train. """ opts |= { From c354332e190f35197de90456519276b4c8ab54ba Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Mon, 19 Oct 2026 08:37:37 -0400 Subject: [PATCH 07/11] ci: run checks for documentation-only changes Required checks stayed pending when path filters skipped documentation-only pull requests. --- .github/workflows/ci.yaml | 3 ++- tests/unittests/test_ci.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 tests/unittests/test_ci.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cf5e715ac..b37d5ee0a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -56,6 +56,8 @@ on: types: [opened, synchronize, reopened, ready_for_review] paths: - ".python-version" + - ".gitignore" + - "**/*.md" - "pyproject.toml" - "uv.lock" - "requirements*.txt" @@ -64,7 +66,6 @@ on: - "luxonis_train/**" - "tests/**" - ".github/workflows/ci.yaml" - - "!**/*.md" concurrency: # External callers can provide an isolated concurrency group. diff --git a/tests/unittests/test_ci.py b/tests/unittests/test_ci.py new file mode 100644 index 000000000..dc206f2f0 --- /dev/null +++ b/tests/unittests/test_ci.py @@ -0,0 +1,14 @@ +from pathlib import Path + + +def test_documentation_changes_start_ci(): + workflow = Path(".github/workflows/ci.yaml").read_text() + pull_request = workflow.partition(" pull_request:\n")[2].partition( + "\n\n" + )[0] + documentation_paths = { + line.strip() + for line in pull_request.splitlines() + if ".md" in line or ".gitignore" in line + } + assert documentation_paths == {'- ".gitignore"', '- "**/*.md"'} From 8ca24266615aa137726d3f49b06a967894827d47 Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Mon, 19 Oct 2026 08:37:37 -0400 Subject: [PATCH 08/11] tests: run archive-only combinations test on CPU The archive test does not train and does not need the GPU runner. --- tests/conftest.py | 5 ++++- tests/integration/test_combinations.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 90dc1561f..af9e4e87d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -442,7 +442,10 @@ def pytest_collection_modifyitems(items: list[Function]): elif "test_predefined_models.py" in path: item.add_marker(pytest.mark.predefined) item.add_marker(pytest.mark.order(2)) - elif "test_combinations.py" in path: + elif ( + "test_combinations.py" in path + and item.name != "test_combinations_archive" + ): item.add_marker(pytest.mark.combinations) item.add_marker(pytest.mark.order(3)) elif "test_overfit_convergence.py" in path: diff --git a/tests/integration/test_combinations.py b/tests/integration/test_combinations.py index fdfa9d962..8d5c91d2a 100644 --- a/tests/integration/test_combinations.py +++ b/tests/integration/test_combinations.py @@ -161,7 +161,9 @@ def test_combinations( def test_combinations_archive( - parking_lot_dataset: LuxonisDataset, opts: Params + parking_lot_dataset: LuxonisDataset, + opts: Params, + request: pytest.FixtureRequest, ): """Archive the model of `test_combinations` once. @@ -172,6 +174,7 @@ def test_combinations_archive( so the test does not train. """ + assert request.node.get_closest_marker("cpu") is not None opts |= { "loader.params.dataset_name": parking_lot_dataset.identifier, } From 82f350dcf54c06032dbbad6567aaa0300261bdf2 Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Mon, 19 Oct 2026 08:37:37 -0400 Subject: [PATCH 09/11] tests: restore HubAI conversion checks HubAI conversion works again. The temporary xfail skipped conversion coverage. --- tests/integration/test_convert.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/integration/test_convert.py b/tests/integration/test_convert.py index fd03f63c8..8981c3adc 100644 --- a/tests/integration/test_convert.py +++ b/tests/integration/test_convert.py @@ -1,7 +1,6 @@ from pathlib import Path import pytest -from hubai_sdk.errors import HubApiError from luxonis_ml.data import LuxonisDataset from luxonis_ml.typing import Params @@ -63,12 +62,6 @@ def test_convert_with_blobconverter( # TODO: reintroduce Hailo conversion when modelconv is released and hub-ai is updated accordingly # TODO: reintroduce RVC3 conversion when remote-side conversion issue is resolved -# TODO: remove the xfail when the conversion on HubAI works again -@pytest.mark.xfail( - raises=HubApiError, - reason="Since 2026-10-08, the conversion jobs on HubAI fail and give " - "no error.", -) @pytest.mark.parametrize("platform", ["rvc2", "rvc4"]) def test_convert_with_hubai( coco_dataset: LuxonisDataset, opts: Params, tmp_path: Path, platform: str From 6c96acba4cfb27ccd1e3242d5f8cb9745483583e Mon Sep 17 00:00:00 2001 From: Martin Kozlovsky Date: Mon, 19 Oct 2026 09:33:26 -0400 Subject: [PATCH 10/11] xfail --- tests/integration/test_convert.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/test_convert.py b/tests/integration/test_convert.py index 8981c3adc..7d4e61763 100644 --- a/tests/integration/test_convert.py +++ b/tests/integration/test_convert.py @@ -1,6 +1,7 @@ from pathlib import Path import pytest +from hubai_sdk.errors import HubApiError from luxonis_ml.data import LuxonisDataset from luxonis_ml.typing import Params @@ -62,6 +63,8 @@ def test_convert_with_blobconverter( # TODO: reintroduce Hailo conversion when modelconv is released and hub-ai is updated accordingly # TODO: reintroduce RVC3 conversion when remote-side conversion issue is resolved +# TODO: remove the xfail when the conversion on HubAI works again +@pytest.mark.xfail(raises=HubApiError) @pytest.mark.parametrize("platform", ["rvc2", "rvc4"]) def test_convert_with_hubai( coco_dataset: LuxonisDataset, opts: Params, tmp_path: Path, platform: str From 2fe38e6d526787eec24bfb5fed4d18146189254f Mon Sep 17 00:00:00 2001 From: klemen1999 Date: Tue, 20 Oct 2026 09:53:14 +0200 Subject: [PATCH 11/11] fixed tests --- tests/integration/test_custom_model.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/integration/test_custom_model.py b/tests/integration/test_custom_model.py index 9916accc8..8b6ac67da 100644 --- a/tests/integration/test_custom_model.py +++ b/tests/integration/test_custom_model.py @@ -4,6 +4,7 @@ import pytest import torch +from luxonis_ml.nn_archive.config_building_blocks import PreprocessingBlock from luxonis_ml.typing import Params from pytest_subtests import SubTests from torch import Tensor, nn @@ -143,6 +144,9 @@ def test_custom_model(opts: Params, tmp_path: Path, subtests: SubTests): correct_archive_config = json.loads( Path("tests/files/custom_archive_config.json").read_text() ) + if "resize_mode" not in PreprocessingBlock.model_fields: + for input_config in correct_archive_config["model"]["inputs"]: + input_config["preprocessing"].pop("resize_mode", None) with tarfile.open(archive_path) as tar: extracted_cfg = tar.extractfile("config.json")