devlogs 2.0.1__tar.gz → 2.0.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 (71) hide show
  1. {devlogs-2.0.1/src/devlogs.egg-info → devlogs-2.0.2}/PKG-INFO +1 -1
  2. {devlogs-2.0.1 → devlogs-2.0.2}/pyproject.toml +1 -1
  3. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/devlogs_client.py +113 -10
  4. {devlogs-2.0.1 → devlogs-2.0.2/src/devlogs.egg-info}/PKG-INFO +1 -1
  5. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_devlogs_client.py +99 -1
  6. {devlogs-2.0.1 → devlogs-2.0.2}/LICENSE +0 -0
  7. {devlogs-2.0.1 → devlogs-2.0.2}/MANIFEST.in +0 -0
  8. {devlogs-2.0.1 → devlogs-2.0.2}/README.md +0 -0
  9. {devlogs-2.0.1 → devlogs-2.0.2}/setup.cfg +0 -0
  10. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/__init__.py +0 -0
  11. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/__main__.py +0 -0
  12. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/build_info.py +0 -0
  13. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/cli.py +0 -0
  14. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/collector/__init__.py +0 -0
  15. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/collector/auth.py +0 -0
  16. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/collector/cli.py +0 -0
  17. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/collector/errors.py +0 -0
  18. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/collector/forwarder.py +0 -0
  19. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/collector/ingestor.py +0 -0
  20. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/collector/schema.py +0 -0
  21. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/collector/server.py +0 -0
  22. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/config.py +0 -0
  23. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/context.py +0 -0
  24. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/demo.py +0 -0
  25. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/formatting.py +0 -0
  26. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/handler.py +0 -0
  27. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/jenkins/__init__.py +0 -0
  28. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/jenkins/cli.py +0 -0
  29. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/jenkins/core.py +0 -0
  30. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/levels.py +0 -0
  31. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/mcp/__init__.py +0 -0
  32. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/mcp/server.py +0 -0
  33. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/opensearch/__init__.py +0 -0
  34. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/opensearch/client.py +0 -0
  35. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/opensearch/indexing.py +0 -0
  36. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/opensearch/mappings.py +0 -0
  37. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/opensearch/queries.py +0 -0
  38. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/retention.py +0 -0
  39. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/scrub.py +0 -0
  40. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/time_utils.py +0 -0
  41. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/web/__init__.py +0 -0
  42. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/web/server.py +0 -0
  43. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/web/static/devlogs.css +0 -0
  44. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/web/static/devlogs.js +0 -0
  45. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/web/static/index.html +0 -0
  46. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs/wrapper.py +0 -0
  47. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs.egg-info/SOURCES.txt +0 -0
  48. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs.egg-info/dependency_links.txt +0 -0
  49. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs.egg-info/entry_points.txt +0 -0
  50. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs.egg-info/requires.txt +0 -0
  51. {devlogs-2.0.1 → devlogs-2.0.2}/src/devlogs.egg-info/top_level.txt +0 -0
  52. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_build_info.py +0 -0
  53. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_cli.py +0 -0
  54. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_collector_auth.py +0 -0
  55. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_collector_config.py +0 -0
  56. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_collector_schema.py +0 -0
  57. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_collector_server.py +0 -0
  58. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_config.py +0 -0
  59. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_context.py +0 -0
  60. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_formatting.py +0 -0
  61. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_handler.py +0 -0
  62. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_indexing.py +0 -0
  63. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_levels.py +0 -0
  64. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_mappings.py +0 -0
  65. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_mcp_server.py +0 -0
  66. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_opensearch_client.py +0 -0
  67. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_opensearch_queries.py +0 -0
  68. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_retention.py +0 -0
  69. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_scrub.py +0 -0
  70. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_time_utils.py +0 -0
  71. {devlogs-2.0.1 → devlogs-2.0.2}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlogs
3
- Version: 2.0.1
3
+ Version: 2.0.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.0.1"
7
+ version = "2.0.2"
8
8
  description = "Developer-focused logging library for Python with OpenSearch integration."
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -8,18 +8,104 @@ import urllib.request
8
8
  import urllib.error
9
9
  from dataclasses import dataclass, field
10
10
  from datetime import datetime, timezone
