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
@@ -0,0 +1,155 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Discovered Contract Model.
4
+
5
+ Internal model for contracts discovered by sources. Using a Pydantic model
6
+ instead of tuples prevents position bugs and makes error reporting cleaner.
7
+
8
+ This model is populated in stages:
9
+ 1. Discovery: origin, ref, text are set
10
+ 2. Parsing: handler_id is extracted from YAML
11
+ 3. Hashing: content_hash (SHA-256) is computed
12
+
13
+ .. versionadded:: 0.3.0
14
+ Created as part of OMN-1752 (ContractPublisher extraction).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import logging
21
+ from pathlib import Path
22
+ from typing import Literal
23
+
24
+ import yaml
25
+ from pydantic import BaseModel, ConfigDict, Field
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class ModelDiscoveredContract(BaseModel):
31
+ """Internal model for discovered contracts.
32
+
33
+ Represents a contract discovered by a source (filesystem or package)
34
+ before validation and publishing. This model prevents tuple position
35
+ bugs and provides a clear structure for error reporting.
36
+
37
+ Population Stages:
38
+ 1. Discovery: origin, ref, text are set by the source
39
+ 2. Parsing: handler_id is extracted from YAML (may fail)
40
+ 3. Hashing: content_hash is computed for dedup/conflict detection
41
+
42
+ Attributes:
43
+ origin: Source type that discovered this contract
44
+ ref: Path (filesystem) or resource path (package)
45
+ text: Raw YAML content
46
+ handler_id: Extracted from contract YAML, None if parsing failed
47
+ content_hash: SHA-256 hash of text, for dedup/conflict detection
48
+
49
+ Example:
50
+ >>> contract = ModelDiscoveredContract(
51
+ ... origin="filesystem",
52
+ ... ref=Path("/app/contracts/foo/contract.yaml"),
53
+ ... text="handler_id: foo.handler\\n...",
54
+ ... )
55
+ >>> contract = contract.with_parsed_data(
56
+ ... handler_id="foo.handler",
57
+ ... )
58
+ >>> contract = contract.with_content_hash()
59
+
60
+ .. versionadded:: 0.3.0
61
+ """
62
+
63
+ model_config = ConfigDict(frozen=True, extra="forbid")
64
+
65
+ origin: Literal["filesystem", "package"] = Field(
66
+ description="Source type that discovered this contract"
67
+ )
68
+ ref: Path | str = Field(description="Path (filesystem) or resource path (package)")
69
+ text: str = Field(description="Raw YAML content")
70
+ handler_id: str | None = Field(
71
+ default=None,
72
+ description="Handler ID extracted from YAML, None if parsing failed",
73
+ )
74
+ content_hash: str | None = Field(
75
+ default=None,
76
+ description="SHA-256 hash of text for dedup/conflict detection",
77
+ )
78
+
79
+ def with_parsed_data(self, handler_id: str) -> ModelDiscoveredContract:
80
+ """Return new instance with handler_id populated.
81
+
82
+ Args:
83
+ handler_id: Handler ID extracted from contract YAML
84
+
85
+ Returns:
86
+ New instance with handler_id set
87
+ """
88
+ return self.model_copy(update={"handler_id": handler_id})
89
+
90
+ def extract_handler_id(self) -> ModelDiscoveredContract:
91
+ """Extract handler_id from YAML and return new instance with it populated.
92
+
93
+ Parses the YAML text content to extract handler_id early, enabling
94
+ proper deterministic sorting and deduplication before validation.
95
+
96
+ Returns:
97
+ New instance with handler_id if extraction succeeded,
98
+ self unchanged if parsing failed (will fail validation later).
99
+ """
100
+ # Skip if handler_id already extracted
101
+ if self.handler_id is not None:
102
+ return self
103
+
104
+ try:
105
+ data = yaml.safe_load(self.text)
106
+ if isinstance(data, dict) and "handler_id" in data:
107
+ handler_id = data["handler_id"]
108
+ if isinstance(handler_id, str) and handler_id:
109
+ return self.with_parsed_data(handler_id=handler_id)
110
+ except yaml.YAMLError:
111
+ # YAML parse errors will be caught during validation
112
+ logger.debug(
113
+ "Failed to extract handler_id from %s:%s (YAML parse error)",
114
+ self.origin,
115
+ self.ref,
116
+ )
117
+ return self
118
+
119
+ def with_content_hash(self) -> ModelDiscoveredContract:
120
+ """Return new instance with content_hash computed.
121
+
122
+ Computes SHA-256 hash of the text content for use in
123
+ deduplication and conflict detection.
124
+
125
+ Returns:
126
+ New instance with content_hash set
127
+ """
128
+ hash_value = hashlib.sha256(self.text.encode("utf-8")).hexdigest()
129
+ return self.model_copy(update={"content_hash": hash_value})
130
+
131
+ @staticmethod
132
+ def compute_content_hash(text: str) -> str:
133
+ """Compute SHA-256 hash of text content.
134
+
135
+ Args:
136
+ text: Raw YAML content
137
+
138
+ Returns:
139
+ Hexadecimal SHA-256 hash string
140
+ """
141
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
142
+
143
+ def sort_key(self) -> tuple[str, str, str]:
144
+ """Return sort key for deterministic ordering.
145
+
146
+ Sorts by (handler_id, origin, ref) to ensure consistent
147
+ ordering across runs regardless of filesystem order.
148
+
149
+ Returns:
150
+ Tuple for sorting: (handler_id or "", origin, str(ref))
151
+ """
152
+ return (self.handler_id or "", self.origin, str(self.ref))
153
+
154
+
155
+ __all__ = ["ModelDiscoveredContract"]
@@ -0,0 +1,101 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Contract Publisher Source Protocol.
4
+
5
+ Defines the protocol interface for contract sources. Sources are responsible
6
+ for discovering contracts from their respective backends (filesystem, package
7
+ resources, etc.).
8
+
9
+ This protocol is distinct from ProtocolContractSource (used for handler
10
+ discovery) because it returns raw contracts for publishing rather than
11
+ parsed handler descriptors.
12
+
13
+ .. versionadded:: 0.3.0
14
+ Created as part of OMN-1752 (ContractPublisher extraction).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Protocol, runtime_checkable
20
+
21
+ from omnibase_infra.services.contract_publisher.sources.model_discovered import (
22
+ ModelDiscoveredContract,
23
+ )
24
+
25
+
26
+ @runtime_checkable
27
+ class ProtocolContractPublisherSource(Protocol):
28
+ """Protocol for contract publisher sources.
29
+
30
+ Defines the interface for sources that discover contracts for bulk
31
+ publishing. Implementations must provide discovery of contracts
32
+ from their specific backend.
33
+
34
+ Methods:
35
+ source_type: Returns identifier for the source type
36
+ source_description: Returns human-readable description
37
+ discover_contracts: Discovers all contracts from the source
38
+
39
+ Implementations:
40
+ - SourceContractFilesystem: Discovers from directory tree
41
+ - SourceContractPackage: Discovers from package resources
42
+ - SourceContractComposite: Merges multiple sources
43
+
44
+ Example:
45
+ >>> class MySource:
46
+ ... @property
47
+ ... def source_type(self) -> str:
48
+ ... return "custom"
49
+ ...
50
+ ... @property
51
+ ... def source_description(self) -> str:
52
+ ... return "custom: my-source"
53
+ ...
54
+ ... async def discover_contracts(self) -> list[ModelDiscoveredContract]:
55
+ ... return []
56
+
57
+ .. versionadded:: 0.3.0
58
+ """
59
+
60
+ @property
61
+ def source_type(self) -> str:
62
+ """Return source type identifier.
63
+
64
+ Used for logging and statistics. Should be a short, lowercase
65
+ identifier like "filesystem" or "package".
66
+
67
+ Returns:
68
+ Source type identifier string
69
+ """
70
+ ...
71
+
72
+ @property
73
+ def source_description(self) -> str:
74
+ """Return human-readable source description.
75
+
76
+ Used for error messages and logging. Should include the source
77
+ type and any relevant configuration details.
78
+
79
+ Returns:
80
+ Human-readable description (e.g., "filesystem: /app/contracts")
81
+ """
82
+ ...
83
+
84
+ async def discover_contracts(self) -> list[ModelDiscoveredContract]:
85
+ """Discover all contracts from this source.
86
+
87
+ Searches the source backend for contract.yaml files and returns
88
+ them as ModelDiscoveredContract instances. The handler_id and
89
+ content_hash fields are NOT populated at this stage - they are
90
+ filled in during validation.
91
+
92
+ Returns:
93
+ List of discovered contracts with origin, ref, and text populated
94
+
95
+ Raises:
96
+ OSError: If the source location is inaccessible
97
+ """
98
+ ...
99
+
100
+
101
+ __all__ = ["ProtocolContractPublisherSource"]
@@ -0,0 +1,309 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Composite Contract Source.
4
+
5
+ Merges contracts from filesystem and package sources with deterministic
6
+ conflict detection. Both sources are discovered, and contracts are merged
7
+ by handler_id.
8
+
9
+ Merge Rules:
10
+ 1. Discover from both configured sources
11
+ 2. For each contract, parse handler_id and compute content_hash
12
+ 3. If same handler_id appears in both sources:
13
+ - Same hash → dedup silently (use first occurrence)
14
+ - Different hash → ModelContractError("duplicate_conflict")
15
+
16
+ Ordering:
17
+ Results are sorted by (handler_id, origin, ref) for deterministic
18
+ ordering across runs regardless of filesystem order.
19
+
20
+ .. versionadded:: 0.3.0
21
+ Created as part of OMN-1752 (ContractPublisher extraction).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from typing import TYPE_CHECKING
28
+
29
+ from omnibase_infra.services.contract_publisher.models import ModelContractError
30
+ from omnibase_infra.services.contract_publisher.sources.model_discovered import (
31
+ ModelDiscoveredContract,
32
+ )
33
+
34
+ if TYPE_CHECKING:
35
+ from omnibase_infra.services.contract_publisher.sources.source_filesystem import (
36
+ SourceContractFilesystem,
37
+ )
38
+ from omnibase_infra.services.contract_publisher.sources.source_package import (
39
+ SourceContractPackage,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ class SourceContractComposite:
46
+ """Composite source that merges filesystem and package sources.
47
+
48
+ Discovers contracts from both configured sources and merges them
49
+ with deterministic conflict detection. At least one source must
50
+ be configured.
51
+
52
+ Conflict Detection:
53
+ When the same handler_id appears in both sources:
54
+ - If content hash matches: Deduplicate silently (keep first)
55
+ - If content hash differs: Generate ModelContractError
56
+
57
+ Ordering:
58
+ All results are sorted by (handler_id, origin, ref) to ensure
59
+ deterministic ordering across runs.
60
+
61
+ Attributes:
62
+ _filesystem_source: Optional filesystem source
63
+ _package_source: Optional package source
64
+ _last_merge_errors: Errors from last discover_contracts call
65
+
66
+ Example:
67
+ >>> filesystem = SourceContractFilesystem(Path("/app/contracts"))
68
+ >>> package = SourceContractPackage("myapp.contracts")
69
+ >>> composite = SourceContractComposite(filesystem, package)
70
+ >>> contracts = await composite.discover_contracts()
71
+ >>> errors = composite.get_merge_errors()
72
+
73
+ .. versionadded:: 0.3.0
74
+ """
75
+
76
+ __slots__ = (
77
+ "_filesystem_source",
78
+ "_last_dedup_count",
79
+ "_last_merge_errors",
80
+ "_package_source",
81
+ )
82
+
83
+ def __init__(
84
+ self,
85
+ filesystem_source: SourceContractFilesystem | None = None,
86
+ package_source: SourceContractPackage | None = None,
87
+ ) -> None:
88
+ """Initialize composite source.
89
+
90
+ Args:
91
+ filesystem_source: Optional filesystem source
92
+ package_source: Optional package source
93
+
94
+ Raises:
95
+ ValueError: If neither source is configured
96
+ """
97
+ if not filesystem_source and not package_source:
98
+ raise ValueError(
99
+ "Composite source requires at least one source "
100
+ "(filesystem_source or package_source)"
101
+ )
102
+
103
+ self._filesystem_source = filesystem_source
104
+ self._package_source = package_source
105
+ self._last_merge_errors: list[ModelContractError] = []
106
+ self._last_dedup_count: int = 0
107
+
108
+ @property
109
+ def source_type(self) -> str:
110
+ """Return source type identifier.
111
+
112
+ Returns:
113
+ "composite"
114
+ """
115
+ return "composite"
116
+
117
+ @property
118
+ def source_description(self) -> str:
119
+ """Return human-readable source description.
120
+
121
+ Returns:
122
+ Description including configured sources
123
+ """
124
+ parts: list[str] = ["composite:"]
125
+ if self._filesystem_source:
126
+ parts.append(f"filesystem={self._filesystem_source.root}")
127
+ if self._package_source:
128
+ parts.append(f"package={self._package_source.package_module}")
129
+ return " ".join(parts)
130
+
131
+ @property
132
+ def filesystem_source(self) -> SourceContractFilesystem | None:
133
+ """Return the filesystem source if configured."""
134
+ return self._filesystem_source
135
+
136
+ @property
137
+ def package_source(self) -> SourceContractPackage | None:
138
+ """Return the package source if configured."""
139
+ return self._package_source
140
+
141
+ async def discover_contracts(self) -> list[ModelDiscoveredContract]:
142
+ """Discover contracts from all configured sources and merge.
143
+
144
+ Discovers from both sources (if configured), then merges with
145
+ conflict detection. Conflict errors are stored internally and
146
+ can be retrieved via get_merge_errors().
147
+
148
+ Returns:
149
+ Deduplicated and sorted contracts (excludes conflicts)
150
+
151
+ Note:
152
+ The returned contracts have handler_id and content_hash populated.
153
+ Call get_merge_errors() after discovery to get conflict errors.
154
+ Call get_dedup_count() after discovery to get deduplication count.
155
+ """
156
+ all_contracts: list[ModelDiscoveredContract] = []
157
+
158
+ # Clear previous state
159
+ self._last_merge_errors = []
160
+ self._last_dedup_count = 0
161
+
162
+ # Discover from filesystem source
163
+ if self._filesystem_source:
164
+ filesystem_contracts = await self._filesystem_source.discover_contracts()
165
+ all_contracts.extend(filesystem_contracts)
166
+ logger.debug(
167
+ "Composite: discovered %d contracts from filesystem",
168
+ len(filesystem_contracts),
169
+ )
170
+
171
+ # Discover from package source
172
+ if self._package_source:
173
+ package_contracts = await self._package_source.discover_contracts()
174
+ all_contracts.extend(package_contracts)
175
+ logger.debug(
176
+ "Composite: discovered %d contracts from package",
177
+ len(package_contracts),
178
+ )
179
+
180
+ # Extract handler_ids BEFORE merging (enables proper dedup/conflict detection)
181
+ all_contracts = [c.extract_handler_id() for c in all_contracts]
182
+
183
+ # Compute content hashes for all contracts
184
+ all_contracts = [c.with_content_hash() for c in all_contracts]
185
+
186
+ # Merge with conflict detection (now handler_id is populated)
187
+ merged, conflicts, dedup_count = self._merge_contracts(all_contracts)
188
+
189
+ # Store results for later retrieval
190
+ self._last_merge_errors = conflicts
191
+ self._last_dedup_count = dedup_count
192
+
193
+ # Sort merged contracts for deterministic ordering
194
+ merged.sort(key=lambda c: c.sort_key())
195
+
196
+ logger.info(
197
+ "Composite discovery complete: %d merged, %d conflicts, %d deduped, %d total",
198
+ len(merged),
199
+ len(conflicts),
200
+ dedup_count,
201
+ len(all_contracts),
202
+ )
203
+
204
+ return merged
205
+
206
+ def get_merge_errors(self) -> list[ModelContractError]:
207
+ """Get merge errors from last discover_contracts call.
208
+
209
+ Returns:
210
+ List of ModelContractError from the last merge operation.
211
+ Empty if discover_contracts hasn't been called or no conflicts.
212
+ """
213
+ return self._last_merge_errors.copy()
214
+
215
+ def get_dedup_count(self) -> int:
216
+ """Get deduplication count from last discover_contracts call.
217
+
218
+ Returns the count of contracts that were deduplicated (same handler_id
219
+ and same content hash). This does NOT include conflicts (same handler_id
220
+ but different content hash) - those are tracked via get_merge_errors().
221
+
222
+ Returns:
223
+ Number of deduplicated contracts (0 if discover_contracts hasn't
224
+ been called or no duplicates found).
225
+ """
226
+ return self._last_dedup_count
227
+
228
+ def _merge_contracts(
229
+ self,
230
+ contracts: list[ModelDiscoveredContract],
231
+ ) -> tuple[list[ModelDiscoveredContract], list[ModelContractError], int]:
232
+ """Merge contracts with conflict detection.
233
+
234
+ Groups contracts by handler_id (extracted from YAML), then:
235
+ - Single occurrence: Keep as-is
236
+ - Multiple with same hash: Deduplicate (keep first), increment dedup_count
237
+ - Multiple with different hash: Generate conflict error
238
+
239
+ Note: Contracts without handler_id (failed parsing) are kept
240
+ as-is and will generate validation errors later.
241
+
242
+ Args:
243
+ contracts: List of contracts with handler_id and content_hash computed
244
+
245
+ Returns:
246
+ Tuple of (merged_contracts, conflict_errors, dedup_count)
247
+ """
248
+ merged: list[ModelDiscoveredContract] = []
249
+ conflicts: list[ModelContractError] = []
250
+ dedup_count = 0
251
+
252
+ # Track seen handler_ids with their hash
253
+ seen: dict[
254
+ str, tuple[ModelDiscoveredContract, str]
255
+ ] = {} # handler_id -> (contract, hash)
256
+
257
+ for contract in contracts:
258
+ handler_id = contract.handler_id
259
+ content_hash = contract.content_hash or ""
260
+
261
+ # If no handler_id, keep contract (will fail validation later)
262
+ if not handler_id:
263
+ merged.append(contract)
264
+ continue
265
+
266
+ if handler_id not in seen:
267
+ # First occurrence
268
+ seen[handler_id] = (contract, content_hash)
269
+ merged.append(contract)
270
+ else:
271
+ # Duplicate handler_id
272
+ existing_contract, existing_hash = seen[handler_id]
273
+
274
+ if content_hash == existing_hash:
275
+ # Same hash - dedup silently, increment counter
276
+ dedup_count += 1
277
+ logger.debug(
278
+ "Deduplicating contract %s (same hash): %s vs %s",
279
+ handler_id,
280
+ contract.ref,
281
+ existing_contract.ref,
282
+ )
283
+ else:
284
+ # Different hash - conflict error
285
+ error = ModelContractError(
286
+ contract_path=str(contract.ref),
287
+ handler_id=handler_id,
288
+ error_type="duplicate_conflict",
289
+ message=(
290
+ f"Duplicate handler_id '{handler_id}' with different content: "
291
+ f"found in {contract.origin}:{contract.ref} "
292
+ f"(hash: {content_hash[:8]}...) but already exists in "
293
+ f"{existing_contract.origin}:{existing_contract.ref} "
294
+ f"(hash: {existing_hash[:8]}...)"
295
+ ),
296
+ )
297
+ conflicts.append(error)
298
+
299
+ logger.warning(
300
+ "Conflict detected for handler_id %s: %s vs %s",
301
+ handler_id,
302
+ contract.ref,
303
+ existing_contract.ref,
304
+ )
305
+
306
+ return merged, conflicts, dedup_count
307
+
308
+
309
+ __all__ = ["SourceContractComposite"]