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
36 changes: 18 additions & 18 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
name: Python Test

on:
push:
paths:
- '**.py'
push: {}
workflow_dispatch: {}

jobs:
python-test:
name: python
runs-on: ubuntu-latest
strategy:
matrix:
python-version:
- "3.11"
- "3.12"
- "3.13"
steps:
- name: Checkout Source
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install .
pip install .[dev]
- name: Run Tests
run: |
pip install pytest pytest-cov
pytest
- uses: actions/checkout@v5
- name: Install uv and set the Python version
uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install the project
run: uv sync --locked --all-extras --dev
- name: Run tests
run: uv run pytest
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Changelog
---------

- v2.1.0 (2025-09-07)
- Support for Shapeshifter 3.1.0
- v2.0.1 (2025-07-08)
- Bumped fastapi-xml depedency version
- v2.0.0 (2025-07-08)
- Support for OAuth2 on outgoing messages
- Updated depedencies
- v1.2.0 (2024-04-04)
- Upgrade to latest FastAPI and Pydantic
- v1.1.2 (2024-03-12)
- Pinned depedencies after a breaking update to fastapi-xml was released
- v1.1.0 (2023-08-30)
- Use the published 3.0.0 spec for XSD validation and objects
- v1.0.1 (2023-08-23)
- Fixed outgoing signed message base 64 encoding
- Add support for empty response messages
- v1.0.0 (2023-07-20)
- Initial release version
69 changes: 19 additions & 50 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ Shapeshifter library for Python

This is a Python implementation of the ShapeShifter UFTP protocol.

Overview
--------

This library implements the full UFTP protocol that you can use for Shapeshifter communications. It implements all three roles: Distribution System Operator (**DSO**), Aggregator (**AGR**) and Common Reference Operator (**CRO**) in both directions (client and service).

Features of this package:

- Building, parsing and validation of the XML messages
- Signing and verifying of the XML messages using signatures
- DNS for service discovery and key retrieval
- Convenient clients for each role-pair
- Convenient services for each role
- JSON-serializable dataclasses for easy transport to other systems
- Fully internal queing system for full-duplex communication with minimal user code required
- Compatible with version 3.0.0 and 3.1.0 of the Shapeshifter protocol.


Installation
------------

Expand All @@ -13,6 +30,8 @@ Installation
Running tests
------------

If you want to develop shapeshifter-uftp, you can fork or clone this repository and run the tests:

.. code-block:: bash

$ pip install .
Expand Down Expand Up @@ -212,53 +231,3 @@ To use OAuth in outgoing requests, you can use the provided OAuthClient class. T


Similarly, if you have a Service instance that dynamically needs to retrieve the OAuth information for each different recipient server, you can provide an ``oauth_lookup_function`` that takes a ``(sender_domain, sender_role)`` and returns an instance of OAuthClient:


Overview
--------

This library implements the full UFTP protocol that you can use for Shapeshifter communications. It implements all three roles: Distribution System Operator (**DSO**), Aggregator (**AGR**) and Common Reference Operator (**CRO**) in both directions (client and service).

Features of this package:

- Building, parsing and validation of the XML messages
- Signing and verifying of the XML messages using signatures
- DNS for service discovery and key retrieval
- Convenient clients for each role-pair
- Convenient services for each role
- JSON-serializable dataclasses for easy transport to other systems
- Fully internal queing system for full-duplex communication with minimal user code required
- Compatible with the 3.0.0 version of the Shapeshifter protocol.

Version History
---------------

