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.
Files changed (109) hide show
  1. coding_client/__init__.py +1 -0
  2. coding_client/api/__init__.py +19 -0
  3. coding_client/api/exception_handlers.py +158 -0
  4. coding_client/api/logging_setup.py +44 -0
  5. coding_client/api/metrics.py +40 -0
  6. coding_client/api/openapi_config.py +124 -0
  7. coding_client/api/request_logging.py +60 -0
  8. coding_client/api/validation.py +150 -0
  9. coding_client/auth/__init__.py +78 -0
  10. coding_client/auth/_default_tenant.py +132 -0
  11. coding_client/auth/_header_trust.py +98 -0
  12. coding_client/auth/_onbehalf.py +315 -0
  13. coding_client/auth/_trusted_networks.py +94 -0
  14. coding_client/auth/dependencies.py +61 -0
  15. coding_client/auth/middleware.py +445 -0
  16. coding_client/auth/models.py +83 -0
  17. coding_client/auth/outbound.py +137 -0
  18. coding_client/auth/rbac.py +21 -0
  19. coding_client/auth/rbac_coding.py +5 -0
  20. coding_client/auth/tenant.py +650 -0
  21. coding_client/auth/websocket.py +148 -0
  22. coding_client/clients/__init__.py +24 -0
  23. coding_client/clients/base.py +131 -0
  24. coding_client/clients/coding_ai.py +33 -0
  25. coding_client/clients/coding_analysis.py +36 -0
  26. coding_client/clients/coding_containers.py +45 -0
  27. coding_client/clients/coding_github.py +127 -0
  28. coding_client/clients/coding_realtime.py +127 -0
  29. coding_client/clients/coding_security.py +51 -0
  30. coding_client/clients/coding_testing.py +31 -0
  31. coding_client/clients/coding_workspaces.py +265 -0
  32. coding_client/config/__init__.py +12 -0
  33. coding_client/config/base_settings.py +61 -0
  34. coding_client/config/yaml_config_loader.py +172 -0
  35. coding_client/db/__init__.py +42 -0
  36. coding_client/db/db_types.py +122 -0
  37. coding_client/db/exceptions.py +59 -0
  38. coding_client/db/postgres_connection.py +572 -0
  39. coding_client/db/tenant_query_helper.py +417 -0
  40. coding_client/db_init.py +49 -0
  41. coding_client/exceptions/__init__.py +67 -0
  42. coding_client/exceptions/api_exceptions.py +395 -0
  43. coding_client/exceptions/pydantic_ai_exceptions.py +533 -0
  44. coding_client/interfaces/__init__.py +22 -0
  45. coding_client/interfaces/cache_interface.py +179 -0
  46. coding_client/interfaces/container_interface.py +49 -0
  47. coding_client/interfaces/docker_interface.py +283 -0
  48. coding_client/interfaces/graph_interface.py +28 -0
  49. coding_client/interfaces/security_interface.py +72 -0
  50. coding_client/interfaces/webhook_interface.py +67 -0
  51. coding_client/llm/__init__.py +54 -0
  52. coding_client/llm/base_agent.py +195 -0
  53. coding_client/llm/context.py +121 -0
  54. coding_client/llm/responses.py +102 -0
  55. coding_client/llm/routing.py +82 -0
  56. coding_client/llm/settings.py +102 -0
  57. coding_client/models/__init__.py +62 -0
  58. coding_client/models/agent.py +38 -0
  59. coding_client/models/ai_test_failure.py +745 -0
  60. coding_client/models/analysis.py +330 -0
  61. coding_client/models/architecture.py +685 -0
  62. coding_client/models/bug.py +128 -0
  63. coding_client/models/code_graph.py +317 -0
  64. coding_client/models/container.py +241 -0
  65. coding_client/models/core.py +878 -0
  66. coding_client/models/docker_container.py +104 -0
  67. coding_client/models/github.py +293 -0
  68. coding_client/models/logging.py +29 -0
  69. coding_client/models/package_manager.py +257 -0
  70. coding_client/models/pr_validation.py +440 -0
  71. coding_client/models/pydantic_ai.py +395 -0
  72. coding_client/models/repo_scanner.py +249 -0
  73. coding_client/models/responses.py +431 -0
  74. coding_client/models/security.py +185 -0
  75. coding_client/models/static_analysis.py +508 -0
  76. coding_client/models/system.py +48 -0
  77. coding_client/models/test_executor.py +204 -0
  78. coding_client/models/testing.py +673 -0
  79. coding_client/models/testing_config.py +200 -0
  80. coding_client/models/validation.py +127 -0
  81. coding_client/models_init.py +272 -0
  82. coding_client/telemetry.py +195 -0
  83. coding_client/testing.py +180 -0
  84. coding_client/types/__init__.py +130 -0
  85. coding_client/types/constraint_types.py +276 -0
  86. coding_client/types/core_types.py +112 -0
  87. coding_client/types/state_machine.py +443 -0
  88. coding_client/utils/__init__.py +86 -0
  89. coding_client/utils/archive_utils.py +387 -0
  90. coding_client/utils/ast_debug.py +45 -0
  91. coding_client/utils/async_timeout.py +364 -0
  92. coding_client/utils/constants.py +518 -0
  93. coding_client/utils/content_processing.py +326 -0
  94. coding_client/utils/database.py +916 -0
  95. coding_client/utils/encoding_helper.py +222 -0
  96. coding_client/utils/enum_helpers.py +166 -0
  97. coding_client/utils/file_operations.py +177 -0
  98. coding_client/utils/filesystem_ops.py +528 -0
  99. coding_client/utils/http_client.py +562 -0
  100. coding_client/utils/path_utils.py +471 -0
  101. coding_client/utils/profiling.py +421 -0
  102. coding_client/utils/response_builder.py +240 -0
  103. coding_client/utils/retry_helper.py +712 -0
  104. coding_client/utils/security_validation.py +423 -0
  105. coding_client-1.21.2.dist-info/METADATA +234 -0
  106. coding_client-1.21.2.dist-info/RECORD +109 -0
  107. coding_client-1.21.2.dist-info/WHEEL +5 -0
  108. coding_client-1.21.2.dist-info/licenses/LICENSE +201 -0
  109. 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
+ ]