omnibase_infra 0.2.8__py3-none-any.whl → 0.3.0__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 (88) 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/errors/__init__.py +18 -0
  5. omnibase_infra/errors/repository/__init__.py +78 -0
  6. omnibase_infra/errors/repository/errors_repository.py +424 -0
  7. omnibase_infra/event_bus/adapters/__init__.py +31 -0
  8. omnibase_infra/event_bus/adapters/adapter_protocol_event_publisher_kafka.py +517 -0
  9. omnibase_infra/mixins/mixin_async_circuit_breaker.py +113 -1
  10. omnibase_infra/models/__init__.py +9 -0
  11. omnibase_infra/models/event_bus/__init__.py +22 -0
  12. omnibase_infra/models/event_bus/model_consumer_retry_config.py +367 -0
  13. omnibase_infra/models/event_bus/model_dlq_config.py +177 -0
  14. omnibase_infra/models/event_bus/model_idempotency_config.py +131 -0
  15. omnibase_infra/models/event_bus/model_offset_policy_config.py +107 -0
  16. omnibase_infra/models/resilience/model_circuit_breaker_config.py +15 -0
  17. omnibase_infra/models/validation/__init__.py +8 -0
  18. omnibase_infra/models/validation/model_declarative_node_validation_result.py +139 -0
  19. omnibase_infra/models/validation/model_declarative_node_violation.py +169 -0
  20. omnibase_infra/nodes/architecture_validator/__init__.py +28 -7
  21. omnibase_infra/nodes/architecture_validator/constants.py +36 -0
  22. omnibase_infra/nodes/architecture_validator/handlers/__init__.py +28 -0
  23. omnibase_infra/nodes/architecture_validator/handlers/contract.yaml +120 -0
  24. omnibase_infra/nodes/architecture_validator/handlers/handler_architecture_validation.py +359 -0
  25. omnibase_infra/nodes/architecture_validator/node.py +1 -0
  26. omnibase_infra/nodes/architecture_validator/node_architecture_validator.py +48 -336
  27. omnibase_infra/nodes/contract_registry_reducer/reducer.py +12 -2
  28. omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +16 -2
  29. omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +14 -4
  30. omnibase_infra/nodes/node_ledger_projection_compute/handlers/__init__.py +18 -0
  31. omnibase_infra/nodes/node_ledger_projection_compute/handlers/contract.yaml +53 -0
  32. omnibase_infra/nodes/node_ledger_projection_compute/handlers/handler_ledger_projection.py +354 -0
  33. omnibase_infra/nodes/node_ledger_projection_compute/node.py +20 -256
  34. omnibase_infra/nodes/node_registry_effect/node.py +20 -73
  35. omnibase_infra/protocols/protocol_dispatch_engine.py +90 -0
  36. omnibase_infra/runtime/__init__.py +11 -0
  37. omnibase_infra/runtime/baseline_subscriptions.py +150 -0
  38. omnibase_infra/runtime/db/__init__.py +73 -0
  39. omnibase_infra/runtime/db/models/__init__.py +41 -0
  40. omnibase_infra/runtime/db/models/model_repository_runtime_config.py +211 -0
  41. omnibase_infra/runtime/db/postgres_repository_runtime.py +545 -0
  42. omnibase_infra/runtime/event_bus_subcontract_wiring.py +455 -24
  43. omnibase_infra/runtime/kafka_contract_source.py +13 -5
  44. omnibase_infra/runtime/service_message_dispatch_engine.py +112 -0
  45. omnibase_infra/runtime/service_runtime_host_process.py +6 -11
  46. omnibase_infra/services/__init__.py +36 -0
  47. omnibase_infra/services/contract_publisher/__init__.py +95 -0
  48. omnibase_infra/services/contract_publisher/config.py +199 -0
  49. omnibase_infra/services/contract_publisher/errors.py +243 -0
  50. omnibase_infra/services/contract_publisher/models/__init__.py +28 -0
  51. omnibase_infra/services/contract_publisher/models/model_contract_error.py +67 -0
  52. omnibase_infra/services/contract_publisher/models/model_infra_error.py +62 -0
  53. omnibase_infra/services/contract_publisher/models/model_publish_result.py +112 -0
  54. omnibase_infra/services/contract_publisher/models/model_publish_stats.py +79 -0
  55. omnibase_infra/services/contract_publisher/service.py +617 -0
  56. omnibase_infra/services/contract_publisher/sources/__init__.py +52 -0
  57. omnibase_infra/services/contract_publisher/sources/model_discovered.py +155 -0
  58. omnibase_infra/services/contract_publisher/sources/protocol.py +101 -0
  59. omnibase_infra/services/contract_publisher/sources/source_composite.py +309 -0
  60. omnibase_infra/services/contract_publisher/sources/source_filesystem.py +174 -0
  61. omnibase_infra/services/contract_publisher/sources/source_package.py +221 -0
  62. omnibase_infra/services/observability/__init__.py +40 -0
  63. omnibase_infra/services/observability/agent_actions/__init__.py +64 -0
  64. omnibase_infra/services/observability/agent_actions/config.py +209 -0
  65. omnibase_infra/services/observability/agent_actions/consumer.py +1320 -0
  66. omnibase_infra/services/observability/agent_actions/models/__init__.py +87 -0
  67. omnibase_infra/services/observability/agent_actions/models/model_agent_action.py +142 -0
  68. omnibase_infra/services/observability/agent_actions/models/model_detection_failure.py +125 -0
  69. omnibase_infra/services/observability/agent_actions/models/model_envelope.py +85 -0
  70. omnibase_infra/services/observability/agent_actions/models/model_execution_log.py +159 -0
  71. omnibase_infra/services/observability/agent_actions/models/model_performance_metric.py +130 -0
  72. omnibase_infra/services/observability/agent_actions/models/model_routing_decision.py +138 -0
  73. omnibase_infra/services/observability/agent_actions/models/model_transformation_event.py +124 -0
  74. omnibase_infra/services/observability/agent_actions/tests/__init__.py +20 -0
  75. omnibase_infra/services/observability/agent_actions/tests/test_consumer.py +1154 -0
  76. omnibase_infra/services/observability/agent_actions/tests/test_models.py +645 -0
  77. omnibase_infra/services/observability/agent_actions/tests/test_writer.py +709 -0
  78. omnibase_infra/services/observability/agent_actions/writer_postgres.py +926 -0
  79. omnibase_infra/validation/__init__.py +12 -0
  80. omnibase_infra/validation/contracts/declarative_node.validation.yaml +143 -0
  81. omnibase_infra/validation/infra_validators.py +4 -1
  82. omnibase_infra/validation/validation_exemptions.yaml +111 -0
  83. omnibase_infra/validation/validator_declarative_node.py +850 -0
  84. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/METADATA +2 -2
  85. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/RECORD +88 -30
  86. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/WHEEL +0 -0
  87. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/entry_points.txt +0 -0
  88. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -21,9 +21,12 @@ Event Topics (Platform Reserved):
