logbrew-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,758 @@
1
+ """Public Python client for building, validating, previewing, and flushing LogBrew event batches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ from collections.abc import Callable, Mapping
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime
11
+ from typing import Annotated, Any, Protocol, TypeAlias, TypedDict
12
+ from urllib.error import HTTPError
13
+ from urllib.request import Request, urlopen
14
+
15
+ MetadataValue: TypeAlias = str | int | float | bool | None
16
+ Metadata: TypeAlias = dict[str, MetadataValue]
17
+
18
+
19
+ class ReleaseAttributes(TypedDict, total=False):
20
+ """Public release event attributes."""
21
+ version: str
22
+ commit: str
23
+ notes: str
24
+ metadata: Metadata
25
+
26
+
27
+ class EnvironmentAttributes(TypedDict, total=False):
28
+ """Public environment event attributes."""
29
+ name: str
30
+ region: str
31
+ metadata: Metadata
32
+
33
+
34
+ class IssueAttributes(TypedDict, total=False):
35
+ """Public issue event attributes."""
36
+ title: str
37
+ level: str
38
+ message: str
39
+ metadata: Metadata
40
+
41
+
42
+ class LogAttributes(TypedDict, total=False):
43
+ """Public log event attributes."""
44
+ message: str
45
+ level: str
46
+ logger: str
47
+ metadata: Metadata
48
+
49
+
50
+ class SpanAttributes(TypedDict, total=False):
51
+ """Public span event attributes."""
52
+ name: str
53
+ traceId: str
54
+ spanId: str
55
+ parentSpanId: str
56
+ status: str
57
+ durationMs: float
58
+ metadata: Metadata
59
+
60
+
61
+ class ActionAttributes(TypedDict, total=False):
62
+ """Public action event attributes."""
63
+ name: str
64
+ status: str
65
+ metadata: Metadata
66
+
67
+
68
+ class ScriptedTransportResponse(TypedDict):
69
+ status_code: int
70
+
71
+
72
+ class Transport(Protocol):
73
+ """Public transport protocol used by client flush, shutdown, and logging helpers."""
74
+
75
+ def send(self, api_key: str, body: str) -> TransportResponse:
76
+ """Send an already serialized event batch body."""
77
+
78
+
79
+ ISSUE_LEVELS = {"info", "warning", "error", "critical"}
80
+ LOG_LEVELS = {"debug", "info", "warning", "error"}
81
+ SPAN_STATUSES = {"ok", "error"}
82
+ ACTION_STATUSES = {"queued", "running", "success", "failure"}
83
+ DEFAULT_HTTP_ENDPOINT = "https://api.logbrew.com/v1/events"
84
+ TRACEPARENT_PATTERN = re.compile(r"^([0-9a-fA-F]{2})-([0-9a-fA-F]{32})-([0-9a-fA-F]{16})-([0-9a-fA-F]{2})$")
85
+ ZERO_TRACE_ID = "00000000000000000000000000000000"
86
+ ZERO_SPAN_ID = "0000000000000000"
87
+
88
+ LOG_RECORD_BUILTINS = frozenset(
89
+ logging.LogRecord(
90
+ name="logbrew",
91
+ level=logging.INFO,
92
+ pathname="logbrew.py",
93
+ lineno=1,
94
+ msg="",
95
+ args=(),
96
+ exc_info=None,
97
+ ).__dict__
98
+ ) | {"message", "asctime"}
99
+
100
+
101
+ @dataclass(slots=True)
102
+ class SdkError(Exception):
103
+ """Stable public SDK error with parseable code and message fields."""
104
+ code: str
105
+ message: str
106
+
107
+ def __str__(self) -> str:
108
+ return f"{self.code}: {self.message}"
109
+
110
+
111
+ @dataclass(slots=True)
112
+ class TransportError(Exception):
113
+ """Transport failure with a stable public code and retry hint."""
114
+ code: str
115
+ message: str
116
+ retryable: bool = False
117
+
118
+ def __str__(self) -> str:
119
+ return f"{self.code}: {self.message}"
120
+
121
+ @classmethod
122
+ def network(cls, message: str) -> TransportError:
123
+ """Create a retryable network failure that preserves queued events."""
124
+ return cls(code="network_failure", message=message, retryable=True)
125
+
126
+
127
+ @dataclass(slots=True)
128
+ class TransportResponse:
129
+ """Stable transport response returned from flush and shutdown operations."""
130
+ status_code: Annotated[int, "Final HTTP-like status returned by the transport."]
131
+ attempts: Annotated[int, "Number of transport attempts used for the flush."]
132
+
133
+
134
+ @dataclass(frozen=True, slots=True)
135
+ class TraceparentContext:
136
+ """Parsed W3C traceparent context."""
137
+
138
+ version: str
139
+ trace_id: str
140
+ parent_span_id: str
141
+ trace_flags: str
142
+ sampled: bool
143
+
144
+
145
+ class RecordingTransport:
146
+ """Scripted transport for previewing, accepting, or failing queued event flushes."""
147
+
148
+ sent_bodies: Annotated[list[str], "Every request body sent through this transport instance."]
149
+
150
+ def __init__(
151
+ self,
152
+ scripted_responses: list[ScriptedTransportResponse | Exception] | None = None,
153
+ ) -> None:
154
+ self.scripted_responses = list(scripted_responses or [{"status_code": 202}])
155
+ self.sent_bodies: list[str] = []
156
+
157
+ @classmethod
158
+ def always_accept(cls) -> RecordingTransport:
159
+ """Create a transport that accepts queued flushes with a 202 response."""
160
+ return cls([{"status_code": 202}])
161
+
162
+ def last_body(self) -> str | None:
163
+ """Return the most recent request body sent through this transport."""
164
+ if not self.sent_bodies:
165
+ return None
166
+ return self.sent_bodies[-1]
167
+
168
+ def send(self, api_key: str, body: str) -> TransportResponse:
169
+ require_non_empty("api_key", api_key)
170
+ self.sent_bodies.append(body)
171
+
172
+ next_response = self.scripted_responses.pop(0) if self.scripted_responses else {"status_code": 202}
173
+ if isinstance(next_response, Exception):
174
+ raise next_response
175
+
176
+ return TransportResponse(status_code=int(next_response["status_code"]), attempts=1)
177
+
178
+
179
+ class HttpTransport:
180
+ """Dependency-free HTTP transport for sending queued batches to LogBrew."""
181
+
182
+ def __init__(
183
+ self,
184
+ *,
185
+ endpoint: str = DEFAULT_HTTP_ENDPOINT,
186
+ headers: Mapping[str, str] | None = None,
187
+ timeout: float = 10.0,
188
+ open_url: Callable[..., Any] | None = None,
189
+ ) -> None:
190
+ require_non_empty("endpoint", endpoint)
191
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0:
192
+ raise SdkError("configuration_error", "HttpTransport timeout must be positive")
193
+ self.endpoint = endpoint
194
+ self.headers = validate_headers(headers)
195
+ self.timeout = float(timeout)
196
+ self.open_url = open_url or urlopen
197
+
198
+ def send(self, api_key: str, body: str) -> TransportResponse:
199
+ """POST one serialized event batch and return the HTTP status."""
200
+ require_non_empty("api_key", api_key)
201
+ request = Request(
202
+ self.endpoint,
203
+ data=body.encode("utf-8"),
204
+ headers={
205
+ "content-type": "application/json",
206
+ "authorization": f"Bearer {api_key}",
207
+ **self.headers,
208
+ },
209
+ method="POST",
210
+ )
211
+ try:
212
+ response = self.open_url(request, timeout=self.timeout)
213
+ try:
214
+ status = getattr(response, "status", None)
215
+ if status is None:
216
+ status = response.getcode()
217
+ return TransportResponse(status_code=int(status), attempts=1)
218
+ finally:
219
+ close_response = getattr(response, "close", None)
220
+ if callable(close_response):
221
+ close_response()
222
+ except HTTPError as error:
223
+ return TransportResponse(status_code=int(error.code), attempts=1)
224
+ except OSError as error:
225
+ raise TransportError.network(f"http transport failed: {error}") from error
226
+
227
+
228
+ class LogBrewClient:
229
+ """Buffered public client for validating, previewing, and flushing LogBrew events."""
230
+
231
+ @classmethod
232
+ def create(
233
+ cls,
234
+ *,
235
+ api_key: str,
236
+ sdk_name: str,
237
+ sdk_version: str,
238
+ max_retries: int = 2,
239
+ ) -> LogBrewClient:
240
+ """Create a client from public SDK identity, retry, and API key settings."""
241
+ require_non_empty("api_key", api_key)
242
+ require_non_empty("sdk_name", sdk_name)
243
+ require_non_empty("sdk_version", sdk_version)
244
+ return cls(
245
+ api_key=api_key,
246
+ sdk={"name": sdk_name, "language": "python", "version": sdk_version},
247
+ max_retries=max_retries,
248
+ )
249
+
250
+ def __init__(self, *, api_key: str, sdk: dict[str, str], max_retries: int) -> None:
251
+ self.api_key = api_key
252
+ self.sdk = sdk
253
+ self.max_retries = max_retries
254
+ self.events: list[dict[str, Any]] = []
255
+ self.closed = False
256
+
257
+ def pending_events(self) -> int:
258
+ """Return the queued event count currently buffered in memory."""
259
+ return len(self.events)
260
+
261
+ def preview_json(self) -> str:
262
+ """Return the queued event batch as stable, pretty-printed JSON."""
263
+ return json.dumps({"sdk": self.sdk, "events": self.events}, indent=2)
264
+
265
+ def release(self, event_id: str, timestamp: str, attributes: ReleaseAttributes) -> None:
266
+ self._push_event("release", event_id, timestamp, validate_release(attributes))
267
+
268
+ def environment(self, event_id: str, timestamp: str, attributes: EnvironmentAttributes) -> None:
269
+ self._push_event("environment", event_id, timestamp, validate_environment(attributes))
270
+
271
+ def issue(self, event_id: str, timestamp: str, attributes: IssueAttributes) -> None:
272
+ self._push_event("issue", event_id, timestamp, validate_issue(attributes))
273
+
274
+ def log(self, event_id: str, timestamp: str, attributes: LogAttributes) -> None:
275
+ self._push_event("log", event_id, timestamp, validate_log(attributes))
276
+
277
+ def span(self, event_id: str, timestamp: str, attributes: SpanAttributes) -> None:
278
+ self._push_event("span", event_id, timestamp, validate_span(attributes))
279
+
280
+ def action(self, event_id: str, timestamp: str, attributes: ActionAttributes) -> None:
281
+ self._push_event("action", event_id, timestamp, validate_action(attributes))
282
+
283
+ def flush(self, transport: Transport) -> TransportResponse:
284
+ """Flush queued events through a transport while preserving retry semantics."""
285
+ if self.closed:
286
+ raise SdkError("shutdown_error", "client is already shut down")
287
+ return self._flush_internal(transport)
288
+
289
+ def shutdown(self, transport: Transport) -> TransportResponse:
290
+ """Flush queued events, then mark the client closed so later writes fail."""
291
+ if self.closed:
292
+ raise SdkError("shutdown_error", "client is already shut down")
293
+ response = self._flush_internal(transport)
294
+ self.closed = True
295
+ return response
296
+
297
+ def _push_event(
298
+ self,
299
+ event_type: str,
300
+ event_id: str,
301
+ timestamp: str,
302
+ attributes: dict[str, Any],
303
+ ) -> None:
304
+ if self.closed:
305
+ raise SdkError("shutdown_error", "client is already shut down")
306
+ require_non_empty("event id", event_id)
307
+ require_timestamp(timestamp)
308
+ self.events.append(
309
+ {
310
+ "type": event_type,
311
+ "id": event_id,
312
+ "timestamp": timestamp,
313
+ "attributes": attributes,
314
+ }
315
+ )
316
+
317
+ def _flush_internal(self, transport: Transport) -> TransportResponse:
318
+ if not self.events:
319
+ return TransportResponse(status_code=204, attempts=0)
320
+
321
+ body = self.preview_json()
322
+ max_attempts = self.max_retries + 1
323
+ attempts = 0
324
+ while attempts < max_attempts:
325
+ attempts += 1
326
+ try:
327
+ response = transport.send(self.api_key, body)
328
+ if response.status_code == 401:
329
+ raise SdkError("unauthenticated", "transport rejected the API key")
330
+ if 200 <= response.status_code < 300:
331
+ self.events.clear()
332
+ return TransportResponse(status_code=response.status_code, attempts=attempts)
333
+ if response.status_code >= 500 and attempts < max_attempts:
334
+ continue
335
+ raise SdkError(
336
+ "transport_error",
337
+ f"unexpected transport status {response.status_code}",
338
+ )
339
+ except SdkError:
340
+ raise
341
+ except TransportError as error:
342
+ if error.retryable and attempts < max_attempts:
343
+ continue
344
+ raise SdkError(error.code, error.message) from error
345
+
346
+ raise SdkError("transport_error", "exhausted retries")
347
+
348
+
349
+ class LogBrewLoggingHandler(logging.Handler):
350
+ """Standard-library logging handler that turns LogRecord objects into LogBrew log events."""
351
+
352
+ def __init__(
353
+ self,
354
+ client: LogBrewClient,
355
+ transport: Transport | None = None,
356
+ *,
357
+ flush_on_emit: bool = False,
358
+ include_exception_text: bool = False,
359
+ metadata: Metadata | None = None,
360
+ raise_flush_errors: bool = False,
361
+ level: int = logging.NOTSET,
362
+ ) -> None:
363
+ super().__init__(level=level)
364
+ self.client = client
365
+ self.transport = transport
366
+ self.flush_on_emit = flush_on_emit
367
+ self.include_exception_text = include_exception_text
368
+ self.metadata = dict(metadata or {})
369
+ self.raise_flush_errors = raise_flush_errors
370
+
371
+ def emit(self, record: logging.LogRecord) -> None:
372
+ """Queue one LogBrew log event from a standard-library log record."""
373
+ try:
374
+ event_id = default_log_record_event_id(record)
375
+ self.client.log(
376
+ event_id,
377
+ timestamp_from_log_record(record),
378
+ log_attributes_from_record(
379
+ record,
380
+ include_exception_text=self.include_exception_text,
381
+ metadata=self.metadata,
382
+ ),
383
+ )
384
+ if self.flush_on_emit:
385
+ self._flush_transport()
386
+ except Exception:
387
+ self.handleError(record)
388
+
389
+ def flush(self) -> None:
390
+ """Flush queued records when a transport was provided to the handler."""
391
+ self.acquire()
392
+ try:
393
+ self._flush_transport()
394
+ finally:
395
+ self.release()
396
+
397
+ def _flush_transport(self) -> None:
398
+ if self.transport is None or self.client.closed or self.client.pending_events() == 0:
399
+ return
400
+ try:
401
+ self.client.flush(self.transport)
402
+ except Exception:
403
+ if self.raise_flush_errors:
404
+ raise
405
+
406
+
407
+ def log_attributes_from_record(
408
+ record: logging.LogRecord,
409
+ *,
410
+ include_exception_text: bool = False,
411
+ metadata: Metadata | None = None,
412
+ ) -> LogAttributes:
413
+ """Convert a standard-library LogRecord into LogBrew log attributes."""
414
+ merged_metadata = {
415
+ **dict(metadata or {}),
416
+ **metadata_from_log_record(record, include_exception_text=include_exception_text),
417
+ }
418
+ return {
419
+ "message": record.getMessage(),
420
+ "level": logbrew_level(record.levelno),
421
+ "logger": record.name,
422
+ "metadata": merged_metadata,
423
+ }
424
+
425
+
426
+ def metadata_from_log_record(
427
+ record: logging.LogRecord,
428
+ *,
429
+ include_exception_text: bool = False,
430
+ ) -> Metadata:
431
+ """Return privacy-conscious metadata from a standard-library LogRecord."""
432
+ metadata: Metadata = {
433
+ "fileName": record.filename,
434
+ "functionName": record.funcName,
435
+ "levelName": record.levelname,
436
+ "levelNumber": record.levelno,
437
+ "lineNumber": record.lineno,
438
+ "module": record.module,
439
+ "processName": record.processName,
440
+ "threadName": record.threadName,
441
+ }
442
+ metadata.update(extra_metadata_from_log_record(record))
443
+ if record.exc_info is not None:
444
+ exception = record.exc_info[1]
445
+ if exception is not None:
446
+ metadata["exceptionName"] = type(exception).__name__
447
+ metadata["exceptionMessage"] = str(exception)
448
+ if include_exception_text:
449
+ formatter = logging.Formatter()
450
+ metadata["exceptionText"] = formatter.formatException(record.exc_info)
451
+ return metadata
452
+
453
+
454
+ def extra_metadata_from_log_record(record: logging.LogRecord) -> Metadata:
455
+ """Collect primitive values passed through logging's extra argument."""
456
+ metadata: Metadata = {}
457
+ for key, value in record.__dict__.items():
458
+ if key in LOG_RECORD_BUILTINS:
459
+ continue
460
+ if isinstance(value, str | int | float | bool) or value is None:
461
+ metadata[key] = value
462
+ return metadata
463
+
464
+
465
+ def timestamp_from_log_record(record: logging.LogRecord) -> str:
466
+ """Return a UTC ISO-8601 timestamp for a standard-library log record."""
467
+ return datetime.fromtimestamp(record.created, tz=UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
468
+
469
+
470
+ def default_log_record_event_id(record: logging.LogRecord) -> str:
471
+ """Return a stable event id for a standard-library log record."""
472
+ millis = int(record.created * 1000)
473
+ return f"evt_log_{slugify(record.name)}_{millis}_{record.lineno}"
474
+
475
+
476
+ def logbrew_level(level_number: int) -> str:
477
+ """Map standard-library logging levels to LogBrew log levels."""
478
+ if level_number >= logging.ERROR:
479
+ return "error"
480
+ if level_number >= logging.WARNING:
481
+ return "warning"
482
+ if level_number >= logging.INFO:
483
+ return "info"
484
+ return "debug"
485
+
486
+
487
+ def parse_traceparent(traceparent: str) -> TraceparentContext:
488
+ """Parse and validate a W3C traceparent header."""
489
+
490
+ require_non_empty("traceparent", traceparent)
491
+ match = TRACEPARENT_PATTERN.match(traceparent.strip())
492
+ if match is None:
493
+ raise SdkError(
494
+ "validation_error",
495
+ "traceparent must use W3C version-traceId-parentSpanId-traceFlags format",
496
+ )
497
+
498
+ version = match.group(1).lower()
499
+ trace_id = match.group(2).lower()
500
+ parent_span_id = match.group(3).lower()
501
+ trace_flags = match.group(4).lower()
502
+ if version == "ff":
503
+ raise SdkError("validation_error", "traceparent version ff is not allowed")
504
+ if trace_id == ZERO_TRACE_ID:
505
+ raise SdkError("validation_error", "traceparent traceId must not be all zeros")
506
+ if parent_span_id == ZERO_SPAN_ID:
507
+ raise SdkError("validation_error", "traceparent parentSpanId must not be all zeros")
508
+
509
+ return TraceparentContext(
510
+ version=version,
511
+ trace_id=trace_id,
512
+ parent_span_id=parent_span_id,
513
+ trace_flags=trace_flags,
514
+ sampled=(int(trace_flags, 16) & 1) == 1,
515
+ )
516
+
517
+
518
+ def create_traceparent(*, trace_id: str, span_id: str, trace_flags: str = "01") -> str:
519
+ """Create a W3C traceparent header from explicit trace and span ids."""
520
+
521
+ require_trace_id(trace_id)
522
+ require_span_id("span_id", span_id)
523
+ require_trace_flags(trace_flags)
524
+ return f"00-{trace_id.lower()}-{span_id.lower()}-{trace_flags.lower()}"
525
+
526
+
527
+ def span_attributes_from_traceparent(
528
+ traceparent: str,
529
+ *,
530
+ name: str,
531
+ span_id: str,
532
+ status: str,
533
+ duration_ms: float | None = None,
534
+ metadata: Mapping[str, Any] | None = None,
535
+ ) -> SpanAttributes:
536
+ """Build LogBrew span attributes that continue an incoming W3C traceparent."""
537
+
538
+ context = parse_traceparent(traceparent)
539
+ require_non_empty("span name", name)
540
+ require_span_id("span_id", span_id)
541
+ require_allowed_value("span status", status, SPAN_STATUSES)
542
+ if duration_ms is not None and (
543
+ isinstance(duration_ms, bool) or not isinstance(duration_ms, (int, float)) or duration_ms < 0
544
+ ):
545
+ raise SdkError("validation_error", "span durationMs must be non-negative")
546
+
547
+ safe_metadata = compact_metadata(metadata)
548
+ return {
549
+ "name": name,
550
+ "traceId": context.trace_id,
551
+ "spanId": span_id.lower(),
552
+ "parentSpanId": context.parent_span_id,
553
+ "status": status,
554
+ **({"durationMs": duration_ms} if duration_ms is not None else {}),
555
+ **({"metadata": safe_metadata} if safe_metadata is not None else {}),
556
+ }
557
+
558
+
559
+ def slugify(value: str) -> str:
560
+ normalized = "".join(character.lower() if character.isalnum() else "_" for character in value)
561
+ return normalized.strip("_") or "logger"
562
+
563
+
564
+ def require_trace_id(trace_id: Any) -> None:
565
+ if not isinstance(trace_id, str) or re.fullmatch(r"[0-9a-fA-F]{32}", trace_id) is None:
566
+ raise SdkError("validation_error", "traceId must be 32 lowercase or uppercase hex characters")
567
+ if trace_id.lower() == ZERO_TRACE_ID:
568
+ raise SdkError("validation_error", "traceId must not be all zeros")
569
+
570
+
571
+ def require_span_id(label: str, span_id: Any) -> None:
572
+ if not isinstance(span_id, str) or re.fullmatch(r"[0-9a-fA-F]{16}", span_id) is None:
573
+ raise SdkError("validation_error", f"{label} must be 16 lowercase or uppercase hex characters")
574
+ if span_id.lower() == ZERO_SPAN_ID:
575
+ raise SdkError("validation_error", f"{label} must not be all zeros")
576
+
577
+
578
+ def require_trace_flags(trace_flags: Any) -> None:
579
+ if not isinstance(trace_flags, str) or re.fullmatch(r"[0-9a-fA-F]{2}", trace_flags) is None:
580
+ raise SdkError("validation_error", "traceFlags must be 2 lowercase or uppercase hex characters")
581
+
582
+
583
+ def compact_metadata(metadata: Mapping[str, Any] | None) -> Metadata | None:
584
+ if metadata is None:
585
+ return None
586
+ if not isinstance(metadata, Mapping):
587
+ raise SdkError("validation_error", "metadata must be an object")
588
+ safe_metadata: Metadata = {}
589
+ for key, value in metadata.items():
590
+ if isinstance(key, str) and (isinstance(value, str | int | float | bool) or value is None):
591
+ safe_metadata[key] = value
592
+ return safe_metadata
593
+
594
+
595
+ def validate_headers(headers: Mapping[str, str] | None) -> dict[str, str]:
596
+ if headers is None:
597
+ return {}
598
+ safe_headers: dict[str, str] = {}
599
+ for name, value in headers.items():
600
+ require_non_empty("header name", name)
601
+ if not isinstance(value, str):
602
+ raise SdkError("configuration_error", "HttpTransport header values must be strings")
603
+ safe_headers[name] = value
604
+ return safe_headers
605
+
606
+
607
+ def require_non_empty(label: str, value: Any) -> None:
608
+ if not isinstance(value, str) or not value.strip():
609
+ raise SdkError("validation_error", f"{label} must be non-empty")
610
+
611
+
612
+ def require_allowed_value(label: str, value: Any, allowed_values: set[str]) -> None:
613
+ require_non_empty(label, value)
614
+ if value not in allowed_values:
615
+ allowed = ", ".join(sorted(allowed_values))
616
+ raise SdkError("validation_error", f"{label} must be one of: {allowed}")
617
+
618
+
619
+ def require_timestamp(timestamp: Any) -> None:
620
+ require_non_empty("timestamp", timestamp)
621
+ if timestamp.endswith("Z"):
622
+ return
623
+ time_portion = timestamp.split("T")[1] if "T" in timestamp else ""
624
+ if "+" in time_portion:
625
+ return
626
+ if "-" in time_portion[1:]:
627
+ return
628
+ raise SdkError("validation_error", f"timestamp must include a timezone offset: {timestamp}")
629
+
630
+
631
+ def clone_metadata(metadata: Any) -> dict[str, Any] | None:
632
+ if metadata is None:
633
+ return None
634
+ if not isinstance(metadata, dict):
635
+ raise SdkError("validation_error", "metadata must be an object")
636
+ return dict(metadata)
637
+
638
+
639
+ def with_metadata(attributes: dict[str, Any], metadata: Any) -> dict[str, Any]:
640
+ safe_metadata = clone_metadata(metadata)
641
+ if safe_metadata is None:
642
+ return attributes
643
+ return {**attributes, "metadata": safe_metadata}
644
+
645
+
646
+ def validate_release(attributes: ReleaseAttributes) -> dict[str, Any]:
647
+ require_non_empty("release version", attributes.get("version"))
648
+ commit = attributes.get("commit")
649
+ if commit is not None:
650
+ require_non_empty("release commit", commit)
651
+ return with_metadata(
652
+ {
653
+ "version": attributes["version"],
654
+ **({"commit": commit} if commit is not None else {}),
655
+ **({"notes": attributes["notes"]} if "notes" in attributes else {}),
656
+ },
657
+ attributes.get("metadata"),
658
+ )
659
+
660
+
661
+ def validate_environment(attributes: EnvironmentAttributes) -> dict[str, Any]:
662
+ require_non_empty("environment name", attributes.get("name"))
663
+ return with_metadata(
664
+ {
665
+ "name": attributes["name"],
666
+ **({"region": attributes["region"]} if "region" in attributes else {}),
667
+ },
668
+ attributes.get("metadata"),
669
+ )
670
+
671
+
672
+ def validate_issue(attributes: IssueAttributes) -> dict[str, Any]:
673
+ require_non_empty("issue title", attributes.get("title"))
674
+ require_allowed_value("issue level", attributes.get("level"), ISSUE_LEVELS)
675
+ return with_metadata(
676
+ {
677
+ "title": attributes["title"],
678
+ "level": attributes["level"],
679
+ **({"message": attributes["message"]} if "message" in attributes else {}),
680
+ },
681
+ attributes.get("metadata"),
682
+ )
683
+
684
+
685
+ def validate_log(attributes: LogAttributes) -> dict[str, Any]:
686
+ require_non_empty("log message", attributes.get("message"))
687
+ require_allowed_value("log level", attributes.get("level"), LOG_LEVELS)
688
+ return with_metadata(
689
+ {
690
+ "message": attributes["message"],
691
+ "level": attributes["level"],
692
+ **({"logger": attributes["logger"]} if "logger" in attributes else {}),
693
+ },
694
+ attributes.get("metadata"),
695
+ )
696
+
697
+
698
+ def validate_span(attributes: SpanAttributes) -> dict[str, Any]:
699
+ require_non_empty("span name", attributes.get("name"))
700
+ require_non_empty("span traceId", attributes.get("traceId"))
701
+ require_non_empty("span spanId", attributes.get("spanId"))
702
+ require_allowed_value("span status", attributes.get("status"), SPAN_STATUSES)
703
+ parent_span_id = attributes.get("parentSpanId")
704
+ if parent_span_id is not None:
705
+ require_non_empty("span parentSpanId", parent_span_id)
706
+ duration_ms = attributes.get("durationMs")
707
+ if duration_ms is not None and (
708
+ isinstance(duration_ms, bool) or not isinstance(duration_ms, (int, float)) or duration_ms < 0
709
+ ):
710
+ raise SdkError("validation_error", "span durationMs must be non-negative")
711
+ return with_metadata(
712
+ {
713
+ "name": attributes["name"],
714
+ "traceId": attributes["traceId"],
715
+ "spanId": attributes["spanId"],
716
+ "status": attributes["status"],
717
+ **({"parentSpanId": parent_span_id} if parent_span_id is not None else {}),
718
+ **({"durationMs": duration_ms} if duration_ms is not None else {}),
719
+ },
720
+ attributes.get("metadata"),
721
+ )
722
+
723
+
724
+ def validate_action(attributes: ActionAttributes) -> dict[str, Any]:
725
+ require_non_empty("action name", attributes.get("name"))
726
+ require_allowed_value("action status", attributes.get("status"), ACTION_STATUSES)
727
+ return with_metadata(
728
+ {
729
+ "name": attributes["name"],
730
+ "status": attributes["status"],
731
+ },
732
+ attributes.get("metadata"),
733
+ )
734
+
735
+
736
+ __all__ = [
737
+ "ActionAttributes",
738
+ "EnvironmentAttributes",
739
+ "HttpTransport",
740
+ "IssueAttributes",
741
+ "LogAttributes",
742
+ "LogBrewClient",
743
+ "LogBrewLoggingHandler",
744
+ "Metadata",
745
+ "MetadataValue",
746
+ "RecordingTransport",
747
+ "ReleaseAttributes",
748
+ "SdkError",
749
+ "SpanAttributes",
750
+ "TraceparentContext",
751
+ "Transport",
752
+ "TransportError",
753
+ "TransportResponse",
754
+ "create_traceparent",
755
+ "log_attributes_from_record",
756
+ "parse_traceparent",
757
+ "span_attributes_from_traceparent",
758
+ ]
@@ -0,0 +1 @@
1
+ """Runnable packaged examples for installed LogBrew SDK users."""
@@ -0,0 +1,68 @@
1
+ """Run packaged examples for installed SDK users."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Callable
7
+
8
+ from . import readme_example, real_user_smoke
9
+
10
+
11
+ def _example_runners() -> dict[str, Callable[[], int]]:
12
+ return {
13
+ "readme-example": readme_example.main,
14
+ "real-user-smoke": real_user_smoke.main,
15
+ }
16
+
17
+
18
+ def _example_commands() -> dict[str, str]:
19
+ commands = {
20
+ name: f"python -m logbrew_sdk.examples {name}" for name in sorted(_example_runners())
21
+ }
22
+ commands["default (real-user-smoke)"] = "python -m logbrew_sdk.examples"
23
+ return commands
24
+
25
+
26
+ def _help_epilog() -> str:
27
+ lines = ["Packaged examples:"]
28
+ lines.extend(
29
+ f" {example} -> {command}" for example, command in _example_commands().items()
30
+ )
31
+ return "\n".join(lines)
32
+
33
+
34
+ def build_parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ description="Run the packaged LogBrew SDK examples that ship with the installed Python package.",
37
+ epilog=_help_epilog(),
38
+ formatter_class=argparse.RawDescriptionHelpFormatter,
39
+ )
40
+ parser.add_argument(
41
+ "example",
42
+ nargs="?",
43
+ choices=sorted(_example_runners()),
44
+ default="real-user-smoke",
45
+ help="packaged example to run (default: real-user-smoke)",
46
+ )
47
+ parser.add_argument(
48
+ "--list",
49
+ action="store_true",
50
+ help="print the available packaged example names and exit",
51
+ )
52
+ return parser
53
+
54
+
55
+ def main(argv: list[str] | None = None) -> int:
56
+ parser = build_parser()
57
+ args = parser.parse_args(argv)
58
+
59
+ if args.list:
60
+ for example, command in _example_commands().items():
61
+ print(f"{example} -> {command}")
62
+ return 0
63
+
64
+ return _example_runners()[args.example]()
65
+
66
+
67
+ if __name__ == "__main__":
68
+ raise SystemExit(main())
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+
6
+ from logbrew_sdk import LogBrewClient, RecordingTransport
7
+
8
+
9
+ def main() -> int:
10
+ client = LogBrewClient.create(
11
+ api_key="LOGBREW_API_KEY",
12
+ sdk_name="logbrew-python",
13
+ sdk_version="0.1.0",
14
+ )
15
+
16
+ client.release(
17
+ "evt_release_001",
18
+ "2026-06-02T10:00:00Z",
19
+ {
20
+ "version": "1.2.3",
21
+ "commit": "abc123def456",
22
+ "notes": "Public release marker",
23
+ },
24
+ )
25
+ client.environment(
26
+ "evt_environment_001",
27
+ "2026-06-02T10:00:01Z",
28
+ {"name": "production", "region": "global"},
29
+ )
30
+ client.issue(
31
+ "evt_issue_001",
32
+ "2026-06-02T10:00:02Z",
33
+ {
34
+ "title": "Checkout timeout",
35
+ "level": "error",
36
+ "message": "Request timed out after retry budget",
37
+ },
38
+ )
39
+ client.log(
40
+ "evt_log_001",
41
+ "2026-06-02T10:00:03Z",
42
+ {"message": "worker started", "level": "info", "logger": "job-runner"},
43
+ )
44
+ client.span(
45
+ "evt_span_001",
46
+ "2026-06-02T10:00:04Z",
47
+ {
48
+ "name": "GET /health",
49
+ "traceId": "trace_001",
50
+ "spanId": "span_001",
51
+ "status": "ok",
52
+ "durationMs": 12.5,
53
+ },
54
+ )
55
+ client.action(
56
+ "evt_action_001",
57
+ "2026-06-02T10:00:05Z",
58
+ {"name": "deploy", "status": "success"},
59
+ )
60
+
61
+ print(client.preview_json())
62
+ transport = RecordingTransport.always_accept()
63
+ response = client.shutdown(transport)
64
+ print(
65
+ json.dumps(
66
+ {
67
+ "ok": True,
68
+ "status": response.status_code,
69
+ "attempts": response.attempts,
70
+ "events": 6,
71
+ }
72
+ ),
73
+ file=sys.stderr,
74
+ )
75
+ return 0
76
+
77
+
78
+ if __name__ == "__main__":
79
+ raise SystemExit(main())
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+
6
+ from logbrew_sdk import LogBrewClient, RecordingTransport
7
+
8
+
9
+ def main() -> int:
10
+ client = LogBrewClient.create(
11
+ api_key="LOGBREW_API_KEY",
12
+ sdk_name="logbrew-python",
13
+ sdk_version="0.1.0",
14
+ )
15
+
16
+ client.release(
17
+ "evt_release_001",
18
+ "2026-06-02T10:00:00Z",
19
+ {
20
+ "version": "1.2.3",
21
+ "commit": "abc123def456",
22
+ "notes": "Public release marker",
23
+ },
24
+ )
25
+ client.environment(
26
+ "evt_environment_001",
27
+ "2026-06-02T10:00:01Z",
28
+ {"name": "production", "region": "global"},
29
+ )
30
+ client.issue(
31
+ "evt_issue_001",
32
+ "2026-06-02T10:00:02Z",
33
+ {
34
+ "title": "Checkout timeout",
35
+ "level": "error",
36
+ "message": "Request timed out after retry budget",
37
+ },
38
+ )
39
+ client.log(
40
+ "evt_log_001",
41
+ "2026-06-02T10:00:03Z",
42
+ {"message": "worker started", "level": "info", "logger": "job-runner"},
43
+ )
44
+ client.span(
45
+ "evt_span_001",
46
+ "2026-06-02T10:00:04Z",
47
+ {
48
+ "name": "GET /health",
49
+ "traceId": "trace_001",
50
+ "spanId": "span_001",
51
+ "status": "ok",
52
+ "durationMs": 12.5,
53
+ },
54
+ )
55
+ client.action(
56
+ "evt_action_001",
57
+ "2026-06-02T10:00:05Z",
58
+ {"name": "deploy", "status": "success"},
59
+ )
60
+
61
+ print(client.preview_json())
62
+ transport = RecordingTransport.always_accept()
63
+ response = client.shutdown(transport)
64
+ print(
65
+ json.dumps(
66
+ {
67
+ "ok": True,
68
+ "status": response.status_code,
69
+ "attempts": response.attempts,
70
+ "events": 6,
71
+ }
72
+ ),
73
+ file=sys.stderr,
74
+ )
75
+ return 0
76
+
77
+
78
+ if __name__ == "__main__":
79
+ raise SystemExit(main())
logbrew_sdk/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: logbrew-sdk
3
+ Version: 0.1.0
4
+ Summary: Public LogBrew Python SDK for building, validating, and flushing event batches.
5
+ Author: LogBrew
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/LogBrewCo/sdk
8
+ Keywords: logbrew,observability,logs,traces,events
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+
19
+ # logbrew-sdk
20
+
21
+ Public Python SDK for creating LogBrew event batches, validating them locally, and flushing them through a transport.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ python3 -m pip install logbrew-sdk
27
+ python3 -m logbrew_sdk.examples --help
28
+ python3 -m logbrew_sdk.examples --list
29
+ python3 -m logbrew_sdk.examples readme-example
30
+ python3 -m logbrew_sdk.examples real-user-smoke
31
+ python3 -m logbrew_sdk.examples
32
+ python3 -m logbrew_sdk.examples.readme_example
33
+ python3 -m logbrew_sdk.examples.real_user_smoke
34
+ ```
35
+
36
+ The built wheel should also carry `py.typed` and wheel metadata with the expected package description before install.
37
+ Normal installs also expose standard package metadata like `pip show logbrew-sdk`, `pip show -f logbrew-sdk`, `pip list --format=json`, `pip freeze`, and `importlib.metadata.version("logbrew-sdk")`. The plain `pip show` summary should keep the expected package name, version, summary, author, license expression, and `site-packages` install location.
38
+ The built source distribution should also carry `README.md`, `pyproject.toml`, `py.typed`, and the packaged `logbrew_sdk.examples.readme_example` plus `logbrew_sdk.examples.real_user_smoke` modules in the archive itself. Both wheel and source-distribution installs carry the expected `py.typed`, example modules, and dist-info metadata files in `site-packages`, and that installed metadata keeps the pip install command, fake `LOGBREW_API_KEY` placeholder, `preview_json()` guidance, and packaged examples entrypoint commands a user would expect from the package description. Those installs should also keep pip-written `INSTALLER`, `direct_url.json`, `--report`, `pip inspect`, plain `pip show` summary fields, `pip show -f` file listings, and `pip list --format=json` package listings plus the expected `pip freeze` file URL with sha256 provenance so tooling can confirm the package came from the expected wheel or source-distribution artifact, the installed environment should stay clean under `python -m pip check`, both the wheel and source-distribution paths should survive a clean `python -m pip uninstall -y logbrew-sdk` removal before reinstalling the same artifact, a small installed-user `python -m unittest` run should still succeed, the published README example should still run from the installed package on both the main install and the reinstall paths, and the packaged examples entrypoint should be discoverable and runnable through `python -m logbrew_sdk.examples --help`, `python -m logbrew_sdk.examples --list`, `python -m logbrew_sdk.examples readme-example`, `python -m logbrew_sdk.examples real-user-smoke`, `python -m logbrew_sdk.examples`, `python -m logbrew_sdk.examples.readme_example`, and `python -m logbrew_sdk.examples.real_user_smoke`, with both `--help` and `--list` printing copy-pasteable packaged-example commands, including explicit named README-example and real-user-smoke entrypoint commands plus the default no-argument `python -m logbrew_sdk.examples` path being called out explicitly as the `real-user-smoke` entrypoint, instead of only generic argument help or bare example names. A one-line direct requirements file derived from that freeze output should also reinstall cleanly under `python -m pip install --require-hashes -r ...` in a fresh virtual environment.
39
+ The installed module, public payload shape types like `ReleaseAttributes`, `SpanAttributes`, and `TraceparentContext`, `LogBrewClient`, `HttpTransport`, `RecordingTransport`, `SdkError`, `TransportResponse`, `TransportError`, W3C trace helpers like `parse_traceparent()`, `create_traceparent()`, and `span_attributes_from_traceparent()`, and key lifecycle methods like `create()`, `preview_json()`, `flush()`, `shutdown()`, `pending_events()`, `always_accept()`, and `TransportError.network()` also expose stable docstrings that tools like `help(...)` can show after install. Installed wheel and sdist paths now both prove the field-level typing metadata for commonly inspected attributes like `TransportResponse.status_code`, `TransportResponse.attempts`, and `RecordingTransport.sent_bodies`, prove the typed consumer through a temp `pyproject.toml`-driven mypy config, and prove a consumer-owned `Makefile` that wraps the installed-user typecheck, unittest, README-example, packaged-example, packaged examples list, packaged examples help, packaged examples entrypoint, packaged real-user example, and happy-path smoke commands instead of relying only on loose raw commands, with plain `make` printing copy-pasteable `make smoke-...` commands and the shorter `make smoke-run` path labeled explicitly as the `real-user-smoke` flow.
40
+
41
+ ## Example
42
+
43
+ ```python
44
+ import json
45
+ import sys
46
+
47
+ from logbrew_sdk import LogBrewClient, RecordingTransport
48
+
49
+ client = LogBrewClient.create(
50
+ api_key="LOGBREW_API_KEY",
51
+ sdk_name="logbrew-python",
52
+ sdk_version="0.1.0",
53
+ )
54
+
55
+ client.release(
56
+ "evt_release_001",
57
+ "2026-06-02T10:00:00Z",
58
+ {
59
+ "version": "1.2.3",
60
+ "commit": "abc123def456",
61
+ "notes": "Public release marker",
62
+ },
63
+ )
64
+ client.environment(
65
+ "evt_environment_001",
66
+ "2026-06-02T10:00:01Z",
67
+ {"name": "production", "region": "global"},
68
+ )
69
+ client.issue(
70
+ "evt_issue_001",
71
+ "2026-06-02T10:00:02Z",
72
+ {
73
+ "title": "Checkout timeout",
74
+ "level": "error",
75
+ "message": "Request timed out after retry budget",
76
+ },
77
+ )
78
+ client.log(
79
+ "evt_log_001",
80
+ "2026-06-02T10:00:03Z",
81
+ {"message": "worker started", "level": "info", "logger": "job-runner"},
82
+ )
83
+ client.span(
84
+ "evt_span_001",
85
+ "2026-06-02T10:00:04Z",
86
+ {
87
+ "name": "GET /health",
88
+ "traceId": "trace_001",
89
+ "spanId": "span_001",
90
+ "status": "ok",
91
+ "durationMs": 12.5,
92
+ },
93
+ )
94
+ client.action(
95
+ "evt_action_001",
96
+ "2026-06-02T10:00:05Z",
97
+ {"name": "deploy", "status": "success"},
98
+ )
99
+
100
+ print(client.preview_json())
101
+
102
+ transport = RecordingTransport.always_accept()
103
+ response = client.shutdown(transport)
104
+ print(
105
+ json.dumps(
106
+ {"ok": True, "status": response.status_code, "attempts": response.attempts, "events": 6}
107
+ ),
108
+ file=sys.stderr,
109
+ )
110
+ ```
111
+
112
+ Use a clearly fake placeholder like `LOGBREW_API_KEY` in local examples and tests. Call `flush()` or `shutdown()` to send queued events through a transport, and use `preview_json()` when you want a stable local JSON preview without sending anything.
113
+
114
+ ## Trace Context
115
+
116
+ Use the W3C helpers when a Python service needs to interoperate with distributed tracing headers:
117
+
118
+ ```python
119
+ from logbrew_sdk import parse_traceparent, span_attributes_from_traceparent
120
+
121
+ traceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
122
+ context = parse_traceparent(traceparent)
123
+ attributes = span_attributes_from_traceparent(
124
+ traceparent,
125
+ name="GET /health",
126
+ span_id="b7ad6b7169203331",
127
+ status="ok",
128
+ duration_ms=12.5,
129
+ metadata={"service": "checkout"},
130
+ )
131
+ ```
132
+
133
+ `parse_traceparent()` validates W3C shape, rejects all-zero trace/span IDs, normalizes IDs to lowercase, and exposes the sampled flag. `span_attributes_from_traceparent()` returns LogBrew span attributes with `traceId` from the incoming trace and `parentSpanId` from the incoming parent span. FastAPI and Django integrations use these helpers automatically for valid inbound `traceparent` headers and start a fresh synthetic span when the header is missing or malformed.
134
+
135
+ ## HTTP Delivery
136
+
137
+ Use `HttpTransport` for real outbound delivery from server-side Python apps:
138
+
139
+ ```python
140
+ from logbrew_sdk import HttpTransport, LogBrewClient
141
+
142
+ client = LogBrewClient.create(
143
+ api_key="LOGBREW_API_KEY",
144
+ sdk_name="logbrew-python",
145
+ sdk_version="0.1.0",
146
+ )
147
+ transport = HttpTransport(
148
+ endpoint="https://api.logbrew.com/v1/events",
149
+ headers={"x-logbrew-source": "python-worker"},
150
+ )
151
+
152
+ client.log(
153
+ "evt_worker_started",
154
+ "2026-06-02T10:00:06Z",
155
+ {"message": "worker started", "level": "info", "logger": "worker"},
156
+ )
157
+ client.flush(transport)
158
+ ```
159
+
160
+ `HttpTransport` uses Python's standard-library HTTP stack, posts JSON, passes the SDK key through the `authorization` header, supports custom endpoint/header/timeout settings, and maps connection failures into retryable `TransportError.network(...)` failures so `LogBrewClient.flush()` can preserve queued events and retry.
161
+
162
+ ## Standard Logging
163
+
164
+ Use `LogBrewLoggingHandler` when an application already uses Python's standard `logging` module:
165
+
166
+ ```python
167
+ import logging
168
+
169
+ from logbrew_sdk import LogBrewClient, LogBrewLoggingHandler, RecordingTransport
170
+
171
+ client = LogBrewClient.create(
172
+ api_key="LOGBREW_API_KEY",
173
+ sdk_name="logbrew-python",
174
+ sdk_version="0.1.0",
175
+ )
176
+ transport = RecordingTransport.always_accept()
177
+ handler = LogBrewLoggingHandler(
178
+ client,
179
+ transport,
180
+ flush_on_emit=True,
181
+ metadata={"service": "checkout"},
182
+ )
183
+
184
+ logger = logging.getLogger("checkout.worker")
185
+ logger.addHandler(handler)
186
+ logger.setLevel(logging.INFO)
187
+
188
+ logger.info("worker started", extra={"order_id": "ord_123"})
189
+ ```
190
+
191
+ The handler does not change global logging configuration. It maps standard logging levels into LogBrew log levels, keeps the logger name, captures primitive `extra={...}` values as metadata, and records source file name, function, line, thread, and process names without sending the full source path by default. Exception type and message are captured when `exc_info` is present; full exception text is opt-in with `include_exception_text=True`.
@@ -0,0 +1,10 @@
1
+ logbrew_sdk/__init__.py,sha256=PT_2uoBFshaK-unOAv1CAfglkIKLeXR2v2mJveatZYA,26786
2
+ logbrew_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
3
+ logbrew_sdk/examples/__init__.py,sha256=Vkg32KK8LKf_wS7UV-kIvkJGxj8GTraBtV_DNXHdH4c,66
4
+ logbrew_sdk/examples/__main__.py,sha256=aVX8pj8_KtMaHh5sZVvjlRRfwwyR9HjgOnLiH-0JAPo,1857
5
+ logbrew_sdk/examples/readme_example.py,sha256=aZ4QegRJVw3IDePBIAvUzQoIpuU3vIcSYf_c8PXcV6c,1888
6
+ logbrew_sdk/examples/real_user_smoke.py,sha256=aZ4QegRJVw3IDePBIAvUzQoIpuU3vIcSYf_c8PXcV6c,1888
7
+ logbrew_sdk-0.1.0.dist-info/METADATA,sha256=OQXwGaN0U4hjyRPwh6lKxovqfzEhx7D1NlGJq6vDwSI,10071
8
+ logbrew_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ logbrew_sdk-0.1.0.dist-info/top_level.txt,sha256=1oEEYoCow7Aw4_SrnaeLpD_bc9zxLbqIeMdWkJd9HgQ,12
10
+ logbrew_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ logbrew_sdk