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,148 @@
|
|
|
1
|
+
"""Identity resolution for WebSocket handshakes.
|
|
2
|
+
|
|
3
|
+
``AuthMiddleware`` is a ``BaseHTTPMiddleware``, and Starlette never invokes
|
|
4
|
+
``dispatch`` on a ``websocket`` scope. So **no flip and no middleware can close a
|
|
5
|
+
WebSocket route** — every WS handler has to resolve identity itself, explicitly.
|
|
6
|
+
That is why this module exists and why it is a plain function rather than
|
|
7
|
+
middleware: middleware is precisely the thing that does not run here.
|
|
8
|
+
|
|
9
|
+
The consequence is sharp. Once the HTTP paths stopped trusting a bare
|
|
10
|
+
``X-Tenant-Id`` (``TRUST_TENANT_HEADER=false``), the WS handlers became the only
|
|
11
|
+
remaining place in the fleet where a pod could assert any tenant and be believed.
|
|
12
|
+
|
|
13
|
+
The rule mirrors the HTTP one exactly
|
|
14
|
+
-------------------------------------
|
|
15
|
+
Same three-way decision as :func:`.._onbehalf.resolve_tenant_assertion`, for the
|
|
16
|
+
same reasons:
|
|
17
|
+
|
|
18
|
+
* no asserted header — the tenant is the one the caller's own licence names;
|
|
19
|
+
* asserted and **equal** — a no-op (this is what a proxy re-injecting the tenant
|
|
20
|
+
it resolved from that same token looks like);
|
|
21
|
+
* asserted and **differing** — honoured only when the licence's ``subject_id`` is
|
|
22
|
+
allowlisted, otherwise refused.
|
|
23
|
+
|
|
24
|
+
An uncredentialed handshake is now closed (1.15.0)
|
|
25
|
+
--------------------------------------------------
|
|
26
|
+
A browser cannot set an ``Authorization`` header on a WebSocket handshake — the
|
|
27
|
+
WebSocket API has no way to pass one. Browser-originated sockets therefore reach
|
|
28
|
+
a backend through a server-side proxy, and it is the PROXY that must present a
|
|
29
|
+
credential. Until every proxy had been converted, requiring one unconditionally
|
|
30
|
+
would not have hardened the browser path, it would simply have broken it. So
|
|
31
|
+
until 1.15.0 ``Outcome.NO_CREDENTIAL`` did **not** set
|
|
32
|
+
:attr:`WebSocketIdentity.should_close`: the handler fell back to a raw
|
|
33
|
+
``X-Tenant-Id`` read and logged a WARNING naming the peer.
|
|
34
|
+
|
|
35
|
+
That fallback was never a safety property, it was an *instrument* — the WARNING
|
|
36
|
+
existed to find the callers still to convert, and it has now reported. Over the
|
|
37
|
+
full log retention on both consumers (~10.5h on coding-realtime, ~18h on
|
|
38
|
+
coding-analysis) the WARNING fired 5 times on coding-realtime, every one of them
|
|
39
|
+
from ``peer=127.0.0.1`` — a verification probe — and 0 times on coding-analysis.
|
|
40
|
+
No real caller has ever used it. Meanwhile both legitimate callers now present a
|
|
41
|
+
credential: coding-gateway attaches its own licence on WS upgrades, and ``fr``
|
|
42
|
+
presents ``svc-fr-backend``'s licence on both of its WS proxies (driven live:
|
|
43
|
+
101 with auth, 403 without).
|
|
44
|
+
|
|
45
|
+
With the instrument's reading in, the residual is closed. A handshake carrying no
|
|
46
|
+
verifiable credential cannot name a tenant, so serving it is an authorisation
|
|
47
|
+
failure exactly as a refusal is, and ``should_close`` is True for both. The
|
|
48
|
+
``Outcome`` enum stays three-valued on purpose: REFUSED ("a credential, but not
|
|
49
|
+
for that tenant") and NO_CREDENTIAL ("no credential at all") are different
|
|
50
|
+
operational facts and want different log lines. Only the *decision* is now shared.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import logging
|
|
56
|
+
from dataclasses import dataclass
|
|
57
|
+
from enum import Enum, auto
|
|
58
|
+
|
|
59
|
+
from ._onbehalf import forwarded_license_id, resolve_tenant_assertion
|
|
60
|
+
from .middleware import _user_from_license
|
|
61
|
+
|
|
62
|
+
logger = logging.getLogger(__name__)
|
|
63
|
+
|
|
64
|
+
__all__ = ["Outcome", "WebSocketIdentity", "resolve_websocket_identity"]
|
|
65
|
+
|
|
66
|
+
#: WS close code for a policy violation. 1008 is the protocol's own
|
|
67
|
+
#: "policy violation", which is what an authorisation refusal is.
|
|
68
|
+
POLICY_VIOLATION = 1008
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Outcome(Enum):
|
|
72
|
+
"""What the handshake proved, and therefore what the handler may do."""
|
|
73
|
+
|
|
74
|
+
#: A licence was verified. ``tenant_id`` is authoritative.
|
|
75
|
+
RESOLVED = auto()
|
|
76
|
+
#: A licence was verified but it may not act for the tenant it asserted.
|
|
77
|
+
#: The handler MUST close; serving this connection is a cross-tenant read.
|
|
78
|
+
REFUSED = auto()
|
|
79
|
+
#: No ``Authorization`` at all, or one that did not introspect to a live
|
|
80
|
+
#: licence. Nothing was proved, so nothing may be served: the handler MUST
|
|
81
|
+
#: close, same as REFUSED. Kept distinct from REFUSED because the two are
|
|
82
|
+
#: different operational facts worth different log lines — see the module
|
|
83
|
+
#: docstring for the measurement that made closing this safe.
|
|
84
|
+
NO_CREDENTIAL = auto()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class WebSocketIdentity:
|
|
89
|
+
"""The result of resolving a handshake. Immutable so a handler cannot
|
|
90
|
+
accidentally widen it after the decision has been made."""
|
|
91
|
+
|
|
92
|
+
outcome: Outcome
|
|
93
|
+
tenant_id: str | None = None
|
|
94
|
+
subject_id: str | None = None
|
|
95
|
+
license_id: str | None = None
|
|
96
|
+
#: Human-readable, safe to put in a WS close reason. Never contains the
|
|
97
|
+
#: asserted tenant — echoing it back confirms the guess to an attacker.
|
|
98
|
+
reason: str | None = None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def should_close(self) -> bool:
|
|
102
|
+
"""True when serving this connection would be an authorisation failure.
|
|
103
|
+
|
|
104
|
+
Both non-RESOLVED outcomes qualify, and the test is written as "not
|
|
105
|
+
RESOLVED" rather than a list of the two so that a fourth outcome added
|
|
106
|
+
later fails CLOSED by default: a new member is a new way of *not*
|
|
107
|
+
proving an identity, and enumerating the refusing side would silently
|
|
108
|
+
admit it.
|
|
109
|
+
"""
|
|
110
|
+
return self.outcome is not Outcome.RESOLVED
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def resolve_websocket_identity(websocket) -> WebSocketIdentity:
|
|
114
|
+
"""Resolve who is on the other end of *websocket*.
|
|
115
|
+
|
|
116
|
+
Takes the Starlette ``WebSocket`` rather than a ``Request``: both expose
|
|
117
|
+
``.headers`` and ``.url``, which is all the shared decision function reads, so
|
|
118
|
+
the HTTP and WS paths run the *same* code and cannot drift.
|
|
119
|
+
|
|
120
|
+
Never raises. A handshake is either resolved, refused, or credential-less.
|
|
121
|
+
"""
|
|
122
|
+
user = await _user_from_license(websocket)
|
|
123
|
+
|
|
124
|
+
if user is None:
|
|
125
|
+
return WebSocketIdentity(
|
|
126
|
+
outcome=Outcome.NO_CREDENTIAL,
|
|
127
|
+
reason="no verifiable credential on the handshake",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
effective_tenant, refused = resolve_tenant_assertion(
|
|
131
|
+
websocket,
|
|
132
|
+
subject_id=user.sub,
|
|
133
|
+
token_tenant=user.tenant_id or "",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if refused:
|
|
137
|
+
return WebSocketIdentity(
|
|
138
|
+
outcome=Outcome.REFUSED,
|
|
139
|
+
subject_id=user.sub,
|
|
140
|
+
reason="this credential may not act for the tenant it asserted",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return WebSocketIdentity(
|
|
144
|
+
outcome=Outcome.RESOLVED,
|
|
145
|
+
tenant_id=effective_tenant,
|
|
146
|
+
subject_id=user.sub,
|
|
147
|
+
license_id=forwarded_license_id(websocket),
|
|
148
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""HTTP service clients for inter-service communication."""
|
|
2
|
+
|
|
3
|
+
from .base import ServiceClient, ServiceError
|
|
4
|
+
from .coding_ai import CodingAIClient
|
|
5
|
+
from .coding_analysis import CodingAnalysisClient
|
|
6
|
+
from .coding_containers import CodingContainersClient
|
|
7
|
+
from .coding_github import CodingGitHubClient
|
|
8
|
+
from .coding_realtime import CodingRealtimeClient
|
|
9
|
+
from .coding_security import CodingSecurityClient
|
|
10
|
+
from .coding_testing import CodingTestingClient
|
|
11
|
+
from .coding_workspaces import CodingWorkspacesClient
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ServiceClient",
|
|
15
|
+
"ServiceError",
|
|
16
|
+
"CodingAIClient",
|
|
17
|
+
"CodingAnalysisClient",
|
|
18
|
+
"CodingContainersClient",
|
|
19
|
+
"CodingGitHubClient",
|
|
20
|
+
"CodingRealtimeClient",
|
|
21
|
+
"CodingSecurityClient",
|
|
22
|
+
"CodingTestingClient",
|
|
23
|
+
"CodingWorkspacesClient",
|
|
24
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Base async HTTP client with retry, error handling, and structured logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import structlog
|
|
9
|
+
from tenacity import (
|
|
10
|
+
AsyncRetrying,
|
|
11
|
+
retry_if_exception_type,
|
|
12
|
+
stop_after_attempt,
|
|
13
|
+
wait_exponential,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
logger = structlog.get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ServiceError(Exception):
|
|
20
|
+
"""Raised when a downstream service returns a non-2xx response."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, status_code: int, message: str, body: Any = None):
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
self.body = body
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ServiceClient:
|
|
29
|
+
"""Base async HTTP client for inter-service communication."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
base_url: str,
|
|
34
|
+
timeout: float = 30.0,
|
|
35
|
+
max_retries: int = 3,
|
|
36
|
+
):
|
|
37
|
+
self.base_url = base_url.rstrip("/")
|
|
38
|
+
self.timeout = timeout
|
|
39
|
+
self.max_retries = max_retries
|
|
40
|
+
self._client: httpx.AsyncClient | None = None
|
|
41
|
+
|
|
42
|
+
async def _ensure_client(self) -> httpx.AsyncClient:
|
|
43
|
+
if self._client is None or self._client.is_closed:
|
|
44
|
+
self._client = httpx.AsyncClient(
|
|
45
|
+
base_url=self.base_url,
|
|
46
|
+
timeout=httpx.Timeout(self.timeout),
|
|
47
|
+
)
|
|
48
|
+
return self._client
|
|
49
|
+
|
|
50
|
+
async def _request(
|
|
51
|
+
self,
|
|
52
|
+
method: str,
|
|
53
|
+
path: str,
|
|
54
|
+
headers: dict[str, str] | None = None,
|
|
55
|
+
*,
|
|
56
|
+
max_attempts: int | None = None,
|
|
57
|
+
**kwargs: Any,
|
|
58
|
+
) -> Any:
|
|
59
|
+
"""Send one request, retrying transport errors up to ``max_retries`` attempts.
|
|
60
|
+
|
|
61
|
+
``max_attempts`` overrides ``max_retries`` for this call only. Pass ``1``
|
|
62
|
+
for a non-idempotent POST (e.g. a job submit), where a retry after a
|
|
63
|
+
read timeout would start the work a second time.
|
|
64
|
+
"""
|
|
65
|
+
client = await self._ensure_client()
|
|
66
|
+
attempts = self.max_retries if max_attempts is None else max_attempts
|
|
67
|
+
log = logger.bind(method=method, path=path, base_url=self.base_url)
|
|
68
|
+
|
|
69
|
+
async for attempt in AsyncRetrying(
|
|
70
|
+
stop=stop_after_attempt(attempts),
|
|
71
|
+
wait=wait_exponential(multiplier=1, min=0.5, max=10),
|
|
72
|
+
retry=retry_if_exception_type(
|
|
73
|
+
(httpx.ConnectError, httpx.ReadTimeout, httpx.PoolTimeout)
|
|
74
|
+
),
|
|
75
|
+
reraise=True,
|
|
76
|
+
):
|
|
77
|
+
with attempt:
|
|
78
|
+
log.debug("http_request", attempt=attempt.retry_state.attempt_number)
|
|
79
|
+
response = await client.request(method, path, headers=headers, **kwargs)
|
|
80
|
+
|
|
81
|
+
if response.status_code >= 400:
|
|
82
|
+
body = None
|
|
83
|
+
try:
|
|
84
|
+
body = response.json()
|
|
85
|
+
except Exception:
|
|
86
|
+
body = response.text
|
|
87
|
+
msg = f"{method} {self.base_url}{path} returned {response.status_code}"
|
|
88
|
+
log.error("service_error", status_code=response.status_code, body=body)
|
|
89
|
+
raise ServiceError(response.status_code, msg, body=body)
|
|
90
|
+
|
|
91
|
+
if response.status_code == 204 or not response.content:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
return response.json()
|
|
96
|
+
except Exception:
|
|
97
|
+
return response.text
|
|
98
|
+
|
|
99
|
+
async def get(self, path: str, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
100
|
+
return await self._request("GET", path, headers=headers, **kwargs)
|
|
101
|
+
|
|
102
|
+
async def post(self, path: str, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
103
|
+
return await self._request("POST", path, headers=headers, **kwargs)
|
|
104
|
+
|
|
105
|
+
async def put(self, path: str, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
106
|
+
return await self._request("PUT", path, headers=headers, **kwargs)
|
|
107
|
+
|
|
108
|
+
async def delete(self, path: str, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
109
|
+
return await self._request("DELETE", path, headers=headers, **kwargs)
|
|
110
|
+
|
|
111
|
+
async def patch(self, path: str, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
112
|
+
return await self._request("PATCH", path, headers=headers, **kwargs)
|
|
113
|
+
|
|
114
|
+
async def health_check(self) -> bool:
|
|
115
|
+
try:
|
|
116
|
+
await self.get("/health")
|
|
117
|
+
return True
|
|
118
|
+
except Exception:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
async def close(self) -> None:
|
|
122
|
+
if self._client and not self._client.is_closed:
|
|
123
|
+
await self._client.aclose()
|
|
124
|
+
self._client = None
|
|
125
|
+
|
|
126
|
+
async def __aenter__(self) -> ServiceClient:
|
|
127
|
+
await self._ensure_client()
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
131
|
+
await self.close()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""HTTP client for the coding-ai 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_AI_URL"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CodingAIClient(ServiceClient):
|
|
14
|
+
"""Client for coding-ai endpoints."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
base_url: str | None = None,
|
|
19
|
+
timeout: float = 60.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"CodingAIClient 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 analyze_failure(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
31
|
+
return await self.post(
|
|
32
|
+
"/api/ai/analysis/tests/failure-analysis", headers=headers, json=kwargs
|
|
33
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""HTTP client for the coding-analysis 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_ANALYSIS_URL"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CodingAnalysisClient(ServiceClient):
|
|
14
|
+
"""Client for coding-analysis 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"CodingAnalysisClient 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 analyze_code(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
31
|
+
return await self.post("/api/analysis/analyze", headers=headers, json=kwargs)
|
|
32
|
+
|
|
33
|
+
async def get_analysis(
|
|
34
|
+
self, analysis_id: str, headers: dict[str, str] | None = None, **kwargs: Any
|
|
35
|
+
) -> Any:
|
|
36
|
+
return await self.get(f"/api/analysis/status/{analysis_id}", headers=headers, **kwargs)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Legacy container-named client for the coding-containers 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_CONTAINERS_URL"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CodingContainersClient(ServiceClient):
|
|
14
|
+
"""Compatibility client for the legacy ``/api/containers`` aliases.
|
|
15
|
+
|
|
16
|
+
New callers should use :class:`CodingWorkspacesClient`. This class remains
|
|
17
|
+
available so existing consumers can migrate without a flag day.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
base_url: str | None = None,
|
|
23
|
+
timeout: float = 30.0,
|
|
24
|
+
max_retries: int = 3,
|
|
25
|
+
):
|
|
26
|
+
url = base_url or os.environ.get(_ENV_VAR, "")
|
|
27
|
+
if not url:
|
|
28
|
+
raise RuntimeError(
|
|
29
|
+
f"CodingContainersClient requires base_url or {_ENV_VAR} env var. "
|
|
30
|
+
"See the platform configuration policy."
|
|
31
|
+
)
|
|
32
|
+
super().__init__(url, timeout=timeout, max_retries=max_retries)
|
|
33
|
+
|
|
34
|
+
async def create_container(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
35
|
+
return await self.post("/api/containers", headers=headers, json=kwargs)
|
|
36
|
+
|
|
37
|
+
async def get_container(
|
|
38
|
+
self, container_name: str, headers: dict[str, str] | None = None, **kwargs: Any
|
|
39
|
+
) -> Any:
|
|
40
|
+
return await self.get(f"/api/containers/{container_name}", headers=headers, **kwargs)
|
|
41
|
+
|
|
42
|
+
async def delete_container(
|
|
43
|
+
self, container_name: str, headers: dict[str, str] | None = None, **kwargs: Any
|
|
44
|
+
) -> Any:
|
|
45
|
+
return await self.delete(f"/api/containers/{container_name}", headers=headers, **kwargs)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""HTTP client for the coding-github 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_GITHUB_URL"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CodingGitHubClient(ServiceClient):
|
|
14
|
+
"""Client for coding-github 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"CodingGitHubClient 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
|
+
# -- Repository management ------------------------------------------------
|
|
31
|
+
|
|
32
|
+
async def list_repositories(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
33
|
+
"""List all provisioned repositories."""
|
|
34
|
+
return await self.get("/api/repositories/repositories", headers=headers, params=kwargs)
|
|
35
|
+
|
|
36
|
+
async def import_repository(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
37
|
+
"""Import a GitHub repository as a canonical workspace."""
|
|
38
|
+
return await self.post("/api/repositories/import", headers=headers, json=kwargs)
|
|
39
|
+
|
|
40
|
+
async def provision(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
41
|
+
"""Compatibility alias for :meth:`import_repository`."""
|
|
42
|
+
return await self.import_repository(headers=headers, **kwargs)
|
|
43
|
+
|
|
44
|
+
async def get_status(
|
|
45
|
+
self, container_name: str, headers: dict[str, str] | None = None, **kwargs: Any
|
|
46
|
+
) -> Any:
|
|
47
|
+
"""Get provisioning status for a specific container."""
|
|
48
|
+
return await self.get(
|
|
49
|
+
f"/api/repositories/status/{container_name}", headers=headers, params=kwargs
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async def create_upstream_pr(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
53
|
+
"""Create a pull request from the system fork to the user's original repo."""
|
|
54
|
+
return await self.post("/api/repositories/create-upstream-pr", headers=headers, json=kwargs)
|
|
55
|
+
|
|
56
|
+
# -- Pull request operations ----------------------------------------------
|
|
57
|
+
|
|
58
|
+
async def fork(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
59
|
+
"""Fork a repository."""
|
|
60
|
+
return await self.post("/api/repositories/fork", headers=headers, json=kwargs)
|
|
61
|
+
|
|
62
|
+
async def apply_changes(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
63
|
+
"""Apply code changes to a forked repository."""
|
|
64
|
+
return await self.post("/api/repositories/apply-changes", headers=headers, json=kwargs)
|
|
65
|
+
|
|
66
|
+
async def create_pull_request(
|
|
67
|
+
self, headers: dict[str, str] | None = None, **kwargs: Any
|
|
68
|
+
) -> Any:
|
|
69
|
+
"""Create a pull request."""
|
|
70
|
+
return await self.post(
|
|
71
|
+
"/api/repositories/create-pull-request", headers=headers, json=kwargs
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def validate_pull_request(
|
|
75
|
+
self, headers: dict[str, str] | None = None, **kwargs: Any
|
|
76
|
+
) -> Any:
|
|
77
|
+
"""Validate a pull request."""
|
|
78
|
+
return await self.post(
|
|
79
|
+
"/api/repositories/validate-pull-request", headers=headers, json=kwargs
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# -- File / tree operations -----------------------------------------------
|
|
83
|
+
|
|
84
|
+
async def get_file(
|
|
85
|
+
self, container_name: str, headers: dict[str, str] | None = None, **kwargs: Any
|
|
86
|
+
) -> Any:
|
|
87
|
+
"""Read a single file from a container repository."""
|
|
88
|
+
return await self.get(
|
|
89
|
+
f"/api/repositories/{container_name}/file", headers=headers, params=kwargs
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def get_tree(
|
|
93
|
+
self, container_name: str, headers: dict[str, str] | None = None, **kwargs: Any
|
|
94
|
+
) -> Any:
|
|
95
|
+
"""Get the file tree for a container repository."""
|
|
96
|
+
return await self.get(
|
|
97
|
+
f"/api/repositories/{container_name}/tree", headers=headers, params=kwargs
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
async def get_directory(
|
|
101
|
+
self, container_name: str, headers: dict[str, str] | None = None, **kwargs: Any
|
|
102
|
+
) -> Any:
|
|
103
|
+
"""List directory contents in a container repository."""
|
|
104
|
+
return await self.get(
|
|
105
|
+
f"/api/repositories/{container_name}/directory", headers=headers, params=kwargs
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def search(
|
|
109
|
+
self, container_name: str, headers: dict[str, str] | None = None, **kwargs: Any
|
|
110
|
+
) -> Any:
|
|
111
|
+
"""Search files in a container repository."""
|
|
112
|
+
return await self.post(
|
|
113
|
+
f"/api/repositories/{container_name}/search", headers=headers, json=kwargs
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# -- Webhooks -------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
async def create_webhook(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
119
|
+
"""Register a GitHub webhook."""
|
|
120
|
+
return await self.post("/api/repositories/webhooks/webhook", headers=headers, json=kwargs)
|
|
121
|
+
|
|
122
|
+
# -- Backward-compatible aliases ------------------------------------------
|
|
123
|
+
# These keep the old method names working with corrected paths.
|
|
124
|
+
|
|
125
|
+
async def get_repo(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
126
|
+
"""Alias for list_repositories (backward compatibility)."""
|
|
127
|
+
return await self.list_repositories(headers=headers, **kwargs)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""HTTP client for the coding-realtime 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_REALTIME_URL"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CodingRealtimeClient(ServiceClient):
|
|
14
|
+
"""Client for coding-realtime endpoints."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
base_url: str | None = None,
|
|
19
|
+
timeout: float = 10.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"CodingRealtimeClient 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
|
+
# -- Broadcast / messaging ------------------------------------------------
|
|
31
|
+
|
|
32
|
+
async def broadcast(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
event_type: str,
|
|
36
|
+
data: dict[str, Any],
|
|
37
|
+
priority: str = "normal",
|
|
38
|
+
container_name: str | None = None,
|
|
39
|
+
tags: list[str] | None = None,
|
|
40
|
+
correlation_id: str | None = None,
|
|
41
|
+
ttl_seconds: int | None = None,
|
|
42
|
+
headers: dict[str, str] | None = None,
|
|
43
|
+
) -> Any:
|
|
44
|
+
"""Publish a broadcast to all subscribers of a container.
|
|
45
|
+
|
|
46
|
+
POSTs to ``/api/broadcast/publish`` — the REST publish endpoint (D9).
|
|
47
|
+
(The old helper posted to ``/api/broadcast/subscribe``, a WebSocket-only
|
|
48
|
+
route, so every broadcast 404'd and was silently dropped.)
|
|
49
|
+
|
|
50
|
+
``container_name`` may be omitted when ``data`` carries it as
|
|
51
|
+
``data['container_name']`` (the shape coding-containers' lifecycle events
|
|
52
|
+
already use). Tenant-scoped: pass the owning tenant as
|
|
53
|
+
``headers={'X-Tenant-Id': ...}`` so the publish resolves to the container's
|
|
54
|
+
owner (BOLA — the server rejects a container the tenant does not own).
|
|
55
|
+
|
|
56
|
+
Returns the publish result, including ``delivered`` and
|
|
57
|
+
``subscriber_count`` so the caller can distinguish a real fan-out from a
|
|
58
|
+
no-op (no subscribers).
|
|
59
|
+
"""
|
|
60
|
+
payload: dict[str, Any] = {
|
|
61
|
+
"event_type": event_type,
|
|
62
|
+
"data": data,
|
|
63
|
+
"priority": priority,
|
|
64
|
+
}
|
|
65
|
+
if container_name is not None:
|
|
66
|
+
payload["container_name"] = container_name
|
|
67
|
+
if tags is not None:
|
|
68
|
+
payload["tags"] = tags
|
|
69
|
+
if correlation_id is not None:
|
|
70
|
+
payload["correlation_id"] = correlation_id
|
|
71
|
+
if ttl_seconds is not None:
|
|
72
|
+
payload["ttl_seconds"] = ttl_seconds
|
|
73
|
+
return await self.post("/api/broadcast/publish", headers=headers, json=payload)
|
|
74
|
+
|
|
75
|
+
# -- Stats / monitoring ---------------------------------------------------
|
|
76
|
+
|
|
77
|
+
async def get_global_stats(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
78
|
+
"""Get global rate-limiter statistics across all containers."""
|
|
79
|
+
return await self.get("/api/global-stats", headers=headers, params=kwargs)
|
|
80
|
+
|
|
81
|
+
async def get_stats(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
82
|
+
"""Get rate-limit stats for all containers."""
|
|
83
|
+
return await self.get("/api/stats", headers=headers, params=kwargs)
|
|
84
|
+
|
|
85
|
+
async def get_container_stats(
|
|
86
|
+
self, container_name: str, headers: dict[str, str] | None = None, **kwargs: Any
|
|
87
|
+
) -> Any:
|
|
88
|
+
"""Get rate-limit stats for a specific container."""
|
|
89
|
+
return await self.get(f"/api/stats/{container_name}", headers=headers, params=kwargs)
|
|
90
|
+
|
|
91
|
+
async def get_violations(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
92
|
+
"""Get rate-limit violations."""
|
|
93
|
+
return await self.get("/api/violations", headers=headers, params=kwargs)
|
|
94
|
+
|
|
95
|
+
# -- Connection / health --------------------------------------------------
|
|
96
|
+
|
|
97
|
+
async def get_connections(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
98
|
+
"""Get active realtime analysis connections."""
|
|
99
|
+
return await self.get("/api/realtime/connections", headers=headers, params=kwargs)
|
|
100
|
+
|
|
101
|
+
async def get_global_health(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
102
|
+
"""Get global WebSocket health statistics."""
|
|
103
|
+
return await self.get("/api/ws-health/global", headers=headers, params=kwargs)
|
|
104
|
+
|
|
105
|
+
async def get_container_health(
|
|
106
|
+
self, container_name: str, headers: dict[str, str] | None = None, **kwargs: Any
|
|
107
|
+
) -> Any:
|
|
108
|
+
"""Get WebSocket health statistics for a specific container."""
|
|
109
|
+
return await self.get(
|
|
110
|
+
f"/api/ws-health/container/{container_name}", headers=headers, params=kwargs
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def get_containers_health(
|
|
114
|
+
self, headers: dict[str, str] | None = None, **kwargs: Any
|
|
115
|
+
) -> Any:
|
|
116
|
+
"""Get health statistics for all containers."""
|
|
117
|
+
return await self.get("/api/ws-health/containers", headers=headers, params=kwargs)
|
|
118
|
+
|
|
119
|
+
async def cleanup_stale(self, headers: dict[str, str] | None = None, **kwargs: Any) -> Any:
|
|
120
|
+
"""Trigger cleanup of stale WebSocket connections."""
|
|
121
|
+
return await self.post("/api/ws-health/cleanup", headers=headers, json=kwargs)
|
|
122
|
+
|
|
123
|
+
# -- Health ---------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
async def health(self, **kwargs: Any) -> Any:
|
|
126
|
+
"""Service-level health check."""
|
|
127
|
+
return await self.get("/api/health/live", **kwargs)
|