diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index f4cabb4..392d4c0 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -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 diff --git a/.gitignore b/.gitignore index de3f1f3..f956608 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,4 @@ cards .vscode -<<<<<<< HEAD __pycache__ -======= -__pycache__ - ->>>>>>> ef5a658... Initial commit +.venv diff --git a/README.md b/README.md index f37eb17..6046d5c 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,32 @@ -# Adaptive Cards +# Adaptive Cards + +- [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** -
This library is still in progress and is lacking some features. However, missing fractions are planned to be added soon. +
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. @@ -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 @@ -92,7 +106,7 @@ Find your final layout below. 💡 **Please note**
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 @@ -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 @@ -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) @@ -567,13 +581,10 @@ print(f"Validation was successful: {result == Result.SUCCESS}") } ``` - 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: @@ -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! diff --git a/adaptive_cards/__init__.py b/adaptive_cards/__init__.py index e69de29..68dcb78 100644 --- a/adaptive_cards/__init__.py +++ b/adaptive_cards/__init__.py @@ -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 * diff --git a/adaptive_cards/actions.py b/adaptive_cards/actions.py index a03863d..acbba6f 100644 --- a/adaptive_cards/actions.py +++ b/adaptive_cards/actions.py @@ -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") ) @@ -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. """ diff --git a/adaptive_cards/card.py b/adaptive_cards/card.py index bc75cd8..1c4f9dd 100644 --- a/adaptive_cards/card.py +++ b/adaptive_cards/card.py @@ -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 @@ -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 @@ -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 diff --git a/adaptive_cards/client.py b/adaptive_cards/client.py new file mode 100644 index 0000000..33cd15b --- /dev/null +++ b/adaptive_cards/client.py @@ -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 diff --git a/adaptive_cards/validation.py b/adaptive_cards/validation.py index f390335..98e1a48 100644 --- a/adaptive_cards/validation.py +++ b/adaptive_cards/validation.py @@ -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 @@ -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 diff --git a/examples/simple_card/simple_card.py b/examples/simple_card/simple_card.py index 7293f27..70e264a 100644 --- a/examples/simple_card/simple_card.py +++ b/examples/simple_card/simple_card.py @@ -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", @@ -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) diff --git a/examples/wrap_up_card/example_wrap_up_card.py b/examples/wrap_up_card/wrap_up_card.py similarity index 96% rename from examples/wrap_up_card/example_wrap_up_card.py rename to examples/wrap_up_card/wrap_up_card.py index 263cae7..f058cf7 100644 --- a/examples/wrap_up_card/example_wrap_up_card.py +++ b/examples/wrap_up_card/wrap_up_card.py @@ -1,4 +1,5 @@ """Example: wrap-up card""" + # pylint: disable=C0413 import sys @@ -10,6 +11,7 @@ from adaptive_cards.card import AdaptiveCard from adaptive_cards.elements import TextBlock, Image from adaptive_cards.containers import Container, ContainerTypes, ColumnSet, Column +from adaptive_cards.client import TeamsClient containers: list[ContainerTypes] = [] @@ -155,9 +157,16 @@ ) ) +# Build card card = AdaptiveCard.new().version("1.5").add_items(containers).create() +# 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) diff --git a/pyproject.toml b/pyproject.toml index b4e3ab4..9f268ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,14 +16,8 @@ classifiers = [ readme = "README.md" license = { file = "LICENSE" } keywords = ["bot", "ui", "adaptivecards", "cards", "adaptivecardsio", "python"] -dependencies = [ - "dataclasses-json", - "StrEnum" -] +dependencies = ["dataclasses-json", "StrEnum", "requests"] requires-python = ">=3.10" -[options] -package_dir = ["adaptive_cards"] - [project.urls] -Homepage = "https://github.com/dennis6p/adaptive-cards-py" \ No newline at end of file +Homepage = "https://github.com/dennis6p/adaptive-cards-py"