Skip to content
Draft
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
38 changes: 38 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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:**

Expand Down
3 changes: 2 additions & 1 deletion kili-sdk-config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions src/kili/__init__.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions src/kili/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down
3 changes: 3 additions & 0 deletions src/kili/client_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down
193 changes: 193 additions & 0 deletions src/kili/core/simplified_errors.py
Original file line number Diff line number Diff line change
@@ -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)
35 changes: 32 additions & 3 deletions src/kili/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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):
Expand All @@ -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"
Expand All @@ -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:
Expand Down
Loading
Loading