Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,5 @@ This document provides guidelines and best practices for using GitHub Copilot in

- [Python Coding Conventions](../docs/coding-conventions.md)
- [Environment Variables Guide](../docs/env-vars.md)
- [Steps to Upgrade Python](../docs/steps-to-upgrade-python.md)
- [Pydantic Annotated fields](../docs/llm-prompts/pydantic-annotated-fields.md)
- [Steps to Upgrade Python](../docs/steps-to-upgrade-python.md)
92 changes: 92 additions & 0 deletions .github/prompts/update-user-messages.prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
mode: 'edit'
description: 'Update user messages'
---

This prompt guide is for updating user-facing messages in ${file} or ${selection}

## What is a User Message?

A user message is any string that will be displayed to end-users of the application.
In our codebase, these messages are marked with the `user_message` function:

```python
from common_library.user_messages import user_message

error_msg = user_message("Operation failed. Please try again later.")
```

## Guidelines for Updating User Messages

When modifying user messages, follow these rules:

1. **Version Tracking**: Every modification to a user message must include an incremented `_version` parameter:

```python
# Before modification
user_message("Error: Unable to connect to the server.")

# After modification
user_message("Error: Cannot establish connection to the server.", _version=1)
```

2. **F-String Preservation**: When modifying messages that use f-strings, preserve all parameters and their formatting:

```python
# Before
user_message(f"Project {project_name} could not be loaded.")

# After (correct)
user_message(f"Unable to load project {project_name}.", _version=1)

# After (incorrect - lost the parameter)
user_message("Unable to load project.", _version=1)
```

3. **Message Style**: Follow the guidelines in `${workspaceFolder}/docs/user-messages-guidelines.md`

4. **Preserve Context**: Ensure the modified message conveys the same meaning and context as the original.

5. **Incremental Versioning**: If a message already has a version, increment it by 1:

```python
# Before
user_message("Session expired.", _version=2)

# After
user_message("Your session has expired. Please log in again.", _version=3)
```

## Examples

### Example 1: Simple Message Update

```python
# Before
error_dialog(user_message("Failed to save changes."))

# After
error_dialog(user_message("Failed to save your changes. Please try again.", _version=1))
```

### Example 2: F-string Message Update

```python
# Before
raise ValueError(user_message(f"Invalid input parameter: {param_name}"))

# After
raise ValueError(user_message(f"The parameter '{param_name}' contains an invalid value.", _version=1))
```

### Example 3: Already Versioned Message

```python
# Before
return HttpErrorInfo(status.HTTP_404_NOT_FOUND, user_message("User not found.", _version=1))

# After
return HttpErrorInfo(status.HTTP_404_NOT_FOUND, user_message("The requested user could not be found.", _version=2))
```

Remember: The goal is to improve clarity and helpfulness for end-users while maintaining accurate versioning for tracking changes.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Error and Warning Message Guidelines
# User Message Guidelines

These guidelines ensure that messages are user-friendly, clear, and helpful while maintaining a professional tone. 🚀
These guidelines ensure that error and warnings user-facing messages are user-friendly, clear, and helpful while maintaining a professional tone. 🚀

Some details:

Expand Down
44 changes: 44 additions & 0 deletions packages/common-library/src/common_library/errors_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,47 @@ def error_context(self) -> dict[str, Any]:
def error_code(self) -> str:
assert isinstance(self, Exception), "subclass must be exception" # nosec
return create_error_code(self)


class BaseOsparcError(OsparcErrorMixin, Exception): ...


class NotFoundError(BaseOsparcError):
msg_template = "{resource} not found: id='{resource_id}'"


class ForbiddenError(BaseOsparcError):
msg_template = "Access to {resource} is forbidden: id='{resource_id}'"


def make_resource_error(
resource: str,
error_cls: type[BaseOsparcError],
base_exception: type[Exception] = Exception,
) -> type[BaseOsparcError]:
"""
Factory function to create a custom error class for a specific resource.

This function dynamically generates an error class that inherits from the provided
`error_cls` and optionally a `base_exception`. The generated error class automatically
includes the resource name and resource ID in its context and message.

See usage examples in test_errors_classes.py

LIMITATIONS: for the moment, exceptions produces with this factory cannot be serialized with pickle.
And therefore it cannot be used as exception of RabbitMQ-RPC interface
"""

