pyintake 0.0.1__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 (47) hide show
  1. pyintake/__init__.py +83 -0
  2. pyintake/__pycache__/__init__.cpython-313.pyc +0 -0
  3. pyintake/__pycache__/_protocols.cpython-313.pyc +0 -0
  4. pyintake/__pycache__/_types.cpython-313.pyc +0 -0
  5. pyintake/__pycache__/errors.cpython-313.pyc +0 -0
  6. pyintake/__pycache__/event.cpython-313.pyc +0 -0
  7. pyintake/_protocols.py +145 -0
  8. pyintake/_types.py +135 -0
  9. pyintake/adapters/__init__.py +6 -0
  10. pyintake/api/__init__.py +3 -0
  11. pyintake/api/routes/__init__.py +3 -0
  12. pyintake/api/routes/v1/__init__.py +3 -0
  13. pyintake/buffer/__init__.py +6 -0
  14. pyintake/buffer/__pycache__/__init__.cpython-313.pyc +0 -0
  15. pyintake/buffer/__pycache__/_dedup.cpython-313.pyc +0 -0
  16. pyintake/buffer/__pycache__/_priority_buffer.cpython-313.pyc +0 -0
  17. pyintake/buffer/_dedup.py +110 -0
  18. pyintake/buffer/_priority_buffer.py +191 -0
  19. pyintake/config/__init__.py +3 -0
  20. pyintake/core/__init__.py +6 -0
  21. pyintake/core/__pycache__/__init__.cpython-313.pyc +0 -0
  22. pyintake/core/__pycache__/_drain.cpython-313.pyc +0 -0
  23. pyintake/core/__pycache__/_engine.cpython-313.pyc +0 -0
  24. pyintake/core/__pycache__/_rate_limiter.cpython-313.pyc +0 -0
  25. pyintake/core/_drain.py +248 -0
  26. pyintake/core/_engine.py +248 -0
  27. pyintake/core/_rate_limiter.py +87 -0
  28. pyintake/errors.py +205 -0
  29. pyintake/event.py +244 -0
  30. pyintake/metrics/__init__.py +7 -0
  31. pyintake/metrics/__pycache__/__init__.cpython-313.pyc +0 -0
  32. pyintake/metrics/__pycache__/_collector.cpython-313.pyc +0 -0
  33. pyintake/metrics/__pycache__/_models.cpython-313.pyc +0 -0
  34. pyintake/metrics/_collector.py +196 -0
  35. pyintake/metrics/_models.py +85 -0
  36. pyintake/py.typed +0 -0
  37. pyintake/validation/__init__.py +6 -0
  38. pyintake/validation/__pycache__/__init__.cpython-313.pyc +0 -0
  39. pyintake/validation/__pycache__/_chain.cpython-313.pyc +0 -0
  40. pyintake/validation/__pycache__/_validators.cpython-313.pyc +0 -0
  41. pyintake/validation/_chain.py +82 -0
  42. pyintake/validation/_validators.py +181 -0
  43. pyintake-0.0.1.dist-info/METADATA +47 -0
  44. pyintake-0.0.1.dist-info/RECORD +47 -0
  45. pyintake-0.0.1.dist-info/WHEEL +5 -0
  46. pyintake-0.0.1.dist-info/entry_points.txt +2 -0
  47. pyintake-0.0.1.dist-info/top_level.txt +1 -0
