Skip to content

Commit

Permalink
Allow sending cards to MS Teams via Webhook URLs (#22)
Browse files Browse the repository at this point in the history
* [doc] Add TOC and documentation about sending cards

resolves #21

* [feat] Rename example and add sending card functionality

resolves #21

* [feat] Add MS Teams client for sending cards

resolves #21

* [feat] Add sending card functionality to example

resolves #21

* [refactor] Auto re-formatting applied

* [refactor] Rename module

* [ci] Add virtualenv to .gitignore

* [doc] Rename chapter

* [refactor] Add module level imports to __init__.py

* [refactor] Rename webhook url variable

* [doc] Add package docstring to __init__ file

* [refactor] Trim trailing whitespaces

* [ci] Update dependencies and pylint pipeline

* [ci] Remove options section from pyproject.toml file

* [ci] Add virtual environment to pipeline

* [ci] Run pylint with uv

* [ci] Source virtualenv before running pylint

* [ci] Don't use virtual environment in CI

* [doc] Fix line too long issue

* [ci] Add .venv as default virtualenv to gitignore

* [doc] Extend TeamsClient example

Show how webhook url can be updated

* [refactor] Use http status codes instead of plain values

* [refactor] Re-order import statements
  • Loading branch information
dennis6p authored Nov 17, 2024
1 parent ead31a5 commit e7b3eab
Show file tree
Hide file tree
Showing 11 changed files with 216 additions and 45 deletions.
6 changes: 5 additions & 1 deletion .github/workflows/pylint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ jobs:
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install uv for package management
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pylint dataclasses-json StrEnum
uv pip install --system pylint
uv pip install --system -r pyproject.toml
- name: Analysing the code with pylint
run: |
pylint $(git ls-files '*.py') --rcfile pylintrc
6 changes: 1 addition & 5 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
cards
.vscode
<<<<<<< HEAD
__pycache__
=======
__pycache__

>>>>>>> ef5a658... Initial commit
.venv
76 changes: 55 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
# Adaptive Cards
# Adaptive Cards <!-- omit in toc -->

- [About](#about)
- [Features](#features)
- [Dependencies](#dependencies)
- [Installation](#installation)
- [Library structure](#library-structure)
- [Usage](#usage)
- [A simple card](#a-simple-card)
- [Adding multiple elements at once](#adding-multiple-elements-at-once)
- [A more complex card](#a-more-complex-card)
- [Validate schema](#validate-schema)
- [Send card to MS Teams](#send-card-to-ms-teams)
- [Examples](#examples)
- [Contribution](#contribution)

[![PyPI version](https://badge.fury.io/py/adaptive-cards-py.svg)](https://pypi.org/project/adaptive-cards-py/)

A thin Python wrapper for creating [**Adaptive Cards**](https://adaptivecards.io/) easily on code level. The deep integration of Python's `typing` package prevents you from creating invalid schemes and guides you while creating visual apealing cards.
A thin Python wrapper for creating [**Adaptive Cards**](https://adaptivecards.io/) easily on code level. The deep integration of Python's `typing` package prevents you from creating invalid schemes and guides you while creating visual apealing cards.

If you are interested in the general concepts of adaptive cards and want to dig a bit deeper, have a look into the [**official documentation**](https://learn.microsoft.com/en-us/adaptive-cards/) or start a jump start and get used to the [**schema**](https://adaptivecards.io/explorer/).

💡 **Please note**
<br>This library is still in progress and is lacking some features. However, missing fractions are planned to be added soon.
<br>This library is still in progress and is lacking some features. However, missing fractions are planned to be added soon.

## About

This library is intended to provide a clear and simple interface for creating adaptive cards with only a few lines of code in a more robust way. The heavy usage of Python's typing library should
prevent one from creating invalid schemes and structures. Instead, creating cards should be intuitive and work like a breeze.
prevent one from creating invalid schemes and structures. Instead, creating cards should be intuitive and work like a breeze.

For a comprehensive introduction into the main ideas and patterns of adaptive cards, have a look on the [**official documentation**](https://docs.microsoft.com/en-us/adaptive-cards). I also recommend using the [**schema explorer**](https://adaptivecards.io/explorer) page alongside the implementation, since the library's type system relies on these schemes.

Expand Down Expand Up @@ -50,7 +64,7 @@ To perform validation on a fully initialized card, one can make use of the `Sche

### A simple card

A simple `TextBlock` lives in the `elements` module and can be used after it's import.
A simple `TextBlock` lives in the `elements` module and can be used after it's import.

```Python
from adaptive_cards.elements import TextBlock
Expand Down Expand Up @@ -92,7 +106,7 @@ Find your final layout below.
💡 **Please note**
<br>After building the object is done, the `create(...)` method must be called in order to create the final object. In this case, the object will be of type `AdaptiveCard`.

To directly export your result, make use of the
To directly export your result, make use of the
`to_json()` method provided by every card.

```Python
Expand All @@ -103,7 +117,7 @@ with open("path/to/out/file.json", "w+") as f:

### Adding multiple elements at once

Assuming you have a bunch of elements you want your card to enrich with. There is also a method for doing so. Let's re-use the example from before, but add another `Image` element here as well.
Assuming you have a bunch of elements you want your card to enrich with. There is also a method for doing so. Let's re-use the example from before, but add another `Image` element here as well.

```Python
from adaptive_cards.elements import TextBlock, Image
Expand Down Expand Up @@ -140,10 +154,10 @@ This will result in a card like shown below.

![simple card 2](https://github.com/dennis6p/adaptive-cards-py/blob/main/examples/simple_card/simple_card_2.jpg?raw=true)

### Finally, a more complex card
### A more complex card

You can have a look on the following example for getting an idea of what's actually possible
with adaptive cards.
with adaptive cards.

![wrap up card](https://github.com/dennis6p/adaptive-cards-py/blob/main/examples/wrap_up_card/wrap_up_card.jpg?raw=true)

Expand Down Expand Up @@ -567,13 +581,10 @@ print(f"Validation was successful: {result == Result.SUCCESS}")
}
```


</details>

But we are still scratching the surface. You can do even better!



### Validate schema

New components and fields are getting introduced every now and then. This means, if you are using an early version for a card and add fields, which are not compliant with it, you will have an invalid schema. To prevent you from exporting fields not yet supported by the card and target framework, a schema validation can be performed. It's as simple as that:
Expand All @@ -596,18 +607,41 @@ print(f"Validation was successful: {result == Result.SUCCESS}")

```

### Send card to MS Teams

Of course, you want to create those cards for a reason. So once you did that, you might want to send it to one of the compatible services like MS Teams. See the following example, how this can be done, assuming that all previously mentioned steps are done prior to that:

```python

...

from adaptive_cards.clients import TeamsClient
from requests import Response

...

# send card
webhook_url: str = "YOUR-URL"
client: TeamsClient = TeamsClient(webhook_url)
response: Response = client.send(card)

new_webhook_url: str = "YOUR-URL-OF-SECOND-CHANNEL"
client.set_webhook_url(new_webhook_url)
response: Response = client.send(card)

...

```

So far, there is only a MS Teams client available. If further services should be supported, give me some feedback by opening an Issue for instance.

Find further information about sending cards or creating Webhooks to/in MS Teams [__here__](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498).

## Examples

If you are interested in more comprehensive examples or the actual source code, have a look into the `examples` folder.
If you are interested in more comprehensive examples or the actual source code, have a look into the `examples` folder.

## Contribution

Feel free to create issues, fork the repository or even come up with a pull request. I am happy about any kind of contribution and would love
to hear your feedback!

## Roadmap

- [x] 📕 Comprehensive documentation on code level
- [x] 🐍 Ready to use Python package
- [ ] 🚀 More and better examples
- [ ] 🔎 Comprehensive validation
to hear your feedback!
24 changes: 24 additions & 0 deletions adaptive_cards/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""
This package provides small components for building adaptive cards compliant to
the current interface definite with Python.
[Schema Explorer](https://adaptivecards.io/explorer/)
This `__init__.py` file exposes key components of the package for easier access:
By importing the package, the following modulesare directly available:
- [actions, card_types, card, client, containers, elements, inputs, utils, validation]
This module also initializes the package and ensures that any necessary
configuration or setup is performed.
"""

from adaptive_cards.actions import *
from adaptive_cards.card_types import *
from adaptive_cards.card import *
from adaptive_cards.client import *
from adaptive_cards.containers import *
from adaptive_cards.elements import *
from adaptive_cards.inputs import *
from adaptive_cards.utils import *
from adaptive_cards.validation import *
12 changes: 7 additions & 5 deletions adaptive_cards/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,20 @@ class Action:
icon_url: An optional string representing the URL of the icon associated with the action.
id: An optional string representing the ID of the action.
style: An optional ActionStyle enum value representing the style of the action.
fallback: An optional fallback ActionTypes object representing the fallback action to be
fallback: An optional fallback ActionTypes object representing the fallback action to be
performed.
tooltip: An optional string representing the tooltip text for the action.
is_enabled: An optional boolean indicating whether the action is enabled or disabled.
mode: An optional ActionMode enum value representing the mode of the action.
requires: An optional dictionary mapping string keys to string values representing the
requires: An optional dictionary mapping string keys to string values representing the
requirements for the action.
"""

title: Optional[str] = field(default=None, metadata=utils.get_metadata("1.0"))
icon_url: Optional[str] = field(default=None, metadata=utils.get_metadata("1.1"))
id: Optional[str] = field(default=None, metadata=utils.get_metadata("1.0")) # pylint: disable=C0103
id: Optional[str] = field(
default=None, metadata=utils.get_metadata("1.0")
) # pylint: disable=C0103
style: Optional[ct.ActionStyle] = field(
default=None, metadata=utils.get_metadata("1.2")
)
Expand Down Expand Up @@ -158,9 +160,9 @@ class ActionExecute(Action):
Attributes:
type: The type of the action, set to "Action.ShowCard".
verb: An optional string representing the verb of the action.
data: An optional string or Any type representing additional data associated
data: An optional string or Any type representing additional data associated
with the action.
associated_inputs: An optional AssociatedInputs object representing associated
associated_inputs: An optional AssociatedInputs object representing associated
inputs for the action.
"""

Expand Down
8 changes: 5 additions & 3 deletions adaptive_cards/card.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Implementation of the adaptive card type"""

from dataclasses import dataclass, field
from typing import Optional
from typing import Optional, Sequence
from dataclasses_json import dataclass_json, LetterCase
from adaptive_cards.actions import SelectAction, ActionTypes
from adaptive_cards.containers import ContainerTypes
Expand Down Expand Up @@ -213,7 +213,9 @@ def schema(self, schema: str) -> "AdaptiveCardBuilder":
self.__card.schema = schema
return self

def add_item(self, item: Element | ContainerTypes | InputTypes) -> "AdaptiveCardBuilder":
def add_item(
self, item: Element | ContainerTypes | InputTypes
) -> "AdaptiveCardBuilder":
"""
Add single element, container or input to card
Expand All @@ -229,7 +231,7 @@ def add_item(self, item: Element | ContainerTypes | InputTypes) -> "AdaptiveCard
return self

def add_items(
self, items: list[Element | ContainerTypes | InputTypes]
self, items: Sequence[Element | ContainerTypes | InputTypes]
) -> "AdaptiveCardBuilder":
"""
Add multiple elements, containers or inputs to card
Expand Down
91 changes: 91 additions & 0 deletions adaptive_cards/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Features to send adaptive cards to MS Teams Channels via web hooks.
## Resources
- [Adaptive Cards for Python](https://github.com/dennis6p/adaptive-cards-py)
- [Creating incoming web hooks with workflows](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498)
""" # pylint: disable=line-too-long

from typing import Any
from dataclasses import asdict
from http import HTTPStatus
import requests

from requests import Response
from adaptive_cards.card import AdaptiveCard


class TeamsClient:
"""Client class for sending adaptive card objects to MS Teams via webhooks"""

def __init__(self, webhook_url: str) -> None:
self._webhook_url: str = webhook_url
"""URL the client should communicate with"""

@staticmethod
def _create_attachment(card: AdaptiveCard) -> dict:
"""
Create an attachment from an AdaptiveCard.
Args:
card (AdaptiveCard): The AdaptiveCard to create an attachment from.
Returns:
dict: A dictionary representing the attachment.
"""
content: dict[str, Any] = asdict(card)
return {
"contentType": "application/vnd.microsoft.card.adaptive",
"contentUrl": None,
"content": content,
}

def webhook_url(self) -> str:
"""
Returns webhook URL the current client has been initialized with
Returns:
str: _description_
"""
return self._webhook_url

def set_webhook_url(self, webhook_url: str) -> None:
"""
Update webhook URL the client should use when sending a card
Args:
webhook_url (str): Webhook URL
"""
self._webhook_url = webhook_url

def send(self, *cards: AdaptiveCard, timeout: int = 1000) -> Response:
"""
Send the payload of one or multiple cards to a via Microsoft Teams webhook.
Args:
cards (tuple[AdaptiveCard, ...]: Card objecs supposed to be sent
timeout (int): Upper request timeout limit
Returns:
Response: Response object from the webhook request.
Raises:
ValueError: If no webhook URL or no cards are provided.
RuntimeError: If the request to the webhook fails.
"""
if not self._webhook_url:
raise ValueError("No webhook URL provided.")
if not cards:
raise ValueError("No cards provided.")

headers = {"Content-Type": "application/json"}
attachments = [self._create_attachment(card) for card in cards]
payload = {"attachments": attachments}
response = requests.post(
self._webhook_url, headers=headers, json=payload, timeout=timeout
)
if response.status_code not in (HTTPStatus.OK, HTTPStatus.ACCEPTED):
raise RuntimeError(
f"Failed to send card: {response.status_code}, {response.text}"
)
return response
2 changes: 2 additions & 0 deletions adaptive_cards/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class InvalidField:
field_name: The name of the invalid field.
version: The version of the field.
"""

parent_type: str
field_name: str
version: str
Expand All @@ -46,6 +47,7 @@ class SchemaValidator:
"""
Validator class for checking a cards schema w.r.t. to version numbers of individual fields.
"""

def __init__(self) -> None:
self.__card: AdaptiveCard
self.__item: Any
Expand Down
17 changes: 15 additions & 2 deletions examples/simple_card/simple_card.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""Example: simple card"""

from adaptive_cards.elements import TextBlock
import adaptive_cards.card_types as types
from adaptive_cards.card import AdaptiveCard
from adaptive_cards.validation import SchemaValidator, Result
from adaptive_cards.client import TeamsClient

text_block: TextBlock = TextBlock(
text="It's your second card",
Expand All @@ -10,7 +13,17 @@
horizontal_alignment=types.HorizontalAlignment.CENTER,
)

# build card
version: str = "1.4"
card: AdaptiveCard = AdaptiveCard.new().version(version).add_item(text_block).create()
output = card.to_json()
print(output)

# validate card
validator: SchemaValidator = SchemaValidator()
result: Result = validator.validate(card)

print(f"Validation was successful: {result == Result.SUCCESS}")

# send card
webhook_url: str = "YOUR-URL"
client: TeamsClient = TeamsClient(webhook_url)
client.send(card)
Loading

0 comments on commit e7b3eab

Please sign in to comment.