+-------------+-------------------+----------------------------------+
| Version | Release Date | Release Notes |
+=============+===================+==================================+
| 2.0.1 | 2025-07-08 | Bumped fastapi-xml depedency |
| | | version. |
+-------------+-------------------+----------------------------------+
| 2.0.0 | 2025-07-08 | Support for OAuth 2 on outgoing |
| | | messages, updated dependencies |
+-------------+-------------------+----------------------------------+
| 1.2.0 | 2024-04-04 | Upgrade to latest FastAPI and |
| | | Pydantic. |
+-------------+-------------------+----------------------------------+
| 1.1.2 | 2024-03-12 | Pinned dependencies after a |
| | | breaking update to fastapi-xml |
| | | was released. |
+-------------+-------------------+----------------------------------+
| 1.1.0 | 2023-08-30 | Use the published 3.0.0 spec |
| | | for the XSD validation and |
| | | objects. |
+-------------+-------------------+----------------------------------+
| 1.0.1 | 2023-08-23 | Fixes the following two issues: |
| | | |
| | | - Outgoing signed messages would |
| | | be twice-encoded into base64 |
| | | - Support for empty response |
| | | messages |
+-------------+-------------------+----------------------------------+
| 1.0.0 | 2023-07-20 | Initial release version |
+-------------+-------------------+----------------------------------+
21 changes: 15 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
[project]
name = "shapeshifter_uftp"
version = "2.0.1"
version = "2.1.0"
description = "Implementation of the Shapeshifter (UFTP) protocol"
dependencies = [
"xsdata[lxml]>=24.4,<=25.4",
"xsdata[lxml]>=25.0,<26.0",
"pynacl==1.5.0",
"dnspython==2.6.1",
"fastapi>=0.110,<0.116",
"dnspython==2.7.0",
"fastapi>=0.110,<0.117",
"fastapi-xml>=1.1.1,<2.0.0",
"requests",
"uvicorn",
"termcolor"
"termcolor",
]
requires-python = ">=3.10"
requires-python = ">=3.11"
readme = "README.md"

[project.urls]
Repository = "https://github.com/shapeshifter/shapeshifter-library-python"
Documentation = "https://github.com/shapeshifter/shapeshifter-library-python/README.md"
Issues = "https://github.com/shapeshifter/shapeshifter-library-python/issues"
Changelog = "https://github.com/shapeshifter/shapeshifter-library-python/blob/main/CHANGELOG.md"


