Skip to content

Commit 1c6be8b

Browse files
committed
refactor(python): move admin HTTP transport (PY-SRP-1)
1 parent 6173cfc commit 1c6be8b

3 files changed

Lines changed: 180 additions & 151 deletions

File tree

streamline_sdk/_admin_http.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Private HTTP transport used by :class:`streamline_sdk.admin.Admin`.
2+
3+
This module owns the REST transport concerns for admin operations: base URL
4+
handling, the optional ``aiohttp``/``urllib`` transport selection, request
5+
construction (GET/POST/DELETE), the fixed 10-second timeout, status/error
6+
mapping, and JSON decoding. It is intentionally private (leading underscore)
7+
because it is an implementation detail of :mod:`streamline_sdk.admin`, not a
8+
public API surface.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import asyncio
14+
import json
15+
from typing import Any
16+
17+
from .exceptions import TopicError
18+
19+
try:
20+
import aiohttp
21+
HAS_AIOHTTP = True
22+
except ImportError:
23+
HAS_AIOHTTP = False
24+
25+
26+
class _AdminHttpTransport:
27+
"""Thin REST transport for the Streamline admin HTTP API.
28+
29+
Uses ``aiohttp`` when available, falling back to a synchronous
30+
``urllib.request`` call executed on a worker thread otherwise.
31+
"""
32+
33+
def __init__(self, base_url: str) -> None:
34+
"""Initialize the transport.
35+
36+
Args:
37+
base_url: Base URL of the Streamline HTTP REST API.
38+
"""
39+
self._base_url = base_url
40+
41+
async def get(self, path: str) -> Any:
42+
"""Make an HTTP GET request to the Streamline REST API."""
43+
url = f"{self._base_url}{path}"
44+
45+
if HAS_AIOHTTP:
46+
async with aiohttp.ClientSession() as session:
47+
async with session.get(
48+
url, timeout=aiohttp.ClientTimeout(total=10)
49+
) as resp:
50+
if resp.status == 404:
51+
raise TopicError(f"Not found: {path}")
52+
if resp.status != 200:
53+
text = await resp.text()
54+
raise TopicError(f"HTTP {resp.status}: {text}")
55+
return await resp.json()
56+
else:
57+
import urllib.request
58+
req = urllib.request.Request(url)
59+
def _sync_get():
60+
with urllib.request.urlopen(req, timeout=10) as resp:
61+
return json.loads(resp.read())
62+
return await asyncio.to_thread(_sync_get)
63+
64+
async def post(self, path: str, body: Any) -> Any:
65+
"""Make an HTTP POST request to the Streamline REST API."""
66+
url = f"{self._base_url}{path}"
67+
68+
if HAS_AIOHTTP:
69+
async with aiohttp.ClientSession() as session:
70+
async with session.post(
71+
url, json=body, timeout=aiohttp.ClientTimeout(total=10)
72+
) as resp:
73+
if resp.status == 404:
74+
raise TopicError(f"Not found: {path}")
75+
if resp.status not in (200, 201):
76+
text = await resp.text()
77+
raise TopicError(f"HTTP {resp.status}: {text}")
78+
return await resp.json()
79+
else:
80+
import urllib.request
81+
payload = json.dumps(body).encode("utf-8")
82+
req = urllib.request.Request(url, data=payload, method="POST")
83+
req.add_header("Content-Type", "application/json")
84+
def _sync_post():
85+
with urllib.request.urlopen(req, timeout=10) as resp:
86+
return json.loads(resp.read())
87+
return await asyncio.to_thread(_sync_post)
88+
89+
async def delete(self, path: str) -> None:
90+
"""Make an HTTP DELETE request to the Streamline REST API."""
91+
url = f"{self._base_url}{path}"
92+
93+
if HAS_AIOHTTP:
94+
async with aiohttp.ClientSession() as session:
95+
async with session.delete(
96+
url, timeout=aiohttp.ClientTimeout(total=10)
97+
) as resp:
98+
if resp.status == 404:
99+
raise TopicError(f"Not found: {path}")
100+
if resp.status >= 300:
101+
text = await resp.text()
102+
raise TopicError(f"HTTP {resp.status}: {text}")
103+
else:
104+
import urllib.request
105+
req = urllib.request.Request(url, method="DELETE")
106+
def _sync_delete():
107+
with urllib.request.urlopen(req, timeout=10):
108+
pass
109+
await asyncio.to_thread(_sync_delete)

streamline_sdk/admin.py

Lines changed: 13 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,16 @@
22

33
from __future__ import annotations
44

5-
import asyncio
6-
import json
75
from dataclasses import dataclass, field
86
from typing import Any
97

108
from aiokafka.admin import AIOKafkaAdminClient, NewTopic
119
from aiokafka.errors import KafkaError
1210

11+
from ._admin_http import _AdminHttpTransport
1312
from .exceptions import TopicError
1413
from .validation import validate_topic_name
1514

16-
try:
17-
import aiohttp
18-
HAS_AIOHTTP = True
19-
except ImportError:
20-
HAS_AIOHTTP = False
21-
2215

2316
@dataclass
2417
class TopicConfig:
@@ -243,6 +236,7 @@ def __init__(self, client_config: Any):
243236
self._client_config = client_config
244237
self._admin: AIOKafkaAdminClient | None = None
245238
self._started = False
239+
self._http = _AdminHttpTransport(client_config.http_url)
246240

247241
async def start(self) -> None:
248242
"""Start the admin client."""
@@ -373,7 +367,7 @@ async def list_topics(self) -> list[str]:
373367
raise TopicError("Admin client not started")
374368

375369
try:
376-
data = await self._http_get("/v1/topics")
370+
data = await self._http.get("/v1/topics")
377371
return [t["name"] for t in data if not t.get("name", "").startswith("__")]
378372
except TopicError:
379373
raise
@@ -393,7 +387,7 @@ async def describe_topic(self, name: str) -> TopicInfo:
393387
raise TopicError("Admin client not started")
394388

