loki-reader-core 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/PKG-INFO +1 -1
  2. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/pyproject.toml +1 -1
  3. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/__init__.py +1 -1
  4. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/client.py +48 -4
  5. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/tests/test_client.py +117 -7
  6. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/.gitignore +0 -0
  7. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/README.md +0 -0
  8. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/build-publish.sh +0 -0
  9. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/dev-requirements.txt +0 -0
  10. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/exceptions.py +0 -0
  11. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/__init__.py +0 -0
  12. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/log_entry.py +0 -0
  13. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/log_stream.py +0 -0
  14. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/metric_sample.py +0 -0
  15. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/metric_series.py +0 -0
  16. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/query_result.py +0 -0
  17. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/models/query_stats.py +0 -0
  18. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/src/loki_reader_core/utils.py +0 -0
  19. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/tests/__init__.py +0 -0
  20. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/tests/test_models.py +0 -0
  21. {loki_reader_core-0.2.0 → loki_reader_core-0.2.1}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loki-reader-core
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Python library for querying Grafana Loki logs via REST API
5
5
  Project-URL: Homepage, https://github.com/jmazzahacks/loki-reader-core
6
6
  Project-URL: Issues, https://github.com/jmazzahacks/loki-reader-core/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "loki-reader-core"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  authors = [
5
5
  { name="Jason Byteforge", email="jason@mzmail.me" },
6
6
  ]
@@ -6,7 +6,7 @@ from .client import LokiClient
6
6
  from .exceptions import LokiAuthError, LokiConnectionError, LokiError, LokiQueryError
7
7
  from .models import LogEntry, LogStream, MetricSample, MetricSeries, QueryResult, QueryStats
8
8
 
9
- __version__ = "0.2.0"
9
+ __version__ = "0.2.1"
10
10
 
