Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/oumi/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
from oumi.cli.synth import synth
from oumi.cli.train import train
from oumi.cli.tune import tune
from oumi.exceptions import OumiConfigError
from oumi.utils.logging import should_use_rich_logging

_ASCII_LOGO = r"""
Expand Down Expand Up @@ -365,6 +366,9 @@ def run():
telemetry = TelemetryManager.get_instance()
with telemetry.capture_operation(event_name, event_properties):
return app()
except OumiConfigError as e:
CONSOLE.print(f"[red]Error: {e}[/red]")
sys.exit(1)
except Exception as e:
tb_str = traceback.format_exc()
CONSOLE.print(tb_str)
Expand Down
3 changes: 3 additions & 0 deletions src/oumi/core/configs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,14 @@
from oumi.core.configs.synthesis_config import SynthesisConfig
from oumi.core.configs.training_config import TrainingConfig
from oumi.core.configs.tuning_config import TuningConfig
from oumi.exceptions import OumiConfigError, OumiConfigFileNotFoundError

__all__ = [
"AsyncEvaluationConfig",
"AutoWrapPolicy",
"BackwardPrefetch",
"BaseConfig",
"OumiConfigFileNotFoundError",
"DataParams",
"DatasetParams",
"DatasetSplit",
Expand Down Expand Up @@ -192,6 +194,7 @@
"MixedPrecisionDtype",
"MixtureStrategy",
"ModelParams",
"OumiConfigError",
"PeftParams",
"PeftSaveMode",
"ProfilerParams",
Expand Down
82 changes: 55 additions & 27 deletions src/oumi/core/configs/base_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,15 @@
from typing import Any, TypeVar, cast

from omegaconf import OmegaConf
from omegaconf.errors import OmegaConfBaseException

from oumi.core.configs.params.base_params import BaseParams
from oumi.exceptions import (
OumiConfigError,
OumiConfigFileNotFoundError,
OumiConfigParsingError,
OumiConfigTypeError,
)

T = TypeVar("T", bound="BaseConfig")

Expand Down Expand Up @@ -128,6 +135,10 @@ def _read_config_without_interpolation(config_path: str) -> str:
Returns:
str: The stringified configuration.
"""
if not Path(config_path).is_file():
raise OumiConfigFileNotFoundError(
f"Config file not found or path is not a file: {config_path}"
)
with open(config_path) as f:
stringified_config = f.read()
pattern = r"(?<!\\)\$\{" # Matches "${" but not "\${"
Expand Down Expand Up @@ -165,7 +176,11 @@ def to_yaml(self, config_path: str | Path | StringIO) -> None:
+ "\n".join(f"- {path}" for path in sorted(removed_paths))
)

OmegaConf.save(config=processed_config, f=config_path)
try:
OmegaConf.save(config=processed_config, f=config_path)
except OSError as e:
# handle missing parent folder
raise OumiConfigError(f"Failed to save config to {config_path}: {e}") from e

