omnibase_infra 0.2.5__py3-none-any.whl → 0.2.7__py3-none-any.whl

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.
Files changed (139) hide show
  1. omnibase_infra/constants_topic_patterns.py +26 -0
  2. omnibase_infra/enums/__init__.py +3 -0
  3. omnibase_infra/enums/enum_consumer_group_purpose.py +92 -0
  4. omnibase_infra/enums/enum_handler_source_mode.py +16 -2
  5. omnibase_infra/errors/__init__.py +4 -0
  6. omnibase_infra/errors/error_binding_resolution.py +128 -0
  7. omnibase_infra/event_bus/configs/kafka_event_bus_config.yaml +0 -2
  8. omnibase_infra/event_bus/event_bus_inmemory.py +64 -10
  9. omnibase_infra/event_bus/event_bus_kafka.py +105 -47
  10. omnibase_infra/event_bus/mixin_kafka_broadcast.py +3 -7
  11. omnibase_infra/event_bus/mixin_kafka_dlq.py +12 -6
  12. omnibase_infra/event_bus/models/config/model_kafka_event_bus_config.py +0 -81
  13. omnibase_infra/event_bus/testing/__init__.py +26 -0
  14. omnibase_infra/event_bus/testing/adapter_protocol_event_publisher_inmemory.py +418 -0
  15. omnibase_infra/event_bus/testing/model_publisher_metrics.py +64 -0
  16. omnibase_infra/handlers/handler_consul.py +2 -0
  17. omnibase_infra/handlers/mixins/__init__.py +5 -0
  18. omnibase_infra/handlers/mixins/mixin_consul_service.py +274 -10
  19. omnibase_infra/handlers/mixins/mixin_consul_topic_index.py +585 -0
  20. omnibase_infra/handlers/models/model_filesystem_config.py +4 -4
  21. omnibase_infra/migrations/001_create_event_ledger.sql +166 -0
  22. omnibase_infra/migrations/001_drop_event_ledger.sql +18 -0
  23. omnibase_infra/mixins/mixin_node_introspection.py +189 -19
  24. omnibase_infra/models/__init__.py +8 -0
  25. omnibase_infra/models/bindings/__init__.py +59 -0
  26. omnibase_infra/models/bindings/constants.py +144 -0
  27. omnibase_infra/models/bindings/model_binding_resolution_result.py +103 -0
  28. omnibase_infra/models/bindings/model_operation_binding.py +44 -0
  29. omnibase_infra/models/bindings/model_operation_bindings_subcontract.py +152 -0
  30. omnibase_infra/models/bindings/model_parsed_binding.py +52 -0
  31. omnibase_infra/models/discovery/model_introspection_config.py +25 -17
  32. omnibase_infra/models/dispatch/__init__.py +8 -0
  33. omnibase_infra/models/dispatch/model_debug_trace_snapshot.py +114 -0
  34. omnibase_infra/models/dispatch/model_materialized_dispatch.py +141 -0
  35. omnibase_infra/models/handlers/model_handler_source_config.py +1 -1
  36. omnibase_infra/models/model_node_identity.py +126 -0
  37. omnibase_infra/models/projection/model_snapshot_topic_config.py +3 -2
  38. omnibase_infra/models/registration/__init__.py +9 -0
  39. omnibase_infra/models/registration/model_event_bus_topic_entry.py +59 -0
  40. omnibase_infra/models/registration/model_node_event_bus_config.py +99 -0
  41. omnibase_infra/models/registration/model_node_introspection_event.py +11 -0
  42. omnibase_infra/models/runtime/__init__.py +9 -0
  43. omnibase_infra/models/validation/model_coverage_metrics.py +2 -2
  44. omnibase_infra/nodes/__init__.py +9 -0
  45. omnibase_infra/nodes/contract_registry_reducer/__init__.py +29 -0
  46. omnibase_infra/nodes/contract_registry_reducer/contract.yaml +255 -0
  47. omnibase_infra/nodes/contract_registry_reducer/models/__init__.py +38 -0
  48. omnibase_infra/nodes/contract_registry_reducer/models/model_contract_registry_state.py +266 -0
  49. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_cleanup_topic_references.py +55 -0
  50. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_deactivate_contract.py +58 -0
  51. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_mark_stale.py +49 -0
  52. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_update_heartbeat.py +71 -0
  53. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_update_topic.py +66 -0
  54. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_upsert_contract.py +92 -0
  55. omnibase_infra/nodes/contract_registry_reducer/node.py +121 -0
  56. omnibase_infra/nodes/contract_registry_reducer/reducer.py +784 -0
  57. omnibase_infra/nodes/contract_registry_reducer/registry/__init__.py +9 -0
  58. omnibase_infra/nodes/contract_registry_reducer/registry/registry_infra_contract_registry_reducer.py +101 -0
  59. omnibase_infra/nodes/handlers/consul/contract.yaml +85 -0
  60. omnibase_infra/nodes/handlers/db/contract.yaml +72 -0
  61. omnibase_infra/nodes/handlers/graph/contract.yaml +127 -0
  62. omnibase_infra/nodes/handlers/http/contract.yaml +74 -0
  63. omnibase_infra/nodes/handlers/intent/contract.yaml +66 -0
  64. omnibase_infra/nodes/handlers/mcp/contract.yaml +69 -0
  65. omnibase_infra/nodes/handlers/vault/contract.yaml +91 -0
  66. omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +50 -0
  67. omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +104 -0
  68. omnibase_infra/nodes/node_ledger_projection_compute/node.py +284 -0
  69. omnibase_infra/nodes/node_ledger_projection_compute/registry/__init__.py +29 -0
  70. omnibase_infra/nodes/node_ledger_projection_compute/registry/registry_infra_ledger_projection.py +118 -0
  71. omnibase_infra/nodes/node_ledger_write_effect/__init__.py +82 -0
  72. omnibase_infra/nodes/node_ledger_write_effect/contract.yaml +200 -0
  73. omnibase_infra/nodes/node_ledger_write_effect/handlers/__init__.py +22 -0
  74. omnibase_infra/nodes/node_ledger_write_effect/handlers/handler_ledger_append.py +372 -0
  75. omnibase_infra/nodes/node_ledger_write_effect/handlers/handler_ledger_query.py +597 -0
  76. omnibase_infra/nodes/node_ledger_write_effect/models/__init__.py +31 -0
  77. omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_append_result.py +54 -0
  78. omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_entry.py +92 -0
  79. omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_query.py +53 -0
  80. omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_query_result.py +41 -0
  81. omnibase_infra/nodes/node_ledger_write_effect/node.py +89 -0
  82. omnibase_infra/nodes/node_ledger_write_effect/protocols/__init__.py +13 -0
  83. omnibase_infra/nodes/node_ledger_write_effect/protocols/protocol_ledger_persistence.py +127 -0
  84. omnibase_infra/nodes/node_ledger_write_effect/registry/__init__.py +9 -0
  85. omnibase_infra/nodes/node_ledger_write_effect/registry/registry_infra_ledger_write.py +121 -0
  86. omnibase_infra/nodes/node_registration_orchestrator/registry/registry_infra_node_registration_orchestrator.py +7 -5
  87. omnibase_infra/nodes/reducers/models/__init__.py +7 -2
  88. omnibase_infra/nodes/reducers/models/model_payload_consul_register.py +11 -0
  89. omnibase_infra/nodes/reducers/models/model_payload_ledger_append.py +133 -0
  90. omnibase_infra/nodes/reducers/registration_reducer.py +1 -0
  91. omnibase_infra/protocols/__init__.py +3 -0
  92. omnibase_infra/protocols/protocol_dispatch_engine.py +152 -0
  93. omnibase_infra/runtime/__init__.py +60 -0
  94. omnibase_infra/runtime/binding_resolver.py +753 -0
  95. omnibase_infra/runtime/constants_security.py +70 -0
  96. omnibase_infra/runtime/contract_loaders/__init__.py +9 -0
  97. omnibase_infra/runtime/contract_loaders/operation_bindings_loader.py +789 -0
  98. omnibase_infra/runtime/emit_daemon/__init__.py +97 -0
  99. omnibase_infra/runtime/emit_daemon/cli.py +844 -0
  100. omnibase_infra/runtime/emit_daemon/client.py +811 -0
  101. omnibase_infra/runtime/emit_daemon/config.py +535 -0
  102. omnibase_infra/runtime/emit_daemon/daemon.py +812 -0
  103. omnibase_infra/runtime/emit_daemon/event_registry.py +477 -0
  104. omnibase_infra/runtime/emit_daemon/model_daemon_request.py +139 -0
  105. omnibase_infra/runtime/emit_daemon/model_daemon_response.py +191 -0
  106. omnibase_infra/runtime/emit_daemon/queue.py +618 -0
  107. omnibase_infra/runtime/event_bus_subcontract_wiring.py +466 -0
  108. omnibase_infra/runtime/handler_source_resolver.py +43 -2
  109. omnibase_infra/runtime/kafka_contract_source.py +984 -0
  110. omnibase_infra/runtime/models/__init__.py +13 -0
  111. omnibase_infra/runtime/models/model_contract_load_result.py +224 -0
  112. omnibase_infra/runtime/models/model_runtime_contract_config.py +268 -0
  113. omnibase_infra/runtime/models/model_runtime_scheduler_config.py +4 -3
  114. omnibase_infra/runtime/models/model_security_config.py +109 -0
  115. omnibase_infra/runtime/publisher_topic_scoped.py +294 -0
  116. omnibase_infra/runtime/runtime_contract_config_loader.py +406 -0
  117. omnibase_infra/runtime/service_kernel.py +76 -6
  118. omnibase_infra/runtime/service_message_dispatch_engine.py +558 -15
  119. omnibase_infra/runtime/service_runtime_host_process.py +770 -20
  120. omnibase_infra/runtime/transition_notification_publisher.py +3 -2
  121. omnibase_infra/runtime/util_wiring.py +206 -62
  122. omnibase_infra/services/mcp/service_mcp_tool_sync.py +27 -9
  123. omnibase_infra/services/session/config_consumer.py +25 -8
  124. omnibase_infra/services/session/config_store.py +2 -2
  125. omnibase_infra/services/session/consumer.py +1 -1
  126. omnibase_infra/topics/__init__.py +45 -0
  127. omnibase_infra/topics/platform_topic_suffixes.py +140 -0
  128. omnibase_infra/topics/util_topic_composition.py +95 -0
  129. omnibase_infra/types/typed_dict/__init__.py +9 -1
  130. omnibase_infra/types/typed_dict/typed_dict_envelope_build_params.py +115 -0
  131. omnibase_infra/utils/__init__.py +9 -0
  132. omnibase_infra/utils/util_consumer_group.py +232 -0
  133. omnibase_infra/validation/infra_validators.py +18 -1
  134. omnibase_infra/validation/validation_exemptions.yaml +192 -0
  135. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/METADATA +3 -3
  136. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/RECORD +139 -52
  137. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/entry_points.txt +1 -0
  138. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/WHEEL +0 -0
  139. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,535 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Emit Daemon Configuration Model.