11
11
  __all__ = [
12
12
  "LokiClient",
@@ -7,7 +7,7 @@ from typing import Optional
7
7
  import requests
8
8
 
9
9
  from .exceptions import LokiAuthError, LokiConnectionError, LokiQueryError
10
- from .models import QueryResult
10
+ from .models import LogStream, QueryResult
11
11
  from .utils import now_ns, NANOSECONDS_PER_MINUTE, NANOSECONDS_PER_HOUR
12
12
 
13
13
  APP_LABEL_NAMES = ["application", "app", "job", "service", "service_name", "logger"]
@@ -106,6 +106,44 @@ def _resolve_since(
106
106
  return None
107
107
 
108
108
 
109
+ def _merge_streams(result: QueryResult, app_label: str, app_value: str) -> QueryResult:
110
+ """Merge multiple log streams into a single stream sorted by timestamp.
111
+
112
+ Used for app-based queries where Loki returns separate streams per
113
+ logger/severity combination. Combines all entries into one flat
114
+ chronological list.
115
+
116
+ Args:
117
+ result: QueryResult with potentially multiple streams.
118
+ app_label: The label name used for the application (e.g. "application").
119
+ app_value: The application name (e.g. "materia-server").
120
+
121
+ Returns:
122
+ QueryResult with a single merged stream.
123
+ """
124
+ if len(result.streams) <= 1:
125
+ return result
126
+
127
+ all_entries = []
128
+ for stream in result.streams:
129
+ all_entries.extend(stream.entries)
130
+
131
+ all_entries.sort(key=lambda e: e.timestamp, reverse=True)
132
+
133
+ merged_stream = LogStream(
134
+ labels={app_label: app_value},
135
+ entries=all_entries,
136
+ )
137
+
138
+ return QueryResult(
139
+ status=result.status,
140
+ streams=[merged_stream],
141
+ stats=result.stats,
142
+ result_type=result.result_type,
143
+ metric_series=result.metric_series,
144
+ )
145
+
146
+
109
147
  class LokiClient:
110
148
  """
111
149
  Client for querying Grafana Loki logs.
@@ -319,9 +357,10 @@ class LokiClient:
319
357
  if logql is None and app is None:
320
358
  raise ValueError("Must provide either 'logql' or 'app'")
321
359
 
360
+ app_label = None
322
361
  if app is not None:
323
- label_name = self._find_app_label(app)
324
- selector = f'{label_name}="{app}"'
362
+ app_label = self._find_app_label(app)
363
+ selector = f'{app_label}="{app}"'
325
364
  if severity is not None:
326
365
  sev_label = self._find_severity_label()
327
366
  if sev_label:
@@ -344,7 +383,7 @@ class LokiClient:
344
383
  start = end - (30 * 24 * NANOSECONDS_PER_HOUR)
345
384
  time_range = (start, end)
346
385
 
347
- return self.query_range(
386
+ result = self.query_range(
348
387
  logql=logql,
349
388
  start=time_range[0],
350
389
  end=time_range[1],
@@ -352,6 +391,11 @@ class LokiClient:
352
391
  direction="backward",
353
392
  )
354
393
 
394
+ if app_label is not None:
395
+ result = _merge_streams(result, app_label, app)
396
+
397
+ return result
398
+
355
399
  def query_range(
356
400
  self,
357
401
  logql: str,
@@ -4,8 +4,10 @@ from unittest.mock import MagicMock, patch, call
4
4
 
5
5
  import pytest
6
6
 
7
- from loki_reader_core import LokiClient
8
- from loki_reader_core.client import _is_metric_query, _build_severity_regex, _resolve_since
7
+ from loki_reader_core import LokiClient, LogEntry, LogStream, QueryResult
8
+ from loki_reader_core.client import (
9
+ _is_metric_query, _build_severity_regex, _resolve_since, _merge_streams,
10
+ )
9
11
  from loki_reader_core.exceptions import LokiAuthError, LokiConnectionError, LokiQueryError
10
12
 
11
13
 
@@ -509,16 +511,101 @@ class TestLabelDiscovery:
509
511
  mock_glv.assert_called_once_with("level")
510
512
 
511
513
 
514
+ class TestMergeStreams:
515
+ """Test _merge_streams helper function."""
516
+
517
+ def test_single_stream_unchanged(self) -> None:
518
+ stream = LogStream(
519
+ labels={"application": "myapp", "logger": "root"},
520
+ entries=[LogEntry(timestamp=100, message="hello")],
521
+ )
522
+ result = QueryResult(status="success", streams=[stream], stats=None)
523
+
524
+ merged = _merge_streams(result, "application", "myapp")
525
+
526
+ assert len(merged.streams) == 1
527
+ assert merged.streams[0] is stream # same object, not rebuilt
528
+
529
+ def test_empty_streams_unchanged(self) -> None:
530
+ result = QueryResult(status="success", streams=[], stats=None)
531
+ merged = _merge_streams(result, "application", "myapp")
532
+ assert len(merged.streams) == 0
533
+
534
+ def test_multiple_streams_merged(self) -> None:
535
+ stream1 = LogStream(
536
+ labels={"application": "myapp", "logger": "root", "severity": "info"},
537
+ entries=[
538
+ LogEntry(timestamp=300, message="root msg 1"),
539
+ LogEntry(timestamp=100, message="root msg 2"),
540
+ ],
541
+ )
542
+ stream2 = LogStream(
543
+ labels={"application": "myapp", "logger": "__main__", "severity": "info"},
544
+ entries=[
545
+ LogEntry(timestamp=200, message="main msg 1"),
546
+ ],
547
+ )
548
+ result = QueryResult(
549
+ status="success", streams=[stream1, stream2], stats=None,
550
+ )
551
+
552
+ merged = _merge_streams(result, "application", "myapp")
553
+
554
+ assert len(merged.streams) == 1
555
+ assert merged.streams[0].labels == {"application": "myapp"}
556
+ assert len(merged.streams[0].entries) == 3
557
+ # Sorted descending by timestamp
558
+ timestamps = [e.timestamp for e in merged.streams[0].entries]
559
+ assert timestamps == [300, 200, 100]
560
+
561
+ def test_merged_preserves_stats(self) -> None:
562
+ from loki_reader_core import QueryStats
563
+ stats = QueryStats(
564
+ exec_time_seconds=0.05, bytes_processed=1024,
565
+ lines_processed=10,
566
+ )
567
+ stream1 = LogStream(
568
+ labels={"app": "x", "logger": "a"},
569
+ entries=[LogEntry(timestamp=2, message="a")],
570
+ )
571
+ stream2 = LogStream(
572
+ labels={"app": "x", "logger": "b"},
573
+ entries=[LogEntry(timestamp=1, message="b")],
574
+ )
575
+ result = QueryResult(
576
+ status="success", streams=[stream1, stream2], stats=stats,
577
+ )
578
+
579
+ merged = _merge_streams(result, "app", "x")
580
+
581
+ assert merged.stats is stats
582
+ assert merged.status == "success"
583
+
584
+
512
585
  class TestLokiClientQueryRedesign:
513
586
  """Test redesigned query() method."""
514
587
 
588
+ def _make_multi_stream_result(self) -> QueryResult:
589
+ """Helper to create a QueryResult with multiple streams."""
590
+ stream1 = LogStream(
591
+ labels={"application": "myapp", "logger": "root", "severity": "info"},
592
+ entries=[LogEntry(timestamp=300, message="root log")],
593
+ )
594
+ stream2 = LogStream(
595
+ labels={"application": "myapp", "logger": "__main__", "severity": "info"},
596
+ entries=[LogEntry(timestamp=200, message="main log")],
597
+ )
598
+ return QueryResult(
599
+ status="success", streams=[stream1, stream2], stats=None,
600
+ )
601
+
515
602
  def test_query_app_only(self) -> None:
516
603
  client = LokiClient(base_url="http://localhost:3100")
517
604
  with patch.object(client, "_find_app_label", return_value="application"), \
518
605
  patch.object(client, "query_range") as mock_qr:
519
- mock_qr.return_value = MagicMock()
606
+ mock_qr.return_value = self._make_multi_stream_result()
520
607
 
521
- client.query(app="myapp")
608
+ result = client.query(app="myapp")
522
609
 
523
610
  mock_qr.assert_called_once()
524
611
  args = mock_qr.call_args
@@ -526,12 +613,35 @@ class TestLokiClientQueryRedesign:
526
613
  assert args.kwargs["limit"] == 100
527
614
  assert args.kwargs["direction"] == "backward"
528
615
 
616
+ def test_query_app_merges_streams(self) -> None:
617
+ client = LokiClient(base_url="http://localhost:3100")
618
+ with patch.object(client, "_find_app_label", return_value="application"), \
619
+ patch.object(client, "query_range") as mock_qr:
620
+ mock_qr.return_value = self._make_multi_stream_result()
621
+
622
+ result = client.query(app="myapp")
623
+
624
+ assert len(result.streams) == 1
625
+ assert result.streams[0].labels == {"application": "myapp"}
626
+ assert len(result.streams[0].entries) == 2
627
+ timestamps = [e.timestamp for e in result.streams[0].entries]
628
+ assert timestamps == [300, 200]
629
+
630
+ def test_query_logql_does_not_merge(self) -> None:
631
+ client = LokiClient(base_url="http://localhost:3100")
632
+ with patch.object(client, "query_range") as mock_qr:
633
+ mock_qr.return_value = self._make_multi_stream_result()
634
+
635
+ result = client.query(logql='{application="myapp"}')
636
+
637
+ assert len(result.streams) == 2
638
+
529
639
  def test_query_app_with_severity(self) -> None:
530
640
  client = LokiClient(base_url="http://localhost:3100")
531
641
  with patch.object(client, "_find_app_label", return_value="application"), \
532
642
  patch.object(client, "_find_severity_label", return_value="level"), \
533
643
  patch.object(client, "query_range") as mock_qr:
534
- mock_qr.return_value = MagicMock()
644
+ mock_qr.return_value = self._make_multi_stream_result()
535
645
 
536
646
  client.query(app="myapp", severity="error")
537
647
 
@@ -542,7 +652,7 @@ class TestLokiClientQueryRedesign:
542
652
  client = LokiClient(base_url="http://localhost:3100")
543
653
  with patch.object(client, "_find_app_label", return_value="application"), \
544
654
  patch.object(client, "query_range") as mock_qr:
545
- mock_qr.return_value = MagicMock()
655
+ mock_qr.return_value = self._make_multi_stream_result()
546
656
 
547
657
  client.query(app="myapp", since_minutes=10)
548
658
 
@@ -590,7 +700,7 @@ class TestLokiClientQueryRedesign:
590
700
  client = LokiClient(base_url="http://localhost:3100")
591
701
  with patch.object(client, "_find_app_label", return_value="job"), \
592
702
  patch.object(client, "query_range") as mock_qr:
593
- mock_qr.return_value = MagicMock()
703
+ mock_qr.return_value = self._make_multi_stream_result()
594
704
 
595
705
  client.query(app="myapp")
596
706