Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [3.6, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12", "3.13"]
poetry-version: [1.0.10]
python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"]
poetry-version: [1.7.1]
os: [ubuntu-18.04, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
Expand Down
2 changes: 2 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ teams-logger

Python logging handler for Microsoft Teams webhook integration with both simple and dictionary configurations.

This package requires Python 3.8 or newer.

Installation
------------
.. code-block:: bash
Expand Down
191 changes: 141 additions & 50 deletions poetry.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
[tool.poetry]
name = "teams-logger"
version = "0.5.0"
version = "0.5.1"
description = "Microsoft Teams logging handler for Python"
authors = ["Anes Foufa <anes.foufa@upply.com>"]
readme = "README.rst"
homepage = "https://github.com/AnesFoufa/python-teams-logger"
license = "MIT"
classifiers = ["Topic :: System :: Logging", "Topic :: Communications :: Chat"]
[tool.poetry.dependencies]
python = "^3.6"
requests = "^2.24.0"
python = "^3.8"
requests = "^2.32.0"

[tool.poetry.dev-dependencies]
pydump = "^1.2.0"
Expand Down
18 changes: 17 additions & 1 deletion teams_logger/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,17 @@
from .core import *
"""Public exports for the :mod:`teams_logger` package."""

from .core import (
TeamsHandler,
TeamsQueueHandler,
Office365CardFormatter,
TeamsCardsFormatter,
TeamsAdaptiveCardFormatter,
)

__all__ = [
"TeamsHandler",
"TeamsQueueHandler",
"Office365CardFormatter",
"TeamsCardsFormatter",
"TeamsAdaptiveCardFormatter",
]
59 changes: 42 additions & 17 deletions teams_logger/core.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Core logging handlers and formatters for Microsoft Teams integration."""

import json
import queue
from collections import defaultdict
Expand Down Expand Up @@ -53,9 +55,12 @@ def emit(self, record: LogRecord):
try:
data = self.format(record)
requests.post(
url=self.url, headers={"Content-Type": "application/json"}, data=data
url=self.url,
headers={"Content-Type": "application/json"},
data=data,
timeout=3,
)
except Exception:
except requests.RequestException:
self.handleError(record)


Expand All @@ -75,7 +80,9 @@ def __init__(self, url, level=NOTSET):
super().__init__(self._log_queue)

self._teams_handler = TeamsHandler(url, level)
teams_log_listener = QueueListener(self._log_queue, self._teams_handler)
teams_log_listener = QueueListener(
self._log_queue, self._teams_handler
)
teams_log_listener.start()

def setFormatter(self, fmt):
Expand All @@ -85,10 +92,11 @@ def setFormatter(self, fmt):

class Office365CardFormatter(TeamsCardsFormatter):
"""
This formatter formats logs records as a simple office 365 connector card.
The connector card documentation is displayed in the link below:
This formatter formats log records as a simple Office 365 connector card.
The connector card documentation is at
https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference#office-365-connector-card
In addition to the message, each log record attribute (levelname, lineno...etc) can be displayed as facts.
In addition to the message, each log record attribute (levelname,
lineno...etc) can be displayed as facts.
"""

_facts = {"name", "levelname", "levelno", "lineno"}
Expand All @@ -105,7 +113,7 @@ class Office365CardFormatter(TeamsCardsFormatter):

def __init__(self, facts: Iterable[str]):
"""
:param facts: LogRecord attributes to be displayed as facts in the message's card.
:param facts: LogRecord attributes to display as facts in the card.
"""
self.facts = self._facts.intersection(set(facts))
super().__init__()
Expand All @@ -132,15 +140,19 @@ def format(self, record: LogRecord) -> str:
)

def _build_facts_list(self, record: LogRecord):
return [{"name": fact, "value": getattr(record, fact)} for fact in self.facts]
return [
{"name": fact, "value": getattr(record, fact)}
for fact in self.facts
]


class TeamsAdaptiveCardFormatter(TeamsCardsFormatter):
"""
This formatter formats logs records as a simple adaptive card.
The connector card documentation is displayed in the link below:
This formatter formats log records as a simple adaptive card.
The card documentation is at
https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/design-effective-cards
In addition to the message, each log record attribute (levelname, lineno...etc) can be displayed as facts.
In addition to the message, each log record attribute (levelname,
lineno...etc) can be displayed as facts.
"""

_facts = {"name", "levelname", "levelno", "lineno"}
Expand All @@ -157,7 +169,7 @@ class TeamsAdaptiveCardFormatter(TeamsCardsFormatter):

def __init__(self, facts: Iterable[str]):
"""
:param facts: LogRecord attributes to be displayed as facts in the message's card.
:param facts: LogRecord attributes to display as facts in the card.
"""
self.facts = self._facts.intersection(set(facts))
super().__init__()
Expand All @@ -175,18 +187,26 @@ def format(self, record: LogRecord) -> str:
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"contentType": (
"application/vnd.microsoft.card.adaptive"
),
"contentUrl": None,
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"$schema": (
"http://adaptivecards.io/schemas/"
"adaptive-card.json"
),
"version": "1.2",
"type": "AdaptiveCard",
"body": [
{
"type": "TextBlock",
"size": "Medium",
"weight": "Bolder",
"text": f"{record.levelname.title()} in {record.module}",
"text": (
f"{record.levelname.title()} in "
f"{record.module}"
),
"color": self._color_map[record.levelname],
"horizontalAlignment": "Center",
},
Expand All @@ -200,7 +220,9 @@ def format(self, record: LogRecord) -> str:
},
{
"type": "RichTextBlock",
"inlines": [{"type": "TextRun", "text": message}],
"inlines": [
{"type": "TextRun", "text": message}
],
},
],
},
Expand All @@ -210,4 +232,7 @@ def format(self, record: LogRecord) -> str:
)

def _build_facts_list(self, record: LogRecord):
return [{"title": fact, "value": getattr(record, fact)} for fact in self.facts]
return [
{"title": fact, "value": getattr(record, fact)}
for fact in self.facts
]
14 changes: 13 additions & 1 deletion tests/test_teams_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import sys
import time
import unittest.mock

import requests
from logging import INFO, WARNING, Handler, LogRecord, getLogger, shutdown
from logging.config import dictConfig

Expand All @@ -19,6 +21,11 @@ def __init__(self, co_filename, co_name):
self.co_filename = co_filename
self.co_name = co_name

def co_positions(self):
"""Return an infinite stream of empty code position tuples."""
while True:
yield (None, None, None, None)


class FakeFrame(object):
def __init__(self, f_code, f_globals):
Expand All @@ -34,6 +41,7 @@ def __init__(self, frames, line_nums):
self._line_nums = line_nums
self.tb_frame = frames[0]
self.tb_lineno = line_nums[0]
self.tb_lasti = 0

@property
def tb_next(self):
Expand Down Expand Up @@ -358,11 +366,12 @@ def test_emit_with_default_formatter(self, mock_requests):
url=self.url,
headers={"Content-Type": "application/json"},
data=self.expected_payload_with_default_formatter,
timeout=3,
)

@unittest.mock.patch("requests.post")
def test_emit_exception(self, mock_requests):
mock_requests.side_effect = ValueError()
mock_requests.side_effect = requests.RequestException()
sys.stderr = io.StringIO() # disable output of handleError operation
try:
self.logger.log(self.log_level, self.log_text, self.log_parameter)
Expand All @@ -385,6 +394,7 @@ def test_emit_with_teams_message_card_formatter(self, mock_requests):
url=self.url,
headers={"Content-Type": "application/json"},
data=self.fake_message_card,
timeout=3,
)

def test_initializing_logger_from_dict(self):
Expand All @@ -409,6 +419,7 @@ def test_dict_level_handling(self, mock_requests):
url=self.url,
headers={"Content-Type": "application/json"},
data=self.expected_payload_with_default_formatter,
timeout=3,
)

# Logging to lower level (INFO) should *not* be handled
Expand Down Expand Up @@ -461,6 +472,7 @@ def test_emit_with_default_formatter(self, mock_requests):
url=self.url,
headers={"Content-Type": "application/json"},
data=self.expected_payload_with_default_formatter,
timeout=3,
)


Expand Down
Loading