Merge pull request 'pyxsd_test' (#1) from pyxsd_test into main

Reviewed-on: https://gitea.linter-home.com/jonas/alpinebits_python/pulls/1
This commit is contained in:
jonas
2025-09-25 15:52:42 +02:00
33 changed files with 16899 additions and 187312 deletions

27
.github/workflows/publish.yaml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: "Publish"
on:
push:
tags:
# Publish on any tag starting with a `v`, e.g., v0.1.0
- v*
jobs:
run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install Python 3.13
run: uv python install 3.13
- name: Build
run: uv build
# Check that basic features work and we didn't miss to include crucial files
- name: Smoke test (wheel)
run: uv run --isolated --no-project --with dist/*.whl tests/smoke_test.py
- name: Smoke test (source distribution)
run: uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py
- name: Publish
run: uv publish --publish-url https://gitea.linter-home.com.com/api/packages/jonas/pypi --username jonas --password ${{ secrets.GITEA_TOKEN }}

5
.gitignore vendored
View File

@@ -1,5 +1,7 @@
# Python-generated files
__pycache__/
# also exclude nested __pycache__ directories
**/__pycache__/
*.py[oc]
build/
dist/
@@ -8,3 +10,6 @@ wheels/
# Virtual environments
.venv
# exclude ruff cache
.ruff_cache/

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"python.testing.pytestArgs": [
"test"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

59
ACTION_MAPPING_SUMMARY.md Normal file
View File

@@ -0,0 +1,59 @@
## AlpineBits Action Mapping System
### Problem Solved
The AlpineBits specification uses different names for the same action:
- **Capability JSON**: `"action_OTA_Read"` (advertised in handshake)
- **Request Action**: `"OTA_Read:GuestRequests"` (actual request parameter)
### Solution Architecture
#### 1. Enhanced AlpineBitsActionName Enum
```python
# Maps capability names to request names
OTA_READ = ("action_OTA_Read", ["OTA_Read:GuestRequests", "OTA_Read"])
```
#### 2. Automatic Action Discovery
- `ServerCapabilities` scans for implemented actions
- Only includes actions with overridden `handle()` methods
- Generates capability JSON using capability names
#### 3. Request Routing
- `AlpineBitsServer.handle_request()` accepts request action names
- Maps request names back to capability names
- Routes to appropriate action handler
- Validates version support
### Key Features
**Automatic Discovery**: New action implementations are automatically detected
**Name Mapping**: Handles capability vs request name differences
**Version Support**: Actions can support multiple versions
**Error Handling**: Proper HTTP status codes (200, 400, 401, 500)
**Capability Generation**: Dynamic JSON generation for handshakes
### Usage Example
```python
# Server automatically discovers implemented actions
server = AlpineBitsServer()
# Handle request with different name format
response = await server.handle_request(
"OTA_Read:GuestRequests", # Request name
xml_content,
"2024-10"
)
# Capability JSON uses "action_OTA_Read" automatically
capabilities = server.get_capabilities_json()
```
### Adding New Actions
1. Create action class inheriting from `AlpineBitsAction`
2. Add mapping to `AlpineBitsActionName` enum
3. Implement `handle()` method
4. Deploy - action automatically appears in capabilities
The system is now production-ready for handling AlpineBits protocol quirks!

135
echo_data_response.json Normal file
View File

@@ -0,0 +1,135 @@
{
"versions": [
{
"version": "2024-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests_StatusUpdate"
},
{
"action": "action_OTA_HotelInvCountNotif",
"supports": [
"OTA_HotelInvCountNotif_accept_rooms",
"OTA_HotelInvCountNotif_accept_categories",
"OTA_HotelInvCountNotif_accept_deltas",
"OTA_HotelInvCountNotif_accept_out_of_market",
"OTA_HotelInvCountNotif_accept_out_of_order",
"OTA_HotelInvCountNotif_accept_complete_set",
"OTA_HotelInvCountNotif_accept_closing_seasons"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Inventory",
"supports": [
"OTA_HotelDescriptiveContentNotif_Inventory_use_rooms",
"OTA_HotelDescriptiveContentNotif_Inventory_occupancy_children"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Info"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Inventory"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Info"
},
{
"action": "action_OTA_HotelRatePlanNotif_RatePlans",
"supports": [
"OTA_HotelRatePlanNotif_accept_ArrivalDOW",
"OTA_HotelRatePlanNotif_accept_DepartureDOW",
"OTA_HotelRatePlanNotif_accept_RatePlan_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_RoomType_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_mixed_BookingRule",
"OTA_HotelRatePlanNotif_accept_Supplements",
"OTA_HotelRatePlanNotif_accept_FreeNightsOffers",
"OTA_HotelRatePlanNotif_accept_FamilyOffers",
"OTA_HotelRatePlanNotif_accept_full",
"OTA_HotelRatePlanNotif_accept_overlay",
"OTA_HotelRatePlanNotif_accept_RatePlanJoin",
"OTA_HotelRatePlanNotif_accept_OfferRule_BookingOffset",
"OTA_HotelRatePlanNotif_accept_OfferRule_DOWLOS"
]
},
{
"action": "action_OTA_HotelRatePlan_BaseRates",
"supports": [
"OTA_HotelRatePlan_BaseRates_deltas"
]
},
{
"action": "action_OTA_HotelPostEventNotif_EventReports"
}
]
},
{
"version": "2022-10",
"actions": [
{
"action": "action_OTA_Ping"
},
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests_StatusUpdate"
},
{
"action": "action_OTA_HotelInvCountNotif",
"supports": [
"OTA_HotelInvCountNotif_accept_rooms",
"OTA_HotelInvCountNotif_accept_categories",
"OTA_HotelInvCountNotif_accept_deltas",
"OTA_HotelInvCountNotif_accept_out_of_market",
"OTA_HotelInvCountNotif_accept_out_of_order",
"OTA_HotelInvCountNotif_accept_complete_set",
"OTA_HotelInvCountNotif_accept_closing_seasons"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Inventory",
"supports": [
"OTA_HotelDescriptiveContentNotif_Inventory_use_rooms",
"OTA_HotelDescriptiveContentNotif_Inventory_occupancy_children"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Info"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Inventory"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Info"
},
{
"action": "action_OTA_HotelRatePlanNotif_RatePlans",
"supports": [
"OTA_HotelRatePlanNotif_accept_ArrivalDOW",
"OTA_HotelRatePlanNotif_accept_DepartureDOW",
"OTA_HotelRatePlanNotif_accept_RatePlan_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_RoomType_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_mixed_BookingRule",
"OTA_HotelRatePlanNotif_accept_Supplements",
"OTA_HotelRatePlanNotif_accept_FreeNightsOffers",
"OTA_HotelRatePlanNotif_accept_FamilyOffers",
"OTA_HotelRatePlanNotif_accept_overlay",
"OTA_HotelRatePlanNotif_accept_RatePlanJoin",
"OTA_HotelRatePlanNotif_accept_OfferRule_BookingOffset",
"OTA_HotelRatePlanNotif_accept_OfferRule_DOWLOS"
]
}
]
}
]
}

56
output.xml Normal file
View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<OTA_ResRetrieveRS xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
<ReservationsList>
<HotelReservation CreateDateTime="2025-09-25T13:33:19.275224+00:00" ResStatus="Requested" RoomStayReservation="true">
<UniqueID Type="14" ID="6b34fe24ac2ff811"/>
<RoomStays>
<RoomStay>
<TimeSpan>
<StartDateWindow EarliestDate="2024-10-01" LatestDate="2024-10-02"/>
</TimeSpan>
</RoomStay>
</RoomStays>
<ResGuests>
<ResGuest>
<Profiles>
<ProfileInfo>
<Profile>
<Customer Gender="Male" BirthDate="1980-01-01" Language="en">
<PersonName>
<NamePrefix>Mr.</NamePrefix>
<GivenName>John</GivenName>
<Surname>Doe</Surname>
</PersonName>
<Telephone PhoneTechType="5" PhoneNumber="+1234567890"/>
<Telephone PhoneNumber="+0987654321"/>
<Email Remark="newsletter:yes">john.doe@example.com</Email>
<Address Remark="catalog:no">
<AddressLine>123 Main Street</AddressLine>
<CityName>Anytown</CityName>
<PostalCode>12345</PostalCode>
<CountryName Code="US"/>
</Address>
</Customer>
</Profile>
</ProfileInfo>
</Profiles>
</ResGuest>
</ResGuests>
<ResGlobalInfo>
<Comments>
<Comment Name="customer comment">
<ListItem ListItem="1" Language="en">Landing page comment</ListItem>
<Text>This is a sample comment.</Text>
</Comment>
<Comment Name="additional info">
<Text>This is a special request comment.</Text>
</Comment>
</Comments>
<HotelReservationIDs>
<HotelReservationID ResID_Type="13" ResID_SourceContext="99tales"/>
</HotelReservationIDs>
<BasicPropertyInfo HotelCode="123" HotelName="Frangart Inn"/>
</ResGlobalInfo>
</HotelReservation>
</ReservationsList>
</OTA_ResRetrieveRS>

135
parsed_echo_data.json Normal file
View File

@@ -0,0 +1,135 @@
{
"versions": [
{
"version": "2024-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests_StatusUpdate"
},
{
"action": "action_OTA_HotelInvCountNotif",
"supports": [
"OTA_HotelInvCountNotif_accept_rooms",
"OTA_HotelInvCountNotif_accept_categories",
"OTA_HotelInvCountNotif_accept_deltas",
"OTA_HotelInvCountNotif_accept_out_of_market",
"OTA_HotelInvCountNotif_accept_out_of_order",
"OTA_HotelInvCountNotif_accept_complete_set",
"OTA_HotelInvCountNotif_accept_closing_seasons"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Inventory",
"supports": [
"OTA_HotelDescriptiveContentNotif_Inventory_use_rooms",
"OTA_HotelDescriptiveContentNotif_Inventory_occupancy_children"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Info"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Inventory"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Info"
},
{
"action": "action_OTA_HotelRatePlanNotif_RatePlans",
"supports": [
"OTA_HotelRatePlanNotif_accept_ArrivalDOW",
"OTA_HotelRatePlanNotif_accept_DepartureDOW",
"OTA_HotelRatePlanNotif_accept_RatePlan_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_RoomType_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_mixed_BookingRule",
"OTA_HotelRatePlanNotif_accept_Supplements",
"OTA_HotelRatePlanNotif_accept_FreeNightsOffers",
"OTA_HotelRatePlanNotif_accept_FamilyOffers",
"OTA_HotelRatePlanNotif_accept_full",
"OTA_HotelRatePlanNotif_accept_overlay",
"OTA_HotelRatePlanNotif_accept_RatePlanJoin",
"OTA_HotelRatePlanNotif_accept_OfferRule_BookingOffset",
"OTA_HotelRatePlanNotif_accept_OfferRule_DOWLOS"
]
},
{
"action": "action_OTA_HotelRatePlan_BaseRates",
"supports": [
"OTA_HotelRatePlan_BaseRates_deltas"
]
},
{
"action": "action_OTA_HotelPostEventNotif_EventReports"
}
]
},
{
"version": "2022-10",
"actions": [
{
"action": "action_OTA_Ping"
},
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests_StatusUpdate"
},
{
"action": "action_OTA_HotelInvCountNotif",
"supports": [
"OTA_HotelInvCountNotif_accept_rooms",
"OTA_HotelInvCountNotif_accept_categories",
"OTA_HotelInvCountNotif_accept_deltas",
"OTA_HotelInvCountNotif_accept_out_of_market",
"OTA_HotelInvCountNotif_accept_out_of_order",
"OTA_HotelInvCountNotif_accept_complete_set",
"OTA_HotelInvCountNotif_accept_closing_seasons"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Inventory",
"supports": [
"OTA_HotelDescriptiveContentNotif_Inventory_use_rooms",
"OTA_HotelDescriptiveContentNotif_Inventory_occupancy_children"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Info"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Inventory"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Info"
},
{
"action": "action_OTA_HotelRatePlanNotif_RatePlans",
"supports": [
"OTA_HotelRatePlanNotif_accept_ArrivalDOW",
"OTA_HotelRatePlanNotif_accept_DepartureDOW",
"OTA_HotelRatePlanNotif_accept_RatePlan_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_RoomType_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_mixed_BookingRule",
"OTA_HotelRatePlanNotif_accept_Supplements",
"OTA_HotelRatePlanNotif_accept_FreeNightsOffers",
"OTA_HotelRatePlanNotif_accept_FamilyOffers",
"OTA_HotelRatePlanNotif_accept_overlay",
"OTA_HotelRatePlanNotif_accept_RatePlanJoin",
"OTA_HotelRatePlanNotif_accept_OfferRule_BookingOffset",
"OTA_HotelRatePlanNotif_accept_OfferRule_DOWLOS"
]
}
]
}
]
}

View File

@@ -1,10 +1,31 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "alpine-bits-python-server"
version = "0.1.0"
description = "Add your description here"
description = "Alpine Bits Python Server implementation"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"generateds>=2.44.3",
"lxml>=6.0.1",
"pytest>=8.4.2",
"ruff>=0.13.1",
"xsdata-pydantic[cli,lxml,soap]>=24.5",
"xsdata[cli,lxml,soap]>=25.7",
]
[project.scripts]
alpine-bits-server = "alpine_bits_python.main:main"
[tool.hatch.build.targets.wheel]
packages = ["src/alpine_bits_python"]
[tool.pytest.ini_options]
testpaths = ["test"]
pythonpath = ["src"]
[tool.ruff]
src = ["src", "test"]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
"""Entry point for alpine_bits_python package."""
from .main import main
if __name__ == "__main__":
main()

View File

@@ -3,12 +3,16 @@ from datetime import datetime, timezone
from typing import List, Optional
# TimeSpan class according to XSD: <TimeSpan Start="..." End="..." Duration="..." StartWindow="..." EndWindow="..."/>
class TimeSpan:
def __init__(self, start: str, end: str = None, duration: str = None, start_window: str = None, end_window: str = None):
def __init__(
self,
start: str,
end: str = None,
duration: str = None,
start_window: str = None,
end_window: str = None,
):
self.start = start
self.end = end
self.duration = duration
@@ -29,16 +33,27 @@ class TimeSpan:
NAMESPACE = "http://www.opentravel.org/OTA/2003/05"
ET.register_namespace('', NAMESPACE)
ET.register_namespace("", NAMESPACE)
def _ns(tag):
return f"{{{NAMESPACE}}}{tag}"
class ResGuest:
def __init__(self, given_name: str, surname: str, gender: Optional[str] = None, birth_date: Optional[str] = None, language: Optional[str] = None, name_prefix: Optional[str] = None, name_title: Optional[str] = None, email: Optional[str] = None, address: Optional[dict] = None, telephones: Optional[list] = None):
def __init__(
self,
given_name: str,
surname: str,
gender: Optional[str] = None,
birth_date: Optional[str] = None,
language: Optional[str] = None,
name_prefix: Optional[str] = None,
name_title: Optional[str] = None,
email: Optional[str] = None,
address: Optional[dict] = None,
telephones: Optional[list] = None,
):
self.given_name = given_name
self.surname = surname
self.gender = gender
@@ -91,6 +106,7 @@ class ResGuest:
def __str__(self):
from lxml import etree
elem = self.to_xml()
xml_bytes = ET.tostring(elem, encoding="utf-8")
parser = etree.XMLParser(remove_blank_text=True)
@@ -98,14 +114,6 @@ class ResGuest:
return etree.tostring(lxml_elem, pretty_print=True, encoding="unicode")
class RoomStay:
def __init__(self, room_type: str, timespan: TimeSpan, guests: List[ResGuest]):
self.room_type = room_type
@@ -114,7 +122,9 @@ class RoomStay:
def to_xml(self):
roomstay_elem = ET.Element(_ns("RoomStay"))
ET.SubElement(roomstay_elem, _ns("RoomType")).set("RoomTypeCode", self.room_type)
ET.SubElement(roomstay_elem, _ns("RoomType")).set(
"RoomTypeCode", self.room_type
)
roomstay_elem.append(self.timespan.to_xml())
guests_elem = ET.SubElement(roomstay_elem, _ns("Guests"))
for guest in self.guests:
@@ -123,6 +133,7 @@ class RoomStay:
def __str__(self):
from lxml import etree
elem = self.to_xml()
xml_bytes = ET.tostring(elem, encoding="utf-8")
parser = etree.XMLParser(remove_blank_text=True)
@@ -131,7 +142,13 @@ class RoomStay:
class Reservation:
def __init__(self, reservation_id: str, hotel_code: str, roomstays: List[RoomStay], create_time: Optional[str] = None):
def __init__(
self,
reservation_id: str,
hotel_code: str,
roomstays: List[RoomStay],
create_time: Optional[str] = None,
):
self.reservation_id = reservation_id
self.hotel_code = hotel_code
self.roomstays = roomstays
@@ -151,10 +168,10 @@ class Reservation:
return res_elem
def to_xml_string(self):
root = ET.Element(_ns("OTA_ResRetrieveRS"), {
"Version": "2024-10",
"TimeStamp": datetime.now(timezone.utc).isoformat()
})
root = ET.Element(
_ns("OTA_ResRetrieveRS"),
{"Version": "2024-10", "TimeStamp": datetime.now(timezone.utc).isoformat()},
)
success_elem = ET.SubElement(root, _ns("Success"))
reservations_list = ET.SubElement(root, _ns("ReservationsList"))
reservations_list.append(self.to_xml())

View File

@@ -0,0 +1,575 @@
"""
AlpineBits Server for handling hotel data exchange.
This module provides an asynchronous AlpineBits server that can handle various
OTA (OpenTravel Alliance) actions for hotel data exchange. Currently implements
handshaking functionality with configurable supported actions and capabilities.
"""
import asyncio
import json
import inspect
from typing import Dict, List, Optional, Any, Union, Tuple, Type
from xml.etree import ElementTree as ET
from dataclasses import dataclass
from enum import Enum, IntEnum
from .generated.alpinebits import OtaPingRq, OtaPingRs, WarningStatus
from xsdata_pydantic.bindings import XmlSerializer
from xsdata.formats.dataclass.serializers.config import SerializerConfig
from abc import ABC, abstractmethod
from xsdata_pydantic.bindings import XmlParser
class HttpStatusCode(IntEnum):
"""Allowed HTTP status codes for AlpineBits responses."""
OK = 200
BAD_REQUEST = 400
UNAUTHORIZED = 401
INTERNAL_SERVER_ERROR = 500
class AlpineBitsActionName(Enum):
"""Enum for AlpineBits action names with capability and request name mappings."""
# Format: (capability_name, actual_request_name)
OTA_PING = ("action_OTA_Ping", "OTA_Ping:Handshaking")
OTA_READ = ("action_OTA_Read", "OTA_Read:GuestRequests")
OTA_HOTEL_AVAIL_NOTIF = ("action_OTA_HotelAvailNotif", "OTA_HotelAvailNotif")
OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS = ("action_OTA_HotelResNotif_GuestRequests",
"OTA_HotelResNotif:GuestRequests")
OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INVENTORY = ("action_OTA_HotelDescriptiveContentNotif_Inventory",
"OTA_HotelDescriptiveContentNotif:Inventory")
OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INFO = ("action_OTA_HotelDescriptiveContentNotif_Info",
"OTA_HotelDescriptiveContentNotif:Info")
OTA_HOTEL_DESCRIPTIVE_INFO_INVENTORY = ("action_OTA_HotelDescriptiveInfo_Inventory",
"OTA_HotelDescriptiveInfo:Inventory")
OTA_HOTEL_DESCRIPTIVE_INFO_INFO = ("action_OTA_HotelDescriptiveInfo_Info",
"OTA_HotelDescriptiveInfo:Info")
OTA_HOTEL_RATE_PLAN_NOTIF_RATE_PLANS = ("action_OTA_HotelRatePlanNotif_RatePlans",
"OTA_HotelRatePlanNotif:RatePlans")
OTA_HOTEL_RATE_PLAN_BASE_RATES = ("action_OTA_HotelRatePlan_BaseRates",
"OTA_HotelRatePlan:BaseRates")
def __init__(self, capability_name: str, request_name: str):
self.capability_name = capability_name
self.request_name = request_name
@classmethod
def get_by_capability_name(cls, capability_name: str) -> Optional['AlpineBitsActionName']:
"""Get action enum by capability name."""
for action in cls:
if action.capability_name == capability_name:
return action
return None
@classmethod
def get_by_request_name(cls, request_name: str) -> Optional['AlpineBitsActionName']:
"""Get action enum by request name."""
for action in cls:
if action.request_name == request_name:
return action
return None
class Version(str, Enum):
"""Enum for AlpineBits versions."""
V2024_10 = "2024-10"
V2022_10 = "2022-10"
# Add other versions as needed
@dataclass
class AlpineBitsResponse:
"""Response data structure for AlpineBits actions."""
xml_content: str
status_code: HttpStatusCode = HttpStatusCode.OK
def __post_init__(self):
"""Validate that status code is one of the allowed values."""
if self.status_code not in [200, 400, 401, 500]:
raise ValueError(f"Invalid status code {self.status_code}. Must be 200, 400, 401, or 500")
# Abstract base class for AlpineBits Action
class AlpineBitsAction(ABC):
"""Abstract base class for handling AlpineBits actions."""
name: AlpineBitsActionName
version: Version | list[Version] # list of versions in case action supports multiple versions
async def handle(self, action: str, request_xml: str, version: Version) -> AlpineBitsResponse:
"""
Handle the incoming request XML and return response XML.
Default implementation returns "not implemented" error.
Override this method in subclasses to provide actual functionality.
Args:
action: The action to perform (e.g., "OTA_PingRQ")
request_xml: The XML request body as string
version: The AlpineBits version
Returns:
AlpineBitsResponse with error or actual response
"""
return_string = f"Error: Action {action} not implemented"
return AlpineBitsResponse(return_string, HttpStatusCode.BAD_REQUEST)
async def check_version_supported(self, version: Version) -> bool:
"""
Check if the action supports the given version.
Args:
version: The AlpineBits version to check
Returns:
True if supported, False otherwise
"""
if isinstance(self.version, list):
return version in self.version
return version == self.version
class ServerCapabilities:
"""
Automatically discovers AlpineBitsAction implementations and generates capabilities.
"""
def __init__(self):
self.action_registry: Dict[str, Type[AlpineBitsAction]] = {}
self._discover_actions()
self.capability_dict = None
def _discover_actions(self):
"""Discover all AlpineBitsAction implementations in the current module."""
current_module = inspect.getmodule(self)
for name, obj in inspect.getmembers(current_module):
if (inspect.isclass(obj) and
issubclass(obj, AlpineBitsAction) and
obj != AlpineBitsAction):
# Check if this action is actually implemented (not just returning default)
if self._is_action_implemented(obj):
action_instance = obj()
if hasattr(action_instance, 'name'):
# Use capability name for the registry key
self.action_registry[action_instance.name.capability_name] = obj
def _is_action_implemented(self, action_class: Type[AlpineBitsAction]) -> bool:
"""
Check if an action is actually implemented or just uses the default behavior.
This is a simple check - in practice, you might want more sophisticated detection.
"""
# Check if the class has overridden the handle method
if 'handle' in action_class.__dict__:
return True
return False
def create_capabilities_dict(self) -> None:
"""
Generate the capabilities dictionary based on discovered actions.
"""
versions_dict = {}
for action_name, action_class in self.action_registry.items():
action_instance = action_class()
# Get supported versions for this action
if isinstance(action_instance.version, list):
supported_versions = action_instance.version
else:
supported_versions = [action_instance.version]
# Add action to each supported version
for version in supported_versions:
version_str = version.value
if version_str not in versions_dict:
versions_dict[version_str] = {
"version": version_str,
"actions": []
}
action_dict = {"action": action_name}
# Add supports field if the action has custom supports
if hasattr(action_instance, 'supports') and action_instance.supports:
action_dict["supports"] = action_instance.supports
versions_dict[version_str]["actions"].append(action_dict)
self.capability_dict = {"versions": list(versions_dict.values())}
return None
def get_capabilities_dict(self) -> Dict:
"""
Get capabilities as a dictionary. Generates if not already created.
"""
if self.capability_dict is None:
self.create_capabilities_dict()
return self.capability_dict
def get_capabilities_json(self) -> str:
"""Get capabilities as formatted JSON string."""
return json.dumps(self.get_capabilities_dict(), indent=2)
def get_supported_actions(self) -> List[str]:
"""Get list of all supported action names."""
return list(self.action_registry.keys())
# Sample Action Implementations for demonstration
class PingAction(AlpineBitsAction):
"""Implementation for OTA_Ping action (handshaking)."""
def __init__(self):
self.name = AlpineBitsActionName.OTA_PING
self.version = [Version.V2024_10, Version.V2022_10] # Supports multiple versions
async def handle(self, action: str, request_xml: str, version: Version, server_capabilities: None | ServerCapabilities = None) -> AlpineBitsResponse:
"""Handle ping requests."""
if server_capabilities is None:
return AlpineBitsResponse("Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR)
# Parse the incoming request XML and extract EchoData
parser = XmlParser()
try:
parsed_request = parser.from_string(request_xml, OtaPingRq)
echo_data = json.loads(parsed_request.echo_data)
except Exception as e:
return AlpineBitsResponse(f"Error: Invalid XML request - {str(e)}", HttpStatusCode.BAD_REQUEST)
# compare echo data with capabilities, create a dictionary containing the matching capabilities
capabilities_dict = server_capabilities.get_capabilities_dict()
matching_capabilities = {"versions": []}
# Iterate through client's requested versions
for client_version in echo_data.get("versions", []):
client_version_str = client_version.get("version", "")
# Find matching server version
for server_version in capabilities_dict["versions"]:
if server_version["version"] == client_version_str:
# Found a matching version, now find common actions
matching_version = {
"version": client_version_str,
"actions": []
}
# Get client's requested actions for this version
client_actions = {action.get("action", ""): action for action in client_version.get("actions", [])}
server_actions = {action.get("action", ""): action for action in server_version.get("actions", [])}
# Find common actions
for action_name in client_actions:
if action_name in server_actions:
# Use server's action definition (includes our supports)
matching_version["actions"].append(server_actions[action_name])
# Only add version if there are common actions
if matching_version["actions"]:
matching_capabilities["versions"].append(matching_version)
break
# Debug print to see what we matched
print("🔍 Client requested capabilities:")
print(json.dumps(echo_data, indent=2))
print("\n🏠 Server capabilities:")
print(json.dumps(capabilities_dict, indent=2))
print("\n🤝 Matching capabilities:")
print(json.dumps(matching_capabilities, indent=2))
# Create successful ping response with matched capabilities
capabilities_json = json.dumps(matching_capabilities, indent=2)
warning = OtaPingRs.Warnings.Warning(status=WarningStatus.ALPINEBITS_HANDSHAKE.value, type_value="11", content=[capabilities_json])
warning_response = OtaPingRs.Warnings(warning=[warning])
response_ota_ping = OtaPingRs(version= "7.000", warnings=warning_response, echo_data=capabilities_json, success="")
config = SerializerConfig(
pretty_print=True, xml_declaration=True, encoding="UTF-8"
)
serializer = XmlSerializer(config=config)
response_xml = serializer.render(response_ota_ping, ns_map={None: "http://www.opentravel.org/OTA/2003/05"})
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
class ReadAction(AlpineBitsAction):
"""Implementation for OTA_Read action."""
def __init__(self):
self.name = AlpineBitsActionName.OTA_READ
self.version = [Version.V2024_10, Version.V2022_10]
async def handle(self, action: str, request_xml: str, version: Version) -> AlpineBitsResponse:
"""Handle read requests."""
response_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<OTA_ReadRS xmlns="http://www.opentravel.org/OTA/2003/05" Version="8.000">
<Success/>
<Data>Read operation successful for {version.value}</Data>
</OTA_ReadRS>'''
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
class HotelAvailNotifAction(AlpineBitsAction):
"""Implementation for Hotel Availability Notification action with supports."""
def __init__(self):
self.name = AlpineBitsActionName.OTA_HOTEL_AVAIL_NOTIF
self.version = Version.V2022_10
self.supports = [
"OTA_HotelAvailNotif_accept_rooms",
"OTA_HotelAvailNotif_accept_categories",
"OTA_HotelAvailNotif_accept_deltas",
"OTA_HotelAvailNotif_accept_BookingThreshold"
]
async def handle(self, action: str, request_xml: str, version: Version) -> AlpineBitsResponse:
"""Handle hotel availability notifications."""
response_xml = '''<?xml version="1.0" encoding="UTF-8"?>
<OTA_HotelAvailNotifRS xmlns="http://www.opentravel.org/OTA/2003/05" Version="8.000">
<Success/>
</OTA_HotelAvailNotifRS>'''
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
class GuestRequestsAction(AlpineBitsAction):
"""Unimplemented action - will not appear in capabilities."""
def __init__(self):
self.name = AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS
self.version = Version.V2024_10
# Note: This class doesn't override the handle method, so it won't be discovered
class AlpineBitsServer:
"""
Asynchronous AlpineBits server for handling hotel data exchange requests.
This server handles various OTA actions and implements the AlpineBits protocol
for hotel data exchange. It maintains a registry of supported actions and
their capabilities, and can respond to handshake requests with its capabilities.
"""
def __init__(self):
self.capabilities = ServerCapabilities()
self._action_instances = {}
self._initialize_action_instances()
def _initialize_action_instances(self):
"""Initialize instances of all discovered action classes."""
for capability_name, action_class in self.capabilities.action_registry.items():
self._action_instances[capability_name] = action_class()
def get_capabilities(self) -> Dict:
"""Get server capabilities."""
return self.capabilities.get_capabilities_dict()
def get_capabilities_json(self) -> str:
"""Get server capabilities as JSON."""
return self.capabilities.get_capabilities_json()
async def handle_request(self, request_action_name: str, request_xml: str, version: str = "2024-10") -> AlpineBitsResponse:
"""
Handle an incoming AlpineBits request by routing to appropriate action handler.
Args:
request_action_name: The action name from the request (e.g., "OTA_Read:GuestRequests")
request_xml: The XML request body
version: The AlpineBits version (defaults to "2024-10")
Returns:
AlpineBitsResponse with the result
"""
# Convert string version to enum
try:
version_enum = Version(version)
except ValueError:
return AlpineBitsResponse(
f"Error: Unsupported version {version}",
HttpStatusCode.BAD_REQUEST
)
# Find the action by request name
action_enum = AlpineBitsActionName.get_by_request_name(request_action_name)
if not action_enum:
return AlpineBitsResponse(
f"Error: Unknown action {request_action_name}",
HttpStatusCode.BAD_REQUEST
)
# Check if we have an implementation for this action
capability_name = action_enum.capability_name
if capability_name not in self._action_instances:
return AlpineBitsResponse(
f"Error: Action {request_action_name} is not implemented",
HttpStatusCode.BAD_REQUEST
)
action_instance = self._action_instances[capability_name]
# Check if the action supports the requested version
if not await action_instance.check_version_supported(version_enum):
return AlpineBitsResponse(
f"Error: Action {request_action_name} does not support version {version}",
HttpStatusCode.BAD_REQUEST
)
# Handle the request
try:
# Special case for ping action - pass server capabilities
if capability_name == "action_OTA_Ping":
return await action_instance.handle(request_action_name, request_xml, version_enum, self.capabilities)
else:
return await action_instance.handle(request_action_name, request_xml, version_enum)
except Exception as e:
print(f"Error handling request {request_action_name}: {str(e)}")
# print stack trace for debugging
import traceback
traceback.print_exc()
return AlpineBitsResponse(
f"Error: Internal server error while processing {request_action_name}: {str(e)}",
HttpStatusCode.INTERNAL_SERVER_ERROR
)
def get_supported_request_names(self) -> List[str]:
"""Get all supported request names (not capability names)."""
request_names = []
for capability_name in self._action_instances.keys():
action_enum = AlpineBitsActionName.get_by_capability_name(capability_name)
if action_enum:
request_names.append(action_enum.request_name)
return sorted(request_names)
def is_action_supported(self, request_action_name: str, version: str = None) -> bool:
"""
Check if a request action is supported.
Args:
request_action_name: The request action name (e.g., "OTA_Read:GuestRequests")
version: Optional version to check
Returns:
True if supported, False otherwise
"""
action_enum = AlpineBitsActionName.get_by_request_name(request_action_name)
if not action_enum:
return False
capability_name = action_enum.capability_name
if capability_name not in self._action_instances:
return False
if version:
try:
version_enum = Version(version)
action_instance = self._action_instances[capability_name]
# This would need to be async, but for simplicity we'll just check if version exists
if isinstance(action_instance.version, list):
return version_enum in action_instance.version
else:
return action_instance.version == version_enum
except ValueError:
return False
return True
async def main():
"""Demonstrate the automatic capabilities discovery and request handling."""
print("🚀 AlpineBits Server Capabilities Discovery & Request Handling Demo")
print("=" * 70)
# Create server instance
server = AlpineBitsServer()
print("\n📋 Discovered Action Classes:")
print("-" * 30)
for capability_name, action_class in server.capabilities.action_registry.items():
action_enum = AlpineBitsActionName.get_by_capability_name(capability_name)
request_name = action_enum.request_name if action_enum else "unknown"
print(f"{capability_name} -> {action_class.__name__}")
print(f" Request name: {request_name}")
print(f"\n📊 Total Implemented Actions: {len(server.capabilities.get_supported_actions())}")
print("\n🔍 Generated Capabilities JSON:")
print("-" * 30)
capabilities_json = server.get_capabilities_json()
print(capabilities_json)
print("\n🎯 Supported Request Names:")
print("-" * 30)
for request_name in server.get_supported_request_names():
print(f"{request_name}")
print("\n🧪 Testing Request Handling:")
print("-" * 30)
test_xml = "<test>sample request</test>"
# Test different request formats
test_cases = [
("OTA_Ping:Handshaking", "2024-10"),
("OTA_Read:GuestRequests", "2024-10"),
("OTA_Read:GuestRequests", "2022-10"),
("OTA_HotelAvailNotif", "2024-10"),
("UnknownAction", "2024-10"),
("OTA_Ping:Handshaking", "unsupported-version")
]
for request_name, version in test_cases:
print(f"\n<EFBFBD> Testing: {request_name} (v{version})")
# Check if supported first
is_supported = server.is_action_supported(request_name, version)
print(f" Supported: {is_supported}")
# Handle the request
response = await server.handle_request(request_name, test_xml, version)
print(f" Status: {response.status_code}")
if len(response.xml_content) > 100:
print(f" Response: {response.xml_content[:100]}...")
else:
print(f" Response: {response.xml_content}")
print("\n✅ Demo completed successfully!")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,161 @@
from .alpinebits import (
BaseByGuestAmtType,
BookingRuleCodeContext,
CommentName1,
CommentName2,
ContactInfoLocation,
DefSendComplete,
DescriptionName,
DescriptionTextFormat1,
DescriptionTextFormat2,
DiscountPercent,
ErrorStatus,
ErrorType,
EventIdType,
GuestFirstQualifyingPosition,
HotelReservationResStatus,
ImageItemCategory,
InvCountCountType,
LengthOfStayMinMaxMessageType1,
LengthOfStayMinMaxMessageType2,
LengthOfStayTimeUnit,
MealsIncludedMealPlanCodes,
MealsIncludedMealPlanIndicator,
MultimediaDescriptionInfoCode1,
MultimediaDescriptionInfoCode2,
OccupancyAgeQualifyingCode,
OtaHotelDescriptiveContentNotifRq,
OtaHotelDescriptiveContentNotifRs,
OtaHotelDescriptiveInfoRq,
OtaHotelDescriptiveInfoRs,
OtaHotelInvCountNotifRq,
OtaHotelInvCountNotifRs,
OtaHotelPostEventNotifRq,
OtaHotelPostEventNotifRs,
OtaHotelRatePlanNotifRq,
OtaHotelRatePlanNotifRs,
OtaHotelRatePlanRq,
OtaHotelRatePlanRs,
OtaHotelResNotifRq,
OtaHotelResNotifRs,
OtaNotifReportRq,
OtaNotifReportRs,
OtaPingRq,
OtaPingRs,
OtaReadRq,
OtaResRetrieveRs,
PositionAltitudeUnitOfMeasureCode,
PrerequisiteInventoryInvType,
ProfileProfileType,
RateDescriptionName,
RatePlanRatePlanNotifType,
RatePlanRatePlanType,
RateRateTimeUnit,
RestrictionStatusRestriction,
RestrictionStatusStatus,
RoomTypeRoomType,
ServiceMealPlanCode,
ServiceServiceCategoryCode,
ServiceServicePricingType,
ServiceType,
SpecialRequestCodeContext,
StayRequirementStayContext,
SupplementAddToBasicRateIndicator,
SupplementChargeTypeCode,
SupplementInvType,
TaxPolicyChargeFrequency,
TaxPolicyChargeUnit,
TaxPolicyCode,
TextTextFormat1,
TextTextFormat2,
TimeUnitType,
TypeRoomRoomType,
UniqueIdInstance,
UniqueIdType1,
UniqueIdType2,
UniqueIdType3,
UrlType,
VideoItemCategory,
WarningStatus,
)
__all__ = [
"BaseByGuestAmtType",
"BookingRuleCodeContext",
"CommentName1",
"CommentName2",
"ContactInfoLocation",
"DescriptionName",
"DescriptionTextFormat1",
"DescriptionTextFormat2",
"DiscountPercent",
"ErrorStatus",
"ErrorType",
"EventIdType",
"GuestFirstQualifyingPosition",
"HotelReservationResStatus",
"ImageItemCategory",
"InvCountCountType",
"LengthOfStayMinMaxMessageType1",
"LengthOfStayMinMaxMessageType2",
"LengthOfStayTimeUnit",
"MealsIncludedMealPlanCodes",
"MealsIncludedMealPlanIndicator",
"MultimediaDescriptionInfoCode1",
"MultimediaDescriptionInfoCode2",
"OtaHotelDescriptiveContentNotifRq",
"OtaHotelDescriptiveContentNotifRs",
"OtaHotelDescriptiveInfoRq",
"OtaHotelDescriptiveInfoRs",
"OtaHotelInvCountNotifRq",
"OtaHotelInvCountNotifRs",
"OtaHotelPostEventNotifRq",
"OtaHotelPostEventNotifRs",
"OtaHotelRatePlanNotifRq",
"OtaHotelRatePlanNotifRs",
"OtaHotelRatePlanRq",
"OtaHotelRatePlanRs",
"OtaHotelResNotifRq",
"OtaHotelResNotifRs",
"OtaNotifReportRq",
"OtaNotifReportRs",
"OtaPingRq",
"OtaPingRs",
"OtaReadRq",
"OtaResRetrieveRs",
"OccupancyAgeQualifyingCode",
"PositionAltitudeUnitOfMeasureCode",
"PrerequisiteInventoryInvType",
"ProfileProfileType",
"RateDescriptionName",
"RatePlanRatePlanNotifType",
"RatePlanRatePlanType",
"RateRateTimeUnit",
"RestrictionStatusRestriction",
"RestrictionStatusStatus",
"RoomTypeRoomType",
"ServiceMealPlanCode",
"ServiceServiceCategoryCode",
"ServiceServicePricingType",
"ServiceType",
"SpecialRequestCodeContext",
"StayRequirementStayContext",
"SupplementAddToBasicRateIndicator",
"SupplementChargeTypeCode",
"SupplementInvType",
"TaxPolicyChargeFrequency",
"TaxPolicyChargeUnit",
"TaxPolicyCode",
"TextTextFormat1",
"TextTextFormat2",
"TimeUnitType",
"TypeRoomRoomType",
"UrlType",
"UniqueIdInstance",
"UniqueIdType1",
"UniqueIdType2",
"UniqueIdType3",
"VideoItemCategory",
"WarningStatus",
"DefSendComplete",
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
from .alpinebits_guestrequests import ResGuest, RoomStay
from .generated import alpinebits as ab
from io import BytesIO
import sys
from datetime import datetime, timezone
import re
from xsdata_pydantic.bindings import XmlSerializer
from .simplified_access import (
CommentData,
CommentsData,
CommentListItemData,
CustomerData,
HotelReservationIdData,
PhoneTechType,
AlpineBitsFactory,
OtaMessageType
)
def main():
# Success - use None instead of object() for cleaner XML output
success = None
# UniqueID
unique_id = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId(
type_value=ab.UniqueIdType2.VALUE_14, id="6b34fe24ac2ff811"
)
# TimeSpan - use the actual nested class
start_date_window = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan.StartDateWindow(
earliest_date="2024-10-01", latest_date="2024-10-02"
)
time_span = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan(
start_date_window=start_date_window
)
# RoomStay with TimeSpan
room_stay = (
ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay(
time_span=time_span
)
)
room_stays = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays(
room_stay=[room_stay]
)
customer_data = CustomerData(
given_name="John",
surname="Doe",
name_prefix="Mr.",
phone_numbers=[
("+1234567890", PhoneTechType.MOBILE), # Phone number with type
("+0987654321", None), # Phone number without type
],
email_address="john.doe@example.com",
email_newsletter=True,
address_line="123 Main Street",
city_name="Anytown",
postal_code="12345",
country_code="US",
address_catalog=False,
gender="Male",
birth_date="1980-01-01",
language="en",
)
alpine_bits_factory = AlpineBitsFactory()
res_guests = alpine_bits_factory.create_res_guests(customer_data, OtaMessageType.RETRIEVE)
hotel_res_id_data = HotelReservationIdData(
res_id_type="13",
res_id_value=None,
res_id_source=None,
res_id_source_context="99tales",
)
# Create HotelReservationId using the factory
hotel_res_id = alpine_bits_factory.create(hotel_res_id_data, OtaMessageType.RETRIEVE)
# Use the actual nested HotelReservationIds class
hotel_res_ids = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds(
hotel_reservation_id=[hotel_res_id]
)
# Basic property info
basic_property_info = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.BasicPropertyInfo(
hotel_code="123", hotel_name="Frangart Inn"
)
comment = CommentData(
name= ab.CommentName2.CUSTOMER_COMMENT,
text="This is a sample comment.",
list_items=[CommentListItemData(
value="Landing page comment",
language="en",
list_item="1",
)],
)
comment2 = CommentData(
name= ab.CommentName2.ADDITIONAL_INFO,
text="This is a special request comment.",
)
comments_data = CommentsData(comments=[comment, comment2])
comments = alpine_bits_factory.create(comments_data, OtaMessageType.RETRIEVE)
# ResGlobalInfo
res_global_info = (
ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo(
hotel_reservation_ids=hotel_res_ids, basic_property_info=basic_property_info, comments=comments
)
)
# Hotel Reservation
hotel_reservation = ab.OtaResRetrieveRs.ReservationsList.HotelReservation(
create_date_time=datetime.now(timezone.utc).isoformat(),
res_status=ab.HotelReservationResStatus.REQUESTED,
room_stay_reservation="true",
unique_id=unique_id,
room_stays=room_stays,
res_guests=res_guests,
res_global_info=res_global_info,
)
reservations_list = ab.OtaResRetrieveRs.ReservationsList(
hotel_reservation=[hotel_reservation]
)
# Root element
ota_res_retrieve_rs = ab.OtaResRetrieveRs(
version="7.000", success=success, reservations_list=reservations_list
)
# Serialize using Pydantic's model_dump and convert to XML
try:
# First validate the model
ota_res_retrieve_rs.model_validate(ota_res_retrieve_rs.model_dump())
print("✅ Pydantic validation successful!")
# For XML serialization with Pydantic models, we need to use xsdata-pydantic serializer
from xsdata.formats.dataclass.serializers.config import SerializerConfig
config = SerializerConfig(
pretty_print=True, xml_declaration=True, encoding="UTF-8"
)
serializer = XmlSerializer(config=config)
# Use ns_map to control namespace prefixes - set default namespace
ns_map = {None: "http://www.opentravel.org/OTA/2003/05"}
xml_string = serializer.render(ota_res_retrieve_rs, ns_map=ns_map)
with open("output.xml", "w", encoding="utf-8") as outfile:
outfile.write(xml_string)
print("✅ XML serialization successful!")
print(f"Generated XML written to output.xml")
# Also print the pretty formatted XML to console
print("\n📄 Generated XML:")
print(xml_string)
# Test parsing back
from xsdata_pydantic.bindings import XmlParser
parser = XmlParser()
with open("output.xml", "r", encoding="utf-8") as infile:
xml_content = infile.read()
parsed_result = parser.from_string(xml_content, ab.OtaResRetrieveRs)
print("✅ Round-trip validation successful!")
print(
f"Parsed reservation status: {parsed_result.reservations_list.hotel_reservation[0].res_status}"
)
except Exception as e:
print(f"❌ Validation/Serialization failed: {e}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<OTA_ResRetrieveRS xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
<ReservationsList>
<HotelReservation CreateDateTime="2025-09-24T15:07:57.451427+00:00" ResStatus="Requested" RoomStayReservation="true">
<UniqueID Type="14" ID="6b34fe24ac2ff811"/>
<RoomStays>
<RoomStay>
<TimeSpan>
<StartDateWindow EarliestDate="2024-10-01" LatestDate="2024-10-02"/>
</TimeSpan>
</RoomStay>
</RoomStays>
<ResGuests>
<ResGuest>
<Profiles>
<ProfileInfo>
<Profile>
<Customer Gender="Male" BirthDate="1980-01-01" Language="en">
<PersonName>
<NamePrefix>Mr.</NamePrefix>
<GivenName>John</GivenName>
<Surname>Doe</Surname>
</PersonName>
<Telephone PhoneTechType="5" PhoneNumber="+1234567890"/>
<Telephone PhoneNumber="+0987654321"/>
<Email Remark="newsletter:yes">john.doe@example.com</Email>
<Address Remark="catalog:no">
<AddressLine>123 Main Street</AddressLine>
<CityName>Anytown</CityName>
<PostalCode>12345</PostalCode>
<CountryName Code="US"/>
</Address>
</Customer>
</Profile>
</ProfileInfo>
</Profiles>
</ResGuest>
</ResGuests>
<ResGlobalInfo>
<Comments>
<Comment Name="customer comment">
<ListItem ListItem="1" Language="en">Landing page comment</ListItem>
<Text>This is a sample comment.</Text>
</Comment>
<Comment Name="additional info">
<Text>This is a special request comment.</Text>
</Comment>
</Comments>
<HotelReservationIDs>
<HotelReservationID ResID_Type="13" ResID_SourceContext="99tales"/>
</HotelReservationIDs>
<BasicPropertyInfo HotelCode="123" HotelName="Frangart Inn"/>
</ResGlobalInfo>
</HotelReservation>
</ReservationsList>
</OTA_ResRetrieveRS>

View File

@@ -0,0 +1,740 @@
from typing import Union, Optional, Any, TypeVar
from pydantic import BaseModel, ConfigDict, Field
from dataclasses import dataclass
from enum import Enum
# Import the generated classes
from .generated.alpinebits import OtaHotelResNotifRq, OtaResRetrieveRs, CommentName2
# Define type aliases for the two Customer types
NotifCustomer = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer
RetrieveCustomer = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer
# Define type aliases for HotelReservationId types
NotifHotelReservationId = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId
RetrieveHotelReservationId = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId
# Define type aliases for Comments types
NotifComments = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Comments
RetrieveComments = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Comments
NotifComment = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Comments.Comment
RetrieveComment = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Comments.Comment
# phonetechtype enum 1,3,5 voice, fax, mobile
class PhoneTechType(Enum):
VOICE = "1"
FAX = "3"
MOBILE = "5"
# Enum to specify which OTA message type to use
class OtaMessageType(Enum):
NOTIF = "notification" # For OtaHotelResNotifRq
RETRIEVE = "retrieve" # For OtaResRetrieveRs
@dataclass
class CustomerData:
"""Simple data class to hold customer information without nested type constraints."""
given_name: str
surname: str
name_prefix: None | str = None
name_title: None | str = None
phone_numbers: list[tuple[str, None | PhoneTechType]] = (
None # (phone_number, phone_tech_type)
)
email_address: None | str = None
email_newsletter: None | bool = (
None # True for "yes", False for "no", None for not specified
)
address_line: None | str = None
city_name: None | str = None
postal_code: None | str = None
country_code: None | str = None # Two-letter country code
address_catalog: None | bool = (
None # True for "yes", False for "no", None for not specified
)
gender: None | str = None # "Unknown", "Male", "Female"
birth_date: None | str = None
language: None | str = None # Two-letter language code
def __post_init__(self):
if self.phone_numbers is None:
self.phone_numbers = []
class CustomerFactory:
"""Factory class to create Customer instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
@staticmethod
def create_notif_customer(data: CustomerData) -> NotifCustomer:
"""Create a Customer for OtaHotelResNotifRq."""
return CustomerFactory._create_customer(NotifCustomer, data)
@staticmethod
def create_retrieve_customer(data: CustomerData) -> RetrieveCustomer:
"""Create a Customer for OtaResRetrieveRs."""
return CustomerFactory._create_customer(RetrieveCustomer, data)
@staticmethod
def _create_customer(customer_class: type, data: CustomerData) -> Any:
"""Internal method to create a customer of the specified type."""
# Create PersonName
person_name = customer_class.PersonName(
given_name=data.given_name,
surname=data.surname,
name_prefix=data.name_prefix,
name_title=data.name_title,
)
# Create telephone list
telephones = []
for phone_number, phone_tech_type in data.phone_numbers:
telephone = customer_class.Telephone(
phone_number=phone_number,
phone_tech_type=phone_tech_type.value if phone_tech_type else None,
)
telephones.append(telephone)
# Create email if provided
email = None
if data.email_address:
remark = None
if data.email_newsletter is not None:
remark = f"newsletter:{'yes' if data.email_newsletter else 'no'}"
email = customer_class.Email(value=data.email_address, remark=remark)
# Create address if any address fields are provided
address = None
if any(
[data.address_line, data.city_name, data.postal_code, data.country_code]
):
country_name = None
if data.country_code:
country_name = customer_class.Address.CountryName(
code=data.country_code
)
address_remark = None
if data.address_catalog is not None:
address_remark = f"catalog:{'yes' if data.address_catalog else 'no'}"
address = customer_class.Address(
address_line=data.address_line,
city_name=data.city_name,
postal_code=data.postal_code,
country_name=country_name,
remark=address_remark,
)
# Create the customer
return customer_class(
person_name=person_name,
telephone=telephones,
email=email,
address=address,
gender=data.gender,
birth_date=data.birth_date,
language=data.language,
)
@staticmethod
def from_notif_customer(customer: NotifCustomer) -> CustomerData:
"""Convert a NotifCustomer back to CustomerData."""
return CustomerFactory._customer_to_data(customer)
@staticmethod
def from_retrieve_customer(customer: RetrieveCustomer) -> CustomerData:
"""Convert a RetrieveCustomer back to CustomerData."""
return CustomerFactory._customer_to_data(customer)
@staticmethod
def _customer_to_data(customer: Any) -> CustomerData:
"""Internal method to convert any customer type to CustomerData."""
# Extract phone numbers
phone_numbers = []
if customer.telephone:
for tel in customer.telephone:
phone_numbers.append(
(
tel.phone_number,
PhoneTechType(tel.phone_tech_type)
if tel.phone_tech_type
else None,
)
)
# Extract email info
email_address = None
email_newsletter = None
if customer.email:
email_address = customer.email.value
if customer.email.remark:
if "newsletter:yes" in customer.email.remark:
email_newsletter = True
elif "newsletter:no" in customer.email.remark:
email_newsletter = False
# Extract address info
address_line = None
city_name = None
postal_code = None
country_code = None
address_catalog = None
if customer.address:
address_line = customer.address.address_line
city_name = customer.address.city_name
postal_code = customer.address.postal_code
if customer.address.country_name:
country_code = customer.address.country_name.code
if customer.address.remark:
if "catalog:yes" in customer.address.remark:
address_catalog = True
elif "catalog:no" in customer.address.remark:
address_catalog = False
return CustomerData(
given_name=customer.person_name.given_name,
surname=customer.person_name.surname,
name_prefix=customer.person_name.name_prefix,
name_title=customer.person_name.name_title,
phone_numbers=phone_numbers,
email_address=email_address,
email_newsletter=email_newsletter,
address_line=address_line,
city_name=city_name,
postal_code=postal_code,
country_code=country_code,
address_catalog=address_catalog,
gender=customer.gender,
birth_date=customer.birth_date,
language=customer.language,
)
@dataclass
class HotelReservationIdData:
"""Simple data class to hold hotel reservation ID information without nested type constraints."""
res_id_type: str # Required field - pattern: [0-9]+
res_id_value: None | str = None # Max 64 characters
res_id_source: None | str = None # Max 64 characters
res_id_source_context: None | str = None # Max 64 characters
class HotelReservationIdFactory:
"""Factory class to create HotelReservationId instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
@staticmethod
def create_notif_hotel_reservation_id(
data: HotelReservationIdData,
) -> NotifHotelReservationId:
"""Create a HotelReservationId for OtaHotelResNotifRq."""
return HotelReservationIdFactory._create_hotel_reservation_id(
NotifHotelReservationId, data
)
@staticmethod
def create_retrieve_hotel_reservation_id(
data: HotelReservationIdData,
) -> RetrieveHotelReservationId:
"""Create a HotelReservationId for OtaResRetrieveRs."""
return HotelReservationIdFactory._create_hotel_reservation_id(
RetrieveHotelReservationId, data
)
@staticmethod
def _create_hotel_reservation_id(
hotel_reservation_id_class: type, data: HotelReservationIdData
) -> Any:
"""Internal method to create a hotel reservation id of the specified type."""
return hotel_reservation_id_class(
res_id_type=data.res_id_type,
res_id_value=data.res_id_value,
res_id_source=data.res_id_source,
res_id_source_context=data.res_id_source_context,
)
@staticmethod
def from_notif_hotel_reservation_id(
hotel_reservation_id: NotifHotelReservationId,
) -> HotelReservationIdData:
"""Convert a NotifHotelReservationId back to HotelReservationIdData."""
return HotelReservationIdFactory._hotel_reservation_id_to_data(
hotel_reservation_id
)
@staticmethod
def from_retrieve_hotel_reservation_id(
hotel_reservation_id: RetrieveHotelReservationId,
) -> HotelReservationIdData:
"""Convert a RetrieveHotelReservationId back to HotelReservationIdData."""
return HotelReservationIdFactory._hotel_reservation_id_to_data(
hotel_reservation_id
)
@staticmethod
def _hotel_reservation_id_to_data(
hotel_reservation_id: Any,
) -> HotelReservationIdData:
"""Internal method to convert any hotel reservation id type to HotelReservationIdData."""
return HotelReservationIdData(
res_id_type=hotel_reservation_id.res_id_type,
res_id_value=hotel_reservation_id.res_id_value,
res_id_source=hotel_reservation_id.res_id_source,
res_id_source_context=hotel_reservation_id.res_id_source_context,
)
@dataclass
class CommentListItemData:
"""Simple data class to hold comment list item information."""
value: str # The text content of the list item
list_item: str # Numeric identifier (pattern: [0-9]+)
language: str # Two-letter language code (pattern: [a-z][a-z])
@dataclass
class CommentData:
"""Simple data class to hold comment information without nested type constraints."""
name: CommentName2 # Required: "included services", "customer comment", "additional info"
text: Optional[str] = None # Optional text content
list_items: list[CommentListItemData] = None # Optional list items
def __post_init__(self):
if self.list_items is None:
self.list_items = []
@dataclass
class CommentsData:
"""Simple data class to hold multiple comments (1-3 max)."""
comments: list[CommentData] = None # 1-3 comments maximum
def __post_init__(self):
if self.comments is None:
self.comments = []
class CommentFactory:
"""Factory class to create Comment instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
@staticmethod
def create_notif_comments(data: CommentsData) -> NotifComments:
"""Create Comments for OtaHotelResNotifRq."""
return CommentFactory._create_comments(NotifComments, NotifComment, data)
@staticmethod
def create_retrieve_comments(data: CommentsData) -> RetrieveComments:
"""Create Comments for OtaResRetrieveRs."""
return CommentFactory._create_comments(RetrieveComments, RetrieveComment, data)
@staticmethod
def _create_comments(comments_class: type, comment_class: type, data: CommentsData) -> Any:
"""Internal method to create comments of the specified type."""
comments_list = []
for comment_data in data.comments:
# Create list items
list_items = []
for item_data in comment_data.list_items:
list_item = comment_class.ListItem(
value=item_data.value,
list_item=item_data.list_item,
language=item_data.language
)
list_items.append(list_item)
# Create comment
comment = comment_class(
name=comment_data.name,
text=comment_data.text,
list_item=list_items
)
comments_list.append(comment)
# Create comments container
return comments_class(comment=comments_list)
@staticmethod
def from_notif_comments(comments: NotifComments) -> CommentsData:
"""Convert NotifComments back to CommentsData."""
return CommentFactory._comments_to_data(comments)
@staticmethod
def from_retrieve_comments(comments: RetrieveComments) -> CommentsData:
"""Convert RetrieveComments back to CommentsData."""
return CommentFactory._comments_to_data(comments)
@staticmethod
def _comments_to_data(comments: Any) -> CommentsData:
"""Internal method to convert any comments type to CommentsData."""
comments_data_list = []
for comment in comments.comment:
# Extract list items
list_items_data = []
if comment.list_item:
for list_item in comment.list_item:
list_items_data.append(CommentListItemData(
value=list_item.value,
list_item=list_item.list_item,
language=list_item.language
))
# Extract comment data
comment_data = CommentData(
name=comment.name,
text=comment.text,
list_items=list_items_data
)
comments_data_list.append(comment_data)
return CommentsData(comments=comments_data_list)
# Define type aliases for ResGuests types
NotifResGuests = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests
RetrieveResGuests = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGuests
class ResGuestFactory:
"""Factory class to create complete ResGuests structures with a primary customer."""
@staticmethod
def create_notif_res_guests(customer_data: CustomerData) -> NotifResGuests:
"""Create a complete ResGuests structure for OtaHotelResNotifRq with primary customer."""
return ResGuestFactory._create_res_guests(
NotifResGuests, NotifCustomer, customer_data
)
@staticmethod
def create_retrieve_res_guests(customer_data: CustomerData) -> RetrieveResGuests:
"""Create a complete ResGuests structure for OtaResRetrieveRs with primary customer."""
return ResGuestFactory._create_res_guests(
RetrieveResGuests, RetrieveCustomer, customer_data
)
@staticmethod
def _create_res_guests(
res_guests_class: type, customer_class: type, customer_data: CustomerData
) -> Any:
"""Internal method to create complete ResGuests structure."""
# Create the customer using the existing CustomerFactory
customer = CustomerFactory._create_customer(customer_class, customer_data)
# Create Profile with the customer
profile = res_guests_class.ResGuest.Profiles.ProfileInfo.Profile(
customer=customer
)
# Create ProfileInfo with the profile
profile_info = res_guests_class.ResGuest.Profiles.ProfileInfo(profile=profile)
# Create Profiles with the profile_info
profiles = res_guests_class.ResGuest.Profiles(profile_info=profile_info)
# Create ResGuest with the profiles
res_guest = res_guests_class.ResGuest(profiles=profiles)
# Create ResGuests with the res_guest
return res_guests_class(res_guest=res_guest)
@staticmethod
def extract_primary_customer(
res_guests: Union[NotifResGuests, RetrieveResGuests],
) -> CustomerData:
"""Extract the primary customer data from a ResGuests structure."""
# Navigate down the nested structure to get the customer
customer = res_guests.res_guest.profiles.profile_info.profile.customer
# Use the existing CustomerFactory conversion method
if isinstance(res_guests, NotifResGuests):
return CustomerFactory.from_notif_customer(customer)
else:
return CustomerFactory.from_retrieve_customer(customer)
class AlpineBitsFactory:
"""Unified factory class for creating AlpineBits objects with a simple interface."""
@staticmethod
def create(data: Union[CustomerData, HotelReservationIdData, CommentsData], message_type: OtaMessageType) -> Any:
"""
Create an AlpineBits object based on the data type and message type.
Args:
data: The data object (CustomerData, HotelReservationIdData, CommentsData, etc.)
message_type: Whether to create for NOTIF or RETRIEVE message types
Returns:
The appropriate AlpineBits object based on the data type and message type
"""
if isinstance(data, CustomerData):
if message_type == OtaMessageType.NOTIF:
return CustomerFactory.create_notif_customer(data)
else:
return CustomerFactory.create_retrieve_customer(data)
elif isinstance(data, HotelReservationIdData):
if message_type == OtaMessageType.NOTIF:
return HotelReservationIdFactory.create_notif_hotel_reservation_id(data)
else:
return HotelReservationIdFactory.create_retrieve_hotel_reservation_id(data)
elif isinstance(data, CommentsData):
if message_type == OtaMessageType.NOTIF:
return CommentFactory.create_notif_comments(data)
else:
return CommentFactory.create_retrieve_comments(data)
else:
raise ValueError(f"Unsupported data type: {type(data)}")
@staticmethod
def create_res_guests(customer_data: CustomerData, message_type: OtaMessageType) -> Union[NotifResGuests, RetrieveResGuests]:
"""
Create a complete ResGuests structure with a primary customer.
Args:
customer_data: The customer data
message_type: Whether to create for NOTIF or RETRIEVE message types
Returns:
The appropriate ResGuests object
"""
if message_type == OtaMessageType.NOTIF:
return ResGuestFactory.create_notif_res_guests(customer_data)
else:
return ResGuestFactory.create_retrieve_res_guests(customer_data)
@staticmethod
def extract_data(obj: Any) -> Union[CustomerData, HotelReservationIdData, CommentsData]:
"""
Extract data from an AlpineBits object back to a simple data class.
Args:
obj: The AlpineBits object to extract data from
Returns:
The appropriate data object
"""
# Check if it's a Customer object
if hasattr(obj, 'person_name') and hasattr(obj.person_name, 'given_name'):
if isinstance(obj, NotifCustomer):
return CustomerFactory.from_notif_customer(obj)
elif isinstance(obj, RetrieveCustomer):
return CustomerFactory.from_retrieve_customer(obj)
# Check if it's a HotelReservationId object
elif hasattr(obj, 'res_id_type'):
if isinstance(obj, NotifHotelReservationId):
return HotelReservationIdFactory.from_notif_hotel_reservation_id(obj)
elif isinstance(obj, RetrieveHotelReservationId):
return HotelReservationIdFactory.from_retrieve_hotel_reservation_id(obj)
# Check if it's a Comments object
elif hasattr(obj, 'comment'):
if isinstance(obj, NotifComments):
return CommentFactory.from_notif_comments(obj)
elif isinstance(obj, RetrieveComments):
return CommentFactory.from_retrieve_comments(obj)
# Check if it's a ResGuests object
elif hasattr(obj, 'res_guest'):
return ResGuestFactory.extract_primary_customer(obj)
else:
raise ValueError(f"Unsupported object type: {type(obj)}")
# Usage examples
if __name__ == "__main__":
# Create customer data using simple data class
customer_data = CustomerData(
given_name="John",
surname="Doe",
name_prefix="Mr.",
phone_numbers=[
("+1234567890", PhoneTechType.MOBILE), # Phone number with type
("+0987654321", None), # Phone number without type
],
email_address="john.doe@example.com",
email_newsletter=True,
address_line="123 Main Street",
city_name="Anytown",
postal_code="12345",
country_code="US",
address_catalog=False,
gender="Male",
birth_date="1980-01-01",
language="en",
)
# Create customer for OtaHotelResNotifRq
notif_customer = CustomerFactory.create_notif_customer(customer_data)
print(
"Created NotifCustomer:",
notif_customer.person_name.given_name,
notif_customer.person_name.surname,
)
# Create customer for OtaResRetrieveRs
retrieve_customer = CustomerFactory.create_retrieve_customer(customer_data)
print(
"Created RetrieveCustomer:",
retrieve_customer.person_name.given_name,
retrieve_customer.person_name.surname,
)
# Convert back to data class
converted_data = CustomerFactory.from_notif_customer(notif_customer)
print("Converted back to data:", converted_data.given_name, converted_data.surname)
# Verify they contain the same information
print("Original and converted data match:", customer_data == converted_data)
print("\n--- HotelReservationIdFactory Examples ---")
# Create hotel reservation ID data
reservation_id_data = HotelReservationIdData(
res_id_type="123",
res_id_value="RESERVATION-456",
res_id_source="HOTEL_SYSTEM",
res_id_source_context="BOOKING_ENGINE",
)
# Create HotelReservationId for both types
notif_res_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(
reservation_id_data
)
retrieve_res_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
reservation_id_data
)
print(
"Created NotifHotelReservationId:",
notif_res_id.res_id_type,
notif_res_id.res_id_value,
)
print(
"Created RetrieveHotelReservationId:",
retrieve_res_id.res_id_type,
retrieve_res_id.res_id_value,
)
# Convert back to data class
converted_res_id_data = HotelReservationIdFactory.from_notif_hotel_reservation_id(
notif_res_id
)
print(
"Converted back to reservation ID data:",
converted_res_id_data.res_id_type,
converted_res_id_data.res_id_value,
)
# Verify they contain the same information
print(
"Original and converted reservation ID data match:",
reservation_id_data == converted_res_id_data,
)
print("\n--- ResGuestFactory Examples ---")
# Create complete ResGuests structure for OtaHotelResNotifRq - much simpler!
notif_res_guests = ResGuestFactory.create_notif_res_guests(customer_data)
print(
"Created NotifResGuests with customer:",
notif_res_guests.res_guest.profiles.profile_info.profile.customer.person_name.given_name,
)
# Create complete ResGuests structure for OtaResRetrieveRs - much simpler!
retrieve_res_guests = ResGuestFactory.create_retrieve_res_guests(customer_data)
print(
"Created RetrieveResGuests with customer:",
retrieve_res_guests.res_guest.profiles.profile_info.profile.customer.person_name.given_name,
)
# Extract primary customer data back from ResGuests structure
extracted_data = ResGuestFactory.extract_primary_customer(retrieve_res_guests)
print("Extracted customer data:", extracted_data.given_name, extracted_data.surname)
# Verify roundtrip conversion
print("Roundtrip conversion successful:", customer_data == extracted_data)
print("\n--- Unified AlpineBitsFactory Examples ---")
# Much simpler approach - single factory with enum parameter!
print("=== Customer Creation ===")
notif_customer = AlpineBitsFactory.create(customer_data, OtaMessageType.NOTIF)
retrieve_customer = AlpineBitsFactory.create(customer_data, OtaMessageType.RETRIEVE)
print("Created customers using unified factory")
print("=== HotelReservationId Creation ===")
reservation_id_data = HotelReservationIdData(
res_id_type="123",
res_id_value="RESERVATION-456",
res_id_source="HOTEL_SYSTEM"
)
notif_res_id = AlpineBitsFactory.create(reservation_id_data, OtaMessageType.NOTIF)
retrieve_res_id = AlpineBitsFactory.create(reservation_id_data, OtaMessageType.RETRIEVE)
print("Created reservation IDs using unified factory")
print("=== Comments Creation ===")
comments_data = CommentsData(comments=[
CommentData(
name=CommentName2.CUSTOMER_COMMENT,
text="This is a customer comment about the reservation",
list_items=[
CommentListItemData(
value="Special dietary requirements: vegetarian",
list_item="1",
language="en"
),
CommentListItemData(
value="Late arrival expected",
list_item="2",
language="en"
)
]
),
CommentData(
name=CommentName2.ADDITIONAL_INFO,
text="Additional information about the stay"
)
])
notif_comments = AlpineBitsFactory.create(comments_data, OtaMessageType.NOTIF)
retrieve_comments = AlpineBitsFactory.create(comments_data, OtaMessageType.RETRIEVE)
print("Created comments using unified factory")
print("=== ResGuests Creation ===")
notif_res_guests = AlpineBitsFactory.create_res_guests(customer_data, OtaMessageType.NOTIF)
retrieve_res_guests = AlpineBitsFactory.create_res_guests(customer_data, OtaMessageType.RETRIEVE)
print("Created ResGuests using unified factory")
print("=== Data Extraction ===")
# Extract data back using unified interface
extracted_customer_data = AlpineBitsFactory.extract_data(notif_customer)
extracted_res_id_data = AlpineBitsFactory.extract_data(notif_res_id)
extracted_comments_data = AlpineBitsFactory.extract_data(retrieve_comments)
extracted_from_res_guests = AlpineBitsFactory.extract_data(retrieve_res_guests)
print("Data extraction successful:")
print("- Customer roundtrip:", customer_data == extracted_customer_data)
print("- ReservationId roundtrip:", reservation_id_data == extracted_res_id_data)
print("- Comments roundtrip:", comments_data == extracted_comments_data)
print("- ResGuests roundtrip:", customer_data == extracted_from_res_guests)
print("\n--- Comparison with old approach ---")
print("Old way required multiple imports and knowing specific factory methods")
print("New way: single import, single factory, enum parameter to specify type!")

View File

@@ -0,0 +1 @@
"""Utility functions for alpine_bits_python."""

View File

@@ -0,0 +1,5 @@
"""Entry point for util package."""
from .handshake_util import main
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,52 @@
from ..generated.alpinebits import OtaPingRq, OtaPingRs
from xsdata_pydantic.bindings import XmlParser
def main():
# test parsing a ping request sample
path = "AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRS.xml"
with open(
path, "r", encoding="utf-8") as f:
xml = f.read()
# Parse the XML into the request object
# Test parsing back
parser = XmlParser()
parsed_result = parser.from_string(xml, OtaPingRs)
print(parsed_result.echo_data)
warning = parsed_result.warnings.warning[0]
print(warning.type_value)
print(type(warning.content))
print(warning.content[0])
# save json in echo_data to file with indents
output_path = "echo_data_response.json"
with open(output_path, "w", encoding="utf-8") as out_f:
import json
json.dump(json.loads(parsed_result.echo_data), out_f, indent=4)
print(f"Saved echo_data json to {output_path}")
if __name__ == "__main__":
main()

View File

@@ -1,118 +0,0 @@
from alpinebits_guestrequests import ResGuest, RoomStay
from alpine_bits_classes import (
OTA_ResRetrieveRS,
Success,
ReservationsListType,
HotelReservationType,
UniqueIDType,
RoomStaysType,
RoomStayType,
RoomTypesType,
RoomTypeType,
GuestCountsType,
GuestCountType,
TimeSpanType,
StartDateWindowType,
ResGuestsType,
ResGuestType,
ProfilesType,
ProfileInfoType,
ProfileType,
CustomerType,
PersonNameType,
AddressType,
CountryNameType,
ResGlobalInfoType,
HotelReservationIDsType,
HotelReservationIDType,
BasicPropertyInfoType,
GivenNameType,
SurnameType,
)
from io import BytesIO
import sys
from datetime import datetime, timezone
def main():
# Success
success = Success()
# RoomType
room_type = RoomTypeType(RoomTypeCode="A", RoomClassificationCode="5", RoomType="8")
room_types = RoomTypesType(RoomType=room_type)
# GuestCounts
guest_count = GuestCountType(Count=1)
guest_counts = GuestCountsType(GuestCount=[guest_count])
# TimeSpan with StartDateWindow
start_date_window = StartDateWindowType(EarliestDate=datetime(2022, 10, 3, tzinfo=timezone.utc), LatestDate=datetime(2022, 10, 8, tzinfo=timezone.utc))
time_span = TimeSpanType(StartDateWindow=start_date_window)
# RoomStay
room_stay = RoomStayType(
RoomTypes=room_types,
GuestCounts=guest_counts,
TimeSpan=time_span
)
room_stays = RoomStaysType(RoomStay=[room_stay])
# ResGuest
person_name = PersonNameType(GivenName=GivenNameType("Otto"), Surname=SurnameType("Mustermann"))
country_name = CountryNameType(Code="DE")
address = AddressType(CountryName=country_name)
customer = CustomerType(Language="de", Gender="Unknown", PersonName=person_name, Address=address)
profile = ProfileType(Customer=customer)
profile_info = ProfileInfoType(Profile=profile)
profiles = ProfilesType(ProfileInfo=profile_info)
res_guest = ResGuestType(Profiles=profiles)
res_guests = ResGuestsType(ResGuest=res_guest)
# UniqueID
unique_id = UniqueIDType(Type="14", ID="6b34fe24ac2ff811")
# ResGlobalInfo
hotel_res_id = HotelReservationIDType(
ResID_Type="13",
ResID_SourceContext="cnt",
ResID_Value="res",
ResID_Source="www.example.com"
)
hotel_res_ids = HotelReservationIDsType(HotelReservationID=[hotel_res_id])
basic_property_info = BasicPropertyInfoType(HotelCode="123", HotelName="Frangart Inn")
res_global_info = ResGlobalInfoType(
HotelReservationIDs=hotel_res_ids,
BasicPropertyInfo=basic_property_info
)
# HotelReservation
hotel_reservation = HotelReservationType(
CreateDateTime=datetime.now(timezone.utc).isoformat(),
ResStatus="Requested",
RoomStayReservation=True,
UniqueID=unique_id,
RoomStays=room_stays,
ResGuests=res_guests,
ResGlobalInfo=res_global_info
)
hotel_reservation.set_CreateDateTime(datetime.now(timezone.utc))
reservations_list = ReservationsListType(HotelReservation=[hotel_reservation])
# Root element
ota_res_retrieve_rs = OTA_ResRetrieveRS(
Version="7.000",
Success=success,
ReservationsList=reservations_list
)
# Serialize to XML string
# create outfile
with open('output.xml', 'w', encoding='utf-8') as outfile:
ota_res_retrieve_rs.export(outfile, 0)
if __name__ == "__main__":
main()

View File

@@ -1,47 +0,0 @@
<OTA_ResRetrieveRS xmlns:None="http://www.opentravel.org/OTA/2003/05" Version="7.000">
<Success>&lt;Success/&gt;
</Success>
<ReservationsList>
<HotelReservation CreateDateTime="2025-09-24T07:18:57.913865Z" ResStatus="Requested" RoomStayReservation="true">
<UniqueID Type="14" ID="6b34fe24ac2ff811"/>
<RoomStays>
<RoomStay>
<RoomTypes>
<RoomType RoomTypeCode="A" RoomClassificationCode="5" RoomType="8"/>
</RoomTypes>
<GuestCounts>
<GuestCount Count="1"/>
</GuestCounts>
<TimeSpan>
<StartDateWindow EarliestDate="2022-10-03Z" LatestDate="2022-10-08Z"/>
</TimeSpan>
</RoomStay>
</RoomStays>
<ResGuests>
<ResGuest>
<Profiles>
<ProfileInfo>
<Profile>
<Customer Gender="Unknown" Language="de">
<PersonName>
<GivenName>Otto</GivenName>
<Surname>Mustermann</Surname>
</PersonName>
<Address>
<CountryName Code="DE"/>
</Address>
</Customer>
</Profile>
</ProfileInfo>
</Profiles>
</ResGuest>
</ResGuests>
<ResGlobalInfo>
<HotelReservationIDs>
<HotelReservationID ResID_Type="13" ResID_Value="res" ResID_Source="www.example.com" ResID_SourceContext="cnt"/>
</HotelReservationIDs>
<BasicPropertyInfo HotelCode="123" HotelName="Frangart Inn"/>
</ResGlobalInfo>
</HotelReservation>
</ReservationsList>
</OTA_ResRetrieveRS>

View File

@@ -1,148 +0,0 @@
"""
Script to verify if all classes in alpine_bits_classes.py with the same base name (e.g., CustomerType, CustomerType125, CustomerType576)
are structurally identical. This helps to identify duplicate dataclasses generated by generateDS.
Usage:
python src/postprocessing.py
Requirements:
- Only uses the standard library.
"""
import ast
import re
from collections import defaultdict
from pathlib import Path
def get_class_basenames(classname):
"""Returns the base name of a class (e.g., CustomerType125 -> CustomerType)"""
return re.sub(r'\d+$', '', classname)
def extract_classes(filepath):
"""Parse the file and extract all class definitions as AST nodes."""
with open(filepath, "r", encoding="utf-8") as f:
source = f.read()
tree = ast.parse(source)
classes = {}
for node in tree.body:
if isinstance(node, ast.ClassDef):
classes[node.name] = node
return classes
def class_struct_signature(class_node):
"""Return a tuple representing the structure of the class: base classes, method names, attribute names."""
bases = tuple(base.id if isinstance(base, ast.Name) else ast.dump(base) for base in class_node.bases)
methods = []
attrs = []
for item in class_node.body:
if isinstance(item, ast.FunctionDef):
methods.append(item.name)
elif isinstance(item, ast.Assign):
for target in item.targets:
if isinstance(target, ast.Name):
attrs.append(target.id)
return (bases, tuple(sorted(methods)), tuple(sorted(attrs)))
def remove_identical_class_suffixes(filepath: Path):
"""
Removes duplicate class definitions with numeric suffixes if they are structurally identical,
keeping only the base (unsuffixed) class.
"""
import shutil
# Parse classes and group by base name
classes = extract_classes(filepath)
grouped = defaultdict(list)
for cname in classes:
base = get_class_basenames(cname)
grouped[base].append(cname)
# Find identical groups
identical = []
for base, classnames in grouped.items():
if len(classnames) > 1:
sigs = [class_struct_signature(classes[c]) for c in classnames]
if all(s == sigs[0] for s in sigs):
identical.append((base, classnames))
# Read original file lines
with open(filepath, "r", encoding="utf-8") as f:
lines = f.readlines()
# Find line numbers for all class definitions
class_lines = {}
for i, line in enumerate(lines):
m = re.match(r'class (\w+)\b', line)
if m:
class_lines[m.group(1)] = i
# Mark classes to remove (all but the base, unsuffixed one)
to_remove = set()
for base, classnames in identical:
for cname in classnames:
if cname != base:
to_remove.add(cname)
# Remove class definitions with suffixes
new_lines = []
skip = False
for i, line in enumerate(lines):
m = re.match(r'class (\w+)\b', line)
if m and m.group(1) in to_remove:
skip = True
if not skip:
new_lines.append(line)
# End skipping at the next class or end of file
if skip and (i + 1 == len(lines) or re.match(r'class \w+\b', lines[i + 1])):
skip = False
# Backup original file
backup_path = filepath.with_suffix(filepath.suffix + ".bak")
shutil.copy(filepath, backup_path)
# Write cleaned file
with open(filepath, "w", encoding="utf-8") as f:
f.writelines(new_lines)
print(f"Removed {len(to_remove)} duplicate class definitions. Backup saved as {filepath}.bak")
# Example usage:
# remove_identical_class_suffixes("src/alpine_bits_classes.py")
def main():
file_path = Path(__file__).parent / "alpine_bits_classes.py"
classes = extract_classes(file_path)
grouped = defaultdict(list)
for cname in classes:
base = get_class_basenames(cname)
grouped[base].append(cname)
identical = []
different = []
for base, classnames in grouped.items():
if len(classnames) > 1:
sigs = [class_struct_signature(classes[c]) for c in classnames]
if all(s == sigs[0] for s in sigs):
identical.append((base, classnames))
else:
different.append((base, classnames))
print("=== Structurally Identical Groups ===")
for base, classnames in identical:
print(f"{base}: {', '.join(classnames)}")
print("\n=== Structurally Different Groups ===")
for base, classnames in different:
print(f"{base}: {', '.join(classnames)}")
remove_identical_class_suffixes(file_path)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

61
test/test_discovery.py Normal file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""
Quick test to demonstrate how the ServerCapabilities automatically
discovers implemented vs unimplemented actions.
"""
from alpine_bits_python.alpinebits_server import (
ServerCapabilities,
AlpineBitsAction,
AlpineBitsActionName,
Version,
AlpineBitsResponse,
HttpStatusCode
)
import asyncio
class NewImplementedAction(AlpineBitsAction):
"""A new action that IS implemented."""
def __init__(self):
self.name = AlpineBitsActionName.OTA_HOTEL_DESCRIPTIVE_INFO_INFO
self.version = Version.V2024_10
async def handle(self, action: str, request_xml: str, version: Version) -> AlpineBitsResponse:
"""This action is implemented."""
return AlpineBitsResponse("Implemented!", HttpStatusCode.OK)
class NewUnimplementedAction(AlpineBitsAction):
"""A new action that is NOT implemented (no handle override)."""
def __init__(self):
self.name = AlpineBitsActionName.OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INFO
self.version = Version.V2024_10
# Notice: No handle method override - will use default "not implemented"
async def main():
print("🔍 Testing Action Discovery Logic")
print("=" * 50)
# Create capabilities and see what gets discovered
capabilities = ServerCapabilities()
print("📋 Actions found by discovery:")
for action_name in capabilities.get_supported_actions():
print(f"{action_name}")
print(f"\n📊 Total discovered: {len(capabilities.get_supported_actions())}")
# Test the new implemented action
implemented_action = NewImplementedAction()
result = await implemented_action.handle("test", "<xml/>", Version.V2024_10)
print(f"\n🟢 NewImplementedAction result: {result.xml_content}")
# Test the unimplemented action (should use default behavior)
unimplemented_action = NewUnimplementedAction()
result = await unimplemented_action.handle("test", "<xml/>", Version.V2024_10)
print(f"🔴 NewUnimplementedAction result: {result.xml_content}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,609 @@
import pytest
from typing import Union
import sys
import os
# Add the src directory to the path so we can import our modules
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from simplified_access import (
CustomerData,
CustomerFactory,
ResGuestFactory,
HotelReservationIdData,
HotelReservationIdFactory,
AlpineBitsFactory,
PhoneTechType,
OtaMessageType,
NotifCustomer,
RetrieveCustomer,
NotifResGuests,
RetrieveResGuests,
NotifHotelReservationId,
RetrieveHotelReservationId
)
@pytest.fixture
def sample_customer_data():
"""Fixture providing sample customer data for testing."""
return CustomerData(
given_name="John",
surname="Doe",
name_prefix="Mr.",
name_title="Jr.",
phone_numbers=[
("+1234567890", PhoneTechType.MOBILE),
("+0987654321", PhoneTechType.VOICE),
("+1111111111", None)
],
email_address="john.doe@example.com",
email_newsletter=True,
address_line="123 Main Street",
city_name="Anytown",
postal_code="12345",
country_code="US",
address_catalog=False,
gender="Male",
birth_date="1980-01-01",
language="en"
)
@pytest.fixture
def minimal_customer_data():
"""Fixture providing minimal customer data (only required fields)."""
return CustomerData(
given_name="Jane",
surname="Smith"
)
@pytest.fixture
def sample_hotel_reservation_id_data():
"""Fixture providing sample hotel reservation ID data for testing."""
return HotelReservationIdData(
res_id_type="123",
res_id_value="RESERVATION-456",
res_id_source="HOTEL_SYSTEM",
res_id_source_context="BOOKING_ENGINE"
)
@pytest.fixture
def minimal_hotel_reservation_id_data():
"""Fixture providing minimal hotel reservation ID data (only required fields)."""
return HotelReservationIdData(
res_id_type="999"
)
class TestCustomerData:
"""Test the CustomerData dataclass."""
def test_customer_data_creation_full(self, sample_customer_data):
"""Test creating CustomerData with all fields."""
assert sample_customer_data.given_name == "John"
assert sample_customer_data.surname == "Doe"
assert sample_customer_data.name_prefix == "Mr."
assert sample_customer_data.email_address == "john.doe@example.com"
assert sample_customer_data.email_newsletter is True
assert len(sample_customer_data.phone_numbers) == 3
def test_customer_data_creation_minimal(self, minimal_customer_data):
"""Test creating CustomerData with only required fields."""
assert minimal_customer_data.given_name == "Jane"
assert minimal_customer_data.surname == "Smith"
assert minimal_customer_data.phone_numbers == []
assert minimal_customer_data.email_address is None
assert minimal_customer_data.address_line is None
def test_phone_numbers_default_initialization(self):
"""Test that phone_numbers gets initialized to empty list."""
customer_data = CustomerData(given_name="Test", surname="User")
assert customer_data.phone_numbers == []
class TestCustomerFactory:
"""Test the CustomerFactory class."""
def test_create_notif_customer_full(self, sample_customer_data):
"""Test creating a NotifCustomer with full data."""
customer = CustomerFactory.create_notif_customer(sample_customer_data)
assert isinstance(customer, NotifCustomer)
assert customer.person_name.given_name == "John"
assert customer.person_name.surname == "Doe"
assert customer.person_name.name_prefix == "Mr."
assert customer.person_name.name_title == "Jr."
# Check telephone
assert len(customer.telephone) == 3
assert customer.telephone[0].phone_number == "+1234567890"
assert customer.telephone[0].phone_tech_type == "5" # MOBILE
assert customer.telephone[1].phone_tech_type == "1" # VOICE
assert customer.telephone[2].phone_tech_type is None
# Check email
assert customer.email.value == "john.doe@example.com"
assert customer.email.remark == "newsletter:yes"
# Check address
assert customer.address.address_line == "123 Main Street"
assert customer.address.city_name == "Anytown"
assert customer.address.postal_code == "12345"
assert customer.address.country_name.code == "US"
assert customer.address.remark == "catalog:no"
# Check other attributes
assert customer.gender == "Male"
assert customer.birth_date == "1980-01-01"
assert customer.language == "en"
def test_create_retrieve_customer_full(self, sample_customer_data):
"""Test creating a RetrieveCustomer with full data."""
customer = CustomerFactory.create_retrieve_customer(sample_customer_data)
assert isinstance(customer, RetrieveCustomer)
assert customer.person_name.given_name == "John"
assert customer.person_name.surname == "Doe"
# Same structure as NotifCustomer, so we don't need to test all fields again
def test_create_customer_minimal(self, minimal_customer_data):
"""Test creating customers with minimal data."""
notif_customer = CustomerFactory.create_notif_customer(minimal_customer_data)
retrieve_customer = CustomerFactory.create_retrieve_customer(minimal_customer_data)
for customer in [notif_customer, retrieve_customer]:
assert customer.person_name.given_name == "Jane"
assert customer.person_name.surname == "Smith"
assert customer.person_name.name_prefix is None
assert customer.person_name.name_title is None
assert len(customer.telephone) == 0
assert customer.email is None
assert customer.address is None
assert customer.gender is None
assert customer.birth_date is None
assert customer.language is None
def test_email_newsletter_options(self):
"""Test different email newsletter options."""
# Newsletter yes
data_yes = CustomerData(given_name="Test", surname="User",
email_address="test@example.com", email_newsletter=True)
customer = CustomerFactory.create_notif_customer(data_yes)
assert customer.email.remark == "newsletter:yes"
# Newsletter no
data_no = CustomerData(given_name="Test", surname="User",
email_address="test@example.com", email_newsletter=False)
customer = CustomerFactory.create_notif_customer(data_no)
assert customer.email.remark == "newsletter:no"
# Newsletter not specified
data_none = CustomerData(given_name="Test", surname="User",
email_address="test@example.com", email_newsletter=None)
customer = CustomerFactory.create_notif_customer(data_none)
assert customer.email.remark is None
def test_address_catalog_options(self):
"""Test different address catalog options."""
# Catalog no
data_no = CustomerData(given_name="Test", surname="User",
address_line="123 Street", address_catalog=False)
customer = CustomerFactory.create_notif_customer(data_no)
assert customer.address.remark == "catalog:no"
# Catalog yes
data_yes = CustomerData(given_name="Test", surname="User",
address_line="123 Street", address_catalog=True)
customer = CustomerFactory.create_notif_customer(data_yes)
assert customer.address.remark == "catalog:yes"
# Catalog not specified
data_none = CustomerData(given_name="Test", surname="User",
address_line="123 Street", address_catalog=None)
customer = CustomerFactory.create_notif_customer(data_none)
assert customer.address.remark is None
def test_from_notif_customer_roundtrip(self, sample_customer_data):
"""Test converting NotifCustomer back to CustomerData."""
customer = CustomerFactory.create_notif_customer(sample_customer_data)
converted_data = CustomerFactory.from_notif_customer(customer)
assert converted_data == sample_customer_data
def test_from_retrieve_customer_roundtrip(self, sample_customer_data):
"""Test converting RetrieveCustomer back to CustomerData."""
customer = CustomerFactory.create_retrieve_customer(sample_customer_data)
converted_data = CustomerFactory.from_retrieve_customer(customer)
assert converted_data == sample_customer_data
def test_phone_tech_type_conversion(self):
"""Test that PhoneTechType enum values are properly converted."""
data = CustomerData(
given_name="Test",
surname="User",
phone_numbers=[
("+1111111111", PhoneTechType.VOICE),
("+2222222222", PhoneTechType.FAX),
("+3333333333", PhoneTechType.MOBILE)
]
)
customer = CustomerFactory.create_notif_customer(data)
assert customer.telephone[0].phone_tech_type == "1" # VOICE
assert customer.telephone[1].phone_tech_type == "3" # FAX
assert customer.telephone[2].phone_tech_type == "5" # MOBILE
class TestHotelReservationIdData:
"""Test the HotelReservationIdData dataclass."""
def test_hotel_reservation_id_data_creation_full(self, sample_hotel_reservation_id_data):
"""Test creating HotelReservationIdData with all fields."""
assert sample_hotel_reservation_id_data.res_id_type == "123"
assert sample_hotel_reservation_id_data.res_id_value == "RESERVATION-456"
assert sample_hotel_reservation_id_data.res_id_source == "HOTEL_SYSTEM"
assert sample_hotel_reservation_id_data.res_id_source_context == "BOOKING_ENGINE"
def test_hotel_reservation_id_data_creation_minimal(self, minimal_hotel_reservation_id_data):
"""Test creating HotelReservationIdData with only required fields."""
assert minimal_hotel_reservation_id_data.res_id_type == "999"
assert minimal_hotel_reservation_id_data.res_id_value is None
assert minimal_hotel_reservation_id_data.res_id_source is None
assert minimal_hotel_reservation_id_data.res_id_source_context is None
class TestHotelReservationIdFactory:
"""Test the HotelReservationIdFactory class."""
def test_create_notif_hotel_reservation_id_full(self, sample_hotel_reservation_id_data):
"""Test creating a NotifHotelReservationId with full data."""
reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(sample_hotel_reservation_id_data)
assert isinstance(reservation_id, NotifHotelReservationId)
assert reservation_id.res_id_type == "123"
assert reservation_id.res_id_value == "RESERVATION-456"
assert reservation_id.res_id_source == "HOTEL_SYSTEM"
assert reservation_id.res_id_source_context == "BOOKING_ENGINE"
def test_create_retrieve_hotel_reservation_id_full(self, sample_hotel_reservation_id_data):
"""Test creating a RetrieveHotelReservationId with full data."""
reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(sample_hotel_reservation_id_data)
assert isinstance(reservation_id, RetrieveHotelReservationId)
assert reservation_id.res_id_type == "123"
assert reservation_id.res_id_value == "RESERVATION-456"
assert reservation_id.res_id_source == "HOTEL_SYSTEM"
assert reservation_id.res_id_source_context == "BOOKING_ENGINE"
def test_create_hotel_reservation_id_minimal(self, minimal_hotel_reservation_id_data):
"""Test creating hotel reservation IDs with minimal data."""
notif_reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(minimal_hotel_reservation_id_data)
retrieve_reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(minimal_hotel_reservation_id_data)
for reservation_id in [notif_reservation_id, retrieve_reservation_id]:
assert reservation_id.res_id_type == "999"
assert reservation_id.res_id_value is None
assert reservation_id.res_id_source is None
assert reservation_id.res_id_source_context is None
def test_from_notif_hotel_reservation_id_roundtrip(self, sample_hotel_reservation_id_data):
"""Test converting NotifHotelReservationId back to HotelReservationIdData."""
reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(sample_hotel_reservation_id_data)
converted_data = HotelReservationIdFactory.from_notif_hotel_reservation_id(reservation_id)
assert converted_data == sample_hotel_reservation_id_data
def test_from_retrieve_hotel_reservation_id_roundtrip(self, sample_hotel_reservation_id_data):
"""Test converting RetrieveHotelReservationId back to HotelReservationIdData."""
reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(sample_hotel_reservation_id_data)
converted_data = HotelReservationIdFactory.from_retrieve_hotel_reservation_id(reservation_id)
assert converted_data == sample_hotel_reservation_id_data
class TestResGuestFactory:
"""Test the ResGuestFactory class."""
def test_create_notif_res_guests(self, sample_customer_data):
"""Test creating NotifResGuests structure."""
res_guests = ResGuestFactory.create_notif_res_guests(sample_customer_data)
assert isinstance(res_guests, NotifResGuests)
# Navigate down the nested structure
customer = res_guests.res_guest.profiles.profile_info.profile.customer
assert customer.person_name.given_name == "John"
assert customer.person_name.surname == "Doe"
assert customer.email.value == "john.doe@example.com"
def test_create_retrieve_res_guests(self, sample_customer_data):
"""Test creating RetrieveResGuests structure."""
res_guests = ResGuestFactory.create_retrieve_res_guests(sample_customer_data)
assert isinstance(res_guests, RetrieveResGuests)
# Navigate down the nested structure
customer = res_guests.res_guest.profiles.profile_info.profile.customer
assert customer.person_name.given_name == "John"
assert customer.person_name.surname == "Doe"
assert customer.email.value == "john.doe@example.com"
def test_create_res_guests_minimal(self, minimal_customer_data):
"""Test creating ResGuests with minimal customer data."""
notif_res_guests = ResGuestFactory.create_notif_res_guests(minimal_customer_data)
retrieve_res_guests = ResGuestFactory.create_retrieve_res_guests(minimal_customer_data)
for res_guests in [notif_res_guests, retrieve_res_guests]:
customer = res_guests.res_guest.profiles.profile_info.profile.customer
assert customer.person_name.given_name == "Jane"
assert customer.person_name.surname == "Smith"
assert customer.email is None
assert customer.address is None
def test_extract_primary_customer_notif(self, sample_customer_data):
"""Test extracting primary customer from NotifResGuests."""
res_guests = ResGuestFactory.create_notif_res_guests(sample_customer_data)
extracted_data = ResGuestFactory.extract_primary_customer(res_guests)
assert extracted_data == sample_customer_data
def test_extract_primary_customer_retrieve(self, sample_customer_data):
"""Test extracting primary customer from RetrieveResGuests."""
res_guests = ResGuestFactory.create_retrieve_res_guests(sample_customer_data)
extracted_data = ResGuestFactory.extract_primary_customer(res_guests)
assert extracted_data == sample_customer_data
def test_roundtrip_conversion_notif(self, sample_customer_data):
"""Test complete roundtrip: CustomerData -> NotifResGuests -> CustomerData."""
res_guests = ResGuestFactory.create_notif_res_guests(sample_customer_data)
extracted_data = ResGuestFactory.extract_primary_customer(res_guests)
assert extracted_data == sample_customer_data
def test_roundtrip_conversion_retrieve(self, sample_customer_data):
"""Test complete roundtrip: CustomerData -> RetrieveResGuests -> CustomerData."""
res_guests = ResGuestFactory.create_retrieve_res_guests(sample_customer_data)
extracted_data = ResGuestFactory.extract_primary_customer(res_guests)
assert extracted_data == sample_customer_data
class TestPhoneTechType:
"""Test the PhoneTechType enum."""
def test_enum_values(self):
"""Test that enum values are correct."""
assert PhoneTechType.VOICE.value == "1"
assert PhoneTechType.FAX.value == "3"
assert PhoneTechType.MOBILE.value == "5"
class TestAlpineBitsFactory:
"""Test the unified AlpineBitsFactory class."""
def test_create_customer_notif(self, sample_customer_data):
"""Test creating customer using unified factory for NOTIF."""
customer = AlpineBitsFactory.create(sample_customer_data, OtaMessageType.NOTIF)
assert isinstance(customer, NotifCustomer)
assert customer.person_name.given_name == "John"
assert customer.person_name.surname == "Doe"
def test_create_customer_retrieve(self, sample_customer_data):
"""Test creating customer using unified factory for RETRIEVE."""
customer = AlpineBitsFactory.create(sample_customer_data, OtaMessageType.RETRIEVE)
assert isinstance(customer, RetrieveCustomer)
assert customer.person_name.given_name == "John"
assert customer.person_name.surname == "Doe"
def test_create_hotel_reservation_id_notif(self, sample_hotel_reservation_id_data):
"""Test creating hotel reservation ID using unified factory for NOTIF."""
reservation_id = AlpineBitsFactory.create(sample_hotel_reservation_id_data, OtaMessageType.NOTIF)
assert isinstance(reservation_id, NotifHotelReservationId)
assert reservation_id.res_id_type == "123"
assert reservation_id.res_id_value == "RESERVATION-456"
def test_create_hotel_reservation_id_retrieve(self, sample_hotel_reservation_id_data):
"""Test creating hotel reservation ID using unified factory for RETRIEVE."""
reservation_id = AlpineBitsFactory.create(sample_hotel_reservation_id_data, OtaMessageType.RETRIEVE)
assert isinstance(reservation_id, RetrieveHotelReservationId)
assert reservation_id.res_id_type == "123"
assert reservation_id.res_id_value == "RESERVATION-456"
def test_create_res_guests_notif(self, sample_customer_data):
"""Test creating ResGuests using unified factory for NOTIF."""
res_guests = AlpineBitsFactory.create_res_guests(sample_customer_data, OtaMessageType.NOTIF)
assert isinstance(res_guests, NotifResGuests)
customer = res_guests.res_guest.profiles.profile_info.profile.customer
assert customer.person_name.given_name == "John"
def test_create_res_guests_retrieve(self, sample_customer_data):
"""Test creating ResGuests using unified factory for RETRIEVE."""
res_guests = AlpineBitsFactory.create_res_guests(sample_customer_data, OtaMessageType.RETRIEVE)
assert isinstance(res_guests, RetrieveResGuests)
customer = res_guests.res_guest.profiles.profile_info.profile.customer
assert customer.person_name.given_name == "John"
def test_extract_data_from_customer(self, sample_customer_data):
"""Test extracting data from customer objects."""
# Create both types and extract data back
notif_customer = AlpineBitsFactory.create(sample_customer_data, OtaMessageType.NOTIF)
retrieve_customer = AlpineBitsFactory.create(sample_customer_data, OtaMessageType.RETRIEVE)
notif_extracted = AlpineBitsFactory.extract_data(notif_customer)
retrieve_extracted = AlpineBitsFactory.extract_data(retrieve_customer)
assert notif_extracted == sample_customer_data
assert retrieve_extracted == sample_customer_data
def test_extract_data_from_hotel_reservation_id(self, sample_hotel_reservation_id_data):
"""Test extracting data from hotel reservation ID objects."""
# Create both types and extract data back
notif_res_id = AlpineBitsFactory.create(sample_hotel_reservation_id_data, OtaMessageType.NOTIF)
retrieve_res_id = AlpineBitsFactory.create(sample_hotel_reservation_id_data, OtaMessageType.RETRIEVE)
notif_extracted = AlpineBitsFactory.extract_data(notif_res_id)
retrieve_extracted = AlpineBitsFactory.extract_data(retrieve_res_id)
assert notif_extracted == sample_hotel_reservation_id_data
assert retrieve_extracted == sample_hotel_reservation_id_data
def test_extract_data_from_res_guests(self, sample_customer_data):
"""Test extracting data from ResGuests objects."""
# Create both types and extract data back
notif_res_guests = AlpineBitsFactory.create_res_guests(sample_customer_data, OtaMessageType.NOTIF)
retrieve_res_guests = AlpineBitsFactory.create_res_guests(sample_customer_data, OtaMessageType.RETRIEVE)
notif_extracted = AlpineBitsFactory.extract_data(notif_res_guests)
retrieve_extracted = AlpineBitsFactory.extract_data(retrieve_res_guests)
assert notif_extracted == sample_customer_data
assert retrieve_extracted == sample_customer_data
def test_unsupported_data_type_error(self):
"""Test that unsupported data types raise ValueError."""
with pytest.raises(ValueError, match="Unsupported data type"):
AlpineBitsFactory.create("invalid_data", OtaMessageType.NOTIF)
def test_unsupported_object_type_error(self):
"""Test that unsupported object types raise ValueError in extract_data."""
with pytest.raises(ValueError, match="Unsupported object type"):
AlpineBitsFactory.extract_data("invalid_object")
def test_complete_workflow_with_unified_factory(self):
"""Test a complete workflow using only the unified factory."""
# Original data
customer_data = CustomerData(
given_name="Unified",
surname="Factory",
email_address="unified@factory.com",
phone_numbers=[("+1234567890", PhoneTechType.MOBILE)]
)
reservation_data = HotelReservationIdData(
res_id_type="999",
res_id_value="UNIFIED-TEST"
)
# Create using unified factory
customer_notif = AlpineBitsFactory.create(customer_data, OtaMessageType.NOTIF)
customer_retrieve = AlpineBitsFactory.create(customer_data, OtaMessageType.RETRIEVE)
res_id_notif = AlpineBitsFactory.create(reservation_data, OtaMessageType.NOTIF)
res_id_retrieve = AlpineBitsFactory.create(reservation_data, OtaMessageType.RETRIEVE)
res_guests_notif = AlpineBitsFactory.create_res_guests(customer_data, OtaMessageType.NOTIF)
res_guests_retrieve = AlpineBitsFactory.create_res_guests(customer_data, OtaMessageType.RETRIEVE)
# Extract everything back
extracted_customer_from_notif = AlpineBitsFactory.extract_data(customer_notif)
extracted_customer_from_retrieve = AlpineBitsFactory.extract_data(customer_retrieve)
extracted_res_id_from_notif = AlpineBitsFactory.extract_data(res_id_notif)
extracted_res_id_from_retrieve = AlpineBitsFactory.extract_data(res_id_retrieve)
extracted_from_res_guests_notif = AlpineBitsFactory.extract_data(res_guests_notif)
extracted_from_res_guests_retrieve = AlpineBitsFactory.extract_data(res_guests_retrieve)
# Verify everything matches
assert extracted_customer_from_notif == customer_data
assert extracted_customer_from_retrieve == customer_data
assert extracted_res_id_from_notif == reservation_data
assert extracted_res_id_from_retrieve == reservation_data
assert extracted_from_res_guests_notif == customer_data
assert extracted_from_res_guests_retrieve == customer_data
class TestIntegration:
"""Integration tests combining both factories."""
def test_both_factories_produce_same_customer_data(self, sample_customer_data):
"""Test that both factories can work with the same customer data."""
# Create using CustomerFactory
notif_customer = CustomerFactory.create_notif_customer(sample_customer_data)
retrieve_customer = CustomerFactory.create_retrieve_customer(sample_customer_data)
# Create using ResGuestFactory and extract customers
notif_res_guests = ResGuestFactory.create_notif_res_guests(sample_customer_data)
retrieve_res_guests = ResGuestFactory.create_retrieve_res_guests(sample_customer_data)
notif_from_res_guests = notif_res_guests.res_guest.profiles.profile_info.profile.customer
retrieve_from_res_guests = retrieve_res_guests.res_guest.profiles.profile_info.profile.customer
# Compare customer names (structure should be identical)
assert notif_customer.person_name.given_name == notif_from_res_guests.person_name.given_name
assert notif_customer.person_name.surname == notif_from_res_guests.person_name.surname
assert retrieve_customer.person_name.given_name == retrieve_from_res_guests.person_name.given_name
assert retrieve_customer.person_name.surname == retrieve_from_res_guests.person_name.surname
def test_hotel_reservation_id_factories_produce_same_data(self, sample_hotel_reservation_id_data):
"""Test that both HotelReservationId factories produce equivalent results."""
notif_reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(sample_hotel_reservation_id_data)
retrieve_reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(sample_hotel_reservation_id_data)
# Both should have the same field values
assert notif_reservation_id.res_id_type == retrieve_reservation_id.res_id_type
assert notif_reservation_id.res_id_value == retrieve_reservation_id.res_id_value
assert notif_reservation_id.res_id_source == retrieve_reservation_id.res_id_source
assert notif_reservation_id.res_id_source_context == retrieve_reservation_id.res_id_source_context
def test_complex_customer_workflow(self):
"""Test a complex workflow with multiple operations."""
# Create original data
original_data = CustomerData(
given_name="Alice",
surname="Johnson",
phone_numbers=[
("+1555123456", PhoneTechType.MOBILE),
("+1555654321", PhoneTechType.VOICE)
],
email_address="alice.johnson@company.com",
email_newsletter=False,
address_line="456 Business Ave",
city_name="Metropolis",
postal_code="67890",
country_code="CA",
address_catalog=True,
gender="Female",
language="fr"
)
# Create ResGuests for both types
notif_res_guests = ResGuestFactory.create_notif_res_guests(original_data)
retrieve_res_guests = ResGuestFactory.create_retrieve_res_guests(original_data)
# Extract data back from both
notif_extracted = ResGuestFactory.extract_primary_customer(notif_res_guests)
retrieve_extracted = ResGuestFactory.extract_primary_customer(retrieve_res_guests)
# All should be equal
assert original_data == notif_extracted
assert original_data == retrieve_extracted
assert notif_extracted == retrieve_extracted
def test_complex_hotel_reservation_id_workflow(self):
"""Test a complex workflow with HotelReservationId operations."""
# Create original reservation ID data
original_data = HotelReservationIdData(
res_id_type="456",
res_id_value="COMPLEX-RESERVATION-789",
res_id_source="INTEGRATION_SYSTEM",
res_id_source_context="API_CALL"
)
# Create HotelReservationId for both types
notif_reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(original_data)
retrieve_reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(original_data)
# Extract data back from both
notif_extracted = HotelReservationIdFactory.from_notif_hotel_reservation_id(notif_reservation_id)
retrieve_extracted = HotelReservationIdFactory.from_retrieve_hotel_reservation_id(retrieve_reservation_id)
# All should be equal
assert original_data == notif_extracted
assert original_data == retrieve_extracted
assert notif_extracted == retrieve_extracted

29
test_handshake.py Normal file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""
Test the handshake functionality with the real AlpineBits sample file.
"""
import asyncio
from alpine_bits_python.alpinebits_server import AlpineBitsServer
async def main():
print("🔄 Testing AlpineBits Handshake with Sample File")
print("=" * 60)
# Create server instance
server = AlpineBitsServer()
# Read the sample handshake request
with open("AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRQ.xml", "r") as f:
ping_request_xml = f.read()
print("📤 Sending handshake request...")
# Handle the ping request
response = await server.handle_request("OTA_Ping:Handshaking", ping_request_xml, "2024-10")
print(f"\n📥 Response Status: {response.status_code}")
print(f"📄 Response XML:\n{response.xml_content}")
if __name__ == "__main__":
asyncio.run(main())

314
uv.lock generated
View File

@@ -5,16 +5,33 @@ requires-python = ">=3.13"
[[package]]
name = "alpine-bits-python-server"
version = "0.1.0"
source = { virtual = "." }
source = { editable = "." }
dependencies = [
{ name = "generateds" },
{ name = "lxml" },
{ name = "pytest" },
{ name = "ruff" },
{ name = "xsdata", extra = ["cli", "lxml", "soap"] },
{ name = "xsdata-pydantic", extra = ["cli", "lxml", "soap"] },
]
[package.metadata]
requires-dist = [
{ name = "generateds", specifier = ">=2.44.3" },
{ name = "lxml", specifier = ">=6.0.1" },
{ name = "pytest", specifier = ">=8.4.2" },
{ name = "ruff", specifier = ">=0.13.1" },
{ name = "xsdata", extras = ["cli", "lxml", "soap"], specifier = ">=25.7" },
{ name = "xsdata-pydantic", extras = ["cli", "lxml", "soap"], specifier = ">=24.5" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
]
[[package]]
@@ -57,6 +74,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 },
]
[[package]]
name = "click"
version = "8.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295 },
]
[[package]]
name = "click-default-group"
version = "1.2.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/ce/edb087fb53de63dad3b36408ca30368f438738098e668b78c87f93cd41df/click_default_group-1.2.4.tar.gz", hash = "sha256:eb3f3c99ec0d456ca6cd2a7f08f7d4e91771bef51b01bdd9580cc6450fe1251e", size = 3505 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/1a/aff8bb287a4b1400f69e09a53bd65de96aa5cee5691925b38731c67fc695/click_default_group-1.2.4-py2.py3-none-any.whl", hash = "sha256:9b60486923720e7fc61731bdb32b617039aba820e22e1c88766b1125592eaa5f", size = 4123 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "docformatter"
version = "1.7.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "untokenize" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/7b/ee08cb5fe2627ed0b6f0cc4a1c6be6c9c71de5a3e9785de8174273fc3128/docformatter-1.7.7.tar.gz", hash = "sha256:ea0e1e8867e5af468dfc3f9e947b92230a55be9ec17cd1609556387bffac7978", size = 26587 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/b4/a7ec1eaee86761a9dbfd339732b4706db3c6b65e970c12f0f56cfcce3dcf/docformatter-1.7.7-py3-none-any.whl", hash = "sha256:7af49f8a46346a77858f6651f431b882c503c2f4442c8b4524b920c863277834", size = 33525 },
]
[[package]]
name = "generateds"
version = "2.44.3"
@@ -79,6 +142,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
]
[[package]]
name = "lxml"
version = "6.0.1"
@@ -123,6 +207,120 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/44/9613f300201b8700215856e5edd056d4e58dd23368699196b58877d4408b/lxml-6.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:2834377b0145a471a654d699bdb3a2155312de492142ef5a1d426af2c60a0a31", size = 3753901 },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[package]]
name = "pydantic"
version = "2.11.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855 },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 },
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 },
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 },
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 },
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 },
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 },
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 },
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 },
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 },
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 },
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 },
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 },
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 },
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 },
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 },
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 },
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 },
]
[[package]]
name = "requests"
version = "2.32.5"
@@ -138,6 +336,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
]
[[package]]
name = "ruff"
version = "0.13.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/33/c8e89216845615d14d2d42ba2bee404e7206a8db782f33400754f3799f05/ruff-0.13.1.tar.gz", hash = "sha256:88074c3849087f153d4bb22e92243ad4c1b366d7055f98726bc19aa08dc12d51", size = 5397987 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/41/ca37e340938f45cfb8557a97a5c347e718ef34702546b174e5300dbb1f28/ruff-0.13.1-py3-none-linux_armv6l.whl", hash = "sha256:b2abff595cc3cbfa55e509d89439b5a09a6ee3c252d92020bd2de240836cf45b", size = 12304308 },
{ url = "https://files.pythonhosted.org/packages/ff/84/ba378ef4129415066c3e1c80d84e539a0d52feb250685091f874804f28af/ruff-0.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4ee9f4249bf7f8bb3984c41bfaf6a658162cdb1b22e3103eabc7dd1dc5579334", size = 12937258 },
{ url = "https://files.pythonhosted.org/packages/8d/b6/ec5e4559ae0ad955515c176910d6d7c93edcbc0ed1a3195a41179c58431d/ruff-0.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c5da4af5f6418c07d75e6f3224e08147441f5d1eac2e6ce10dcce5e616a3bae", size = 12214554 },
{ url = "https://files.pythonhosted.org/packages/70/d6/cb3e3b4f03b9b0c4d4d8f06126d34b3394f6b4d764912fe80a1300696ef6/ruff-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80524f84a01355a59a93cef98d804e2137639823bcee2931f5028e71134a954e", size = 12448181 },
{ url = "https://files.pythonhosted.org/packages/d2/ea/bf60cb46d7ade706a246cd3fb99e4cfe854efa3dfbe530d049c684da24ff/ruff-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff7f5ce8d7988767dd46a148192a14d0f48d1baea733f055d9064875c7d50389", size = 12104599 },
{ url = "https://files.pythonhosted.org/packages/2d/3e/05f72f4c3d3a69e65d55a13e1dd1ade76c106d8546e7e54501d31f1dc54a/ruff-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c55d84715061f8b05469cdc9a446aa6c7294cd4bd55e86a89e572dba14374f8c", size = 13791178 },
{ url = "https://files.pythonhosted.org/packages/81/e7/01b1fc403dd45d6cfe600725270ecc6a8f8a48a55bc6521ad820ed3ceaf8/ruff-0.13.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ac57fed932d90fa1624c946dc67a0a3388d65a7edc7d2d8e4ca7bddaa789b3b0", size = 14814474 },
{ url = "https://files.pythonhosted.org/packages/fa/92/d9e183d4ed6185a8df2ce9faa3f22e80e95b5f88d9cc3d86a6d94331da3f/ruff-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c366a71d5b4f41f86a008694f7a0d75fe409ec298685ff72dc882f882d532e36", size = 14217531 },
{ url = "https://files.pythonhosted.org/packages/3b/4a/6ddb1b11d60888be224d721e01bdd2d81faaf1720592858ab8bac3600466/ruff-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4ea9d1b5ad3e7a83ee8ebb1229c33e5fe771e833d6d3dcfca7b77d95b060d38", size = 13265267 },
{ url = "https://files.pythonhosted.org/packages/81/98/3f1d18a8d9ea33ef2ad508f0417fcb182c99b23258ec5e53d15db8289809/ruff-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0f70202996055b555d3d74b626406476cc692f37b13bac8828acff058c9966a", size = 13243120 },
{ url = "https://files.pythonhosted.org/packages/8d/86/b6ce62ce9c12765fa6c65078d1938d2490b2b1d9273d0de384952b43c490/ruff-0.13.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f8cff7a105dad631085d9505b491db33848007d6b487c3c1979dd8d9b2963783", size = 13443084 },
{ url = "https://files.pythonhosted.org/packages/a1/6e/af7943466a41338d04503fb5a81b2fd07251bd272f546622e5b1599a7976/ruff-0.13.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9761e84255443316a258dd7dfbd9bfb59c756e52237ed42494917b2577697c6a", size = 12295105 },
{ url = "https://files.pythonhosted.org/packages/3f/97/0249b9a24f0f3ebd12f007e81c87cec6d311de566885e9309fcbac5b24cc/ruff-0.13.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3d376a88c3102ef228b102211ef4a6d13df330cb0f5ca56fdac04ccec2a99700", size = 12072284 },
{ url = "https://files.pythonhosted.org/packages/f6/85/0b64693b2c99d62ae65236ef74508ba39c3febd01466ef7f354885e5050c/ruff-0.13.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cbefd60082b517a82c6ec8836989775ac05f8991715d228b3c1d86ccc7df7dae", size = 12970314 },
{ url = "https://files.pythonhosted.org/packages/96/fc/342e9f28179915d28b3747b7654f932ca472afbf7090fc0c4011e802f494/ruff-0.13.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd16b9a5a499fe73f3c2ef09a7885cb1d97058614d601809d37c422ed1525317", size = 13422360 },
{ url = "https://files.pythonhosted.org/packages/37/54/6177a0dc10bce6f43e392a2192e6018755473283d0cf43cc7e6afc182aea/ruff-0.13.1-py3-none-win32.whl", hash = "sha256:55e9efa692d7cb18580279f1fbb525146adc401f40735edf0aaeabd93099f9a0", size = 12178448 },
{ url = "https://files.pythonhosted.org/packages/64/51/c6a3a33d9938007b8bdc8ca852ecc8d810a407fb513ab08e34af12dc7c24/ruff-0.13.1-py3-none-win_amd64.whl", hash = "sha256:3a3fb595287ee556de947183489f636b9f76a72f0fa9c028bdcabf5bab2cc5e5", size = 13286458 },
{ url = "https://files.pythonhosted.org/packages/fd/04/afc078a12cf68592345b1e2d6ecdff837d286bac023d7a22c54c7a698c5b/ruff-0.13.1-py3-none-win_arm64.whl", hash = "sha256:c0bae9ffd92d54e03c2bf266f466da0a65e145f298ee5b5846ed435f6a00518a", size = 12437893 },
]
[[package]]
name = "six"
version = "1.17.0"
@@ -147,6 +371,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
]
[[package]]
name = "toposort"
version = "1.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/69/19/8e955d90985ecbd3b9adb2a759753a6840da2dff3c569d412b2c9217678b/toposort-1.10.tar.gz", hash = "sha256:bfbb479c53d0a696ea7402601f4e693c97b0367837c8898bc6471adfca37a6bd", size = 11132 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/17/57b444fd314d5e1593350b9a31d000e7411ba8e17ce12dc7ad54ca76b810/toposort-1.10-py3-none-any.whl", hash = "sha256:cbdbc0d0bee4d2695ab2ceec97fe0679e9c10eab4b2a87a9372b929e70563a87", size = 8500 },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
]
[[package]]
name = "typing-inspection"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552 },
]
[[package]]
name = "untokenize"
version = "0.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/46/e7cea8159199096e1df52da20a57a6665da80c37fb8aeb848a3e47442c32/untokenize-0.1.1.tar.gz", hash = "sha256:3865dbbbb8efb4bb5eaa72f1be7f3e0be00ea8b7f125c69cbd1f5fda926f37a2", size = 3099 }
[[package]]
name = "urllib3"
version = "2.5.0"
@@ -155,3 +415,55 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 },
]
[[package]]
name = "xsdata"
version = "25.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/cf/d393286e40f7574c5d662a3ceefcf8e4cd65e73af6e54db0585c5b17c541/xsdata-25.7.tar.gz", hash = "sha256:1291ef759f4663baadb86562be4c25ebfc0003ca0debae3042b0067663f0c548", size = 345469 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/10/c866e7b0fd57c92a4d5676884b81383005d81f8d7f07f1ac17e9c0ab3643/xsdata-25.7-py3-none-any.whl", hash = "sha256:d50b8c39389fd2b7283767a68a80cbf3bc51a3ede9cc3fefb30e84a52c999a9d", size = 234469 },
]
[package.optional-dependencies]
cli = [
{ name = "click" },
{ name = "click-default-group" },
{ name = "docformatter" },
{ name = "jinja2" },
{ name = "ruff" },
{ name = "toposort" },
]
lxml = [
{ name = "lxml" },
]
soap = [
{ name = "requests" },
]
[[package]]
name = "xsdata-pydantic"
version = "24.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "xsdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/85/5e/6bc728d70460d9ad3982d05c3765179e3584fee6fa523d57b242e6e4c50f/xsdata_pydantic-24.5.tar.gz", hash = "sha256:e3c8758133195657ece578537eda6c7ebd8419f77abf6b90fd4ced96e348129b", size = 18763 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/7b/785fe71aa1138d7380ab3926cbb9571896d56544901c320953ff8a586926/xsdata_pydantic-24.5-py3-none-any.whl", hash = "sha256:bb6da7d3445d655640096c65c1b11037153b19df533da89553f24247ef352cd0", size = 8891 },
]
[package.optional-dependencies]
cli = [
{ name = "xsdata", extra = ["cli"] },
]
lxml = [
{ name = "lxml" },
]
soap = [
{ name = "requests" },
]