devlogs 2.3.1__tar.gz → 2.3.2__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 (82) hide show
  1. {devlogs-2.3.1/src/devlogs.egg-info → devlogs-2.3.2}/PKG-INFO +1 -1
  2. {devlogs-2.3.1 → devlogs-2.3.2}/pyproject.toml +1 -1
  3. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/_version_static.py +1 -1
  4. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/cli.py +148 -1
  5. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/config.py +61 -4
  6. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/loki/queries.py +9 -4
  7. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/mcp/server.py +17 -2
  8. {devlogs-2.3.1 → devlogs-2.3.2/src/devlogs.egg-info}/PKG-INFO +1 -1
  9. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_cli.py +44 -0
  10. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_config.py +67 -0
  11. {devlogs-2.3.1 → devlogs-2.3.2}/LICENSE +0 -0
  12. {devlogs-2.3.1 → devlogs-2.3.2}/MANIFEST.in +0 -0
  13. {devlogs-2.3.1 → devlogs-2.3.2}/README.md +0 -0
  14. {devlogs-2.3.1 → devlogs-2.3.2}/setup.cfg +0 -0
  15. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/__init__.py +0 -0
  16. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/__main__.py +0 -0
  17. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/build_info.py +0 -0
  18. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/collector/__init__.py +0 -0
  19. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/collector/auth.py +0 -0
  20. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/collector/cli.py +0 -0
  21. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/collector/errors.py +0 -0
  22. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/collector/forwarder.py +0 -0
  23. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/collector/ingestor.py +0 -0
  24. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/collector/loki_plugin.py +0 -0
  25. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/collector/plugins.py +0 -0
  26. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/collector/schema.py +0 -0
  27. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/collector/server.py +0 -0
  28. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/context.py +0 -0
  29. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/demo.py +0 -0
  30. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/devlogs_client.py +0 -0
  31. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/formatting.py +0 -0
  32. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/handler.py +0 -0
  33. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/jenkins/__init__.py +0 -0
  34. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/jenkins/cli.py +0 -0
  35. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/jenkins/core.py +0 -0
  36. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/levels.py +0 -0
  37. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/loki/__init__.py +0 -0
  38. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/mcp/__init__.py +0 -0
  39. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/opensearch/__init__.py +0 -0
  40. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/opensearch/client.py +0 -0
  41. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/opensearch/indexing.py +0 -0
  42. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/opensearch/mappings.py +0 -0
  43. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/opensearch/queries.py +0 -0
  44. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/proxy/__init__.py +0 -0
  45. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/proxy/server.py +0 -0
  46. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/retention.py +0 -0
  47. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/scrub.py +0 -0
  48. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/time_utils.py +0 -0
  49. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/version.py +0 -0
  50. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/web/__init__.py +0 -0
  51. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/web/server.py +0 -0
  52. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/web/static/devlogs.css +0 -0
  53. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/web/static/devlogs.js +0 -0
  54. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/web/static/index.html +0 -0
  55. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs/wrapper.py +0 -0
  56. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs.egg-info/SOURCES.txt +0 -0
  57. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs.egg-info/dependency_links.txt +0 -0
  58. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs.egg-info/entry_points.txt +0 -0
  59. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs.egg-info/requires.txt +0 -0
  60. {devlogs-2.3.1 → devlogs-2.3.2}/src/devlogs.egg-info/top_level.txt +0 -0
  61. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_build_info.py +0 -0
  62. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_collector_auth.py +0 -0
  63. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_collector_config.py +0 -0
  64. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_collector_plugins.py +0 -0
  65. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_collector_schema.py +0 -0
  66. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_collector_server.py +0 -0
  67. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_context.py +0 -0
  68. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_devlogs_client.py +0 -0
  69. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_formatting.py +0 -0
  70. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_handler.py +0 -0
  71. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_indexing.py +0 -0
  72. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_levels.py +0 -0
  73. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_mappings.py +0 -0
  74. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_mcp_server.py +0 -0
  75. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_opensearch_client.py +0 -0
  76. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_opensearch_queries.py +0 -0
  77. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_proxy_server.py +0 -0
  78. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_retention.py +0 -0
  79. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_scrub.py +0 -0
  80. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_time_utils.py +0 -0
  81. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_url_parsing.py +0 -0
  82. {devlogs-2.3.1 → devlogs-2.3.2}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlogs
3
- Version: 2.3.1
3
+ Version: 2.3.2
4
4
  Summary: Developer-focused logging library for Python with OpenSearch integration.
5
5
  Author-email: Dan Driscoll <dan@thedandriscoll.org>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devlogs"
7
- version = "2.3.1"
7
+ version = "2.3.2"
8
8
  description = "Developer-focused logging library for Python with OpenSearch integration."
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -1,2 +1,2 @@
1
1
  # AUTO-GENERATED at build time — do not edit or commit
2
- __version__ = "2.3.1"
2
+ __version__ = "2.3.2"
@@ -10,7 +10,7 @@ import click
10
10
  import typer
11
11
  from pathlib import Path
12
12
 
13
- from .config import load_config, set_dotenv_path, set_url, URLParseError, _parse_opensearch_url, parse_url, CollectorURLConfig
13
+ from .config import load_config, set_dotenv_path, set_url, URLParseError, _parse_opensearch_url, parse_url, CollectorURLConfig, LokiURLConfig
14
14
  from .formatting import format_timestamp, all_entries_today, format_entry_text
15
15
  from .opensearch.client import (
16
16
  get_opensearch_client,
@@ -313,6 +313,12 @@ def init(
313
313
  automatically migrate data from v1 to v2 schema.
314
314
  """
315
315
  _apply_common_options(env, url)
316
+
317
+ cfg = load_config()
318
+ if cfg.is_loki:
319
+ typer.echo("Loki backends do not require initialization. No action needed.")
320
+ return
321
+
316
322
  client, cfg = require_opensearch(check_idx=False)
317
323
 
318
324
  # Check existing index schema
@@ -780,6 +786,123 @@ def diagnose(
780
786
  raise typer.Exit(1)
781
787
 
782
788
 
789
+ def _format_loki_entries(entries, utc=False, jsonl=False):
790
+ """Format and print Loki log entries."""
791
+ if jsonl:
792
+ for entry in entries:
793
+ typer.echo(json.dumps(entry, default=str))
794
+ else:
795
+ use_color = sys.stdout.isatty()
796
+ normalized = []
797
+ for e in entries:
798
+ normalized.append({
799
+ "timestamp": e.get("timestamp") or "",
800
+ "level": e.get("level") or "",
801
+ "area": e.get("area") or "",
802
+ "component": e.get("component") or "",
803
+ "operation_id": e.get("operation_id") or "",
804
+ "message": e.get("message") or "",
805
+ "fields": e.get("fields"),
806
+ })
807
+ omit_date = all_entries_today(normalized, use_utc=utc)
808
+ for doc in normalized:
809
+ line = format_entry_text(
810
+ doc, use_utc=utc, omit_date=omit_date,
811
+ color=use_color, format_features_fn=_format_features,
812
+ )
813
+ typer.echo(line)
814
+
815
+
816
+ def _tail_loki(cfg, application=None, operation_id=None, area=None,
817
+ component=None, level=None, since=None, limit=20, follow=False,
818
+ utc=False, jsonl=False):
819
+ """Tail logs from a Loki backend."""
820
+ from .loki.queries import tail as loki_tail
821
+
822
+ effective_app = application or cfg.application
823
+ if not effective_app:
824
+ typer.echo(typer.style(
825
+ "Error: --application is required for Loki backends",
826
+ fg=typer.colors.RED
827
+ ), err=True)
828
+ raise typer.Exit(1)
829
+
830
+ first_poll = True
831
+ while True:
832
+ try:
833
+ entries = loki_tail(
834
+ loki_url=cfg.loki_url,
835
+ app=effective_app,
836
+ level=level,
837
+ component=component,
838
+ since=since or "10m",
839
+ limit=limit,
840
+ token=cfg.loki_token,
841
+ )
842
+ except Exception as e:
843
+ typer.echo(typer.style(
844
+ f"Error: {type(e).__name__}: {e}",
845
+ fg=typer.colors.RED
846
+ ), err=True)
847
+ raise typer.Exit(1)
848
+
849
+ if first_poll and not entries:
850
+ typer.echo(typer.style("No logs found.", dim=True), err=True)
851
+ first_poll = False
852
+
853
+ _format_loki_entries(entries, utc=utc, jsonl=jsonl)
854
+
855
+ if not follow:
856
+ break
857
+ time.sleep(2)
858
+
859
+
860
+ def _search_loki(cfg, q="", application=None, area=None, component=None,
861
+ level=None, operation_id=None, since=None, limit=50, follow=False,
862
+ utc=False, jsonl=False):
863
+ """Search logs from a Loki backend."""
864
+ from .loki.queries import search as loki_search
865
+
866
+ effective_app = application or cfg.application
867
+ if not effective_app:
868
+ typer.echo(typer.style(
869
+ "Error: --application is required for Loki backends",
870
+ fg=typer.colors.RED
871
+ ), err=True)
872
+ raise typer.Exit(1)
873
+
874
+ first_poll = True
875
+ while True:
876
+ try:
877
+ entries = loki_search(
878
+ loki_url=cfg.loki_url,
879
+ app=effective_app,
880
+ level=level,
881
+ component=component,
882
+ area=area,
883
+ start=since,
884
+ limit=limit,
885
+ filter_text=q or None,
886
+ token=cfg.loki_token,
887
+ )
888
+ except Exception as e:
889
+ typer.echo(typer.style(
890
+ f"Error: {type(e).__name__}: {e}",
891
+ fg=typer.colors.RED
892
+ ), err=True)
893
+ raise typer.Exit(1)
894
+
895
+ if first_poll and not entries:
896
+ typer.echo(typer.style("No logs found.", dim=True), err=True)
897
+ first_poll = False
898
+
899
+ _format_loki_entries(entries, utc=utc, jsonl=jsonl)
900
+
901
+ if not follow:
902
+ break
903
+ time.sleep(2)
904
+
905
+
783
906
  @app.command()
784
907
  def tail(
785
908
  operation_id: str = typer.Option(None, "--operation", "-o"),
@@ -801,6 +924,14 @@ def tail(
801
924
  import traceback
802
925
 
803
926
  _apply_common_options(env, url)
927
+
928
+ cfg = load_config()
929
+ if cfg.is_loki:
930
+ _tail_loki(cfg, application=application, operation_id=operation_id, area=area,
931
+ component=component, level=level, since=since, limit=limit, follow=follow,
932
+ utc=utc, jsonl=jsonl)
933
+ return
934
+
804
935
  client, cfg = require_opensearch()
805
936
 
806
937
  def _verbose_echo(message, color=typer.colors.BLUE):
@@ -982,6 +1113,14 @@ def search(
982
1113
  import urllib.error
983
1114
 
984
1115
  _apply_common_options(env, url)
1116
+
1117
+ cfg = load_config()
1118
+ if cfg.is_loki:
1119
+ _search_loki(cfg, q=q, application=application, area=area,
1120
+ component=component, level=level, operation_id=operation_id,
1121
+ since=since, limit=limit, follow=follow, utc=utc, jsonl=jsonl)
1122
+ return
1123
+
985
1124
  client, cfg = require_opensearch()
986
1125
  effective_application = application or cfg.application
987
1126
  search_after = None
@@ -1094,6 +1233,14 @@ def last_error(
1094
1233
  import urllib.error
1095
1234
 
1096
1235
  _apply_common_options(env, url)
1236
+
1237
+ cfg = load_config()
1238
+ if cfg.is_loki:
1239
+ _search_loki(cfg, q=q, application=application, area=area,
1240
+ component=component, level="error", operation_id=operation_id,
1241
+ since=since, limit=limit, follow=False, utc=utc, jsonl=False)
1242
+ return
1243
+
1097
1244
  client, cfg = require_opensearch()
1098
1245
  effective_application = application or cfg.application
1099
1246
 
@@ -129,6 +129,13 @@ class CollectorURLConfig:
129
129
  token: Optional[str] = None # Bearer token
130
130
 
131
131
 
132
+ @dataclass
133
+ class LokiURLConfig:
134
+ """Parsed Loki URL configuration (loki:// or lokis:// scheme)."""
135
+ url: str # Base HTTP(S) URL for Loki API (no credentials)
136
+ token: Optional[str] = None # Bearer token
137
+
138
+
132
139
  @dataclass
133
140
  class OpenSearchURLConfig:
134
141
  """Parsed OpenSearch URL configuration."""
@@ -142,19 +149,24 @@ class OpenSearchURLConfig:
142
149
 
143
150
 
144
151
  def parse_url(url: str):
145
- """Parse a URL and return a CollectorURLConfig or OpenSearchURLConfig.
152
+ """Parse a URL and return a CollectorURLConfig, LokiURLConfig, or OpenSearchURLConfig.
146
153
 
147
154
  Detection logic:
155
+ - loki:// (non-TLS) or lokis:// (TLS) → Loki
148
156
  - opensearchs:// (TLS) or opensearch:// (non-TLS) → OpenSearch
149
157
  - https:// or http:// with both user+pass → legacy OpenSearch (deprecation warning)
150
158
  - https:// or http:// with token-only or ?token= → Collector
151
159
 
152
- Returns: CollectorURLConfig or OpenSearchURLConfig
160
+ Returns: CollectorURLConfig, LokiURLConfig, or OpenSearchURLConfig
153
161
  Raises: URLParseError if URL is malformed
154
162
  """
155
163
  if not url:
156
164
  raise URLParseError("Empty URL")
157
165
 
166
+ # Check for loki:// or lokis:// schemes
167
+ if url.startswith("lokis://") or url.startswith("loki://"):
168
+ return _parse_loki_url(url)
169
+
158
170
  # Check for opensearch:// or opensearchs:// schemes
159
171
  if url.startswith("opensearchs://") or url.startswith("opensearch://"):
160
172
  return _parse_opensearch_scheme_url(url)
@@ -188,6 +200,39 @@ def parse_url(url: str):
188
200
  return _parse_collector_url_config(url)
189
201
 
190
202
 
203
+ def _parse_loki_url(url: str) -> LokiURLConfig:
204
+ """Parse loki:// (non-TLS) or lokis:// (TLS) URL into LokiURLConfig."""
205
+ if url.startswith("lokis://"):
206
+ transport_scheme = "https"
207
+ parse_url_str = "https://" + url[len("lokis://"):]
208
+ else:
209
+ transport_scheme = "http"
210
+ parse_url_str = "http://" + url[len("loki://"):]
211
+
212
+ parsed = urlparse(parse_url_str)
213
+
214
+ if not parsed.hostname:
215
+ raise URLParseError(f"Invalid URL '{url}': missing hostname")
216
+
217
+ token = None
218
+ if parsed.username and not parsed.password:
219
+ token = unquote(parsed.username)
220
+
221
+ # Rebuild clean URL without credentials
222
+ if parsed.port:
223
+ netloc = f"{parsed.hostname}:{parsed.port}"
224
+ else:
225
+ netloc = parsed.hostname or ""
226
+
227
+ from urllib.parse import urlunparse
228
+ clean_url = urlunparse((
229
+ transport_scheme, netloc, parsed.path,
230
+ parsed.params, parsed.query, parsed.fragment,
231
+ ))
232
+
233
+ return LokiURLConfig(url=clean_url, token=token)
234
+
235
+
191
236
  def _parse_opensearch_scheme_url(url: str) -> OpenSearchURLConfig:
192
237
  """Parse opensearchs:// (TLS) or opensearch:// (non-TLS) URL into OpenSearchURLConfig."""
193
238
  # Determine the transport scheme
@@ -342,6 +387,8 @@ class DevlogsConfig:
342
387
  # DEVLOGS_URL is the standard env var. It auto-detects collector vs OpenSearch
343
388
  # via parse_url(). DEVLOGS_OPENSEARCH_URL is a legacy alias for OpenSearch URLs.
344
389
  self.collector_url = ""
390
+ self.loki_url = None # Loki base URL (set when loki:// or lokis:// scheme used)
391
+ self.loki_token = None # Loki auth token
345
392
  self.application = None
346
393
  opensearch_url_config = None # result from _parse_opensearch_url if found
347
394
 
@@ -351,7 +398,10 @@ class DevlogsConfig:
351
398
  if devlogs_url:
352
399
  try:
353
400
  parsed = parse_url(devlogs_url)
354
- if isinstance(parsed, CollectorURLConfig):
401
+ if isinstance(parsed, LokiURLConfig):
402
+ self.loki_url = parsed.url
403
+ self.loki_token = parsed.token
404
+ elif isinstance(parsed, CollectorURLConfig):
355
405
  self.collector_url = devlogs_url
356
406
  else:
357
407
  # OpenSearch URL via DEVLOGS_URL
@@ -427,9 +477,16 @@ class DevlogsConfig:
427
477
  self.collector_workers = int(_getenv("DEVLOGS_COLLECTOR_WORKERS", "1"))
428
478
  self.collector_log_level = _getenv("DEVLOGS_COLLECTOR_LOG_LEVEL", "info")
429
479
 
480
+ @property
481
+ def is_loki(self) -> bool:
482
+ """Return True if a Loki backend is configured."""
483
+ return bool(self.loki_url)
484
+
430
485
  @property
431
486
  def url_mode(self) -> str:
432
- """Return 'collector' if DEVLOGS_URL is set, 'opensearch' if OpenSearch is configured, else 'none'."""
487
+ """Return 'loki', 'collector', 'opensearch', or 'none'."""
488
+ if self.loki_url:
489
+ return "loki"
433
490
  if self.collector_url:
434
491
  return "collector"
435
492
  if self.has_opensearch_config():
@@ -110,12 +110,14 @@ def _parse_time_param(value: Optional[str]) -> Optional[datetime]:
110
110
  # HTTP helpers
111
111
  # ---------------------------------------------------------------------------
112
112
 
113
- def _loki_get(loki_url: str, path: str, params: Dict[str, str]) -> Dict[str, Any]:
113
+ def _loki_get(loki_url: str, path: str, params: Dict[str, str], token: Optional[str] = None) -> Dict[str, Any]:
114
114
  """Execute a GET request against the Loki HTTP API."""
115
115
  base = loki_url.rstrip("/")
116
116
  qs = urllib.parse.urlencode(params)
117
117
  url = f"{base}{path}?{qs}"
118
118
  req = urllib.request.Request(url, method="GET")
119
+ if token:
120
+ req.add_header("Authorization", f"Bearer {token}")
119
121
  try:
120
122
  with urllib.request.urlopen(req, timeout=30) as resp:
121
123
  return json.loads(resp.read().decode("utf-8"))
@@ -185,6 +187,7 @@ def search(
185
187
  end: Optional[str] = None,
186
188
  limit: int = 100,
187
189
  filter_text: Optional[str] = None,
190
+ token: Optional[str] = None,
188
191
  ) -> List[Dict[str, Any]]:
189
192
  """Search log entries in a time range.
190
193
 
@@ -229,7 +232,7 @@ def search(
229
232
  "end": str(_to_ns(end_dt)),
230
233
  "limit": str(limit),
231
234
  "direction": "backward",
232
- })
235
+ }, token=token)
233
236
 
234
237
  entries = _parse_log_streams(data)
235
238
  # Sort by Loki timestamp descending (most recent first)
@@ -244,6 +247,7 @@ def tail(
244
247
  component: Optional[str] = None,
245
248
  since: Optional[str] = None,
246
249
  limit: int = 50,
250
+ token: Optional[str] = None,
247
251
  ) -> List[Dict[str, Any]]:
248
252
  """Get the most recent log entries (non-streaming snapshot).
249
253
 
@@ -276,7 +280,7 @@ def tail(
276
280
  "end": str(_to_ns(now)),
277
281
  "limit": str(limit),
278
282
  "direction": "backward",
279
- })
283
+ }, token=token)
280
284
 
