stream-velocity 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.
- stream_velocity/__init__.py +5 -0
- stream_velocity/ring_buffer.py +88 -0
- stream_velocity/tracker.py +125 -0
- stream_velocity-0.1.0.dist-info/METADATA +193 -0
- stream_velocity-0.1.0.dist-info/RECORD +7 -0
- stream_velocity-0.1.0.dist-info/WHEEL +4 -0
- stream_velocity-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
|
|
3
|
+
class BucketedRingBuffer:
|
|
4
|
+
"""
|
|
5
|
+
Fixed-memory ring buffer using time-bucketed aggregation for constant-time O(1)
|
|
6
|
+
velocity calculations over a sliding time window.
|
|
7
|
+
|
|
8
|
+
Writes map timestamps to a slot index via integer division (O(1)). Reads return
|
|
9
|
+
pre-computed running totals after lazily expiring stale buckets, making both
|
|
10
|
+
operations constant-time regardless of the number of buckets in the window.
|
|
11
|
+
"""
|
|
12
|
+
__slots__ = (
|
|
13
|
+
'window_seconds', 'bucket_seconds', 'num_buckets',
|
|
14
|
+
'counts', 'sums', 'last_bucket_idx',
|
|
15
|
+
'running_count', 'running_sum', 'trailing_bucket',
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def __init__(self, window_seconds: int = 300, bucket_seconds: int = 5):
|
|
19
|
+
if window_seconds % bucket_seconds != 0:
|
|
20
|
+
raise ValueError("window_seconds must be evenly divisible by bucket_seconds")
|
|
21
|
+
|
|
22
|
+
self.window_seconds = window_seconds
|
|
23
|
+
self.bucket_seconds = bucket_seconds
|
|
24
|
+
self.num_buckets = window_seconds // bucket_seconds
|
|
25
|
+
|
|
26
|
+
self.counts = [0] * self.num_buckets
|
|
27
|
+
self.sums = [0.0] * self.num_buckets
|
|
28
|
+
self.last_bucket_idx = [-1] * self.num_buckets
|
|
29
|
+
|
|
30
|
+
self.running_count = 0
|
|
31
|
+
self.running_sum = 0.0
|
|
32
|
+
self.trailing_bucket = -1
|
|
33
|
+
|
|
34
|
+
def _get_current_slot(self, timestamp: float) -> tuple[int, int]:
|
|
35
|
+
total_buckets = int(timestamp // self.bucket_seconds)
|
|
36
|
+
slot_idx = total_buckets % self.num_buckets
|
|
37
|
+
return slot_idx, total_buckets
|
|
38
|
+
|
|
39
|
+
def _evict_stale(self, current_total_buckets: int) -> None:
|
|
40
|
+
min_valid_bucket = current_total_buckets - self.num_buckets + 1
|
|
41
|
+
|
|
42
|
+
# First observation: nothing can be stale yet, so fast-forward the
|
|
43
|
+
# trailing edge instead of scanning an empty buffer bucket-by-bucket.
|
|
44
|
+
if self.trailing_bucket == -1:
|
|
45
|
+
self.trailing_bucket = min_valid_bucket - 1
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
# Out-of-order guard: never regress the trailing edge backwards.
|
|
49
|
+
if min_valid_bucket <= self.trailing_bucket:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
while self.trailing_bucket < min_valid_bucket:
|
|
53
|
+
self.trailing_bucket += 1
|
|
54
|
+
slot_idx = self.trailing_bucket % self.num_buckets
|
|
55
|
+
if self.last_bucket_idx[slot_idx] == self.trailing_bucket:
|
|
56
|
+
self.running_count -= self.counts[slot_idx]
|
|
57
|
+
self.running_sum -= self.sums[slot_idx]
|
|
58
|
+
self.counts[slot_idx] = 0
|
|
59
|
+
self.sums[slot_idx] = 0.0
|
|
60
|
+
self.last_bucket_idx[slot_idx] = -1
|
|
61
|
+
|
|
62
|
+
def record(self, timestamp: float, amount: float = 0.0) -> None:
|
|
63
|
+
slot_idx, total_buckets = self._get_current_slot(timestamp)
|
|
64
|
+
self._evict_stale(total_buckets)
|
|
65
|
+
|
|
66
|
+
# Evict stale data if slot belongs to a previous window iteration.
|
|
67
|
+
if self.last_bucket_idx[slot_idx] != total_buckets:
|
|
68
|
+
if self.last_bucket_idx[slot_idx] != -1:
|
|
69
|
+
self.running_count -= self.counts[slot_idx]
|
|
70
|
+
self.running_sum -= self.sums[slot_idx]
|
|
71
|
+
self.counts[slot_idx] = 0
|
|
72
|
+
self.sums[slot_idx] = 0.0
|
|
73
|
+
self.last_bucket_idx[slot_idx] = total_buckets
|
|
74
|
+
|
|
75
|
+
self.counts[slot_idx] += 1
|
|
76
|
+
self.sums[slot_idx] += amount
|
|
77
|
+
self.running_count += 1
|
|
78
|
+
self.running_sum += amount
|
|
79
|
+
|
|
80
|
+
def get_metrics(self, current_timestamp: float) -> Dict[str, Any]:
|
|
81
|
+
current_total_buckets = int(current_timestamp // self.bucket_seconds)
|
|
82
|
+
self._evict_stale(current_total_buckets)
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
"window_seconds": self.window_seconds,
|
|
86
|
+
"count": self.running_count,
|
|
87
|
+
"sum_amount": self.running_sum,
|
|
88
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
from .ring_buffer import BucketedRingBuffer
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class VelocityResult:
|
|
9
|
+
"""A snapshot of one entity's rolling-window velocity at a point in time."""
|
|
10
|
+
|
|
11
|
+
entity_id: str
|
|
12
|
+
window_seconds: int
|
|
13
|
+
count: int
|
|
14
|
+
sum_amount: float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VelocityTracker:
|
|
18
|
+
"""
|
|
19
|
+
In-memory manager that tracks rolling velocity across many entities.
|
|
20
|
+
|
|
21
|
+
Memory is reclaimed lazily: when `idle_timeout` is set, any entity that has
|
|
22
|
+
not recorded an event for `idle_timeout` seconds is dropped on the next
|
|
23
|
+
`cleanup()` call or the periodic automatic sweep. This keeps the registry
|
|
24
|
+
bounded instead of growing forever with distinct entities.
|
|
25
|
+
|
|
26
|
+
`idle_timeout` should be at least as large as `window_seconds`; by the time
|
|
27
|
+
an entity has been idle for a full window, its counters are already empty,
|
|
28
|
+
so dropping it loses no current-velocity information.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
window_seconds: int = 300,
|
|
34
|
+
bucket_seconds: int = 5,
|
|
35
|
+
idle_timeout: Optional[float] = None,
|
|
36
|
+
cleanup_interval: float = 60.0,
|
|
37
|
+
):
|
|
38
|
+
if idle_timeout is not None and idle_timeout <= 0:
|
|
39
|
+
raise ValueError("idle_timeout must be greater than 0")
|
|
40
|
+
|
|
41
|
+
self.window_seconds = window_seconds
|
|
42
|
+
self.bucket_seconds = bucket_seconds
|
|
43
|
+
self.idle_timeout = idle_timeout
|
|
44
|
+
self.cleanup_interval = cleanup_interval
|
|
45
|
+
|
|
46
|
+
self.entities: Dict[str, BucketedRingBuffer] = {}
|
|
47
|
+
self._last_seen: Dict[str, float] = {}
|
|
48
|
+
self._last_cleanup: float = 0.0
|
|
49
|
+
|
|
50
|
+
def __len__(self) -> int:
|
|
51
|
+
return len(self.entities)
|
|
52
|
+
|
|
53
|
+
def record_event(
|
|
54
|
+
self,
|
|
55
|
+
entity_id: str,
|
|
56
|
+
amount: float = 0.0,
|
|
57
|
+
timestamp: Optional[float] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
if timestamp is None:
|
|
60
|
+
timestamp = time.time()
|
|
61
|
+
|
|
62
|
+
self._maybe_cleanup(timestamp)
|
|
63
|
+
|
|
64
|
+
ring = self.entities.get(entity_id)
|
|
65
|
+
if ring is None:
|
|
66
|
+
ring = BucketedRingBuffer(
|
|
67
|
+
window_seconds=self.window_seconds,
|
|
68
|
+
bucket_seconds=self.bucket_seconds,
|
|
69
|
+
)
|
|
70
|
+
self.entities[entity_id] = ring
|
|
71
|
+
|
|
72
|
+
ring.record(timestamp=timestamp, amount=amount)
|
|
73
|
+
self._last_seen[entity_id] = timestamp
|
|
74
|
+
|
|
75
|
+
def get_velocity(
|
|
76
|
+
self,
|
|
77
|
+
entity_id: str,
|
|
78
|
+
timestamp: Optional[float] = None,
|
|
79
|
+
) -> VelocityResult:
|
|
80
|
+
if timestamp is None:
|
|
81
|
+
timestamp = time.time()
|
|
82
|
+
|
|
83
|
+
self._maybe_cleanup(timestamp)
|
|
84
|
+
|
|
85
|
+
ring = self.entities.get(entity_id)
|
|
86
|
+
if ring is None:
|
|
87
|
+
return VelocityResult(
|
|
88
|
+
entity_id=entity_id,
|
|
89
|
+
window_seconds=self.window_seconds,
|
|
90
|
+
count=0,
|
|
91
|
+
sum_amount=0.0,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
metrics = ring.get_metrics(current_timestamp=timestamp)
|
|
95
|
+
return VelocityResult(
|
|
96
|
+
entity_id=entity_id,
|
|
97
|
+
window_seconds=metrics["window_seconds"],
|
|
98
|
+
count=metrics["count"],
|
|
99
|
+
sum_amount=metrics["sum_amount"],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def cleanup(self, now: Optional[float] = None) -> None:
|
|
103
|
+
"""Drop entities that have been idle longer than `idle_timeout`."""
|
|
104
|
+
if self.idle_timeout is None:
|
|
105
|
+
return
|
|
106
|
+
if now is None:
|
|
107
|
+
now = time.time()
|
|
108
|
+
|
|
109
|
+
expired = [
|
|
110
|
+
entity_id
|
|
111
|
+
for entity_id, seen in self._last_seen.items()
|
|
112
|
+
if now - seen > self.idle_timeout
|
|
113
|
+
]
|
|
114
|
+
for entity_id in expired:
|
|
115
|
+
del self.entities[entity_id]
|
|
116
|
+
del self._last_seen[entity_id]
|
|
117
|
+
|
|
118
|
+
self._last_cleanup = now
|
|
119
|
+
|
|
120
|
+
def _maybe_cleanup(self, now: float) -> None:
|
|
121
|
+
if self.idle_timeout is None:
|
|
122
|
+
return
|
|
123
|
+
if now - self._last_cleanup < self.cleanup_interval:
|
|
124
|
+
return
|
|
125
|
+
self.cleanup(now)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: stream-velocity
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A zero-dependency, bounded-memory Python library for sub-5ms sliding-window transaction velocity aggregations.
|
|
5
|
+
Project-URL: Homepage, https://github.com/lee14257/stream-velocity
|
|
6
|
+
Project-URL: Repository, https://github.com/lee14257/stream-velocity
|
|
7
|
+
Author: Woo Chan Lee
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: feature-store,fintech,fraud-detection,streaming,velocity
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# stream-velocity
|
|
24
|
+
|
|
25
|
+
[](https://opensource.org/licenses/MIT)
|
|
26
|
+
[](https://www.python.org/downloads/)
|
|
27
|
+
[](https://github.com/lee14257/stream-velocity/actions/workflows/test.yml)
|
|
28
|
+
|
|
29
|
+
`stream-velocity` is a small, dependency-free Python library that answers one question in real time: **"how many events happened in the last few minutes, and how much did they total?"** — per account, card, or any other entity.
|
|
30
|
+
|
|
31
|
+
It exists because small fraud and risk teams often need this exact check but can't justify a Redis cluster or a $10k/month streaming platform. `stream-velocity` gives them a fast, in-process alternative that uses a fixed amount of memory no matter how much data flows through it.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install -e .
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
> A public PyPI release is in progress. Until then, install from source as shown above.
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from stream_velocity import VelocityTracker
|
|
45
|
+
|
|
46
|
+
# Track a 5-minute window, split into 5-second buckets
|
|
47
|
+
tracker = VelocityTracker(window_seconds=300, bucket_seconds=5)
|
|
48
|
+
|
|
49
|
+
# Record incoming events for an account
|
|
50
|
+
tracker.record_event(entity_id="acc_990142", amount=150.00)
|
|
51
|
+
tracker.record_event(entity_id="acc_990142", amount=45.50)
|
|
52
|
+
tracker.record_event(entity_id="acc_990142", amount=210.00)
|
|
53
|
+
|
|
54
|
+
# Read the current rolling totals
|
|
55
|
+
velocity = tracker.get_velocity("acc_990142")
|
|
56
|
+
|
|
57
|
+
print(velocity.count) # 3
|
|
58
|
+
print(velocity.sum_amount) # 405.5
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## API Reference
|
|
62
|
+
|
|
63
|
+
### `VelocityTracker`
|
|
64
|
+
|
|
65
|
+
The main entry point. Tracks rolling velocity across many entities.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
VelocityTracker(
|
|
69
|
+
window_seconds=300,
|
|
70
|
+
bucket_seconds=5,
|
|
71
|
+
idle_timeout=None,
|
|
72
|
+
cleanup_interval=60.0,
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Parameter | Type | Default | Description |
|
|
77
|
+
| --- | --- | --- | --- |
|
|
78
|
+
| `window_seconds` | `int` | `300` | Length of the rolling window, in seconds. |
|
|
79
|
+
| `bucket_seconds` | `int` | `5` | Width of each bucket, in seconds. `window_seconds` must be evenly divisible by `bucket_seconds`. |
|
|
80
|
+
| `idle_timeout` | `float` or `None` | `None` | Drop an entity if it records no events for this many seconds. `None` disables eviction. Should be at least `window_seconds`. |
|
|
81
|
+
| `cleanup_interval` | `float` | `60.0` | How often (seconds) the idle sweep runs automatically. |
|
|
82
|
+
|
|
83
|
+
**Methods**
|
|
84
|
+
|
|
85
|
+
- `record_event(entity_id, amount=0.0, timestamp=None)` → `None` — record one event.
|
|
86
|
+
- `timestamp` defaults to the current time; pass an explicit value for replay or testing.
|
|
87
|
+
- `get_velocity(entity_id, timestamp=None)` → `VelocityResult` — current rolling totals for an entity. Returns zeros if the entity is unknown or has been evicted.
|
|
88
|
+
- `cleanup(now=None)` → `None` — immediately drop entities idle longer than `idle_timeout`. The sweep also runs automatically every `cleanup_interval` seconds.
|
|
89
|
+
- `len(tracker)` → `int` — number of entities currently tracked.
|
|
90
|
+
|
|
91
|
+
### `VelocityResult`
|
|
92
|
+
|
|
93
|
+
A frozen (immutable) dataclass returned by `get_velocity`.
|
|
94
|
+
|
|
95
|
+
| Field | Type | Description |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| `entity_id` | `str` | The entity the result is for. |
|
|
98
|
+
| `window_seconds` | `int` | The window length used. |
|
|
99
|
+
| `count` | `int` | Number of events in the window. |
|
|
100
|
+
| `sum_amount` | `float` | Sum of `amount` values in the window. |
|
|
101
|
+
|
|
102
|
+
### `BucketedRingBuffer` (lower-level)
|
|
103
|
+
|
|
104
|
+
The underlying single-entity counter. Prefer `VelocityTracker` unless you need to manage one entity manually.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
buffer = BucketedRingBuffer(window_seconds=300, bucket_seconds=5)
|
|
108
|
+
buffer.record(timestamp=1_000_000.0, amount=10.0)
|
|
109
|
+
metrics = buffer.get_metrics(current_timestamp=1_000_010.0) # {"window_seconds": 300, "count": 1, "sum_amount": 10.0}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Features
|
|
113
|
+
|
|
114
|
+
- **No dependencies** — pure Python 3.9+, no database, no C extensions.
|
|
115
|
+
- **Always fast** — updates and lookups take the same time whether you process 10 or 10 million events (this is what "O(1)" means).
|
|
116
|
+
- **Fixed memory** — counters are created once and reused forever, so memory use stays flat no matter how many events arrive.
|
|
117
|
+
- **Bounded tracking** — set `idle_timeout` to drop idle entities automatically, so millions of distinct accounts don't grow memory forever.
|
|
118
|
+
- **Low latency** — reads stay under 5 ms while processing 100,000+ events.
|
|
119
|
+
|
|
120
|
+
## Limitations
|
|
121
|
+
|
|
122
|
+
- **Single process** — the tracker lives in memory and is not shared across processes or machines. Run one instance per worker, or pair it with an external store for distributed setups.
|
|
123
|
+
- **Not persistent** — state is lost on restart; there is no disk or external storage.
|
|
124
|
+
- **Not thread-safe** — use one tracker per thread or worker, or add external locking if you share a tracker across threads.
|
|
125
|
+
|
|
126
|
+
## How It Works
|
|
127
|
+
|
|
128
|
+
A rolling window is split into a fixed number of buckets. For a 5-minute window with 5-second buckets, that's 60 buckets.
|
|
129
|
+
|
|
130
|
+
- **Finding the bucket** — an event's timestamp maps to a bucket with one division: `slot = (timestamp // bucket_seconds) % num_buckets`.
|
|
131
|
+
- **Reusing buckets** — as time passes, expired buckets are wiped clean and reused, so the total number of buckets never changes.
|
|
132
|
+
- **Running totals** — two totals (count and amount) are kept up to date on every write, so reading the answer means just returning the totals — no scanning.
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
events ──► slot = (timestamp // 5) % 60
|
|
136
|
+
│
|
|
137
|
+
▼
|
|
138
|
+
┌───┬───┬───┬───┬───┬───┬───┐
|
|
139
|
+
│ 0 │ 1 │ 2 │...│ 57│ 58│ 59│ ◄── fixed ring of buckets, reused forever
|
|
140
|
+
└───┴───┴───┴───┴───┴───┴───┘
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Only counts and sums are stored — no raw transaction details.
|
|
144
|
+
|
|
145
|
+
## Benchmarks
|
|
146
|
+
|
|
147
|
+
### Latency
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
python3 tests/benchmark_latency.py
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Representative output (Apple M-series MacBook Air, Python 3.10). Absolute timings vary by machine; the `< 5.0 ms` SLA is enforced in CI on every push.
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
--- Running stream-velocity benchmark: 100,000 events ---
|
|
157
|
+
Total write time for 100,000 events: 61.88 ms
|
|
158
|
+
Average write latency per event: 0.62 µs
|
|
159
|
+
Read query latency: 0.0418 ms
|
|
160
|
+
Aggregation result: 100000 events, $1,000,000.00
|
|
161
|
+
SLA Target Passed: Query execution < 5.0 ms
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Memory
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
python3 tests/benchmark_memory.py
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The library creates its counters once and reuses them, so memory stays flat as events pile up. A typical 5-minute window uses only a few kilobytes — far below the 100 MB ceiling a lightweight fraud service can run within.
|
|
171
|
+
|
|
172
|
+
## Docker
|
|
173
|
+
|
|
174
|
+
A minimal Alpine image installs the library and runs the speed benchmark:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
docker build -t stream-velocity .
|
|
178
|
+
docker run --rm stream-velocity
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Development
|
|
182
|
+
|
|
183
|
+
Run the test suite and benchmarks:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
python3 -m unittest discover -s tests
|
|
187
|
+
python3 tests/benchmark_latency.py
|
|
188
|
+
python3 tests/benchmark_memory.py
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
stream_velocity/__init__.py,sha256=pNlQAL9nTE_q3gcc_x5uspFsgGqz4--JKkP0vh4XWFw,190
|
|
2
|
+
stream_velocity/ring_buffer.py,sha256=2LOuOSM_YE4EYK2vdX5Cr-7I7oyH5xanEk_vOaT8y7U,3631
|
|
3
|
+
stream_velocity/tracker.py,sha256=vlyAdRL1s1fmdOHWi40a7OLynKaFmiuz33KzbLdyCis,3853
|
|
4
|
+
stream_velocity-0.1.0.dist-info/METADATA,sha256=qEH5xC3yk13uJKPiX5wfnim1VJAHXL8CTRMgyzX-5pY,7908
|
|
5
|
+
stream_velocity-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
6
|
+
stream_velocity-0.1.0.dist-info/licenses/LICENSE,sha256=Rz_5tPzKwMf6dsCSkzX0DyVVkYrnk_EA3vsASoCYflw,1069
|
|
7
|
+
stream_velocity-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Woo Chan Lee
|
|
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.
|