11
- from typing import Any, Dict, List, Optional
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+ from urllib.parse import urlparse, unquote, urlunparse
13
+
14
+
15
+ def _parse_collector_url(url: str) -> Tuple[str, Optional[str]]:
16
+ """Parse a URL and extract auth token if it's a collector URL.
17
+
18
+ Distinguishes between OpenSearch URLs and collector URLs:
19
+ - OpenSearch URL: has BOTH username AND password - keep credentials in URL
20
+ - Collector URL: has only token in username position - extract for Bearer auth
21
+
22
+ Collector URL format: http://token@host:port
23
+ OpenSearch URL format: http://user:password@host:port
24
+
25
+ Args:
26
+ url: The URL, optionally with credentials in userinfo
27
+
28
+ Returns:
29
+ Tuple of (url, token):
30
+ - For OpenSearch URLs (user:pass): returns original URL, None
31
+ - For collector URLs (token only): returns clean URL without userinfo, token
32
+ - For plain URLs: returns original URL, None
33
+ """
34
+ if not url:
35
+ return url, None
36
+
37
+ parsed = urlparse(url)
38
+
39
+ # If no userinfo, return as-is
40
+ if not parsed.username and not parsed.password:
41
+ return url, None
42
+
43
+ # OpenSearch URL: has BOTH username AND password
44
+ # Keep the URL as-is with credentials, no Bearer token
45
+ if parsed.username and parsed.password:
46
+ return url, None
47
+
48
+ # Collector URL: token in username position only (no password)
49
+ # Extract token and strip userinfo from URL
50
+ token = unquote(parsed.username) if parsed.username else None
51
+
52
+ # Rebuild URL without userinfo
53
+ # netloc without userinfo is just host:port
54
+ if parsed.port:
55
+ netloc = f"{parsed.hostname}:{parsed.port}"
56
+ else:
57
+ netloc = parsed.hostname or ""
58
+
59
+ clean_url = urlunparse((
60
+ parsed.scheme,
61
+ netloc,
62
+ parsed.path,
63
+ parsed.params,
64
+ parsed.query,
65
+ parsed.fragment,
66
+ ))
67
+
68
+ return clean_url, token
12
69
 
13
70
 
14
71
  @dataclass
15
72
  class DevlogsClient:
16
- """Client for sending logs to a devlogs collector.
73
+ """Client for sending logs to a devlogs collector or OpenSearch.
74
+
75
+ URL Types:
76
+ This client distinguishes between collector URLs and OpenSearch URLs:
77
+
78
+ Collector URL (token in username position):
79
+ http://dl1_myapp_secret@localhost:8080
80
+ - Token is extracted and sent as Bearer auth header
81
+ - Userinfo is stripped from the request URL
82
+
83
+ OpenSearch URL (both username AND password):
84
+ https://admin:password@opensearch.example.com:9200
85
+ - Credentials remain in the URL for HTTP Basic auth
86
+ - No Bearer token is used
17
87
 
18
88
  Usage:
89
+ # Collector URL with token:
90
+ client = DevlogsClient(
91
+ collector_url="http://dl1_myapp_secret@localhost:8080",
92
+ application="my-app",
93
+ component="api-server",
94
+ )
95
+
96
+ # OpenSearch URL with credentials:
97
+ client = DevlogsClient(
98
+ collector_url="https://admin:password@opensearch.example.com:9200",
99
+ application="my-app",
100
+ component="api-server",
101
+ )
102
+
103
+ # Or with explicit auth_token parameter:
19
104
  client = DevlogsClient(
20
105
  collector_url="http://localhost:8080",
21
106
  application="my-app",
22
107
  component="api-server",
108
+ auth_token="dl1_myapp_secret",
23
109
  )
24
110
 
25
111
  # Send a single log
@@ -34,6 +120,8 @@ class DevlogsClient:
34
120
  {"message": "Event 1", "level": "info"},
35
121
  {"message": "Event 2", "level": "warning"},
36
122
  ])