class _ResourceError(error_cls, base_exception):
def __init__(self, **ctx: Any):
ctx.setdefault("resource", resource)

# guesses identifer e.g. project_id, user_id
if resource_id := ctx.get(f"{resource.lower()}_id"):
ctx.setdefault("resource_id", resource_id)

super().__init__(**ctx)

resource_class_name = "".join(word.capitalize() for word in resource.split("_"))
_ResourceError.__name__ = f"{resource_class_name}{error_cls.__name__}"
return _ResourceError
11 changes: 11 additions & 0 deletions packages/common-library/src/common_library/user_messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
def user_message(msg: str, *, _version=None) -> str:
"""Marks a message as user-facing
Arguments:
msg -- human-friendly string that follows docs/user-messages-guidelines.md
_version -- version number to track changes to messages; increment when modifying an existing message
Returns:
The original message string, allowing it to be used inline in code
"""
return msg
51 changes: 50 additions & 1 deletion packages/common-library/tests/test_errors_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
from typing import Any

import pytest
from common_library.errors_classes import OsparcErrorMixin
from common_library.errors_classes import (
ForbiddenError,
NotFoundError,
OsparcErrorMixin,
make_resource_error,
)


def test_get_full_class_name():
Expand Down Expand Up @@ -154,3 +159,47 @@ class MyError(OsparcErrorMixin, ValueError):
"message": "42 and 'missing=?'",
"value": 42,
}


def test_resource_error_factory():
ProjectNotFoundError = make_resource_error("project", NotFoundError)

error_1 = ProjectNotFoundError(resource_id="abc123")
assert "resource_id" in error_1.error_context()
assert error_1.resource_id in error_1.message # type: ignore


def test_resource_error_factory_auto_detect_resource_id():
ProjectForbiddenError = make_resource_error("project", ForbiddenError)
error_2 = ProjectForbiddenError(project_id="abc123", other_id="foo")
assert (
error_2.resource_id == error_2.project_id # type: ignore
), "auto-detects project ids as resourceid"
assert error_2.other_id # type: ignore
assert error_2.code == "BaseOsparcError.ForbiddenError.ProjectForbiddenError"

assert error_2.error_context() == {
"project_id": "abc123",
"other_id": "foo",
"resource": "project",
"resource_id": "abc123",
"message": "Access to project is forbidden: id='abc123'",
"code": "BaseOsparcError.ForbiddenError.ProjectForbiddenError",
}


def test_resource_error_factory_different_base_exception():

class MyServiceError(Exception): ...

OtherProjectForbiddenError = make_resource_error(
"other_project", ForbiddenError, MyServiceError
)

assert issubclass(OtherProjectForbiddenError, MyServiceError)

error_3 = OtherProjectForbiddenError(project_id="abc123")
assert (
error_3.code
== "MyServiceError.BaseOsparcError.ForbiddenError.OtherProjectForbiddenError"
)
6 changes: 6 additions & 0 deletions packages/common-library/tests/test_user_messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from common_library.user_messages import user_message


def test_user_message() -> None:

assert user_message("This is a user message") == "This is a user message"
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from aiohttp.web_response import StreamResponse
from common_library.error_codes import create_error_code
from common_library.json_serialization import json_dumps, json_loads
from common_library.user_messages import user_message
from models_library.rest_error import ErrorGet, ErrorItemType, LogMessageType

from ..logging_errors import create_troubleshotting_log_kwargs
Expand All @@ -31,7 +32,7 @@
from .web_exceptions_extension import get_http_error_class_or_none