@classmethod
def from_yaml(
Expand All @@ -181,15 +196,24 @@ def from_yaml(
Returns:
BaseConfig: The merged configuration object.
"""
schema = OmegaConf.structured(cls)
if ignore_interpolation:
stringified_config = _read_config_without_interpolation(str(config_path))
file_config = OmegaConf.create(stringified_config)
else:
file_config = OmegaConf.load(config_path)
config = OmegaConf.to_object(OmegaConf.merge(schema, file_config))
if not Path(config_path).is_file():
raise OumiConfigFileNotFoundError(
f"Config file not found or path is not a file: {config_path}"
)
try:
schema = OmegaConf.structured(cls)
if ignore_interpolation:
stringified_config = _read_config_without_interpolation(
str(config_path)
)
file_config = OmegaConf.create(stringified_config)
else:
file_config = OmegaConf.load(config_path)
config = OmegaConf.to_object(OmegaConf.merge(schema, file_config))
except OmegaConfBaseException as e:
raise OumiConfigParsingError(e) from e
if not isinstance(config, cls):
raise TypeError(f"config is not {cls}")
raise OumiConfigTypeError(config_type=cls, config_value=config)
return cast(T, config)

@classmethod
Expand All @@ -202,11 +226,14 @@ def from_str(cls: type[T], config_str: str) -> T:
Returns:
BaseConfig: The configuration object.
"""
schema = OmegaConf.structured(cls)
file_config = OmegaConf.create(config_str)
config = OmegaConf.to_object(OmegaConf.merge(schema, file_config))
try:
schema = OmegaConf.structured(cls)
file_config = OmegaConf.create(config_str)
config = OmegaConf.to_object(OmegaConf.merge(schema, file_config))
except OmegaConfBaseException as e:
raise OumiConfigParsingError(e) from e
if not isinstance(config, cls):
raise TypeError(f"config is not {cls}")
raise OumiConfigTypeError(config_type=cls, config_value=config)
return cast(T, config)

@classmethod
Expand Down Expand Up @@ -234,23 +261,22 @@ def from_yaml_and_arg_list(
"""
# Start with an empty typed config. This forces OmegaConf to validate
# that all other configs are of this structured type as well.
all_configs = [OmegaConf.structured(cls)]

# Override with configuration file if provided.
if config_path is not None:
if ignore_interpolation:
stringified_config = _read_config_without_interpolation(config_path)
all_configs.append(OmegaConf.create(stringified_config))
else:
all_configs.append(cls.from_yaml(config_path))

# Merge base config and config from yaml.
try:
# Merge and validate configs
all_configs = [OmegaConf.structured(cls)]
if config_path is not None:
if ignore_interpolation:
stringified_config = _read_config_without_interpolation(config_path)
all_configs.append(OmegaConf.create(stringified_config))
else:
all_configs.append(cls.from_yaml(config_path))
config = OmegaConf.merge(*all_configs)
except OmegaConfBaseException as e:
raise OumiConfigParsingError(e) from e
except Exception:
if logger:
configs_str = "\n\n".join([f"{config}" for config in all_configs])
configs_str = "\n\n".join([f"{c}" for c in all_configs])
logger.exception(
f"Failed to merge {len(all_configs)} Omega configs:\n{configs_str}"
)
Expand All @@ -267,16 +293,18 @@ def from_yaml_and_arg_list(
arg_list = _filter_ignored_args(arg_list)
# Override with CLI arguments.
config.merge_with_dotlist(arg_list)
config = OmegaConf.to_object(config)
except OmegaConfBaseException as e:
raise OumiConfigParsingError(e) from e
except Exception:
if logger:
logger.exception(
f"Failed to merge arglist {arg_list} with Omega config:\n{config}"
f"Failed to apply CLI args {arg_list} to Omega config:\n{config}"
)
raise

config = OmegaConf.to_object(config)
if not isinstance(config, cls):
raise TypeError(f"config {type(config)} is not {type(cls)}")
raise OumiConfigTypeError(config_type=cls, config_value=config)

return cast(T, config)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""Backward-compatible re-exports. Canonical definitions live in oumi.exceptions."""

class HardwareException(Exception):
"""An exception thrown for invalid hardware configurations."""
from oumi.exceptions import OumiConfigError, OumiConfigFileNotFoundError

__all__ = ["OumiConfigFileNotFoundError", "OumiConfigError"]
26 changes: 23 additions & 3 deletions src/oumi/core/configs/params/model_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@
from transformers.utils import find_adapter_config_file, is_flash_attn_2_available

from oumi.core.configs.params.base_params import BaseParams
from oumi.core.types.exceptions import HardwareException
from oumi.exceptions import (
HardwareException,
OumiConfigError,
OumiConfigFileNotFoundError,
)
from oumi.utils.logging import logger
from oumi.utils.torch_utils import get_torch_dtype

Expand Down Expand Up @@ -268,6 +272,11 @@ def __finalize_and_validate__(self):
adapter_config_file = None
# If this check fails, it means this is not a LoRA model.
if adapter_config_file:
if not Path(adapter_config_file).is_file():
raise OumiConfigFileNotFoundError(
f"Adapter config file not found or path is not a file: "
f"{adapter_config_file}"
)
# If `model_name` is a local dir, this should be the same.
# If it's a HF Hub repo, this should be the path to the cached repo.
adapter_dir = Path(adapter_config_file).parent
Expand All @@ -280,8 +289,19 @@ def __finalize_and_validate__(self):
# present, set it to the base model name found in the adapter config,
# if present. Error otherwise.
if len(list(adapter_dir.glob("config.json"))) == 0:
with open(adapter_config_file) as f:
adapter_config = json.load(f)
try:
with open(adapter_config_file) as f:
adapter_config = json.load(f)
except OSError as e:
raise OumiConfigError(
f"Failed to read adapter config at "
f"{adapter_config_file}: {e}"
) from e
except json.JSONDecodeError as e:
raise OumiConfigError(
f"Adapter config at {adapter_config_file} contains invalid "
f"JSON: (line {e.lineno}, col {e.colno}): {e.msg}"
) from e
model_name = adapter_config.get("base_model_name_or_path")
if not model_name:
raise ValueError(
Expand Down
2 changes: 1 addition & 1 deletion src/oumi/core/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
TemplatedMessage,
Type,
)
from oumi.core.types.exceptions import HardwareException
from oumi.exceptions import HardwareException

__all__ = [
"HardwareException",
Expand Down
62 changes: 62 additions & 0 deletions src/oumi/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Copyright 2025 - Oumi
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Oumi exception hierarchy.

This module is intentionally free of heavy dependencies (torch, transformers, etc.)
so that it can be imported cheaply in lightweight entry-points such as the CLI.
"""

from typing import Any


class OumiConfigError(Exception):
"""Base class for all configuration-related errors."""


class OumiConfigFileNotFoundError(OumiConfigError, FileNotFoundError):
"""A configuration file path does not exist."""


class OumiConfigTypeError(OumiConfigError):
"""A configuration type error."""

def __init__(self, config_type: type, config_value: Any):
"""Initialize with the expected type and actual value."""
self.config_type = config_type
self.config_value = config_value
super().__init__(
f"Expected config of type {config_type.__name__}, "
f"got {type(config_value).__name__}"
)


class OumiConfigParsingError(OumiConfigError):
"""Wraps any OmegaConf exception into a user-friendly config error.

Covers all subclasses of ``OmegaConfBaseException``, suppressing the internal
call stack from the CLI.
"""

def __init__(self, cause: Exception):
"""Extract a user-friendly message from an OmegaConf exception."""
full_key = getattr(cause, "full_key", None)
candidate = full_key if full_key else getattr(cause, "key", None)
self.config_key: str | None = str(candidate) if candidate is not None else None
self.msg: str = getattr(cause, "msg", None) or str(cause)
super().__init__(f"Config error: {self.msg}")


class HardwareException(Exception):
"""An exception thrown for invalid hardware configurations."""
4 changes: 3 additions & 1 deletion tests/unit/cli/test_cli_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,9 @@ def test_launch_up_job_not_found(
)
if res.exception:
raise res.exception
assert "No such file or directory" in str(exception_info.value)
assert "Config file not found or path is not a file" in str(
exception_info.value
)


def test_launch_run_job(
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/core/configs/params/test_model_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest

from oumi.core.configs.params.model_params import ModelParams
from oumi.exceptions import OumiConfigError, OumiConfigFileNotFoundError


def test_post_init_adapter_model_present():
Expand Down Expand Up @@ -130,3 +131,44 @@ def test_chat_template_kwargs_custom_assignment():
model_params = ModelParams(chat_template_kwargs={"enable_thinking": False})
assert model_params.chat_template_kwargs is not None
assert model_params.chat_template_kwargs["enable_thinking"] is False


@patch("oumi.core.configs.params.model_params.find_adapter_config_file")
def test_adapter_config_file_path_not_a_file(mock_find, tmp_path: Path):
"""Raises OumiConfigFileNotFoundError for a missing adapter config path."""
mock_find.return_value = str(tmp_path / "ghost_adapter_config.json")

params = ModelParams(model_name=str(tmp_path))
with pytest.raises(
OumiConfigFileNotFoundError,
match="Adapter config file not found or path is not a file",
):
params.finalize_and_validate()


@patch("oumi.core.configs.params.model_params.find_adapter_config_file")
def test_adapter_config_read_oserror(mock_find, tmp_path: Path):
"""Test OSError reading adapter_config.json is re-raised as OumiConfigError."""
adapter_path = tmp_path / "adapter_config.json"
adapter_path.write_text('{"base_model_name_or_path": "base_model"}')
mock_find.return_value = str(adapter_path)

params = ModelParams(model_name=str(tmp_path))
with patch("builtins.open", side_effect=OSError("Permission denied")):
with pytest.raises(
OumiConfigError,
match="Failed to read adapter config",
):
params.finalize_and_validate()


def test_adapter_config_invalid_json(tmp_path: Path):
"""Test malformed JSON raises OumiConfigError with location info."""
(tmp_path / "adapter_config.json").write_text("{not: valid json!!}")

params = ModelParams(model_name=str(tmp_path))
with pytest.raises(OumiConfigError, match="contains invalid JSON") as exc_info:
params.finalize_and_validate()

assert "line" in str(exc_info.value)
assert "col" in str(exc_info.value)
Loading
Loading