necoherence-http-adapter 0.2.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,623 @@
1
+ # SPDX-FileCopyrightText: 2026 barineco
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Finite HTTP host adapter for the pure network-effect ledger."""
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ import http.client
9
+ import queue
10
+ import socket
11
+ import threading
12
+ import time
13
+ from typing import Protocol, cast
14
+ from urllib.parse import SplitResult, urlsplit
15
+
16
+ from necoherence import network_effect as ne
17
+
18
+
19
+ class MonotonicClockPort(Protocol):
20
+ def capability(self) -> ne.MonotonicClockCapability: ...
21
+ def observe(self) -> ne.MonotonicClockObservation: ...
22
+
23
+
24
+ class CancellationPort(Protocol):
25
+ def capability(self) -> ne.CancellationCapability: ...
26
+ def request(
27
+ self,
28
+ capability: ne.CancellationCapability,
29
+ handle: ne.ExecutionHandle,
30
+ ) -> bool: ...
31
+
32
+
33
+ class SystemMonotonicClock:
34
+ def __init__(
35
+ self,
36
+ capability: ne.MonotonicClockCapability | None = None,
37
+ ) -> None:
38
+ self._capability = capability or ne.MonotonicClockCapability(
39
+ ne.OpaqueEvidence("python-system-monotonic-clock"),
40
+ ne.ClockBasis("python-monotonic"),
41
+ )
42
+
43
+ def capability(self) -> ne.MonotonicClockCapability:
44
+ return self._capability
45
+
46
+ def observe(self) -> ne.MonotonicClockObservation:
47
+ return ne.MonotonicClockObservation(
48
+ self._capability.clock_basis,
49
+ time.monotonic_ns(),
50
+ )
51
+
52
+
53
+ class EventCancellationPort:
54
+ def __init__(
55
+ self,
56
+ capability: ne.CancellationCapability | None = None,
57
+ ) -> None:
58
+ self._capability = capability or ne.CancellationCapability(
59
+ ne.OpaqueEvidence("python-event-cancellation")
60
+ )
61
+ self._events: dict[ne.ExecutionHandle, threading.Event] = {}
62
+ self._lock = threading.Lock()
63
+
64
+ def capability(self) -> ne.CancellationCapability:
65
+ return self._capability
66
+
67
+ def register(self, handle: ne.ExecutionHandle) -> None:
68
+ with self._lock:
69
+ self._events[handle] = threading.Event()
70
+
71
+ def request(
72
+ self,
73
+ capability: ne.CancellationCapability,
74
+ handle: ne.ExecutionHandle,
75
+ ) -> bool:
76
+ if capability != self._capability:
77
+ return False
78
+ with self._lock:
79
+ event = self._events.get(handle)
80
+ if event is None:
81
+ return False
82
+ event.set()
83
+ return True
84
+
85
+ def cancelled(self, handle: ne.ExecutionHandle) -> bool:
86
+ with self._lock:
87
+ event = self._events.get(handle)
88
+ return event is not None and event.is_set()
89
+
90
+
91
+ @dataclass(slots=True)
92
+ class _Worker:
93
+ command: ne.SendCommand
94
+ queue: queue.SimpleQueue[ne.AdapterObservation]
95
+ cancel: threading.Event
96
+ next_source: int = 1
97
+ delivery: int = 0
98
+ deadline_emitted: bool = False
99
+
100
+
101
+ class HttpHostAdapter:
102
+ def __init__(
103
+ self,
104
+ clock: MonotonicClockPort | None = None,
105
+ cancellation: CancellationPort | None = None,
106
+ ) -> None:
107
+ self._clock = clock
108
+ self._cancellation = cancellation or EventCancellationPort()
109
+ self._workers: dict[ne.ExecutionHandle, _Worker] = {}
110
+ self._counter = 0
111
+ self._lock = threading.Lock()
112
+
113
+ def start(self, command: ne.SendCommand) -> ne.Started | ne.StartFailed:
114
+ input = command.prepared.input
115
+ failure = self._start_failure(input)
116
+ if failure is not None:
117
+ return self._start_failed(input, failure)
118
+ with self._lock:
119
+ handle = ne.ExecutionHandle(f"python-http-{self._counter}")
120
+ self._counter += 1
121
+ worker = _Worker(command, queue.SimpleQueue(), threading.Event())
122
+ self._workers[handle] = worker
123
+ register = getattr(self._cancellation, "register", None)
124
+ if callable(register):
125
+ register(handle)
126
+ try:
127
+ thread = threading.Thread(
128
+ target=self._run,
129
+ args=(handle, worker, 0),
130
+ daemon=True,
131
+ )
132
+ thread.start()
133
+ except BaseException:
134
+ with self._lock:
135
+ self._workers.pop(handle, None)
136
+ return self._start_failed(input, ne.ExecutionStartFailed())
137
+ return ne.Started(input.attempt, handle)
138
+
139
+ def observe(self, started: ne.StartedAttempt) -> ne.HostObservation:
140
+ worker = self._worker(started.handle, started.prepared.input.attempt)
141
+ if worker is None:
142
+ return ne.PendingObservation(
143
+ started.prepared.input.attempt,
144
+ started.handle,
145
+ )
146
+ observation = self._take(worker)
147
+ if observation is not None:
148
+ return self._delivered(worker, observation)
149
+ self._enqueue_deadline(started.handle, worker)
150
+ observation = self._take(worker)
151
+ if observation is not None:
152
+ return self._delivered(worker, observation)
153
+ return ne.PendingObservation(
154
+ worker.command.prepared.input.attempt,
155
+ started.handle,
156
+ )
157
+
158
+ def cancel(self, command: ne.CancelCommand) -> ne.HostObservation:
159
+ worker = self._worker(command.handle, command.attempt)
160
+ if worker is None:
161
+ return ne.PendingObservation(command.attempt, command.handle)
162
+ observation = self._take(worker)
163
+ if observation is not None:
164
+ return self._delivered(worker, observation)
165
+ source = self._reserve_source(worker)
166
+ if self._cancellation.request(command.capability, command.handle):
167
+ worker.cancel.set()
168
+ observation = ne.CancellationObservation(
169
+ command.attempt,
170
+ command.handle,
171
+ source,
172
+ ne.Unknown(ne.UnknownEffectReason.CANCELLATION),
173
+ )
174
+ else:
175
+ observation = self._failure_observation(
176
+ worker.command.prepared.input,
177
+ command.handle,
178
+ source,
179
+ ne.HostStage.CANCEL,
180
+ ne.CancellationRejected(),
181
+ )
182
+ return self._delivered(worker, observation)
183
+
184
+ def close(self, started: ne.StartedAttempt) -> ne.HostObservation:
185
+ worker = self._worker(
186
+ started.handle,
187
+ started.prepared.input.attempt,
188
+ )
189
+ if worker is None:
190
+ return ne.PendingObservation(
191
+ started.prepared.input.attempt,
192
+ started.handle,
193
+ )
194
+ source = self._reserve_source(worker)
195
+ observation = self._failure_observation(
196
+ worker.command.prepared.input,
197
+ started.handle,
198
+ source,
199
+ ne.HostStage.RESPONSE_BODY,
200
+ ne.HostClosed(),
201
+ )
202
+ return self._delivered(worker, observation)
203
+
204
+ def _start_failure(self, input: ne.AttemptInput) -> ne.HostFailure | None:
205
+ target = urlsplit(input.instruction.target)
206
+ if target.scheme not in {"http", "https"}:
207
+ return ne.UnsupportedUriScheme()
208
+ if not ne.valid_attempt_input(input):
209
+ return ne.InvalidHostConfiguration()
210
+ if input.deadline is not None:
211
+ if (
212
+ self._clock is None
213
+ or self._clock.capability() != input.deadline.capability
214
+ or input.configuration.monotonic_clock
215
+ != input.deadline.capability
216
+ ):
217
+ return ne.DeadlineControlUnavailable()
218
+ if input.configuration.cancellation is not None:
219
+ capability = getattr(self._cancellation, "capability", None)
220
+ if (
221
+ not callable(capability)
222
+ or capability() != input.configuration.cancellation
223
+ ):
224
+ return ne.InvalidHostConfiguration()
225
+ return None
226
+
227
+ def _run(
228
+ self,
229
+ handle: ne.ExecutionHandle,
230
+ worker: _Worker,
231
+ source: int,
232
+ ) -> None:
233
+ input = worker.command.prepared.input
234
+ try:
235
+ deadline = self._deadline_observation(
236
+ input,
237
+ handle,
238
+ source,
239
+ )
240
+ outcome = (
241
+ deadline
242
+ if deadline is not None
243
+ else self._request(handle, input, source)
244
+ )
245
+ except BaseException as error:
246
+ stage, cause = self._classify_exception(error)
247
+ outcome = self._failure_observation(
248
+ input,
249
+ handle,
250
+ source,
251
+ stage,
252
+ cause,
253
+ )
254
+ worker.queue.put(outcome)
255
+
256
+ def _request(
257
+ self,
258
+ handle: ne.ExecutionHandle,
259
+ input: ne.AttemptInput,
260
+ source: int,
261
+ ) -> ne.AdapterObservation:
262
+ instruction = input.instruction
263
+ target = urlsplit(instruction.target)
264
+ try:
265
+ headers = tuple(
266
+ self._encode_request_header(header)
267
+ for header in instruction.headers
268
+ )
269
+ except (UnicodeError, ValueError):
270
+ return self._failure_observation(
271
+ input,
272
+ handle,
273
+ source,
274
+ ne.HostStage.REQUEST_WRITE,
275
+ ne.RequestHeaderEncodingUnavailable(),
276
+ )
277
+ connection = self._new_connection(target)
278
+ try:
279
+ try:
280
+ connection.connect()
281
+ except socket.gaierror:
282
+ return self._failure_observation(
283
+ input,
284
+ handle,
285
+ source,
286
+ ne.HostStage.REQUEST_WRITE,
287
+ ne.NameResolutionFailed(),
288
+ )
289
+ except (ConnectionError, OSError):
290
+ return self._failure_observation(
291
+ input,
292
+ handle,
293
+ source,
294
+ ne.HostStage.REQUEST_WRITE,
295
+ ne.ConnectionFailed(),
296
+ )
297
+ try:
298
+ path = target.path or "/"
299
+ if target.query:
300
+ path += "?" + target.query
301
+ connection.putrequest(
302
+ instruction.method,
303
+ path,
304
+ skip_host=True,
305
+ skip_accept_encoding=True,
306
+ )
307
+ for name, value in headers:
308
+ connection.putheader(name, value)
309
+ connection.endheaders(bytes(instruction.body))
310
+ except (UnicodeError, ValueError):
311
+ return self._failure_observation(
312
+ input,
313
+ handle,
314
+ source,
315
+ ne.HostStage.REQUEST_WRITE,
316
+ ne.RequestHeaderEncodingUnavailable(),
317
+ )
318
+ except (ConnectionError, OSError, http.client.HTTPException):
319
+ return self._failure_observation(
320
+ input,
321
+ handle,
322
+ source,
323
+ ne.HostStage.REQUEST_WRITE,
324
+ ne.RequestWriteFailed(),
325
+ )
326
+ try:
327
+ response = connection.getresponse()
328
+ except socket.timeout:
329
+ return self._failure_observation(
330
+ input,
331
+ handle,
332
+ source,
333
+ ne.HostStage.RESPONSE_HEADERS,
334
+ ne.Timeout(
335
+ ne.HostStage.RESPONSE_HEADERS,
336
+ ne.HostTimeoutCause.LIBRARY_REPORTED,
337
+ ),
338
+ )
339
+ except http.client.RemoteDisconnected:
340
+ return self._failure_observation(
341
+ input,
342
+ handle,
343
+ source,
344
+ ne.HostStage.RESPONSE_HEADERS,
345
+ ne.ResponseHeadersFailed(),
346
+ )
347
+ except http.client.BadStatusLine:
348
+ return self._failure_observation(
349
+ input,
350
+ handle,
351
+ source,
352
+ ne.HostStage.RESPONSE_HEADERS,
353
+ ne.MalformedResponse(),
354
+ )
355
+ except (ConnectionError, OSError, http.client.HTTPException):
356
+ return self._failure_observation(
357
+ input,
358
+ handle,
359
+ source,
360
+ ne.HostStage.RESPONSE_HEADERS,
361
+ ne.ResponseHeadersFailed(),
362
+ )
363
+ try:
364
+ response_headers = tuple(
365
+ self._encode_response_header(name, value)
366
+ for name, value in response.getheaders()
367
+ )
368
+ except (UnicodeError, ValueError, AttributeError):
369
+ return self._failure_observation(
370
+ input,
371
+ handle,
372
+ source,
373
+ ne.HostStage.RESPONSE_HEADERS,
374
+ ne.HeaderRepresentationUnavailable(),
375
+ )
376
+ body = bytearray()
377
+ try:
378
+ while True:
379
+ chunk = response.read(65536)
380
+ if not chunk:
381
+ break
382
+ body.extend(chunk)
383
+ if (
384
+ len(body)
385
+ > input.configuration.response_body_limit
386
+ ):
387
+ return self._failure_observation(
388
+ input,
389
+ handle,
390
+ source,
391
+ ne.HostStage.RESPONSE_BODY,
392
+ ne.ResponseBodyLimitExceeded(),
393
+ )
394
+ except socket.timeout:
395
+ return self._failure_observation(
396
+ input,
397
+ handle,
398
+ source,
399
+ ne.HostStage.RESPONSE_BODY,
400
+ ne.Timeout(
401
+ ne.HostStage.RESPONSE_BODY,
402
+ ne.HostTimeoutCause.LIBRARY_REPORTED,
403
+ ),
404
+ )
405
+ except (
406
+ http.client.IncompleteRead,
407
+ ConnectionError,
408
+ OSError,
409
+ http.client.HTTPException,
410
+ ):
411
+ return self._failure_observation(
412
+ input,
413
+ handle,
414
+ source,
415
+ ne.HostStage.RESPONSE_BODY,
416
+ ne.ResponseBodyFailed(),
417
+ )
418
+ if response.length not in (None, 0):
419
+ return self._failure_observation(
420
+ input,
421
+ handle,
422
+ source,
423
+ ne.HostStage.RESPONSE_BODY,
424
+ ne.ResponseBodyFailed(),
425
+ )
426
+ return ne.ResponseObservation(
427
+ input.attempt,
428
+ handle,
429
+ source,
430
+ ne.RawHttpResponse(
431
+ response.status,
432
+ response_headers,
433
+ tuple(body),
434
+ ),
435
+ ne.HeaderProvenance.LIBRARY_OBSERVED,
436
+ ne.Unknown(ne.UnknownEffectReason.NO_RESPONSE),
437
+ )
438
+ finally:
439
+ connection.close()
440
+
441
+ def _new_connection(
442
+ self,
443
+ target: SplitResult,
444
+ ) -> http.client.HTTPConnection:
445
+ connection_type = (
446
+ http.client.HTTPSConnection
447
+ if target.scheme == "https"
448
+ else http.client.HTTPConnection
449
+ )
450
+ return connection_type(cast(str, target.hostname), target.port)
451
+
452
+ def _encode_request_header(
453
+ self,
454
+ header: ne.RawHeaderField,
455
+ ) -> tuple[str, str]:
456
+ if any(
457
+ value != 9 and (value < 32 or value == 127)
458
+ for value in header.value
459
+ ):
460
+ raise ValueError("unrepresentable request header")
461
+ return header.name, bytes(header.value).decode("latin-1")
462
+
463
+ def _encode_response_header(
464
+ self,
465
+ name: str,
466
+ value: str,
467
+ ) -> ne.RawHeaderField:
468
+ return ne.RawHeaderField(name, tuple(value.encode("latin-1")))
469
+
470
+ def _enqueue_deadline(
471
+ self,
472
+ handle: ne.ExecutionHandle,
473
+ worker: _Worker,
474
+ ) -> None:
475
+ if worker.deadline_emitted:
476
+ return
477
+ source = self._reserve_source(worker)
478
+ observation = self._deadline_observation(
479
+ worker.command.prepared.input,
480
+ handle,
481
+ source,
482
+ )
483
+ if observation is not None:
484
+ worker.deadline_emitted = True
485
+ worker.queue.put(observation)
486
+
487
+ def _deadline_observation(
488
+ self,
489
+ input: ne.AttemptInput,
490
+ handle: ne.ExecutionHandle,
491
+ source: int,
492
+ ) -> ne.AdapterObservation | None:
493
+ deadline = input.deadline
494
+ if deadline is None or self._clock is None:
495
+ return None
496
+ observed = self._clock.observe()
497
+ if observed.clock_basis != deadline.clock_basis:
498
+ return self._failure_observation(
499
+ input,
500
+ handle,
501
+ source,
502
+ ne.HostStage.DEADLINE,
503
+ ne.DeadlineControlUnavailable(),
504
+ ne.UnknownEffectReason.DEADLINE,
505
+ )
506
+ if deadline.limit <= observed.value:
507
+ return ne.DeadlineObservation(
508
+ input.attempt,
509
+ handle,
510
+ source,
511
+ deadline,
512
+ observed,
513
+ ne.Unknown(ne.UnknownEffectReason.DEADLINE),
514
+ )
515
+ return None
516
+
517
+ def _worker(
518
+ self,
519
+ handle: ne.ExecutionHandle,
520
+ attempt: ne.AttemptIdentity,
521
+ ) -> _Worker | None:
522
+ with self._lock:
523
+ worker = self._workers.get(handle)
524
+ if (
525
+ worker is None
526
+ or worker.command.prepared.input.attempt != attempt
527
+ ):
528
+ return None
529
+ return worker
530
+
531
+ def _take(
532
+ self,
533
+ worker: _Worker,
534
+ ) -> ne.AdapterObservation | None:
535
+ try:
536
+ return worker.queue.get_nowait()
537
+ except queue.Empty:
538
+ return None
539
+
540
+ def _delivered(
541
+ self,
542
+ worker: _Worker,
543
+ observation: ne.AdapterObservation,
544
+ ) -> ne.DeliveredObservation:
545
+ with self._lock:
546
+ delivery = worker.delivery
547
+ worker.delivery += 1
548
+ return ne.DeliveredObservation(delivery, observation)
549
+
550
+ def _reserve_source(self, worker: _Worker) -> int:
551
+ with self._lock:
552
+ source = worker.next_source
553
+ worker.next_source += 1
554
+ return source
555
+
556
+ def _start_failed(
557
+ self,
558
+ input: ne.AttemptInput,
559
+ cause: ne.HostFailure,
560
+ ) -> ne.StartFailed:
561
+ return ne.StartFailed(
562
+ self._failure(
563
+ input,
564
+ ne.HostStage.START,
565
+ cause,
566
+ ),
567
+ ne.Unknown(ne.UnknownEffectReason.HOST_FAILURE),
568
+ )
569
+
570
+ def _failure_observation(
571
+ self,
572
+ input: ne.AttemptInput,
573
+ handle: ne.ExecutionHandle,
574
+ source: int | None,
575
+ stage: ne.HostStage,
576
+ cause: ne.HostFailure,
577
+ reason: ne.UnknownEffectReason = ne.UnknownEffectReason.HOST_FAILURE,
578
+ ) -> ne.FailureObservation:
579
+ return ne.FailureObservation(
580
+ input.attempt,
581
+ handle,
582
+ source,
583
+ self._failure(input, stage, cause),
584
+ ne.Unknown(reason),
585
+ )
586
+
587
+ @staticmethod
588
+ def _failure(
589
+ input: ne.AttemptInput,
590
+ stage: ne.HostStage,
591
+ cause: ne.HostFailure,
592
+ ) -> ne.NetworkHostFailure:
593
+ return ne.NetworkHostFailure(
594
+ input.attempt,
595
+ stage,
596
+ input.configuration,
597
+ cause,
598
+ )
599
+
600
+ @staticmethod
601
+ def _classify_exception(
602
+ error: BaseException,
603
+ ) -> tuple[ne.HostStage, ne.HostFailure]:
604
+ if isinstance(error, socket.gaierror):
605
+ return ne.HostStage.REQUEST_WRITE, ne.NameResolutionFailed()
606
+ if isinstance(error, socket.timeout):
607
+ return (
608
+ ne.HostStage.RESPONSE_BODY,
609
+ ne.Timeout(
610
+ ne.HostStage.RESPONSE_BODY,
611
+ ne.HostTimeoutCause.LIBRARY_REPORTED,
612
+ ),
613
+ )
614
+ return ne.HostStage.RESPONSE_BODY, ne.HostRuntimeFailure()
615
+
616
+
617
+ __all__ = [
618
+ "CancellationPort",
619
+ "EventCancellationPort",
620
+ "HttpHostAdapter",
621
+ "MonotonicClockPort",
622
+ "SystemMonotonicClock",
623
+ ]
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.5
2
+ Name: necoherence-http-adapter
3
+ Version: 0.2.0
4
+ Summary: HTTP host adapter for Necoherence
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: necoherence==0.2.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: mypy; extra == 'dev'
11
+ Requires-Dist: pytest; extra == 'dev'
@@ -0,0 +1,5 @@
1
+ necoherence_http_adapter/__init__.py,sha256=kXAq9s1GMveh7qNW2w3e3Nyi38wVOSKKDpLlLQN_OE8,20466
2
+ necoherence_http_adapter-0.2.0.dist-info/METADATA,sha256=0nFzyH9wse6B68tc7ukca6OAXZ8QN-MUBCrWXQBPB8s,309
3
+ necoherence_http_adapter-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
4
+ necoherence_http_adapter-0.2.0.dist-info/licenses/LICENSE,sha256=JbpxLbnfFXzSWAmI-qa8ddABbmNbok6264R7DhXRAFM,1065
5
+ necoherence_http_adapter-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 barineco
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.