pympacds-http 0.1.0__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.
@@ -0,0 +1,3 @@
1
+ """pympacds-http — HTTP client service for pympacds."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,43 @@
1
+ """HTTP D-Bus contract (REQ-HTTP-004)."""
2
+
3
+ from pympacds.contracts import ServiceContract, dbus_method, dbus_signal
4
+
5
+
6
+ class HttpContract(ServiceContract):
7
+ """HTTP request interface."""
8
+
9
+ iface_name = "HTTP"
10
+ iface_version = "1.0.0"
11
+ iface_provides = ["http"]
12
+ iface_requires = ["network"]
13
+
14
+ def __init__(self, ifname: str, base):
15
+ super().__init__(ifname, base)
16
+ self._require(
17
+ "dbus_http_send",
18
+ "dbus_http_download",
19
+ "dbus_http_request",
20
+ )
21
+
22
+ @dbus_method()
23
+ def send(self, payload: "s") -> "s":
24
+ return self.base.dbus_http_send(payload)
25
+
26
+ @dbus_method()
27
+ def download(self, payload: "s", download_path: "s") -> "s":
28
+ return self.base.dbus_http_download(payload, download_path)
29
+
30
+ @dbus_method()
31
+ def request(
32
+ self,
33
+ method: "s",
34
+ url: "s",
35
+ headers: "s",
36
+ body: "s",
37
+ download_path: "s",
38
+ ) -> "s":
39
+ return self.base.dbus_http_request(method, url, headers, body, download_path)
40
+
41
+ @dbus_signal()
42
+ def request_completed(self, token: "s", result: "s") -> "ss":
43
+ return [token, result]
@@ -0,0 +1,420 @@
1
+ """HTTP client service (REQ-HTTP-001..007)."""
2
+
3
+ import asyncio
4
+ import configparser
5
+ import json
6
+ import os
7
+ import secrets
8
+ import ssl
9
+ import tempfile
10
+ import time
11
+ from typing import Any
12
+
13
+ import aiohttp
14
+ from pympacds.builtin_contracts import ConfigContract, HealthContract
15
+ from pympacds.dbus import DBusManager
16
+ from pympacds.process import ProcessBase
17
+
18
+ from .contracts import HttpContract
19
+
20
+
21
+ class HttpService(ProcessBase):
22
+ """Sends HTTP requests on behalf of other services, exposed over D-Bus.
23
+
24
+ ``send``/``download``/``request`` follow a fire-and-forget pattern: each
25
+ returns a random request token immediately and the actual request runs as
26
+ an asyncio task; on completion the ``request_completed(token, result)``
27
+ signal is emitted (REQ-HTTP-004).
28
+
29
+ Args:
30
+ session_factory: Optional callable ``f(service) -> session`` returning
31
+ an aiohttp-like session, used by tests to inject a fake (no
32
+ network). Defaults to building a real ``aiohttp.ClientSession``.
33
+ """
34
+
35
+ def __init__(self, session_factory=None):
36
+ super().__init__(
37
+ name="http",
38
+ version="0.1.0",
39
+ description="HTTP client service for pympacds",
40
+ )
41
+ self.bus: Any = None # DBusManager, created in start_dbus()
42
+ self._start_time = time.time()
43
+ self._session_factory = session_factory
44
+ self._session: aiohttp.ClientSession | None = None
45
+ self._contract = None
46
+ self._health_contract = None
47
+ self._config_contract = None
48
+ self._tasks: set[asyncio.Task] = set()
49
+
50
+ # -- config helpers -------------------------------------------------
51
+
52
+ def _cfg(self, key: str, default):
53
+ try:
54
+ return self.config["http"].get(key, default)
55
+ except (KeyError, configparser.Error):
56
+ return default
57
+
58
+ def _cfg_int(self, key: str, default: int) -> int:
59
+ try:
60
+ return int(self._cfg(key, default))
61
+ except (TypeError, ValueError):
62
+ return default
63
+
64
+ def _cfg_bool(self, key: str, default: bool) -> bool:
65
+ return str(self._cfg(key, default)).lower() in ("true", "1", "yes", "on")
66
+
67
+ def _dbus_flag(self, key: str, default: bool) -> bool:
68
+ if not self.config.has_section("dbus"):
69
+ return default
70
+ try:
71
+ return self.config["dbus"].getboolean(key, default)
72
+ except ValueError:
73
+ return default
74
+
75
+ # -- synchronous setup ----------------------------------------------
76
+
77
+ def setup(self, inputargs: list[str] | None = None) -> bool:
78
+ """Parse args, load config, and validate the mutual-TLS pair."""
79
+ if not super().setup(inputargs):
80
+ return False
81
+ return self._validate_tls_pair()
82
+
83
+ def _validate_tls_pair(self) -> bool:
84
+ """Require ``tls_certfile``/``tls_keyfile`` together (REQ-HTTP-006)."""
85
+ certfile = self._cfg("tls_certfile", "").strip()
86
+ keyfile = self._cfg("tls_keyfile", "").strip()
87
+ if bool(certfile) != bool(keyfile):
88
+ self.logger.error("http.tls_certfile and http.tls_keyfile must be provided together")
89
+ return False
90
+ return True
91
+
92
+ # -- D-Bus setup ----------------------------------------------------
93
+
94
+ async def start_dbus(self) -> bool:
95
+ self.bus = DBusManager(
96
+ logger=self.logger,
97
+ busname="http",
98
+ bus_prefix=self.bus_prefix,
99
+ )
100
+
101
+ self._contract = HttpContract(f"{self.bus_prefix}.HTTP", self)
102
+ self.bus.add_interface("http", self._contract)
103
+
104
+ if self._dbus_flag("contract_health", True):
105
+ self._health_contract = HealthContract(f"{self.bus_prefix}.Health", self)
106
+ self.bus.add_interface("health", self._health_contract)
107
+
108
+ if self._dbus_flag("contract_config", False):
109
+ self._config_contract = ConfigContract(f"{self.bus_prefix}.Config", self)
110
+ self.bus.add_interface("config", self._config_contract)
111
+
112
+ await self.bus.start()
113
+
114
+ self._build_session()
115
+ return True
116
+
117
+ def _build_session(self) -> None:
118
+ """Build the aiohttp session (no network I/O) — REQ-HTTP-006."""
119
+ if self._session_factory is not None:
120
+ self._session = self._session_factory(self)
121
+ return
122
+ timeout = aiohttp.ClientTimeout(total=self._cfg_int("timeout_s", 10))
123
+ connector = aiohttp.TCPConnector(ssl=self._build_ssl_context())
124
+ self._session = aiohttp.ClientSession(timeout=timeout, connector=connector)
125
+
126
+ def _build_ssl_context(self):
127
+ """Return the connector ``ssl`` value: a context for mutual TLS,
128
+ ``False`` to disable verification, or ``True`` for the default."""
129
+ tls_verify = self._cfg_bool("tls_verify", True)
130
+ certfile = self._cfg("tls_certfile", "").strip()
131
+ keyfile = self._cfg("tls_keyfile", "").strip()
132
+
133
+ if certfile and keyfile:
134
+ ctx = ssl.create_default_context()
135
+ ctx.load_cert_chain(certfile=certfile, keyfile=keyfile)
136
+ if not tls_verify:
137
+ ctx.check_hostname = False
138
+ ctx.verify_mode = ssl.CERT_NONE
139
+ return ctx
140
+ if not tls_verify:
141
+ return False
142
+ return True
143
+
144
+ # -- fire-and-forget D-Bus handlers (REQ-HTTP-004) ------------------
145
+
146
+ def dbus_http_send(self, payload: str) -> str:
147
+ token = secrets.token_hex(16)
148
+ self._spawn(token, self._run_send(payload))
149
+ return token
150
+
151
+ def dbus_http_download(self, payload: str, download_path: str) -> str:
152
+ token = secrets.token_hex(16)
153
+ self._spawn(token, self._run_send(payload, download_path=download_path))
154
+ return token
155
+
156
+ def dbus_http_request(
157
+ self,
158
+ method: str,
159
+ url: str,
160
+ headers: str,
161
+ body: str,
162
+ download_path: str,
163
+ ) -> str:
164
+ token = secrets.token_hex(16)
165
+ self._spawn(token, self._run_request(method, url, headers, body, download_path))
166
+ return token
167
+
168
+ def _spawn(self, token: str, coro) -> None:
169
+ """Schedule a request task on the running loop."""
170
+ loop = asyncio.get_running_loop()
171
+ task = loop.create_task(self._complete(token, coro), name=f"http_{token}")
172
+ self._tasks.add(task)
173
+ task.add_done_callback(self._tasks.discard)
174
+
175
+ async def _complete(self, token: str, coro) -> None:
176
+ """Await a request and emit ``request_completed`` (never lost)."""
177
+ try:
178
+ result = await coro
179
+ except asyncio.CancelledError:
180
+ raise
181
+ except Exception as exc: # noqa: BLE001
182
+ result = {
183
+ "status_code": None,
184
+ "url": "",
185
+ "elapsed_ms": 0,
186
+ "body": None,
187
+ "download_path": None,
188
+ "bytes_written": None,
189
+ "error": f"internal error: {exc}",
190
+ }
191
+ self._emit_completed(token, result)
192
+
193
+ def _emit_completed(self, token: str, result: dict) -> None:
194
+ if self._contract is not None:
195
+ self._contract.request_completed(token, json.dumps(result))
196
+
197
+ # -- request execution ----------------------------------------------
198
+
199
+ async def _run_send(self, payload: str, download_path: str | None = None) -> dict:
200
+ url = self._cfg("default_url", "").strip()
201
+ method = self._cfg("default_method", "POST").strip()
202
+ headers = self._parse_headers(self._cfg("default_headers", ""))
203
+ return await self._execute(
204
+ method, url, headers, payload, self._normalize_path(download_path)
205
+ )
206
+
207
+ async def _run_request(
208
+ self,
209
+ method: str,
210
+ url: str,
211
+ headers: str,
212
+ body: str,
213
+ download_path: str,
214
+ ) -> dict:
215
+ parsed_headers = self._parse_headers(headers)
216
+ return await self._execute(
217
+ method, url, parsed_headers, body, self._normalize_path(download_path)
218
+ )
219
+
220
+ @staticmethod
221
+ def _normalize_path(path: str | None) -> str | None:
222
+ if not path:
223
+ return None
224
+ path = path.strip()
225
+ return path or None
226
+
227
+ def _parse_headers(self, raw: str) -> dict:
228
+ """Parse a JSON-object header string; invalid input yields {}."""
229
+ if not raw or not raw.strip():
230
+ return {}
231
+ try:
232
+ data = json.loads(raw)
233
+ except json.JSONDecodeError:
234
+ self.logger.warning("http: headers are not valid JSON; ignoring")
235
+ return {}
236
+ if not isinstance(data, dict):
237
+ self.logger.warning("http: headers must be a JSON object; ignoring")
238
+ return {}
239
+ return {str(k): str(v) for k, v in data.items()}
240
+
241
+ async def _execute(
242
+ self,
243
+ method: str,
244
+ url: str,
245
+ headers: dict,
246
+ body: str,
247
+ download_path: str | None,
248
+ ) -> dict:
249
+ """Run a request with retry/backoff and return the result JSON (REQ-HTTP-005)."""
250
+ start = time.monotonic()
251
+
252
+ def _result(**fields) -> dict:
253
+ result = {
254
+ "status_code": None,
255
+ "url": url,
256
+ "elapsed_ms": self._elapsed_ms(start),
257
+ "body": None,
258
+ "download_path": download_path,
259
+ "bytes_written": None,
260
+ "error": None,
261
+ }
262
+ result.update(fields)
263
+ return result
264
+
265
+ if not url:
266
+ return _result(error="no URL configured")
267
+
268
+ session = self._session
269
+ if session is None:
270
+ return _result(error="HTTP session is not started")
271
+
272
+ retries = max(0, self._cfg_int("retries", 3))
273
+ backoff = max(0, self._cfg_int("retry_backoff_s", 1))
274
+
275
+ for attempt in range(1, retries + 2):
276
+ ok, fields = await self._attempt(session, method, url, headers, body, download_path)
277
+ if ok:
278
+ return _result(**fields)
279
+ if attempt <= retries:
280
+ delay = backoff * (2 ** (attempt - 1))
281
+ self.logger.warning(
282
+ "http: attempt %d failed (%s); retrying in %.1fs",
283
+ attempt,
284
+ fields.get("error"),
285
+ delay,
286
+ )
287
+ await asyncio.sleep(delay)
288
+ else:
289
+ return _result(**fields)
290
+
291
+ return _result(error="unreachable") # pragma: no cover
292
+
293
+ async def _attempt(
294
+ self,
295
+ session,
296
+ method: str,
297
+ url: str,
298
+ headers: dict,
299
+ body: str,
300
+ download_path: str | None,
301
+ ) -> tuple[bool, dict]:
302
+ """Perform one request. Returns ``(ok, fields)``; ``ok=False`` marks
303
+ a transient failure (network error, timeout, or 5xx) to retry."""
304
+ max_redirects = max(0, self._cfg_int("max_redirects", 5))
305
+ try:
306
+ async with session.request(
307
+ method,
308
+ url,
309
+ headers=headers or None,
310
+ data=body or None,
311
+ max_redirects=max_redirects,
312
+ ) as resp:
313
+ status = resp.status
314
+ final_url = str(resp.url)
315
+ payload = await resp.read()
316
+
317
+ if status >= 500:
318
+ return False, {
319
+ "status_code": status,
320
+ "url": final_url,
321
+ "error": f"server returned HTTP {status}",
322
+ }
323
+
324
+ fields: dict = {"status_code": status, "url": final_url, "error": None}
325
+ if download_path:
326
+ try:
327
+ written = self._write_atomic(download_path, payload)
328
+ except OSError as exc:
329
+ fields["error"] = f"download write failed: {exc}"
330
+ return True, fields
331
+ fields["download_path"] = download_path
332
+ fields["bytes_written"] = written
333
+ else:
334
+ fields["body"] = payload.decode("utf-8", errors="replace")
335
+ return True, fields
336
+ except (aiohttp.ClientError, asyncio.TimeoutError, OSError, ValueError) as exc:
337
+ return False, {"error": str(exc)}
338
+
339
+ @staticmethod
340
+ def _write_atomic(path: str, data: bytes) -> int:
341
+ """Write ``data`` to ``path`` atomically (temp file + rename)."""
342
+ directory = os.path.dirname(path)
343
+ if directory:
344
+ os.makedirs(directory, exist_ok=True)
345
+ fd, tmp = tempfile.mkstemp(dir=directory or ".", prefix=".httpdl-")
346
+ try:
347
+ with os.fdopen(fd, "wb") as f:
348
+ f.write(data)
349
+ os.replace(tmp, path)
350
+ except Exception: # noqa: BLE001
351
+ try:
352
+ os.unlink(tmp)
353
+ except OSError:
354
+ pass
355
+ raise
356
+ return len(data)
357
+
358
+ @staticmethod
359
+ def _elapsed_ms(start: float) -> int:
360
+ return int((time.monotonic() - start) * 1000)
361
+
362
+ # -- graceful shutdown (REQ-HTTP-006) -------------------------------
363
+
364
+ async def close_loop(self) -> None:
365
+ for task in list(self._tasks):
366
+ task.cancel()
367
+ if self._tasks:
368
+ await asyncio.gather(*self._tasks, return_exceptions=True)
369
+ self._tasks.clear()
370
+ if self._session is not None:
371
+ await self._session.close()
372
+ self._session = None
373
+ await super().close_loop()
374
+
375
+ # -- health / config contract base callbacks (REQ-XCUT-004) --------
376
+
377
+ def dbus_health_ping(self) -> bool:
378
+ return True
379
+
380
+ def dbus_health_status(self) -> str:
381
+ return json.dumps(
382
+ {
383
+ "name": self.name,
384
+ "version": self.version,
385
+ "uptime": self.dbus_health_get_uptime(),
386
+ "provides": self.dbus_health_get_provides(),
387
+ "requires": self.dbus_health_get_requires(),
388
+ }
389
+ )
390
+
391
+ def dbus_health_get_uptime(self) -> int:
392
+ return int(time.time() - self._start_time)
393
+
394
+ def dbus_config_get(self, section: str, key: str) -> str:
395
+ try:
396
+ return self.config[section][key]
397
+ except (KeyError, configparser.Error):
398
+ return ""
399
+
400
+ def dbus_config_set(self, section: str, key: str, value: str) -> bool:
401
+ try:
402
+ if not self.config.has_section(section):
403
+ self.config.add_section(section)
404
+ self.config[section][key] = value
405
+ except Exception: # noqa: BLE001
406
+ return False
407
+ if self._config_contract is not None:
408
+ try:
409
+ self._config_contract.config_changed(section, key, value)
410
+ except Exception: # noqa: BLE001
411
+ pass
412
+ return True
413
+
414
+
415
+ def main() -> None:
416
+ HttpService().start()
417
+
418
+
419
+ if __name__ == "__main__":
420
+ main()
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: pympacds-http
3
+ Version: 0.1.0
4
+ Summary: HTTP client service for pympacds
5
+ Author-email: Oscar Diaz <odiaz@ieee.org>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: pympacds>=0.2.0
10
+ Requires-Dist: aiohttp>=3.0
11
+
12
+ # pympacds-http
13
+
14
+ HTTP client service for [pympacds](https://github.com/dargor0/pympacds). Sends
15
+ data over HTTP on behalf of other services and exposes it over D-Bus, with
16
+ automatic retry and optional mutual TLS.
17
+
18
+ ## Features
19
+
20
+ - Fire-and-forget D-Bus API: `send`, `download`, and `request` each return a
21
+ random request token immediately; the result is delivered asynchronously via
22
+ the `request_completed(token, result)` signal, so callers correlate a call
23
+ with its outcome by token.
24
+ - A single pooled `aiohttp.ClientSession` (native asyncio, no thread
25
+ offloading), created at startup with no network I/O and closed on shutdown.
26
+ - Automatic retry of transient failures (network error, timeout, HTTP 5xx)
27
+ with exponential backoff (`retries`, `retry_backoff_s`).
28
+ - TLS verification control (`tls_verify`) plus mutual TLS via
29
+ `tls_certfile`/`tls_keyfile` (must be provided together).
30
+ - `download` / `request` with a `download_path` write the response body
31
+ atomically to disk (temp file + rename) for binary/large downloads.
32
+ - Runtime configuration: `[http]` keys are writable via the framework
33
+ `ConfigContract`.
34
+ - Exports the framework `HealthContract` (always) and `ConfigContract`
35
+ (opt-in via `[dbus] contract_config = true`) alongside the HTTP contract.
36
+
37
+ ### Why aiohttp
38
+
39
+ The service uses [aiohttp](https://docs.aiohttp.org/) because it is asyncio
40
+ native: requests run directly on the framework's single event loop with no
41
+ thread offloading. It is isolated in this service's own package, so the core
42
+ `pympacds` framework keeps its zero-dependency property (the core's
43
+ `httpconfprov` middleware continues to use the stdlib `urllib.request`).
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install pympacds-http
49
+ ```
50
+
51
+ ## Configuration
52
+
53
+ The service reads its parameters from the `[http]` INI section (see
54
+ `config/http.ini.example`). Key options:
55
+
56
+ | Key | Default | Description |
57
+ |-----|---------|-------------|
58
+ | `timeout_s` | `10` | Per-request timeout |
59
+ | `retries` | `3` | Number of retry attempts |
60
+ | `retry_backoff_s` | `1` | Initial backoff (exponential) between retries |
61
+ | `tls_verify` | `true` | Verify TLS certificates |
62
+ | `tls_certfile` / `tls_keyfile` | `""` | Client cert/key (PEM) for mutual TLS |
63
+ | `max_redirects` | `5` | Maximum redirects to follow |
64
+ | `default_url` | `""` | Target URL used by `send()` (empty = `send()` fails) |
65
+ | `default_method` | `POST` | Default HTTP method used by `send()` |
66
+ | `default_headers` | `""` | Default headers (JSON object) used by `send()` |
67
+
68
+ Run with:
69
+
70
+ ```bash
71
+ pympacds-http -c /etc/pympacds/http.ini
72
+ ```
73
+
74
+ ## D-Bus API
75
+
76
+ Interface `org.pympacds.HTTP` at object path `/org/pympacds/http`:
77
+
78
+ | Member | Type | Description |
79
+ |--------|------|-------------|
80
+ | `send(payload)` | `send(s) -> s` | Send with default url/method/headers; returns a token |
81
+ | `download(payload, download_path)` | `download(ss) -> s` | Like `send`, but saves the body to `download_path` |
82
+ | `request(method, url, headers, body, download_path)` | `request(sssss) -> s` | Explicit request; empty `download_path` = return body |
83
+ | `request_completed(token, result)` | signal `(ss)` | Emitted on completion; carries the token and JSON result |
84
+
85
+ All sends are **fire-and-forget**: the method returns immediately with a
86
+ randomized token, the request runs asynchronously, and `request_completed` is
87
+ emitted with the full JSON outcome:
88
+
89
+ ```json
90
+ {
91
+ "status_code": 200,
92
+ "url": "https://example.com/api",
93
+ "elapsed_ms": 123,
94
+ "body": "...",
95
+ "download_path": null,
96
+ "bytes_written": null,
97
+ "error": null
98
+ }
99
+ ```
100
+
101
+ The framework `HealthContract` (at `.../health`) and `ConfigContract`
102
+ (`[dbus] contract_config = true`) are also exported.
@@ -0,0 +1,8 @@
1
+ pympacds_http/__init__.py,sha256=C-do81Q61iZRjMsxRFFS-RRAGHxHTpFop_6rqNRKd7U,81
2
+ pympacds_http/contracts.py,sha256=VbWnBkLg5KQNVPlnfzA5lk5qLfhcTdmqq0slOrJbIuU,1157
3
+ pympacds_http/service.py,sha256=H6sg5r10zITm3WjdYbtHnTaMexac9g59qiVSX9NXUXQ,14790
4
+ pympacds_http-0.1.0.dist-info/METADATA,sha256=pCM5-Ow3E1I01aAj4Yk7QVlT7rfI9XVZT2Law0nvaQo,3939
5
+ pympacds_http-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ pympacds_http-0.1.0.dist-info/entry_points.txt,sha256=_FK5STvcctcxKFNwGfSDmJfPwB4R_Z2685HeOIH7BpY,61
7
+ pympacds_http-0.1.0.dist-info/top_level.txt,sha256=m9QXEtI4zX5-pc4ACLiXV2uhKjZqIxiqT1C_ztXSmCg,14
8
+ pympacds_http-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pympacds-http = pympacds_http.service:main
@@ -0,0 +1 @@
1
+ pympacds_http