21
21
  - Registration: {env}.{TOPIC_SUFFIX_CONTRACT_REGISTERED}
22
22
  - Deregistration: {env}.{TOPIC_SUFFIX_CONTRACT_DEREGISTERED}
23
23
 
24
- Topic suffixes are imported from omnibase_core.constants for single source of truth.
24
+ Topic suffixes are imported from omnibase_core.constants. For runtime subscription
25
+ wiring, use baseline_subscriptions.BASELINE_CONTRACT_TOPICS which aggregates the
26
+ platform-reserved contract topics.
25
27
 
26
28
  See Also:
29
+ - baseline_subscriptions: Baseline topic assembly for runtime wiring
27
30
  - HandlerContractSource: Filesystem-based discovery
28
31
  - RegistryContractSource: Consul KV-based discovery
29
32
  - ProtocolContractSource: Protocol definition
@@ -76,10 +79,6 @@ from uuid import UUID, uuid4
76
79
  import yaml
77
80
  from pydantic import ValidationError
78
81
 
79
- from omnibase_core.constants import (
80
- TOPIC_SUFFIX_CONTRACT_DEREGISTERED,
81
- TOPIC_SUFFIX_CONTRACT_REGISTERED,
82
- )
83
82
  from omnibase_core.models.contracts.model_handler_contract import ModelHandlerContract
84
83
  from omnibase_core.models.errors import ModelOnexError
