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,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)