hostel-protocol-python 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hostel_protocol_python-0.1.0/.github/workflows/ci.yml +44 -0
- hostel_protocol_python-0.1.0/.github/workflows/publish.yml +39 -0
- hostel_protocol_python-0.1.0/.gitignore +41 -0
- hostel_protocol_python-0.1.0/Makefile +18 -0
- hostel_protocol_python-0.1.0/PKG-INFO +16 -0
- hostel_protocol_python-0.1.0/pyproject.toml +54 -0
- hostel_protocol_python-0.1.0/src/hostel_protocol/__init__.py +76 -0
- hostel_protocol_python-0.1.0/src/hostel_protocol/converter.py +368 -0
- hostel_protocol_python-0.1.0/src/hostel_protocol/models.py +267 -0
- hostel_protocol_python-0.1.0/tests/__init__.py +0 -0
- hostel_protocol_python-0.1.0/tests/test_converter.py +428 -0
- hostel_protocol_python-0.1.0/tests/test_models.py +278 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
ci:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.12", "3.13"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Install uv
|
|
20
|
+
uses: astral-sh/setup-uv@v4
|
|
21
|
+
|
|
22
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
23
|
+
run: uv python install ${{ matrix.python-version }}
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: uv sync --all-extras
|
|
27
|
+
|
|
28
|
+
- name: Lint
|
|
29
|
+
run: |
|
|
30
|
+
uv run ruff check src/ tests/
|
|
31
|
+
uv run black --check src/ tests/
|
|
32
|
+
|
|
33
|
+
- name: Typecheck
|
|
34
|
+
run: uv run mypy src/
|
|
35
|
+
|
|
36
|
+
- name: Test
|
|
37
|
+
run: uv run pytest --cov-report=xml
|
|
38
|
+
|
|
39
|
+
- name: Upload coverage
|
|
40
|
+
if: matrix.python-version == '3.12'
|
|
41
|
+
uses: actions/upload-artifact@v4
|
|
42
|
+
with:
|
|
43
|
+
name: coverage-report
|
|
44
|
+
path: coverage.xml
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
permissions:
|
|
4
|
+
actions: write # Necessary to cancel workflow executions
|
|
5
|
+
checks: write # Necessary to write reports
|
|
6
|
+
pull-requests: write # Necessary to comment on PRs
|
|
7
|
+
contents: read
|
|
8
|
+
packages: write
|
|
9
|
+
|
|
10
|
+
on:
|
|
11
|
+
push:
|
|
12
|
+
tags: 'v*'
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
publish:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
environment: pypi
|
|
18
|
+
permissions:
|
|
19
|
+
id-token: write
|
|
20
|
+
actions: write # Necessary to cancel workflow executions
|
|
21
|
+
checks: write # Necessary to write reports
|
|
22
|
+
pull-requests: write # Necessary to comment on PRs
|
|
23
|
+
contents: read
|
|
24
|
+
packages: write
|
|
25
|
+
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
|
|
29
|
+
- name: Install uv
|
|
30
|
+
uses: astral-sh/setup-uv@v4
|
|
31
|
+
|
|
32
|
+
- name: Set up Python
|
|
33
|
+
run: uv python install 3.12
|
|
34
|
+
|
|
35
|
+
- name: Build package
|
|
36
|
+
run: uv build
|
|
37
|
+
|
|
38
|
+
- name: Publish to PyPI
|
|
39
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
*.egg-info/
|
|
7
|
+
*.egg
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
.eggs/
|
|
11
|
+
|
|
12
|
+
# Virtual environments
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
ENV/
|
|
16
|
+
|
|
17
|
+
# IDE
|
|
18
|
+
.idea/
|
|
19
|
+
.vscode/
|
|
20
|
+
*.swp
|
|
21
|
+
*.swo
|
|
22
|
+
*~
|
|
23
|
+
|
|
24
|
+
# Testing / Coverage
|
|
25
|
+
.coverage
|
|
26
|
+
.coverage.*
|
|
27
|
+
htmlcov/
|
|
28
|
+
.pytest_cache/
|
|
29
|
+
|
|
30
|
+
# mypy
|
|
31
|
+
.mypy_cache/
|
|
32
|
+
|
|
33
|
+
# ruff
|
|
34
|
+
.ruff_cache/
|
|
35
|
+
|
|
36
|
+
# OS
|
|
37
|
+
.DS_Store
|
|
38
|
+
Thumbs.db
|
|
39
|
+
|
|
40
|
+
# uv
|
|
41
|
+
uv.lock
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
.PHONY: install format lint typecheck test
|
|
2
|
+
|
|
3
|
+
install:
|
|
4
|
+
uv sync --all-extras
|
|
5
|
+
|
|
6
|
+
format:
|
|
7
|
+
uv run black src/ tests/
|
|
8
|
+
uv run ruff check --fix src/ tests/
|
|
9
|
+
|
|
10
|
+
lint:
|
|
11
|
+
uv run ruff check src/ tests/
|
|
12
|
+
uv run black --check src/ tests/
|
|
13
|
+
|
|
14
|
+
typecheck:
|
|
15
|
+
uv run mypy src/
|
|
16
|
+
|
|
17
|
+
test:
|
|
18
|
+
uv run pytest
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hostel-protocol-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pydantic convenience layer for the Hostel protocol
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: hostel-protocol>=0.1.0
|
|
7
|
+
Requires-Dist: protobuf>=7.0
|
|
8
|
+
Requires-Dist: pydantic>=2.5.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: black; extra == 'dev'
|
|
11
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
15
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
16
|
+
Requires-Dist: types-protobuf; extra == 'dev'
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hostel-protocol-python"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Pydantic convenience layer for the Hostel protocol"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"hostel-protocol>=0.1.0",
|
|
12
|
+
"pydantic>=2.5.0",
|
|
13
|
+
"protobuf>=7.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest",
|
|
19
|
+
"pytest-cov",
|
|
20
|
+
"pytest-asyncio",
|
|
21
|
+
"black",
|
|
22
|
+
"ruff",
|
|
23
|
+
"mypy",
|
|
24
|
+
"types-protobuf",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/hostel_protocol"]
|
|
29
|
+
|
|
30
|
+
[tool.black]
|
|
31
|
+
line-length = 120
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
line-length = 120
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
select = ["E", "F", "I", "W"]
|
|
38
|
+
|
|
39
|
+
[tool.mypy]
|
|
40
|
+
python_version = "3.12"
|
|
41
|
+
strict = true
|
|
42
|
+
plugins = ["pydantic.mypy"]
|
|
43
|
+
|
|
44
|
+
[[tool.mypy.overrides]]
|
|
45
|
+
module = "hostel.protocol.v1.*"
|
|
46
|
+
ignore_missing_imports = true
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
addopts = "--cov=hostel_protocol --cov-report=term-missing"
|
|
51
|
+
|
|
52
|
+
[tool.uv]
|
|
53
|
+
constraint-dependencies = ["protobuf>=7.0"]
|
|
54
|
+
override-dependencies = ["protobuf>=7.0"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""hostel_protocol — Pydantic convenience layer for the Hostel protocol."""
|
|
2
|
+
|
|
3
|
+
from hostel_protocol.converter import proto_to_pydantic, pydantic_to_proto
|
|
4
|
+
from hostel_protocol.models import (
|
|
5
|
+
AIChatMessage,
|
|
6
|
+
ChatMessage,
|
|
7
|
+
ChatRequest,
|
|
8
|
+
ChatResponse,
|
|
9
|
+
CreateComponentRequest,
|
|
10
|
+
CreateComponentResponse,
|
|
11
|
+
CreateTaskRequest,
|
|
12
|
+
CreateTaskResponse,
|
|
13
|
+
DeleteComponentRequest,
|
|
14
|
+
DeleteComponentResponse,
|
|
15
|
+
DeleteTaskRequest,
|
|
16
|
+
DeleteTaskResponse,
|
|
17
|
+
GetComponentRequest,
|
|
18
|
+
GetComponentResponse,
|
|
19
|
+
GetTaskRequest,
|
|
20
|
+
GetTaskResponse,
|
|
21
|
+
HostelMessage,
|
|
22
|
+
HumanChatMessage,
|
|
23
|
+
ListAgentsRequest,
|
|
24
|
+
ListAgentsResponse,
|
|
25
|
+
ListComponentsRequest,
|
|
26
|
+
ListComponentsResponse,
|
|
27
|
+
ListTasksRequest,
|
|
28
|
+
ListTasksResponse,
|
|
29
|
+
TaskData,
|
|
30
|
+
ToolChatMessage,
|
|
31
|
+
UpdateComponentRequest,
|
|
32
|
+
UpdateComponentResponse,
|
|
33
|
+
UpdateTaskRequest,
|
|
34
|
+
UpdateTaskResponse,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
# Converter
|
|
39
|
+
"pydantic_to_proto",
|
|
40
|
+
"proto_to_pydantic",
|
|
41
|
+
# Chat
|
|
42
|
+
"HumanChatMessage",
|
|
43
|
+
"AIChatMessage",
|
|
44
|
+
"ToolChatMessage",
|
|
45
|
+
"ChatMessage",
|
|
46
|
+
"ChatRequest",
|
|
47
|
+
"ChatResponse",
|
|
48
|
+
# Agent
|
|
49
|
+
"ListAgentsRequest",
|
|
50
|
+
"ListAgentsResponse",
|
|
51
|
+
# Component
|
|
52
|
+
"CreateComponentRequest",
|
|
53
|
+
"CreateComponentResponse",
|
|
54
|
+
"GetComponentRequest",
|
|
55
|
+
"GetComponentResponse",
|
|
56
|
+
"ListComponentsRequest",
|
|
57
|
+
"ListComponentsResponse",
|
|
58
|
+
"UpdateComponentRequest",
|
|
59
|
+
"UpdateComponentResponse",
|
|
60
|
+
"DeleteComponentRequest",
|
|
61
|
+
"DeleteComponentResponse",
|
|
62
|
+
# Task
|
|
63
|
+
"TaskData",
|
|
64
|
+
"CreateTaskRequest",
|
|
65
|
+
"CreateTaskResponse",
|
|
66
|
+
"ListTasksRequest",
|
|
67
|
+
"ListTasksResponse",
|
|
68
|
+
"GetTaskRequest",
|
|
69
|
+
"GetTaskResponse",
|
|
70
|
+
"UpdateTaskRequest",
|
|
71
|
+
"UpdateTaskResponse",
|
|
72
|
+
"DeleteTaskRequest",
|
|
73
|
+
"DeleteTaskResponse",
|
|
74
|
+
# Envelope
|
|
75
|
+
"HostelMessage",
|
|
76
|
+
]
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Bidirectional converter between Pydantic models and Protobuf messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from google.protobuf import struct_pb2, wrappers_pb2
|
|
8
|
+
from google.protobuf.json_format import MessageToDict, ParseDict
|
|
9
|
+
from google.protobuf.message import Message
|
|
10
|
+
from hostel.protocol.v1 import (
|
|
11
|
+
agent_pb2,
|
|
12
|
+
chat_pb2,
|
|
13
|
+
component_pb2,
|
|
14
|
+
message_pb2,
|
|
15
|
+
task_pb2,
|
|
16
|
+
)
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from hostel_protocol import models
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Registry: Pydantic model class <-> Protobuf message class
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
_PYDANTIC_TO_PROTO: dict[type[BaseModel], type[Message]] = {
|
|
26
|
+
# Chat
|
|
27
|
+
models.HumanChatMessage: chat_pb2.HumanChatMessage,
|
|
28
|
+
models.AIChatMessage: chat_pb2.AIChatMessage,
|
|
29
|
+
models.ToolChatMessage: chat_pb2.ToolChatMessage,
|
|
30
|
+
models.ChatMessage: chat_pb2.ChatMessage,
|
|
31
|
+
models.ChatRequest: chat_pb2.ChatRequest,
|
|
32
|
+
models.ChatResponse: chat_pb2.ChatResponse,
|
|
33
|
+
# Agent
|
|
34
|
+
models.ListAgentsRequest: agent_pb2.ListAgentsRequest,
|
|
35
|
+
models.ListAgentsResponse: agent_pb2.ListAgentsResponse,
|
|
36
|
+
# Component
|
|
37
|
+
models.CreateComponentRequest: component_pb2.CreateComponentRequest,
|
|
38
|
+
models.CreateComponentResponse: component_pb2.CreateComponentResponse,
|
|
39
|
+
models.GetComponentRequest: component_pb2.GetComponentRequest,
|
|
40
|
+
models.GetComponentResponse: component_pb2.GetComponentResponse,
|
|
41
|
+
models.ListComponentsRequest: component_pb2.ListComponentsRequest,
|
|
42
|
+
models.ListComponentsResponse: component_pb2.ListComponentsResponse,
|
|
43
|
+
models.UpdateComponentRequest: component_pb2.UpdateComponentRequest,
|
|
44
|
+
models.UpdateComponentResponse: component_pb2.UpdateComponentResponse,
|
|
45
|
+
models.DeleteComponentRequest: component_pb2.DeleteComponentRequest,
|
|
46
|
+
models.DeleteComponentResponse: component_pb2.DeleteComponentResponse,
|
|
47
|
+
# Task
|
|
48
|
+
models.TaskData: task_pb2.TaskData,
|
|
49
|
+
models.CreateTaskRequest: task_pb2.CreateTaskRequest,
|
|
50
|
+
models.CreateTaskResponse: task_pb2.CreateTaskResponse,
|
|
51
|
+
models.ListTasksRequest: task_pb2.ListTasksRequest,
|
|
52
|
+
models.ListTasksResponse: task_pb2.ListTasksResponse,
|
|
53
|
+
models.GetTaskRequest: task_pb2.GetTaskRequest,
|
|
54
|
+
models.GetTaskResponse: task_pb2.GetTaskResponse,
|
|
55
|
+
models.UpdateTaskRequest: task_pb2.UpdateTaskRequest,
|
|
56
|
+
models.UpdateTaskResponse: task_pb2.UpdateTaskResponse,
|
|
57
|
+
models.DeleteTaskRequest: task_pb2.DeleteTaskRequest,
|
|
58
|
+
models.DeleteTaskResponse: task_pb2.DeleteTaskResponse,
|
|
59
|
+
# Envelope
|
|
60
|
+
models.HostelMessage: message_pb2.HostelMessage,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_PROTO_TO_PYDANTIC: dict[type[Message], type[BaseModel]] = {v: k for k, v in _PYDANTIC_TO_PROTO.items()}
|
|
64
|
+
|
|
65
|
+
# Fields in each proto that use google.protobuf.StringValue (nullable strings)
|
|
66
|
+
_STRING_VALUE_FIELDS: dict[type[Message], set[str]] = {
|
|
67
|
+
task_pb2.TaskData: {"webhook_url", "status", "response", "created_at", "updated_at", "executed_at"},
|
|
68
|
+
task_pb2.CreateTaskRequest: {"webhook_url"},
|
|
69
|
+
task_pb2.UpdateTaskRequest: {"agent_name", "prompt", "start_datetime", "webhook_url", "status"},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Fields that hold google.protobuf.Struct (dict[str, Any])
|
|
73
|
+
_STRUCT_FIELDS: dict[type[Message], set[str]] = {
|
|
74
|
+
chat_pb2.ChatResponse: {"content"},
|
|
75
|
+
component_pb2.CreateComponentRequest: {"data"},
|
|
76
|
+
component_pb2.GetComponentResponse: {"data"},
|
|
77
|
+
component_pb2.UpdateComponentRequest: {"data"},
|
|
78
|
+
message_pb2.HostelMessage: {"meta", "system_payload", "generic_payload"},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Fields that hold google.protobuf.Value
|
|
82
|
+
_VALUE_FIELDS: dict[type[Message], set[str]] = {
|
|
83
|
+
chat_pb2.ChatResponse: {"content"},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Fields that hold repeated sub-messages
|
|
87
|
+
_REPEATED_MSG_FIELDS: dict[type[Message], dict[str, type[Message]]] = {
|
|
88
|
+
agent_pb2.ListAgentsResponse: {"agents": struct_pb2.Struct},
|
|
89
|
+
chat_pb2.ChatRequest: {"messages": chat_pb2.ChatMessage},
|
|
90
|
+
component_pb2.ListComponentsResponse: {"components": struct_pb2.Struct},
|
|
91
|
+
task_pb2.ListTasksResponse: {"tasks": task_pb2.TaskData},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Fields that hold a single sub-message (non-oneof)
|
|
95
|
+
_SUB_MSG_FIELDS: dict[type[Message], dict[str, type[Message]]] = {
|
|
96
|
+
task_pb2.CreateTaskResponse: {"task": task_pb2.TaskData},
|
|
97
|
+
task_pb2.GetTaskResponse: {"task": task_pb2.TaskData},
|
|
98
|
+
task_pb2.UpdateTaskResponse: {"task": task_pb2.TaskData},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# oneof groups: proto class -> {field_name: proto sub-message class}
|
|
102
|
+
_ONEOF_FIELDS: dict[type[Message], dict[str, type[Message]]] = {
|
|
103
|
+
chat_pb2.ChatMessage: {
|
|
104
|
+
"human": chat_pb2.HumanChatMessage,
|
|
105
|
+
"ai": chat_pb2.AIChatMessage,
|
|
106
|
+
"tool": chat_pb2.ToolChatMessage,
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# HostelMessage payload oneof (special handling due to breadth)
|
|
111
|
+
_HOSTEL_MESSAGE_PAYLOAD_FIELDS: dict[str, type[Message]] = {
|
|
112
|
+
"agent_list_request": agent_pb2.ListAgentsRequest,
|
|
113
|
+
"agent_list_response": agent_pb2.ListAgentsResponse,
|
|
114
|
+
"system_payload": struct_pb2.Struct,
|
|
115
|
+
"chat_request": chat_pb2.ChatRequest,
|
|
116
|
+
"chat_response_chunk": chat_pb2.ChatResponse,
|
|
117
|
+
"task_create": task_pb2.CreateTaskRequest,
|
|
118
|
+
"task_create_response": task_pb2.CreateTaskResponse,
|
|
119
|
+
"task_list": task_pb2.ListTasksRequest,
|
|
120
|
+
"task_list_response": task_pb2.ListTasksResponse,
|
|
121
|
+
"task_get": task_pb2.GetTaskRequest,
|
|
122
|
+
"task_get_response": task_pb2.GetTaskResponse,
|
|
123
|
+
"task_update": task_pb2.UpdateTaskRequest,
|
|
124
|
+
"task_update_response": task_pb2.UpdateTaskResponse,
|
|
125
|
+
"task_delete": task_pb2.DeleteTaskRequest,
|
|
126
|
+
"task_delete_response": task_pb2.DeleteTaskResponse,
|
|
127
|
+
"component_create": component_pb2.CreateComponentRequest,
|
|
128
|
+
"component_create_response": component_pb2.CreateComponentResponse,
|
|
129
|
+
"component_get": component_pb2.GetComponentRequest,
|
|
130
|
+
"component_get_response": component_pb2.GetComponentResponse,
|
|
131
|
+
"component_list": component_pb2.ListComponentsRequest,
|
|
132
|
+
"component_list_response": component_pb2.ListComponentsResponse,
|
|
133
|
+
"component_update": component_pb2.UpdateComponentRequest,
|
|
134
|
+
"component_update_response": component_pb2.UpdateComponentResponse,
|
|
135
|
+
"component_delete": component_pb2.DeleteComponentRequest,
|
|
136
|
+
"component_delete_response": component_pb2.DeleteComponentResponse,
|
|
137
|
+
"generic_payload": struct_pb2.Struct,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# Helpers
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _struct_to_dict(s: struct_pb2.Struct) -> dict[str, Any]:
|
|
147
|
+
return dict(MessageToDict(s))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _dict_to_struct(d: dict[str, Any]) -> struct_pb2.Struct:
|
|
151
|
+
s = struct_pb2.Struct()
|
|
152
|
+
s.update(d)
|
|
153
|
+
return s
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _value_to_python(v: struct_pb2.Value) -> Any:
|
|
157
|
+
return MessageToDict(v)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _python_to_value(v: Any) -> struct_pb2.Value:
|
|
161
|
+
val = struct_pb2.Value()
|
|
162
|
+
if v is None:
|
|
163
|
+
val.null_value = 0 # type: ignore[assignment]
|
|
164
|
+
elif isinstance(v, bool):
|
|
165
|
+
val.bool_value = v
|
|
166
|
+
elif isinstance(v, (int, float)):
|
|
167
|
+
val.number_value = float(v)
|
|
168
|
+
elif isinstance(v, str):
|
|
169
|
+
val.string_value = v
|
|
170
|
+
elif isinstance(v, dict):
|
|
171
|
+
ParseDict(v, val.struct_value)
|
|
172
|
+
elif isinstance(v, list):
|
|
173
|
+
for item in v:
|
|
174
|
+
val.list_value.values.append(_python_to_value(item))
|
|
175
|
+
return val
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Pydantic → Protobuf
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def pydantic_to_proto(model: BaseModel) -> Message:
|
|
184
|
+
"""Convert a Pydantic model instance to the corresponding Protobuf message."""
|
|
185
|
+
proto_cls = _PYDANTIC_TO_PROTO.get(type(model))
|
|
186
|
+
if proto_cls is None:
|
|
187
|
+
raise TypeError(f"No protobuf mapping registered for {type(model).__name__}")
|
|
188
|
+
return _pydantic_to_proto_inner(model, proto_cls)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _pydantic_to_proto_inner(model: BaseModel, proto_cls: type[Message]) -> Message:
|
|
192
|
+
proto = proto_cls()
|
|
193
|
+
string_value_fields = _STRING_VALUE_FIELDS.get(proto_cls, set())
|
|
194
|
+
struct_fields = _STRUCT_FIELDS.get(proto_cls, set())
|
|
195
|
+
value_fields = _VALUE_FIELDS.get(proto_cls, set())
|
|
196
|
+
repeated_fields = _REPEATED_MSG_FIELDS.get(proto_cls, {})
|
|
197
|
+
sub_msg_fields = _SUB_MSG_FIELDS.get(proto_cls, {})
|
|
198
|
+
oneof_fields = _ONEOF_FIELDS.get(proto_cls, {})
|
|
199
|
+
|
|
200
|
+
# Special handling for HostelMessage payload oneof
|
|
201
|
+
is_envelope = proto_cls is message_pb2.HostelMessage
|
|
202
|
+
|
|
203
|
+
for field_name, value in model:
|
|
204
|
+
if value is None:
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
# StringValue wrapper fields
|
|
208
|
+
if field_name in string_value_fields:
|
|
209
|
+
wrapper = wrappers_pb2.StringValue(value=value)
|
|
210
|
+
getattr(proto, field_name).CopyFrom(wrapper)
|
|
211
|
+
|
|
212
|
+
# google.protobuf.Value fields
|
|
213
|
+
elif field_name in value_fields:
|
|
214
|
+
getattr(proto, field_name).CopyFrom(_python_to_value(value))
|
|
215
|
+
|
|
216
|
+
# Struct fields (dict)
|
|
217
|
+
elif field_name in struct_fields:
|
|
218
|
+
if isinstance(value, dict):
|
|
219
|
+
getattr(proto, field_name).CopyFrom(_dict_to_struct(value))
|
|
220
|
+
# repeated Struct handled below
|
|
221
|
+
|
|
222
|
+
# Repeated Struct (list[dict])
|
|
223
|
+
elif field_name in repeated_fields and repeated_fields[field_name] is struct_pb2.Struct:
|
|
224
|
+
for item in value:
|
|
225
|
+
getattr(proto, field_name).append(_dict_to_struct(item))
|
|
226
|
+
|
|
227
|
+
# Repeated sub-messages
|
|
228
|
+
elif field_name in repeated_fields:
|
|
229
|
+
child_proto_cls = repeated_fields[field_name]
|
|
230
|
+
child_pydantic_cls = _PROTO_TO_PYDANTIC.get(child_proto_cls)
|
|
231
|
+
for item in value:
|
|
232
|
+
if child_pydantic_cls and isinstance(item, child_pydantic_cls):
|
|
233
|
+
getattr(proto, field_name).append(_pydantic_to_proto_inner(item, child_proto_cls))
|
|
234
|
+
|
|
235
|
+
# Single sub-message fields
|
|
236
|
+
elif field_name in sub_msg_fields:
|
|
237
|
+
child_proto_cls = sub_msg_fields[field_name]
|
|
238
|
+
getattr(proto, field_name).CopyFrom(_pydantic_to_proto_inner(value, child_proto_cls))
|
|
239
|
+
|
|
240
|
+
# Oneof sub-message fields
|
|
241
|
+
elif field_name in oneof_fields:
|
|
242
|
+
child_proto_cls = oneof_fields[field_name]
|
|
243
|
+
getattr(proto, field_name).CopyFrom(_pydantic_to_proto_inner(value, child_proto_cls))
|
|
244
|
+
|
|
245
|
+
# HostelMessage payload oneof
|
|
246
|
+
elif is_envelope and field_name in _HOSTEL_MESSAGE_PAYLOAD_FIELDS:
|
|
247
|
+
child_proto_cls = _HOSTEL_MESSAGE_PAYLOAD_FIELDS[field_name]
|
|
248
|
+
if child_proto_cls is struct_pb2.Struct:
|
|
249
|
+
getattr(proto, field_name).CopyFrom(_dict_to_struct(value))
|
|
250
|
+
else:
|
|
251
|
+
child_pydantic_cls = _PROTO_TO_PYDANTIC.get(child_proto_cls)
|
|
252
|
+
if child_pydantic_cls and isinstance(value, child_pydantic_cls):
|
|
253
|
+
getattr(proto, field_name).CopyFrom(_pydantic_to_proto_inner(value, child_proto_cls))
|
|
254
|
+
|
|
255
|
+
# Scalar fields
|
|
256
|
+
else:
|
|
257
|
+
setattr(proto, field_name, value)
|
|
258
|
+
|
|
259
|
+
return proto
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Protobuf → Pydantic
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def proto_to_pydantic(proto: Message) -> BaseModel:
|
|
268
|
+
"""Convert a Protobuf message to the corresponding Pydantic model."""
|
|
269
|
+
pydantic_cls = _PROTO_TO_PYDANTIC.get(type(proto))
|
|
270
|
+
if pydantic_cls is None:
|
|
271
|
+
raise TypeError(f"No pydantic mapping registered for {type(proto).__name__}")
|
|
272
|
+
return _proto_to_pydantic_inner(proto, pydantic_cls)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _proto_to_pydantic_inner(proto: Message, pydantic_cls: type[BaseModel]) -> BaseModel:
|
|
276
|
+
proto_cls = type(proto)
|
|
277
|
+
string_value_fields = _STRING_VALUE_FIELDS.get(proto_cls, set())
|
|
278
|
+
struct_fields = _STRUCT_FIELDS.get(proto_cls, set())
|
|
279
|
+
value_fields = _VALUE_FIELDS.get(proto_cls, set())
|
|
280
|
+
repeated_fields = _REPEATED_MSG_FIELDS.get(proto_cls, {})
|
|
281
|
+
sub_msg_fields = _SUB_MSG_FIELDS.get(proto_cls, {})
|
|
282
|
+
oneof_fields = _ONEOF_FIELDS.get(proto_cls, {})
|
|
283
|
+
|
|
284
|
+
is_envelope = proto_cls is message_pb2.HostelMessage
|
|
285
|
+
|
|
286
|
+
kwargs: dict[str, Any] = {}
|
|
287
|
+
|
|
288
|
+
for field_desc in proto.DESCRIPTOR.fields:
|
|
289
|
+
field_name = field_desc.name
|
|
290
|
+
|
|
291
|
+
# StringValue wrapper fields
|
|
292
|
+
if field_name in string_value_fields:
|
|
293
|
+
if proto.HasField(field_name):
|
|
294
|
+
kwargs[field_name] = getattr(proto, field_name).value
|
|
295
|
+
else:
|
|
296
|
+
kwargs[field_name] = None
|
|
297
|
+
|
|
298
|
+
# google.protobuf.Value fields
|
|
299
|
+
elif field_name in value_fields:
|
|
300
|
+
if proto.HasField(field_name):
|
|
301
|
+
kwargs[field_name] = _value_to_python(getattr(proto, field_name))
|
|
302
|
+
else:
|
|
303
|
+
kwargs[field_name] = None
|
|
304
|
+
|
|
305
|
+
# Struct fields (dict)
|
|
306
|
+
elif field_name in struct_fields and field_name not in repeated_fields:
|
|
307
|
+
if is_envelope and field_name in {"system_payload", "generic_payload"}:
|
|
308
|
+
# These are oneof payload members
|
|
309
|
+
if proto.HasField(field_name):
|
|
310
|
+
kwargs[field_name] = _struct_to_dict(getattr(proto, field_name))
|
|
311
|
+
else:
|
|
312
|
+
kwargs[field_name] = None
|
|
313
|
+
elif proto.HasField(field_name):
|
|
314
|
+
kwargs[field_name] = _struct_to_dict(getattr(proto, field_name))
|
|
315
|
+
else:
|
|
316
|
+
kwargs[field_name] = {}
|
|
317
|
+
|
|
318
|
+
# Repeated Struct (list[dict])
|
|
319
|
+
elif field_name in repeated_fields and repeated_fields[field_name] is struct_pb2.Struct:
|
|
320
|
+
kwargs[field_name] = [_struct_to_dict(item) for item in getattr(proto, field_name)]
|
|
321
|
+
|
|
322
|
+
# Repeated sub-messages
|
|
323
|
+
elif field_name in repeated_fields:
|
|
324
|
+
child_proto_cls = repeated_fields[field_name]
|
|
325
|
+
child_pydantic_cls = _PROTO_TO_PYDANTIC.get(child_proto_cls)
|
|
326
|
+
if child_pydantic_cls:
|
|
327
|
+
kwargs[field_name] = [
|
|
328
|
+
_proto_to_pydantic_inner(item, child_pydantic_cls) for item in getattr(proto, field_name)
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
# Single sub-message fields
|
|
332
|
+
elif field_name in sub_msg_fields:
|
|
333
|
+
if proto.HasField(field_name):
|
|
334
|
+
child_proto_cls = sub_msg_fields[field_name]
|
|
335
|
+
child_pydantic_cls = _PROTO_TO_PYDANTIC.get(child_proto_cls)
|
|
336
|
+
if child_pydantic_cls:
|
|
337
|
+
kwargs[field_name] = _proto_to_pydantic_inner(getattr(proto, field_name), child_pydantic_cls)
|
|
338
|
+
else:
|
|
339
|
+
kwargs[field_name] = None
|
|
340
|
+
|
|
341
|
+
# Oneof sub-message fields
|
|
342
|
+
elif field_name in oneof_fields:
|
|
343
|
+
if proto.HasField(field_name):
|
|
344
|
+
child_proto_cls = oneof_fields[field_name]
|
|
345
|
+
child_pydantic_cls = _PROTO_TO_PYDANTIC.get(child_proto_cls)
|
|
346
|
+
if child_pydantic_cls:
|
|
347
|
+
kwargs[field_name] = _proto_to_pydantic_inner(getattr(proto, field_name), child_pydantic_cls)
|
|
348
|
+
else:
|
|
349
|
+
kwargs[field_name] = None
|
|
350
|
+
|
|
351
|
+
# HostelMessage payload oneof
|
|
352
|
+
elif is_envelope and field_name in _HOSTEL_MESSAGE_PAYLOAD_FIELDS:
|
|
353
|
+
if proto.HasField(field_name):
|
|
354
|
+
child_proto_cls = _HOSTEL_MESSAGE_PAYLOAD_FIELDS[field_name]
|
|
355
|
+
if child_proto_cls is struct_pb2.Struct:
|
|
356
|
+
kwargs[field_name] = _struct_to_dict(getattr(proto, field_name))
|
|
357
|
+
else:
|
|
358
|
+
child_pydantic_cls = _PROTO_TO_PYDANTIC.get(child_proto_cls)
|
|
359
|
+
if child_pydantic_cls:
|
|
360
|
+
kwargs[field_name] = _proto_to_pydantic_inner(getattr(proto, field_name), child_pydantic_cls)
|
|
361
|
+
else:
|
|
362
|
+
kwargs[field_name] = None
|
|
363
|
+
|
|
364
|
+
# Scalar fields
|
|
365
|
+
else:
|
|
366
|
+
kwargs[field_name] = getattr(proto, field_name)
|
|
367
|
+
|
|
368
|
+
return pydantic_cls(**kwargs)
|