281
285
  entries = _parse_log_streams(data)
282
286
  entries.sort(key=lambda e: e.get("_loki_ts_ns", "0"), reverse=True)
@@ -290,6 +294,7 @@ def count_over_time(
290
294
  group_by: Optional[List[str]] = None,
291
295
  start: Optional[str] = None,
292
296
  end: Optional[str] = None,
297
+ token: Optional[str] = None,
293
298
  ) -> List[Dict[str, Any]]:
294
299
  """Aggregate log counts over a time interval.
295
300
 
@@ -327,6 +332,6 @@ def count_over_time(
327
332
  "start": str(_to_ns(start_dt)),
328
333
  "end": str(_to_ns(end_dt)),
329
334
  "step": interval,
330
- })
335
+ }, token=token)
331
336
 
332
337
  return _parse_metric_matrix(data)
@@ -115,8 +115,20 @@ def _error_response(message: str, error_type: str = "Error") -> list[types.TextC
115
115
 
116
116
 
117
117
  def _get_loki_url() -> str | None:
118
- """Return LOKI_URL from environment, or None if not set."""
119
- return os.environ.get("LOKI_URL") or None
118
+ """Return Loki URL from LOKI_URL env var or auto-detected from DEVLOGS_URL."""
119
+ url = os.environ.get("LOKI_URL")
120
+ if url:
121
+ return url
122
+ cfg = load_config()
123
+ if cfg.is_loki:
124
+ return cfg.loki_url
125
+ return None
126
+
127
+
128
+ def _get_loki_token() -> str | None:
129
+ """Return Loki auth token from config, if available."""
130
+ cfg = load_config()
131
+ return cfg.loki_token
120
132
 
121
133
 
122
134
  def _handle_loki_search(arguments: dict) -> list[types.TextContent]:
@@ -142,6 +154,7 @@ def _handle_loki_search(arguments: dict) -> list[types.TextContent]:
142
154
  end=arguments.get("until"),
143
155
  limit=_coerce_limit(arguments.get("limit"), 50, 100),
144
156
  filter_text=arguments.get("query"),
157
+ token=_get_loki_token(),
145
158
  )
146
159
  return _json_response(
147
160
  data={"entries": entries},
@@ -171,6 +184,7 @@ def _handle_loki_tail(arguments: dict) -> list[types.TextContent]:
171
184
  component=arguments.get("component"),
172
185
  since=arguments.get("since", "10m"),
173
186
  limit=_coerce_limit(arguments.get("limit"), 20, 100),
187
+ token=_get_loki_token(),
174
188
  )
175
189
  return _json_response(
176
190
  data={"entries": entries},
@@ -200,6 +214,7 @@ def _handle_loki_get_log_stats(arguments: dict) -> list[types.TextContent]:
200
214
  group_by=arguments.get("group_by"),
201
215
  start=arguments.get("since"),
202
216
  end=arguments.get("until"),
217
+ token=_get_loki_token(),
203
218
  )
204
219
  return _json_response(
205
220
  data={"stats": stats},
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlogs
3
- Version: 2.3.1
3
+ Version: 2.3.2
4
4
  Summary: Developer-focused logging library for Python with OpenSearch integration.
5
5
  Author-email: Dan Driscoll <dan@thedandriscoll.org>
6
6
  License: MIT License
@@ -875,3 +875,47 @@ class TestDiagnosePluginMode:
875
875
  finally:
876
876
  _registry.clear()
877
877
  _registry.update(old_registry)
878
+
879
+
880
+ class TestLokiCLI:
881
+ """Test CLI commands with Loki backends."""
882
+
883
+ def test_init_refuses_loki_backend(self, monkeypatch):
884
+ """Test that init exits cleanly for Loki URLs."""
885
+ monkeypatch.setattr(config, "_dotenv_loaded", True)
886
+ monkeypatch.delenv("DEVLOGS_OPENSEARCH_URL", raising=False)
887
+ monkeypatch.delenv("DEVLOGS_OPENSEARCH_HOST", raising=False)
888
+ runner = CliRunner()
889
+ result = runner.invoke(cli.app, ["--url", "lokis://token@host.example.io/query", "init"])
890
+ assert result.exit_code == 0
891
+ assert "Loki backends do not require initialization" in result.output
892
+
893
+ def test_tail_requires_application_for_loki(self, monkeypatch):
894
+ """Test that tail requires --application for Loki backends."""
895
+ monkeypatch.setattr(config, "_dotenv_loaded", True)
896
+ monkeypatch.delenv("DEVLOGS_OPENSEARCH_URL", raising=False)
897
+ monkeypatch.delenv("DEVLOGS_OPENSEARCH_HOST", raising=False)
898
+ runner = CliRunner()
899
+ result = runner.invoke(cli.app, ["--url", "lokis://token@host.example.io/query", "tail"])
900
+ assert result.exit_code == 1
901
+ assert "--application is required" in result.output
902
+
903
+ def test_search_requires_application_for_loki(self, monkeypatch):
904
+ """Test that search requires --application for Loki backends."""
905
+ monkeypatch.setattr(config, "_dotenv_loaded", True)
906
+ monkeypatch.delenv("DEVLOGS_OPENSEARCH_URL", raising=False)
907
+ monkeypatch.delenv("DEVLOGS_OPENSEARCH_HOST", raising=False)
908
+ runner = CliRunner()
909
+ result = runner.invoke(cli.app, ["--url", "lokis://token@host.example.io/query", "search"])
910
+ assert result.exit_code == 1
911
+ assert "--application is required" in result.output
912
+
913
+ def test_last_error_requires_application_for_loki(self, monkeypatch):
914
+ """Test that last-error requires --application for Loki backends."""
915
+ monkeypatch.setattr(config, "_dotenv_loaded", True)
916
+ monkeypatch.delenv("DEVLOGS_OPENSEARCH_URL", raising=False)
917
+ monkeypatch.delenv("DEVLOGS_OPENSEARCH_HOST", raising=False)
918
+ runner = CliRunner()
919
+ result = runner.invoke(cli.app, ["--url", "lokis://token@host.example.io/query", "last-error"])
920
+ assert result.exit_code == 1
921
+ assert "--application is required" in result.output
@@ -206,6 +206,73 @@ def test_retention_duration_strings(monkeypatch):
206
206
 
207
207
  # URL format tests
208
208
 
209
+ # Loki URL format tests
210
+
211
+ def test_parse_url_lokis_with_token_and_path():
212
+ """Test lokis:// URL parsing extracts token and converts to https."""
213
+ result = config.parse_url("lokis://mytoken@host.example.io/query")
214
+ assert isinstance(result, config.LokiURLConfig)
215
+ assert result.url == "https://host.example.io/query"
216
+ assert result.token == "mytoken"
217
+
218
+
219
+ def test_parse_url_loki_no_token():
220
+ """Test loki:// URL parsing without token."""
221
+ result = config.parse_url("loki://localhost:3100")
222
+ assert isinstance(result, config.LokiURLConfig)
223
+ assert result.url == "http://localhost:3100"
224
+ assert result.token is None
225
+
226
+
227
+ def test_parse_url_lokis_no_path():
228
+ """Test lokis:// URL with no path."""
229
+ result = config.parse_url("lokis://host.example.io")
230
+ assert isinstance(result, config.LokiURLConfig)
231
+ assert result.url == "https://host.example.io"
232
+ assert result.token is None
233
+
234
+
235
+ def test_parse_url_loki_with_port():
236
+ """Test loki:// URL with custom port."""
237
+ result = config.parse_url("loki://token@localhost:3100/loki")
238
+ assert isinstance(result, config.LokiURLConfig)
239
+ assert result.url == "http://localhost:3100/loki"
240
+ assert result.token == "token"
241
+
242
+
243
+ def test_parse_url_loki_missing_hostname():
244
+ """Test loki:// URL with missing hostname raises error."""
245
+ import pytest
246
+ with pytest.raises(config.URLParseError, match="missing hostname"):
247
+ config.parse_url("loki://")
248
+
249
+
250
+ def test_config_loki_url(monkeypatch):
251
+ """Test DevlogsConfig detects Loki URL from DEVLOGS_URL."""
252
+ monkeypatch.setattr(config, "_dotenv_loaded", True)
253
+ monkeypatch.setenv("DEVLOGS_URL", "lokis://mytoken@host.example.io/query")
254
+ monkeypatch.delenv("DEVLOGS_OPENSEARCH_URL", raising=False)
255
+
256
+ cfg = config.load_config()
257
+ assert cfg.is_loki is True
258
+ assert cfg.loki_url == "https://host.example.io/query"
259
+ assert cfg.loki_token == "mytoken"
260
+ assert cfg.url_mode == "loki"
261
+
262
+
263
+ def test_config_not_loki_when_opensearch(monkeypatch):
264
+ """Test DevlogsConfig.is_loki is False for OpenSearch URLs."""
265
+ monkeypatch.setattr(config, "_dotenv_loaded", True)
266
+ monkeypatch.setenv("DEVLOGS_URL", "opensearchs://admin:pass@host:9200/myindex")
267
+ monkeypatch.delenv("DEVLOGS_OPENSEARCH_URL", raising=False)
268
+
269
+ cfg = config.load_config()
270
+ assert cfg.is_loki is False
271
+ assert cfg.loki_url is None
272
+
273
+
274
+ # OpenSearch URL format tests
275
+
209
276
  def test_parse_opensearch_url_with_index():
210
277
  """Test URL parsing extracts index from path."""
211
278
  result = config._parse_opensearch_url("https://admin:pass@host:9200/myindex")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes