oluso 1.0.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.
oluso/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Oluso: AI-powered error monitoring for Python applications."""
2
+
3
+ from .client import Oluso
4
+ from .context import add_breadcrumb, scope, set_custom_context, set_user
5
+ from .options import Options
6
+ from .types import (
7
+ Breadcrumb,
8
+ BreadcrumbLevel,
9
+ ErrorContext,
10
+ ErrorReport,
11
+ RequestContext,
12
+ ServerContext,
13
+ Severity,
14
+ UserContext,
15
+ )
16
+
17
+ __version__ = "1.0.0"
18
+
19
+ __all__ = [
20
+ "Oluso",
21
+ "Options",
22
+ "scope",
23
+ "add_breadcrumb",
24
+ "set_user",
25
+ "set_custom_context",
26
+ "Breadcrumb",
27
+ "BreadcrumbLevel",
28
+ "ErrorContext",
29
+ "ErrorReport",
30
+ "RequestContext",
31
+ "ServerContext",
32
+ "Severity",
33
+ "UserContext",
34
+ ]
oluso/client.py ADDED
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import queue
5
+ import threading
6
+ import time
7
+ import traceback
8
+ from typing import Any, Dict, Optional, Tuple
9
+
10
+ from .context import _snapshot
11
+ from .fingerprint import generate_fingerprint
12
+ from .options import Options
13
+ from .queue import OfflineQueue
14
+ from .rate_limiter import RateLimiter
15
+ from .sanitizer import Sanitizer
16
+ from .server_context import get_server_context
17
+ from .transport import TransportError, send_error_report
18
+ from .types import ErrorContext, ErrorReport, RequestContext, Severity
19
+
20
+ logger = logging.getLogger("oluso")
21
+
22
+
23
+ class Oluso:
24
+ """Reports errors to Oluso.
25
+
26
+ Sends run on a background daemon thread (tracked so `flush()` can drain
27
+ them before shutdown) rather than blocking the calling thread on network
28
+ I/O, since capture calls typically sit on a request-handling hot path.
29
+ """
30
+
31
+ def __init__(self, options: Options) -> None:
32
+ if not options.api_key:
33
+ raise ValueError("oluso: api_key is required")
34
+ self._options = options
35
+ self._sanitizer = Sanitizer(options.sensitive_keys)
36
+ self._rate_limiter = RateLimiter(options.max_errors_per_minute)
37
+ self._offline_queue = OfflineQueue(options.max_queue_size, options.queue_dir)
38
+
39
+ self._send_queue: "queue.Queue[Dict[str, Any]]" = queue.Queue()
40
+ self._pending = 0
41
+ self._pending_lock = threading.Lock()
42
+ self._all_sent = threading.Condition(self._pending_lock)
43
+ self._worker = threading.Thread(target=self._worker_loop, name="oluso-sender", daemon=True)
44
+ self._worker.start()
45
+
46
+ @property
47
+ def max_breadcrumbs(self) -> int:
48
+ """The configured max breadcrumbs per scope, for framework
49
+ integrations that open a scope themselves.
50
+ """
51
+ return self._options.max_breadcrumbs
52
+
53
+ @property
54
+ def sanitizer(self) -> Sanitizer:
55
+ """The Sanitizer configured for this client (respects
56
+ Options.sensitive_keys), for framework integrations that build a
57
+ RequestContext manually.
58
+ """
59
+ return self._sanitizer
60
+
61
+ def capture_exception(
62
+ self, error: BaseException, custom_context: Optional[Dict[str, Any]] = None
63
+ ) -> None:
64
+ """Report error, attaching any breadcrumbs/user/custom context on the
65
+ current scope (see `oluso.scope`), plus any custom_context given
66
+ here.
67
+ """
68
+ self._capture(error, http_info=None, custom_context=custom_context)
69
+
70
+ def capture_http_error(
71
+ self,
72
+ error: BaseException,
73
+ request_context: RequestContext,
74
+ status_code: int,
75
+ custom_context: Optional[Dict[str, Any]] = None,
76
+ ) -> None:
77
+ """Like capture_exception but also attaches HTTP request context and
78
+ a response status code, for reporting an error with request context
79
+ from a framework integration.
80
+ """
81
+ self._capture(error, http_info=(request_context, status_code), custom_context=custom_context)
82
+
83
+ def _capture(
84
+ self,
85
+ error: BaseException,
86
+ http_info: Optional[Tuple[RequestContext, int]],
87
+ custom_context: Optional[Dict[str, Any]],
88
+ ) -> None:
89
+ if error is None:
90
+ return
91
+ if self._options.should_report and not self._options.should_report(error):
92
+ return
93
+ if not self._rate_limiter.can_send():
94
+ if self._options.log_to_console:
95
+ logger.warning("[Oluso] rate limit exceeded, error not reported")
96
+ return
97
+ if self._options.log_to_console:
98
+ logger.error("[Oluso] %s", error)
99
+
100
+ stack_trace = "".join(
101
+ traceback.format_exception(type(error), error, error.__traceback__)
102
+ )
103
+ err_ctx = self._build_error_context(http_info, custom_context)
104
+
105
+ if self._options.fingerprint:
106
+ fingerprint = self._options.fingerprint(error, err_ctx)
107
+ else:
108
+ fingerprint = generate_fingerprint(error, stack_trace)
109
+
110
+ severity = self._options.default_severity
111
+ if http_info is not None:
112
+ _, status_code = http_info
113
+ if status_code >= 500:
114
+ severity = Severity.CRITICAL
115
+ elif status_code >= 400:
116
+ severity = Severity.HIGH
117
+
118
+ report = ErrorReport(
119
+ title=_error_title(error),
120
+ message=str(error),
121
+ stack_trace=stack_trace,
122
+ environment=self._options.environment,
123
+ severity=severity.value if isinstance(severity, Severity) else severity,
124
+ tags=list(self._options.tags),
125
+ fingerprint=fingerprint,
126
+ context=err_ctx,
127
+ timestamp=int(time.time() * 1000),
128
+ )
129
+
130
+ with self._pending_lock:
131
+ self._pending += 1
132
+ self._send_queue.put(report.to_dict())
133
+
134
+ def _build_error_context(
135
+ self,
136
+ http_info: Optional[Tuple[RequestContext, int]],
137
+ custom_context: Optional[Dict[str, Any]],
138
+ ) -> ErrorContext:
139
+ breadcrumbs, user, custom = _snapshot()
140
+
141
+ if custom_context:
142
+ custom = {**custom, **custom_context}
143
+
144
+ err_ctx = ErrorContext(
145
+ server=get_server_context(),
146
+ user=user,
147
+ custom=custom,
148
+ breadcrumbs=breadcrumbs,
149
+ )
150
+
151
+ if http_info is not None:
152
+ err_ctx.request = http_info[0]
153
+
154
+ return err_ctx
155
+
156
+ def _worker_loop(self) -> None:
157
+ while True:
158
+ report = self._send_queue.get()
159
+ try:
160
+ self._send_report(report)
161
+ finally:
162
+ with self._pending_lock:
163
+ self._pending -= 1
164
+ if self._pending <= 0:
165
+ self._all_sent.notify_all()
166
+ self._send_queue.task_done()
167
+
168
+ def _send_report(self, report: Dict[str, Any]) -> None:
169
+ try:
170
+ send_error_report(
171
+ self._options.endpoint, report, self._options.api_key, self._options.timeout
172
+ )
173
+ except TransportError as e:
174
+ if self._options.log_to_console:
175
+ logger.error("[Oluso] failed to send error report: %s", e)
176
+ if self._options.enable_offline_queue:
177
+ self._offline_queue.enqueue(report)
178
+ return
179
+
180
+ if self._options.enable_offline_queue and not self._offline_queue.is_empty():
181
+ self._offline_queue.process_queue(
182
+ lambda r: send_error_report(
183
+ self._options.endpoint, r, self._options.api_key, self._options.timeout
184
+ )
185
+ )
186
+
187
+ def flush(self, timeout: Optional[float] = None) -> bool:
188
+ """Block until all in-flight and queued reports have been sent, or
189
+ timeout elapses (None waits indefinitely). Returns True if everything
190
+ was flushed before the timeout. Call this before your process exits
191
+ so a capture right before shutdown isn't lost.
192
+ """
193
+ with self._pending_lock:
194
+ deadline = None if timeout is None else time.time() + timeout
195
+ while self._pending > 0:
196
+ remaining = None if deadline is None else max(0.0, deadline - time.time())
197
+ if remaining == 0.0:
198
+ return False
199
+ if not self._all_sent.wait(remaining):
200
+ return False
201
+
202
+ if self._options.enable_offline_queue and not self._offline_queue.is_empty():
203
+ self._offline_queue.process_queue(
204
+ lambda r: send_error_report(
205
+ self._options.endpoint, r, self._options.api_key, self._options.timeout
206
+ )
207
+ )
208
+ return True
209
+
210
+
211
+ def _error_title(error: BaseException) -> str:
212
+ message = str(error)
213
+ first_line = message.splitlines()[0].strip() if message else ""
214
+ if first_line:
215
+ return first_line if len(first_line) <= 100 else first_line[:97] + "..."
216
+ return f"{type(error).__name__} error"
oluso/context.py ADDED
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import contextvars
4
+ import threading
5
+ import time
6
+ from contextlib import contextmanager
7
+ from typing import Any, Dict, Iterator, List, Optional, Tuple
8
+
9
+ from .types import Breadcrumb, BreadcrumbLevel, UserContext
10
+
11
+ _current_scope: "contextvars.ContextVar[Optional[_Scope]]" = contextvars.ContextVar(
12
+ "oluso_scope", default=None
13
+ )
14
+
15
+
16
+ class _Scope:
17
+ def __init__(self, max_breadcrumbs: int) -> None:
18
+ self._lock = threading.Lock()
19
+ self._max_breadcrumbs = max_breadcrumbs
20
+ self.breadcrumbs: List[Breadcrumb] = []
21
+ self.user: Optional[UserContext] = None
22
+ self.custom: Dict[str, Any] = {}
23
+ self.request_start: Optional[float] = None
24
+
25
+ def add_breadcrumb(self, breadcrumb: Breadcrumb) -> None:
26
+ with self._lock:
27
+ breadcrumb.timestamp = time.time()
28
+ self.breadcrumbs.append(breadcrumb)
29
+ if len(self.breadcrumbs) > self._max_breadcrumbs:
30
+ self.breadcrumbs.pop(0)
31
+
32
+ def set_user(self, user: UserContext) -> None:
33
+ with self._lock:
34
+ self.user = user
35
+
36
+ def set_custom(self, key: str, value: Any) -> None:
37
+ with self._lock:
38
+ self.custom[key] = value
39
+
40
+ def snapshot(self) -> Tuple[List[Breadcrumb], Optional[UserContext], Dict[str, Any]]:
41
+ with self._lock:
42
+ return list(self.breadcrumbs), self.user, dict(self.custom)
43
+
44
+
45
+ @contextmanager
46
+ def scope(max_breadcrumbs: int = 30) -> Iterator[None]:
47
+ """Context manager establishing an isolated breadcrumb/user/custom-data
48
+ scope for the code inside it -- Python's equivalent of the per-request
49
+ scope other Oluso SDKs build on Node's AsyncLocalStorage or Go's
50
+ context.Context, implemented with contextvars instead, since that's the
51
+ idiomatic mechanism for request/task-scoped state in Python (correctly
52
+ isolated per thread AND per asyncio task).
53
+
54
+ Framework integrations call this for you; call it yourself for
55
+ non-request work (a background job, a CLI command) where you still want
56
+ breadcrumbs/user context scoped to that one unit of work::
57
+
58
+ with oluso.scope():
59
+ oluso.add_breadcrumb("job started")
60
+ client.capture_exception(err)
61
+ """
62
+ token = _current_scope.set(_Scope(max_breadcrumbs))
63
+ try:
64
+ yield
65
+ finally:
66
+ _current_scope.reset(token)
67
+
68
+
69
+ def add_breadcrumb(
70
+ message: str,
71
+ level: BreadcrumbLevel = BreadcrumbLevel.INFO,
72
+ category: Optional[str] = None,
73
+ data: Optional[Dict[str, Any]] = None,
74
+ ) -> None:
75
+ """Record a breadcrumb on the current scope. A no-op outside a scope()."""
76
+ s = _current_scope.get()
77
+ if s is None:
78
+ return
79
+ s.add_breadcrumb(Breadcrumb(message=message, level=level, category=category, data=data))
80
+
81
+
82
+ def set_user(user: UserContext) -> None:
83
+ """Set the user context on the current scope. A no-op outside a scope()."""
84
+ s = _current_scope.get()
85
+ if s is not None:
86
+ s.set_user(user)
87
+
88
+
89
+ def set_custom_context(key: str, value: Any) -> None:
90
+ """Set a custom key/value on the current scope. A no-op outside a scope()."""
91
+ s = _current_scope.get()
92
+ if s is not None:
93
+ s.set_custom(key, value)
94
+
95
+
96
+ def _set_request_start_time(t: float) -> None:
97
+ s = _current_scope.get()
98
+ if s is not None:
99
+ s.request_start = t
100
+
101
+
102
+ def _get_request_start_time() -> Optional[float]:
103
+ s = _current_scope.get()
104
+ return s.request_start if s is not None else None
105
+
106
+
107
+ def _snapshot() -> Tuple[List[Breadcrumb], Optional[UserContext], Dict[str, Any]]:
108
+ s = _current_scope.get()
109
+ if s is None:
110
+ return [], None, {}
111
+ return s.snapshot()
oluso/fingerprint.py ADDED
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ from typing import Optional
6
+
7
+ _RE_NUMBER = re.compile(r"\d+")
8
+ _RE_UUID = re.compile(
9
+ r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE
10
+ )
11
+ _RE_PATH = re.compile(r"/[\w/.\-]+")
12
+ _RE_URL = re.compile(r"https?://\S+")
13
+ _RE_SPACE = re.compile(r"\s+")
14
+
15
+
16
+ def generate_fingerprint(error: BaseException, stack_trace: Optional[str] = None) -> str:
17
+ """Produce a stable identifier for grouping similar errors together, from
18
+ the error's type, a normalized version of its message (with dynamic
19
+ values like IDs and paths stripped), and -- if provided -- a stack trace
20
+ signature.
21
+ """
22
+ components = [type(error).__name__, _normalize_message(str(error))]
23
+ if stack_trace:
24
+ components.append(_stack_signature(stack_trace))
25
+
26
+ digest = hashlib.sha256("|".join(components).encode("utf-8")).hexdigest()
27
+ return digest[:8]
28
+
29
+
30
+ def _normalize_message(message: str) -> str:
31
+ message = _RE_NUMBER.sub("N", message)
32
+ message = _RE_UUID.sub("UUID", message)
33
+ message = _RE_PATH.sub("PATH", message)
34
+ message = _RE_URL.sub("URL", message)
35
+ message = _RE_SPACE.sub(" ", message)
36
+ return message.strip()
37
+
38
+
39
+ def _stack_signature(stack: str, limit: int = 6) -> str:
40
+ """Extract just the call-site lines from a traceback (skipping the
41
+ indented "File ..., line N" detail lines Python's traceback module
42
+ produces), so the signature groups the same logical call site together
43
+ even as line numbers shift.
44
+ """
45
+ frames = []
46
+ for line in stack.splitlines():
47
+ stripped = line.strip()
48
+ if not stripped or stripped.startswith("File ") or stripped.startswith("Traceback"):
49
+ continue
50
+ frames.append(stripped)
51
+ if len(frames) >= limit:
52
+ break
53
+ return "->".join(frames)
File without changes
@@ -0,0 +1,151 @@
1
+ """Oluso integration for Flask (and any other WSGI application).
2
+
3
+ Two hooks are combined here, deliberately:
4
+
5
+ - A WSGI middleware wrapping the whole app, since that gives one
6
+ synchronous call frame per request -- the natural place to open a single
7
+ `oluso.scope()` context manager around it, and to catch 5xx responses that
8
+ don't come from a raised exception (e.g. a view returning `"", 500`
9
+ directly). As a side effect, this works for any WSGI app, not just Flask.
10
+ - Flask's `got_request_exception` signal, since Flask/Werkzeug catches an
11
+ unhandled view exception *internally* and converts it to a 500 response
12
+ before it would ever reach the outer WSGI middleware's `except` clause --
13
+ without this signal, only a synthetic "server error: 500" message would
14
+ be reported instead of the real exception.
15
+
16
+ Both paths mark the request as already-reported (via the WSGI environ, which
17
+ both share) so a raised exception is never double-reported.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import time
23
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
24
+ from urllib.parse import parse_qs
25
+
26
+ from ..client import Oluso
27
+ from ..context import _get_request_start_time, _set_request_start_time, add_breadcrumb, scope
28
+ from ..types import BreadcrumbLevel, RequestContext
29
+
30
+ StartResponse = Callable[..., Any]
31
+ WSGIApp = Callable[[Dict[str, Any], StartResponse], Iterable[bytes]]
32
+
33
+ _REPORTED_KEY = "oluso.reported"
34
+
35
+
36
+ class OlusoMiddleware:
37
+ """WSGI middleware: scopes breadcrumbs to each request and auto-reports
38
+ 5xx responses that aren't already covered by the `got_request_exception`
39
+ signal (see module docstring). Lets exceptions propagate normally
40
+ afterward -- this only observes and reports, it doesn't change how your
41
+ app responds to errors.
42
+
43
+ Note: status is captured from the synchronous `start_response` call, so
44
+ a view that defers setting its status code until a streamed response
45
+ body is iterated (uncommon in typical Flask apps) won't be captured
46
+ accurately.
47
+ """
48
+
49
+ def __init__(self, wsgi_app: WSGIApp, client: Oluso) -> None:
50
+ self.wsgi_app = wsgi_app
51
+ self.client = client
52
+
53
+ def __call__(self, environ: Dict[str, Any], start_response: StartResponse) -> Iterable[bytes]:
54
+ with scope(self.client.max_breadcrumbs):
55
+ _set_request_start_time(time.time())
56
+ method = environ.get("REQUEST_METHOD", "")
57
+ path = environ.get("PATH_INFO", "")
58
+
59
+ add_breadcrumb(
60
+ message=f"{method} {path}",
61
+ level=BreadcrumbLevel.INFO,
62
+ category="http",
63
+ data={"method": method, "url": path},
64
+ )
65
+
66
+ status_holder: Dict[str, int] = {}
67
+
68
+ def _start_response(status: str, headers: List[Tuple[str, str]], exc_info: Any = None) -> Any:
69
+ status_holder["code"] = _parse_status_code(status)
70
+ return start_response(status, headers, exc_info)
71
+
72
+ try:
73
+ result = self.wsgi_app(environ, _start_response)
74
+ except Exception as exc:
75
+ if not environ.get(_REPORTED_KEY):
76
+ req_ctx = _build_request_context(self.client, environ)
77
+ self.client.capture_http_error(exc, req_ctx, 500)
78
+ raise
79
+
80
+ status = status_holder.get("code", 200)
81
+ if status >= 500 and not environ.get(_REPORTED_KEY):
82
+ req_ctx = _build_request_context(self.client, environ)
83
+ error = RuntimeError(f"server error: {status} - {method} {path}")
84
+ self.client.capture_http_error(error, req_ctx, status)
85
+
86
+ add_breadcrumb(
87
+ message=f"Response {status} - {method} {path}",
88
+ level=BreadcrumbLevel.ERROR if status >= 400 else BreadcrumbLevel.INFO,
89
+ category="http",
90
+ data={"statusCode": status},
91
+ )
92
+
93
+ return result
94
+
95
+
96
+ def init_app(app: Any, client: Oluso) -> None:
97
+ """Register Oluso on a Flask app: `oluso.integrations.flask.init_app(app, client)`."""
98
+ app.wsgi_app = OlusoMiddleware(app.wsgi_app, client)
99
+
100
+ from flask import got_request_exception # local import: flask is an optional dependency
101
+
102
+ def _on_exception(sender: Any, exception: BaseException, **extra: Any) -> None:
103
+ from flask import request
104
+
105
+ request.environ[_REPORTED_KEY] = True
106
+ req_ctx = _build_request_context(client, request.environ)
107
+ client.capture_http_error(exception, req_ctx, 500)
108
+
109
+ got_request_exception.connect(_on_exception, app, weak=False)
110
+
111
+
112
+ def _parse_status_code(status: str) -> int:
113
+ try:
114
+ return int(status.split(" ", 1)[0])
115
+ except (ValueError, IndexError):
116
+ return 200
117
+
118
+
119
+ def _build_request_context(client: Oluso, environ: Dict[str, Any]) -> RequestContext:
120
+ method = environ.get("REQUEST_METHOD", "")
121
+ path = environ.get("PATH_INFO", "")
122
+ query_string = environ.get("QUERY_STRING", "")
123
+
124
+ headers: Dict[str, str] = {}
125
+ for key, value in environ.items():
126
+ if key.startswith("HTTP_"):
127
+ headers[key[5:].replace("_", "-").title()] = value
128
+ if environ.get("CONTENT_TYPE"):
129
+ headers["Content-Type"] = environ["CONTENT_TYPE"]
130
+ if environ.get("CONTENT_LENGTH"):
131
+ headers["Content-Length"] = environ["CONTENT_LENGTH"]
132
+
133
+ query: Dict[str, Any] = {}
134
+ if query_string:
135
+ for key, values in parse_qs(query_string).items():
136
+ query[key] = values[0] if len(values) == 1 else values
137
+
138
+ response_time_ms: Optional[int] = None
139
+ start = _get_request_start_time()
140
+ if start is not None:
141
+ response_time_ms = int((time.time() - start) * 1000)
142
+
143
+ return RequestContext(
144
+ url=path,
145
+ method=method,
146
+ headers=client.sanitizer.sanitize_headers(headers),
147
+ query=client.sanitizer.sanitize_query(query),
148
+ ip=environ.get("REMOTE_ADDR"),
149
+ user_agent=environ.get("HTTP_USER_AGENT"),
150
+ response_time_ms=response_time_ms,
151
+ )
oluso/options.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Optional
5
+
6
+ from .types import FingerprintFunc, Severity, ShouldReportFunc
7
+
8
+ DEFAULT_ENDPOINT = "https://api.oluso.dev/api/v1/error/report"
9
+
10
+
11
+ @dataclass
12
+ class Options:
13
+ """Configures an :class:`~oluso.client.Oluso` client."""
14
+
15
+ # API key for authentication (sent as the x-oluso-signature header). Required.
16
+ api_key: str
17
+
18
+ # Override the ingestion endpoint. Useful for self-hosting.
19
+ endpoint: str = DEFAULT_ENDPOINT
20
+
21
+ environment: str = "production"
22
+ default_severity: Severity = Severity.MEDIUM
23
+ tags: List[str] = field(default_factory=list)
24
+
25
+ should_report: Optional[ShouldReportFunc] = None
26
+ fingerprint: Optional[FingerprintFunc] = None
27
+
28
+ # Timeout in seconds for each report request.
29
+ timeout: float = 5.0
30
+
31
+ log_to_console: bool = True
32
+
33
+ max_breadcrumbs: int = 30
34
+
35
+ enable_offline_queue: bool = True
36
+ max_queue_size: int = 100
37
+ # Override where the offline queue is persisted. Defaults to
38
+ # <tempdir>/oluso-queue.
39
+ queue_dir: Optional[str] = None
40
+
41
+ max_errors_per_minute: int = 60
42
+ sensitive_keys: List[str] = field(default_factory=list)
oluso/queue.py ADDED
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import Any, Callable, Dict, List, Optional
10
+
11
+
12
+ @dataclass
13
+ class _QueuedReport:
14
+ report: Dict[str, Any]
15
+ timestamp: float
16
+ retries: int = 0
17
+
18
+
19
+ class OfflineQueue:
20
+ """Persists error reports that failed to send, for retry on the next
21
+ successful send.
22
+ """
23
+
24
+ def __init__(self, max_size: int = 100, queue_dir: Optional[str] = None) -> None:
25
+ self._max_size = max_size if max_size > 0 else 100
26
+ directory = queue_dir or os.path.join(tempfile.gettempdir(), "oluso-queue")
27
+ os.makedirs(directory, exist_ok=True)
28
+ self._file_path = os.path.join(directory, "error-queue.json")
29
+
30
+ self._lock = threading.Lock()
31
+ self._queue: List[_QueuedReport] = []
32
+ self._load()
33
+
34
+ def enqueue(self, report: Dict[str, Any]) -> None:
35
+ """report is an already-serialized report (see ErrorReport.to_dict())."""
36
+ with self._lock:
37
+ self._queue.append(_QueuedReport(report=report, timestamp=time.time()))
38
+ if len(self._queue) > self._max_size:
39
+ self._queue.pop(0)
40
+ self._save()
41
+
42
+ def size(self) -> int:
43
+ with self._lock:
44
+ return len(self._queue)
45
+
46
+ def is_empty(self) -> bool:
47
+ return self.size() == 0
48
+
49
+ def clear(self) -> None:
50
+ with self._lock:
51
+ self._queue = []
52
+ self._save()
53
+
54
+ def _load(self) -> None:
55
+ try:
56
+ with open(self._file_path, "r", encoding="utf-8") as f:
57
+ raw = json.load(f)
58
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
59
+ return
60
+
61
+ cutoff = time.time() - 24 * 60 * 60
62
+ self._queue = [
63
+ _QueuedReport(report=item["report"], timestamp=item["timestamp"], retries=item.get("retries", 0))
64
+ for item in raw
65
+ if item.get("timestamp", 0) > cutoff
66
+ ]
67
+
68
+ def _save(self) -> None:
69
+ """Must be called with self._lock held."""
70
+ try:
71
+ payload = [
72
+ {"report": q.report, "timestamp": q.timestamp, "retries": q.retries} for q in self._queue
73
+ ]
74
+ with open(self._file_path, "w", encoding="utf-8") as f:
75
+ json.dump(payload, f)
76
+ except OSError:
77
+ pass
78
+
79
+ def process_queue(self, send_fn: Callable[[dict], None]) -> None:
80
+ """Attempt to send each queued report in order via send_fn (raises on
81
+ failure), stopping at the first failure (which is requeued at the
82
+ front with an incremented retry count, and dropped after 3 failed
83
+ attempts).
84
+
85
+ send_fn is called without holding the queue's lock, so enqueue() from
86
+ another thread isn't blocked for the duration of a slow or hanging
87
+ send.
88
+ """
89
+ while True:
90
+ with self._lock:
91
+ if not self._queue:
92
+ return
93
+ item = self._queue[0]
94
+
95
+ try:
96
+ send_fn(item.report)
97
+ except Exception:
98
+ with self._lock:
99
+ if not self._queue or self._queue[0] is not item:
100
+ return # queue mutated concurrently; bail out safely
101
+ item.retries += 1
102
+ if item.retries >= 3:
103
+ self._queue.pop(0)
104
+ self._save()
105
+ return
106
+
107
+ with self._lock:
108
+ if not self._queue or self._queue[0] is not item:
109
+ return
110
+ self._queue.pop(0)
111
+ self._save()
oluso/rate_limiter.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ from typing import Callable, List
6
+
7
+
8
+ class RateLimiter:
9
+ """Caps how many errors are reported within a rolling one-minute window."""
10
+
11
+ def __init__(self, max_per_minute: int = 60, now: Callable[[], float] = time.time) -> None:
12
+ self._max_per_minute = max_per_minute if max_per_minute > 0 else 60
13
+ self._now = now
14
+ self._timestamps: List[float] = []
15
+ self._lock = threading.Lock()
16
+
17
+ def can_send(self) -> bool:
18
+ with self._lock:
19
+ now = self._now()
20
+ cutoff = now - 60
21
+ self._timestamps = [ts for ts in self._timestamps if ts > cutoff]
22
+
23
+ if len(self._timestamps) < self._max_per_minute:
24
+ self._timestamps.append(now)
25
+ return True
26
+ return False
27
+
28
+ def count(self) -> int:
29
+ with self._lock:
30
+ cutoff = self._now() - 60
31
+ self._timestamps = [ts for ts in self._timestamps if ts > cutoff]
32
+ return len(self._timestamps)
33
+
34
+ def reset(self) -> None:
35
+ with self._lock:
36
+ self._timestamps = []
oluso/sanitizer.py ADDED
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Dict, Iterable, Optional
5
+
6
+ DEFAULT_SENSITIVE_KEYS = [
7
+ "password",
8
+ "passwd",
9
+ "pwd",
10
+ "secret",
11
+ "token",
12
+ "api_key",
13
+ "apikey",
14
+ "access_token",
15
+ "auth",
16
+ "credentials",
17
+ "mysql_pwd",
18
+ "private_key",
19
+ "privatekey",
20
+ "session",
21
+ "cookie",
22
+ "csrf",
23
+ "xsrf",
24
+ "authorization",
25
+ "bearer",
26
+ "jwt",
27
+ "ssn",
28
+ "social_security",
29
+ "credit_card",
30
+ "card_number",
31
+ "cvv",
32
+ "pin",
33
+ ]
34
+
35
+ REDACTED = "[REDACTED]"
36
+
37
+
38
+ class Sanitizer:
39
+ """Redacts sensitive values from request data before it's reported."""
40
+
41
+ def __init__(self, custom_sensitive_keys: Optional[Iterable[str]] = None) -> None:
42
+ keys = list(DEFAULT_SENSITIVE_KEYS) + list(custom_sensitive_keys or [])
43
+ self._patterns = [re.compile(re.escape(k), re.IGNORECASE) for k in keys]
44
+
45
+ def _is_sensitive_key(self, key: str) -> bool:
46
+ return any(p.search(key) for p in self._patterns)
47
+
48
+ def sanitize_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
49
+ out: Dict[str, str] = {}
50
+ for key, value in headers.items():
51
+ lower = key.lower()
52
+ if lower in ("authorization", "cookie") or self._is_sensitive_key(key):
53
+ out[key] = REDACTED
54
+ else:
55
+ out[key] = str(value)
56
+ return out
57
+
58
+ def sanitize_query(self, query: Dict[str, Any]) -> Dict[str, str]:
59
+ out: Dict[str, str] = {}
60
+ for key, value in query.items():
61
+ if self._is_sensitive_key(key):
62
+ out[key] = REDACTED
63
+ elif isinstance(value, (list, tuple)):
64
+ out[key] = ", ".join(str(v) for v in value)
65
+ else:
66
+ out[key] = str(value)
67
+ return out
68
+
69
+ def sanitize_value(self, value: Any, max_depth: int = 10) -> Any:
70
+ """Recursively redact sensitive keys from arbitrary JSON-shaped data
71
+ (dicts, lists, and primitives). Data of this shape can't contain
72
+ cycles, so unlike sanitizing an arbitrary object graph, no
73
+ circular-reference guard is needed.
74
+ """
75
+ return self._sanitize(value, max_depth)
76
+
77
+ def _sanitize(self, value: Any, depth: int) -> Any:
78
+ if depth <= 0:
79
+ return "[Max Depth Reached]"
80
+
81
+ if isinstance(value, dict):
82
+ out: Dict[str, Any] = {}
83
+ for key, val in value.items():
84
+ if self._is_sensitive_key(str(key)):
85
+ out[key] = REDACTED
86
+ else:
87
+ out[key] = self._sanitize(val, depth - 1)
88
+ return out
89
+
90
+ if isinstance(value, (list, tuple)):
91
+ return [self._sanitize(item, depth - 1) for item in value]
92
+
93
+ return value
94
+
95
+
96
+ def truncate_string(s: str, max_length: int = 1000) -> str:
97
+ """Limit a string's length to prevent huge payloads."""
98
+ if len(s) <= max_length:
99
+ return s
100
+ return s[:max_length] + "... [truncated]"
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import threading
6
+ import time
7
+
8
+ from .types import ServerContext
9
+
10
+ _process_start = time.time()
11
+
12
+ try:
13
+ import resource
14
+
15
+ def _memory_rss_bytes() -> int:
16
+ # ru_maxrss is KB on Linux, bytes on macOS/BSD.
17
+ rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
18
+ return rss * 1024 if platform.system() != "Darwin" else rss
19
+ except ImportError: # resource is POSIX-only; not available on Windows
20
+
21
+ def _memory_rss_bytes() -> int:
22
+ return 0
23
+
24
+
25
+ def get_server_context() -> ServerContext:
26
+ return ServerContext(
27
+ hostname=platform.node(),
28
+ platform=f"{platform.system()} {platform.release()}",
29
+ python_version=platform.python_version(),
30
+ process_id=os.getpid(),
31
+ memory_rss=_memory_rss_bytes(),
32
+ thread_count=threading.active_count(),
33
+ uptime_seconds=time.time() - _process_start,
34
+ )
oluso/transport.py ADDED
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.request
6
+ from typing import Any, Dict
7
+
8
+
9
+ class TransportError(Exception):
10
+ """Raised when a report fails to send. Carries no special data -- callers
11
+ only need to know it failed, so it can be queued for retry.
12
+ """
13
+
14
+
15
+ def send_error_report(
16
+ endpoint: str, report: Dict[str, Any], api_key: str, timeout: float
17
+ ) -> None:
18
+ """POST report to endpoint. Raises TransportError on any failure
19
+ (network error, timeout, or non-2xx status) so callers can decide how to
20
+ handle it (e.g. enqueue for retry) -- this never fails silently the way
21
+ a fire-and-forget call would.
22
+ """
23
+ body = json.dumps(report).encode("utf-8")
24
+ req = urllib.request.Request(
25
+ endpoint,
26
+ data=body,
27
+ method="POST",
28
+ headers={
29
+ "Content-Type": "application/json",
30
+ "x-oluso-signature": api_key,
31
+ },
32
+ )
33
+
34
+ try:
35
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
36
+ status = resp.status
37
+ if status < 200 or status >= 300:
38
+ raise TransportError(f"oluso: reporting failed with status {status}")
39
+ except urllib.error.HTTPError as e:
40
+ raise TransportError(f"oluso: reporting failed with status {e.code}") from e
41
+ except urllib.error.URLError as e:
42
+ raise TransportError(f"oluso: send report: {e.reason}") from e
43
+ except TimeoutError as e:
44
+ raise TransportError(f"oluso: send report timed out: {e}") from e
oluso/types.py ADDED
@@ -0,0 +1,168 @@
1
+ """Data types for Oluso error reports.
2
+
3
+ These serialize to the same wire format the Node and Go SDKs use (camelCase
4
+ JSON keys, with the historical exception of ``stack_trace``), since all
5
+ Oluso SDKs report to the same backend API.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import Any, Callable, Optional
13
+
14
+
15
+ class Severity(str, Enum):
16
+ CRITICAL = "critical"
17
+ HIGH = "high"
18
+ MEDIUM = "medium"
19
+ LOW = "low"
20
+
21
+
22
+ class BreadcrumbLevel(str, Enum):
23
+ DEBUG = "debug"
24
+ INFO = "info"
25
+ WARNING = "warning"
26
+ ERROR = "error"
27
+
28
+
29
+ def _omit_none(d: dict[str, Any]) -> dict[str, Any]:
30
+ return {k: v for k, v in d.items() if v is not None}
31
+
32
+
33
+ @dataclass
34
+ class Breadcrumb:
35
+ message: str
36
+ level: BreadcrumbLevel = BreadcrumbLevel.INFO
37
+ category: Optional[str] = None
38
+ data: Optional[dict[str, Any]] = None
39
+ timestamp: Optional[float] = None # unix seconds, set when recorded
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ return _omit_none(
43
+ {
44
+ "timestamp": int(self.timestamp * 1000) if self.timestamp else None,
45
+ "message": self.message,
46
+ "level": self.level.value if isinstance(self.level, BreadcrumbLevel) else self.level,
47
+ "category": self.category,
48
+ "data": self.data,
49
+ }
50
+ )
51
+
52
+
53
+ @dataclass
54
+ class UserContext:
55
+ id: Optional[str] = None
56
+ email: Optional[str] = None
57
+ username: Optional[str] = None
58
+ extra: dict[str, Any] = field(default_factory=dict)
59
+
60
+ def to_dict(self) -> dict[str, Any]:
61
+ return _omit_none(
62
+ {
63
+ "id": self.id,
64
+ "email": self.email,
65
+ "username": self.username,
66
+ "extra": self.extra or None,
67
+ }
68
+ )
69
+
70
+
71
+ @dataclass
72
+ class RequestContext:
73
+ url: str
74
+ method: str
75
+ headers: Optional[dict[str, str]] = None
76
+ query: Optional[dict[str, str]] = None
77
+ body: Any = None
78
+ ip: Optional[str] = None
79
+ user_agent: Optional[str] = None
80
+ response_time_ms: Optional[int] = None
81
+
82
+ def to_dict(self) -> dict[str, Any]:
83
+ return _omit_none(
84
+ {
85
+ "url": self.url,
86
+ "method": self.method,
87
+ "headers": self.headers,
88
+ "query": self.query,
89
+ "body": self.body,
90
+ "ip": self.ip,
91
+ "userAgent": self.user_agent,
92
+ "responseTime": self.response_time_ms,
93
+ }
94
+ )
95
+
96
+
97
+ @dataclass
98
+ class ServerContext:
99
+ hostname: str
100
+ platform: str
101
+ python_version: str
102
+ process_id: int
103
+ memory_rss: int
104
+ thread_count: int
105
+ uptime_seconds: float
106
+
107
+ def to_dict(self) -> dict[str, Any]:
108
+ return {
109
+ "hostname": self.hostname,
110
+ "platform": self.platform,
111
+ "pythonVersion": self.python_version,
112
+ "processId": self.process_id,
113
+ "memoryUsed": self.memory_rss,
114
+ "threadCount": self.thread_count,
115
+ "uptime": self.uptime_seconds,
116
+ }
117
+
118
+
119
+ @dataclass
120
+ class ErrorContext:
121
+ request: Optional[RequestContext] = None
122
+ user: Optional[UserContext] = None
123
+ server: Optional[ServerContext] = None
124
+ custom: dict[str, Any] = field(default_factory=dict)
125
+ breadcrumbs: list[Breadcrumb] = field(default_factory=list)
126
+
127
+ def to_dict(self) -> dict[str, Any]:
128
+ return _omit_none(
129
+ {
130
+ "request": self.request.to_dict() if self.request else None,
131
+ "user": self.user.to_dict() if self.user else None,
132
+ "server": self.server.to_dict() if self.server else None,
133
+ "custom": self.custom or None,
134
+ "breadcrumbs": [b.to_dict() for b in self.breadcrumbs] or None,
135
+ }
136
+ )
137
+
138
+
139
+ @dataclass
140
+ class ErrorReport:
141
+ title: str
142
+ message: str
143
+ stack_trace: Optional[str] = None
144
+ environment: Optional[str] = None
145
+ severity: Optional[str] = None
146
+ tags: list[str] = field(default_factory=list)
147
+ fingerprint: Optional[str] = None
148
+ context: Optional[ErrorContext] = None
149
+ timestamp: Optional[int] = None # unix millis
150
+
151
+ def to_dict(self) -> dict[str, Any]:
152
+ return _omit_none(
153
+ {
154
+ "title": self.title,
155
+ "message": self.message,
156
+ "stack_trace": self.stack_trace,
157
+ "environment": self.environment,
158
+ "severity": self.severity,
159
+ "tags": self.tags or None,
160
+ "fingerprint": self.fingerprint,
161
+ "context": self.context.to_dict() if self.context else None,
162
+ "timestamp": self.timestamp,
163
+ }
164
+ )
165
+
166
+
167
+ ShouldReportFunc = Callable[[BaseException], bool]
168
+ FingerprintFunc = Callable[[BaseException, Optional[ErrorContext]], str]
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: oluso
3
+ Version: 1.0.0
4
+ Summary: AI-powered error monitoring for Python applications: automatic error reporting, breadcrumb tracking, and intelligent error grouping.
5
+ Project-URL: Homepage, https://github.com/olusodotdev/oluso-py
6
+ Project-URL: Repository, https://github.com/olusodotdev/oluso-py
7
+ Project-URL: Issues, https://github.com/olusodotdev/oluso-py/issues
8
+ Author: David Ezeh
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai,crash-reporting,debugging,error,error-monitoring,error-tracking,logging,monitoring,reporting
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.9
23
+ Provides-Extra: dev
24
+ Requires-Dist: flask>=2.3; extra == 'dev'
25
+ Requires-Dist: mypy; extra == 'dev'
26
+ Requires-Dist: pytest-cov; extra == 'dev'
27
+ Requires-Dist: pytest>=7.0; extra == 'dev'
28
+ Requires-Dist: ruff; extra == 'dev'
29
+ Provides-Extra: flask
30
+ Requires-Dist: flask>=2.3; extra == 'flask'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # oluso-py
34
+
35
+ AI-powered error monitoring for Python applications: automatic error reporting, breadcrumb tracking, and intelligent error grouping.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install oluso
41
+
42
+ # With a framework integration
43
+ pip install "oluso[flask]"
44
+ ```
45
+
46
+ ## Usage with Flask
47
+
48
+ ```python
49
+ from flask import Flask
50
+ from oluso import Oluso, Options
51
+ from oluso.integrations.flask import init_app
52
+
53
+ app = Flask(__name__)
54
+
55
+ client = Oluso(Options(api_key="your-api-key", environment="production"))
56
+ init_app(app, client)
57
+
58
+ @app.route("/")
59
+ def index():
60
+ raise RuntimeError("something went wrong") # captured and reported automatically
61
+ ```
62
+
63
+ `init_app` wraps `app.wsgi_app`, so it works with any WSGI application, not just Flask. It scopes breadcrumbs to each request, auto-reports unhandled exceptions and 5xx responses, then re-raises so Flask/Werkzeug's own error handling still runs — Oluso only observes and reports, it never changes how your app responds to errors.
64
+
65
+ ## Breadcrumbs & User Context
66
+
67
+ Breadcrumbs and user context are scoped per request using `contextvars` — Python's idiomatic equivalent of the `AsyncLocalStorage`-based scoping the Node SDK uses (and Go's `context.Context`), correctly isolated per thread *and* per asyncio task:
68
+
69
+ ```python
70
+ from oluso import add_breadcrumb, set_user
71
+
72
+ @app.route("/checkout")
73
+ def checkout():
74
+ add_breadcrumb("user started checkout", category="action")
75
+ set_user(UserContext(id="user_456"))
76
+
77
+ try:
78
+ do_checkout()
79
+ except Exception as err:
80
+ client.capture_exception(err, {"cartId": "cart_123"})
81
+ ```
82
+
83
+ For non-request work (a background job, a CLI command) where you still want a scope, open one yourself:
84
+
85
+ ```python
86
+ from oluso import scope, add_breadcrumb
87
+
88
+ with scope():
89
+ add_breadcrumb("job started")
90
+ client.capture_exception(err)
91
+ ```
92
+
93
+ ## Manual Reporting
94
+
95
+ ```python
96
+ client.capture_exception(err, {"customMeta": "extra-info"})
97
+ ```
98
+
99
+ ## Advanced Configuration
100
+
101
+ ```python
102
+ from oluso import Oluso, Options, Severity
103
+
104
+ client = Oluso(Options(
105
+ api_key="your-api-key",
106
+ endpoint="https://api.oluso.dev/api/v1/error/report", # override for self-hosting
107
+ environment="staging",
108
+ default_severity=Severity.MEDIUM,
109
+ max_breadcrumbs=50,
110
+ max_errors_per_minute=100,
111
+ sensitive_keys=["ssn", "internal_id"],
112
+ should_report=lambda err: "expected" not in str(err),
113
+ ))
114
+ ```
115
+
116
+ Call `client.flush(timeout=5)` before your process exits so a capture right before shutdown isn't lost.
117
+
118
+ ## Error Report Structure
119
+
120
+ Reports sent to the API include:
121
+
122
+ - **Metadata**: Title, message, stack trace, severity, tags.
123
+ - **Context**: Request details (URL, method, headers, etc.), server details (hostname, Python version, memory, thread count).
124
+ - **History**: Breadcrumbs leading up to the error.
125
+ - **Identification**: Fingerprint for deduplication and user ID.
126
+
127
+ ## License
128
+
129
+ MIT
@@ -0,0 +1,17 @@
1
+ oluso/__init__.py,sha256=0oPV61l3CYTNi7m6pxuMHnSb7L81v9dYn8TmvDdjWGw,661
2
+ oluso/client.py,sha256=jD92oyDeQSs8sfU941xLAEvi7t7fxngMdKSkPfY1Mc0,7937
3
+ oluso/context.py,sha256=XwZkAbNSmO0V7tiZPRT6m2hMr_pz--4b9pnFxzpBmcc,3644
4
+ oluso/fingerprint.py,sha256=hLVQH83T0_0MPzmzB2nZzG3ZOokxlWVFmFT9l0ZiwjM,1843
5
+ oluso/options.py,sha256=ZuvOzZ1DZ9yLf4zLI2JyhhXKMQ2h8sw2Ol28SyHmdaE,1207
6
+ oluso/queue.py,sha256=9GwyNcRuyART8vu2LzrWLCEX8qtgtLn29XKURTI8rug,3652
7
+ oluso/rate_limiter.py,sha256=LDYzDji3Veg7AJ0WXW_33uq_s7WDoMUlKsCNIkdSYbE,1134
8
+ oluso/sanitizer.py,sha256=kI1FHPCblvmA7tY0uY4xj6ia6Wys93xFHNCAA-wsfxI,2972
9
+ oluso/server_context.py,sha256=JYHLqlSSMBlLcC4kgZjl01BLYagruiCiN1tKT2F89O8,928
10
+ oluso/transport.py,sha256=RUcrCNTCUQre6GEB4bUwfGEbeCnQK-wG8wTe3fLdJNI,1522
11
+ oluso/types.py,sha256=9AszCW1ttgclKkLn6R4iBg7rqUWo_C0Uf5oVe7KZ_Ow,4843
12
+ oluso/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ oluso/integrations/flask.py,sha256=veV8VX9N7pSIv9mTeFSi6CTtFTPtJwG1wnANj9D5qqk,6006
14
+ oluso-1.0.0.dist-info/METADATA,sha256=RAc2aMhalql78siW8lqBL9grzqvRmFRnG3-OfEF1FEk,4267
15
+ oluso-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
16
+ oluso-1.0.0.dist-info/licenses/LICENSE,sha256=MdjdOgyMeHkqBppsOqxjWO-RTFx-uYrPvuRTSMJEFm4,1067
17
+ oluso-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Ezeh
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.