[dependency-groups]
dev = [
Expand Down
40 changes: 0 additions & 40 deletions setup.py

This file was deleted.

13 changes: 9 additions & 4 deletions shapeshifter_uftp/client/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,11 @@ class ShapeshifterClient:

sender_role: str
recipient_role: str
protocol_version = "3.0.0"
num_outgoing_workers = 10
num_delivery_attempts = 10
request_timeout = 30
exponential_retry_factor = 1
exponential_retry_base = 2
exponential_retry_factor = 1.0
exponential_retry_base = 2.0

def __init__(
self,
Expand All @@ -36,6 +35,7 @@ def __init__(
recipient_endpoint: str = None,
recipient_signing_key: str = None,
oauth_client: OAuthClient = None,
version: str = "3.1.0"
):
"""
Shapeshifter client class that allows you to initiate messages to a different party.
Expand All @@ -47,12 +47,17 @@ def __init__(
:param str recipient_signing_key: the public signing key of the recipient. If omitted, will
look up the signing key using DNS.
:param OAuthClient oauth_client: Optional OAuth client instance for using oauth to authenticate outgoing messages.
:param str version: Version number for the shapeshfter protocol (3.0.0 or 3.1.0)
"""
if recipient_domain is None and recipient_endpoint is None:
raise ValueError(
"One of recipient_domain or recipient_endpoint must be provided."
)

if version not in ("3.0.0", "3.1.0"):
raise ValueError(f"'version' should be one of '3.0.0' or '3.1.0', not '{version}'")

self.version = version
self.sender_domain = sender_domain
self.signing_key = signing_key
self.recipient_domain = recipient_domain
Expand Down Expand Up @@ -94,7 +99,7 @@ def _send_message(self, message: PayloadMessage) -> PayloadMessageResponse:
# every time they create any message, in order to reduce the
# duplicated code that would result in, and all of these
# properties can be calculated in the framework anyway.
message.version = self.protocol_version
message.version = self.version
message.sender_domain = self.sender_domain
message.sender_role = self.sender_role
message.recipient_domain = self.recipient_domain
Expand Down
8 changes: 4 additions & 4 deletions shapeshifter_uftp/service/agr_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,14 @@ def process_agr_portfolio_update_response(
# participant. #
# ------------------------------------------------------------ #

def cro_client(self, recipient_domain) -> ShapeshifterAgrCroClient:
def cro_client(self, recipient_domain, version="3.1.0") -> ShapeshifterAgrCroClient:
"""
Retrieve a client object for sending messages to the CRO.
"""
return self._get_client(recipient_domain, "CRO")
return self._get_client(recipient_domain, "CRO", version)

def dso_client(self, recipient_domain) -> ShapeshifterAgrDsoClient:
def dso_client(self, recipient_domain, version="3.1.0") -> ShapeshifterAgrDsoClient:
"""
Retrieve a client object for sending messages to the DSO.
"""
return self._get_client(recipient_domain, "DSO")
return self._get_client(recipient_domain, "DSO", version)
14 changes: 10 additions & 4 deletions shapeshifter_uftp/service/base_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ class ShapeshifterService():
threading and context options.
"""

protocol_version = "3.0.0"
sender_domain = None
sender_role = None
acceptable_messages = []
Expand All @@ -52,7 +51,8 @@ def __init__(
oauth_lookup_function=None,
host: str = "0.0.0.0",
port: int = 8080,
path="/shapeshifter/api/v3/message",
path: str = "/shapeshifter/api/v3/message",
version: str = "3.1.0"
):
"""
:param sender_domain: our sender domain (FQDN) that the recipient uses to look us up.
Expand All @@ -71,6 +71,11 @@ def __init__(
:param path: the URL path that the server listens on (default: /shapeshifter/api/v3/message)
"""

if version not in ("3.0.0", "3.1.0"):
raise ValueError(f"'version' must be one of '3.0.0' or '3.1.0', not {version}")

self.version = version

# Set the sender domain, which is used
# to identify us to the other party.
self.sender_domain = sender_domain
Expand Down Expand Up @@ -214,7 +219,7 @@ def _process_message(self, message: PayloadMessage):
f"{err.__class__.__name__}: {err}"
)

def _get_client(self, recipient_domain, recipient_role):
def _get_client(self, recipient_domain: str, recipient_role: str, version: str = "3.1.0"):
"""
Method to get a relevant client to communicate to the
indicated participant.
Expand All @@ -230,6 +235,7 @@ def _get_client(self, recipient_domain, recipient_role):
recipient_endpoint = recipient_endpoint,
recipient_signing_key = recipient_signing_key,
oauth_client = oauth_client,
version=version,
)

def _reject_message(self, message, unsealed_message, reason):
Expand All @@ -239,7 +245,7 @@ def _reject_message(self, message, unsealed_message, reason):
if type(unsealed_message) not in request_response_map:
return

client = self._get_client(message.sender_domain, message.sender_role)
client = self._get_client(message.sender_domain, message.sender_role, unsealed_message.version)
response_type = request_response_map[type(unsealed_message)]
response_id_field = snake_case(type(unsealed_message).__name__) + "_message_id"
message_contents = {
Expand Down
8 changes: 4 additions & 4 deletions shapeshifter_uftp/service/cro_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,14 @@ def process_dso_portfolio_update(self, message: DsoPortfolioUpdate):
# participant. #
# ------------------------------------------------------------ #

def agr_client(self, recipient_domain) -> ShapeshifterCroAgrClient:
def agr_client(self, recipient_domain, version="3.1.0") -> ShapeshifterCroAgrClient:
"""
Retrieve a client object for sending messages to the AGR.
"""
return self._get_client(recipient_domain, "AGR")
return self._get_client(recipient_domain, "AGR", version)

def dso_client(self, recipient_domain) -> ShapeshifterCroDsoClient:
def dso_client(self, recipient_domain, version="3.1.0") -> ShapeshifterCroDsoClient:
"""
Retrieve a client object for sending messages to the DSO.
"""
return self._get_client(recipient_domain, "DSO")
return self._get_client(recipient_domain, "DSO", version)
8 changes: 4 additions & 4 deletions shapeshifter_uftp/service/dso_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,14 @@ def process_metering(self, message: Metering):
# participant. #
# ------------------------------------------------------------ #

def agr_client(self, recipient_domain) -> ShapeshifterDsoAgrClient:
def agr_client(self, recipient_domain, version="3.1.0") -> ShapeshifterDsoAgrClient:
"""
Retrieve a client object for sending messages to the AGR.
"""
return self._get_client(recipient_domain, "AGR")
return self._get_client(recipient_domain, "AGR", version)

def cro_client(self, recipient_domain) -> ShapeshifterDsoCroClient:
def cro_client(self, recipient_domain, version="3.1.0") -> ShapeshifterDsoCroClient:
"""
Retrieve a client object for sending messages to the CRO.
"""
return self._get_client(recipient_domain, "CRO")
return self._get_client(recipient_domain, "CRO", version)
Loading