crprotocol 2.0.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.
- crp/__init__.py +126 -0
- crp/__main__.py +8 -0
- crp/_typing.py +27 -0
- crp/_version.py +5 -0
- crp/adapters.py +31 -0
- crp/advanced/__init__.py +40 -0
- crp/advanced/auto_ingest.py +400 -0
- crp/advanced/cqs.py +235 -0
- crp/advanced/cross_window.py +477 -0
- crp/advanced/curator.py +265 -0
- crp/advanced/feedback.py +146 -0
- crp/advanced/hierarchical.py +211 -0
- crp/advanced/meta_learning.py +401 -0
- crp/advanced/parallel.py +98 -0
- crp/advanced/review_cycle.py +329 -0
- crp/advanced/scale_mode.py +129 -0
- crp/advanced/source_grounding.py +207 -0
- crp/ckf/__init__.py +35 -0
- crp/ckf/community.py +377 -0
- crp/ckf/fabric.py +445 -0
- crp/ckf/gc.py +175 -0
- crp/ckf/graph_walk.py +87 -0
- crp/ckf/merge.py +133 -0
- crp/ckf/pattern_query.py +122 -0
- crp/ckf/pubsub.py +128 -0
- crp/ckf/semantic.py +207 -0
- crp/cli/__init__.py +7 -0
- crp/cli/main.py +329 -0
- crp/cli/sidecar.py +929 -0
- crp/cli/startup.py +272 -0
- crp/continuation/__init__.py +103 -0
- crp/continuation/completion.py +348 -0
- crp/continuation/degradation.py +157 -0
- crp/continuation/document_map.py +160 -0
- crp/continuation/flow.py +109 -0
- crp/continuation/gap.py +419 -0
- crp/continuation/manager.py +484 -0
- crp/continuation/quality_monitor.py +179 -0
- crp/continuation/stitch.py +419 -0
- crp/continuation/trigger.py +142 -0
- crp/continuation/voice.py +157 -0
- crp/core/__init__.py +69 -0
- crp/core/batch.py +77 -0
- crp/core/circuit_breaker.py +116 -0
- crp/core/config.py +377 -0
- crp/core/context_tools.py +540 -0
- crp/core/dispatch_router.py +3977 -0
- crp/core/errors.py +128 -0
- crp/core/extraction_facade.py +384 -0
- crp/core/facilitator.py +713 -0
- crp/core/idempotency.py +215 -0
- crp/core/orchestrator.py +1435 -0
- crp/core/relay_strategies.py +613 -0
- crp/core/security_manager.py +140 -0
- crp/core/session.py +134 -0
- crp/core/task_intent.py +36 -0
- crp/core/window.py +363 -0
- crp/envelope/__init__.py +30 -0
- crp/envelope/builder.py +288 -0
- crp/envelope/decomposer.py +236 -0
- crp/envelope/formatter.py +168 -0
- crp/envelope/packer.py +211 -0
- crp/envelope/reranker.py +209 -0
- crp/envelope/scoring.py +310 -0
- crp/extraction/__init__.py +45 -0
- crp/extraction/complexity.py +96 -0
- crp/extraction/contradiction.py +132 -0
- crp/extraction/pipeline.py +360 -0
- crp/extraction/quality_gate.py +237 -0
- crp/extraction/stage1_regex.py +173 -0
- crp/extraction/stage2_statistical.py +244 -0
- crp/extraction/stage3_gliner.py +210 -0
- crp/extraction/stage4_uie.py +183 -0
- crp/extraction/stage5_discourse.py +175 -0
- crp/extraction/stage6_llm.py +178 -0
- crp/extraction/structured_output.py +219 -0
- crp/extraction/types.py +299 -0
- crp/license_guard.py +722 -0
- crp/observability/__init__.py +30 -0
- crp/observability/audit.py +118 -0
- crp/observability/events.py +233 -0
- crp/observability/metrics.py +264 -0
- crp/observability/quality.py +135 -0
- crp/observability/structured_logging.py +81 -0
- crp/observability/telemetry.py +117 -0
- crp/provenance/__init__.py +314 -0
- crp/provenance/_embeddings.py +97 -0
- crp/provenance/_types.py +378 -0
- crp/provenance/attribution_scorer.py +252 -0
- crp/provenance/claim_detector.py +229 -0
- crp/provenance/contradiction_detector.py +243 -0
- crp/provenance/distortion_detector.py +397 -0
- crp/provenance/entailment_verifier.py +358 -0
- crp/provenance/fabrication_detector.py +203 -0
- crp/provenance/hallucination_scorer.py +320 -0
- crp/provenance/omission_analyzer.py +106 -0
- crp/provenance/provenance_chain.py +205 -0
- crp/provenance/report_generator.py +440 -0
- crp/providers/__init__.py +43 -0
- crp/providers/anthropic.py +270 -0
- crp/providers/base.py +135 -0
- crp/providers/custom.py +63 -0
- crp/providers/diagnostic.py +251 -0
- crp/providers/llamacpp.py +224 -0
- crp/providers/manager.py +139 -0
- crp/providers/ollama.py +243 -0
- crp/providers/openai.py +628 -0
- crp/providers/tokenizers.py +48 -0
- crp/py.typed +0 -0
- crp/resources/__init__.py +53 -0
- crp/resources/adaptive_allocator.py +525 -0
- crp/resources/cost_model.py +388 -0
- crp/resources/overhead_manager.py +217 -0
- crp/resources/resource_manager.py +262 -0
- crp/schemas/__init__.py +20 -0
- crp/schemas/cost-estimate.json +33 -0
- crp/schemas/crp-error.json +43 -0
- crp/schemas/envelope-preview.json +40 -0
- crp/schemas/persisted-state-header.json +27 -0
- crp/schemas/quality-report.json +94 -0
- crp/schemas/session-handle.json +33 -0
- crp/schemas/session-status.json +57 -0
- crp/schemas/stream-event.json +18 -0
- crp/schemas/task-intent.json +42 -0
- crp/security/__init__.py +93 -0
- crp/security/audit_trail.py +392 -0
- crp/security/binding.py +192 -0
- crp/security/compliance.py +813 -0
- crp/security/consent.py +593 -0
- crp/security/embedding_defense.py +161 -0
- crp/security/encryption.py +202 -0
- crp/security/injection.py +335 -0
- crp/security/integrity.py +267 -0
- crp/security/privacy.py +662 -0
- crp/security/quarantine.py +249 -0
- crp/security/rbac.py +221 -0
- crp/security/validation.py +164 -0
- crp/state/__init__.py +31 -0
- crp/state/cold_storage.py +258 -0
- crp/state/compaction.py +263 -0
- crp/state/critical_state.py +104 -0
- crp/state/event_log.py +313 -0
- crp/state/fact.py +189 -0
- crp/state/serialization.py +189 -0
- crp/state/session_cleanup.py +77 -0
- crp/state/snapshot.py +290 -0
- crp/state/warm_store.py +346 -0
- crprotocol-2.0.0.dist-info/METADATA +1295 -0
- crprotocol-2.0.0.dist-info/RECORD +153 -0
- crprotocol-2.0.0.dist-info/WHEEL +4 -0
- crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
- crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
- crprotocol-2.0.0.dist-info/licenses/NOTICE +18 -0
crp/core/config.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""ConfigurationResolver — 5-layer hierarchy, feature flags (§06 §6.12).
|
|
4
|
+
|
|
5
|
+
Resolution order (later overrides prior):
|
|
6
|
+
Layer 1: Hardcoded defaults (this file)
|
|
7
|
+
Layer 2: Environment variables (CRP_* prefix)
|
|
8
|
+
Layer 3: Config file (YAML/.env) — future
|
|
9
|
+
Layer 4: Client() init kwargs
|
|
10
|
+
Layer 5: Runtime configure() call
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("crp.core.config")
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Defaults (Layer 1)
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
_DEFAULTS: dict[str, Any] = {
|
|
29
|
+
# Master switch — True by default for standalone SDK usage.
|
|
30
|
+
# Set CRP_ENABLED=false to disable when embedded in a host framework.
|
|
31
|
+
"enabled": True,
|
|
32
|
+
# Limits
|
|
33
|
+
"max_continuations": 50,
|
|
34
|
+
"max_dispatch_rate": 60,
|
|
35
|
+
"session_timeout": 86400,
|
|
36
|
+
"ingest_quarantine": 1,
|
|
37
|
+
# Resource
|
|
38
|
+
"max_ram_mb": 512,
|
|
39
|
+
"max_model_ram_mb": 300,
|
|
40
|
+
"max_threads": 2,
|
|
41
|
+
"process_priority": "below_normal",
|
|
42
|
+
"memory_budget_mb": 512,
|
|
43
|
+
"idle_model_timeout_s": 300.0,
|
|
44
|
+
# Behaviour
|
|
45
|
+
"log_envelopes": False,
|
|
46
|
+
"encrypt_cold_state": True,
|
|
47
|
+
"default_role": "OPERATOR",
|
|
48
|
+
# Security
|
|
49
|
+
"binding_secret": "",
|
|
50
|
+
# Budget caps (immutable) — 0 means unlimited
|
|
51
|
+
"max_windows_per_session": 0,
|
|
52
|
+
"max_total_input_tokens": 0,
|
|
53
|
+
"max_total_output_tokens": 0,
|
|
54
|
+
# Latency
|
|
55
|
+
"max_envelope_latency_ms": 500,
|
|
56
|
+
"max_extraction_latency_ms": 200,
|
|
57
|
+
"max_windows_per_minute": 0,
|
|
58
|
+
# Observability
|
|
59
|
+
"telemetry_path": "",
|
|
60
|
+
# Overhead & parallelism
|
|
61
|
+
"overhead_cap_pct": 15.0,
|
|
62
|
+
"parallel_max_concurrent": 4,
|
|
63
|
+
# Extraction stages — which stages are enabled at init
|
|
64
|
+
"enable_stage_3": True, # GLiNER zero-shot NER
|
|
65
|
+
"enable_stage_4": True, # UIE relation extraction
|
|
66
|
+
"enable_stage_5": True, # Discourse markers
|
|
67
|
+
"enable_stage_6": False, # LLM-assisted (costly, rare)
|
|
68
|
+
# Dispatch timeout (seconds) — wall-time cap per continuation loop
|
|
69
|
+
"dispatch_timeout": 3600,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Fields set at init that CANNOT change after session start
|
|
73
|
+
_IMMUTABLE_FIELDS: frozenset[str] = frozenset({
|
|
74
|
+
"max_windows_per_session",
|
|
75
|
+
"max_total_input_tokens",
|
|
76
|
+
"max_total_output_tokens",
|
|
77
|
+
"binding_secret",
|
|
78
|
+
"session_timeout",
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
# Environment variable prefix
|
|
82
|
+
_ENV_PREFIX = "CRP_"
|
|
83
|
+
|
|
84
|
+
# Mapping from env-var suffix to config key
|
|
85
|
+
_ENV_MAP: dict[str, str] = {
|
|
86
|
+
"ENABLED": "enabled",
|
|
87
|
+
"LOG_ENVELOPES": "log_envelopes",
|
|
88
|
+
"MAX_CONTINUATIONS": "max_continuations",
|
|
89
|
+
"MAX_RAM_MB": "max_ram_mb",
|
|
90
|
+
"MAX_MODEL_RAM_MB": "max_model_ram_mb",
|
|
91
|
+
"MAX_THREADS": "max_threads",
|
|
92
|
+
"PROCESS_PRIORITY": "process_priority",
|
|
93
|
+
"BINDING_SECRET": "binding_secret",
|
|
94
|
+
"SESSION_TIMEOUT": "session_timeout",
|
|
95
|
+
"DEFAULT_ROLE": "default_role",
|
|
96
|
+
"ENCRYPT_COLD_STATE": "encrypt_cold_state",
|
|
97
|
+
"MAX_DISPATCH_RATE": "max_dispatch_rate",
|
|
98
|
+
"INGEST_QUARANTINE": "ingest_quarantine",
|
|
99
|
+
"ENABLE_STAGE_3": "enable_stage_3",
|
|
100
|
+
"ENABLE_STAGE_4": "enable_stage_4",
|
|
101
|
+
"ENABLE_STAGE_5": "enable_stage_5",
|
|
102
|
+
"ENABLE_STAGE_6": "enable_stage_6",
|
|
103
|
+
"DISPATCH_TIMEOUT": "dispatch_timeout",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_BOOL_KEYS = frozenset({"enabled", "log_envelopes", "encrypt_cold_state",
|
|
107
|
+
"enable_stage_3", "enable_stage_4", "enable_stage_5",
|
|
108
|
+
"enable_stage_6"})
|
|
109
|
+
_INT_KEYS = frozenset({
|
|
110
|
+
"max_continuations", "max_dispatch_rate", "session_timeout",
|
|
111
|
+
"ingest_quarantine", "max_ram_mb", "max_model_ram_mb", "max_threads",
|
|
112
|
+
"max_windows_per_session", "max_total_input_tokens",
|
|
113
|
+
"max_total_output_tokens", "max_envelope_latency_ms",
|
|
114
|
+
"max_extraction_latency_ms", "max_windows_per_minute",
|
|
115
|
+
"dispatch_timeout",
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_env_value(key: str, raw: str) -> Any:
|
|
120
|
+
"""Parse a raw environment variable string to the correct type."""
|
|
121
|
+
if key in _BOOL_KEYS:
|
|
122
|
+
return raw.lower() in ("1", "true", "yes")
|
|
123
|
+
if key in _INT_KEYS:
|
|
124
|
+
try:
|
|
125
|
+
return int(raw)
|
|
126
|
+
except ValueError:
|
|
127
|
+
logger.warning("Invalid integer for %s: %r — using default", key, raw)
|
|
128
|
+
return None # Will be filtered out, falling through to default
|
|
129
|
+
return raw
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class CRPConfig:
|
|
134
|
+
"""Resolved CRP configuration.
|
|
135
|
+
|
|
136
|
+
Constructed via :class:`ConfigurationResolver`.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
_values: dict[str, Any] = field(default_factory=dict)
|
|
140
|
+
_locked: bool = False
|
|
141
|
+
|
|
142
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
143
|
+
return self._values.get(key, default)
|
|
144
|
+
|
|
145
|
+
def __getitem__(self, key: str) -> Any:
|
|
146
|
+
return self._values[key]
|
|
147
|
+
|
|
148
|
+
# Convenience typed accessors for common flags
|
|
149
|
+
@property
|
|
150
|
+
def enabled(self) -> bool:
|
|
151
|
+
return bool(self._values.get("enabled", False))
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def max_continuations(self) -> int:
|
|
155
|
+
return int(self._values.get("max_continuations", 50))
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def log_envelopes(self) -> bool:
|
|
159
|
+
return bool(self._values.get("log_envelopes", False))
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def max_dispatch_rate(self) -> int:
|
|
163
|
+
return int(self._values.get("max_dispatch_rate", 60))
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def session_timeout(self) -> int:
|
|
167
|
+
return int(self._values.get("session_timeout", 86400))
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def max_windows_per_session(self) -> int:
|
|
171
|
+
return int(self._values.get("max_windows_per_session", 0))
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def max_total_input_tokens(self) -> int:
|
|
175
|
+
return int(self._values.get("max_total_input_tokens", 0))
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def max_total_output_tokens(self) -> int:
|
|
179
|
+
return int(self._values.get("max_total_output_tokens", 0))
|
|
180
|
+
|
|
181
|
+
def lock(self) -> None:
|
|
182
|
+
"""Lock immutable fields — called after session starts."""
|
|
183
|
+
self._locked = True
|
|
184
|
+
|
|
185
|
+
def update(self, overrides: dict[str, Any]) -> None:
|
|
186
|
+
"""Apply mutable-only overrides (Layer 5: runtime configure).
|
|
187
|
+
|
|
188
|
+
Raises ValueError if attempting to change an immutable field
|
|
189
|
+
after lock().
|
|
190
|
+
"""
|
|
191
|
+
for key, value in overrides.items():
|
|
192
|
+
if self._locked and key in _IMMUTABLE_FIELDS:
|
|
193
|
+
msg = f"Cannot change immutable config '{key}' after session start"
|
|
194
|
+
raise ValueError(msg)
|
|
195
|
+
self._values[key] = value
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_VALID_PROCESS_PRIORITIES = frozenset({
|
|
199
|
+
"idle", "below_normal", "normal", "above_normal", "high",
|
|
200
|
+
})
|
|
201
|
+
_VALID_ROLES = frozenset({
|
|
202
|
+
"OBSERVER", "OPERATOR", "ADMIN",
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
# Validation bounds: (min, max) inclusive. None means unbounded.
|
|
206
|
+
_INT_BOUNDS: dict[str, tuple[int | None, int | None]] = {
|
|
207
|
+
"max_continuations": (0, 10_000),
|
|
208
|
+
"max_dispatch_rate": (1, 10_000),
|
|
209
|
+
"session_timeout": (0, 604_800), # 0 s – 7 days (0 = immediate expiry)
|
|
210
|
+
"ingest_quarantine": (0, 60),
|
|
211
|
+
"max_ram_mb": (1, 65_536),
|
|
212
|
+
"max_model_ram_mb": (1, 65_536),
|
|
213
|
+
"max_threads": (1, 64),
|
|
214
|
+
"max_windows_per_session": (0, 1_000_000), # 0 = unlimited
|
|
215
|
+
"max_total_input_tokens": (0, 2**31),
|
|
216
|
+
"max_total_output_tokens": (0, 2**31),
|
|
217
|
+
"max_envelope_latency_ms": (0, 60_000),
|
|
218
|
+
"max_extraction_latency_ms": (0, 60_000),
|
|
219
|
+
"max_windows_per_minute": (0, 10_000),
|
|
220
|
+
"parallel_max_concurrent": (1, 128),
|
|
221
|
+
"dispatch_timeout": (10, 86_400), # 10s – 24h
|
|
222
|
+
"memory_budget_mb": (1, 65_536),
|
|
223
|
+
}
|
|
224
|
+
_FLOAT_BOUNDS: dict[str, tuple[float | None, float | None]] = {
|
|
225
|
+
"overhead_cap_pct": (0.0, 100.0),
|
|
226
|
+
"idle_model_timeout_s": (0.0, 86_400.0),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _validate_config(values: dict[str, Any]) -> None:
|
|
231
|
+
"""Validate resolved configuration values. Raises ValueError on bad input."""
|
|
232
|
+
errors: list[str] = []
|
|
233
|
+
for key, (lo, hi) in _INT_BOUNDS.items():
|
|
234
|
+
val = values.get(key)
|
|
235
|
+
if val is None:
|
|
236
|
+
continue
|
|
237
|
+
try:
|
|
238
|
+
val = int(val)
|
|
239
|
+
except (TypeError, ValueError):
|
|
240
|
+
errors.append(f"{key}: expected int, got {type(val).__name__}")
|
|
241
|
+
continue
|
|
242
|
+
if lo is not None and val < lo:
|
|
243
|
+
errors.append(f"{key}: {val} < minimum {lo}")
|
|
244
|
+
if hi is not None and val > hi:
|
|
245
|
+
errors.append(f"{key}: {val} > maximum {hi}")
|
|
246
|
+
|
|
247
|
+
for key, (lo, hi) in _FLOAT_BOUNDS.items():
|
|
248
|
+
val = values.get(key)
|
|
249
|
+
if val is None:
|
|
250
|
+
continue
|
|
251
|
+
try:
|
|
252
|
+
val = float(val)
|
|
253
|
+
except (TypeError, ValueError):
|
|
254
|
+
errors.append(f"{key}: expected number, got {type(val).__name__}")
|
|
255
|
+
continue
|
|
256
|
+
if lo is not None and val < lo:
|
|
257
|
+
errors.append(f"{key}: {val} < minimum {lo}")
|
|
258
|
+
if hi is not None and val > hi:
|
|
259
|
+
errors.append(f"{key}: {val} > maximum {hi}")
|
|
260
|
+
|
|
261
|
+
pp = values.get("process_priority")
|
|
262
|
+
if pp is not None and pp not in _VALID_PROCESS_PRIORITIES:
|
|
263
|
+
errors.append(
|
|
264
|
+
f"process_priority: '{pp}' not in {sorted(_VALID_PROCESS_PRIORITIES)}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
role = values.get("default_role")
|
|
268
|
+
if role is not None and role not in _VALID_ROLES:
|
|
269
|
+
errors.append(f"default_role: '{role}' not in {sorted(_VALID_ROLES)}")
|
|
270
|
+
|
|
271
|
+
if errors:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
"CRP configuration validation failed:\n - " + "\n - ".join(errors)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class ConfigurationResolver:
|
|
278
|
+
"""Resolve configuration through the 5-layer hierarchy."""
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _read_config_file() -> dict[str, Any]:
|
|
282
|
+
"""Layer 3: Read config from file (YAML/JSON).
|
|
283
|
+
|
|
284
|
+
Search order:
|
|
285
|
+
1. ``CRP_CONFIG_FILE`` env var (explicit path)
|
|
286
|
+
2. ``~/.crp/config.yaml`` / ``~/.crp/config.json``
|
|
287
|
+
3. ``.crp.yaml`` / ``.crp.json`` in CWD
|
|
288
|
+
"""
|
|
289
|
+
explicit = os.environ.get("CRP_CONFIG_FILE")
|
|
290
|
+
if explicit:
|
|
291
|
+
p = Path(explicit)
|
|
292
|
+
if not p.is_file():
|
|
293
|
+
logger.warning("CRP_CONFIG_FILE=%s does not exist — skipping", explicit)
|
|
294
|
+
return {}
|
|
295
|
+
return ConfigurationResolver._parse_config_path(p)
|
|
296
|
+
|
|
297
|
+
candidates = [
|
|
298
|
+
Path.home() / ".crp" / "config.yaml",
|
|
299
|
+
Path.home() / ".crp" / "config.yml",
|
|
300
|
+
Path.home() / ".crp" / "config.json",
|
|
301
|
+
Path.cwd() / ".crp.yaml",
|
|
302
|
+
Path.cwd() / ".crp.yml",
|
|
303
|
+
Path.cwd() / ".crp.json",
|
|
304
|
+
]
|
|
305
|
+
for candidate in candidates:
|
|
306
|
+
if candidate.is_file():
|
|
307
|
+
return ConfigurationResolver._parse_config_path(candidate)
|
|
308
|
+
return {}
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def _parse_config_path(path: Path) -> dict[str, Any]:
|
|
312
|
+
"""Parse a YAML or JSON config file, returning only known keys."""
|
|
313
|
+
raw_text = path.read_text(encoding="utf-8")
|
|
314
|
+
suffix = path.suffix.lower()
|
|
315
|
+
try:
|
|
316
|
+
if suffix in (".yaml", ".yml"):
|
|
317
|
+
try:
|
|
318
|
+
import yaml # type: ignore[import-untyped]
|
|
319
|
+
data = yaml.safe_load(raw_text) or {}
|
|
320
|
+
except ImportError:
|
|
321
|
+
logger.warning("PyYAML not installed — cannot read %s", path)
|
|
322
|
+
return {}
|
|
323
|
+
elif suffix == ".json":
|
|
324
|
+
data = json.loads(raw_text)
|
|
325
|
+
else:
|
|
326
|
+
logger.warning("Unsupported config file format: %s", path)
|
|
327
|
+
return {}
|
|
328
|
+
except Exception:
|
|
329
|
+
logger.exception("Failed to parse config file %s", path)
|
|
330
|
+
return {}
|
|
331
|
+
|
|
332
|
+
if not isinstance(data, dict):
|
|
333
|
+
logger.warning("Config file %s did not parse as a dict — ignoring", path)
|
|
334
|
+
return {}
|
|
335
|
+
|
|
336
|
+
# Only accept known config keys to prevent injection of arbitrary values
|
|
337
|
+
known_keys = set(_DEFAULTS)
|
|
338
|
+
result: dict[str, Any] = {}
|
|
339
|
+
for key, value in data.items():
|
|
340
|
+
if key in known_keys:
|
|
341
|
+
result[key] = _parse_env_value(key, str(value)) if isinstance(value, str) else value
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
def resolve(self, **init_kwargs: Any) -> CRPConfig:
|
|
345
|
+
"""Build a CRPConfig from layers 1-4.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
**init_kwargs: Layer 4 overrides from Client() constructor.
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
ValueError: If any resolved value is out of bounds.
|
|
352
|
+
"""
|
|
353
|
+
# Layer 1: Hardcoded defaults
|
|
354
|
+
values = dict(_DEFAULTS)
|
|
355
|
+
|
|
356
|
+
# Layer 2: Environment variables
|
|
357
|
+
for env_suffix, config_key in _ENV_MAP.items():
|
|
358
|
+
env_var = f"{_ENV_PREFIX}{env_suffix}"
|
|
359
|
+
raw = os.environ.get(env_var)
|
|
360
|
+
if raw is not None:
|
|
361
|
+
values[config_key] = _parse_env_value(config_key, raw)
|
|
362
|
+
|
|
363
|
+
# Layer 3: Config file (YAML/JSON)
|
|
364
|
+
file_values = self._read_config_file()
|
|
365
|
+
values.update(file_values)
|
|
366
|
+
|
|
367
|
+
# Layer 4: Client() init kwargs
|
|
368
|
+
unknown_keys = [k for k in init_kwargs if k not in values]
|
|
369
|
+
if unknown_keys:
|
|
370
|
+
logger.warning("Unknown config keys ignored (§audit L3): %s", unknown_keys)
|
|
371
|
+
for key, value in init_kwargs.items():
|
|
372
|
+
if key in values:
|
|
373
|
+
values[key] = value
|
|
374
|
+
|
|
375
|
+
_validate_config(values)
|
|
376
|
+
|
|
377
|
+
return CRPConfig(_values=values)
|