Skip to content
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -178,5 +178,5 @@ lightning_logs/
mlruns

# application data
data/
logs/
Comment on lines -181 to -182
Copy link
Contributor

Choose a reason for hiding this comment

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

The data and logs folders sit under application/backend/ usually. But why even specify the path? data/ and logs/ should ignore all data and logs folders recursively

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The problem is that it then ignores all the python files in src/anomalib/data folder as well.

Copy link
Contributor

Choose a reason for hiding this comment

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

I see, what how about application/**/data/ application/**/logs/ then?

application/data/
application/logs/
2 changes: 1 addition & 1 deletion application/ui/tests/main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { expect, test } from '@geti-inspect/test-fixtures';

test.describe('Geti Inspect', () => {
test('Allows users to inspect', async ({ page }) => {
await page.goto('/');
await page.goto('/', { waitUntil: 'domcontentloaded' });

await expect(page.getByText(/Geti Inspect/i)).toBeVisible();
});
Expand Down
4 changes: 2 additions & 2 deletions src/anomalib/cli/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import logging

from pkg_resources import Requirement
from packaging.requirements import Requirement
from rich.console import Console
from rich.logging import RichHandler

Expand Down Expand Up @@ -62,7 +62,7 @@ def anomalib_install(option: str = "full", verbose: bool = False) -> int:
elif option in requirements_dict:
requirements.extend(requirements_dict[option])
elif option is not None:
requirements.append(Requirement.parse(option))
requirements.append(Requirement(option))

# Parse requirements into torch and other requirements.
# This is done to parse the correct version of torch (cpu/cuda).
Expand Down
32 changes: 20 additions & 12 deletions src/anomalib/cli/utils/installation.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
from pathlib import Path
from warnings import warn

from pkg_resources import Requirement
from packaging.requirements import Requirement
from packaging.version import Version

AVAILABLE_TORCH_VERSIONS = {
"2.0.0": {"torchvision": "0.15.1", "cuda": ("11.7", "11.8")},
Expand Down Expand Up @@ -66,7 +67,7 @@ def get_requirements(module: str = "anomalib") -> dict[str, list[Requirement]]:
if isinstance(requirement_extra, list) and len(requirement_extra) > 1:
extra = requirement_extra[-1].split("==")[-1].strip("'\"")
requirement_name_ = requirement_extra[0]
requirement_ = Requirement.parse(requirement_name_)
requirement_ = Requirement(requirement_name_)
if extra in extra_requirement:
extra_requirement[extra].append(requirement_)
else:
Expand Down Expand Up @@ -114,9 +115,9 @@ def parse_requirements(
other_requirements: list[str] = []

for requirement in requirements:
if requirement.unsafe_name == "torch":
if requirement.name.lower() == "torch":
torch_requirement = str(requirement)
if len(requirement.specs) > 1:
if len(requirement.specifier) > 1:
warn(
"requirements.txt contains. Please remove other versions of torch from requirements.",
stacklevel=2,
Expand Down Expand Up @@ -333,20 +334,27 @@ def get_torch_install_args(requirement: str | Requirement) -> list[str]:
True
"""
if isinstance(requirement, str):
requirement = Requirement.parse(requirement)
requirement = Requirement(requirement)

# NOTE: This does not take into account if the requirement has multiple versions
# such as torch<2.0.1,>=1.13.0
if len(requirement.specs) < 1:
if not requirement.specifier:
return [str(requirement)]
select_spec_idx = 0
for i, spec in enumerate(requirement.specs):
if "=" in spec[0]:
select_spec_idx = i
preferred_operators = ("==", "<=", "<", ">=", ">")
selected_spec = None
for operator in preferred_operators:
for spec in requirement.specifier:
if spec.operator == operator:
selected_spec = spec
break
if selected_spec is not None:
break
operator, version = requirement.specs[select_spec_idx]
if selected_spec is None:
selected_spec = next(iter(requirement.specifier))
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

if not requirement.specifier may not reliably detect an empty SpecifierSet (truthiness isn’t guaranteed to reflect emptiness). If the set is empty and this branch doesn’t trigger, next(iter(requirement.specifier)) will raise StopIteration. Use an explicit emptiness check based on iteration (e.g., materialize once into a list and reuse it) so the early return is guaranteed for no-specifier requirements like "torch".

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback

operator = selected_spec.operator
version = selected_spec.version
Comment on lines 342 to +359
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

This selection logic picks a single specifier and ignores the full SpecifierSet, which can produce an install constraint that violates the original requirement when multiple specifiers are provided (e.g., torch>=2.1,<=2.0.1 would incorrectly select <=2.0.1). A more correct approach is to evaluate available torch versions against the full requirement.specifier and select an appropriate version deterministically (e.g., highest available version that satisfies the entire set), otherwise fall back with a warning.

Copilot uses AI. Check for mistakes.
if version not in AVAILABLE_TORCH_VERSIONS:
version = max(AVAILABLE_TORCH_VERSIONS.keys())
version = max(AVAILABLE_TORCH_VERSIONS.keys(), key=Version)
warn(
Comment on lines 360 to 362
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

max(AVAILABLE_TORCH_VERSIONS.keys()) compares version strings lexicographically, which can select the wrong 'latest' version (e.g., '2.10.0' sorts before '2.9.0'). Use a proper version comparison (e.g., packaging.version.Version) when selecting the maximum available Torch version.

Copilot uses AI. Check for mistakes.
f"Torch Version will be selected as {version}.",
Comment on lines 360 to 363
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The fallback branch that selects the latest available torch version when an unsupported version is requested is updated to use Version ordering. Please add/extend a unit test to cover this branch (including asserting the selected version is the semver-highest key and that a warning is emitted), since this behavior is important for correctness and was changed in this PR.

Copilot uses AI. Check for mistakes.
stacklevel=2,
Expand Down
33 changes: 18 additions & 15 deletions tests/unit/cli/test_installation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@

"""Tests for installation utils."""

import os
import tempfile
from pathlib import Path

import pytest
from pkg_resources import Requirement
from packaging.requirements import Requirement
from pytest_mock import MockerFixture

from anomalib.cli.utils.installation import (
Expand All @@ -34,21 +33,23 @@ def requirements_file() -> Path:
def test_get_requirements(mocker: MockerFixture) -> None:
"""Test that get_requirements returns the expected dictionary of requirements."""
requirements = get_requirements("anomalib")

assert isinstance(requirements, dict)
Comment on lines 35 to 37
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

This test calls get_requirements(\"anomalib\") before stubbing installation.requires, which makes the first half of the test depend on the actual installed package metadata (can be absent or differ in CI). Make the test deterministic by monkeypatching installation.requires before the first call to get_requirements, returning a controlled list of requirement strings, and assert the resulting dict contents (extras/groups and parsed requirements), not just the types.

Copilot uses AI. Check for mistakes.
assert len(requirements) > 0
for reqs in requirements.values():
assert isinstance(reqs, list)
for req in reqs:
assert isinstance(req, Requirement)

mocker.patch("anomalib.cli.utils.installation.requires", return_value=None)
assert get_requirements() == {}


def test_parse_requirements() -> None:
"""Test that parse_requirements returns the expected tuple of requirements."""
requirements = [
Requirement.parse("torch==2.0.0"),
Requirement.parse("onnx>=1.8.1"),
Requirement("torch==2.0.0"),
Requirement("onnx>=1.8.1"),
]
torch_req, other_reqs = parse_requirements(requirements)
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

This test is asserting get_requirements() == {} after patching get_cuda_version, but get_requirements doesn’t use get_cuda_version. Also, requires is still patched to return a non-empty list, so get_requirements() will continue returning requirements, making the final assertion incorrect/flaky. Patch anomalib.cli.utils.installation.requires to return None (or stop/reset the original patch) before asserting the empty dict.

Suggested change
torch_req, other_reqs = parse_requirements(requirements)
mocker.patch("anomalib.cli.utils.installation.get_cuda_version", return_value=None)
# When no package name is provided and `requires` returns None, get_requirements should return an empty dict.
mocker.patch("anomalib.cli.utils.installation.requires", return_value=None)

Copilot uses AI. Check for mistakes.
assert isinstance(torch_req, str)
Expand All @@ -57,28 +58,30 @@ def test_parse_requirements() -> None:
assert other_reqs == ["onnx>=1.8.1"]

requirements = [
Requirement.parse("torch<=2.0.1, >=1.8.1"),
Requirement("torch<=2.0.1, >=1.8.1"),
]
torch_req, other_reqs = parse_requirements(requirements)
assert torch_req == "torch<=2.0.1,>=1.8.1"
assert other_reqs == []

requirements = [
Requirement.parse("onnx>=1.8.1"),
Requirement("onnx>=1.8.1"),
]
with pytest.raises(ValueError, match=r"Could not find torch requirement."):
parse_requirements(requirements)


def test_get_cuda_version_with_version_file(mocker: MockerFixture, tmp_path: Path) -> None:
"""Test that get_cuda_version returns the expected CUDA version when version file exists."""
tmp_path = tmp_path / "cuda"
tmp_path.mkdir()
mocker.patch.dict(os.environ, {"CUDA_HOME": str(tmp_path)})
version_file = tmp_path / "version.json"
version_file.write_text('{"cuda": {"version": "11.2.0"}}')
mock_run = mocker.patch("anomalib.cli.utils.installation.Path.exists", return_value=False)
mock_run = mocker.patch("os.popen")
mock_run.return_value.read.return_value = "Build cuda_11.2.r11.2/compiler.00000_0"
assert get_cuda_version() == "11.2"

mock_run = mocker.patch("os.popen")
mock_run.side_effect = FileNotFoundError
assert get_cuda_version() is None


def test_get_cuda_version_with_nvcc(mocker: MockerFixture) -> None:
"""Test that get_cuda_version returns the expected CUDA version when nvcc is available."""
Expand Down Expand Up @@ -123,7 +126,7 @@ def test_get_hardware_suffix(mocker: MockerFixture) -> None:

def test_get_torch_install_args(mocker: MockerFixture) -> None:
"""Test that get_torch_install_args returns the expected install arguments."""
requirement = Requirement.parse("torch>=2.1.1")
requirement = Requirement("torch>=2.1.1")
mocker.patch("anomalib.cli.utils.installation.platform.system", return_value="Linux")
mocker.patch("anomalib.cli.utils.installation.get_hardware_suffix", return_value="cpu")
install_args = get_torch_install_args(requirement)
Expand All @@ -136,7 +139,7 @@ def test_get_torch_install_args(mocker: MockerFixture) -> None:
for arg in expected_args:
assert arg in install_args

requirement = Requirement.parse("torch>=1.13.0,<=2.0.1")
requirement = Requirement("torch>=1.13.0,<=2.0.1")
mocker.patch("anomalib.cli.utils.installation.get_hardware_suffix", return_value="cu111")
install_args = get_torch_install_args(requirement)
expected_args = [
Expand All @@ -146,7 +149,7 @@ def test_get_torch_install_args(mocker: MockerFixture) -> None:
for arg in expected_args:
assert arg in install_args

requirement = Requirement.parse("torch==2.0.1")
requirement = Requirement("torch==2.0.1")
expected_args = [
"--extra-index-url",
"https://download.pytorch.org/whl/cu111",
Expand All @@ -161,7 +164,7 @@ def test_get_torch_install_args(mocker: MockerFixture) -> None:
assert install_args == ["torch"]

mocker.patch("anomalib.cli.utils.installation.platform.system", return_value="Darwin")
requirement = Requirement.parse("torch==2.0.1")
requirement = Requirement("torch==2.0.1")
install_args = get_torch_install_args(requirement)
assert install_args == ["torch==2.0.1"]

Expand Down
Loading