4
+
5
+ This module provides the Pydantic configuration model for the Hook Event Emit Daemon.
6
+ The daemon provides a Unix socket interface for Claude Code hooks to emit events
7
+ to Kafka without blocking hook execution.
8
+
9
+ Configuration includes:
10
+ - Socket and PID file paths
11
+ - Spool directory for message persistence during Kafka unavailability
12
+ - Memory and disk limits for backpressure management
13
+ - Kafka connection settings
14
+ - Timeout configurations for graceful operations
15
+
16
+ Environment Variable Overrides:
17
+ EMIT_DAEMON_SOCKET_PATH: Override socket_path
18
+ EMIT_DAEMON_PID_PATH: Override pid_path
19
+ EMIT_DAEMON_SPOOL_DIR: Override spool_dir
20
+ EMIT_DAEMON_SOCKET_PERMISSIONS: Override socket_permissions (octal string, e.g., "660")
21
+ EMIT_DAEMON_KAFKA_BOOTSTRAP_SERVERS: Override kafka_bootstrap_servers
22
+ EMIT_DAEMON_KAFKA_CLIENT_ID: Override kafka_client_id
23
+ EMIT_DAEMON_ENVIRONMENT: Override environment
24
+ EMIT_DAEMON_MAX_RETRY_ATTEMPTS: Override max_retry_attempts
25
+ EMIT_DAEMON_BACKOFF_BASE_SECONDS: Override backoff_base_seconds
26
+ EMIT_DAEMON_MAX_BACKOFF_SECONDS: Override max_backoff_seconds
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ from pathlib import Path
33
+
34
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
35
+
36
+ from omnibase_core.errors import OnexError
37
+
38
+
39
+ class ModelEmitDaemonConfigInput(BaseModel):
40
+ """Intermediate input model for environment variable override parsing.
41
+
42
+ This model captures configuration values from kwargs and environment variables
43
+ before final validation. All fields are optional since we only populate what's
44
+ actually provided, allowing the final ModelEmitDaemonConfig to apply defaults.
45
+
46
+ Purpose:
47
+ - Eliminates union types in with_env_overrides() method
48
+ - Provides early validation at parse time with ConfigDict(extra="forbid")
49
+ - Allows type-safe accumulation of config values from multiple sources
50
+
51
+ Usage:
52
+ This model is used internally by ModelEmitDaemonConfig.with_env_overrides().
53
+ It should not be used directly - use ModelEmitDaemonConfig instead.
54
+
55
+ Note:
56
+ Validation happens in the final ModelEmitDaemonConfig model, not here.
57
+ This model only ensures type safety and catches typos in field names.
58
+ """
59
+
60
+ model_config = ConfigDict(extra="forbid")
61
+
62
+ # Path configurations
63
+ socket_path: Path | None = None
64
+ pid_path: Path | None = None
65
+ spool_dir: Path | None = None
66
+
67
+ # Limit configurations
68
+ max_payload_bytes: int | None = None
69
+ max_memory_queue: int | None = None
70
+ max_spool_messages: int | None = None
71
+ max_spool_bytes: int | None = None
72
+
73
+ # Kafka configurations
74
+ kafka_bootstrap_servers: str | None = None
75
+ kafka_client_id: str | None = None
76
+ environment: str | None = None
77
+
78
+ # Socket permissions
79
+ socket_permissions: int | None = None
80
+
81
+ # Timeout configurations
82
+ socket_timeout_seconds: float | None = None
83
+ kafka_timeout_seconds: float | None = None
84
+ shutdown_drain_seconds: float | None = None
85
+
86
+ # Retry configurations
87
+ max_retry_attempts: int | None = None
88
+ backoff_base_seconds: float | None = None
89
+ max_backoff_seconds: float | None = None
90
+
91
+
92
+ class ModelEmitDaemonConfig(BaseModel):
93
+ """Configuration model for the Hook Event Emit Daemon.
94
+
95
+ The emit daemon provides a non-blocking interface for Claude Code hooks
96
+ to emit events to Kafka. This configuration controls all operational
97
+ parameters including paths, limits, and timeouts.
98
+
99
+ Attributes:
100
+ socket_path: Unix domain socket path for client connections
101
+ pid_path: PID file path for daemon process management
102
+ spool_dir: Directory for spooling messages when Kafka is unavailable
103
+ max_payload_bytes: Maximum allowed payload size per message
104
+ max_memory_queue: Maximum messages to hold in memory queue
105
+ max_spool_messages: Maximum messages to persist in spool directory
106
+ max_spool_bytes: Maximum total bytes in spool directory
107
+ kafka_bootstrap_servers: Kafka broker addresses (host:port format)
108
+ kafka_client_id: Client identifier for Kafka producer
109
+ environment: Deployment environment for topic naming
110
+ socket_timeout_seconds: Timeout for socket read/write operations
111
+ kafka_timeout_seconds: Timeout for Kafka produce operations
112
+ shutdown_drain_seconds: Time to drain queues during graceful shutdown
113
+ max_retry_attempts: Maximum retry attempts before dropping an event
114
+ backoff_base_seconds: Base backoff delay in seconds for exponential backoff
115
+ max_backoff_seconds: Maximum backoff delay in seconds (caps exponential growth)
116
+
117
+ Example:
118
+ >>> config = ModelEmitDaemonConfig(
119
+ ... kafka_bootstrap_servers="kafka.example.com:9092",
120
+ ... socket_path=Path("/tmp/my-emit.sock"),
121
+ ... )
122
+ >>> print(config.max_payload_bytes)
123
+ 1048576
124
+
125
+ >>> # Load with environment overrides
126
+ >>> config = ModelEmitDaemonConfig.with_env_overrides(
127
+ ... kafka_bootstrap_servers="localhost:9092"
128
+ ... )
129
+ """
130
+
131
+ model_config = ConfigDict(
132
+ strict=True,
133
+ frozen=True,
134
+ extra="forbid",
135
+ from_attributes=True,
136
+ validate_default=True,
137
+ )
138
+
139
+ # Path configurations
140
+ # NOTE: /tmp is standard for Unix domain sockets - not a security issue
141
+ socket_path: Path = Field(
142
+ default=Path("/tmp/omniclaude-emit.sock"), # noqa: S108
143
+ description="Unix domain socket path for client connections",
144
+ )
145
+ pid_path: Path = Field(
146
+ default=Path("/tmp/omniclaude-emit.pid"), # noqa: S108
147
+ description="PID file path for daemon process management",
148
+ )
149
+ spool_dir: Path = Field(
150
+ default_factory=lambda: Path.home() / ".omniclaude" / "emit-spool",
151
+ description="Directory for spooling messages when Kafka is unavailable",
152
+ )
153
+
154
+ # Limit configurations
155
+ max_payload_bytes: int = Field(
156
+ default=1_048_576, # 1MB
157
+ ge=1024, # Minimum 1KB
158
+ le=10_485_760, # Maximum 10MB
159
+ description="Maximum allowed payload size per message in bytes",
160
+ )
161
+ max_memory_queue: int = Field(
162
+ default=100,
163
+ ge=1,
164
+ le=10_000,
165
+ description="Maximum messages to hold in memory queue",
166
+ )
167
+ max_spool_messages: int = Field(
168
+ default=1000,
169
+ ge=0, # 0 disables spooling
170
+ le=100_000,
171
+ description="Maximum messages to persist in spool directory",
172
+ )
173
+ max_spool_bytes: int = Field(
174
+ default=10_485_760, # 10MB
175
+ ge=0, # 0 disables spooling
176
+ le=1_073_741_824, # Maximum 1GB
177
+ description="Maximum total bytes in spool directory",
178
+ )
179
+
180
+ # Kafka configurations
181
+ kafka_bootstrap_servers: str = Field(
182
+ ..., # Required, no default
183
+ min_length=1,
184
+ description="Kafka broker addresses (host:port format, comma-separated for multiple)",
185
+ )
186
+ # ONEX_EXCLUDE: string_id - kafka_client_id is Kafka identifier, not UUID
187
+ kafka_client_id: str = Field(
188
+ default="emit-daemon",
189
+ min_length=1,
190
+ max_length=255,
191
+ description="Client identifier for Kafka producer",
192
+ )
193
+ environment: str = Field(
194
+ default="dev",
195
+ pattern=r"^[a-z][a-z0-9-]*$",
196
+ description="Deployment environment (e.g., 'dev', 'staging', 'prod'). Used in topic names.",
197
+ )
198
+
199
+ # Socket permissions
200
+ socket_permissions: int = Field(
201
+ default=0o660, # Owner and group read/write
202
+ ge=0,
203
+ le=0o777, # Maximum valid permission mode
204
+ description=(
205
+ "Unix permission mode for the socket file. "
206
+ "Default 0o660 allows owner and group read/write access. "
207
+ "Use 0o600 for single-user, 0o666 for multi-user development."
208
+ ),
209
+ )
210
+
211
+ # Timeout configurations
212
+ socket_timeout_seconds: float = Field(
213
+ default=5.0,
214
+ ge=0.1,
215
+ le=60.0,
216
+ description="Timeout for socket read/write operations in seconds",
217
+ )
218
+ kafka_timeout_seconds: float = Field(
219
+ default=30.0,
220
+ ge=1.0,
221
+ le=300.0,
222
+ description="Timeout for Kafka produce operations in seconds",
223
+ )
224
+ shutdown_drain_seconds: float = Field(
225
+ default=10.0,
226
+ ge=0.0,
227
+ le=300.0,
228
+ description="Time to drain queues during graceful shutdown in seconds",
229
+ )
230
+
231
+ # Retry configurations
232
+ max_retry_attempts: int = Field(
233
+ default=3,
234
+ ge=1,
235
+ le=10,
236
+ description="Maximum retry attempts before dropping an event",
237
+ )
238
+ backoff_base_seconds: float = Field(
239
+ default=1.0,
240
+ ge=0.1,
241
+ le=30.0,
242
+ description="Base backoff delay in seconds for exponential backoff",
243
+ )
244
+ max_backoff_seconds: float = Field(
245
+ default=60.0,
246
+ ge=1.0,
247
+ le=300.0,
248
+ description="Maximum backoff delay in seconds (caps exponential growth)",
249
+ )
250
+
251
+ @field_validator("socket_path", "pid_path", mode="after")
252
+ @classmethod
253
+ def validate_file_path_parent_exists_or_creatable(cls, v: Path) -> Path:
254
+ """Validate that file path's parent directory exists or can be created.
255
+
256
+ For socket and PID files, we validate that the parent directory either
257
+ exists or can be created (i.e., its parent exists).
258
+
259
+ Args:
260
+ v: The path to validate
261
+
262
+ Returns:
263
+ The validated path
264
+
265
+ Raises:
266
+ OnexError: If the parent directory path is invalid
267
+ """
268
+ parent = v.parent
269
+ if parent.exists():
270
+ if not parent.is_dir():
271
+ raise OnexError(f"Parent path exists but is not a directory: {parent}")
272
+ return v
273
+
274
+ # Check if grandparent exists (parent can be created)
275
+ grandparent = parent.parent
276
+ if grandparent.exists() and grandparent.is_dir():
277
+ return v
278
+
279
+ raise OnexError(
280
+ f"Parent directory does not exist and cannot be created: {parent}"
281
+ )
282
+
283
+ @field_validator("spool_dir", mode="after")
284
+ @classmethod
285
+ def validate_spool_dir_creatable(cls, v: Path) -> Path:
286
+ """Validate that spool directory exists or can be created.
287
+
288
+ The spool directory may be nested (e.g., ~/.omniclaude/emit-spool),
289
+ so we validate that at least one ancestor exists.
290
+
291
+ Args:
292
+ v: The spool directory path to validate
293
+
294
+ Returns:
295
+ The validated path
296
+
297
+ Raises:
298
+ OnexError: If no valid ancestor exists for directory creation
299
+ """
300
+ if v.exists():
301
+ if not v.is_dir():
302
+ raise OnexError(f"Spool path exists but is not a directory: {v}")
303
+ return v
304
+
305
+ # Walk up the path to find an existing ancestor
306
+ current = v
307
+ while current != current.parent: # Stop at filesystem root
308
+ current = current.parent
309
+ if current.exists():
310
+ if current.is_dir():
311
+ return v
312
+ raise OnexError(
313
+ f"Ancestor path exists but is not a directory: {current}"
314
+ )
315
+
316
+ raise OnexError(f"No valid ancestor directory found for spool path: {v}")
317
+
318
+ @field_validator("socket_permissions", mode="after")
319
+ @classmethod
320
+ def validate_socket_permissions(cls, v: int) -> int:
321
+ """Validate that socket permissions is a valid Unix permission mode.
322
+
323
+ Unix permissions are represented as octal values from 0o000 to 0o777.
324
+ Each digit represents permissions for owner, group, and others respectively.
325
+ Values 0-7 encode read (4), write (2), and execute (1) bits.
326
+
327
+ Note:
328
+ The range is already enforced by Field(ge=0, le=0o777), so this
329
+ validator provides explicit error messages for edge cases.
330
+
331
+ Args:
332
+ v: The permission mode to validate (integer)
333
+
334
+ Returns:
335
+ The validated permission mode
336
+
337
+ Raises:
338
+ OnexError: If the permission mode is invalid
339
+ """
340
+ # Range is enforced by Field constraints (ge=0, le=0o777)
341
+ # This validator provides explicit error messaging
342
+ if v < 0 or v > 0o777:
343
+ raise OnexError(
344
+ f"Invalid socket permissions {oct(v)}. "
345
+ "Must be between 0o000 and 0o777 (0-511 in decimal)."
346
+ )
347
+
348
+ return v
349
+
350
+ @field_validator("kafka_bootstrap_servers", mode="after")
351
+ @classmethod
352
+ def validate_bootstrap_servers_format(cls, v: str) -> str:
353
+ """Validate Kafka bootstrap servers format.
354
+
355
+ Each server must be in host:port format with a valid port number.
356
+
357
+ Args:
358
+ v: Bootstrap servers string
359
+
360
+ Returns:
361
+ The validated bootstrap servers string
362
+
363
+ Raises:
364
+ OnexError: If the format is invalid
365
+ """
366
+ servers = v.strip().split(",")
367
+ for server in servers:
368
+ server = server.strip()
369
+ if not server:
370
+ raise OnexError("Bootstrap servers cannot contain empty entries")
371
+ if ":" not in server:
372
+ raise OnexError(
373
+ f"Invalid bootstrap server format '{server}'. "
374
+ "Expected 'host:port' (e.g., 'localhost:9092')"
375
+ )
376
+ host, port_str = server.rsplit(":", 1)
377
+ if not host:
378
+ raise OnexError(
379
+ f"Invalid bootstrap server format '{server}'. Host cannot be empty"
380
+ )
381
+ try:
382
+ port = int(port_str)
383
+ if port < 1 or port > 65535:
384
+ raise OnexError(
385
+ f"Invalid port {port} in '{server}'. "
386
+ "Port must be between 1 and 65535"
387
+ )
388
+ except ValueError as e:
389
+ raise OnexError(
390
+ f"Invalid port '{port_str}' in '{server}'. "
391
+ "Port must be a valid integer"
392
+ ) from e
393
+
394
+ return v.strip()
395
+
396
+ @model_validator(mode="after")
397
+ def validate_spool_limits_consistency(self) -> ModelEmitDaemonConfig:
398
+ """Validate that spool limits are consistent.
399
+
400
+ If max_spool_messages is 0 (spooling disabled), max_spool_bytes
401
+ should also be 0, and vice versa.
402
+
403
+ Returns:
404
+ The validated model instance
405
+
406
+ Raises:
407
+ OnexError: If spool limits are inconsistent
408
+ """
409
+ if self.max_spool_messages == 0 and self.max_spool_bytes > 0:
410
+ raise OnexError(
411
+ "Inconsistent spool limits: max_spool_messages is 0 (disabled) "
412
+ "but max_spool_bytes is non-zero. Set both to 0 to disable spooling."
413
+ )
414
+ if self.max_spool_bytes == 0 and self.max_spool_messages > 0:
415
+ raise OnexError(
416
+ "Inconsistent spool limits: max_spool_bytes is 0 (disabled) "
417
+ "but max_spool_messages is non-zero. Set both to 0 to disable spooling."
418
+ )
419
+ return self
420
+
421
+ @classmethod
422
+ def with_env_overrides(cls, **kwargs: object) -> ModelEmitDaemonConfig:
423
+ """Create configuration with environment variable overrides.
424
+
425
+ Environment variables take precedence over provided kwargs.
426
+ If an environment variable is set, it overrides the corresponding
427
+ kwarg value.
428
+
429
+ Environment Variable Mapping:
430
+ EMIT_DAEMON_SOCKET_PATH -> socket_path
431
+ EMIT_DAEMON_PID_PATH -> pid_path
432
+ EMIT_DAEMON_SPOOL_DIR -> spool_dir
433
+ EMIT_DAEMON_SOCKET_PERMISSIONS -> socket_permissions (parsed as octal string)
434
+ EMIT_DAEMON_KAFKA_BOOTSTRAP_SERVERS -> kafka_bootstrap_servers
435
+ EMIT_DAEMON_KAFKA_CLIENT_ID -> kafka_client_id
436
+ EMIT_DAEMON_MAX_PAYLOAD_BYTES -> max_payload_bytes
437
+ EMIT_DAEMON_MAX_MEMORY_QUEUE -> max_memory_queue
438
+ EMIT_DAEMON_MAX_SPOOL_MESSAGES -> max_spool_messages
439
+ EMIT_DAEMON_MAX_SPOOL_BYTES -> max_spool_bytes
440
+ EMIT_DAEMON_SOCKET_TIMEOUT_SECONDS -> socket_timeout_seconds
441
+ EMIT_DAEMON_KAFKA_TIMEOUT_SECONDS -> kafka_timeout_seconds
442
+ EMIT_DAEMON_SHUTDOWN_DRAIN_SECONDS -> shutdown_drain_seconds
443
+ EMIT_DAEMON_MAX_RETRY_ATTEMPTS -> max_retry_attempts
444
+ EMIT_DAEMON_BACKOFF_BASE_SECONDS -> backoff_base_seconds
445
+ EMIT_DAEMON_MAX_BACKOFF_SECONDS -> max_backoff_seconds
446
+
447
+ Args:
448
+ **kwargs: Base configuration values to use if env vars not set
449
+
450
+ Returns:
451
+ Configuration instance with environment overrides applied
452
+
453
+ Example:
454
+ >>> import os
455
+ >>> os.environ["EMIT_DAEMON_KAFKA_BOOTSTRAP_SERVERS"] = "kafka:9092"
456
+ >>> config = ModelEmitDaemonConfig.with_env_overrides(
457
+ ... kafka_bootstrap_servers="localhost:9092" # Overridden by env
458
+ ... )
459
+ >>> config.kafka_bootstrap_servers
460
+ 'kafka:9092'
461
+ """
462
+ # Marker for fields that should be parsed as octal integers
463
+ OCTAL_INT = "octal_int"
464
+
465
+ # Environment variable to field mapping with type converters
466
+ # Uses object type for converter since it can be type, str marker, or callable
467
+ env_mappings: dict[str, tuple[str, object]] = {
468
+ "EMIT_DAEMON_SOCKET_PATH": ("socket_path", Path),
469
+ "EMIT_DAEMON_PID_PATH": ("pid_path", Path),
470
+ "EMIT_DAEMON_SPOOL_DIR": ("spool_dir", Path),
471
+ # NOTE: socket_permissions uses octal string parsing (e.g., "660" -> 0o660)
472
+ "EMIT_DAEMON_SOCKET_PERMISSIONS": ("socket_permissions", OCTAL_INT),
473
+ "EMIT_DAEMON_KAFKA_BOOTSTRAP_SERVERS": ("kafka_bootstrap_servers", str),
474
+ "EMIT_DAEMON_KAFKA_CLIENT_ID": ("kafka_client_id", str),
475
+ "EMIT_DAEMON_ENVIRONMENT": ("environment", str),
476
+ "EMIT_DAEMON_MAX_PAYLOAD_BYTES": ("max_payload_bytes", int),
477
+ "EMIT_DAEMON_MAX_MEMORY_QUEUE": ("max_memory_queue", int),
478
+ "EMIT_DAEMON_MAX_SPOOL_MESSAGES": ("max_spool_messages", int),
479
+ "EMIT_DAEMON_MAX_SPOOL_BYTES": ("max_spool_bytes", int),
480
+ "EMIT_DAEMON_SOCKET_TIMEOUT_SECONDS": ("socket_timeout_seconds", float),
481
+ "EMIT_DAEMON_KAFKA_TIMEOUT_SECONDS": ("kafka_timeout_seconds", float),
482
+ "EMIT_DAEMON_SHUTDOWN_DRAIN_SECONDS": ("shutdown_drain_seconds", float),
483
+ "EMIT_DAEMON_MAX_RETRY_ATTEMPTS": ("max_retry_attempts", int),
484
+ "EMIT_DAEMON_BACKOFF_BASE_SECONDS": ("backoff_base_seconds", float),
485
+ "EMIT_DAEMON_MAX_BACKOFF_SECONDS": ("max_backoff_seconds", float),
486
+ }
487
+
488
+ # Build intermediate config using strongly typed input model
489
+ # This provides early validation and eliminates union types
490
+ input_fields: dict[str, object] = {}
491
+
492
+ # First, apply provided kwargs (filter None values)
493
+ for key, value in kwargs.items():
494
+ if value is not None:
495
+ input_fields[key] = value
496
+
497
+ # Then, apply environment variable overrides (env takes precedence)
498
+ for env_var, (field_name, field_type) in env_mappings.items():
499
+ env_value = os.environ.get(env_var)
500
+ if env_value is not None:
501
+ try:
502
+ if field_type is Path:
503
+ input_fields[field_name] = Path(env_value)
504
+ elif field_type == OCTAL_INT:
505
+ # Parse as octal string (e.g., "660" -> 0o660 = 432)
506
+ # Handles both "660" and "0o660" formats
507
+ input_fields[field_name] = int(env_value, 8)
508
+ elif field_type is int:
509
+ input_fields[field_name] = int(env_value)
510
+ elif field_type is float:
511
+ input_fields[field_name] = float(env_value)
512
+ else:
513
+ input_fields[field_name] = env_value
514
+ except ValueError:
515
+ # Skip invalid env values, let Pydantic validation handle
516
+ pass
517
+
518
+ # Validate through input model first (catches typos, provides type safety)
519
+ # Then extract only set values for final model construction
520
+ input_model = ModelEmitDaemonConfigInput.model_validate(input_fields)
521
+ final_config = input_model.model_dump(exclude_none=True)
522
+
523
+ return cls.model_validate(final_config)
524
+
525
+ @property
526
+ def spooling_enabled(self) -> bool:
527
+ """Check if message spooling is enabled.
528
+
529
+ Returns:
530
+ True if both max_spool_messages and max_spool_bytes are non-zero
531
+ """
532
+ return self.max_spool_messages > 0 and self.max_spool_bytes > 0
533
+
534
+
535
+ __all__: list[str] = ["ModelEmitDaemonConfig", "ModelEmitDaemonConfigInput"]