omnibase_infra 0.2.7__py3-none-any.whl → 0.2.9__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 (79) hide show
  1. omnibase_infra/__init__.py +1 -1
  2. omnibase_infra/enums/__init__.py +4 -0
  3. omnibase_infra/enums/enum_declarative_node_violation.py +102 -0
  4. omnibase_infra/event_bus/adapters/__init__.py +31 -0
  5. omnibase_infra/event_bus/adapters/adapter_protocol_event_publisher_kafka.py +517 -0
  6. omnibase_infra/mixins/mixin_async_circuit_breaker.py +113 -1
  7. omnibase_infra/models/__init__.py +9 -0
  8. omnibase_infra/models/event_bus/__init__.py +22 -0
  9. omnibase_infra/models/event_bus/model_consumer_retry_config.py +367 -0
  10. omnibase_infra/models/event_bus/model_dlq_config.py +177 -0
  11. omnibase_infra/models/event_bus/model_idempotency_config.py +131 -0
  12. omnibase_infra/models/event_bus/model_offset_policy_config.py +107 -0
  13. omnibase_infra/models/resilience/model_circuit_breaker_config.py +15 -0
  14. omnibase_infra/models/validation/__init__.py +8 -0
  15. omnibase_infra/models/validation/model_declarative_node_validation_result.py +139 -0
  16. omnibase_infra/models/validation/model_declarative_node_violation.py +169 -0
  17. omnibase_infra/nodes/architecture_validator/__init__.py +28 -7
  18. omnibase_infra/nodes/architecture_validator/constants.py +36 -0
  19. omnibase_infra/nodes/architecture_validator/handlers/__init__.py +28 -0
  20. omnibase_infra/nodes/architecture_validator/handlers/contract.yaml +120 -0
  21. omnibase_infra/nodes/architecture_validator/handlers/handler_architecture_validation.py +359 -0
  22. omnibase_infra/nodes/architecture_validator/node.py +1 -0
  23. omnibase_infra/nodes/architecture_validator/node_architecture_validator.py +48 -336
  24. omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +16 -2
  25. omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +14 -4
  26. omnibase_infra/nodes/node_ledger_projection_compute/handlers/__init__.py +18 -0
  27. omnibase_infra/nodes/node_ledger_projection_compute/handlers/contract.yaml +53 -0
  28. omnibase_infra/nodes/node_ledger_projection_compute/handlers/handler_ledger_projection.py +354 -0
  29. omnibase_infra/nodes/node_ledger_projection_compute/node.py +20 -256
  30. omnibase_infra/nodes/node_registry_effect/node.py +20 -73
  31. omnibase_infra/protocols/protocol_dispatch_engine.py +90 -0
  32. omnibase_infra/runtime/__init__.py +11 -0
  33. omnibase_infra/runtime/baseline_subscriptions.py +150 -0
  34. omnibase_infra/runtime/event_bus_subcontract_wiring.py +455 -24
  35. omnibase_infra/runtime/kafka_contract_source.py +13 -5
  36. omnibase_infra/runtime/service_message_dispatch_engine.py +112 -0
  37. omnibase_infra/runtime/service_runtime_host_process.py +6 -11
  38. omnibase_infra/services/__init__.py +36 -0
  39. omnibase_infra/services/contract_publisher/__init__.py +95 -0
  40. omnibase_infra/services/contract_publisher/config.py +199 -0
  41. omnibase_infra/services/contract_publisher/errors.py +243 -0
  42. omnibase_infra/services/contract_publisher/models/__init__.py +28 -0
  43. omnibase_infra/services/contract_publisher/models/model_contract_error.py +67 -0
  44. omnibase_infra/services/contract_publisher/models/model_infra_error.py +62 -0
  45. omnibase_infra/services/contract_publisher/models/model_publish_result.py +112 -0
  46. omnibase_infra/services/contract_publisher/models/model_publish_stats.py +79 -0
  47. omnibase_infra/services/contract_publisher/service.py +617 -0
  48. omnibase_infra/services/contract_publisher/sources/__init__.py +52 -0
  49. omnibase_infra/services/contract_publisher/sources/model_discovered.py +155 -0
  50. omnibase_infra/services/contract_publisher/sources/protocol.py +101 -0
  51. omnibase_infra/services/contract_publisher/sources/source_composite.py +309 -0
  52. omnibase_infra/services/contract_publisher/sources/source_filesystem.py +174 -0
  53. omnibase_infra/services/contract_publisher/sources/source_package.py +221 -0
  54. omnibase_infra/services/observability/__init__.py +40 -0
  55. omnibase_infra/services/observability/agent_actions/__init__.py +64 -0
  56. omnibase_infra/services/observability/agent_actions/config.py +209 -0
  57. omnibase_infra/services/observability/agent_actions/consumer.py +1320 -0
  58. omnibase_infra/services/observability/agent_actions/models/__init__.py +87 -0
  59. omnibase_infra/services/observability/agent_actions/models/model_agent_action.py +142 -0
  60. omnibase_infra/services/observability/agent_actions/models/model_detection_failure.py +125 -0
  61. omnibase_infra/services/observability/agent_actions/models/model_envelope.py +85 -0
  62. omnibase_infra/services/observability/agent_actions/models/model_execution_log.py +159 -0
  63. omnibase_infra/services/observability/agent_actions/models/model_performance_metric.py +130 -0
  64. omnibase_infra/services/observability/agent_actions/models/model_routing_decision.py +138 -0
  65. omnibase_infra/services/observability/agent_actions/models/model_transformation_event.py +124 -0
  66. omnibase_infra/services/observability/agent_actions/tests/__init__.py +20 -0
  67. omnibase_infra/services/observability/agent_actions/tests/test_consumer.py +1154 -0
  68. omnibase_infra/services/observability/agent_actions/tests/test_models.py +645 -0
  69. omnibase_infra/services/observability/agent_actions/tests/test_writer.py +709 -0
  70. omnibase_infra/services/observability/agent_actions/writer_postgres.py +926 -0
  71. omnibase_infra/validation/__init__.py +12 -0
  72. omnibase_infra/validation/contracts/declarative_node.validation.yaml +143 -0
  73. omnibase_infra/validation/validation_exemptions.yaml +93 -0
  74. omnibase_infra/validation/validator_declarative_node.py +850 -0
  75. {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.dist-info}/METADATA +3 -3
  76. {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.dist-info}/RECORD +79 -27
  77. {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.dist-info}/WHEEL +0 -0
  78. {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.dist-info}/entry_points.txt +0 -0
  79. {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,617 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Contract Publisher Service.
4
+
5
+ Main service for discovering and publishing contracts to Kafka.
6
+ Follows the flow: Source → Validate → Normalize → Publish → Report.
7
+
8
+ This service standardizes the publishing *engine* while apps provide
9
+ *source configuration*. It enforces ARCH-002: "Runtime owns all Kafka plumbing."
10
+
11
+ Related:
12
+ - OMN-1752: Extract ContractPublisher to omnibase_infra
13
+ - ARCH-002: Runtime owns all Kafka plumbing
14
+
15
+ .. versionadded:: 0.3.0
16
+ Created as part of OMN-1752 (ContractPublisher extraction).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import time
23
+ from typing import TYPE_CHECKING
24
+ from uuid import uuid4
25
+
26
+ import yaml
27
+ from pydantic import ValidationError
28
+
29
+ from omnibase_core.constants import TOPIC_SUFFIX_CONTRACT_REGISTERED
30
+ from omnibase_core.models.contracts.model_handler_contract import ModelHandlerContract
31
+ from omnibase_core.models.events import ModelContractRegisteredEvent
32
+ from omnibase_core.protocols.event_bus import ProtocolEventBusPublisher
33
+ from omnibase_infra.errors import (
34
+ InfraConnectionError,
35
+ InfraTimeoutError,
36
+ InfraUnavailableError,
37
+ )
38
+ from omnibase_infra.services.contract_publisher.config import (
39
+ ModelContractPublisherConfig,
40
+ )
41
+ from omnibase_infra.services.contract_publisher.errors import (
42
+ ContractPublishingInfraError,
43
+ ContractSourceNotConfiguredError,
44
+ NoContractsFoundError,
45
+ )
46
+ from omnibase_infra.services.contract_publisher.models import (
47
+ ModelContractError,
48
+ ModelInfraError,
49
+ ModelPublishResult,
50
+ ModelPublishStats,
51
+ )
52
+ from omnibase_infra.services.contract_publisher.sources import (
53
+ ModelDiscoveredContract,
54
+ ProtocolContractPublisherSource,
55
+ SourceContractComposite,
56
+ SourceContractFilesystem,
57
+ SourceContractPackage,
58
+ )
59
+
60
+ if TYPE_CHECKING:
61
+ from omnibase_core.container import ModelONEXContainer
62
+
63
+ logger = logging.getLogger(__name__)
64
+
65
+
66
+ class ServiceContractPublisher:
67
+ """Contract publishing service with injectable sources.
68
+
69
+ Discovers contracts from configured sources (filesystem, package, composite)
70
+ and publishes them to Kafka for dynamic discovery by KafkaContractSource.
71
+
72
+ Architecture:
73
+ Container → ServiceContractPublisher → ProtocolContractPublisherSource → Kafka
74
+
75
+ ModelPublishResult
76
+
77
+ Flow:
78
+ 1. Discover: Get contracts from source
79
+ 2. Sort: Order by (handler_id, origin, ref) for determinism
80
+ 3. Validate: Parse YAML, validate schema, extract handler_id
81
+ 4. Normalize: Compute SHA-256 hash for change detection
82
+ 5. Publish: Create ModelContractRegisteredEvent, publish to Kafka
83
+ 6. Report: Return ModelPublishResult with errors and stats
84
+
85
+ Thread Safety:
86
+ This service is async-safe. Each call to publish_all() is independent.
87
+
88
+ Example:
89
+ >>> config = ModelContractPublisherConfig(
90
+ ... mode="filesystem",
91
+ ... filesystem_root=Path("/app/contracts"),
92
+ ... )
93
+ >>> publisher = await ServiceContractPublisher.from_container(container, config)
94
+ >>> result = await publisher.publish_all()
95
+ >>> if result:
96
+ ... print(f"Published {len(result.published)} contracts")
97
+
98
+ .. versionadded:: 0.3.0
99
+ """
100
+
101
+ __slots__ = ("_config", "_environment", "_publisher", "_source")
102
+
103
+ def __init__(
104
+ self,
105
+ publisher: ProtocolEventBusPublisher,
106
+ source: ProtocolContractPublisherSource,
107
+ config: ModelContractPublisherConfig,
108
+ ) -> None:
109
+ """Initialize service with publisher and source.
110
+
111
+ Args:
112
+ publisher: Event bus publisher for Kafka
113
+ source: Contract source for discovery
114
+ config: Publishing configuration
115
+ """
116
+ self._publisher = publisher
117
+ self._source = source
118
+ self._config = config
119
+ self._environment = config.resolve_environment()
120
+
121
+ @classmethod
122
+ async def from_container(
123
+ cls,
124
+ container: ModelONEXContainer,
125
+ config: ModelContractPublisherConfig,
126
+ ) -> ServiceContractPublisher:
127
+ """Factory method for DI resolution.
128
+
129
+ Resolves ProtocolEventBusPublisher from container and creates
130
+ appropriate source based on config.mode.
131
+
132
+ Args:
133
+ container: ONEX container with event bus publisher
134
+ config: Publishing configuration
135
+
136
+ Returns:
137
+ Configured ServiceContractPublisher instance
138
+
139
+ Raises:
140
+ ContractSourceNotConfiguredError: If required source not configured
141
+ RuntimeError: If publisher not available in container
142
+ """
143
+ # Resolve publisher from container
144
+ # NOTE: Protocol type passed for duck-typed resolution per ONEX patterns
145
+ publisher = await container.get_service_async(
146
+ ProtocolEventBusPublisher # type: ignore[type-abstract]
147
+ )
148
+ if publisher is None:
149
+ raise ContractSourceNotConfiguredError(
150
+ mode=config.mode,
151
+ missing_field="publisher",
152
+ message="ProtocolEventBusPublisher not available in container",
153
+ )
154
+
155
+ # Create source based on mode
156
+ source = cls._create_source(config)
157
+
158
+ return cls(publisher, source, config)
159
+
160
+ @staticmethod
161
+ def _create_source(
162
+ config: ModelContractPublisherConfig,
163
+ ) -> ProtocolContractPublisherSource:
164
+ """Create source based on config mode.
165
+
166
+ Args:
167
+ config: Publishing configuration
168
+
169
+ Returns:
170
+ Appropriate source implementation
171
+
172
+ Raises:
173
+ ContractSourceNotConfiguredError: If required source not configured
174
+ """
175
+ match config.mode:
176
+ case "filesystem":
177
+ if not config.filesystem_root:
178
+ raise ContractSourceNotConfiguredError(
179
+ mode="filesystem",
180
+ missing_field="filesystem_root",
181
+ )
182
+ return SourceContractFilesystem(config.filesystem_root)
183
+
184
+ case "package":
185
+ if not config.package_module:
186
+ raise ContractSourceNotConfiguredError(
187
+ mode="package",
188
+ missing_field="package_module",
189
+ )
190
+ return SourceContractPackage(config.package_module)
191
+
192
+ case "composite":
193
+ filesystem_source = None
194
+ package_source = None
195
+
196
+ if config.filesystem_root:
197
+ filesystem_source = SourceContractFilesystem(config.filesystem_root)
198
+ if config.package_module:
199
+ package_source = SourceContractPackage(config.package_module)
200
+
201
+ if not filesystem_source and not package_source:
202
+ raise ContractSourceNotConfiguredError(
203
+ mode="composite",
204
+ missing_field="filesystem_root or package_module",
205
+ )
206
+
207
+ return SourceContractComposite(filesystem_source, package_source)
208
+
209
+ case _:
210
+ raise ContractSourceNotConfiguredError(
211
+ mode=config.mode,
212
+ missing_field="mode",
213
+ message=f"Unknown mode: {config.mode}",
214
+ )
215
+
216
+ def resolve_topic(self, topic_suffix: str) -> str:
217
+ """Resolve topic suffix to full topic name with environment prefix.
218
+
219
+ Uses the same pattern as EventBusSubcontractWiring.
220
+
221
+ Args:
222
+ topic_suffix: Topic suffix (e.g., "onex.evt.contract-registered.v1")
223
+
224
+ Returns:
225
+ Full topic name (e.g., "dev.onex.evt.contract-registered.v1")
226
+ """
227
+ return f"{self._environment}.{topic_suffix}"
228
+
229
+ async def publish_all(self) -> ModelPublishResult:
230
+ """Discover and publish all contracts from configured source.
231
+
232
+ Executes the full flow: Discover → Sort → Validate → Publish → Report
233
+
234
+ Returns:
235
+ ModelPublishResult with published handlers and any errors
236
+
237
+ Raises:
238
+ NoContractsFoundError: If no contracts found and allow_zero_contracts=False
239
+ ContractPublishingInfraError: If infrastructure error and fail_fast=True
240
+ """
241
+ start_time = time.perf_counter()
242
+ correlation_id = uuid4()
243
+
244
+ # Initialize result tracking
245
+ published: list[str] = []
246
+ contract_errors: list[ModelContractError] = []
247
+ infra_errors: list[ModelInfraError] = []
248
+ filesystem_count = 0
249
+ package_count = 0
250
+
251
+ # Phase 1: Discover
252
+ discover_start = time.perf_counter()
253
+ contracts, source_errors, dedup_count = await self._discover()
254
+ discover_ms = (time.perf_counter() - discover_start) * 1000
255
+
256
+ # Add any source errors (from composite conflict detection)
257
+ contract_errors.extend(source_errors)
258
+
259
+ # Count per-origin
260
+ for contract in contracts:
261
+ if contract.origin == "filesystem":
262
+ filesystem_count += 1
263
+ elif contract.origin == "package":
264
+ package_count += 1
265
+
266
+ discovered_count = len(contracts)
267
+
268
+ logger.info(
269
+ "Contract discovery complete: %d contracts from %s",
270
+ discovered_count,
271
+ self._source.source_description,
272
+ extra={"correlation_id": str(correlation_id)},
273
+ )
274
+
275
+ # Phase 2: Validate
276
+ validate_start = time.perf_counter()
277
+ valid_contracts: list[tuple[ModelDiscoveredContract, ModelHandlerContract]] = []
278
+
279
+ for contract in contracts:
280
+ parsed, error = self._validate_contract(contract)
281
+ if error:
282
+ contract_errors.append(error)
283
+ elif parsed:
284
+ valid_contracts.append((contract, parsed))
285
+
286
+ validate_ms = (time.perf_counter() - validate_start) * 1000
287
+ valid_count = len(valid_contracts)
288
+
289
+ logger.info(
290
+ "Contract validation complete: %d valid, %d errors",
291
+ valid_count,
292
+ len(contract_errors),
293
+ extra={"correlation_id": str(correlation_id)},
294
+ )
295
+
296
+ # Check for zero contracts
297
+ if valid_count == 0 and not self._config.allow_zero_contracts:
298
+ raise NoContractsFoundError(
299
+ source_description=self._source.source_description,
300
+ discovered_count=discovered_count,
301
+ valid_count=0,
302
+ )
303
+
304
+ # Phase 3: Publish
305
+ publish_start = time.perf_counter()
306
+ topic = self.resolve_topic(TOPIC_SUFFIX_CONTRACT_REGISTERED)
307
+
308
+ for contract, parsed in valid_contracts:
309
+ handler_id = parsed.handler_id
310
+
311
+ # Create event
312
+ try:
313
+ event = ModelContractRegisteredEvent(
314
+ node_name=handler_id,
315
+ node_version=parsed.contract_version,
316
+ contract_hash=contract.content_hash or "",
317
+ contract_yaml=contract.text,
318
+ correlation_id=correlation_id,
319
+ )
320
+
321
+ # Serialize to bytes
322
+ event_bytes = event.model_dump_json().encode("utf-8")
323
+ key_bytes = handler_id.encode("utf-8")
324
+
325
+ # Publish
326
+ await self._publisher.publish(
327
+ topic=topic,
328
+ key=key_bytes,
329
+ value=event_bytes,
330
+ )
331
+
332
+ published.append(handler_id)
333
+
334
+ logger.debug(
335
+ "Published contract: %s to %s",
336
+ handler_id,
337
+ topic,
338
+ extra={"correlation_id": str(correlation_id)},
339
+ )
340
+
341
+ except ValidationError as e:
342
+ # Pydantic model validation error during event creation
343
+ infra_error = ModelInfraError(
344
+ error_type="serialization_failed",
345
+ message=f"Contract event validation failed for {handler_id}: {e}",
346
+ retriable=False, # Validation errors won't fix on retry
347
+ )
348
+ infra_errors.append(infra_error)
349
+
350
+ logger.exception(
351
+ "Contract event validation failed for %s",
352
+ handler_id,
353
+ extra={"correlation_id": str(correlation_id)},
354
+ )
355
+
356
+ if self._config.fail_fast:
357
+ raise ContractPublishingInfraError(infra_errors) from e
358
+
359
+ except (TypeError, ValueError, UnicodeEncodeError) as e:
360
+ # Serialization or encoding error
361
+ infra_error = ModelInfraError(
362
+ error_type="serialization_failed",
363
+ message=f"Failed to serialize contract for {handler_id}: {e}",
364
+ retriable=False, # Serialization errors won't fix on retry
365
+ )
366
+ infra_errors.append(infra_error)
367
+
368
+ logger.exception(
369
+ "Contract serialization failed for %s: %s",
370
+ handler_id,
371
+ type(e).__name__,
372
+ extra={"correlation_id": str(correlation_id)},
373
+ )
374
+
375
+ if self._config.fail_fast:
376
+ raise ContractPublishingInfraError(infra_errors) from e
377
+
378
+ except InfraTimeoutError as e:
379
+ # Kafka timeout during publish
380
+ infra_error = ModelInfraError(
381
+ error_type="kafka_timeout",
382
+ message=f"Timeout publishing contract {handler_id}: {e}",
383
+ retriable=True,
384
+ )
385
+ infra_errors.append(infra_error)
386
+
387
+ logger.warning(
388
+ "Timeout publishing contract %s",
389
+ handler_id,
390
+ extra={"correlation_id": str(correlation_id)},
391
+ )
392
+
393
+ if self._config.fail_fast:
394
+ raise ContractPublishingInfraError(infra_errors) from e
395
+
396
+ except InfraUnavailableError as e:
397
+ # Publisher not available
398
+ infra_error = ModelInfraError(
399
+ error_type="publisher_unavailable",
400
+ message=f"Publisher unavailable for {handler_id}: {e}",
401
+ retriable=True,
402
+ )
403
+ infra_errors.append(infra_error)
404
+
405
+ logger.warning(
406
+ "Publisher unavailable for contract %s",
407
+ handler_id,
408
+ extra={"correlation_id": str(correlation_id)},
409
+ )
410
+
411
+ if self._config.fail_fast:
412
+ raise ContractPublishingInfraError(infra_errors) from e
413
+
414
+ except InfraConnectionError as e:
415
+ # Kafka connection or broker error
416
+ infra_error = ModelInfraError(
417
+ error_type="broker_down",
418
+ message=f"Kafka connection failed for {handler_id}: {e}",
419
+ retriable=True,
420
+ )
421
+ infra_errors.append(infra_error)
422
+
423
+ logger.warning(
424
+ "Kafka connection failed for contract %s",
425
+ handler_id,
426
+ extra={"correlation_id": str(correlation_id)},
427
+ )
428
+
429
+ if self._config.fail_fast:
430
+ raise ContractPublishingInfraError(infra_errors) from e
431
+
432
+ except Exception as e:
433
+ # Unexpected error - log as warning since this catch-all is unexpected
434
+ infra_error = ModelInfraError(
435
+ error_type="publish_failed",
436
+ message=f"Unexpected error publishing {handler_id}: {type(e).__name__}: {e}",
437
+ retriable=True,
438
+ )
439
+ infra_errors.append(infra_error)
440
+
441
+ logger.warning(
442
+ "Unexpected error type during contract publish for %s: %s",
443
+ handler_id,
444
+ type(e).__name__,
445
+ extra={"correlation_id": str(correlation_id)},
446
+ )
447
+ logger.exception(
448
+ "Full exception details for contract %s",
449
+ handler_id,
450
+ extra={"correlation_id": str(correlation_id)},
451
+ )
452
+
453
+ if self._config.fail_fast:
454
+ raise ContractPublishingInfraError(infra_errors) from e
455
+
456
+ publish_ms = (time.perf_counter() - publish_start) * 1000
457
+ duration_ms = (time.perf_counter() - start_time) * 1000
458
+
459
+ # Build stats
460
+ stats = ModelPublishStats(
461
+ discovered_count=discovered_count,
462
+ valid_count=valid_count,
463
+ published_count=len(published),
464
+ errored_count=len(contract_errors),
465
+ dedup_count=dedup_count,
466
+ duration_ms=duration_ms,
467
+ discover_ms=discover_ms,
468
+ validate_ms=validate_ms,
469
+ publish_ms=publish_ms,
470
+ environment=self._environment,
471
+ filesystem_count=filesystem_count,
472
+ package_count=package_count,
473
+ )
474
+
475
+ # Build result
476
+ result = ModelPublishResult(
477
+ published=published,
478
+ contract_errors=contract_errors,
479
+ infra_errors=infra_errors,
480
+ stats=stats,
481
+ )
482
+
483
+ logger.info(
484
+ "Contract publishing complete: %d published, %d contract errors, %d infra errors",
485
+ len(published),
486
+ len(contract_errors),
487
+ len(infra_errors),
488
+ extra={
489
+ "correlation_id": str(correlation_id),
490
+ "duration_ms": duration_ms,
491
+ },
492
+ )
493
+
494
+ return result
495
+
496
+ async def _discover(
497
+ self,
498
+ ) -> tuple[list[ModelDiscoveredContract], list[ModelContractError], int]:
499
+ """Discover contracts from source.
500
+
501
+ Returns:
502
+ Tuple of (contracts, errors from composite merge, dedup_count)
503
+
504
+ Note:
505
+ **Deduplication Tracking**
506
+
507
+ The ``dedup_count`` return value is only meaningful for composite sources
508
+ (``SourceContractComposite``). For single sources (filesystem-only or
509
+ package-only), ``dedup_count`` is always 0.
510
+
511
+ This is correct behavior, not a bug:
512
+
513
+ - **Single sources cannot have duplicates**: A filesystem source reads
514
+ from one directory tree; a package source reads from one module.
515
+ Duplicate handler_ids within a single source indicate a configuration
516
+ error, not a merge scenario.
517
+
518
+ - **Deduplication is a merge operation**: When ``SourceContractComposite``
519
+ merges filesystem and package sources, it may encounter the same
520
+ ``handler_id`` from both. The composite source tracks how many such
521
+ duplicates were resolved (filesystem wins by default).
522
+
523
+ - **Semantic clarity**: Reporting dedup_count=0 for single sources
524
+ accurately reflects that no deduplication occurred, rather than
525
+ hiding the metric entirely.
526
+ """
527
+ # All sources now return list[ModelDiscoveredContract]
528
+ contracts = await self._source.discover_contracts()
529
+
530
+ # Extract handler_id BEFORE sorting for ALL contracts
531
+ # (composite source does this internally, but filesystem/package do not)
532
+ # This ensures consistent sorting by (handler_id, origin, ref) regardless of source
533
+ contracts = [c.extract_handler_id() for c in contracts]
534
+
535
+ # Add content hash only to contracts that don't already have one
536
+ # (composite source already computes hashes, avoid double-hashing)
537
+ contracts = [c if c.content_hash else c.with_content_hash() for c in contracts]
538
+
539
+ # Sort for determinism - now sorts by (handler_id, origin, ref) correctly
540
+ contracts.sort(key=lambda c: c.sort_key())
541
+
542
+ # Get merge errors and dedup count from composite source (duck typing)
543
+ errors: list[ModelContractError] = []
544
+ dedup_count = 0
545
+
546
+ get_merge_errors = getattr(self._source, "get_merge_errors", None)
547
+ if callable(get_merge_errors):
548
+ errors = get_merge_errors()
549
+
550
+ get_dedup_count = getattr(self._source, "get_dedup_count", None)
551
+ if callable(get_dedup_count):
552
+ dedup_count = get_dedup_count()
553
+
554
+ return contracts, errors, dedup_count
555
+
556
+ def _validate_contract(
557
+ self,
558
+ contract: ModelDiscoveredContract,
559
+ ) -> tuple[ModelHandlerContract | None, ModelContractError | None]:
560
+ """Validate contract YAML and extract handler_id.
561
+
562
+ Parses YAML exactly once and returns both the parsed contract (on success)
563
+ or the error details (on failure). This avoids double-parsing when
564
+ validation fails.
565
+
566
+ Args:
567
+ contract: Discovered contract to validate
568
+
569
+ Returns:
570
+ Tuple of (parsed_contract, error). Exactly one will be non-None:
571
+ - (ModelHandlerContract, None) on success
572
+ - (None, ModelContractError) on failure
573
+ """
574
+ try:
575
+ # Parse YAML (only once)
576
+ data = yaml.safe_load(contract.text)
577
+ if not isinstance(data, dict):
578
+ return None, ModelContractError(
579
+ contract_path=str(contract.ref),
580
+ handler_id=None,
581
+ error_type="yaml_parse",
582
+ message="Contract YAML must be a dictionary",
583
+ )
584
+
585
+ # Validate against ModelHandlerContract
586
+ try:
587
+ parsed = ModelHandlerContract.model_validate(data)
588
+ return parsed, None
589
+ except ValidationError as e:
590
+ # Extract first error for detailed message
591
+ first_error = e.errors()[0] if e.errors() else None
592
+ if first_error:
593
+ field = ".".join(str(loc) for loc in first_error.get("loc", []))
594
+ msg = first_error.get("msg", "Validation failed")
595
+ return None, ModelContractError(
596
+ contract_path=str(contract.ref),
597
+ handler_id=data.get("handler_id"),
598
+ error_type="schema_validation",
599
+ message=f"Validation error in '{field}': {msg}",
600
+ )
601
+ return None, ModelContractError(
602
+ contract_path=str(contract.ref),
603
+ handler_id=data.get("handler_id"),
604
+ error_type="schema_validation",
605
+ message="Contract validation failed",
606
+ )
607
+
608
+ except yaml.YAMLError as e:
609
+ return None, ModelContractError(
610
+ contract_path=str(contract.ref),
611
+ handler_id=None,
612
+ error_type="yaml_parse",
613
+ message=f"Invalid YAML: {e}",
614
+ )
615
+
616
+
617
+ __all__ = ["ServiceContractPublisher"]
@@ -0,0 +1,52 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Contract Publisher Sources Module.
4
+
5
+ Provides contract source implementations for discovering contracts from
6
+ different backends:
7
+
8
+ - SourceContractFilesystem: Discovers from directory tree
9
+ - SourceContractPackage: Discovers from package resources
10
+ - SourceContractComposite: Merges multiple sources
11
+
12
+ All sources implement ProtocolContractPublisherSource.
13
+
14
+ Example:
15
+ >>> from omnibase_infra.services.contract_publisher.sources import (
16
+ ... SourceContractFilesystem,
17
+ ... SourceContractPackage,
18
+ ... SourceContractComposite,
19
+ ... )
20
+ >>> filesystem = SourceContractFilesystem(Path("/app/contracts"))
21
+ >>> contracts = await filesystem.discover_contracts()
22
+
23
+ .. versionadded:: 0.3.0
24
+ Created as part of OMN-1752 (ContractPublisher extraction).
25
+ """
26
+
27
+ from omnibase_infra.services.contract_publisher.sources.model_discovered import (
28
+ ModelDiscoveredContract,
29
+ )
30
+ from omnibase_infra.services.contract_publisher.sources.protocol import (
31
+ ProtocolContractPublisherSource,
32
+ )
33
+ from omnibase_infra.services.contract_publisher.sources.source_composite import (
34
+ SourceContractComposite,
35
+ )
36
+ from omnibase_infra.services.contract_publisher.sources.source_filesystem import (
37
+ SourceContractFilesystem,
38
+ )
39
+ from omnibase_infra.services.contract_publisher.sources.source_package import (
40
+ SourceContractPackage,
41
+ )
42
+
43
+ __all__ = [
44
+ # Protocol
45
+ "ProtocolContractPublisherSource",
46
+ # Model
47
+ "ModelDiscoveredContract",
48
+ # Implementations
49
+ "SourceContractFilesystem",
50
+ "SourceContractPackage",
51
+ "SourceContractComposite",
52
+ ]