85
84
  from omnibase_core.models.events import (
@@ -94,6 +93,12 @@ from omnibase_infra.models.handlers import (
94
93
  ModelHandlerDescriptor,
95
94
  ModelHandlerIdentifier,
96
95
  )
96
+ from omnibase_infra.runtime.baseline_subscriptions import (
97
+ BASELINE_CONTRACT_TOPICS,
98
+ BASELINE_PLATFORM_TOPICS,
99
+ TOPIC_SUFFIX_CONTRACT_DEREGISTERED,
100
+ TOPIC_SUFFIX_CONTRACT_REGISTERED,
101
+ )
97
102
  from omnibase_infra.runtime.protocol_contract_source import ProtocolContractSource
98
103
 
99
104
  logger = logging.getLogger(__name__)
@@ -979,6 +984,9 @@ __all__ = [
979
984
  # Re-exported from omnibase_core for convenience
980
985
  "ModelContractDeregisteredEvent",
981
986
  "ModelContractRegisteredEvent",
987
+ # Re-exported from baseline_subscriptions for runtime wiring (OMN-1696)
988
+ "BASELINE_CONTRACT_TOPICS",
989
+ "BASELINE_PLATFORM_TOPICS",
982
990
  "TOPIC_SUFFIX_CONTRACT_DEREGISTERED",
983
991
  "TOPIC_SUFFIX_CONTRACT_REGISTERED",
984
992
  ]
@@ -1420,6 +1420,118 @@ class MessageDispatchEngine:
1420
1420
  output_events=[],
1421
1421
  )
1422
1422
 
