modelable 1.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.

Potentially problematic release.


This version of modelable might be problematic. Click here for more details.

Files changed (122) hide show
  1. modelable/__init__.py +1 -0
  2. modelable/__main__.py +3 -0
  3. modelable/_pydantic_py314_compat.py +31 -0
  4. modelable/cli.py +41 -0
  5. modelable/commands/__init__.py +1 -0
  6. modelable/commands/apicurio.py +84 -0
  7. modelable/commands/codegen.py +241 -0
  8. modelable/commands/common.py +43 -0
  9. modelable/commands/compile.py +237 -0
  10. modelable/commands/create.py +164 -0
  11. modelable/commands/diff.py +82 -0
  12. modelable/commands/graph.py +53 -0
  13. modelable/commands/llm.py +564 -0
  14. modelable/commands/lsp.py +15 -0
  15. modelable/commands/runtime.py +37 -0
  16. modelable/commands/scenario.py +104 -0
  17. modelable/commands/spec.py +197 -0
  18. modelable/commands/workspace.py +240 -0
  19. modelable/compat/__init__.py +11 -0
  20. modelable/compat/checker.py +179 -0
  21. modelable/compat/diff.py +169 -0
  22. modelable/compiler/__init__.py +3 -0
  23. modelable/compiler/compiler.py +19 -0
  24. modelable/compiler/workspace.py +346 -0
  25. modelable/diagnostics/__init__.py +3 -0
  26. modelable/diagnostics/model.py +27 -0
  27. modelable/emitters/__init__.py +0 -0
  28. modelable/emitters/base.py +22 -0
  29. modelable/emitters/csharp.py +245 -0
  30. modelable/emitters/dbt_yaml.py +290 -0
  31. modelable/emitters/diagnostics.py +25 -0
  32. modelable/emitters/fhir.py +694 -0
  33. modelable/emitters/fhir_validator.py +36 -0
  34. modelable/emitters/go.py +334 -0
  35. modelable/emitters/java.py +264 -0
  36. modelable/emitters/json_schema.py +458 -0
  37. modelable/emitters/markdown.py +252 -0
  38. modelable/emitters/odcs.py +355 -0
  39. modelable/emitters/openlineage.py +315 -0
  40. modelable/emitters/openmetadata.py +258 -0
  41. modelable/emitters/python.py +282 -0
  42. modelable/emitters/rust.py +643 -0
  43. modelable/emitters/shapes.py +261 -0
  44. modelable/emitters/sql.py +266 -0
  45. modelable/emitters/targets.py +141 -0
  46. modelable/emitters/typescript.py +352 -0
  47. modelable/expressions/__init__.py +0 -0
  48. modelable/expressions/cel.py +547 -0
  49. modelable/governance/__init__.py +3 -0
  50. modelable/governance/checker.py +271 -0
  51. modelable/governance/por.py +46 -0
  52. modelable/grammar/__init__.py +1 -0
  53. modelable/grammar/modelable.lark +257 -0
  54. modelable/graph/__init__.py +5 -0
  55. modelable/graph/export.py +442 -0
  56. modelable/llm/__init__.py +43 -0
  57. modelable/llm/chat.py +255 -0
  58. modelable/llm/config.py +87 -0
  59. modelable/llm/context.py +194 -0
  60. modelable/llm/engine.py +976 -0
  61. modelable/llm/importers.py +1077 -0
  62. modelable/llm/provenance.py +84 -0
  63. modelable/llm/providers.py +182 -0
  64. modelable/llm/qa.py +126 -0
  65. modelable/llm/recommendations.py +33 -0
  66. modelable/llm/redaction.py +19 -0
  67. modelable/llm/render.py +279 -0
  68. modelable/llm/update_plan.py +101 -0
  69. modelable/llm/validation_help.py +10 -0
  70. modelable/lsp/__init__.py +3 -0
  71. modelable/lsp/__main__.py +4 -0
  72. modelable/lsp/code_actions.py +210 -0
  73. modelable/lsp/completion.py +480 -0
  74. modelable/lsp/definition.py +343 -0
  75. modelable/lsp/diagnostics.py +31 -0
  76. modelable/lsp/document_symbols.py +197 -0
  77. modelable/lsp/federation.py +261 -0
  78. modelable/lsp/folding.py +33 -0
  79. modelable/lsp/formatting.py +64 -0
  80. modelable/lsp/highlight.py +30 -0
  81. modelable/lsp/hover.py +370 -0
  82. modelable/lsp/inlay_hints.py +158 -0
  83. modelable/lsp/references.py +511 -0
  84. modelable/lsp/rename.py +564 -0
  85. modelable/lsp/semantic_tokens.py +412 -0
  86. modelable/lsp/server.py +370 -0
  87. modelable/lsp/workspace.py +83 -0
  88. modelable/lsp/workspace_symbols.py +104 -0
  89. modelable/parser/__init__.py +94 -0
  90. modelable/parser/ir.py +451 -0
  91. modelable/parser/parse.py +47 -0
  92. modelable/parser/transformer.py +798 -0
  93. modelable/parser/wire.py +68 -0
  94. modelable/planner/__init__.py +0 -0
  95. modelable/planner/lineage.py +91 -0
  96. modelable/planner/planner.py +134 -0
  97. modelable/planner/plans.py +122 -0
  98. modelable/py.typed +0 -0
  99. modelable/registry/__init__.py +9 -0
  100. modelable/registry/apicurio.py +166 -0
  101. modelable/registry/base.py +18 -0
  102. modelable/registry/factory.py +18 -0
  103. modelable/registry/index.py +419 -0
  104. modelable/registry/local.py +26 -0
  105. modelable/registry/oci.py +22 -0
  106. modelable/registry/resolver.py +213 -0
  107. modelable/registry/schema.sql +119 -0
  108. modelable/registry/signature.py +26 -0
  109. modelable/release.py +125 -0
  110. modelable/runtime/__init__.py +5 -0
  111. modelable/runtime/adapter/__init__.py +17 -0
  112. modelable/runtime/adapter/base.py +18 -0
  113. modelable/runtime/adapter/postgres.py +82 -0
  114. modelable/specs/__init__.py +23 -0
  115. modelable/specs/tracking.py +220 -0
  116. modelable/validation/__init__.py +3 -0
  117. modelable/validation/semantic.py +659 -0
  118. modelable-1.0.0.dist-info/METADATA +61 -0
  119. modelable-1.0.0.dist-info/RECORD +122 -0
  120. modelable-1.0.0.dist-info/WHEEL +4 -0
  121. modelable-1.0.0.dist-info/entry_points.txt +2 -0
  122. modelable-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class WriteProvenance:
10
+ command: str
11
+ artifact_path: str
12
+ provider: str
13
+ model: str
14
+ validation_status: str
15
+ diagnostics_repaired: int
16
+ inputs: dict[str, str]
17
+
18
+ def as_dict(self) -> dict[str, object]:
19
+ return asdict(self)
20
+
21
+
22
+ def build_write_provenance(
23
+ *,
24
+ command: str,
25
+ artifact_path: Path,
26
+ provider: str,
27
+ model: str,
28
+ validation_status: str,
29
+ diagnostics_repaired: int,
30
+ inputs: dict[str, str],
31
+ ) -> WriteProvenance:
32
+ return WriteProvenance(
33
+ command=command,
34
+ artifact_path=str(artifact_path),
35
+ provider=provider,
36
+ model=model,
37
+ validation_status=validation_status,
38
+ diagnostics_repaired=diagnostics_repaired,
39
+ inputs=inputs,
40
+ )
41
+
42
+
43
+ def provenance_sidecar_path(artifact_path: Path) -> Path:
44
+ return artifact_path.with_name(f"{artifact_path.name}.provenance.json")
45
+
46
+
47
+ def render_write_provenance(provenance: WriteProvenance) -> str:
48
+ return json.dumps(provenance.as_dict(), indent=2, ensure_ascii=False, sort_keys=True) + "\n"
49
+
50
+
51
+ def write_provenance_sidecar(artifact_path: Path, provenance: WriteProvenance) -> Path:
52
+ sidecar_path = provenance_sidecar_path(artifact_path)
53
+ sidecar_path.write_text(render_write_provenance(provenance), encoding="utf-8")
54
+ return sidecar_path
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class AttachmentRecord:
59
+ ref: str
60
+ source_format: str
61
+ source_name: str
62
+ source_path: str
63
+ source_hash: str
64
+ from_version: int
65
+ to_version: int | None
66
+ change_kind: str | None
67
+ changes: list[dict[str, object]]
68
+
69
+ def as_dict(self) -> dict[str, object]:
70
+ return asdict(self)
71
+
72
+
73
+ def attachment_sidecar_path(artifact_path: Path) -> Path:
74
+ return artifact_path.with_name(f"{artifact_path.name}.attachments.json")
75
+
76
+
77
+ def write_attachment_record(artifact_path: Path, record: AttachmentRecord) -> Path:
78
+ sidecar_path = attachment_sidecar_path(artifact_path)
79
+ records: list[dict[str, object]] = []
80
+ if sidecar_path.exists():
81
+ records = json.loads(sidecar_path.read_text(encoding="utf-8"))
82
+ records.append(record.as_dict())
83
+ sidecar_path.write_text(json.dumps(records, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
84
+ return sidecar_path
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from os import environ
6
+ from typing import Any, Protocol, SupportsIndex, cast
7
+ from urllib import error, request
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class LLMRequest:
12
+ system: str
13
+ user: str
14
+ temperature: float = 0.2
15
+ response_format: str = "text"
16
+ schema: dict[str, object] | None = None
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class LLMResponse:
21
+ content: str
22
+ provider: str
23
+ model: str
24
+ prompt_tokens: int | None = None
25
+ completion_tokens: int | None = None
26
+
27
+
28
+ class LLMProvider(Protocol):
29
+ def complete(self, request: LLMRequest) -> LLMResponse:
30
+ raise NotImplementedError
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class OllamaProvider:
35
+ base_url: str
36
+ model: str
37
+ timeout: float = 120.0
38
+
39
+ def complete(self, request: LLMRequest) -> LLMResponse:
40
+ payload: dict[str, object] = {
41
+ "model": self.model,
42
+ "messages": [
43
+ {"role": "system", "content": request.system},
44
+ {"role": "user", "content": request.user},
45
+ ],
46
+ "stream": False,
47
+ "options": {"temperature": request.temperature},
48
+ }
49
+ if request.response_format == "json" or request.schema is not None:
50
+ payload["format"] = "json"
51
+
52
+ response = self._post_json("/api/chat", payload)
53
+ message = response.get("message") or {}
54
+ content = str(message.get("content") or "")
55
+ return LLMResponse(
56
+ content=content,
57
+ provider="ollama",
58
+ model=self.model,
59
+ prompt_tokens=_int_or_none(response.get("prompt_eval_count")),
60
+ completion_tokens=_int_or_none(response.get("eval_count")),
61
+ )
62
+
63
+ def _post_json(self, path: str, payload: dict[str, object]) -> dict[str, Any]:
64
+ body = json.dumps(payload).encode("utf-8")
65
+ req = request.Request(
66
+ self.base_url.rstrip("/") + path,
67
+ data=body,
68
+ headers={"Content-Type": "application/json"},
69
+ method="POST",
70
+ )
71
+ try:
72
+ with request.urlopen(req, timeout=self.timeout) as resp:
73
+ raw = resp.read().decode("utf-8")
74
+ except error.HTTPError as exc: # pragma: no cover - thin transport wrapper
75
+ detail = exc.read().decode("utf-8", errors="replace")
76
+ raise RuntimeError(f"Ollama request failed: {exc.code} {detail}") from exc
77
+ except error.URLError as exc: # pragma: no cover - thin transport wrapper
78
+ raise RuntimeError(f"Ollama request failed: {exc.reason}") from exc
79
+
80
+ try:
81
+ return cast(dict[str, object], json.loads(raw))
82
+ except json.JSONDecodeError as exc:
83
+ raise RuntimeError(f"Ollama returned invalid JSON: {exc}") from exc
84
+
85
+
86
+ def build_provider(provider: str | None, *, model: str | None, base_url: str | None) -> LLMProvider | None:
87
+ if provider is None:
88
+ return None
89
+ normalized = provider.strip().lower()
90
+ if normalized in {"local", "heuristic", "none"}:
91
+ return None
92
+ if normalized == "anthropic":
93
+ api_key = environ.get("ANTHROPIC_API_KEY")
94
+ if not api_key:
95
+ raise ValueError("anthropic provider requires ANTHROPIC_API_KEY")
96
+ if not model:
97
+ raise ValueError("anthropic provider requires a model")
98
+ return AnthropicProvider(api_key=api_key, model=model, base_url=base_url or "https://api.anthropic.com")
99
+ if normalized == "ollama":
100
+ if not model:
101
+ raise ValueError("ollama provider requires a model")
102
+ return OllamaProvider(base_url=base_url or "http://localhost:11434", model=model)
103
+ raise ValueError(f"Unsupported LLM provider: {provider}")
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class AnthropicProvider:
108
+ api_key: str
109
+ model: str
110
+ base_url: str = "https://api.anthropic.com"
111
+ timeout: float = 120.0
112
+
113
+ def complete(self, request: LLMRequest) -> LLMResponse:
114
+ payload: dict[str, object] = {
115
+ "model": self.model,
116
+ "max_tokens": 1024,
117
+ "system": request.system,
118
+ "messages": [
119
+ {"role": "user", "content": request.user},
120
+ ],
121
+ }
122
+ if request.response_format == "json" or request.schema is not None:
123
+ payload["max_tokens"] = 2048
124
+
125
+ response = self._post_json("/v1/messages", payload)
126
+ content = self._extract_content(response.get("content"))
127
+ usage = response.get("usage") if isinstance(response.get("usage"), dict) else None
128
+ return LLMResponse(
129
+ content=content,
130
+ provider="anthropic",
131
+ model=self.model,
132
+ prompt_tokens=_int_or_none(usage.get("input_tokens") if isinstance(usage, dict) else None),
133
+ completion_tokens=_int_or_none(usage.get("output_tokens") if isinstance(usage, dict) else None),
134
+ )
135
+
136
+ def _post_json(self, path: str, payload: dict[str, object]) -> dict[str, Any]:
137
+ body = json.dumps(payload).encode("utf-8")
138
+ req = request.Request(
139
+ self.base_url.rstrip("/") + path,
140
+ data=body,
141
+ headers={
142
+ "Content-Type": "application/json",
143
+ "x-api-key": self.api_key,
144
+ "anthropic-version": "2023-06-01",
145
+ },
146
+ method="POST",
147
+ )
148
+ try:
149
+ with request.urlopen(req, timeout=self.timeout) as resp:
150
+ raw = resp.read().decode("utf-8")
151
+ except error.HTTPError as exc: # pragma: no cover - thin transport wrapper
152
+ detail = exc.read().decode("utf-8", errors="replace")
153
+ raise RuntimeError(f"Anthropic request failed: {exc.code} {detail}") from exc
154
+ except error.URLError as exc: # pragma: no cover - thin transport wrapper
155
+ raise RuntimeError(f"Anthropic request failed: {exc.reason}") from exc
156
+
157
+ try:
158
+ return cast(dict[str, object], json.loads(raw))
159
+ except json.JSONDecodeError as exc:
160
+ raise RuntimeError(f"Anthropic returned invalid JSON: {exc}") from exc
161
+
162
+ def _extract_content(self, content: object) -> str:
163
+ if isinstance(content, list):
164
+ parts: list[str] = []
165
+ for block in content:
166
+ if not isinstance(block, dict):
167
+ continue
168
+ if block.get("type") == "text":
169
+ parts.append(str(block.get("text") or ""))
170
+ return "".join(parts)
171
+ if isinstance(content, str):
172
+ return content
173
+ return ""
174
+
175
+
176
+ def _int_or_none(value: object) -> int | None:
177
+ if value is None:
178
+ return None
179
+ try:
180
+ return int(cast("int | str | bytes | bytearray | SupportsIndex", value))
181
+ except TypeError, ValueError:
182
+ return None
modelable/llm/qa.py ADDED
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from modelable.compiler.workspace import Workspace
4
+ from modelable.llm.context import parse_model_ref
5
+
6
+
7
+ def answer_question(workspace: Workspace, question: str) -> str:
8
+ lower = question.lower().strip()
9
+ if "owner" in lower or "who owns" in lower:
10
+ return _answer_owner_question(workspace, question)
11
+ if "lineage" in lower or "where did" in lower:
12
+ return _answer_lineage_question(workspace, question)
13
+ if "depend" in lower or "impact" in lower or "break" in lower:
14
+ return _answer_dependency_question(workspace, question)
15
+ if "field" in lower or "model" in lower:
16
+ return _answer_model_question(workspace, question)
17
+ return "I can answer ownership, lineage, dependency, and validation questions when you point me at a model or projection."
18
+
19
+
20
+ def _answer_owner_question(workspace: Workspace, question: str) -> str:
21
+ ref = _extract_ref(question)
22
+ if ref:
23
+ domain = next((d for d in workspace.mdl.domains if d.name == ref.domain), None)
24
+ if domain is None:
25
+ return f"Unknown domain: {ref.domain}"
26
+ if ref.name in domain.models:
27
+ owner = domain.owner or "unspecified"
28
+ return f"{ref.domain}.{ref.name}@{ref.version} is owned by {owner}."
29
+ if ref.name in domain.projections:
30
+ owner = domain.owner or "unspecified"
31
+ return f"{ref.domain}.{ref.name}@{ref.version} is owned by {owner}."
32
+ return _domain_owners(workspace)
33
+
34
+
35
+ def _answer_lineage_question(workspace: Workspace, question: str) -> str:
36
+ ref = _extract_ref(question)
37
+ if not ref:
38
+ return "Ask about a specific projection ref like `billing.BillingCustomer@1`."
39
+ domain = next((d for d in workspace.mdl.domains if d.name == ref.domain), None)
40
+ if domain is None:
41
+ return f"Unknown domain: {ref.domain}"
42
+ versions = domain.projections.get(ref.name)
43
+ if not versions:
44
+ return f"Unknown projection: {ref.domain}.{ref.name}"
45
+ version = next((item for item in versions if item.version == ref.version), None)
46
+ if version is None:
47
+ return f"Unknown projection version: {ref.domain}.{ref.name}@{ref.version}"
48
+ lines = [f"{ref.domain}.{ref.name}@{ref.version} lineage:"]
49
+ for field in version.fields:
50
+ lines.append(f"- {field.name}: {_field_lineage(field)}")
51
+ return "\n".join(lines)
52
+
53
+
54
+ def _answer_dependency_question(workspace: Workspace, question: str) -> str:
55
+ ref = _extract_ref(question)
56
+ if not ref:
57
+ return "Ask about a specific model ref like `customer.Customer@1`."
58
+ dependents: list[str] = []
59
+ for domain in workspace.mdl.domains:
60
+ for projection_name, versions in domain.projections.items():
61
+ for version in versions:
62
+ if version.source.model == f"{ref.domain}.{ref.name}" or any(
63
+ join.model == f"{ref.domain}.{ref.name}" for join in version.joins
64
+ ):
65
+ dependents.append(f"{domain.name}.{projection_name}@{version.version}")
66
+ if not dependents:
67
+ return f"No projections currently depend on {ref.domain}.{ref.name}@{ref.version}."
68
+ return "Dependents:\n" + "\n".join(f"- {item}" for item in dependents)
69
+
70
+
71
+ def _answer_model_question(workspace: Workspace, question: str) -> str:
72
+ ref = _extract_ref(question)
73
+ if ref:
74
+ return _model_summary(workspace, ref.domain, ref.name, ref.version)
75
+ return _workspace_summary(workspace)
76
+
77
+
78
+ def _extract_ref(question: str):
79
+ tokens = question.replace("?", " ").replace(",", " ").split()
80
+ for token in tokens:
81
+ if "@" in token and "." in token:
82
+ try:
83
+ return parse_model_ref(token.strip("`"))
84
+ except Exception:
85
+ continue
86
+ return None
87
+
88
+
89
+ def _model_summary(workspace: Workspace, domain_name: str, model_name: str, version: int) -> str:
90
+ domain = next((d for d in workspace.mdl.domains if d.name == domain_name), None)
91
+ if domain is None:
92
+ return f"Unknown domain: {domain_name}"
93
+ versions = domain.models.get(model_name)
94
+ if not versions:
95
+ return f"Unknown model: {domain_name}.{model_name}"
96
+ mv = next((item for item in versions if item.version == version), None)
97
+ if mv is None:
98
+ return f"Unknown model version: {domain_name}.{model_name}@{version}"
99
+ lines = [f"{domain_name}.{model_name}@{version} ({mv.model_kind.value}, {mv.change_kind.value})"]
100
+ if domain.owner:
101
+ lines.append(f"owner: {domain.owner}")
102
+ for field in mv.fields:
103
+ lines.append(f"- {field.name}")
104
+ return "\n".join(lines)
105
+
106
+
107
+ def _domain_owners(workspace: Workspace) -> str:
108
+ lines = []
109
+ for domain in workspace.mdl.domains:
110
+ owner = domain.owner or "unspecified"
111
+ lines.append(f"{domain.name}: {owner}")
112
+ return "\n".join(lines) if lines else "No domains found."
113
+
114
+
115
+ def _workspace_summary(workspace: Workspace) -> str:
116
+ lines = []
117
+ for domain in workspace.mdl.domains:
118
+ lines.append(f"{domain.name}: {len(domain.models)} models, {len(domain.projections)} projections")
119
+ return "\n".join(lines) if lines else "Workspace is empty."
120
+
121
+
122
+ def _field_lineage(field) -> str:
123
+ mapping = field.mapping
124
+ if mapping.kind == "direct":
125
+ return f"direct {mapping.source_alias}.{mapping.source_field}"
126
+ return f"computed {mapping.expression}"
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from modelable.compiler.workspace import Workspace
4
+ from modelable.llm.context import parse_model_ref
5
+
6
+
7
+ def recommend_for_model(workspace: Workspace, ref: str | None = None, consumer: str | None = None) -> str:
8
+ if ref is None:
9
+ return "Provide a model ref like `customer.Customer@1` to get a targeted recommendation."
10
+ model_ref = parse_model_ref(ref)
11
+ domain = next((d for d in workspace.mdl.domains if d.name == model_ref.domain), None)
12
+ if domain is None:
13
+ return f"Unknown domain: {model_ref.domain}"
14
+ versions = domain.models.get(model_ref.name)
15
+ if not versions:
16
+ return f"Unknown model: {model_ref.domain}.{model_ref.name}"
17
+ version = next((item for item in versions if item.version == model_ref.version), None)
18
+ if version is None:
19
+ return f"Unknown model version: {ref}"
20
+
21
+ lines = [f"Recommendations for {ref}:"]
22
+ if consumer:
23
+ lines.append(f"- draft a projection for consumer `{consumer}` with explicit lineage")
24
+ if any(field.is_pii for field in version.fields):
25
+ lines.append("- preserve governance metadata or exclude PII from downstream projections")
26
+ if any(
27
+ field.classification and field.classification.value in {"confidential", "secret"} for field in version.fields
28
+ ):
29
+ lines.append("- keep classification on derived fields and avoid weakening it in projections")
30
+ if len(version.fields) > 10:
31
+ lines.append("- consider splitting the model into a smaller core entity plus value objects")
32
+ lines.append("- pin version references explicitly when generating imports or projections")
33
+ return "\n".join(lines)
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ _REDACTION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
6
+ (re.compile(r"(?i)\b(password|passwd|secret|token|api[_-]?key)\s*=\s*([^\s,;]+)"), r"\1=[REDACTED]"),
7
+ (re.compile(r"(?i)\b(password|passwd|secret|token|api[_-]?key)\s*:\s*([^\s,;]+)"), r"\1: [REDACTED]"),
8
+ (
9
+ re.compile(r"(?i)(-----BEGIN [^-]+PRIVATE KEY-----).*?(-----END [^-]+PRIVATE KEY-----)", re.DOTALL),
10
+ "[REDACTED PRIVATE KEY]",
11
+ ),
12
+ )
13
+
14
+
15
+ def redact_sensitive_values(text: str) -> str:
16
+ redacted = text
17
+ for pattern, replacement in _REDACTION_PATTERNS:
18
+ redacted = pattern.sub(replacement, redacted)
19
+ return redacted