123
+
124
+ If both URL token and auth_token parameter are provided, auth_token takes precedence.
37
125
  """
38
126
 
39
127
  collector_url: str
@@ -44,16 +132,27 @@ class DevlogsClient:
44
132
  auth_token: Optional[str] = None
45
133
  timeout: int = 30
46
134
 
135
+ # Internal fields set by __post_init__
136
+ _clean_url: str = field(default="", init=False, repr=False)
137
+ _resolved_token: Optional[str] = field(default=None, init=False, repr=False)
138
+
139
+ def __post_init__(self):
140
+ """Parse collector URL and extract token if present."""
141
+ clean_url, url_token = _parse_collector_url(self.collector_url)
142
+ self._clean_url = clean_url
143
+ # Explicit auth_token parameter takes precedence over URL token
144
+ self._resolved_token = self.auth_token if self.auth_token else url_token
145
+
47
146
  def _get_endpoint(self) -> str:
48
147
  """Get the collector endpoint URL."""
49
- base = self.collector_url.rstrip("/")
148
+ base = self._clean_url.rstrip("/")
50
149
  return f"{base}/v1/logs"
51
150
 
52
151
  def _get_headers(self) -> Dict[str, str]:
53
152
  """Get request headers."""
54
153
  headers = {"Content-Type": "application/json"}
55
- if self.auth_token:
56
- headers["Authorization"] = f"Bearer {self.auth_token}"
154
+ if self._resolved_token:
155
+ headers["Authorization"] = f"Bearer {self._resolved_token}"
57
156
  return headers
58
157
 
59
158
  def _now(self) -> str:
@@ -209,15 +308,17 @@ def create_client(
209
308
  version: Optional[str] = None,
210
309
  auth_token: Optional[str] = None,
211
310
  ) -> DevlogsClient:
212
- """Create an Devlogs client.
311
+ """Create a Devlogs client.
213
312
 
214
313
  Args:
215
- collector_url: The collector endpoint URL (DEVLOGS_URL)
314
+ collector_url: The endpoint URL. URL type is auto-detected:
315
+ - Collector URL: http://token@host:port (token becomes Bearer auth)
316
+ - OpenSearch URL: http://user:pass@host:port (credentials kept in URL)
216
317
  application: Application name
217
318
  component: Component name within the application
218
319
  environment: Deployment environment (optional)
219
320
  version: Application version (optional)
220
- auth_token: Bearer token for authentication (optional)
321
+ auth_token: Bearer token for authentication (optional, overrides URL token)
221
322
 
222
323
  Returns:
223
324
  Configured DevlogsClient instance
@@ -248,7 +349,9 @@ def emit_log(
248
349
  For repeated logging, use create_client() instead.
249
350
 
250
351
  Args:
251
- collector_url: The collector endpoint URL
352
+ collector_url: The endpoint URL. URL type is auto-detected:
353
+ - Collector URL: http://token@host:port (token becomes Bearer auth)
354
+ - OpenSearch URL: http://user:pass@host:port (credentials kept in URL)
252
355
  application: Application name
253
356
  component: Component name
254
357
  message: Log message
@@ -256,7 +359,7 @@ def emit_log(
256
359
  fields: Custom fields
257
360
  environment: Deployment environment
258
361
  version: Application version
259
- auth_token: Bearer token
362
+ auth_token: Bearer token (optional, overrides URL token)
260
363
 
261
364
  Returns:
262
365
  True if accepted, False on error
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlogs
3
- Version: 2.0.1
3
+ Version: 2.0.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,65 @@ import json
4
4
  import pytest
5
5
  from unittest.mock import Mock, patch
6
6
 
7
- from devlogs.devlogs_client import DevlogsClient, create_client, emit_log
7
+ from devlogs.devlogs_client import DevlogsClient, create_client, emit_log, _parse_collector_url
8
+
9
+
10
+ class TestParseCollectorUrl:
11
+ """Tests for URL parsing - distinguishes collector vs OpenSearch URLs."""
12
+
13
+ def test_no_userinfo(self):
14
+ """Plain URL without credentials."""
15
+ url, token = _parse_collector_url("http://localhost:8080")
16
+ assert url == "http://localhost:8080"
17
+ assert token is None
18
+
19
+ def test_collector_url_token_in_username(self):
20
+ """Collector URL: token in username position only."""
21
+ url, token = _parse_collector_url("http://mytoken@localhost:8080")
22
+ assert url == "http://localhost:8080"
23
+ assert token == "mytoken"
24
+
25
+ def test_opensearch_url_user_and_password(self):
26
+ """OpenSearch URL: both username and password - keep credentials in URL."""
27
+ url, token = _parse_collector_url("http://admin:secretpass@localhost:9200")
28
+ assert url == "http://admin:secretpass@localhost:9200"
29
+ assert token is None
30
+
31
+ def test_opensearch_url_preserves_all_parts(self):
32
+ """OpenSearch URL preserves scheme, path, query."""
33
+ url, token = _parse_collector_url("https://user:pass@opensearch.example.com:9200/_bulk?refresh=true")
34
+ assert url == "https://user:pass@opensearch.example.com:9200/_bulk?refresh=true"
35
+ assert token is None
36
+
37
+ def test_collector_url_preserves_port(self):
38
+ url, token = _parse_collector_url("http://token@localhost:9999")
39
+ assert url == "http://localhost:9999"
40
+ assert token == "token"
41
+
42
+ def test_collector_url_preserves_path(self):
43
+ url, token = _parse_collector_url("http://token@localhost:8080/path/to/api")
44
+ assert url == "http://localhost:8080/path/to/api"
45
+ assert token == "token"
46
+
47
+ def test_collector_url_https_scheme(self):
48
+ url, token = _parse_collector_url("https://token@example.com")
49
+ assert url == "https://example.com"
50
+ assert token == "token"
51
+
52
+ def test_collector_url_encoded_token(self):
53
+ url, token = _parse_collector_url("http://my%3Atoken%40special@localhost:8080")
54
+ assert url == "http://localhost:8080"
55
+ assert token == "my:token@special"
56
+
57
+ def test_empty_url(self):
58
+ url, token = _parse_collector_url("")
59
+ assert url == ""
60
+ assert token is None
61
+
62
+ def test_devlogs_token_format(self):
63
+ url, token = _parse_collector_url("http://dl1_myapp_abcdefghijklmnopqrstuvwxyz123456@localhost:8080")
64
+ assert url == "http://localhost:8080"
65
+ assert token == "dl1_myapp_abcdefghijklmnopqrstuvwxyz123456"
8
66
 
9
67
 
10
68
  class TestDevlogsClient:
@@ -116,6 +174,46 @@ class TestDevlogsClient:
116
174
  headers = client._get_headers()
117
175
  assert headers["Authorization"] == "Bearer my-secret-token"
118
176
 
177
+ def test_get_headers_with_token_in_url(self):
178
+ """Collector URL: token in username position extracts Bearer auth."""
179
+ client = DevlogsClient(
180
+ collector_url="http://url-token@localhost:8080",
181
+ application="test-app",
182
+ component="api",
183
+ )
184
+ headers = client._get_headers()
185
+ assert headers["Authorization"] == "Bearer url-token"
186
+
187
+ def test_auth_token_param_overrides_url_token(self):
188
+ client = DevlogsClient(
189
+ collector_url="http://url-token@localhost:8080",
190
+ application="test-app",
191
+ component="api",
192
+ auth_token="param-token",
193
+ )
194
+ headers = client._get_headers()
195
+ assert headers["Authorization"] == "Bearer param-token"
196
+
197
+ def test_get_endpoint_strips_userinfo_for_collector_url(self):
198
+ """Collector URL: userinfo is stripped from endpoint."""
199
+ client = DevlogsClient(
200
+ collector_url="http://mytoken@localhost:8080",
201
+ application="test-app",
202
+ component="api",
203
+ )
204
+ assert client._get_endpoint() == "http://localhost:8080/v1/logs"
205
+
206
+ def test_opensearch_url_keeps_credentials(self):
207
+ """OpenSearch URL: credentials remain in URL, no Bearer token."""
208
+ client = DevlogsClient(
209
+ collector_url="https://admin:password@opensearch.example.com:9200",
210
+ application="test-app",
211
+ component="api",
212
+ )
213
+ headers = client._get_headers()
214
+ assert "Authorization" not in headers
215
+ assert client._get_endpoint() == "https://admin:password@opensearch.example.com:9200/v1/logs"
216
+
119
217
  def test_emit_sends_single_record(self):
120
218
  client = DevlogsClient(
121
219
  collector_url="http://localhost:8080",
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
File without changes
File without changes
File without changes