1423
+ async def dispatch_with_transaction(
1424
+ self,
1425
+ *,
1426
+ topic: str,
1427
+ envelope: ModelEventEnvelope[object],
1428
+ tx: object,
1429
+ ) -> ModelDispatchResult:
1430
+ """Dispatch an event envelope with database transaction context.
1431
+
1432
+ This method enables transaction-scoped dispatch for correct idempotency
1433
+ semantics. The transaction parameter allows handlers to participate in
1434
+ the same database transaction as the idempotency insert, ensuring
1435
+ exactly-once processing.
1436
+
1437
+ Current Implementation:
1438
+ This initial implementation delegates to the standard ``dispatch()``
1439
+ method. The ``tx`` parameter is stored in the dispatch context for
1440
+ handlers that need transactional access. Future iterations may pass
1441
+ ``tx`` directly to handlers via a context object.
1442
+
1443
+ Design Decision:
1444
+ The ``tx`` parameter is typed as ``object`` rather than a specific
1445
+ database type (e.g., ``asyncpg.Connection``) to avoid leaking
1446
+ infrastructure types into the protocol. Handlers that need the
1447
+ transaction should type-narrow based on their database backend.
1448
+
1449
+ Thread Safety:
1450
+ This method is safe for concurrent calls from multiple coroutines.
1451
+ However, the transaction context (``tx``) is typically connection-
1452
+ scoped and should not be shared across coroutines.
1453
+
1454
+ Args:
1455
+ topic: The full topic name from which the envelope was consumed.
1456
+ Used for routing and logging context.
1457
+ Example: "dev.onex.evt.node.introspected.v1"
1458
+ envelope: The deserialized event envelope containing the payload.
1459
+ The payload type varies by topic/event type.
1460
+ tx: Database transaction/connection context. Typed as ``object``
1461
+ to avoid infrastructure type leakage; handlers should type-
1462
+ narrow based on their database backend (e.g.,
1463
+ ``asyncpg.Connection``, ``aiosqlite.Connection``).
1464
+
1465
+ Returns:
1466
+ ModelDispatchResult with dispatch status, metrics, and dispatcher outputs.
1467
+
1468
+ Raises:
1469
+ ModelOnexError: If engine is not frozen (INVALID_STATE)
1470
+ ModelOnexError: If topic is empty (INVALID_PARAMETER)
1471
+ ModelOnexError: If envelope is None (INVALID_PARAMETER)
1472
+
1473
+ Example:
1474
+ .. code-block:: python
1475
+
1476
+ # Idempotency consumer with transaction context
1477
+ async with pool.acquire() as conn:
1478
+ async with conn.transaction():
1479
+ # Insert idempotency record and dispatch in same transaction
1480
+ await insert_idempotency_record(conn, message_id)
1481
+ result = await engine.dispatch_with_transaction(
1482
+ topic=topic,
1483
+ envelope=envelope,
1484
+ tx=conn,
1485
+ )
1486
+ # Both committed atomically
1487
+
1488
+ Related:
1489
+ - OMN-1740: Transaction-scoped dispatch for idempotency
1490
+ - dispatch(): Non-transactional dispatch method
1491
+
1492
+ .. versionadded:: 0.2.9
1493
+ """
1494
+ # Enforce freeze contract (same as dispatch())
1495
+ if not self._frozen:
1496
+ raise ModelOnexError(
1497
+ message="dispatch_with_transaction() called before freeze(). "
1498
+ "Registration MUST complete and freeze() MUST be called before dispatch. "
1499
+ "This is required for thread safety.",
1500
+ error_code=EnumCoreErrorCode.INVALID_STATE,
1501
+ )
1502
+
1503
+ # Validate inputs (same as dispatch())
1504
+ if not topic or not topic.strip():
1505
+ raise ModelOnexError(
1506
+ message="Topic cannot be empty or whitespace.",
1507
+ error_code=EnumCoreErrorCode.INVALID_PARAMETER,
1508
+ )
1509
+
1510
+ if envelope is None:
1511
+ raise ModelOnexError(
1512
+ message="Cannot dispatch None envelope. ModelEventEnvelope is required.",
1513
+ error_code=EnumCoreErrorCode.INVALID_PARAMETER,
1514
+ )
1515
+
1516
+ # Log transaction context at DEBUG level for traceability
1517
+ correlation_id = envelope.correlation_id or uuid4()
1518
+ self._logger.debug(
1519
+ "dispatch_with_transaction called with tx context (tx_type=%s)",
1520
+ type(tx).__name__,
1521
+ extra=self._build_log_context(
1522
+ topic=topic,
1523
+ correlation_id=correlation_id,
1524
+ trace_id=envelope.trace_id,
1525
+ ),
1526
+ )
1527
+
1528
+ # Current implementation: delegate to standard dispatch()
1529
+ # The tx parameter is available for future handler context injection
1530
+ # TODO(OMN-1740): Pass tx to handlers via dispatch context when needed
1531
+ _ = tx # Explicitly acknowledge tx parameter for future use
1532
+
1533
+ return await self.dispatch(topic=topic, envelope=envelope)
1534
+
1423
1535
  def _find_matching_dispatchers(
1424
1536
  self,
1425
1537
  topic: str,
@@ -2730,18 +2730,12 @@ class RuntimeHostProcess:
2730
2730
  # Import architecture validator components
2731
2731
  from omnibase_infra.errors import ArchitectureViolationError
2732
2732
  from omnibase_infra.nodes.architecture_validator import (
2733
+ HandlerArchitectureValidation,
2733
2734
  ModelArchitectureValidationRequest,
2734
- NodeArchitectureValidatorCompute,
2735
2735
  )
2736
2736
 
2737
- # Create or get container
2738
- container = self._get_or_create_container()
2739
-
2740
- # Instantiate validator with rules
2741
- validator = NodeArchitectureValidatorCompute(
2742
- container=container,
2743
- rules=self._architecture_rules,
2744
- )
2737
+ # Create handler with rules (declarative pattern - handler owns the logic)
2738
+ handler = HandlerArchitectureValidation(rules=self._architecture_rules)
2745
2739
 
2746
2740
  # Build validation request
2747
2741
  # Note: At this point, handlers haven't been instantiated yet (that happens
@@ -2769,8 +2763,8 @@ class RuntimeHostProcess:
2769
2763
  handlers=tuple(handler_classes),
2770
2764
  )
2771
2765
 
2772
- # Execute validation
2773
- result = validator.compute(request)
2766
+ # Execute validation via handler
2767
+ result = handler.validate_architecture(request)
2774
2768
 
2775
2769
  # Separate blocking and non-blocking violations
2776
2770
  blocking_violations = tuple(v for v in result.violations if v.blocks_startup())
@@ -2924,6 +2918,7 @@ class RuntimeHostProcess:
2924
2918
  event_bus=cast("ProtocolEventBusSubscriber", self._event_bus),
2925
2919
  dispatch_engine=self._dispatch_engine,
2926
2920
  environment=environment,
2921
+ node_name="runtime-host",
2927
2922
  )
2928
2923
 
2929
2924
  # Wire subscriptions for each handler with a contract
@@ -13,6 +13,7 @@ Exports:
13
13
  ModelTimeoutEmissionResult: Result model for timeout emission processing
14
14
  ModelTimeoutQueryResult: Result model for timeout queries
