coding-client 1.21.2__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.
- coding_client/__init__.py +1 -0
- coding_client/api/__init__.py +19 -0
- coding_client/api/exception_handlers.py +158 -0
- coding_client/api/logging_setup.py +44 -0
- coding_client/api/metrics.py +40 -0
- coding_client/api/openapi_config.py +124 -0
- coding_client/api/request_logging.py +60 -0
- coding_client/api/validation.py +150 -0
- coding_client/auth/__init__.py +78 -0
- coding_client/auth/_default_tenant.py +132 -0
- coding_client/auth/_header_trust.py +98 -0
- coding_client/auth/_onbehalf.py +315 -0
- coding_client/auth/_trusted_networks.py +94 -0
- coding_client/auth/dependencies.py +61 -0
- coding_client/auth/middleware.py +445 -0
- coding_client/auth/models.py +83 -0
- coding_client/auth/outbound.py +137 -0
- coding_client/auth/rbac.py +21 -0
- coding_client/auth/rbac_coding.py +5 -0
- coding_client/auth/tenant.py +650 -0
- coding_client/auth/websocket.py +148 -0
- coding_client/clients/__init__.py +24 -0
- coding_client/clients/base.py +131 -0
- coding_client/clients/coding_ai.py +33 -0
- coding_client/clients/coding_analysis.py +36 -0
- coding_client/clients/coding_containers.py +45 -0
- coding_client/clients/coding_github.py +127 -0
- coding_client/clients/coding_realtime.py +127 -0
- coding_client/clients/coding_security.py +51 -0
- coding_client/clients/coding_testing.py +31 -0
- coding_client/clients/coding_workspaces.py +265 -0
- coding_client/config/__init__.py +12 -0
- coding_client/config/base_settings.py +61 -0
- coding_client/config/yaml_config_loader.py +172 -0
- coding_client/db/__init__.py +42 -0
- coding_client/db/db_types.py +122 -0
- coding_client/db/exceptions.py +59 -0
- coding_client/db/postgres_connection.py +572 -0
- coding_client/db/tenant_query_helper.py +417 -0
- coding_client/db_init.py +49 -0
- coding_client/exceptions/__init__.py +67 -0
- coding_client/exceptions/api_exceptions.py +395 -0
- coding_client/exceptions/pydantic_ai_exceptions.py +533 -0
- coding_client/interfaces/__init__.py +22 -0
- coding_client/interfaces/cache_interface.py +179 -0
- coding_client/interfaces/container_interface.py +49 -0
- coding_client/interfaces/docker_interface.py +283 -0
- coding_client/interfaces/graph_interface.py +28 -0
- coding_client/interfaces/security_interface.py +72 -0
- coding_client/interfaces/webhook_interface.py +67 -0
- coding_client/llm/__init__.py +54 -0
- coding_client/llm/base_agent.py +195 -0
- coding_client/llm/context.py +121 -0
- coding_client/llm/responses.py +102 -0
- coding_client/llm/routing.py +82 -0
- coding_client/llm/settings.py +102 -0
- coding_client/models/__init__.py +62 -0
- coding_client/models/agent.py +38 -0
- coding_client/models/ai_test_failure.py +745 -0
- coding_client/models/analysis.py +330 -0
- coding_client/models/architecture.py +685 -0
- coding_client/models/bug.py +128 -0
- coding_client/models/code_graph.py +317 -0
- coding_client/models/container.py +241 -0
- coding_client/models/core.py +878 -0
- coding_client/models/docker_container.py +104 -0
- coding_client/models/github.py +293 -0
- coding_client/models/logging.py +29 -0
- coding_client/models/package_manager.py +257 -0
- coding_client/models/pr_validation.py +440 -0
- coding_client/models/pydantic_ai.py +395 -0
- coding_client/models/repo_scanner.py +249 -0
- coding_client/models/responses.py +431 -0
- coding_client/models/security.py +185 -0
- coding_client/models/static_analysis.py +508 -0
- coding_client/models/system.py +48 -0
- coding_client/models/test_executor.py +204 -0
- coding_client/models/testing.py +673 -0
- coding_client/models/testing_config.py +200 -0
- coding_client/models/validation.py +127 -0
- coding_client/models_init.py +272 -0
- coding_client/telemetry.py +195 -0
- coding_client/testing.py +180 -0
- coding_client/types/__init__.py +130 -0
- coding_client/types/constraint_types.py +276 -0
- coding_client/types/core_types.py +112 -0
- coding_client/types/state_machine.py +443 -0
- coding_client/utils/__init__.py +86 -0
- coding_client/utils/archive_utils.py +387 -0
- coding_client/utils/ast_debug.py +45 -0
- coding_client/utils/async_timeout.py +364 -0
- coding_client/utils/constants.py +518 -0
- coding_client/utils/content_processing.py +326 -0
- coding_client/utils/database.py +916 -0
- coding_client/utils/encoding_helper.py +222 -0
- coding_client/utils/enum_helpers.py +166 -0
- coding_client/utils/file_operations.py +177 -0
- coding_client/utils/filesystem_ops.py +528 -0
- coding_client/utils/http_client.py +562 -0
- coding_client/utils/path_utils.py +471 -0
- coding_client/utils/profiling.py +421 -0
- coding_client/utils/response_builder.py +240 -0
- coding_client/utils/retry_helper.py +712 -0
- coding_client/utils/security_validation.py +423 -0
- coding_client-1.21.2.dist-info/METADATA +234 -0
- coding_client-1.21.2.dist-info/RECORD +109 -0
- coding_client-1.21.2.dist-info/WHEEL +5 -0
- coding_client-1.21.2.dist-info/licenses/LICENSE +201 -0
- coding_client-1.21.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""HTTP client for the coding-security service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .base import ServiceClient
|
|
9
|
+
|
|
10
|
+
_ENV_VAR = "CODING_SECURITY_URL"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CodingSecurityClient(ServiceClient):
|
|
14
|
+
"""Client for coding-security endpoints."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
base_url: str | None = None,
|
|
19
|
+
timeout: float = 30.0,
|
|
20
|
+
max_retries: int = 3,
|
|
21
|
+
):
|
|
22
|
+
url = base_url or os.environ.get(_ENV_VAR, "")
|
|
23
|
+
if not url:
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
f"CodingSecurityClient requires base_url or {_ENV_VAR} env var. "
|
|
26
|
+
"See the platform configuration policy."
|
|
27
|
+
)
|
|
28
|
+
super().__init__(url, timeout=timeout, max_retries=max_retries)
|
|
29
|
+
|
|
30
|
+
async def scan(
|
|
31
|
+
self, container_name: str, headers: dict[str, str] | None = None, **kwargs: Any
|
|
32
|
+
) -> Any:
|
|
33
|
+
return await self.post(f"/api/security/scan/{container_name}", headers=headers, json=kwargs)
|
|
34
|
+
|
|
35
|
+
async def get_scan_results(
|
|
36
|
+
self, container_name: str, headers: dict[str, str] | None = None, **kwargs: Any
|
|
37
|
+
) -> Any:
|
|
38
|
+
return await self.get(f"/api/security/report/{container_name}", headers=headers, **kwargs)
|
|
39
|
+
|
|
40
|
+
async def analyze_vulnerabilities(
|
|
41
|
+
self,
|
|
42
|
+
file_paths: list[str],
|
|
43
|
+
max_files: int = 20,
|
|
44
|
+
headers: dict[str, str] | None = None,
|
|
45
|
+
**kwargs: Any,
|
|
46
|
+
) -> Any:
|
|
47
|
+
return await self.post(
|
|
48
|
+
"/api/security/analyze-vulnerabilities",
|
|
49
|
+
headers=headers,
|
|
50
|
+
json={"file_paths": file_paths, "max_files": max_files, **kwargs},
|
|
51
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""HTTP client for the coding-testing service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .base import ServiceClient
|
|
9
|
+
|
|
10
|
+
_ENV_VAR = "CODING_TESTING_URL"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CodingTestingClient(ServiceClient):
|
|
14
|
+
"""Client for coding-testing endpoints."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
base_url: str | None = None,
|
|
19
|
+
timeout: float = 30.0,
|
|
20
|
+
max_retries: int = 3,
|
|
21
|
+
):
|
|
22
|
+
url = base_url or os.environ.get(_ENV_VAR, "")
|
|
23
|
+
if not url:
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
f"CodingTestingClient requires base_url or {_ENV_VAR} env var. "
|
|
26
|
+
"See the platform configuration policy."
|
|
27
|
+
)
|
|
28
|
+
super().__init__(url, timeout=timeout, max_retries=max_retries)
|
|
29
|
+
|
|
30
|
+
async def run_tests(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
31
|
+
return await self.post("/api/tests/execute", headers=headers, json=kwargs)
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""HTTP client for canonical repository-workspace operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .base import ServiceClient
|
|
9
|
+
|
|
10
|
+
_ENV_VAR = "CODING_CONTAINERS_URL"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CodingWorkspacesClient(ServiceClient):
|
|
14
|
+
"""Client for workspaces owned by the coding-containers service.
|
|
15
|
+
|
|
16
|
+
A workspace is a checked-out GitHub repository plus its isolated runtime.
|
|
17
|
+
The runtime may be Docker or Kubernetes, so callers should use this client
|
|
18
|
+
instead of coupling domain operations to the legacy container API.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
base_url: str | None = None,
|
|
24
|
+
timeout: float = 30.0,
|
|
25
|
+
max_retries: int = 3,
|
|
26
|
+
) -> None:
|
|
27
|
+
url = base_url or os.environ.get(_ENV_VAR, "")
|
|
28
|
+
if not url:
|
|
29
|
+
raise RuntimeError(
|
|
30
|
+
f"CodingWorkspacesClient requires base_url or {_ENV_VAR} env var. "
|
|
31
|
+
"See the platform configuration policy."
|
|
32
|
+
)
|
|
33
|
+
super().__init__(url, timeout=timeout, max_retries=max_retries)
|
|
34
|
+
|
|
35
|
+
async def list_workspaces(self, headers: dict[str, str] | None = None, **filters: Any) -> Any:
|
|
36
|
+
return await self.get("/api/workspaces/", headers=headers, params=filters)
|
|
37
|
+
|
|
38
|
+
async def create_workspace(
|
|
39
|
+
self, headers: dict[str, str] | None = None, **workspace: Any
|
|
40
|
+
) -> Any:
|
|
41
|
+
return await self.post("/api/workspaces/", headers=headers, json=workspace)
|
|
42
|
+
|
|
43
|
+
async def get_workspace(
|
|
44
|
+
self, workspace_name: str, headers: dict[str, str] | None = None
|
|
45
|
+
) -> Any:
|
|
46
|
+
return await self.get(f"/api/workspaces/{workspace_name}", headers=headers)
|
|
47
|
+
|
|
48
|
+
async def get_status(self, workspace_name: str, headers: dict[str, str] | None = None) -> Any:
|
|
49
|
+
return await self.get(f"/api/workspaces/{workspace_name}/status", headers=headers)
|
|
50
|
+
|
|
51
|
+
async def delete_workspace(
|
|
52
|
+
self,
|
|
53
|
+
workspace_name: str,
|
|
54
|
+
headers: dict[str, str] | None = None,
|
|
55
|
+
**options: Any,
|
|
56
|
+
) -> Any:
|
|
57
|
+
return await self.delete(
|
|
58
|
+
f"/api/workspaces/{workspace_name}", headers=headers, params=options
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def delete_all_workspaces(self, headers: dict[str, str] | None = None) -> Any:
|
|
62
|
+
return await self.delete("/api/workspaces/all", headers=headers)
|
|
63
|
+
|
|
64
|
+
async def list_files(
|
|
65
|
+
self,
|
|
66
|
+
workspace_name: str,
|
|
67
|
+
headers: dict[str, str] | None = None,
|
|
68
|
+
**query: Any,
|
|
69
|
+
) -> Any:
|
|
70
|
+
return await self.get(
|
|
71
|
+
f"/api/workspaces/{workspace_name}/files", headers=headers, params=query
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def create_directory(
|
|
75
|
+
self, workspace_name: str, headers: dict[str, str] | None = None, **body: Any
|
|
76
|
+
) -> Any:
|
|
77
|
+
return await self.post(
|
|
78
|
+
f"/api/workspaces/{workspace_name}/mkdir", headers=headers, json=body
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def create_file(
|
|
82
|
+
self, workspace_name: str, headers: dict[str, str] | None = None, **body: Any
|
|
83
|
+
) -> Any:
|
|
84
|
+
return await self.post(
|
|
85
|
+
f"/api/workspaces/{workspace_name}/touch", headers=headers, json=body
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def write_file(
|
|
89
|
+
self, workspace_name: str, headers: dict[str, str] | None = None, **body: Any
|
|
90
|
+
) -> Any:
|
|
91
|
+
return await self.post(
|
|
92
|
+
f"/api/workspaces/{workspace_name}/write-file", headers=headers, json=body
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def move_path(
|
|
96
|
+
self, workspace_name: str, headers: dict[str, str] | None = None, **body: Any
|
|
97
|
+
) -> Any:
|
|
98
|
+
return await self.post(f"/api/workspaces/{workspace_name}/move", headers=headers, json=body)
|
|
99
|
+
|
|
100
|
+
async def delete_path(
|
|
101
|
+
self,
|
|
102
|
+
workspace_name: str,
|
|
103
|
+
headers: dict[str, str] | None = None,
|
|
104
|
+
**query: Any,
|
|
105
|
+
) -> Any:
|
|
106
|
+
return await self.delete(
|
|
107
|
+
f"/api/workspaces/{workspace_name}/files", headers=headers, params=query
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def get_git_status(
|
|
111
|
+
self, workspace_name: str, headers: dict[str, str] | None = None
|
|
112
|
+
) -> Any:
|
|
113
|
+
return await self.get(f"/api/workspaces/{workspace_name}/git/status", headers=headers)
|
|
114
|
+
|
|
115
|
+
async def commit(
|
|
116
|
+
self, workspace_name: str, headers: dict[str, str] | None = None, **body: Any
|
|
117
|
+
) -> Any:
|
|
118
|
+
return await self.post(
|
|
119
|
+
f"/api/workspaces/{workspace_name}/git/commit", headers=headers, json=body
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def push(
|
|
123
|
+
self, workspace_name: str, headers: dict[str, str] | None = None, **body: Any
|
|
124
|
+
) -> Any:
|
|
125
|
+
return await self.post(
|
|
126
|
+
f"/api/workspaces/{workspace_name}/git/push", headers=headers, json=body
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def exec(
|
|
130
|
+
self, workspace_name: str, headers: dict[str, str] | None = None, **body: Any
|
|
131
|
+
) -> Any:
|
|
132
|
+
return await self.post(f"/api/workspaces/{workspace_name}/exec", headers=headers, json=body)
|
|
133
|
+
|
|
134
|
+
async def install_dependencies(
|
|
135
|
+
self, workspace_name: str, headers: dict[str, str] | None = None, **body: Any
|
|
136
|
+
) -> Any:
|
|
137
|
+
return await self.post(
|
|
138
|
+
f"/api/workspaces/{workspace_name}/install-dependencies",
|
|
139
|
+
headers=headers,
|
|
140
|
+
json=body,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
async def get_dependency_status(
|
|
144
|
+
self, workspace_name: str, headers: dict[str, str] | None = None
|
|
145
|
+
) -> Any:
|
|
146
|
+
return await self.get(
|
|
147
|
+
f"/api/workspaces/{workspace_name}/dependency-status", headers=headers
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def package_upgrade(
|
|
151
|
+
self, workspace_name: str, headers: dict[str, str] | None = None, **body: Any
|
|
152
|
+
) -> Any:
|
|
153
|
+
return await self.post(
|
|
154
|
+
f"/api/workspaces/{workspace_name}/package-upgrade", headers=headers, json=body
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def package_modify(
|
|
158
|
+
self, workspace_name: str, headers: dict[str, str] | None = None, **body: Any
|
|
159
|
+
) -> Any:
|
|
160
|
+
return await self.post(
|
|
161
|
+
f"/api/workspaces/{workspace_name}/package-modify", headers=headers, json=body
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def execute_agent(
|
|
165
|
+
self, workspace_name: str, headers: dict[str, str] | None = None, **body: Any
|
|
166
|
+
) -> Any:
|
|
167
|
+
return await self.post(
|
|
168
|
+
f"/api/workspaces/{workspace_name}/execute-agent", headers=headers, json=body
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def get_agent_capabilities(
|
|
172
|
+
self, workspace_name: str, headers: dict[str, str] | None = None
|
|
173
|
+
) -> Any:
|
|
174
|
+
return await self.get(
|
|
175
|
+
f"/api/workspaces/{workspace_name}/agent-capabilities", headers=headers
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# Async exec jobs (coding-containers ``src/routers/exec_async.py``)
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
async def exec_async(
|
|
183
|
+
self,
|
|
184
|
+
workspace_name: str,
|
|
185
|
+
command: list[str],
|
|
186
|
+
workdir: str | None = None,
|
|
187
|
+
timeout: int | None = None,
|
|
188
|
+
callback_url: str | None = None,
|
|
189
|
+
headers: dict[str, str] | None = None,
|
|
190
|
+
) -> Any:
|
|
191
|
+
"""Start ``command`` as an async job: ``POST {ws}/exec/async`` (202).
|
|
192
|
+
|
|
193
|
+
Returns the accepted document (``job_id``, ``container_name``, ``status``,
|
|
194
|
+
``links.status``). ``timeout`` is the job's wall-clock limit in seconds
|
|
195
|
+
(server range 1-14400, default 3600), not the HTTP timeout. Omitted
|
|
196
|
+
arguments are not sent, so the server's defaults apply.
|
|
197
|
+
|
|
198
|
+
Sent exactly once (no transport retry): a retried POST without
|
|
199
|
+
``callback_url`` would start a second job. With ``callback_url`` the
|
|
200
|
+
server de-duplicates a replay itself (same ``job_id``).
|
|
201
|
+
"""
|
|
202
|
+
body: dict[str, Any] = {"command": command}
|
|
203
|
+
if workdir is not None:
|
|
204
|
+
body["workdir"] = workdir
|
|
205
|
+
if timeout is not None:
|
|
206
|
+
body["timeout"] = timeout
|
|
207
|
+
if callback_url is not None:
|
|
208
|
+
body["callback_url"] = callback_url
|
|
209
|
+
return await self.post(
|
|
210
|
+
f"/api/workspaces/{workspace_name}/exec/async",
|
|
211
|
+
headers=headers,
|
|
212
|
+
json=body,
|
|
213
|
+
max_attempts=1,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def get_exec_job(
|
|
217
|
+
self, workspace_name: str, job_id: str, headers: dict[str, str] | None = None
|
|
218
|
+
) -> Any:
|
|
219
|
+
"""Read an async exec job: ``GET {ws}/exec/jobs/{job_id}``.
|
|
220
|
+
|
|
221
|
+
Status is ``queued | running | completed | failed``; a job of another
|
|
222
|
+
tenant or workspace is a 404 (:class:`ServiceError`).
|
|
223
|
+
"""
|
|
224
|
+
return await self.get(
|
|
225
|
+
f"/api/workspaces/{workspace_name}/exec/jobs/{job_id}", headers=headers
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
# Secondary read-only repositories
|
|
230
|
+
# (coding-containers ``src/routers/containers/secondary_repos.py``)
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
async def add_repo(
|
|
234
|
+
self,
|
|
235
|
+
workspace_name: str,
|
|
236
|
+
repo_url: str,
|
|
237
|
+
ref: str | None = None,
|
|
238
|
+
refresh: bool = False,
|
|
239
|
+
headers: dict[str, str] | None = None,
|
|
240
|
+
request_timeout: float | None = None,
|
|
241
|
+
) -> Any:
|
|
242
|
+
"""Check out a read-only secondary repository: ``POST {ws}/repos``.
|
|
243
|
+
|
|
244
|
+
``repo_url`` is ``https://github.com/<owner>/<repo>`` or ``<owner>/<repo>``;
|
|
245
|
+
``ref`` a branch, tag or full commit SHA (default: the repository's default
|
|
246
|
+
branch). The checkout lands at ``/workspace/.repos/<owner>__<repo>`` (the
|
|
247
|
+
response's ``path``) and is always read-only: it has no remote and no push
|
|
248
|
+
credential. ``request_timeout`` overrides the client's HTTP timeout for a
|
|
249
|
+
large clone.
|
|
250
|
+
"""
|
|
251
|
+
body: dict[str, Any] = {"repo_url": repo_url}
|
|
252
|
+
if ref is not None:
|
|
253
|
+
body["ref"] = ref
|
|
254
|
+
if refresh:
|
|
255
|
+
body["refresh"] = True
|
|
256
|
+
kwargs: dict[str, Any] = {}
|
|
257
|
+
if request_timeout is not None:
|
|
258
|
+
kwargs["timeout"] = request_timeout
|
|
259
|
+
return await self.post(
|
|
260
|
+
f"/api/workspaces/{workspace_name}/repos", headers=headers, json=body, **kwargs
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
async def list_repos(self, workspace_name: str, headers: dict[str, str] | None = None) -> Any:
|
|
264
|
+
"""List the workspace's secondary repositories: ``GET {ws}/repos``."""
|
|
265
|
+
return await self.get(f"/api/workspaces/{workspace_name}/repos", headers=headers)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Configuration loading for coding-client.
|
|
2
|
+
|
|
3
|
+
Provides YAML configuration loading with schema versioning support.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .base_settings import BaseMicroserviceSettings
|
|
7
|
+
from .yaml_config_loader import YamlConfigLoader
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BaseMicroserviceSettings",
|
|
11
|
+
"YamlConfigLoader",
|
|
12
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared base settings for coding-* microservices.
|
|
2
|
+
|
|
3
|
+
Centralises the fail-loud-in-production pattern repeated across every service:
|
|
4
|
+
each service lists the env-var-backed fields that must be non-empty when
|
|
5
|
+
``ENVIRONMENT=production`` via ``_REQUIRED_IN_PRODUCTION``; this base raises a
|
|
6
|
+
single combined ``RuntimeError`` at startup if any are missing, instead of
|
|
7
|
+
silently routing traffic to a dead default.
|
|
8
|
+
|
|
9
|
+
Per ``the platform configuration policy``: service
|
|
10
|
+
addresses must come from gitops env, and missing env in production must crash
|
|
11
|
+
boot loudly.
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
class Settings(BaseMicroserviceSettings):
|
|
16
|
+
model_config = SettingsConfigDict(env_prefix="CODING_FOO_")
|
|
17
|
+
_SERVICE_NAME = "coding-foo"
|
|
18
|
+
_REQUIRED_IN_PRODUCTION = ("database_url", "redis_url")
|
|
19
|
+
|
|
20
|
+
environment: str = "production"
|
|
21
|
+
database_url: str = ""
|
|
22
|
+
redis_url: str = ""
|
|
23
|
+
|
|
24
|
+
Services whose environment field is named ``ENVIRONMENT`` (rather than
|
|
25
|
+
``environment``) set ``_ENV_ATTR = "ENVIRONMENT"``. Subclasses that need extra
|
|
26
|
+
post-init behaviour must call ``super().model_post_init(context)``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from typing import ClassVar
|
|
32
|
+
|
|
33
|
+
from pydantic_settings import BaseSettings
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BaseMicroserviceSettings(BaseSettings):
|
|
37
|
+
"""Base settings providing required-in-production enforcement."""
|
|
38
|
+
|
|
39
|
+
# ── Subclass-overridable contract ─────────────────────────────────
|
|
40
|
+
# Fields (by attribute name) that must be non-empty in production.
|
|
41
|
+
_REQUIRED_IN_PRODUCTION: ClassVar[tuple[str, ...]] = ()
|
|
42
|
+
# Service name used in the missing-fields error message.
|
|
43
|
+
_SERVICE_NAME: ClassVar[str] = "service"
|
|
44
|
+
# Attribute that holds the environment string ("environment" or "ENVIRONMENT").
|
|
45
|
+
_ENV_ATTR: ClassVar[str] = "environment"
|
|
46
|
+
|
|
47
|
+
def model_post_init(self, __context: object) -> None:
|
|
48
|
+
self._enforce_required_in_production()
|
|
49
|
+
|
|
50
|
+
def _enforce_required_in_production(self) -> None:
|
|
51
|
+
"""Crash boot if any required field is empty in production."""
|
|
52
|
+
environment = getattr(self, self._ENV_ATTR, "production")
|
|
53
|
+
if environment != "production":
|
|
54
|
+
return
|
|
55
|
+
missing = [name for name in self._REQUIRED_IN_PRODUCTION if not getattr(self, name)]
|
|
56
|
+
if missing:
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
f"{self._SERVICE_NAME} settings missing required fields in production: "
|
|
59
|
+
f"{missing}. Set them via env vars on the Deployment "
|
|
60
|
+
"(see the platform repo)."
|
|
61
|
+
)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Module for loading YAML configuration files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
HAS_YAML = True
|
|
13
|
+
except ImportError:
|
|
14
|
+
HAS_YAML = False
|
|
15
|
+
yaml: Any = None
|
|
16
|
+
from pydantic import ValidationError
|
|
17
|
+
|
|
18
|
+
from ..models.architecture import RepoConfig
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class YamlConfigLoader:
|
|
24
|
+
"""Load and validate .codegraph.yml configuration files with versioning support."""
|
|
25
|
+
|
|
26
|
+
# Current schema version - update when making breaking changes
|
|
27
|
+
CURRENT_VERSION = "2.0"
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
"""Initialize the YAML config loader with version migration mappings."""
|
|
31
|
+
self._version_migrations = {
|
|
32
|
+
# Map from version to migration function
|
|
33
|
+
"1.0": self._migrate_v1_to_v2,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def load_config(self, repo: Path) -> RepoConfig:
|
|
37
|
+
"""Load configuration from .codegraph.yml with version detection and migration.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
repo: Repository root path containing .codegraph.yml
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Validated RepoConfig object, migrated to current schema if needed
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ValueError: If config is invalid or cannot be migrated
|
|
47
|
+
"""
|
|
48
|
+
cfg_path = repo / ".codegraph.yml"
|
|
49
|
+
|
|
50
|
+
if not cfg_path.is_file():
|
|
51
|
+
logger.info("No .codegraph.yml found at %s, using defaults", repo)
|
|
52
|
+
return RepoConfig()
|
|
53
|
+
|
|
54
|
+
if not HAS_YAML:
|
|
55
|
+
logger.warning("yaml module not available - using defaults")
|
|
56
|
+
return RepoConfig()
|
|
57
|
+
|
|
58
|
+
assert yaml is not None, "yaml module must be available (guarded by HAS_YAML check)"
|
|
59
|
+
try:
|
|
60
|
+
# Load raw data for version detection
|
|
61
|
+
raw_data = yaml.safe_load(cfg_path.read_text()) or {}
|
|
62
|
+
|
|
63
|
+
# Check version and migrate if necessary
|
|
64
|
+
config_version = self._detect_version(raw_data)
|
|
65
|
+
|
|
66
|
+
if config_version != self.CURRENT_VERSION:
|
|
67
|
+
logger.info(
|
|
68
|
+
"Config schema version %s detected, migrating to %s",
|
|
69
|
+
config_version,
|
|
70
|
+
self.CURRENT_VERSION,
|
|
71
|
+
)
|
|
72
|
+
raw_data = self._migrate_config(raw_data, config_version)
|
|
73
|
+
|
|
74
|
+
# Create and validate the config object
|
|
75
|
+
config = RepoConfig(**raw_data)
|
|
76
|
+
|
|
77
|
+
# Store version information
|
|
78
|
+
config.schema_version = self.CURRENT_VERSION
|
|
79
|
+
|
|
80
|
+
return config
|
|
81
|
+
|
|
82
|
+
except ValidationError as e:
|
|
83
|
+
raise ValueError(f"Invalid .codegraph.yml: {e}") from e
|
|
84
|
+
except yaml.YAMLError as e:
|
|
85
|
+
raise ValueError(f"Invalid YAML in .codegraph.yml: {e}") from e
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.exception("Error processing .codegraph.yml")
|
|
88
|
+
raise ValueError(f"Error reading .codegraph.yml: {e}") from e
|
|
89
|
+
|
|
90
|
+
def _detect_version(self, data: dict[str, Any]) -> str:
|
|
91
|
+
"""Detect the schema version from config data.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
data: Raw configuration dictionary
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Detected version string, defaults to "1.0" for legacy configs
|
|
98
|
+
"""
|
|
99
|
+
# Check if version is explicitly provided
|
|
100
|
+
if "schema_version" in data:
|
|
101
|
+
return str(data["schema_version"])
|
|
102
|
+
|
|
103
|
+
# Legacy detection logic
|
|
104
|
+
if "detect_by" in data and isinstance(data["detect_by"], list):
|
|
105
|
+
# This is likely a v1.0 config
|
|
106
|
+
return "1.0"
|
|
107
|
+
|
|
108
|
+
# Default for completely unknown formats
|
|
109
|
+
return "1.0"
|
|
110
|
+
|
|
111
|
+
def _migrate_config(self, data: dict[str, Any], from_version: str) -> dict[str, Any]:
|
|
112
|
+
"""Migrate configuration from one version to another.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
data: Raw configuration dictionary
|
|
116
|
+
from_version: Source version string
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Migrated configuration dictionary
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ValueError: If migration path doesn't exist
|
|
123
|
+
"""
|
|
124
|
+
current = from_version
|
|
125
|
+
config_data = data.copy()
|
|
126
|
+
|
|
127
|
+
# Find migration path to current version
|
|
128
|
+
while current != self.CURRENT_VERSION:
|
|
129
|
+
if current not in self._version_migrations:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"No migration path from version {current} to {self.CURRENT_VERSION}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Apply migration step
|
|
135
|
+
migration_func = self._version_migrations[current]
|
|
136
|
+
config_data = migration_func(config_data)
|
|
137
|
+
|
|
138
|
+
# Update to next version in chain
|
|
139
|
+
# The migration function should return the new version
|
|
140
|
+
current = config_data.get("schema_version", current)
|
|
141
|
+
|
|
142
|
+
return config_data
|
|
143
|
+
|
|
144
|
+
def _migrate_v1_to_v2(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
145
|
+
"""Migrate from schema v1.0 to v2.0.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
data: v1.0 config data
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
v2.0 config data
|
|
152
|
+
"""
|
|
153
|
+
# Make a copy to avoid modifying the original
|
|
154
|
+
result = data.copy()
|
|
155
|
+
|
|
156
|
+
# Set the new version
|
|
157
|
+
result["schema_version"] = "2.0"
|
|
158
|
+
|
|
159
|
+
# Handle renamed fields
|
|
160
|
+
if "detection_strategy" in result:
|
|
161
|
+
result["detect_by"] = result.pop("detection_strategy")
|
|
162
|
+
|
|
163
|
+
# Handle format changes for layer definitions
|
|
164
|
+
if "layers" in result:
|
|
165
|
+
layers = result["layers"]
|
|
166
|
+
|
|
167
|
+
# In v1, layers could be a basic list of names
|
|
168
|
+
# In v2, they must be a dict mapping names to glob patterns
|
|
169
|
+
if isinstance(layers, list):
|
|
170
|
+
result["layers"] = {layer: ["**/*"] for layer in layers}
|
|
171
|
+
|
|
172
|
+
return result
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Database layer for coding-client.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- PostgreSQL connection management (via PgBouncer)
|
|
5
|
+
- Tenant-aware query building
|
|
6
|
+
- Generic database type aliases
|
|
7
|
+
- Custom database exceptions
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from . import db_types, exceptions, postgres_connection, tenant_query_helper
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
DatabaseConnectionError,
|
|
13
|
+
DatabaseError,
|
|
14
|
+
DatabaseIntegrityError,
|
|
15
|
+
DatabaseLockError,
|
|
16
|
+
DatabaseMigrationError,
|
|
17
|
+
DatabaseOperationalError,
|
|
18
|
+
DatabaseQueryError,
|
|
19
|
+
DatabaseSchemaError,
|
|
20
|
+
DatabaseTransactionError,
|
|
21
|
+
)
|
|
22
|
+
from .tenant_query_helper import TenantQueryBuilder
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
# Submodules
|
|
26
|
+
"db_types",
|
|
27
|
+
"exceptions",
|
|
28
|
+
"postgres_connection",
|
|
29
|
+
"tenant_query_helper",
|
|
30
|
+
# Key classes
|
|
31
|
+
"TenantQueryBuilder",
|
|
32
|
+
# Exceptions
|
|
33
|
+
"DatabaseError",
|
|
34
|
+
"DatabaseConnectionError",
|
|
35
|
+
"DatabaseIntegrityError",
|
|
36
|
+
"DatabaseLockError",
|
|
37
|
+
"DatabaseMigrationError",
|
|
38
|
+
"DatabaseOperationalError",
|
|
39
|
+
"DatabaseQueryError",
|
|
40
|
+
"DatabaseSchemaError",
|
|
41
|
+
"DatabaseTransactionError",
|
|
42
|
+
]
|