From 71f616d34e482315162748f7a0631d8db08241c0 Mon Sep 17 00:00:00 2001 From: paulruelle Date: Wed, 22 Jul 2026 12:23:21 +0200 Subject: [PATCH 1/2] feat(DEBT-67): add opt-in single-line error display When KILI_SDK_SIMPLIFY_ERROR_LOGS=true (or "simplify_error_logs": true in kili-sdk-config.json), SDK errors are displayed as a single concise line instead of a full traceback: - GraphQL errors keep only the actual cause: error codes, boilerplate and backend stack traces are stripped - authentication failures become e.g. "Invalid API key `**gerg`" - SDK warnings are displayed on one line - works in plain scripts (sys.excepthook) and IPython/Jupyter (custom exc) Default behavior is unchanged when the flag is not set. --- docs/configuration.md | 38 ++++ kili-sdk-config.example.json | 3 +- src/kili/__init__.py | 4 + src/kili/client.py | 2 + src/kili/core/simplified_errors.py | 193 ++++++++++++++++++ src/kili/exceptions.py | 35 +++- tests/unit/core/test_simplified_errors.py | 228 ++++++++++++++++++++++ 7 files changed, 499 insertions(+), 4 deletions(-) create mode 100644 src/kili/core/simplified_errors.py create mode 100644 tests/unit/core/test_simplified_errors.py diff --git a/docs/configuration.md b/docs/configuration.md index 610a2485d..5f5c46817 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -216,6 +216,43 @@ kili = Kili(api_key="your-api-key") # Progress bars disabled --- +### 5. Simplified Error Logs (`simplify_error_logs`) + +Display SDK errors as a short, single-line message instead of the full Python +traceback and raw GraphQL error payload. Warnings emitted by the SDK are also +displayed on a single line. + +**Values:** + +- `False` (default): Standard Python error display (full tracebacks) +- `True`: Single-line, human-readable error messages + +**Configuration Methods:** +```python +# 1. Environment variable +export KILI_SDK_SIMPLIFY_ERROR_LOGS=true # or "1", "yes" + +# 2. Configuration file +{ + "simplify_error_logs": true +} + +# 3. Default: false +``` + +**Example:** + +With the option disabled (default), a call on a non-existing project ends with a +full traceback and a raw GraphQL error. With the option enabled: + +```bash +$ export KILI_SDK_SIMPLIFY_ERROR_LOGS=true +$ python my_script.py +Project with id cme2rmsjdg0k4an0w4j0iggq3 not found +``` + +--- + ## Environment Variables Reference | Variable | Type | Default | Description | @@ -224,6 +261,7 @@ kili = Kili(api_key="your-api-key") # Progress bars disabled | `KILI_API_ENDPOINT` | string | `https://cloud.kili-technology.com/api/label/v2/graphql` | GraphQL API endpoint | | `KILI_VERIFY` | boolean/string | `true` | TLS certificate verification | | `KILI_DISABLE_TQDM` | boolean | None | Disable progress bars globally | +| `KILI_SDK_SIMPLIFY_ERROR_LOGS` | boolean | `false` | Display SDK errors as single-line messages without traceback | **Boolean Environment Variables:** diff --git a/kili-sdk-config.example.json b/kili-sdk-config.example.json index a00301d36..4176b6342 100644 --- a/kili-sdk-config.example.json +++ b/kili-sdk-config.example.json @@ -2,5 +2,6 @@ "api_key": "your-api-key-here", "api_endpoint": "https://api-endpoint.com", "verify_ssl": true, - "disable_tqdm": false + "disable_tqdm": false, + "simplify_error_logs": false } diff --git a/src/kili/__init__.py b/src/kili/__init__.py index 025ded7ab..0402d402d 100644 --- a/src/kili/__init__.py +++ b/src/kili/__init__.py @@ -1,3 +1,7 @@ """Kili Python SDK.""" +from kili.core.simplified_errors import install_error_display_hooks_if_enabled + __version__ = "26.1.10" + +install_error_display_hooks_if_enabled() diff --git a/src/kili/client.py b/src/kili/client.py index 54d66a2c8..8727138ab 100644 --- a/src/kili/client.py +++ b/src/kili/client.py @@ -13,6 +13,7 @@ from kili.adapters.kili_api_gateway.kili_api_gateway import KiliAPIGateway from kili.core.config_loader import load_config_from_file from kili.core.graphql.graphql_client import GraphQLClient, GraphQLClientName +from kili.core.simplified_errors import enable_error_simplification_from_config from kili.entrypoints.mutations.asset import MutationsAsset from kili.entrypoints.mutations.issue import MutationsIssue from kili.entrypoints.mutations.notification import MutationsNotification @@ -143,6 +144,7 @@ def __init__( ``` """ config_file = load_config_from_file() + enable_error_simplification_from_config(config_file) api_key = api_key or os.getenv("KILI_API_KEY") or config_file.get("api_key") diff --git a/src/kili/core/simplified_errors.py b/src/kili/core/simplified_errors.py new file mode 100644 index 000000000..0910fd265 --- /dev/null +++ b/src/kili/core/simplified_errors.py @@ -0,0 +1,193 @@ +"""Opt-in simplified display of the SDK errors and warnings. + +When error simplification is enabled, exceptions raised by the SDK are reported +as a single-line message (without traceback), backend GraphQL error messages are +stripped from their technical noise (error codes, stack traces, boilerplate), +and warnings emitted by the SDK are displayed on a single line. + +It can be enabled with the ``KILI_SDK_SIMPLIFY_ERROR_LOGS`` environment variable +(set to ``true``, ``1`` or ``yes``), or with ``"simplify_error_logs": true`` in +the ``kili-sdk-config.json`` configuration file. +""" + +import os +import re +import sys +import warnings +from collections.abc import Mapping +from types import TracebackType +from typing import Any, Optional, Union + +SIMPLIFY_ERROR_LOGS_ENV_VAR = "KILI_SDK_SIMPLIFY_ERROR_LOGS" +SIMPLIFY_ERROR_LOGS_CONFIG_KEY = "simplify_error_logs" + +_TRUTHY_ENV_VALUES = ("true", "1", "yes") + +# Backend GraphQL error messages look like: +# "[notFound] Resource not found. -- This can be due to: Project with id X not found" +_ERROR_CODE_PREFIX_REGEX = re.compile(r"^\[\w+\]\s*") +_CAUSE_SEPARATOR = " -- This can be due to: " +# Stack-trace lines that older backend versions embed in the error message. +_STACK_FRAME_LINE_REGEX = re.compile(r"^\s+at\s.*$", flags=re.MULTILINE) + +_RED = "\033[91m" +_RESET = "\033[0m" + +_default_formatwarning = warnings.formatwarning + +_state: dict[str, Any] = { + "enabled_from_config": False, + "previous_excepthook": None, + "previous_formatwarning": None, +} + + +def is_error_simplification_enabled() -> bool: + """Return whether the simplified error display is currently enabled. + + The ``KILI_SDK_SIMPLIFY_ERROR_LOGS`` environment variable takes precedence + over the configuration file. + """ + env_flag = _env_flag_value() + if env_flag is not None: + return env_flag + return _state["enabled_from_config"] + + +def enable_error_simplification_from_config(config: Mapping[str, Any]) -> None: + """Enable the simplified error display if the configuration file asks for it. + + Args: + config: The configuration loaded from the ``kili-sdk-config.json`` file. + """ + if bool(config.get(SIMPLIFY_ERROR_LOGS_CONFIG_KEY, False)): + _state["enabled_from_config"] = True + install_error_display_hooks() + + +def install_error_display_hooks_if_enabled() -> None: + """Install the display hooks if the simplified error display is enabled.""" + if is_error_simplification_enabled(): + install_error_display_hooks() + + +def install_error_display_hooks() -> None: + """Install the hooks rendering SDK errors and warnings as single lines. + + The hooks only alter the display when the simplified error display is + enabled, and delegate to the previous behavior otherwise. Installing them + twice is a no-op. + """ + if sys.excepthook is not _simplified_excepthook: + _state["previous_excepthook"] = sys.excepthook + sys.excepthook = _simplified_excepthook + + if warnings.formatwarning is not _simplified_formatwarning: + _state["previous_formatwarning"] = warnings.formatwarning + warnings.formatwarning = _simplified_formatwarning + + _install_ipython_hook() + + +def simplify_graphql_error_message(message: str) -> str: + """Extract the human-readable part of a backend GraphQL error message. + + Strips the stack-trace lines, the ``[errorCode]`` prefix and the generic + boilerplate that the backend prepends to the actual cause of the error. + Messages that do not follow the backend format are returned unchanged. + """ + simplified = _STACK_FRAME_LINE_REGEX.sub("", message).strip() + + if _CAUSE_SEPARATOR in simplified: + simplified = simplified.split(_CAUSE_SEPARATOR, 1)[1].strip() + else: + simplified = _ERROR_CODE_PREFIX_REGEX.sub("", simplified) + + return simplified or message + + +def _env_flag_value() -> Optional[bool]: + raw_value = os.getenv(SIMPLIFY_ERROR_LOGS_ENV_VAR) + if raw_value is None: + return None + return raw_value.strip().lower() in _TRUTHY_ENV_VALUES + + +def _is_kili_exception_type(exception_type: type) -> bool: + module_name = getattr(exception_type, "__module__", "") or "" + return module_name == "kili" or module_name.startswith("kili.") + + +def _is_kili_file(filename: str) -> bool: + return f"{os.sep}kili{os.sep}" in filename + + +def _print_error_line(exception: BaseException) -> None: + message = str(exception).strip() or exception.__class__.__name__ + stream = sys.stderr + try: + is_a_tty = stream.isatty() + except (AttributeError, ValueError): + is_a_tty = False + if is_a_tty: + message = f"{_RED}{message}{_RESET}" + stream.write(f"{message}\n") + + +def _simplified_excepthook( + exception_type: type[BaseException], + exception_value: BaseException, + exception_traceback: Optional[TracebackType], +) -> None: + if is_error_simplification_enabled() and _is_kili_exception_type(exception_type): + _print_error_line(exception_value) + return + previous_excepthook = _state["previous_excepthook"] or sys.__excepthook__ + previous_excepthook(exception_type, exception_value, exception_traceback) + + +def _simplified_formatwarning( + message: Union[Warning, str], + category: type[Warning], + filename: str, + lineno: int, + line: Optional[str] = None, +) -> str: + if is_error_simplification_enabled() and _is_kili_file(filename): + return f"{category.__name__}: {message}\n" + previous_formatwarning = _state["previous_formatwarning"] or _default_formatwarning + return previous_formatwarning(message, category, filename, lineno, line) + + +def _install_ipython_hook() -> None: + """Register a single-line display of SDK errors in IPython/Jupyter.""" + if "IPython" not in sys.modules: + # Not running in an IPython/Jupyter shell: sys.excepthook is enough. + return + + try: + from IPython.core.getipython import ( # pylint: disable=import-outside-toplevel + get_ipython, + ) + except ImportError: + return + + shell = get_ipython() + if shell is None: + return + + def _custom_exception_handler( + shell_: Any, # noqa: ANN401 + exception_type: type[BaseException], + exception_value: BaseException, + exception_traceback: Optional[TracebackType], + tb_offset: Optional[int] = None, + ) -> None: + if is_error_simplification_enabled() and _is_kili_exception_type(exception_type): + _print_error_line(exception_value) + return + shell_.showtraceback( + (exception_type, exception_value, exception_traceback), tb_offset=tb_offset + ) + + shell.set_custom_exc((Exception,), _custom_exception_handler) diff --git a/src/kili/exceptions.py b/src/kili/exceptions.py index 7a5f6b1ab..21c5e56a1 100644 --- a/src/kili/exceptions.py +++ b/src/kili/exceptions.py @@ -2,6 +2,11 @@ from typing import Optional +from kili.core.simplified_errors import ( + is_error_simplification_enabled, + simplify_graphql_error_message, +) + class GraphQLError(Exception): """Raised when the GraphQL call returns an error.""" @@ -17,10 +22,15 @@ def __init__(self, error, batch_number=None, context=None) -> None: else: error_msg = str(error) - if batch_number is None: + if is_error_simplification_enabled(): + error_msg = simplify_graphql_error_message(error_msg) + if batch_number is not None: + error_msg = f"{error_msg} (at index {100*batch_number})" + super().__init__(error_msg) + elif batch_number is None: super().__init__(f'GraphQL error: "{error_msg}"') else: - super().__init__(f'GraphQL error at index {100*batch_number}: {error_msg}"') + super().__init__(f'GraphQL error at index {100*batch_number}: "{error_msg}"') class NotFound(Exception): @@ -38,7 +48,9 @@ class AuthenticationFailed(Exception): """Used when the authentification fails.""" def __init__(self, api_key, api_endpoint, error_msg: Optional[str] = None) -> None: - if api_key is None: + if is_error_simplification_enabled(): + super().__init__(self._simplified_message(api_key, api_endpoint, error_msg)) + elif api_key is None: super().__init__( "You need to provide an API KEY to connect." " Visit https://docs.kili-technology.com/reference/creating-an-api-key" @@ -52,6 +64,23 @@ def __init__(self, api_key, api_endpoint, error_msg: Optional[str] = None) -> No raise_msg += f"\nError message:\n{error_msg}" super().__init__(raise_msg) + @classmethod + def _simplified_message( + cls, api_key: Optional[str], api_endpoint: str, error_msg: Optional[str] + ) -> str: + if not api_key: + return ( + "No API key provided." + " Set the `KILI_API_KEY` environment variable or pass `api_key` to the client." + ) + if error_msg and "api key" in error_msg.lower(): + return f"Invalid API key `{cls._obfuscate(api_key)}`" + message = f"Connection to Kili endpoint {api_endpoint} failed" + message += f" with API key `{cls._obfuscate(api_key)}`" + if error_msg: + message += f": {error_msg}" + return message + @staticmethod def _obfuscate(input_str: str) -> str: if len(input_str) >= 4: diff --git a/tests/unit/core/test_simplified_errors.py b/tests/unit/core/test_simplified_errors.py new file mode 100644 index 000000000..f0a49904e --- /dev/null +++ b/tests/unit/core/test_simplified_errors.py @@ -0,0 +1,228 @@ +"""Tests for the opt-in simplified error display.""" + +import os +import sys +import warnings + +import pytest + +from kili.core import simplified_errors +from kili.core.simplified_errors import ( + SIMPLIFY_ERROR_LOGS_ENV_VAR, + enable_error_simplification_from_config, + install_error_display_hooks, + is_error_simplification_enabled, + simplify_graphql_error_message, +) +from kili.exceptions import AuthenticationFailed, GraphQLError + +BACKEND_MESSAGE = ( + "[notFound] Resource not found. -- This can be due to:" + " Project with id cme2rmsjdg0k4an0w4j0iggq3 not found" +) + +BACKEND_MESSAGE_WITH_STACK = ( + BACKEND_MESSAGE + + "\n at (/app/src/context/localContext/projectContext/index.ts:388:9)" + + "\n at Array.map ()" + + "\n at process.processTicksAndRejections (node:internal/process/task_queues:103:5)" +) + +SIMPLIFIED_MESSAGE = "Project with id cme2rmsjdg0k4an0w4j0iggq3 not found" + + +@pytest.fixture(autouse=True) +def _reset_simplified_errors_state(monkeypatch): + """Isolate each test from the environment and restore the display hooks.""" + monkeypatch.delenv(SIMPLIFY_ERROR_LOGS_ENV_VAR, raising=False) + monkeypatch.setitem(simplified_errors._state, "enabled_from_config", False) + monkeypatch.setattr(sys, "excepthook", sys.excepthook) + monkeypatch.setattr(warnings, "formatwarning", warnings.formatwarning) + + +def test_given_no_configuration_when_checking_the_flag_then_it_is_disabled(): + assert is_error_simplification_enabled() is False + + +@pytest.mark.parametrize("value", ["true", "True", "1", "yes"]) +def test_given_a_truthy_env_value_when_checking_the_flag_then_it_is_enabled(monkeypatch, value): + monkeypatch.setenv(SIMPLIFY_ERROR_LOGS_ENV_VAR, value) + + assert is_error_simplification_enabled() is True + + +@pytest.mark.parametrize("value", ["false", "0", "no", ""]) +def test_given_a_falsy_env_value_when_checking_the_flag_then_it_is_disabled(monkeypatch, value): + monkeypatch.setenv(SIMPLIFY_ERROR_LOGS_ENV_VAR, value) + + assert is_error_simplification_enabled() is False + + +def test_given_a_config_file_enabling_it_when_checking_the_flag_then_it_is_enabled(): + enable_error_simplification_from_config({"simplify_error_logs": True}) + + assert is_error_simplification_enabled() is True + + +def test_given_a_falsy_env_value_when_the_config_file_enables_it_then_the_env_wins(monkeypatch): + enable_error_simplification_from_config({"simplify_error_logs": True}) + monkeypatch.setenv(SIMPLIFY_ERROR_LOGS_ENV_VAR, "false") + + assert is_error_simplification_enabled() is False + + +def test_given_a_backend_message_when_simplifying_then_only_the_cause_remains(): + assert simplify_graphql_error_message(BACKEND_MESSAGE) == SIMPLIFIED_MESSAGE + + +def test_given_a_message_with_stack_traces_when_simplifying_then_they_are_stripped(): + assert simplify_graphql_error_message(BACKEND_MESSAGE_WITH_STACK) == SIMPLIFIED_MESSAGE + + +def test_given_a_message_without_cause_when_simplifying_then_the_code_prefix_is_stripped(): + message = "[accessDenied] Access denied. Verify your credentials." + + assert simplify_graphql_error_message(message) == "Access denied. Verify your credentials." + + +def test_given_a_plain_message_when_simplifying_then_it_is_unchanged(): + assert ( + simplify_graphql_error_message("GraphQL response contains no data") + == "GraphQL response contains no data" + ) + + +def test_given_the_flag_disabled_when_raising_a_graphql_error_then_the_message_is_legacy(): + error = GraphQLError(error=[{"message": BACKEND_MESSAGE_WITH_STACK}]) + + assert str(error).startswith('GraphQL error: "') + assert " at " in str(error) + + +def test_given_the_flag_enabled_when_raising_a_graphql_error_then_the_message_is_short(monkeypatch): + monkeypatch.setenv(SIMPLIFY_ERROR_LOGS_ENV_VAR, "true") + + error = GraphQLError(error=[{"message": BACKEND_MESSAGE_WITH_STACK}]) + + assert str(error) == SIMPLIFIED_MESSAGE + + +def test_given_the_flag_enabled_when_raising_a_batched_graphql_error_then_the_index_is_kept( + monkeypatch, +): + monkeypatch.setenv(SIMPLIFY_ERROR_LOGS_ENV_VAR, "true") + + error = GraphQLError(error=[{"message": BACKEND_MESSAGE}], batch_number=3) + + assert str(error) == f"{SIMPLIFIED_MESSAGE} (at index 300)" + + +def test_given_the_flag_enabled_when_the_api_key_is_invalid_then_the_message_is_short(monkeypatch): + monkeypatch.setenv(SIMPLIFY_ERROR_LOGS_ENV_VAR, "true") + + error = AuthenticationFailed( + api_key="rggerg", + api_endpoint="http://localhost:4001/api/label/v2/graphql", + error_msg="Api key does not seem to be valid.", + ) + + assert str(error) == "Invalid API key `**gerg`" + + +def test_given_the_flag_enabled_when_the_api_key_is_missing_then_the_message_is_one_line( + monkeypatch, +): + monkeypatch.setenv(SIMPLIFY_ERROR_LOGS_ENV_VAR, "true") + + error = AuthenticationFailed(api_key=None, api_endpoint="https://cloud.kili-technology.com") + + assert "KILI_API_KEY" in str(error) + assert "\n" not in str(error) + + +def test_given_the_flag_disabled_when_authentication_fails_then_the_message_is_legacy(): + error = AuthenticationFailed( + api_key="rggerg", + api_endpoint="http://localhost:4001/api/label/v2/graphql", + error_msg="Api key does not seem to be valid.", + ) + + assert "Check your connection and API key." in str(error) + assert "**gerg" in str(error) + + +def test_given_the_flag_enabled_when_a_kili_error_is_uncaught_then_a_single_line_is_printed( + monkeypatch, capsys +): + monkeypatch.setenv(SIMPLIFY_ERROR_LOGS_ENV_VAR, "true") + install_error_display_hooks() + + error = GraphQLError(error=[{"message": BACKEND_MESSAGE}]) + sys.excepthook(type(error), error, None) + + assert capsys.readouterr().err == f"{SIMPLIFIED_MESSAGE}\n" + + +def test_given_the_flag_enabled_when_a_non_kili_error_is_uncaught_then_the_traceback_is_kept( + monkeypatch, capsys +): + monkeypatch.setenv(SIMPLIFY_ERROR_LOGS_ENV_VAR, "true") + install_error_display_hooks() + + try: + raise ValueError("boom") + except ValueError: + exception_info = sys.exc_info() + sys.excepthook(*exception_info) + + assert "Traceback" in capsys.readouterr().err + + +def test_given_the_flag_disabled_when_a_kili_error_is_uncaught_then_the_traceback_is_kept(capsys): + install_error_display_hooks() + + try: + raise GraphQLError(error=[{"message": BACKEND_MESSAGE}]) + except GraphQLError: + exception_info = sys.exc_info() + sys.excepthook(*exception_info) + + assert "Traceback" in capsys.readouterr().err + + +def test_given_the_flag_enabled_when_a_kili_warning_is_emitted_then_it_is_one_line(monkeypatch): + monkeypatch.setenv(SIMPLIFY_ERROR_LOGS_ENV_VAR, "true") + install_error_display_hooks() + + formatted = warnings.formatwarning( + "Client domain api is still a work in progress.", + UserWarning, + os.sep.join(["", "site-packages", "kili", "client_domain.py"]), + 100, + ) + + assert formatted == "UserWarning: Client domain api is still a work in progress.\n" + + +def test_given_the_flag_enabled_when_a_non_kili_warning_is_emitted_then_the_format_is_default( + monkeypatch, +): + monkeypatch.setenv(SIMPLIFY_ERROR_LOGS_ENV_VAR, "true") + install_error_display_hooks() + + filename = os.sep.join(["", "site-packages", "other", "module.py"]) + formatted = warnings.formatwarning("Some warning.", UserWarning, filename, 42) + + assert filename in formatted + assert "42" in formatted + + +def test_given_installed_hooks_when_installing_again_then_it_is_a_no_op(): + install_error_display_hooks() + first_excepthook = sys.excepthook + first_formatwarning = warnings.formatwarning + + install_error_display_hooks() + + assert sys.excepthook is first_excepthook + assert warnings.formatwarning is first_formatwarning From d7a97391a68d2ed0db3adfb629e6c8bd1d57fcb2 Mon Sep 17 00:00:00 2001 From: paulruelle Date: Wed, 22 Jul 2026 12:33:39 +0200 Subject: [PATCH 2/2] fix(DEBT-67): apply simplified error display to the domain client early warning The domain client emits its work-in-progress warning before building the legacy client, i.e. before the configuration file had a chance to enable the simplified display. Enable it from the configuration at the very start of the domain client init. --- src/kili/client_domain.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/kili/client_domain.py b/src/kili/client_domain.py index d9da9d046..8ec88a5d3 100644 --- a/src/kili/client_domain.py +++ b/src/kili/client_domain.py @@ -7,7 +7,9 @@ from kili.client import GraphQLClientParams from kili.client import Kili as KiliLegacy +from kili.core.config_loader import load_config_from_file from kili.core.graphql.graphql_client import GraphQLClientName +from kili.core.simplified_errors import enable_error_simplification_from_config if TYPE_CHECKING: from kili.domain_api import ( @@ -97,6 +99,7 @@ def __init__( kili = Kili(disable_tqdm=True) ``` """ + enable_error_simplification_from_config(load_config_from_file()) warnings.warn( "Client domain api is still a work in progress. Method names and return type will evolve.", stacklevel=1,