Skip to content

Commit

Permalink
Merge pull request #77 from lsst-sqre/tickets/DM-31273
Browse files Browse the repository at this point in the history
[DM-31273] Redo Slack formatting
  • Loading branch information
rra authored Aug 5, 2021
2 parents c59cc27 + a887de7 commit d202f53
Show file tree
Hide file tree
Showing 12 changed files with 698 additions and 106 deletions.
16 changes: 5 additions & 11 deletions src/mobu/business/notebookrunner.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@

import git

from ..jupyterclient import JupyterLabSession, NotebookException
from ..exceptions import CodeExecutionError
from ..jupyterclient import JupyterLabSession
from ..models.business import BusinessData
from .jupyterpythonloop import JupyterPythonLoop

Expand Down Expand Up @@ -108,18 +109,11 @@ async def execute_cell(
reply = await self._client.run_python(session, code)
sw.annotation["result"] = reply
self.logger.info(f"Result:\n{reply}\n")
except NotebookException as e:
running_code = self.running_code
notebook_name = "no notebook"
except CodeExecutionError as e:
if self.notebook:
self._failed_notebooks.append(self.notebook.name)
notebook_name = self.notebook.name
self.logger.error(f"Error running notebook: {notebook_name}")
self.running_code = None
raise NotebookException(
f"Running {notebook_name}: '"
f"```{running_code}``` generated: ```{e}```"
)
e.notebook = self.notebook.name
raise

def dump(self) -> BusinessData:
data = super().dump()
Expand Down
3 changes: 3 additions & 0 deletions src/mobu/constants.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"""Global constants for mobu."""

DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
"""Date format to use for dates in Slack alerts."""

NOTEBOOK_REPO_URL = "https://github.com/lsst-sqre/notebook-demo.git"
"""Default notebook repository for NotebookRunner."""

Expand Down
213 changes: 207 additions & 6 deletions src/mobu/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

from __future__ import annotations

from abc import ABCMeta, abstractmethod
from datetime import datetime, timezone
from typing import TYPE_CHECKING

from .constants import DATE_FORMAT

if TYPE_CHECKING:
from typing import Any, Dict, List, Optional

from aiohttp import ClientResponse

__all__ = [
"CodeExecutionError",
"FlockNotFoundException",
"JupyterError",
"JupyterTimeoutError",
"MonkeyNotFoundException",
"SlackError",
]


class FlockNotFoundException(Exception):
"""The named flock was not found."""
Expand All @@ -11,10 +31,6 @@ def __init__(self, flock: str) -> None:
super().__init__(f"Flock {flock} not found")


class LabSpawnTimeoutError(Exception):
"""Timed out waiting for the lab to spawn."""


class MonkeyNotFoundException(Exception):
"""The named monkey was not found."""

Expand All @@ -23,5 +39,190 @@ def __init__(self, monkey: str) -> None:
super().__init__(f"Monkey {monkey} not found")


class NotebookException(Exception):
"""Passing an error back from a remote notebook session."""
class SlackError(Exception, metaclass=ABCMeta):
"""Represents an exception that can be reported to Slack.
Intended to be subclassed. Subclasses must override the to_slack
method.
"""

def __init__(self, user: str, msg: str) -> None:
self.user = user
self.failed = datetime.now(tz=timezone.utc)
self.started: Optional[datetime] = None
self.event: Optional[str] = None
super().__init__(msg)

@abstractmethod
def to_slack(self) -> Dict[str, Any]:
"""Build a Slack message suitable for sending to an incoming webook."""

def common_fields(self) -> List[Dict[str, str]]:
"""Return common fields to put in any alert."""
failed = self.failed.strftime(DATE_FORMAT)
fields = [
{"type": "mrkdwn", "text": f"*Failed at*\n{failed}"},
{"type": "mrkdwn", "text": f"*User*\n{self.user}"},
]
if self.started:
started = self.started.strftime(DATE_FORMAT)
fields.insert(
0, {"type": "mrkdwn", "text": f"*Started at*\n{started}"}
)
if self.event:
fields.append({"type": "mrkdwn", "text": f"*Event*\n{self.event}"})
return fields


class CodeExecutionError(SlackError):
"""Error generated by code execution in a notebook on JupyterLab."""

def __init__(
self,
user: str,
code: str,
*,
error: Optional[str] = None,
notebook: Optional[str] = None,
status: Optional[str] = None,
) -> None:
self.code = code
self.error = error
self.notebook = notebook
self.status = status
super().__init__(user, "Code execution failed")

def __str__(self) -> str:
if self.notebook:
message = f"{self.user}: cell of notebook {self.notebook} failed"
if self.status:
message += f" (status: {self.status}"
message += f"\nCode: {self.code}"
else:
message = f"{self.user}: running code '{self.code}' block failed"
message += f"\nError: {self.error}"
return message

def to_slack(self) -> Dict[str, Any]:
"""Format the error as a Slack Block Kit message."""
if self.notebook:
intro = f"Error while running `{self.notebook}`"
else:
intro = "Error while running code"
if self.status:
intro += f"\n*Status*: {self.status}"

fields = self.common_fields()

code = self.code
if not code.endswith("\n"):
code += "\n"
result: Dict[str, Any] = {
"blocks": [
{"type": "section", "text": {"type": "mrkdwn", "text": intro}},
{"type": "section", "fields": fields},
],
"attachments": [
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*Code executed*\n```\n{code}```",
"verbatim": True,
},
}
],
}
],
}
if self.error:
error = self.error
if error and not error.endswith("\n"):
error += "\n"
result["attachments"][0]["blocks"].insert(
0,
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*Error*\n```\n{error}```",
"verbatim": True,
},
},
)
return result


class JupyterError(SlackError):
"""Web error from JupyterHub or JupyterLab."""

@classmethod
async def from_response(
cls, user: str, response: ClientResponse
) -> JupyterError:
return cls(
url=str(response.url),
user=user,
status=response.status,
reason=response.reason,
method=response.method,
body=await response.text(),
)

def __init__(
self,
*,
url: str,
user: str,
status: int,
reason: Optional[str],
method: str,
body: str,
) -> None:
self.url = url
self.status = status
self.reason = reason
self.method = method
self.body = body
super().__init__(user, f"Status {status} from {method} {url}")

def __str__(self) -> str:
return (
f"{self.user}: status {self.status} ({self.reason}) from"
f" {self.method} {self.url}\nBody:\n{self.body}\n"
)

def to_slack(self) -> Dict[str, Any]:
"""Format the error as a Slack Block Kit message."""
intro = f"Status {self.status} from {self.method} {self.url}"
fields = self.common_fields()
if self.reason:
fields.append(
{"type": "mrkdwn", "text": f"*Message*\n{self.reason}"}
)
return {
"blocks": [
{"type": "section", "text": {"type": "mrkdwn", "text": intro}},
{"type": "section", "fields": fields},
{"type": "divider"},
]
}


class JupyterTimeoutError(SlackError):
"""Timed out waiting for the lab to spawn."""

def to_slack(self) -> Dict[str, Any]:
"""Format the error as a Slack Block Kit message."""
return {
"blocks": [
{
"type": "section",
"text": {"type": "mrkdwn", "text": str(self)},
},
{"type": "section", "fields": self.common_fields()},
{"type": "divider"},
]
}
Loading

0 comments on commit d202f53

Please sign in to comment.