Skip to content
Closed
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
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
fontconfig \
fonts-dejavu-core \
git \
libmariadb3 \
libpq5 \
openssh-client \
supervisor \
Expand Down Expand Up @@ -146,7 +147,7 @@
# Environment variables
ENV MODE=prod
ENV NODE_ENV=production
ENV BETTER_AUTH_URL=http://localhost:5005

Check warning on line 150 in Dockerfile

View workflow job for this annotation

GitHub Actions / Deploy Preview

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "BETTER_AUTH_URL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
ENV FASTAPI_PORT=8005
ENV APP_VERSION=$APP_VERSION
ENV APP_COMMIT=$APP_COMMIT
Expand Down
57 changes: 53 additions & 4 deletions cli/nao_core/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@
"vertex": "gemini",
}

# Native system libraries required by certain backends, keyed by ibis backend
# name. Used to give an actionable hint when the Python package is installed but
# the shared library it links against is missing from the host.
_SYSTEM_LIBRARY_HINTS: dict[str, str] = {
"mysql": "the MySQL/MariaDB client library (on Debian/Ubuntu: 'apt-get install libmariadb3')",
"postgres": "the PostgreSQL client library (on Debian/Ubuntu: 'apt-get install libpq5')",
"mssql": "the unixODBC driver manager (on Debian/Ubuntu: 'apt-get install unixodbc')",
}

# Substrings found in ImportError messages when a native shared library cannot
# be loaded, across Linux, macOS and Windows.
_SHARED_LIBRARY_ERROR_MARKERS: tuple[str, ...] = (
"cannot open shared object file",
"library not loaded",
"image not found",
"dll load failed",
)


# ---------------------------------------------------------------------------
# Public helpers
Expand All @@ -76,12 +94,33 @@ def __init__(self, package: str, extra: str, purpose: str = ""):
super().__init__(message)


class MissingSystemLibraryError(ImportError):
"""Raised when a dependency is installed but a native library it links
against cannot be loaded (e.g. a missing shared object on the host)."""

def __init__(self, component: str, original_error: BaseException, hint: str | None = None):
self.component = component
self.original_error = original_error
lines = [
f"The '{component}' backend is installed but a required system library could not be loaded:",
f" {original_error}",
"This is a missing system (native) library, not a missing Python package.",
]
if hint:
lines.append(f"Install {hint} and try again.")
super().__init__("\n".join(lines))


def require_dependency(package: str, extra: str, purpose: str = "") -> None:
"""Verify that *package* is importable, raising a helpful error if not."""
try:
importlib.import_module(package)
except ImportError:
raise MissingDependencyError(package, extra, purpose) from None
except ModuleNotFoundError as error:
raise MissingDependencyError(package, extra, purpose) from error
except ImportError as error:
if _is_missing_shared_library(error):
raise MissingSystemLibraryError(package, error) from error
raise


def require_database_backend(backend: str, *, extra: str | None = None, database_type: str | None = None) -> None:
Expand All @@ -90,12 +129,16 @@ def require_database_backend(backend: str, *, extra: str | None = None, database
display_type = database_type or backend
try:
importlib.import_module(f"ibis.backends.{backend}")
except (ImportError, ModuleNotFoundError):
except ModuleNotFoundError as error:
raise MissingDependencyError(
f"ibis-framework[{backend}]",
install_extra,
f"to connect to {display_type} databases",
) from None
) from error
except ImportError as error:
if _is_missing_shared_library(error):
raise MissingSystemLibraryError(display_type, error, _SYSTEM_LIBRARY_HINTS.get(backend)) from error
raise


def get_required_extras(config: NaoConfig) -> list[str]:
Expand Down Expand Up @@ -170,6 +213,12 @@ def install_extras(extras: list[str]) -> bool:
# ---------------------------------------------------------------------------


def _is_missing_shared_library(error: ImportError) -> bool:
"""Heuristically detect an ImportError caused by a missing native library."""
message = str(error).lower()
return any(marker in message for marker in _SHARED_LIBRARY_ERROR_MARKERS)


def _resolve_extra(provider_or_type: str) -> str | None:
"""Map a config provider/database type to its extra name."""
name = _PROVIDER_ALIASES.get(provider_or_type, provider_or_type)
Expand Down
63 changes: 62 additions & 1 deletion cli/tests/nao_core/test_deps.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import pytest

from nao_core.deps import MissingDependencyError, require_database_backend
from nao_core.deps import (
MissingDependencyError,
MissingSystemLibraryError,
require_database_backend,
require_dependency,
)


def test_require_database_backend_uses_public_extra_for_shared_ibis_backend(monkeypatch):
Expand All @@ -17,3 +22,59 @@ def raise_missing_backend(module_name: str):
assert "to connect to redshift databases" in message
assert "pip install 'nao-core[redshift]'" in message
assert "uv pip install 'nao-core[redshift]'" in message


def test_require_database_backend_reports_missing_system_library(monkeypatch):
def raise_missing_shared_lib(module_name: str):
assert module_name == "ibis.backends.mysql"
raise ImportError("libmariadb.so.3: cannot open shared object file: No such file or directory")

monkeypatch.setattr("nao_core.deps.importlib.import_module", raise_missing_shared_lib)

with pytest.raises(MissingSystemLibraryError) as exc_info:
require_database_backend("mysql")

message = str(exc_info.value)
assert "libmariadb.so.3" in message
assert "system" in message
assert "libmariadb3" in message
assert "pip install 'nao-core[mysql]'" not in message


def test_require_database_backend_does_not_mask_unrelated_import_errors(monkeypatch):
def raise_unrelated(module_name: str):
raise ImportError("some unrelated import failure")

monkeypatch.setattr("nao_core.deps.importlib.import_module", raise_unrelated)

with pytest.raises(ImportError) as exc_info:
require_database_backend("mysql")

assert not isinstance(exc_info.value, (MissingDependencyError, MissingSystemLibraryError))


def test_require_dependency_reports_missing_system_library(monkeypatch):
def raise_missing_shared_lib(module_name: str):
raise ImportError("libfoo.so.1: cannot open shared object file: No such file or directory")

monkeypatch.setattr("nao_core.deps.importlib.import_module", raise_missing_shared_lib)

with pytest.raises(MissingSystemLibraryError) as exc_info:
require_dependency("some_package", "some-extra")

message = str(exc_info.value)
assert "libfoo.so.1" in message
assert "system" in message


def test_require_dependency_reports_missing_python_package(monkeypatch):
def raise_missing_module(module_name: str):
raise ModuleNotFoundError(f"No module named '{module_name}'")

monkeypatch.setattr("nao_core.deps.importlib.import_module", raise_missing_module)

with pytest.raises(MissingDependencyError) as exc_info:
require_dependency("some_package", "some-extra", "for testing")

message = str(exc_info.value)
assert "pip install 'nao-core[some-extra]'" in message
Loading