pycommonlog 0.2.1__tar.gz → 0.2.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycommonlog
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Unified logging and alerting library for Python.
5
5
  Home-page: https://github.com/alvianhanif/pycommonlog
6
6
  Author: Alvian Rahman Hanif
@@ -12,6 +12,9 @@ Classifier: Operating System :: OS Independent
12
12
  Requires-Python: >=3.8
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
+ Requires-Dist: requests
16
+ Provides-Extra: redis
17
+ Requires-Dist: redis>=4.0.0; extra == "redis"
15
18
  Dynamic: author
16
19
  Dynamic: author-email
17
20
  Dynamic: classifier
@@ -20,6 +23,8 @@ Dynamic: description-content-type
20
23
  Dynamic: home-page
21
24
  Dynamic: license
22
25
  Dynamic: license-file
26
+ Dynamic: provides-extra
27
+ Dynamic: requires-dist
23
28
  Dynamic: requires-python
24
29
  Dynamic: summary
25
30
 
@@ -50,13 +55,13 @@ from pycommonlog import commonlog, Config, SendMethod, AlertLevel, Attachment, L
50
55
 
51
56
  # Configure logger
52
57
  config = Config(
53
- provider="lark", # or "slack"
54
58
  send_method=SendMethod.WEBCLIENT,
55
- token="app_id++app_secret", # for Lark, use "app_id++app_secret" format
56
- slack_token="xoxb-your-slack-token", # dedicated Slack token
57
- lark_token=LarkToken(app_id="your-app-id", app_secret="your-app-secret"), # dedicated Lark token
58
59
  channel="your_lark_channel_id",
59
60
  provider_config={
61
+ "provider": "lark", # or "slack"
62
+ "token": "app_id++app_secret", # for Lark, use "app_id++app_secret" format
63
+ "slack_token": "xoxb-your-slack-token", # dedicated Slack token
64
+ "lark_token": LarkToken(app_id="your-app-id", app_secret="your-app-secret"), # dedicated Lark token
60
65
  "redis_host": "localhost", # required for Lark
61
66
  "redis_port": 6379, # required for Lark
62
67
  }
@@ -95,13 +100,13 @@ WebClient uses the full API with authentication tokens:
95
100
 
96
101
  ```python
97
102
  config = Config(
98
- provider="lark",
99
103
  send_method=SendMethod.WEBCLIENT,
100
- token="app_id++app_secret", # for Lark
101
- slack_token="xoxb-your-slack-token", # for Slack
102
- lark_token=LarkToken(app_id="your-app-id", app_secret="your-app-secret"),
103
104
  channel="your_channel",
104
105
  provider_config={
106
+ "provider": "lark", # or "slack"
107
+ "token": "app_id++app_secret", # for Lark
108
+ "slack_token": "xoxb-your-slack-token", # for Slack
109
+ "lark_token": LarkToken(app_id="your-app-id", app_secret="your-app-secret"),
105
110
  "redis_host": "localhost", # required for Lark
106
111
  "redis_port": 6379, # required for Lark
107
112
  }
@@ -114,10 +119,12 @@ Webhook is simpler and requires only a webhook URL:
114
119
 
115
120
  ```python
116
121
  config = Config(
117
- provider="slack",
118
122
  send_method=SendMethod.WEBHOOK,
119
- token="https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
120
123
  channel="optional-channel-override", # optional
124
+ provider_config={
125
+ "provider": "slack",
126
+ "token": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
127
+ }
121
128
  )
122
129
  ```
123
130
 
@@ -129,11 +136,11 @@ Lark integration requires proper token configuration for authentication. You can
129
136
 
130
137
  ```python
131
138
  config = Config(
132
- provider="lark",
133
139
  send_method=SendMethod.WEBCLIENT,
134
- token="your_app_id++your_app_secret", # Combined format: app_id++app_secret
135
140
  channel="your_channel_id",
136
141
  provider_config={
142
+ "provider": "lark",
143
+ "token": "your_app_id++your_app_secret", # Combined format: app_id++app_secret
137
144
  "redis_host": "localhost", # Optional: enables caching
138
145
  "redis_port": 6379,
139
146
  }
@@ -143,17 +150,15 @@ config = Config(
143
150
  #### Method 2: Dedicated Lark Token Object
144
151
 
145
152
  ```python
146
- from pycommonlog import LarkToken
147
-
148
153
  config = Config(
149
- provider="lark",
150
154
  send_method=SendMethod.WEBCLIENT,
151
- lark_token=LarkToken(
152
- app_id="your_app_id",
153
- app_secret="your_app_secret"
154
- ),
155
155
  channel="your_channel_id",
156
156
  provider_config={
157
+ "provider": "lark",
158
+ "lark_token": LarkToken(
159
+ app_id="your_app_id",
160
+ app_secret="your_app_secret"
161
+ ),
157
162
  "redis_host": "localhost", # Optional: enables caching
158
163
  "redis_port": 6379,
159
164
  }
@@ -199,12 +204,14 @@ resolver = DefaultChannelResolver(
199
204
 
200
205
  # Create config with channel resolver
201
206
  config = Config(
202
- provider="slack",
203
207
  send_method=SendMethod.WEBCLIENT,
204
- token="xoxb-your-slack-bot-token",
205
208
  channel_resolver=resolver,
206
209
  service_name="user-service",
207
- environment="production"
210
+ environment="production",
211
+ provider_config={
212
+ "provider": "slack",
213
+ "token": "xoxb-your-slack-bot-token",
214
+ }
208
215
  )
209
216
 
210
217
  logger = commonlog(config)
@@ -234,17 +241,27 @@ class CustomResolver(ChannelResolver):
234
241
 
235
242
  ### Common Settings
236
243
 
237
- - **provider**: `"slack"` or `"lark"`
238
- - **send_method**: `"webclient"` (token-based authentication)
244
+ - **send_method**: `"webclient"` (token-based authentication) or `"webhook"`
239
245
  - **channel**: Target channel or chat ID (used if no resolver)
240
246
  - **channel_resolver**: Optional resolver for dynamic channel mapping
241
247
  - **service_name**: Name of the service sending alerts
242
248
  - **environment**: Environment (dev, staging, production)
243
249
  - **debug**: `True` to enable detailed debug logging of all internal processes
244
250
 
245
- ### Provider-Specific
251
+ ### ProviderConfig Settings
246
252
 
247
- - **token**: API token for WebClient authentication (required)
253
+ All provider-specific configuration is now done via the `provider_config` dict:
254
+
255
+ - **provider**: `"slack"` or `"lark"`
256
+ - **token**: API token for WebClient authentication or webhook URL for Webhook method
257
+ - **slack_token**: Dedicated Slack token (optional, overrides token for Slack)
258
+ - **lark_token**: `LarkToken` object with app_id and app_secret (optional, overrides token for Lark)
259
+ - **redis_host**: Redis host for Lark caching (optional)
260
+ - **redis_port**: Redis port for Lark caching (optional)
261
+ - **redis_password**: Redis password (optional)
262
+ - **redis_ssl**: Enable SSL for Redis (optional)
263
+ - **redis_cluster_mode**: Enable Redis cluster mode (optional)
264
+ - **redis_db**: Redis database number (optional)
248
265
 
249
266
  ## Alert Levels
250
267
 
@@ -25,13 +25,13 @@ from pycommonlog import commonlog, Config, SendMethod, AlertLevel, Attachment, L
25
25
 
26
26
  # Configure logger
27
27
  config = Config(
28
- provider="lark", # or "slack"
29
28
  send_method=SendMethod.WEBCLIENT,
30
- token="app_id++app_secret", # for Lark, use "app_id++app_secret" format
31
- slack_token="xoxb-your-slack-token", # dedicated Slack token
32
- lark_token=LarkToken(app_id="your-app-id", app_secret="your-app-secret"), # dedicated Lark token
33
29
  channel="your_lark_channel_id",
34
30
  provider_config={
31
+ "provider": "lark", # or "slack"
32
+ "token": "app_id++app_secret", # for Lark, use "app_id++app_secret" format
33
+ "slack_token": "xoxb-your-slack-token", # dedicated Slack token
34
+ "lark_token": LarkToken(app_id="your-app-id", app_secret="your-app-secret"), # dedicated Lark token
35
35
  "redis_host": "localhost", # required for Lark
36
36
  "redis_port": 6379, # required for Lark
37
37
  }
@@ -70,13 +70,13 @@ WebClient uses the full API with authentication tokens:
70
70
 
71
71
  ```python
72
72
  config = Config(
73
- provider="lark",
74
73
  send_method=SendMethod.WEBCLIENT,
75
- token="app_id++app_secret", # for Lark
76
- slack_token="xoxb-your-slack-token", # for Slack
77
- lark_token=LarkToken(app_id="your-app-id", app_secret="your-app-secret"),
78
74
  channel="your_channel",
79
75
  provider_config={
76
+ "provider": "lark", # or "slack"
77
+ "token": "app_id++app_secret", # for Lark
78
+ "slack_token": "xoxb-your-slack-token", # for Slack
79
+ "lark_token": LarkToken(app_id="your-app-id", app_secret="your-app-secret"),
80
80
  "redis_host": "localhost", # required for Lark
81
81
  "redis_port": 6379, # required for Lark
82
82
  }
@@ -89,10 +89,12 @@ Webhook is simpler and requires only a webhook URL:
89
89
 
90
90
  ```python
91
91
  config = Config(
92
- provider="slack",
93
92
  send_method=SendMethod.WEBHOOK,
94
- token="https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
95
93
  channel="optional-channel-override", # optional
94
+ provider_config={
95
+ "provider": "slack",
96
+ "token": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
97
+ }
96
98
  )
97
99
  ```
98
100
 
@@ -104,11 +106,11 @@ Lark integration requires proper token configuration for authentication. You can
104
106
 
105
107
  ```python
106
108
  config = Config(
107
- provider="lark",
108
109
  send_method=SendMethod.WEBCLIENT,
109
- token="your_app_id++your_app_secret", # Combined format: app_id++app_secret
110
110
  channel="your_channel_id",
111
111
  provider_config={
112
+ "provider": "lark",
113
+ "token": "your_app_id++your_app_secret", # Combined format: app_id++app_secret
112
114
  "redis_host": "localhost", # Optional: enables caching
113
115
  "redis_port": 6379,
114
116
  }
@@ -118,17 +120,15 @@ config = Config(
118
120
  #### Method 2: Dedicated Lark Token Object
119
121
 
120
122
  ```python
121
- from pycommonlog import LarkToken
122
-
123
123
  config = Config(
124
- provider="lark",
125
124
  send_method=SendMethod.WEBCLIENT,
126
- lark_token=LarkToken(
127
- app_id="your_app_id",
128
- app_secret="your_app_secret"
129
- ),
130
125
  channel="your_channel_id",
131
126
  provider_config={
127
+ "provider": "lark",
128
+ "lark_token": LarkToken(
129
+ app_id="your_app_id",
130
+ app_secret="your_app_secret"
131
+ ),
132
132
  "redis_host": "localhost", # Optional: enables caching
133
133
  "redis_port": 6379,
134
134
  }
@@ -174,12 +174,14 @@ resolver = DefaultChannelResolver(
174
174
 
175
175
  # Create config with channel resolver
176
176
  config = Config(
177
- provider="slack",
178
177
  send_method=SendMethod.WEBCLIENT,
179
- token="xoxb-your-slack-bot-token",
180
178
  channel_resolver=resolver,
181
179
  service_name="user-service",
182
- environment="production"
180
+ environment="production",
181
+ provider_config={
182
+ "provider": "slack",
183
+ "token": "xoxb-your-slack-bot-token",
184
+ }
183
185
  )
184
186
 
185
187
  logger = commonlog(config)
@@ -209,17 +211,27 @@ class CustomResolver(ChannelResolver):
209
211
 
210
212
  ### Common Settings
211
213
 
212
- - **provider**: `"slack"` or `"lark"`
213
- - **send_method**: `"webclient"` (token-based authentication)
214
+ - **send_method**: `"webclient"` (token-based authentication) or `"webhook"`
214
215
  - **channel**: Target channel or chat ID (used if no resolver)
215
216
  - **channel_resolver**: Optional resolver for dynamic channel mapping
216
217
  - **service_name**: Name of the service sending alerts
217
218
  - **environment**: Environment (dev, staging, production)
218
219
  - **debug**: `True` to enable detailed debug logging of all internal processes
219
220
 
220
- ### Provider-Specific
221
+ ### ProviderConfig Settings
221
222
 
222
- - **token**: API token for WebClient authentication (required)
223
+ All provider-specific configuration is now done via the `provider_config` dict:
224
+
225
+ - **provider**: `"slack"` or `"lark"`
226
+ - **token**: API token for WebClient authentication or webhook URL for Webhook method
227
+ - **slack_token**: Dedicated Slack token (optional, overrides token for Slack)
228
+ - **lark_token**: `LarkToken` object with app_id and app_secret (optional, overrides token for Lark)
229
+ - **redis_host**: Redis host for Lark caching (optional)
230
+ - **redis_port**: Redis port for Lark caching (optional)
231
+ - **redis_password**: Redis password (optional)
232
+ - **redis_ssl**: Enable SSL for Redis (optional)
233
+ - **redis_cluster_mode**: Enable Redis cluster mode (optional)
234
+ - **redis_db**: Redis database number (optional)
223
235
 
224
236
  ## Alert Levels
225
237
 
@@ -2,7 +2,7 @@
2
2
  commonlog: Unified logging and alerting for Slack/Lark (Python)
3
3
  """
4
4
 
5
- from .log_types import SendMethod, AlertLevel, Attachment, Config, Provider, ChannelResolver, DefaultChannelResolver
5
+ from .log_types import SendMethod, AlertLevel, Attachment, Config, Provider, ChannelResolver, DefaultChannelResolver, LarkToken
6
6
  from .providers import SlackProvider, LarkProvider
7
7
  from .logger import commonlog
8
8
 
@@ -14,6 +14,7 @@ __all__ = [
14
14
  "Provider",
15
15
  "ChannelResolver",
16
16
  "DefaultChannelResolver",
17
+ "LarkToken",
17
18
  "SlackProvider",
18
19
  "LarkProvider",
19
20
  "commonlog"
@@ -49,6 +49,16 @@ class Config:
49
49
  self.environment = environment
50
50
  self.provider_config = provider_config or {}
51
51
  self.debug = debug
52
+
53
+ # Populate provider_config with top-level fields for consistency, only if top-level is set
54
+ if self.provider:
55
+ self.provider_config["provider"] = self.provider
56
+ if self.token:
57
+ self.provider_config["token"] = self.token
58
+ if self.slack_token:
59
+ self.provider_config["slack_token"] = self.slack_token
60
+ if self.lark_token and (self.lark_token.app_id or self.lark_token.app_secret):
61
+ self.provider_config["lark_token"] = self.lark_token
52
62
 
53
63
  class Provider(ABC):
54
64
  @abstractmethod
@@ -2,16 +2,9 @@
2
2
  Main logger for commonlog
3
3
  """
4
4
  import logging
5
- import sys
6
- import os
7
5
 
8
- # Add current directory to path for direct imports
9
- _current_dir = os.path.dirname(os.path.abspath(__file__))
10
- if _current_dir not in sys.path:
11
- sys.path.insert(0, _current_dir)
12
-
13
- from providers import SlackProvider, LarkProvider
14
- from log_types import AlertLevel, Attachment, debug_log
6
+ from pycommonlog.providers import SlackProvider, LarkProvider
7
+ from pycommonlog.log_types import AlertLevel, Attachment, debug_log
15
8
 
16
9
  # ====================
17
10
  # Configuration and Logger
@@ -104,15 +97,16 @@ class commonlog:
104
97
 
105
98
  def __init__(self, config):
106
99
  self.config = config
107
- if config.provider == "slack":
100
+ provider_name = config.provider_config.get("provider", "slack")
101
+ if provider_name == "slack":
108
102
  self.provider = SlackProvider()
109
- elif config.provider == "lark":
103
+ elif provider_name == "lark":
110
104
  self.provider = LarkProvider()
111
105
  else:
112
- logging.warning(f"Unknown provider: {config.provider}, defaulting to Slack")
106
+ logging.warning(f"Unknown provider: {provider_name}, defaulting to Slack")
113
107
  self.provider = SlackProvider()
114
108
 
115
- debug_log(config, f"Created logger with provider: {config.provider}, send method: {config.send_method}, debug: {config.debug}")
109
+ debug_log(config, f"Created logger with provider: {provider_name}, send method: {config.send_method}, debug: {config.debug}")
116
110
 
117
111
  def _resolve_channel(self, level):
118
112
  if self.config.channel_resolver:
@@ -0,0 +1,7 @@
1
+ """
2
+ Providers package for commonlog
3
+ """
4
+ from .slack import SlackProvider
5
+ from .lark import LarkProvider
6
+
7
+ __all__ = ["SlackProvider", "LarkProvider"]
@@ -0,0 +1,269 @@
1
+ """
2
+ Lark Provider for commonlog
3
+ """
4
+ import requests
5
+ import json
6
+ import time
7
+ import threading
8
+ from typing import Dict, Optional, Tuple
9
+
10
+ from pycommonlog.log_types import SendMethod, Provider, debug_log
11
+ from pycommonlog.providers.redis_client import get_redis_client, RedisConfigError
12
+ from pycommonlog.cache import get_memory_cache
13
+
14
+ class LarkProvider(Provider):
15
+ def send_to_channel(self, level, message, attachment, config, channel):
16
+ original_channel = config.channel
17
+ config.channel = channel
18
+ title, formatted_message = self._format_message(message, attachment, config)
19
+ if config.send_method == SendMethod.WEBCLIENT:
20
+ self._send_lark_webclient(title, formatted_message, config)
21
+ elif config.send_method == SendMethod.WEBHOOK:
22
+ self._send_lark_webhook(title, formatted_message, config)
23
+ config.channel = original_channel
24
+
25
+ def cache_lark_token(self, config, app_id, app_secret, token, expire):
26
+ key = f"commonlog_lark_token:{app_id}:{app_secret}"
27
+ try:
28
+ client = get_redis_client(config)
29
+ expire_seconds = expire - 600
30
+ if expire_seconds <= 0:
31
+ expire_seconds = 60
32
+ client.setex(key, expire_seconds, token)
33
+ debug_log(config, f"Lark token cached in Redis for key: {key}")
34
+ except RedisConfigError:
35
+ # Fallback to in-memory cache
36
+ expire_seconds = expire - 600
37
+ if expire_seconds <= 0:
38
+ expire_seconds = 60
39
+ get_memory_cache().set(key, token, expire_seconds)
40
+ debug_log(config, f"Lark token cached in memory for key: {key}")
41
+
42
+ def get_cached_lark_token(self, config, app_id, app_secret):
43
+ key = f"commonlog_lark_token:{app_id}:{app_secret}"
44
+ try:
45
+ client = get_redis_client(config)
46
+ token = client.get(key)
47
+ if token:
48
+ debug_log(config, f"Lark token retrieved from Redis for key: {key}")
49
+ return token
50
+ except RedisConfigError:
51
+ # Fallback to in-memory cache
52
+ token = get_memory_cache().get(key)
53
+ if token:
54
+ debug_log(config, f"Lark token retrieved from memory for key: {key}")
55
+ return token
56
+
57
+ def cache_chat_id(self, config, channel_name, chat_id):
58
+ key = f"commonlog_lark_chat_id:{config.environment}:{channel_name}"
59
+ try:
60
+ client = get_redis_client(config)
61
+ client.set(key, chat_id) # No expiry
62
+ debug_log(config, f"Lark chat ID cached in Redis for key: {key}")
63
+ except RedisConfigError:
64
+ # Fallback to in-memory cache (no expiry for chat IDs)
65
+ get_memory_cache().set(key, chat_id, 86400 * 30) # 30 days expiry
66
+ debug_log(config, f"Lark chat ID cached in memory for key: {key}")
67
+
68
+ def get_cached_chat_id(self, config, channel_name):
69
+ key = f"commonlog_lark_chat_id:{config.environment}:{channel_name}"
70
+ try:
71
+ client = get_redis_client(config)
72
+ chat_id = client.get(key)
73
+ if chat_id:
74
+ debug_log(config, f"Lark chat ID retrieved from Redis for key: {key}")
75
+ return chat_id
76
+ except RedisConfigError:
77
+ # Fallback to in-memory cache
78
+ chat_id = get_memory_cache().get(key)
79
+ if chat_id:
80
+ debug_log(config, f"Lark chat ID retrieved from memory for key: {key}")
81
+ return chat_id
82
+
83
+ def get_tenant_access_token(self, config, app_id, app_secret):
84
+ cached = self.get_cached_lark_token(config, app_id, app_secret)
85
+ if cached:
86
+ return cached
87
+ url = "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"
88
+ payload = {"app_id": app_id, "app_secret": app_secret}
89
+ response = requests.post(url, json=payload)
90
+ result = response.json()
91
+ if result.get("code", 1) != 0:
92
+ raise Exception(f"lark token error: {result.get('msg')}")
93
+ token = result.get("tenant_access_token")
94
+ expire = result.get("expire", 0)
95
+ self.cache_lark_token(config, app_id, app_secret, token, expire)
96
+ return token
97
+
98
+ def get_chat_id_from_channel_name(self, config, token, channel_name):
99
+ """Get chat_id from channel name using Lark API with pagination"""
100
+ # Try Redis cache first
101
+ cached = self.get_cached_chat_id(config, channel_name)
102
+ if cached:
103
+ return cached
104
+
105
+ base_url = "https://open.larksuite.com/open-apis/im/v1/chats"
106
+ headers = {"Authorization": f"Bearer {token}"}
107
+
108
+ all_chats = []
109
+ page_token = ""
110
+ has_more = True
111
+
112
+ while has_more:
113
+ url = f"{base_url}?page_size=10"
114
+ if page_token:
115
+ url += f"&page_token={page_token}"
116
+
117
+ response = requests.get(url, headers=headers)
118
+ if response.status_code != 200:
119
+ raise Exception(f"Lark chats API response: {response.status_code}")
120
+
121
+ result = response.json()
122
+
123
+ # Check for API error
124
+ if result.get("code", 1) != 0:
125
+ raise Exception(f"Lark API error: {result.get('msg', 'Unknown error')}")
126
+
127
+ data = result.get("data", {})
128
+ items = data.get("items", [])
129
+
130
+ # Add current page items to all chats
131
+ all_chats.extend(items)
132
+
133
+ # Update pagination info
134
+ page_token = data.get("page_token", "")
135
+ has_more = data.get("has_more", False)
136
+
137
+ # Find the chat with matching name
138
+ for item in all_chats:
139
+ if item.get("name") == channel_name:
140
+ chat_id = item.get("chat_id")
141
+ # Cache the chat_id without expiry
142
+ self.cache_chat_id(config, channel_name, chat_id)
143
+ return chat_id
144
+
145
+ raise Exception(f"Channel '{channel_name}' not found")
146
+
147
+ def send(self, level, message, attachment, config):
148
+ debug_log(config, f"LarkProvider.send called with level: {level}, send method: {config.send_method}")
149
+ title, formatted_message = self._format_message(message, attachment, config)
150
+ if config.send_method == SendMethod.WEBCLIENT:
151
+ debug_log(config, "Using Lark webclient method")
152
+ self._send_lark_webclient(title, formatted_message, config)
153
+ elif config.send_method == SendMethod.WEBHOOK:
154
+ debug_log(config, "Using Lark webhook method")
155
+ self._send_lark_webhook(title, formatted_message, config)
156
+ else:
157
+ error_msg = f"Unknown send method for Lark: {config.send_method}"
158
+ debug_log(config, f"Error: {error_msg}")
159
+ raise ValueError(error_msg)
160
+
161
+ def _format_message(self, message, attachment, config):
162
+ # Extract title from service and environment
163
+ title = "Alert"
164
+ if config.service_name and config.environment:
165
+ title = f"{config.service_name} - {config.environment}"
166
+ elif config.service_name:
167
+ title = config.service_name
168
+ elif config.environment:
169
+ title = config.environment
170
+
171
+ # Format message content without the header
172
+ formatted = message
173
+ if attachment and attachment.content:
174
+ filename = attachment.file_name or "Trace Logs"
175
+ formatted += f"\n\n**{filename}:**\n```\n{attachment.content}\n```"
176
+ if attachment and attachment.url:
177
+ formatted += f"\n\n**Attachment:** {attachment.url}"
178
+ return title, json.dumps(formatted)
179
+
180
+ def _send_lark_webclient(self, title, formatted_message, config):
181
+ debug_log(config, "send_lark_webclient: preparing API request")
182
+ token = config.provider_config.get("token", "")
183
+
184
+ # Use lark_token if available, otherwise fall back to token parsing
185
+ lark_token = config.provider_config.get("lark_token")
186
+ if lark_token and lark_token.app_id and lark_token.app_secret:
187
+ debug_log(config, "send_lark_webclient: fetching tenant access token using lark_token")
188
+ token = self.get_tenant_access_token(config, lark_token.app_id, lark_token.app_secret)
189
+ debug_log(config, "send_lark_webclient: tenant access token fetched")
190
+ elif token and len(token) < 100 and "++" in token:
191
+ # If token is in "app_id++app_secret" format, fetch the tenant_access_token
192
+ debug_log(config, "send_lark_webclient: parsing token in app_id++app_secret format")
193
+ parts = token.split("++")
194
+ if len(parts) == 2:
195
+ token = self.get_tenant_access_token(config, parts[0], parts[1])
196
+ debug_log(config, "send_lark_webclient: tenant access token fetched from parsed token")
197
+
198
+ # Get chat_id from channel name
199
+ debug_log(config, f"send_lark_webclient: resolving chat_id for channel '{config.channel}'")
200
+ chat_id = self.get_chat_id_from_channel_name(config, token, config.channel)
201
+ debug_log(config, f"send_lark_webclient: resolved chat_id")
202
+
203
+ url = "https://open.larksuite.com/open-apis/im/v1/messages?receive_id_type=chat_id"
204
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
205
+ payload = {
206
+ "receive_id": chat_id,
207
+ "msg_type": "post",
208
+ "content": {
209
+ "post": {
210
+ "zh_cn": {
211
+ "title": title,
212
+ "content": [
213
+ [
214
+ {
215
+ "tag": "text",
216
+ "text": formatted_message
217
+ }
218
+ ]
219
+ ]
220
+ }
221
+ }
222
+ }
223
+ }
224
+ debug_log(config, f"send_lark_webclient: sending HTTP request, payload size: {len(str(payload))}, payload: {json.dumps(payload)}")
225
+
226
+ response = requests.post(url, headers=headers, json=payload)
227
+ debug_log(config, f"send_lark_webclient: response status: {response.status_code}")
228
+ if response.status_code != 200:
229
+ error_msg = f"Lark WebClient response: {response.status_code}"
230
+ debug_log(config, f"send_lark_webclient: error: {error_msg}")
231
+ raise Exception(error_msg)
232
+ debug_log(config, "send_lark_webclient: message sent successfully")
233
+
234
+ def _send_lark_webhook(self, title, formatted_message, config):
235
+ debug_log(config, "send_lark_webhook: preparing webhook request")
236
+ # For webhook, the token field contains the webhook URL
237
+ webhook_url = config.token
238
+ if not webhook_url:
239
+ error_msg = "Webhook URL is required for Lark webhook method"
240
+ debug_log(config, f"Error: {error_msg}")
241
+ raise Exception(error_msg)
242
+
243
+ debug_log(config, "send_lark_webhook: using webhook URL")
244
+ payload = {
245
+ "msg_type": "post",
246
+ "content": {
247
+ "post": {
248
+ "zh_cn": {
249
+ "title": title,
250
+ "content": [
251
+ [
252
+ {
253
+ "tag": "text",
254
+ "text": formatted_message
255
+ }
256
+ ]
257
+ ]
258
+ }
259
+ }
260
+ }
261
+ }
262
+ debug_log(config, f"send_lark_webhook: payload prepared, size: {len(str(payload))}, payload: {json.dumps(payload)}")
263
+ response = requests.post(webhook_url, json=payload)
264
+ debug_log(config, f"send_lark_webhook: response status: {response.status_code}, response data: {response.text}")
265
+ if response.status_code != 200:
266
+ error_msg = f"Lark webhook response: {response.status_code}"
267
+ debug_log(config, f"send_lark_webhook: error: {error_msg}")
268
+ raise Exception(error_msg)
269
+ debug_log(config, "send_lark_webhook: webhook sent successfully")
@@ -0,0 +1,51 @@
1
+ """
2
+ Redis client for commonlog (Python)
3
+ """
4
+
5
+ class RedisConfigError(Exception):
6
+ pass
7
+
8
+ def get_redis_client(config):
9
+ import redis # Import lazily to avoid distutils issues in Python 3.12+
10
+ provider_config = getattr(config, 'provider_config', {})
11
+ host = provider_config.get('redis_host')
12
+ port = provider_config.get('redis_port')
13
+ password = provider_config.get('redis_password')
14
+ ssl = provider_config.get('redis_ssl', False)
15
+ cluster_mode = provider_config.get('redis_cluster_mode', False)
16
+ db = provider_config.get('redis_db', 0)
17
+
18
+ if not host or not port:
19
+ raise RedisConfigError("redis_host and redis_port must be set in provider_config")
20
+
21
+ if cluster_mode:
22
+ # Use RedisCluster for cluster mode (ElastiCache with cluster mode enabled)
23
+ try:
24
+ from redis.cluster import RedisCluster
25
+ except ImportError:
26
+ raise RedisConfigError("Redis cluster support is not available. Please upgrade redis package to version 4.0.0 or later")
27
+
28
+ # For cluster mode, we need to handle startup nodes differently
29
+ # ElastiCache cluster mode provides a single endpoint
30
+ startup_nodes = [{"host": host, "port": int(port)}]
31
+
32
+ return RedisCluster(
33
+ startup_nodes=startup_nodes,
34
+ password=password,
35
+ ssl=ssl,
36
+ decode_responses=True,
37
+ skip_full_coverage_check=True, # Allow partial cluster access
38
+ )
39
+ else:
40
+ # Standard Redis client for single node or ElastiCache without cluster mode
41
+ return redis.StrictRedis(
42
+ host=host,
43
+ port=int(port),
44
+ password=password,
45
+ db=int(db),
46
+ ssl=ssl,
47
+ decode_responses=True,
48
+ socket_connect_timeout=5,
49
+ socket_timeout=5,
50
+ retry_on_timeout=True,
51
+ )
@@ -0,0 +1,96 @@
1
+ """
2
+ Slack Provider for commonlog
3
+ """
4
+ import requests
5
+
6
+ from pycommonlog.log_types import SendMethod, Provider, debug_log
7
+
8
+ class SlackProvider(Provider):
9
+ def send_to_channel(self, level, message, attachment, config, channel):
10
+ original_channel = config.channel
11
+ config.channel = channel
12
+ self.send(level, message, attachment, config)
13
+ config.channel = original_channel
14
+
15
+ def send(self, level, message, attachment, config):
16
+ debug_log(config, f"SlackProvider.send called with level: {level}, send method: {config.send_method}")
17
+ formatted_message = self._format_message(message, attachment, config)
18
+ if config.send_method == SendMethod.WEBCLIENT:
19
+ debug_log(config, "Using Slack webclient method")
20
+ self._send_slack_webclient(formatted_message, config)
21
+ elif config.send_method == SendMethod.WEBHOOK:
22
+ debug_log(config, "Using Slack webhook method")
23
+ self._send_slack_webhook(formatted_message, config)
24
+ else:
25
+ error_msg = f"Unknown send method for Slack: {config.send_method}"
26
+ debug_log(config, f"Error: {error_msg}")
27
+ raise ValueError(error_msg)
28
+
29
+ def _format_message(self, message, attachment, config):
30
+ formatted = ""
31
+
32
+ # Add service and environment header
33
+ if config.service_name and config.environment:
34
+ formatted += f"*[{config.service_name} - {config.environment}]*\n"
35
+ elif config.service_name:
36
+ formatted += f"*[{config.service_name}]*\n"
37
+ elif config.environment:
38
+ formatted += f"*[{config.environment}]*\n"
39
+
40
+ formatted += message
41
+
42
+ if attachment and attachment.content:
43
+ filename = attachment.file_name or "Trace Logs"
44
+ formatted += f"\n\n*{filename}:*\n```\n{attachment.content}\n```"
45
+ if attachment and attachment.url:
46
+ formatted += f"\n\n*Attachment:* {attachment.url}"
47
+
48
+ return formatted
49
+
50
+ def _send_slack_webclient(self, formatted_message, config):
51
+ debug_log(config, "send_slack_webclient: preparing API request")
52
+ # Use slack_token if available, otherwise fall back to token
53
+ token = config.provider_config.get("token", "")
54
+ slack_token = config.provider_config.get("slack_token", "")
55
+ if slack_token:
56
+ token = slack_token
57
+ debug_log(config, "send_slack_webclient: using slack_token")
58
+ else:
59
+ debug_log(config, "send_slack_webclient: using token")
60
+
61
+ url = "https://slack.com/api/chat.postMessage"
62
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json; charset=utf-8"}
63
+ payload = {"channel": config.channel, "text": formatted_message}
64
+ debug_log(config, f"send_slack_webclient: sending to channel: {config.channel}, payload size: {len(str(payload))}")
65
+
66
+ response = requests.post(url, headers=headers, json=payload)
67
+ debug_log(config, f"send_slack_webclient: response status: {response.status_code}, response data: {response.text}")
68
+ if response.status_code != 200:
69
+ error_msg = f"Slack WebClient response: {response.status_code}"
70
+ debug_log(config, f"send_slack_webclient: error: {error_msg}")
71
+ raise Exception(error_msg)
72
+ debug_log(config, "send_slack_webclient: message sent successfully")
73
+
74
+ def _send_slack_webhook(self, formatted_message, config):
75
+ debug_log(config, "send_slack_webhook: preparing webhook request")
76
+ # For webhook, the token field contains the webhook URL
77
+ webhook_url = config.provider_config.get("token", "")
78
+ if not webhook_url:
79
+ error_msg = "Webhook URL is required for Slack webhook method"
80
+ debug_log(config, f"Error: {error_msg}")
81
+ raise Exception(error_msg)
82
+
83
+ debug_log(config, f"send_slack_webhook: using webhook URL, channel: {config.channel}")
84
+ payload = {"text": formatted_message}
85
+ # If channel is specified, include it in the payload
86
+ if config.channel:
87
+ payload["channel"] = config.channel
88
+
89
+ debug_log(config, f"send_slack_webhook: payload prepared, size: {len(str(payload))}")
90
+ response = requests.post(webhook_url, json=payload)
91
+ debug_log(config, f"send_slack_webhook: response status: {response.status_code}, response data: {response.text}")
92
+ if response.status_code != 200:
93
+ error_msg = f"Slack webhook response: {response.status_code}"
94
+ debug_log(config, f"send_slack_webhook: error: {error_msg}")
95
+ raise Exception(error_msg)
96
+ debug_log(config, "send_slack_webhook: webhook sent successfully")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycommonlog
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Unified logging and alerting library for Python.
5
5
  Home-page: https://github.com/alvianhanif/pycommonlog
6
6
  Author: Alvian Rahman Hanif
@@ -12,6 +12,9 @@ Classifier: Operating System :: OS Independent
12
12
  Requires-Python: >=3.8
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
+ Requires-Dist: requests
16
+ Provides-Extra: redis
17
+ Requires-Dist: redis>=4.0.0; extra == "redis"
15
18
  Dynamic: author
16
19
  Dynamic: author-email
17
20
  Dynamic: classifier
@@ -20,6 +23,8 @@ Dynamic: description-content-type
20
23
  Dynamic: home-page
21
24
  Dynamic: license
22
25
  Dynamic: license-file
26
+ Dynamic: provides-extra
27
+ Dynamic: requires-dist
23
28
  Dynamic: requires-python
24
29
  Dynamic: summary
25
30
 
@@ -50,13 +55,13 @@ from pycommonlog import commonlog, Config, SendMethod, AlertLevel, Attachment, L
50
55
 
51
56
  # Configure logger
52
57
  config = Config(
53
- provider="lark", # or "slack"
54
58
  send_method=SendMethod.WEBCLIENT,
55
- token="app_id++app_secret", # for Lark, use "app_id++app_secret" format
56
- slack_token="xoxb-your-slack-token", # dedicated Slack token
57
- lark_token=LarkToken(app_id="your-app-id", app_secret="your-app-secret"), # dedicated Lark token
58
59
  channel="your_lark_channel_id",
59
60
  provider_config={
61
+ "provider": "lark", # or "slack"
62
+ "token": "app_id++app_secret", # for Lark, use "app_id++app_secret" format
63
+ "slack_token": "xoxb-your-slack-token", # dedicated Slack token
64
+ "lark_token": LarkToken(app_id="your-app-id", app_secret="your-app-secret"), # dedicated Lark token
60
65
  "redis_host": "localhost", # required for Lark
61
66
  "redis_port": 6379, # required for Lark
62
67
  }
@@ -95,13 +100,13 @@ WebClient uses the full API with authentication tokens:
95
100
 
96
101
  ```python
97
102
  config = Config(
98
- provider="lark",
99
103
  send_method=SendMethod.WEBCLIENT,
100
- token="app_id++app_secret", # for Lark
101
- slack_token="xoxb-your-slack-token", # for Slack
102
- lark_token=LarkToken(app_id="your-app-id", app_secret="your-app-secret"),
103
104
  channel="your_channel",
104
105
  provider_config={
106
+ "provider": "lark", # or "slack"
107
+ "token": "app_id++app_secret", # for Lark
108
+ "slack_token": "xoxb-your-slack-token", # for Slack
109
+ "lark_token": LarkToken(app_id="your-app-id", app_secret="your-app-secret"),
105
110
  "redis_host": "localhost", # required for Lark
106
111
  "redis_port": 6379, # required for Lark
107
112
  }
@@ -114,10 +119,12 @@ Webhook is simpler and requires only a webhook URL:
114
119
 
115
120
  ```python
116
121
  config = Config(
117
- provider="slack",
118
122
  send_method=SendMethod.WEBHOOK,
119
- token="https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
120
123
  channel="optional-channel-override", # optional
124
+ provider_config={
125
+ "provider": "slack",
126
+ "token": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
127
+ }
121
128
  )
122
129
  ```
123
130
 
@@ -129,11 +136,11 @@ Lark integration requires proper token configuration for authentication. You can
129
136
 
130
137
  ```python
131
138
  config = Config(
132
- provider="lark",
133
139
  send_method=SendMethod.WEBCLIENT,
134
- token="your_app_id++your_app_secret", # Combined format: app_id++app_secret
135
140
  channel="your_channel_id",
136
141
  provider_config={
142
+ "provider": "lark",
143
+ "token": "your_app_id++your_app_secret", # Combined format: app_id++app_secret
137
144
  "redis_host": "localhost", # Optional: enables caching
138
145
  "redis_port": 6379,
139
146
  }
@@ -143,17 +150,15 @@ config = Config(
143
150
  #### Method 2: Dedicated Lark Token Object
144
151
 
145
152
  ```python
146
- from pycommonlog import LarkToken
147
-
148
153
  config = Config(
149
- provider="lark",
150
154
  send_method=SendMethod.WEBCLIENT,
151
- lark_token=LarkToken(
152
- app_id="your_app_id",
153
- app_secret="your_app_secret"
154
- ),
155
155
  channel="your_channel_id",
156
156
  provider_config={
157
+ "provider": "lark",
158
+ "lark_token": LarkToken(
159
+ app_id="your_app_id",
160
+ app_secret="your_app_secret"
161
+ ),
157
162
  "redis_host": "localhost", # Optional: enables caching
158
163
  "redis_port": 6379,
159
164
  }
@@ -199,12 +204,14 @@ resolver = DefaultChannelResolver(
199
204
 
200
205
  # Create config with channel resolver
201
206
  config = Config(
202
- provider="slack",
203
207
  send_method=SendMethod.WEBCLIENT,
204
- token="xoxb-your-slack-bot-token",
205
208
  channel_resolver=resolver,
206
209
  service_name="user-service",
207
- environment="production"
210
+ environment="production",
211
+ provider_config={
212
+ "provider": "slack",
213
+ "token": "xoxb-your-slack-bot-token",
214
+ }
208
215
  )
209
216
 
210
217
  logger = commonlog(config)
@@ -234,17 +241,27 @@ class CustomResolver(ChannelResolver):
234
241
 
235
242
  ### Common Settings
236
243
 
237
- - **provider**: `"slack"` or `"lark"`
238
- - **send_method**: `"webclient"` (token-based authentication)
244
+ - **send_method**: `"webclient"` (token-based authentication) or `"webhook"`
239
245
  - **channel**: Target channel or chat ID (used if no resolver)
240
246
  - **channel_resolver**: Optional resolver for dynamic channel mapping
241
247
  - **service_name**: Name of the service sending alerts
242
248
  - **environment**: Environment (dev, staging, production)
243
249
  - **debug**: `True` to enable detailed debug logging of all internal processes
244
250
 
245
- ### Provider-Specific
251
+ ### ProviderConfig Settings
246
252
 
247
- - **token**: API token for WebClient authentication (required)
253
+ All provider-specific configuration is now done via the `provider_config` dict:
254
+
255
+ - **provider**: `"slack"` or `"lark"`
256
+ - **token**: API token for WebClient authentication or webhook URL for Webhook method
257
+ - **slack_token**: Dedicated Slack token (optional, overrides token for Slack)
258
+ - **lark_token**: `LarkToken` object with app_id and app_secret (optional, overrides token for Lark)
259
+ - **redis_host**: Redis host for Lark caching (optional)
260
+ - **redis_port**: Redis port for Lark caching (optional)
261
+ - **redis_password**: Redis password (optional)
262
+ - **redis_ssl**: Enable SSL for Redis (optional)
263
+ - **redis_cluster_mode**: Enable Redis cluster mode (optional)
264
+ - **redis_db**: Redis database number (optional)
248
265
 
249
266
  ## Alert Levels
250
267
 
@@ -8,4 +8,9 @@ pycommonlog/logger.py
8
8
  pycommonlog.egg-info/PKG-INFO
9
9
  pycommonlog.egg-info/SOURCES.txt
10
10
  pycommonlog.egg-info/dependency_links.txt
11
- pycommonlog.egg-info/top_level.txt
11
+ pycommonlog.egg-info/requires.txt
12
+ pycommonlog.egg-info/top_level.txt
13
+ pycommonlog/providers/__init__.py
14
+ pycommonlog/providers/lark.py
15
+ pycommonlog/providers/redis_client.py
16
+ pycommonlog/providers/slack.py
@@ -0,0 +1,4 @@
1
+ requests
2
+
3
+ [redis]
4
+ redis>=4.0.0
@@ -25,13 +25,16 @@ setup(
25
25
  name="pycommonlog",
26
26
  version=get_latest_git_tag(),
27
27
  description="Unified logging and alerting library for Python.",
28
- long_description=open("README.md").read(),
28
+ long_description=open("README.md", encoding="utf-8").read(),
29
29
  long_description_content_type="text/markdown",
30
30
  author="Alvian Rahman Hanif",
31
31
  author_email="alvian.hanif@pasarpolis.com",
32
32
  url="https://github.com/alvianhanif/pycommonlog",
33
- packages=["pycommonlog"],
34
- install_requires=[],
33
+ packages=find_packages(),
34
+ install_requires=["requests"],
35
+ extras_require={
36
+ "redis": ["redis>=4.0.0"],
37
+ },
35
38
  license="MIT",
36
39
  python_requires=">=3.8",
37
40
  classifiers=[
File without changes
File without changes