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.
@@ -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)