DEFAULT_API_VERSION = "v0"
_FMSG_INTERNAL_ERROR_USER_FRIENDLY = (
_FMSG_INTERNAL_ERROR_USER_FRIENDLY = user_message(
"We apologize for the inconvenience. "
"The issue has been recorded, please report it if it persists."
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from common_library.user_messages import user_message
from servicelib.aiohttp import status

from ...exception_handling import (
Expand All @@ -11,11 +12,11 @@
_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = {
ApiKeyDuplicatedDisplayNameError: HttpErrorInfo(
status.HTTP_409_CONFLICT,
"API key display name duplicated",
user_message("An API key with this display name already exists", _version=1),
),
ApiKeyNotFoundError: HttpErrorInfo(
status.HTTP_404_NOT_FOUND,
"API key was not found",
user_message("The requested API key could not be found", _version=1),
),
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from aiohttp import web
from common_library.error_codes import create_error_code
from common_library.user_messages import user_message
from models_library.rest_error import ErrorGet
from servicelib.aiohttp import status
from servicelib.logging_errors import create_troubleshotting_log_kwargs
Expand Down Expand Up @@ -85,22 +86,29 @@ async def _handler_catalog_client_errors(
_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = {
RemoteMethodNotRegisteredError: HttpErrorInfo(
status.HTTP_503_SERVICE_UNAVAILABLE,
MSG_CATALOG_SERVICE_UNAVAILABLE,
user_message(
"Catalog service method unavailable. Please try again shortly.", _version=1
),
),
CatalogForbiddenError: HttpErrorInfo(
status.HTTP_403_FORBIDDEN,
"Forbidden catalog access",
user_message(
"You do not have permission to access this catalog item.", _version=1
),
),
CatalogItemNotFoundError: HttpErrorInfo(
status.HTTP_404_NOT_FOUND,
"Catalog item not found",
user_message("The requested catalog item could not be found.", _version=1),
),
DefaultPricingPlanNotFoundError: HttpErrorInfo(
status.HTTP_404_NOT_FOUND,
"Default pricing plan not found",
user_message("The default pricing plan could not be found.", _version=1),
),
DefaultPricingUnitForServiceNotFoundError: HttpErrorInfo(
status.HTTP_404_NOT_FOUND, "Default pricing unit not found"
status.HTTP_404_NOT_FOUND,
user_message(
"The default pricing unit for this service could not be found.", _version=1
),
),
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from typing import Final

from common_library.user_messages import user_message
from servicelib.aiohttp.application_keys import (
APP_AIOPG_ENGINE_KEY,
APP_CONFIG_KEY,
Expand Down Expand Up @@ -42,12 +43,11 @@
"Under development. Use WEBSERVER_DEV_FEATURES_ENABLED=1 to enable current implementation"
)


# Request storage keys
RQ_PRODUCT_KEY: Final[str] = f"{__name__}.RQ_PRODUCT_KEY"


MSG_TRY_AGAIN_OR_SUPPORT: Final[str] = (
MSG_TRY_AGAIN_OR_SUPPORT: Final[str] = user_message(
"Please try again shortly. If the issue persists, contact support."
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from aiohttp import web
from common_library.error_codes import create_error_code
from common_library.user_messages import user_message
from models_library.rest_error import ErrorGet
from servicelib import status_codes_utils
from servicelib.aiohttp import status
Expand Down Expand Up @@ -43,10 +44,11 @@ async def _handler_director_service_error_as_503_or_4xx(
if status_codes_utils.is_5xx_server_error(exception.status):
# NOTE: All directorv2 5XX are mapped to 503
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
user_msg = (
user_msg = user_message(
# Most likely the director service is down or misconfigured so the user is asked to try again later.
"This service is temporarily unavailable. The incident was logged and will be investigated. "
+ MSG_TRY_AGAIN_OR_SUPPORT
"This service is temporarily unavailable. The incident has been logged and will be investigated. "
+ MSG_TRY_AGAIN_OR_SUPPORT,
_version=1,
)

# Log for further investigation
Expand Down Expand Up @@ -85,11 +87,11 @@ async def _handler_director_service_error_as_503_or_4xx(
_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = {
UserDefaultWalletNotFoundError: HttpErrorInfo(
status.HTTP_404_NOT_FOUND,
"Default wallet not found but necessary for computations",
user_message("Default wallet not found but necessary for computations"),
),
WalletNotEnoughCreditsError: HttpErrorInfo(
status.HTTP_402_PAYMENT_REQUIRED,
"Wallet does not have enough credits for computations. {reason}",
user_message("Wallet does not have enough credits for computations. {reason}"),
),
}

Expand Down
Loading
Loading