15
15
  ServiceCapabilityQuery: Query nodes by capability, not by name
16
+ ServiceContractPublisher: Publish contracts to Kafka for dynamic discovery
16
17
  ServiceNodeSelector: Select nodes from candidates using various strategies
17
18
  ServiceSnapshot: Generic snapshot service for point-in-time state capture
18
19
  ServiceTimeoutEmitter: Emitter for timeout events with markers
@@ -24,6 +25,25 @@ Exports:
24
25
  """
25
26
 
26
27
  from omnibase_infra.enums import EnumSelectionStrategy
28
+
29
+ # Contract publisher service (OMN-1752)
30
+ from omnibase_infra.services.contract_publisher import (
31
+ ContractPublisherError,
32
+ ContractPublishingInfraError,
33
+ ContractSourceNotConfiguredError,
34
+ ModelContractError,
35
+ ModelContractPublisherConfig,
36
+ ModelDiscoveredContract,
37
+ ModelInfraError,
38
+ ModelPublishResult,
39
+ ModelPublishStats,
40
+ NoContractsFoundError,
41
+ ProtocolContractPublisherSource,
42
+ ServiceContractPublisher,
43
+ SourceContractComposite,
44
+ SourceContractFilesystem,
45
+ SourceContractPackage,
46
+ )
27
47
  from omnibase_infra.services.corpus_capture import CorpusCapture
28
48
  from omnibase_infra.services.service_capability_query import ServiceCapabilityQuery
29
49
  from omnibase_infra.services.service_node_selector import (
@@ -86,4 +106,20 @@ __all__ = [
86
106
  "SessionEventConsumer",
87
107
  "SessionSnapshotStore",
88
108
  "SessionStoreNotInitializedError",
109
+ # Contract publisher service (OMN-1752)
110
+ "ContractPublisherError",
111
+ "ContractPublishingInfraError",
112
+ "ContractSourceNotConfiguredError",
113
+ "ModelContractError",
114
+ "ModelContractPublisherConfig",
115
+ "ModelDiscoveredContract",
116
+ "ModelInfraError",
117
+ "ModelPublishResult",
118
+ "ModelPublishStats",
119
+ "NoContractsFoundError",
120
+ "ProtocolContractPublisherSource",
121
+ "ServiceContractPublisher",
122
+ "SourceContractComposite",
123
+ "SourceContractFilesystem",
124
+ "SourceContractPackage",
89
125
  ]
@@ -0,0 +1,95 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Contract Publisher Service Module.
4
+
5
+ Provides infrastructure for bulk contract discovery and publishing to Kafka.
6
+ This service discovers contracts from configured sources (filesystem, package)
7
+ and publishes them to the contract registration topic for dynamic discovery.
8
+
9
+ Moved from omniclaude as part of OMN-1752 (ARCH-002 compliance).
10
+
11
+ Design Principle:
12
+ Infra standardizes the publishing *engine*; apps provide *source configuration*.
13
+
14
+ Flow:
15
+ Source → Validate → Normalize → Publish → Report
16
+ - Sources provide origin (filesystem, package)
17
+ - Event bus provides distribution (broadcast plane)
18
+
19
+ Example:
20
+ >>> from omnibase_infra.services.contract_publisher import (
21
+ ... ServiceContractPublisher,
22
+ ... ModelContractPublisherConfig,
23
+ ... )
24
+ >>> config = ModelContractPublisherConfig(
25
+ ... mode="filesystem",
26
+ ... filesystem_root=Path("/app/contracts/handlers"),
27
+ ... )
28
+ >>> publisher = await ServiceContractPublisher.from_container(container, config)
29
+ >>> result = await publisher.publish_all()
30
+ >>> if result:
31
+ ... print(f"Published {len(result.published)} contracts")
32
+ ... else:
33
+ ... print(f"No contracts published, {len(result.contract_errors)} errors")
34
+
35
+ .. versionadded:: 0.3.0
36
+ Created as part of OMN-1752 (ContractPublisher extraction).
37
+ """
38
+
39
+ # Config
40
+ from omnibase_infra.services.contract_publisher.config import (
41
+ ModelContractPublisherConfig,
42
+ )
43
+
44
+ # Errors
45
+ from omnibase_infra.services.contract_publisher.errors import (
46
+ ContractPublisherError,
47
+ ContractPublishingInfraError,
48
+ ContractSourceNotConfiguredError,
49
+ NoContractsFoundError,
50
+ )
51
+
52
+ # Result models
53
+ from omnibase_infra.services.contract_publisher.models import (
54
+ ModelContractError,
55
+ ModelInfraError,
56
+ ModelPublishResult,
57
+ ModelPublishStats,
58
+ )
59
+
60
+ # Service
61
+ from omnibase_infra.services.contract_publisher.service import (
62
+ ServiceContractPublisher,
63
+ )
64
+
65
+ # Sources
66
+ from omnibase_infra.services.contract_publisher.sources import (
67
+ ModelDiscoveredContract,
68
+ ProtocolContractPublisherSource,
69
+ SourceContractComposite,
70
+ SourceContractFilesystem,
71
+ SourceContractPackage,
72
+ )
73
+
74
+ __all__ = [
75
+ # Service
76
+ "ServiceContractPublisher",
77
+ # Config
78
+ "ModelContractPublisherConfig",
79
+ # Result models
80
+ "ModelPublishResult",
81
+ "ModelPublishStats",
82
+ "ModelContractError",
83
+ "ModelInfraError",
84
+ # Errors
85
+ "ContractPublisherError",
86
+ "ContractSourceNotConfiguredError",
87
+ "ContractPublishingInfraError",
88
+ "NoContractsFoundError",
89
+ # Sources
90
+ "ProtocolContractPublisherSource",
91
+ "ModelDiscoveredContract",
92
+ "SourceContractFilesystem",
93
+ "SourceContractPackage",
94
+ "SourceContractComposite",
95
+ ]
@@ -0,0 +1,199 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Contract Publisher Configuration Model.
4
+
5
+ This module defines the configuration model for contract publishing operations.
6
+ Configuration is validated at construction time using Pydantic model validators.
7
+
8
+ Configuration Options:
9
+ mode: Publishing source mode (filesystem, package, composite)
10
+ filesystem_root: Root directory for filesystem mode
11
+ package_module: Module name for package mode
12
+ fail_fast: Whether to raise on infrastructure errors
13
+ allow_zero_contracts: Whether to allow empty publish results
14
+ environment: Environment prefix for Kafka topics
15
+
16
+ Environment Resolution:
17
+ The environment is resolved with precedence:
18
+ 1. config.environment (if provided)
19
+ 2. ONEX_ENV environment variable
20
+ 3. Default "dev"
21
+
22
+ Related:
23
+ - OMN-1752: Extract ContractPublisher to omnibase_infra
24
+ - ARCH-002: Runtime owns all Kafka plumbing
25
+
26
+ .. versionadded:: 0.3.0
27
+ Created as part of OMN-1752 (ContractPublisher extraction).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ from pathlib import Path
34
+ from typing import Literal, Self
35
+
36
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
37
+
38
+
39
+ class ModelContractPublisherConfig(BaseModel):
40
+ """Configuration for contract publishing.
41
+
42
+ Defines the source mode and configuration for discovering and
43
+ publishing contracts to Kafka.
44
+
45
+ Source Modes:
46
+ filesystem: Discover contracts from a directory tree
47
+ package: Discover contracts from installed package resources
48
+ composite: Merge both sources with conflict detection
49
+
50
+ Configuration Rules (enforced by validator):
51
+ - filesystem mode requires filesystem_root
52
+ - package mode requires package_module
53
+ - composite mode requires at least one of filesystem_root or package_module
54
+
55
+ Attributes:
56
+ mode: Publishing source mode
57
+ filesystem_root: Root directory for filesystem discovery
58
+ package_module: Module name for package resource discovery
59
+ fail_fast: If True, raise immediately on infrastructure errors
60
+ allow_zero_contracts: If True, allow empty publish results
61
+ environment: Environment prefix for topics (defaults via resolve_environment)
62
+
63
+ Example:
64
+ >>> config = ModelContractPublisherConfig(
65
+ ... mode="filesystem",
66
+ ... filesystem_root=Path("/app/contracts/handlers"),
67
+ ... fail_fast=True,
68
+ ... allow_zero_contracts=False,
69
+ ... )
70
+ >>> env = config.resolve_environment()
71
+ >>> print(f"Publishing to {env}.onex.evt.contract-registered.v1")
72
+
73
+ .. versionadded:: 0.3.0
74
+ """
75
+
76
+ model_config = ConfigDict(frozen=True, extra="forbid")
77
+
78
+ mode: Literal["filesystem", "package", "composite"] = Field(
79
+ description="Publishing source mode"
80
+ )
81
+ filesystem_root: Path | None = Field(
82
+ default=None,
83
+ description="Root directory for filesystem discovery",
84
+ )
85
+ package_module: str | None = Field(
86
+ default=None,
87
+ description="Module name for package resource discovery (e.g., 'myapp.contracts')",
88
+ )
89
+ fail_fast: bool = Field(
90
+ default=True,
91
+ description="If True, raise immediately on infrastructure errors",
92
+ )
93
+ allow_zero_contracts: bool = Field(
94
+ default=False,
95
+ description="If True, allow empty publish results without raising",
96
+ )
97
+ environment: str | None = Field(
98
+ default=None,
99
+ description="Environment prefix for topics (resolved via resolve_environment)",
100
+ )
101
+
102
+ @model_validator(mode="after")
103
+ def validate_source_configured(self) -> Self:
104
+ """Validate that required source fields are configured for the mode.
105
+
106
+ Rules:
107
+ - filesystem mode requires filesystem_root
108
+ - package mode requires package_module
109
+ - composite mode requires at least one source
110
+
111
+ Returns:
112
+ Self if validation passes
113
+
114
+ Raises:
115
+ ValueError: If required source configuration is missing
116
+ """
117
+ match self.mode:
118
+ case "filesystem":
119
+ if not self.filesystem_root:
120
+ raise ValueError("filesystem mode requires filesystem_root")
121
+ case "package":
122
+ if not self.package_module:
123
+ raise ValueError("package mode requires package_module")
124
+ case "composite":
125
+ if not self.filesystem_root and not self.package_module:
126
+ raise ValueError(
127
+ "composite mode requires at least one source "
128
+ "(filesystem_root or package_module)"
129
+ )
130
+ return self
131
+
132
+ def resolve_environment(self) -> str:
133
+ """Resolve environment with precedence: config > env var > 'dev'.
134
+
135
+ Resolution Order:
136
+ 1. self.environment (if provided and non-empty after normalization)
137
+ 2. ONEX_ENV environment variable (if set and non-empty after normalization)
138
+ 3. Default "dev"
139
+
140
+ Normalization:
141
+ - Whitespace is stripped
142
+ - Trailing dots are removed (to prevent "dev..topic" issues)
143
+
144
+ Note:
145
+ Whitespace-only strings (e.g., " ") are treated as empty and
146
+ fall through to the next priority level.
147
+
148
+ Returns:
149
+ Resolved environment string, normalized
150
+
151
+ Example:
152
+ >>> config = ModelContractPublisherConfig(
153
+ ... mode="filesystem",
154
+ ... filesystem_root=Path("/app"),
155
+ ... environment="prod",
156
+ ... )
157
+ >>> config.resolve_environment()
158
+ 'prod'
159
+
160
+ >>> # With no config.environment and ONEX_ENV=staging
161
+ >>> config2 = ModelContractPublisherConfig(
162
+ ... mode="filesystem",
163
+ ... filesystem_root=Path("/app"),
164
+ ... )
165
+ >>> # Returns "staging" if ONEX_ENV is set, else "dev"
166
+ """
167
+ # Priority 1: Explicit config (if non-empty after normalization)
168
+ if self.environment:
169
+ normalized = self._normalize_environment(self.environment)
170
+ if normalized:
171
+ return normalized
172
+
173
+ # Priority 2: Environment variable (if non-empty after normalization)
174
+ env_var = os.getenv("ONEX_ENV", "")
175
+ if env_var:
176
+ normalized = self._normalize_environment(env_var)
177
+ if normalized:
178
+ return normalized
179
+
180
+ # Priority 3: Default
181
+ return "dev"
182
+
183
+ @staticmethod
184
+ def _normalize_environment(value: str) -> str:
185
+ """Normalize environment string.
186
+
187
+ Strips whitespace and removes trailing dots to prevent
188
+ topic formatting issues like "dev..topic".
189
+
190
+ Args:
191
+ value: Raw environment value
192
+
193
+ Returns:
194
+ Normalized environment string
195
+ """
196
+ return value.strip().rstrip(".")
197
+
198
+
199
+ __all__ = ["ModelContractPublisherConfig"]