pyintake/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ """PyIntake — Transport-agnostic data ingestion system.
2
+
3
+ Collects, validates, deduplicates, and buffers events from any transport,
4
+ then drains them into pluggable downstream consumers with priority ordering
5
+ and backpressure signaling.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pyintake._protocols import Consumer, MetricsCollector, TransportAdapter, Validator
11
+ from pyintake._types import (
12
+ BackpressureSignal,
13
+ BufferEntry,
14
+ ConsumeResult,
15
+ IngestResult,
16
+ Priority,
17
+ ValidationOutcome,
18
+ )
19
+ from pyintake.buffer._dedup import SlidingWindowDedup
20
+ from pyintake.buffer._priority_buffer import PriorityBuffer
21
+ from pyintake.core._drain import AdaptiveDrainLoop
22
+ from pyintake.core._engine import IngestionEngine
23
+ from pyintake.core._rate_limiter import TokenBucketRateLimiter
24
+ from pyintake.errors import (
25
+ AdapterError,
26
+ BufferFullError,
27
+ ConfigError,
28
+ ConsumerError,
29
+ DrainError,
30
+ DuplicateEventError,
31
+ ErrorCode,
32
+ PyIntakeError,
33
+ RateLimitedError,
34
+ ShutdownTimeoutError,
35
+ ValidationError,
36
+ )
37
+ from pyintake.event import IngestEvent
38
+ from pyintake.metrics._collector import InMemoryMetricsCollector
39
+ from pyintake.metrics._models import MetricsSnapshot, SourceMetrics
40
+ from pyintake.validation._chain import ValidationChain
41
+
42
+ __all__ = [
43
+ # Core event type
44
+ "IngestEvent",
45
+ # Value types and enums
46
+ "Priority",
47
+ "BackpressureSignal",
48
+ "IngestResult",
49
+ "ValidationOutcome",
50
+ "ConsumeResult",
51
+ "BufferEntry",
52
+ # Protocols
53
+ "TransportAdapter",
54
+ "Validator",
55
+ "Consumer",
56
+ "MetricsCollector",
57
+ # Engine and drain
58
+ "IngestionEngine",
59
+ "AdaptiveDrainLoop",
60
+ # Buffer
61
+ "PriorityBuffer",
62
+ "SlidingWindowDedup",
63
+ # Validation
64
+ "ValidationChain",
65
+ # Rate limiting
66
+ "TokenBucketRateLimiter",
67
+ # Metrics
68
+ "InMemoryMetricsCollector",
69
+ "MetricsSnapshot",
70
+ "SourceMetrics",
71
+ # Errors
72
+ "PyIntakeError",
73
+ "ErrorCode",
74
+ "BufferFullError",
75
+ "ValidationError",
76
+ "DuplicateEventError",
77
+ "RateLimitedError",
78
+ "AdapterError",
79
+ "DrainError",
80
+ "ConfigError",
81
+ "ConsumerError",
82
+ "ShutdownTimeoutError",
83
+ ]
pyintake/_protocols.py ADDED
@@ -0,0 +1,145 @@
1
+ """Protocol definitions for PyIntake pluggable interfaces.
2
+
3
+ Protocols define the contracts for transport adapters, validators,
4
+ consumers, and metrics collectors. Implementations can be swapped
5
+ in via dependency injection without coupling to concrete classes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
11
+
12
+ if TYPE_CHECKING:
13
+ from pyintake._types import ConsumeResult, ValidationOutcome
14
+ from pyintake.event import IngestEvent
15
+ from pyintake.metrics._models import MetricsSnapshot
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Transport adapter protocol
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ @runtime_checkable
24
+ class TransportAdapter(Protocol):
25
+ """Receives events from a transport and feeds them into IngestionEngine.
26
+
27
+ Implementations handle transport-specific concerns (Kafka consumer,
28
+ HTTP server, file watcher, etc.) and call engine.ingest() for each event.
29
+ """
30
+
31
+ async def start(self, engine: Any) -> None:
32
+ """Start the adapter and begin feeding events to the engine.
33
+
34
+ Args:
35
+ engine: The IngestionEngine to feed events into.
36
+ """
37
+ ...
38
+
39
+ async def stop(self) -> None:
40
+ """Stop the adapter and release transport resources."""
41
+ ...
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Validator protocol
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ @runtime_checkable
50
+ class Validator(Protocol):
51
+ """Validates an IngestEvent before it enters the buffer.
52
+
53
+ Validators run in the ingestion path (before buffering), so they
54
+ must be fast and synchronous. Heavy validation should be done
55
+ downstream by consumers.
56
+ """
57
+
58
+ @property
59
+ def name(self) -> str:
60
+ """Human-readable name for metrics and logging."""
61
+ ...
62
+
63
+ def validate(self, event: IngestEvent) -> ValidationOutcome:
64
+ """Validate a single event.
65
+
66
+ Args:
67
+ event: The event to validate.
68
+
69
+ Returns:
70
+ Outcome indicating whether the event is valid.
71
+ """
72
+ ...
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Consumer protocol
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ @runtime_checkable
81
+ class Consumer(Protocol):
82
+ """Downstream consumer that receives drained event batches.
83
+
84
+ Consumers are the output side of the ingestion pipeline. They
85
+ receive batches of validated, deduplicated events from the
86
+ drain loop. Examples: pystator orchestrator, pycharter validator,
87
+ file collector for offline analysis.
88
+ """
89
+
90
+ async def consume(self, batch: list[IngestEvent]) -> ConsumeResult:
91
+ """Process a batch of events.
92
+
93
+ Args:
94
+ batch: List of events drained from the buffer.
95
+
96
+ Returns:
97
+ Result indicating success/failure and events processed.
98
+ """
99
+ ...
100
+
101
+ async def close(self) -> None:
102
+ """Release any resources held by the consumer."""
103
+ ...
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Metrics collector protocol
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ @runtime_checkable
112
+ class MetricsCollector(Protocol):
113
+ """Collects ingestion metrics programmatically.
114
+
115
+ All methods are synchronous and must be fast — they are called
116
+ in the hot ingestion path.
117
+ """
118
+
119
+ def record_accepted(self, event: IngestEvent) -> None:
120
+ """Record a successfully accepted event."""
121
+ ...
122
+
123
+ def record_rejected(self, event: IngestEvent, reason: str) -> None:
124
+ """Record a rejected event with the rejection reason."""
125
+ ...
126
+
127
+ def record_drained(self, count: int, sources: list[str] | None = None) -> None:
128
+ """Record that count events were drained to consumers."""
129
+ ...
130
+
131
+ def record_deduplicated(self, event: IngestEvent) -> None:
132
+ """Record a deduplicated (duplicate-rejected) event."""
133
+ ...
134
+
135
+ def record_shed(self, event: IngestEvent) -> None:
136
+ """Record an event shed due to buffer pressure."""
137
+ ...
138
+
139
+ def record_dead_letter(self, event: IngestEvent) -> None:
140
+ """Record an event that exceeded max retries."""
141
+ ...
142
+
143
+ def snapshot(self) -> MetricsSnapshot:
144
+ """Return a frozen point-in-time snapshot of all metrics."""
145
+ ...
pyintake/_types.py ADDED
@@ -0,0 +1,135 @@
1
+ """Core value types for PyIntake.
2
+
3
+ Frozen dataclasses and enums used throughout the ingestion system.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from datetime import UTC, datetime
10
+ from enum import IntEnum, StrEnum
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ if TYPE_CHECKING:
14
+ from pyintake.event import IngestEvent
15
+
16
+
17
+ class Priority(IntEnum):
18
+ """Buffer priority lanes. Lower value = higher priority.
19
+
20
+ HIGH events are never shed under pressure (only rejected when
21
+ the buffer is completely full). LOW events are shed first.
22
+ """
23
+
24
+ HIGH = 0
25
+ NORMAL = 1
26
+ LOW = 2
27
+
28
+
29
+ class BackpressureSignal(StrEnum):
30
+ """Signal to callers about current buffer health.
31
+
32
+ Transport adapters use this to decide how to react:
33
+ - HTTP: return 429
34
+ - Kafka: pause partitions
35
+ - WebSocket: send flow-control frame
36
+ """
37
+
38
+ NONE = "none"
39
+ SLOW_DOWN = "slow_down"
40
+ REJECT = "reject"
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class IngestResult:
45
+ """Result returned from every ingest() call.
46
+
47
+ Attributes:
48
+ accepted: Whether the event was accepted into the buffer.
49
+ event_id: The event ID that was processed.
50
+ buffer_utilization: Current buffer utilization (0.0 - 1.0).
51
+ backpressure: Backpressure signal for the caller.
52
+ rejection_reason: Human-readable reason if rejected.
53
+ """
54
+
55
+ accepted: bool
56
+ event_id: str
57
+ buffer_utilization: float
58
+ backpressure: BackpressureSignal
59
+ rejection_reason: str | None = None
60
+
61
+ def to_dict(self) -> dict[str, Any]:
62
+ """Serialize to dictionary."""
63
+ return {
64
+ "accepted": self.accepted,
65
+ "event_id": self.event_id,
66
+ "buffer_utilization": self.buffer_utilization,
67
+ "backpressure": self.backpressure.value,
68
+ "rejection_reason": self.rejection_reason,
69
+ }
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class ValidationOutcome:
74
+ """Result of running a validator on an event.
75
+
76
+ Attributes:
77
+ is_valid: Whether the event passed validation.
78
+ reasons: Rejection reasons (empty if valid).
79
+ validator_name: Name of the validator that produced this outcome.
80
+ """
81
+
82
+ is_valid: bool
83
+ reasons: tuple[str, ...] = ()
84
+ validator_name: str = ""
85
+
86
+ @classmethod
87
+ def accept(cls) -> ValidationOutcome:
88
+ """Create an accepted outcome."""
89
+ return cls(is_valid=True)
90
+
91
+ @classmethod
92
+ def reject(
93
+ cls,
94
+ reasons: tuple[str, ...] | list[str],
95
+ validator_name: str = "",
96
+ ) -> ValidationOutcome:
97
+ """Create a rejected outcome."""
98
+ return cls(
99
+ is_valid=False,
100
+ reasons=tuple(reasons),
101
+ validator_name=validator_name,
102
+ )
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class ConsumeResult:
107
+ """Result from a downstream consumer processing a batch.
108
+
109
+ Attributes:
110
+ success: Whether the consumer processed the batch without errors.
111
+ processed: Number of events successfully processed.
112
+ errors: Error messages for failed events.
113
+ """
114
+
115
+ success: bool
116
+ processed: int
117
+ errors: tuple[str, ...] = ()
118
+
119
+
120
+ @dataclass(slots=True)
121
+ class BufferEntry:
122
+ """Mutable wrapper around IngestEvent for retry tracking in the buffer.
123
+
124
+ The IngestEvent is frozen/immutable, but we need to track retry state
125
+ as events are requeued on consumer failure.
126
+
127
+ Attributes:
128
+ event: The immutable IngestEvent.
129
+ retry_count: Number of times this event has been requeued.
130
+ first_buffered_at: When the event first entered the buffer.
131
+ """
132
+
133
+ event: IngestEvent
134
+ retry_count: int = 0
135
+ first_buffered_at: datetime = field(default_factory=lambda: datetime.now(UTC))
@@ -0,0 +1,6 @@
1
+ """Transport adapters for PyIntake.
2
+
3
+ Pluggable adapters for HTTP, Kafka, WebSocket, and file-based ingestion.
4
+ """
5
+
6
+ from __future__ import annotations
@@ -0,0 +1,3 @@
1
+ """REST API for PyIntake health, metrics, and HTTP ingestion."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,3 @@
1
+ """API route modules."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,3 @@
1
+ """V1 API routes."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,6 @@
1
+ """Buffer components for PyIntake.
2
+
3
+ Priority-based in-memory buffering with shed-under-pressure and deduplication.
4
+ """
5
+
6
+ from __future__ import annotations
@@ -0,0 +1,110 @@
1
+ """Sliding-window deduplication for PyIntake.
2
+
3
+ Time-bounded AND count-bounded dedup using OrderedDict for O(1)
4
+ insert/lookup with LRU eviction.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import threading
10
+ import time
11
+ from collections import OrderedDict
12
+
13
+
14
+ class SlidingWindowDedup:
15
+ """Sliding-window deduplication by event ID.
16
+
17
+ Maintains a bounded set of recently seen event IDs. An event is
18
+ considered a duplicate if its ID was seen within the window.
19
+
20
+ Attributes:
21
+ window_seconds: Time window for dedup (seconds).
22
+ max_ids: Maximum number of IDs to track (prevents unbounded memory).
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ window_seconds: int = 300,
28
+ max_ids: int = 100_000,
29
+ ) -> None:
30
+ if window_seconds <= 0:
31
+ raise ValueError("window_seconds must be positive")
32
+ if max_ids <= 0:
33
+ raise ValueError("max_ids must be positive")
34
+
35
+ self._window_seconds = window_seconds
36
+ self._max_ids = max_ids
37
+ self._lock = threading.Lock()
38
+ self._seen: OrderedDict[str, float] = OrderedDict()
39
+
40
+ def is_duplicate(self, event_id: str) -> bool:
41
+ """Check if event_id was seen within the sliding window.
42
+
43
+ Thread-safe.
44
+
45
+ Args:
46
+ event_id: The event ID to check.
47
+
48
+ Returns:
49
+ True if the event is a duplicate.
50
+ """
51
+ with self._lock:
52
+ self._evict_expired()
53
+ return event_id in self._seen
54
+
55
+ def record(self, event_id: str) -> None:
56
+ """Record event_id with current timestamp.
57
+
58
+ Thread-safe. Evicts expired entries and overflow entries
59
+ beyond max_ids.
60
+
61
+ Args:
62
+ event_id: The event ID to record.
63
+ """
64
+ with self._lock:
65
+ now = time.monotonic()
66
+ self._evict_expired()
67
+
68
+ if event_id in self._seen:
69
+ self._seen.move_to_end(event_id)
70
+ self._seen[event_id] = now
71
+ return
72
+
73
+ self._seen[event_id] = now
74
+
75
+ while len(self._seen) > self._max_ids:
76
+ self._seen.popitem(last=False)
77
+
78
+ def _evict_expired(self) -> None:
79
+ """Remove entries older than window_seconds from the front.
80
+
81
+ Must be called while holding self._lock.
82
+ """
83
+ cutoff = time.monotonic() - self._window_seconds
84
+ while self._seen:
85
+ oldest_key = next(iter(self._seen))
86
+ if self._seen[oldest_key] < cutoff:
87
+ self._seen.popitem(last=False)
88
+ else:
89
+ break
90
+
91
+ @property
92
+ def size(self) -> int:
93
+ """Number of event IDs currently tracked."""
94
+ with self._lock:
95
+ return len(self._seen)
96
+
97
+ @property
98
+ def window_seconds(self) -> int:
99
+ """The time window for dedup."""
100
+ return self._window_seconds
101
+
102
+ @property
103
+ def max_ids(self) -> int:
104
+ """Maximum number of IDs tracked."""
105
+ return self._max_ids
106
+
107
+ def clear(self) -> None:
108
+ """Clear all tracked event IDs."""
109
+ with self._lock:
110
+ self._seen.clear()