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.
- omnibase_infra/__init__.py +1 -1
- omnibase_infra/enums/__init__.py +4 -0
- omnibase_infra/enums/enum_declarative_node_violation.py +102 -0
- omnibase_infra/event_bus/adapters/__init__.py +31 -0
- omnibase_infra/event_bus/adapters/adapter_protocol_event_publisher_kafka.py +517 -0
- omnibase_infra/mixins/mixin_async_circuit_breaker.py +113 -1
- omnibase_infra/models/__init__.py +9 -0
- omnibase_infra/models/event_bus/__init__.py +22 -0
- omnibase_infra/models/event_bus/model_consumer_retry_config.py +367 -0
- omnibase_infra/models/event_bus/model_dlq_config.py +177 -0
- omnibase_infra/models/event_bus/model_idempotency_config.py +131 -0
- omnibase_infra/models/event_bus/model_offset_policy_config.py +107 -0
- omnibase_infra/models/resilience/model_circuit_breaker_config.py +15 -0
- omnibase_infra/models/validation/__init__.py +8 -0
- omnibase_infra/models/validation/model_declarative_node_validation_result.py +139 -0
- omnibase_infra/models/validation/model_declarative_node_violation.py +169 -0
- omnibase_infra/nodes/architecture_validator/__init__.py +28 -7
- omnibase_infra/nodes/architecture_validator/constants.py +36 -0
- omnibase_infra/nodes/architecture_validator/handlers/__init__.py +28 -0
- omnibase_infra/nodes/architecture_validator/handlers/contract.yaml +120 -0
- omnibase_infra/nodes/architecture_validator/handlers/handler_architecture_validation.py +359 -0
- omnibase_infra/nodes/architecture_validator/node.py +1 -0
- omnibase_infra/nodes/architecture_validator/node_architecture_validator.py +48 -336
- omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +16 -2
- omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +14 -4
- omnibase_infra/nodes/node_ledger_projection_compute/handlers/__init__.py +18 -0
- omnibase_infra/nodes/node_ledger_projection_compute/handlers/contract.yaml +53 -0
- omnibase_infra/nodes/node_ledger_projection_compute/handlers/handler_ledger_projection.py +354 -0
- omnibase_infra/nodes/node_ledger_projection_compute/node.py +20 -256
- omnibase_infra/nodes/node_registry_effect/node.py +20 -73
- omnibase_infra/protocols/protocol_dispatch_engine.py +90 -0
- omnibase_infra/runtime/__init__.py +11 -0
- omnibase_infra/runtime/baseline_subscriptions.py +150 -0
- omnibase_infra/runtime/event_bus_subcontract_wiring.py +455 -24
- omnibase_infra/runtime/kafka_contract_source.py +13 -5
- omnibase_infra/runtime/service_message_dispatch_engine.py +112 -0
- omnibase_infra/runtime/service_runtime_host_process.py +6 -11
- omnibase_infra/services/__init__.py +36 -0
- omnibase_infra/services/contract_publisher/__init__.py +95 -0
- omnibase_infra/services/contract_publisher/config.py +199 -0
- omnibase_infra/services/contract_publisher/errors.py +243 -0
- omnibase_infra/services/contract_publisher/models/__init__.py +28 -0
- omnibase_infra/services/contract_publisher/models/model_contract_error.py +67 -0
- omnibase_infra/services/contract_publisher/models/model_infra_error.py +62 -0
- omnibase_infra/services/contract_publisher/models/model_publish_result.py +112 -0
- omnibase_infra/services/contract_publisher/models/model_publish_stats.py +79 -0
- omnibase_infra/services/contract_publisher/service.py +617 -0
- omnibase_infra/services/contract_publisher/sources/__init__.py +52 -0
- omnibase_infra/services/contract_publisher/sources/model_discovered.py +155 -0
- omnibase_infra/services/contract_publisher/sources/protocol.py +101 -0
- omnibase_infra/services/contract_publisher/sources/source_composite.py +309 -0
- omnibase_infra/services/contract_publisher/sources/source_filesystem.py +174 -0
- omnibase_infra/services/contract_publisher/sources/source_package.py +221 -0
- omnibase_infra/services/observability/__init__.py +40 -0
- omnibase_infra/services/observability/agent_actions/__init__.py +64 -0
- omnibase_infra/services/observability/agent_actions/config.py +209 -0
- omnibase_infra/services/observability/agent_actions/consumer.py +1320 -0
- omnibase_infra/services/observability/agent_actions/models/__init__.py +87 -0
- omnibase_infra/services/observability/agent_actions/models/model_agent_action.py +142 -0
- omnibase_infra/services/observability/agent_actions/models/model_detection_failure.py +125 -0
- omnibase_infra/services/observability/agent_actions/models/model_envelope.py +85 -0
- omnibase_infra/services/observability/agent_actions/models/model_execution_log.py +159 -0
- omnibase_infra/services/observability/agent_actions/models/model_performance_metric.py +130 -0
- omnibase_infra/services/observability/agent_actions/models/model_routing_decision.py +138 -0
- omnibase_infra/services/observability/agent_actions/models/model_transformation_event.py +124 -0
- omnibase_infra/services/observability/agent_actions/tests/__init__.py +20 -0
- omnibase_infra/services/observability/agent_actions/tests/test_consumer.py +1154 -0
- omnibase_infra/services/observability/agent_actions/tests/test_models.py +645 -0
- omnibase_infra/services/observability/agent_actions/tests/test_writer.py +709 -0
- omnibase_infra/services/observability/agent_actions/writer_postgres.py +926 -0
- omnibase_infra/validation/__init__.py +12 -0
- omnibase_infra/validation/contracts/declarative_node.validation.yaml +143 -0
- omnibase_infra/validation/validation_exemptions.yaml +93 -0
- omnibase_infra/validation/validator_declarative_node.py +850 -0
- {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.dist-info}/METADATA +3 -3
- {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.dist-info}/RECORD +79 -27
- {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.dist-info}/entry_points.txt +0 -0
- {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.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"]
|