395389
try:
396-
data = await self._http_get(f"/v1/topics/{name}")
390+
data = await self._http.get(f"/v1/topics/{name}")
397391
return TopicInfo(
398392
name=data.get("name", name),
399393
partitions=data.get("partitions", 0),
@@ -405,79 +399,6 @@ async def describe_topic(self, name: str) -> TopicInfo:
405399
except Exception as e:
406400
raise TopicError(f"Failed to describe topic '{name}': {e}") from e
407401

408-
async def _http_get(self, path: str) -> Any:
409-
"""Make an HTTP GET request to the Streamline REST API."""
410-
http_url = self._client_config.http_url
411-
url = f"{http_url}{path}"
412-
413-
if HAS_AIOHTTP:
414-
async with aiohttp.ClientSession() as session:
415-
async with session.get(
416-
url, timeout=aiohttp.ClientTimeout(total=10)
417-
) as resp:
418-
if resp.status == 404:
419-
raise TopicError(f"Not found: {path}")
420-
if resp.status != 200:
421-
text = await resp.text()
422-
raise TopicError(f"HTTP {resp.status}: {text}")
423-
return await resp.json()
424-
else:
425-
import urllib.request
426-
req = urllib.request.Request(url)
427-
def _sync_get():
428-
with urllib.request.urlopen(req, timeout=10) as resp:
429-
return json.loads(resp.read())
430-
return await asyncio.to_thread(_sync_get)
431-
432-
async def _http_post(self, path: str, body: Any) -> Any:
433-
"""Make an HTTP POST request to the Streamline REST API."""
434-
http_url = self._client_config.http_url
435-
url = f"{http_url}{path}"
436-
437-
if HAS_AIOHTTP:
438-
async with aiohttp.ClientSession() as session:
439-
async with session.post(
440-
url, json=body, timeout=aiohttp.ClientTimeout(total=10)
441-
) as resp:
442-
if resp.status == 404:
443-
raise TopicError(f"Not found: {path}")
444-
if resp.status not in (200, 201):
445-
text = await resp.text()
446-
raise TopicError(f"HTTP {resp.status}: {text}")
447-
return await resp.json()
448-
else:
449-
import urllib.request
450-
payload = json.dumps(body).encode("utf-8")
451-
req = urllib.request.Request(url, data=payload, method="POST")
452-
req.add_header("Content-Type", "application/json")
453-
def _sync_post():
454-
with urllib.request.urlopen(req, timeout=10) as resp:
455-
return json.loads(resp.read())
456-
return await asyncio.to_thread(_sync_post)
457-
458-
async def _http_delete(self, path: str) -> None:
459-
"""Make an HTTP DELETE request to the Streamline REST API."""
460-
http_url = self._client_config.http_url
461-
url = f"{http_url}{path}"
462-
463-
if HAS_AIOHTTP:
464-
async with aiohttp.ClientSession() as session:
465-
async with session.delete(
466-
url, timeout=aiohttp.ClientTimeout(total=10)
467-
) as resp:
468-
if resp.status == 404:
469-
raise TopicError(f"Not found: {path}")
470-
if resp.status >= 300:
471-
text = await resp.text()
472-
raise TopicError(f"HTTP {resp.status}: {text}")
473-
else:
474-
import urllib.request
475-
req = urllib.request.Request(url, method="DELETE")
476-
def _sync_delete():
477-
with urllib.request.urlopen(req, timeout=10):
478-
pass
479-
await asyncio.to_thread(_sync_delete)
480-
481402
async def list_consumer_groups(self) -> list[str]:
482403
"""List all consumer groups.
483404
@@ -540,7 +461,7 @@ async def cluster_info(self) -> ClusterInfo:
540461
Returns:
541462
ClusterInfo with broker details.
542463
"""
543-
data = await self._http_get("/v1/cluster")
464+
data = await self._http.get("/v1/cluster")
544465
brokers = [
545466
BrokerInfo(
546467
id=b.get("id", 0),
@@ -566,7 +487,7 @@ async def consumer_group_lag(self, group_id: str) -> ConsumerGroupLag:
566487
Returns:
567488
ConsumerGroupLag with per-partition lag.
568489
"""
569-
data = await self._http_get(f"/v1/consumer-groups/{group_id}/lag")
490+
data = await self._http.get(f"/v1/consumer-groups/{group_id}/lag")
570491
partitions = [
571492
ConsumerLag(
572493
topic=p.get("topic", ""),
@@ -595,7 +516,7 @@ async def consumer_group_topic_lag(
595516
Returns:
596517
ConsumerGroupLag scoped to the given topic.
597518
"""
598-
data = await self._http_get(f"/v1/consumer-groups/{group_id}/lag/{topic}")
519+
data = await self._http.get(f"/v1/consumer-groups/{group_id}/lag/{topic}")
599520
partitions = [
600521
ConsumerLag(
601522
topic=p.get("topic", topic),
@@ -633,7 +554,7 @@ async def inspect_messages(
633554
path = f"/v1/inspect/{topic}?partition={partition}&limit={limit}"
634555
if offset is not None:
635556
path += f"&offset={offset}"
636-
data = await self._http_get(path)
557+
data = await self._http.get(path)
637558
return [
638559
InspectedMessage(
639560
offset=m.get("offset", 0),
@@ -658,7 +579,7 @@ async def latest_messages(
658579
Returns:
659580
List of latest messages.
660581
"""
661-
data = await self._http_get(f"/v1/inspect/{topic}/latest?count={count}")
582+
data = await self._http.get(f"/v1/inspect/{topic}/latest?count={count}")
662583
return [
663584
InspectedMessage(
664585
offset=m.get("offset", 0),
@@ -677,7 +598,7 @@ async def metrics_history(self) -> list[MetricPoint]:
677598
Returns:
678599
List of metric data points.
679600
"""
680-
data = await self._http_get("/v1/metrics/history")
601+
data = await self._http.get("/v1/metrics/history")
681602
return [
682603
MetricPoint(
683604
name=m.get("name", ""),
@@ -704,7 +625,7 @@ async def create_branch(
704625
body: dict[str, Any] = {"name": name, "base_topic": base_topic}
705626
if base_offsets:
706627
body["base_offsets"] = base_offsets
707-
data = await self._http_post("/v1/branches", body)
628+
data = await self._http.post("/v1/branches", body)
708629
return BranchInfo(
709630
name=data.get("name", name),
710631
base_topic=data.get("base_topic", base_topic),
@@ -724,7 +645,7 @@ async def list_branches(self, topic: str | None = None) -> list[BranchInfo]:
724645
path = "/v1/branches"
725646
if topic:
726647
path += f"?topic={topic}"
727-
data = await self._http_get(path)
648+
data = await self._http.get(path)
728649
items = data if isinstance(data, list) else data.get("items", [])
729650
return [
730651
BranchInfo(
@@ -742,7 +663,7 @@ async def discard_branch(self, branch_id: str) -> None:
742663
Args:
743664
branch_id: Branch identifier.
744665
"""
745-
await self._http_delete(f"/v1/branches/{branch_id}")
666+
await self._http.delete(f"/v1/branches/{branch_id}")
746667

747668
async def __aenter__(self) -> Admin:
748669
"""Enter async context manager."""

0 commit comments

Comments
 (0)