json-logify 0.1.0__py3-none-any.whl → 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json-logify
3
- Version: 0.1.0
3
+ Version: 0.1.3
4
4
  Summary: Universal structured logging with exact JSON schema for Python frameworks
5
5
  Author-email: Bakdoolot Kulbarakov <kulbarakovbh@gmail.com>
6
6
  Maintainer-email: Bakdoolot Kulbarakov <kulbarakovbh@gmail.com>
@@ -54,11 +54,6 @@ Requires-Dist: structlog>=23.0.0
54
54
  Requires-Dist: orjson>=3.8.0
55
55
  Provides-Extra: django
56
56
  Requires-Dist: django>=5.2.6; extra == "django"
57
- Provides-Extra: fastapi
58
- Requires-Dist: fastapi>=0.116.1; extra == "fastapi"
59
- Requires-Dist: uvicorn>=0.35.0; extra == "fastapi"
60
- Provides-Extra: flask
61
- Requires-Dist: flask>=3.1.2; extra == "flask"
62
57
  Provides-Extra: dev
63
58
  Requires-Dist: pytest>=8.4.2; extra == "dev"
64
59
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -83,13 +78,14 @@ Universal structured logging with exact JSON schema for Python frameworks.
83
78
 
84
79
  ## Features
85
80
 
86
- - <� **Exact JSON Schema**: Consistent log format across all frameworks
87
- - **High Performance**: Built with structlog and orjson for maximum speed
88
- - < **Universal**: Works with Django, FastAPI, Flask, and standalone Python
89
- - =' **Easy Setup**: One-line configuration for most use cases
90
- - =� **Rich Context**: Request IDs, user tracking, and custom payload support
91
- - =
92
- **Modern Python**: Full type hints and async support
81
+ - **Exact JSON Schema**: Consistent log format across all frameworks
82
+ - **High Performance**: Built with structlog and orjson for maximum speed
83
+ - **Universal**: Works with Django, FastAPI, Flask and standalone Python
84
+ - **Security First**: Automatic masking of sensitive data (passwords, tokens, etc.)
85
+ - **Easy Setup**: One-line configuration for most use cases
86
+ - **Rich Context**: Request IDs, user tracking, and custom payload support
87
+ - **Smart Filtering**: Configurable path ignoring and request/response body logging
88
+ - **Modern Python**: Full type hints and async support
93
89
 
94
90
  ## Quick Start
95
91
 
@@ -101,8 +97,8 @@ pip install json-logify
101
97
 
102
98
  # For specific frameworks
103
99
  pip install json-logify[django]
104
- pip install json-logify[fastapi]
105
- pip install json-logify[flask]
100
+ # pip install json-logify[fastapi] # Coming soon
101
+ # pip install json-logify[flask] # Coming soon
106
102
 
107
103
  # Everything
108
104
  pip install json-logify[all]
@@ -111,50 +107,164 @@ pip install json-logify[all]
111
107
  ### Basic Usage
112
108
 
113
109
  ```python
114
- from logify import info, error
110
+ from logify import info, error, debug, warning
115
111
 
116
- # Simple logging
117
- info("User logged in", user_id="12345", action="login")
112
+ # Basic logging with message
113
+ info("User logged in")
118
114
 
119
- # Error logging with exception
115
+ # With structured context
116
+ info("Payment processed", amount=100.0, currency="USD", user_id="user123")
117
+
118
+ # Different log levels
119
+ debug("Debug information", query_time=0.023)
120
+ warning("Slow database query detected", query_time=1.52, query_id="a1b2c3")
121
+ error("Payment failed", error_code="CARD_DECLINED", user_id="user123")
122
+
123
+ # Exception handling
120
124
  try:
121
- raise ValueError("Something went wrong")
125
+ # Some code that might fail
126
+ result = some_function()
122
127
  except Exception as e:
123
- error("Operation failed", error=e, operation="data_processing")
128
+ error("Operation failed", exception=str(e), operation="some_function")
124
129
  ```
125
130
 
126
- Output:
127
- ```json
128
- {
129
- "timestamp": "2025-01-15T10:30:00.123Z",
130
- "message": "User logged in",
131
- "level": "INFO",
132
- "payload": {
133
- "user_id": "12345",
134
- "action": "login"
135
- }
136
- }
131
+ ### Django Integration
132
+
133
+ #### 1. Install with Django extras:
134
+
135
+ ```bash
136
+ pip install json-logify[django]
137
137
  ```
138
138
 
139
- ### Django Integration
139
+ #### 2. Configure in settings.py:
140
140
 
141
141
  ```python
142
- # settings.py
143
142
  from logify.django import get_logging_config
144
143
 
145
- LOGGING = get_logging_config(
146
- service_name="myapp",
147
- json_logs=True
148
- )
149
-
150
- # Add middleware (optional for request tracking)
144
+ # Add middleware to MIDDLEWARE list
151
145
  MIDDLEWARE = [
152
- 'logify.django.LogifyMiddleware',
153
146
  # ... other middleware
147
+ 'logify.django.LogifyMiddleware', # ← Add this
154
148
  ]
149
+
150
+ # Configure logging with json-logify
151
+ LOGGING = get_logging_config(
152
+ service_name="my-django-app",
153
+ level="INFO",
154
+ max_string_length=200, # String truncation limit
155
+ sensitive_fields=[ # Fields to mask with "***"
156
+ "password", "passwd", "secret", "token", "api_key",
157
+ "access_token", "refresh_token", "session_key",
158
+ "credit_card", "cvv", "ssn", "authorization",
159
+ "cookie", "x-api-key", "custom_sensitive_field"
160
+ ],
161
+ ignore_paths=[ # Paths to skip logging
162
+ "/health/", "/static/", "/favicon.ico",
163
+ "/admin/jsi18n/", "/metrics/"
164
+ ]
165
+ )
166
+
167
+ # Optional: Reduce Django built-in logger noise
168
+ LOGGING['loggers'].update({
169
+ 'django.utils.autoreload': {'level': 'WARNING'},
170
+ 'django.db.backends': {'level': 'WARNING'},
171
+ 'django.server': {'level': 'WARNING'},
172
+ 'django.request': {'level': 'WARNING'},
173
+ })
155
174
  ```
156
175
 
157
- ### FastAPI Integration
176
+ #### 3. Use in your views:
177
+
178
+ ```python
179
+ from logify import info, error, debug, warning
180
+ from django.http import JsonResponse
181
+
182
+ def process_payment(request):
183
+ # Log with automatic request context
184
+ info("Payment processing started",
185
+ user_id=request.user.id,
186
+ amount=request.POST.get('amount'))
187
+
188
+ try:
189
+ # Sensitive data gets automatically masked
190
+ info("User data received",
191
+ username=request.user.username, # ← Visible
192
+ password=request.POST.get('password'), # ← Masked: "***"
193
+ credit_card=request.POST.get('card'), # ← Masked: "***"
194
+ email=request.user.email) # ← Visible
195
+
196
+ # Your business logic
197
+ payment = process_payment_logic(request.POST)
198
+
199
+ # Log success
200
+ info("Payment completed",
201
+ payment_id=payment.id,
202
+ status="success",
203
+ amount=payment.amount)
204
+
205
+ return JsonResponse({"status": "success", "payment_id": payment.id})
206
+
207
+ except ValidationError as e:
208
+ error("Payment validation failed", error=e, user_id=request.user.id)
209
+ return JsonResponse({"status": "error", "message": str(e)}, status=400)
210
+ except Exception as e:
211
+ error("Payment processing failed", error=e)
212
+ return JsonResponse({"status": "error"}, status=500)
213
+ ```
214
+
215
+ #### 4. What you get automatically:
216
+
217
+ **Request logging:**
218
+ ```json
219
+ {
220
+ "timestamp": "2025-09-09T08:09:35.933Z",
221
+ "message": "Request started",
222
+ "level": "INFO",
223
+ "payload": {
224
+ "request_id": "b62e59b6-bae7-4a96-821d",
225
+ "service": "my-django-app",
226
+ "method": "POST",
227
+ "path": "/api/payment/",
228
+ "user_info": "User ID: 123: john_doe",
229
+ "headers": {
230
+ "Content-Type": "application/json",
231
+ "Authorization": "***", // ← Automatically masked
232
+ "User-Agent": "curl/8.7.1"
233
+ },
234
+ "request_body": {
235
+ "username": "john_doe",
236
+ "password": "***", // ← Automatically masked
237
+ "credit_card": "***" // ← Automatically masked
238
+ }
239
+ }
240
+ }
241
+ ```
242
+
243
+ **Your application logs:**
244
+ ```json
245
+ {
246
+ "timestamp": "2025-09-09T08:09:35.934Z",
247
+ "message": "Payment completed",
248
+ "level": "INFO",
249
+ "payload": {
250
+ "request_id": "b62e59b6-bae7-4a96-821d", // ← Auto-linked to request
251
+ "payment_id": "pay_123456",
252
+ "status": "success",
253
+ "amount": 99.99
254
+ }
255
+ }
256
+ ```
257
+
258
+ **🔒 Security Features:**
259
+ - **Automatic masking**: Passwords, tokens, API keys, credit cards → `"***"`
260
+ - **Header filtering**: Authorization, Cookie, X-API-Key → `"***"`
261
+ - **Recursive masking**: Works in nested objects and arrays
262
+ - **Request/Response body**: Limited size + content-type filtering
263
+ - **Path ignoring**: Skip health checks, static files, etc.
264
+ - Request and response bodies (with sensitive fields masked)
265
+
266
+ <!--
267
+ ### FastAPI Integration (Coming Soon)
158
268
 
159
269
  ```python
160
270
  from fastapi import FastAPI
@@ -170,7 +280,7 @@ async def root():
170
280
  return {"message": "Hello World"}
171
281
  ```
172
282
 
173
- ### Flask Integration
283
+ ### Flask Integration (Coming Soon)
174
284
 
175
285
  ```python
176
286
  from flask import Flask
@@ -185,6 +295,7 @@ def hello():
185
295
  info("Flask endpoint called", endpoint="/")
186
296
  return "Hello, World!"
187
297
  ```
298
+ -->
188
299
 
189
300
  ## Advanced Usage
190
301
 
@@ -0,0 +1,11 @@
1
+ json_logify-0.1.3.dist-info/licenses/LICENSE,sha256=qRyWd6Y0_db_j_ECsAAEJW8-XMnhYUg_yK3c5UkOfNI,1077
2
+ logify/__init__.py,sha256=t2Jjw7T4jRMIYvXJOTBraaW6MneifmS_gDKDlrRHmeU,1053
3
+ logify/core.py,sha256=6IoiwnIOlghiCPE89lyAG_FK4II9bhyvZRlK75UJ_F8,12603
4
+ logify/django.py,sha256=qy20qkJhhUPz0Nl3yQMPryqVj5H0RXE2DxPk7La6iws,12842
5
+ logify/fastapi.py,sha256=G570BPDdgEv2e0gASI2SZqNjyUNeIyemPcCXH6f-UPg,2710
6
+ logify/flask.py,sha256=TCWBSni8uEWBjvNJTFM87VPUwDRI3PTdAm3HSbP5R2g,2802
7
+ logify/version.py,sha256=YvuYzWnKtqBb-IqG8HAu-nhIYAsgj9Vmc_b9o7vO-js,22
8
+ json_logify-0.1.3.dist-info/METADATA,sha256=ZAxBK34k_XY-G64m6uSFn5isgO-VwUa3RKWi2BpFkOs,11563
9
+ json_logify-0.1.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
10
+ json_logify-0.1.3.dist-info/top_level.txt,sha256=MRERkeaav8J-KtwGpiLDCnSNQ5e2JqKUMAlJnYVntao,7
11
+ json_logify-0.1.3.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
logify/core.py CHANGED
@@ -15,12 +15,187 @@ import structlog
15
15
  # Context variables for request tracking
16
16
  _request_context: ContextVar[Dict[str, Any]] = ContextVar("request_context", default={})
17
17
 
18
+ # Internal configuration storage
19
+ _config = {
20
+ "MAX_STRING_LENGTH": 100,
21
+ "SENSITIVE_FIELDS": {
22
+ "password",
23
+ "passwd",
24
+ "pass",
25
+ "pwd",
26
+ "secret",
27
+ "token",
28
+ "key",
29
+ "api_key",
30
+ "access_token",
31
+ "refresh_token",
32
+ "auth_token",
33
+ "session_key",
34
+ "private_key",
35
+ "credit_card",
36
+ "card_number",
37
+ "cvv",
38
+ "ssn",
39
+ "social_security_number",
40
+ },
41
+ }
42
+
43
+
44
+ def configure_core(sensitive_fields=None, replace_sensitive_defaults=False, max_string_length=None):
45
+ """
46
+ Configure core logging settings.
47
+
48
+ Args:
49
+ sensitive_fields: List or set of field names to mask
50
+ replace_sensitive_defaults: If True, replace default sensitive fields with provided ones.
51
+ If False, merge provided fields with defaults.
52
+ max_string_length: Maximum length for string truncation
53
+ """
54
+ if sensitive_fields is not None:
55
+ new_fields = set()
56
+ if isinstance(sensitive_fields, (list, tuple)):
57
+ new_fields = set(field.lower() for field in sensitive_fields)
58
+ elif isinstance(sensitive_fields, set):
59
+ new_fields = set(field.lower() for field in sensitive_fields)
60
+
61
+ if replace_sensitive_defaults:
62
+ _config["SENSITIVE_FIELDS"] = new_fields
63
+ else:
64
+ _config["SENSITIVE_FIELDS"].update(new_fields)
65
+
66
+ if max_string_length is not None:
67
+ _config["MAX_STRING_LENGTH"] = max_string_length
68
+
69
+
70
+ def get_config_value(key: str, default=None):
71
+ """Get a value from core configuration."""
72
+ return _config.get(key, default)
73
+
18
74
 
19
75
  def orjson_serializer(_, __, event_dict):
20
76
  """Serialize log entries using orjson for performance."""
21
77
  return orjson.dumps(event_dict).decode("utf-8")
22
78
 
23
79
 
80
+ def truncate_long_strings(_, __, event_dict):
81
+ """Truncate long strings in log entries based on max_string_length setting."""
82
+ max_length = _config["MAX_STRING_LENGTH"]
83
+
84
+ # Truncate long strings - only if max_length is positive
85
+ if max_length > 0:
86
+ for key, value in event_dict.items():
87
+ if isinstance(value, str) and len(value) > max_length:
88
+ event_dict[key] = value[:max_length] + "..."
89
+
90
+ return event_dict
91
+
92
+
93
+ def clean_non_serializable_objects(_, __, event_dict):
94
+ """Clean non-serializable objects from log entries."""
95
+ max_length = _config["MAX_STRING_LENGTH"]
96
+
97
+ cleaned = {}
98
+ for key, value in event_dict.items():
99
+ # Skip 'error' field - it will be handled specially in format_log_entry
100
+ if key == "error":
101
+ cleaned[key] = value
102
+ continue
103
+
104
+ try:
105
+ # Test if value is JSON serializable
106
+ orjson.dumps(value)
107
+ cleaned[key] = value
108
+ except TypeError:
109
+ # Convert non-serializable objects to their string representation
110
+ if hasattr(value, "__class__"):
111
+ cleaned[key] = f"<{value.__class__.__name__}: {str(value)[:max_length]}>"
112
+ else:
113
+ cleaned[key] = str(value)[:max_length]
114
+ return cleaned
115
+
116
+
117
+ def mask_sensitive_fields(_, __, event_dict):
118
+ """Mask sensitive fields in log entries recursively."""
119
+ sensitive_fields = _config["SENSITIVE_FIELDS"]
120
+
121
+ def _mask_value_if_sensitive(key, value):
122
+ """Check if key is sensitive and return masked value if so."""
123
+ key_lower = key.lower()
124
+ if any(s in key_lower for s in sensitive_fields):
125
+ if value and str(value).strip():
126
+ return "***"
127
+ return value
128
+
129
+ def _scrub_string(text):
130
+ """Scrub sensitive data from strings (e.g. url encoded bodies, query strings)."""
131
+ if not text:
132
+ return text
133
+
134
+ # Handle URLs with query parameters
135
+ if "?" in text:
136
+ base, query = text.split("?", 1)
137
+ # Recursively scrub the query part
138
+ scrubbed_query = _scrub_string(query)
139
+ return f"{base}?{scrubbed_query}"
140
+
141
+ # pattern matching for key=value pairs where key is sensitive
142
+ # We do a simple approach: split by & and =
143
+ if "=" in text:
144
+ # Check if this looks like a query string or form data
145
+ # This is a heuristic
146
+ parts = text.split("&")
147
+ new_parts = []
148
+ modified = False
149
+
150
+ for part in parts:
151
+ if "=" in part:
152
+ k, v = part.split("=", 1)
153
+ # Check if key is sensitive
154
+ k_lower = k.lower()
155
+ if any(s in k_lower for s in sensitive_fields):
156
+ new_parts.append(f"{k}=***")
157
+ modified = True
158
+ else:
159
+ new_parts.append(part)
160
+ else:
161
+ new_parts.append(part)
162
+
163
+ if modified:
164
+ return "&".join(new_parts)
165
+
166
+ return text
167
+
168
+ def _mask_recursive(obj):
169
+ """Recursively mask sensitive fields in dicts and lists."""
170
+ if isinstance(obj, dict):
171
+ masked = {}
172
+ for k, v in obj.items():
173
+ # First check if the key itself is sensitive
174
+ v_masked = _mask_value_if_sensitive(k, v)
175
+ if v_masked == "***":
176
+ masked[k] = "***"
177
+ continue
178
+
179
+ # If not masked by key, recurse or string scrub
180
+ if isinstance(v, (dict, list)):
181
+ masked[k] = _mask_recursive(v)
182
+ elif isinstance(v, str):
183
+ masked[k] = _scrub_string(v)
184
+ else:
185
+ masked[k] = v
186
+ return masked
187
+ elif isinstance(obj, list):
188
+ return [_mask_recursive(item) for item in obj]
189
+ elif isinstance(obj, str):
190
+ return _scrub_string(obj)
191
+ return obj
192
+
193
+ # Apply masking to the entire event_dict (including top-level keys)
194
+ event_dict = _mask_recursive(event_dict)
195
+
196
+ return event_dict
197
+
198
+
24
199
  def add_timestamp(_, __, event_dict):
25
200
  """Add ISO timestamp to log entries."""
26
201
  # Unresolved attribute reference 'UTC' for class 'datetime'
@@ -39,11 +214,12 @@ def format_log_entry(logger, name, event_dict):
39
214
  # Remove level from event_dict to avoid duplication
40
215
  event_dict.pop("level", None)
41
216
 
42
- # Get context
217
+ # Get only request_id from context to avoid duplication
43
218
  try:
44
219
  context = _request_context.get({})
220
+ request_id_only = {"request_id": context.get("request_id")} if context.get("request_id") else {}
45
221
  except LookupError:
46
- context = {}
222
+ request_id_only = {}
47
223
 
48
224
  # Extract error from event_dict if present
49
225
  error_field = event_dict.pop("error", None)
@@ -53,12 +229,19 @@ def format_log_entry(logger, name, event_dict):
53
229
  "timestamp": event_dict.pop("timestamp", datetime.now(UTC).isoformat().replace("+00:00", "Z")),
54
230
  "message": message,
55
231
  "level": level.upper(),
56
- "payload": {**context, **event_dict},
232
+ "payload": {**request_id_only, **event_dict},
57
233
  }
58
234
 
59
235
  # Add optional error field at top level
60
236
  if error_field:
61
- formatted_entry["error"] = str(error_field)
237
+ # Format as "ErrorType: message"
238
+ if isinstance(error_field, Exception):
239
+ if error_field.args:
240
+ formatted_entry["error"] = f"{error_field.__class__.__name__}: {error_field.args[0]}"
241
+ else:
242
+ formatted_entry["error"] = f"{error_field.__class__.__name__}: No message"
243
+ else:
244
+ formatted_entry["error"] = str(error_field)
62
245
 
63
246
  return formatted_entry
64
247
 
@@ -67,6 +250,9 @@ def format_log_entry(logger, name, event_dict):
67
250
  structlog.configure(
68
251
  processors=[
69
252
  structlog.stdlib.add_log_level,
253
+ clean_non_serializable_objects, # Clean non-serializable objects FIRST
254
+ truncate_long_strings, # Truncate long strings SECOND
255
+ mask_sensitive_fields, # Mask sensitive fields THIRD
70
256
  add_timestamp,
71
257
  format_log_entry,
72
258
  orjson_serializer,
@@ -106,7 +292,7 @@ def error(message: str, error: Exception = None, **kwargs):
106
292
  """Log error message."""
107
293
  if error:
108
294
  kwargs["error"] = error # Will be moved to top-level by format_log_entry
109
- kwargs["error_type"] = type(error).__name__
295
+ # error_type removed - now included in error field as "Type: message"
110
296
  logger.error(message, **kwargs)
111
297
 
112
298
 
@@ -123,21 +309,37 @@ def exception(message: str, **kwargs):
123
309
  logger.error(message, **kwargs)
124
310
 
125
311
 
126
- def get_logger(name: str = "json-logify", service: str = "app"):
312
+ def get_logger(name: str = "json-logify"):
127
313
  """Get a named logger instance."""
128
314
  bound_logger = structlog.get_logger(name)
129
- return bound_logger.bind(service=service)
315
+ return bound_logger
130
316
 
131
317
 
132
- def configure_logging(service_name: str = "app", level: str = "INFO"):
318
+ def configure_logging(
319
+ service_name: str = "app",
320
+ level: str = "INFO",
321
+ sensitive_fields=None,
322
+ replace_sensitive_defaults=False,
323
+ max_string_length=None,
324
+ ):
133
325
  """Configure logging for the application."""
134
326
  import logging
135
327
 
328
+ # Configure core settings
329
+ configure_core(
330
+ sensitive_fields=sensitive_fields,
331
+ replace_sensitive_defaults=replace_sensitive_defaults,
332
+ max_string_length=max_string_length,
333
+ )
334
+
136
335
  # Set logging level on stdlib logger
137
336
  logging.basicConfig(level=getattr(logging, level.upper(), logging.INFO))
138
337
 
139
338
  structlog.configure(
140
339
  processors=[
340
+ clean_non_serializable_objects, # Clean non-serializable objects FIRST
341
+ truncate_long_strings, # Truncate long strings SECOND
342
+ mask_sensitive_fields, # Mask sensitive fields THIRD
141
343
  add_timestamp,
142
344
  format_log_entry,
143
345
  orjson_serializer,
@@ -147,9 +349,9 @@ def configure_logging(service_name: str = "app", level: str = "INFO"):
147
349
  cache_logger_on_first_use=True,
148
350
  )
149
351
 
150
- # Bind service name to the default logger
352
+ # Set default logger without service binding
151
353
  global logger
152
- logger = structlog.get_logger("json-logify").bind(service=service_name)
354
+ logger = structlog.get_logger("json-logify")
153
355
 
154
356
 
155
357
  def bind(**kwargs):
logify/django.py CHANGED
@@ -2,44 +2,134 @@
2
2
  Django integration for json-logify structured logging.
3
3
  """
4
4
 
5
- from .core import clear_request_context, configure_logging, generate_request_id, set_request_context
5
+ import logging
6
6
 
7
+ import structlog
7
8
 
8
- def get_logging_config(service_name: str = "django", level: str = "INFO", json_logs: bool = True):
9
+ from .core import clear_request_context, configure_core
10
+ from .core import configure_logging as configure_core_logging
11
+ from .core import generate_request_id, get_config_value, info, set_request_context
12
+
13
+ # Django-specific settings storage
14
+ _django_settings = {
15
+ "SERVICE_NAME": "django",
16
+ "IGNORE_PATHS": ["/health/", "/healthz/", "/api/schema/", "/static/", "/favicon.ico", "/robots.txt"],
17
+ "EXCLUDED_FIELDS": {
18
+ "name",
19
+ "msg",
20
+ "args",
21
+ "levelname",
22
+ "levelno",
23
+ "pathname",
24
+ "filename",
25
+ "module",
26
+ "lineno",
27
+ "funcName",
28
+ "created",
29
+ "msecs",
30
+ "relativeCreated",
31
+ "thread",
32
+ "threadName",
33
+ "processName",
34
+ "process",
35
+ "message",
36
+ "exc_info",
37
+ "exc_text",
38
+ "stack_info",
39
+ "getMessage",
40
+ "request",
41
+ "response",
42
+ "server_time",
43
+ "status_code",
44
+ },
45
+ }
46
+
47
+
48
+ def get_logging_config(
49
+ service_name: str = "django",
50
+ level: str = "INFO",
51
+ json_logs: bool = True,
52
+ excluded_fields: list = None,
53
+ sensitive_fields: list = None,
54
+ replace_sensitive_defaults: bool = False,
55
+ max_string_length: int = None,
56
+ ignore_paths: list = None,
57
+ ):
9
58
  """
10
59
  Get Django logging configuration for json-logify.
11
60
 
61
+ Args:
62
+ service_name: Name of the service for logging
63
+ level: Logging level (DEBUG, INFO, WARNING, ERROR)
64
+ json_logs: Whether to enable JSON logging
65
+ excluded_fields: List of fields to exclude from logs
66
+ sensitive_fields: List of fields to mask with *** (also used for sensitive headers)
67
+ replace_sensitive_defaults: If True, replace default sensitive fields. If False, merge.
68
+ max_string_length: Maximum length for string truncation
69
+ ignore_paths: List of URL paths to ignore from logging
70
+
12
71
  Usage in settings.py:
13
72
  from logify.django import get_logging_config
14
- LOGGING = get_logging_config(service_name="myapp")
73
+ LOGGING = get_logging_config(
74
+ service_name="myapp",
75
+ level="INFO",
76
+ excluded_fields=["custom_field"],
77
+ sensitive_fields=["password", "secret", "authorization"],
78
+ replace_sensitive_defaults=False, # Optional, defaults to False (merge)
79
+ max_string_length=200,
80
+ ignore_paths=["/health/", "/static/"]
81
+ )
15
82
  """
83
+ # Configure core settings (this sets global state in core module)
84
+ configure_core(
85
+ sensitive_fields=sensitive_fields,
86
+ replace_sensitive_defaults=replace_sensitive_defaults,
87
+ max_string_length=max_string_length,
88
+ )
89
+
16
90
  if json_logs:
17
- configure_logging(service_name=service_name, level=level)
91
+ # This sets up the structlog pipeline
92
+ configure_core_logging(service_name=service_name, level=level)
93
+
94
+ # Store Django-specific settings
95
+ _django_settings["SERVICE_NAME"] = service_name
96
+
97
+ if excluded_fields is not None:
98
+ default_excluded = _django_settings["EXCLUDED_FIELDS"]
99
+ _django_settings["EXCLUDED_FIELDS"] = set(default_excluded) | set(excluded_fields)
100
+
101
+ if ignore_paths is not None:
102
+ _django_settings["IGNORE_PATHS"] = ignore_paths
18
103
 
19
104
  return {
20
105
  "version": 1,
21
106
  "disable_existing_loggers": False,
22
107
  "formatters": {
23
- "json": {"format": "%(message)s"},
108
+ "json": {
109
+ "format": "%(asctime)s %(name)s %(levelname)s %(message)s",
110
+ },
24
111
  },
25
112
  "handlers": {
26
113
  "console": {
27
114
  "class": "logging.StreamHandler",
28
115
  "formatter": "json",
29
116
  },
117
+ "structlog": {
118
+ "()": "logify.django.StructlogHandler",
119
+ },
30
120
  },
31
121
  "root": {
32
- "handlers": ["console"],
122
+ "handlers": ["structlog"],
33
123
  "level": level,
34
124
  },
35
125
  "loggers": {
36
126
  "django": {
37
- "handlers": ["console"],
127
+ "handlers": ["structlog"],
38
128
  "level": level,
39
129
  "propagate": False,
40
130
  },
41
131
  service_name: {
42
- "handlers": ["console"],
132
+ "handlers": ["structlog"],
43
133
  "level": level,
44
134
  "propagate": False,
45
135
  },
@@ -54,35 +144,238 @@ class LogifyMiddleware:
54
144
  self.get_response = get_response
55
145
 
56
146
  def __call__(self, request):
147
+ # Skip logging for ignored paths
148
+ if self._should_ignore_path(request.path):
149
+ return self.get_response(request)
150
+
57
151
  # Generate request ID and set context
58
152
  request_id = generate_request_id()
59
153
  request.logify_request_id = request_id
60
154
 
61
- set_request_context(
155
+ # Get service name
156
+ service_name = _django_settings.get("SERVICE_NAME", "django-app")
157
+
158
+ # Get user info
159
+ user_info = self._get_user_info(request)
160
+
161
+ # Get and scrub headers
162
+ scrubbed_headers = self._scrub_headers(dict(request.headers))
163
+
164
+ # Get request body (no scrubbing needed here - core processors will handle masking)
165
+ request_body = self._get_request_body(request)
166
+
167
+ # Log request start with all context information ONCE
168
+ info(
169
+ "Request started",
62
170
  request_id=request_id,
171
+ service=service_name,
63
172
  method=request.method,
64
173
  path=request.path,
65
- user_agent=request.META.get("HTTP_USER_AGENT", ""),
66
- remote_addr=request.META.get("REMOTE_ADDR", ""),
174
+ user_info=user_info,
175
+ headers=scrubbed_headers,
176
+ query_params=dict(request.GET) if request.GET else None,
177
+ request_body=request_body,
67
178
  )
68
179
 
180
+ # Set minimal context for other logs (only request_id)
181
+ set_request_context(request_id=request_id)
182
+
69
183
  try:
70
184
  response = self.get_response(request)
71
185
 
72
- # Add response info to context
73
- set_request_context(
186
+ # Get response body with content type filtering
187
+ response_body = self._get_response_body(response)
188
+
189
+ # Log request completion with response info
190
+ info(
191
+ "Request completed",
192
+ request_id=request_id,
193
+ user_info=user_info,
74
194
  status_code=response.status_code,
75
195
  content_length=len(response.content) if hasattr(response, "content") else None,
196
+ response_body=response_body,
76
197
  )
77
198
 
78
199
  return response
79
200
  finally:
80
201
  clear_request_context()
81
202
 
203
+ def _should_ignore_path(self, path):
204
+ """Check if path should be ignored from logging."""
205
+ ignore_paths = _django_settings.get("IGNORE_PATHS", [])
206
+
207
+ if isinstance(ignore_paths, (list, tuple)):
208
+ for ignore_path in ignore_paths:
209
+ if path.startswith(ignore_path):
210
+ return True
211
+ return False
212
+
213
+ def _get_user_info(self, request):
214
+ """Get user information from request."""
215
+ if hasattr(request, "user") and request.user.is_authenticated:
216
+ return f"User ID: {request.user.id}: {request.user.username}"
217
+ return "Anonymous user"
218
+
219
+ def _scrub_headers(self, headers):
220
+ """Mask sensitive headers using sensitive_fields settings."""
221
+ # Get sensitive fields from core config
222
+ sensitive_fields = get_config_value("SENSITIVE_FIELDS")
223
+
224
+ # We need to manually scrub here because headers are a dict we are constructing to pass to log
225
+ # And we want to label filtered headers specifically as [FILTERED] sometimes, or just use core logic.
226
+ # But core logic turns things into "***".
227
+ # Let's align with core logic.
228
+
229
+ scrubbed = {}
230
+ for key, value in headers.items():
231
+ key_lower = key.lower().replace("-", "_") # Convert dashes to underscores for matching
232
+ # Check if header name contains any sensitive substring
233
+ if any(s in key_lower for s in sensitive_fields):
234
+ scrubbed[key] = "[FILTERED]"
235
+ else:
236
+ scrubbed[key] = value
237
+ return scrubbed
238
+
239
+ def _get_request_body(self, request):
240
+ """Get request body with size limits."""
241
+ if not request.body:
242
+ return None
243
+
244
+ try:
245
+ # Limit body size for logging (max 10KB)
246
+ body_bytes = request.body[:10240]
247
+
248
+ if request.content_type and "json" in request.content_type:
249
+ import json
250
+
251
+ try:
252
+ request_body = json.loads(body_bytes.decode("utf-8"))
253
+ return request_body
254
+ except Exception:
255
+ # Fallback if JSON parse fails
256
+ pass
257
+
258
+ # For non-JSON data, try to parse as form data
259
+ if request.method in ["POST", "PUT", "PATCH"]:
260
+ try:
261
+ # Try form data first
262
+ form_data = dict(request.POST)
263
+ if form_data:
264
+ return form_data
265
+ except Exception:
266
+ pass
267
+
268
+ # Fall back to raw string (truncated)
269
+ # The CORE processor will mask sensitive data in this string!
270
+ return body_bytes.decode("utf-8", errors="replace")[:1000]
271
+
272
+ except Exception:
273
+ return "<non-readable body>"
274
+
275
+ def _get_response_body(self, response):
276
+ """Get response body with content type filtering."""
277
+ if not hasattr(response, "content") or not response.content:
278
+ return None
279
+
280
+ content_type = response.get("Content-Type", "") if hasattr(response, "get") else ""
281
+
282
+ # Skip HTML, JavaScript, CSS, and other non-data content types
283
+ skip_content_types = [
284
+ "text/html",
285
+ "text/css",
286
+ "application/javascript",
287
+ "text/javascript",
288
+ "image/",
289
+ "video/",
290
+ "audio/",
291
+ "application/pdf",
292
+ "application/octet-stream",
293
+ ]
294
+
295
+ if any(skip_type in content_type for skip_type in skip_content_types):
296
+ return f"<skipped: {content_type}>"
297
+
298
+ try:
299
+ # Limit response body size for logging (max 10KB)
300
+ body_bytes = response.content[:10240]
301
+
302
+ if "json" in content_type:
303
+ import json
304
+
305
+ try:
306
+ response_body = json.loads(body_bytes.decode("utf-8"))
307
+ return response_body
308
+ except Exception:
309
+ pass
310
+
311
+ # For non-JSON responses, return truncated text
312
+ return body_bytes.decode("utf-8", errors="replace")[:1000]
313
+
314
+ except Exception:
315
+ return "<non-readable body>"
316
+
82
317
 
83
318
  def setup_django_logging(service_name: str = "django"):
84
319
  """
85
320
  Set up Django logging with json-logify.
86
321
  Call this in your Django settings or apps.py
87
322
  """
88
- configure_logging(service_name=service_name)
323
+ # Configure json-logify structlog
324
+ configure_core_logging(service_name=service_name)
325
+
326
+ # Configure structlog to intercept standard logging
327
+ structlog.configure(
328
+ processors=[
329
+ structlog.stdlib.filter_by_level,
330
+ structlog.stdlib.add_logger_name,
331
+ structlog.stdlib.add_log_level,
332
+ structlog.stdlib.PositionalArgumentsFormatter(),
333
+ structlog.processors.TimeStamper(fmt="iso"),
334
+ structlog.processors.StackInfoRenderer(),
335
+ structlog.processors.format_exc_info,
336
+ structlog.processors.UnicodeDecoder(),
337
+ structlog.processors.JSONRenderer(),
338
+ ],
339
+ context_class=dict,
340
+ logger_factory=structlog.stdlib.LoggerFactory(),
341
+ wrapper_class=structlog.stdlib.BoundLogger,
342
+ cache_logger_on_first_use=True,
343
+ )
344
+
345
+
346
+ class StructlogHandler(logging.Handler):
347
+ """Custom handler that routes standard logging to structlog."""
348
+
349
+ def __init__(self):
350
+ super().__init__()
351
+ self.structlog_logger = structlog.get_logger()
352
+
353
+ def emit(self, record):
354
+ # Convert logging record to structlog format
355
+ level_name = record.levelname.lower()
356
+ structlog_method = getattr(self.structlog_logger, level_name, self.structlog_logger.info)
357
+
358
+ # Get excluded fields from settings
359
+ excluded_fields = _django_settings.get("EXCLUDED_FIELDS", set())
360
+
361
+ # Extract extra fields, excluding specified fields
362
+ # Note: We rely on core processors to mask sensitive data!
363
+ extra = {}
364
+ for key, value in record.__dict__.items():
365
+ if key not in excluded_fields:
366
+ # Skip complex objects that can't be JSON serialized
367
+ # Core has a cleaner which handles this too, but we can filtering here for safety
368
+ if hasattr(value, "__dict__") and not isinstance(value, (str, int, float, bool, list, dict)):
369
+ continue
370
+
371
+ extra[key] = value
372
+
373
+ # Log with structlog
374
+ structlog_method(
375
+ record.getMessage(),
376
+ logger=record.name,
377
+ module=record.module,
378
+ funcName=record.funcName,
379
+ lineno=record.lineno,
380
+ **extra,
381
+ )
logify/fastapi.py CHANGED
@@ -1,82 +1,82 @@
1
- """
2
- FastAPI integration for json-logify structured logging.
3
- """
4
-
5
- import time
6
-
7
- from fastapi import Request
8
- from starlette.middleware.base import BaseHTTPMiddleware
9
-
10
- from .core import clear_request_context, configure_logging, error, generate_request_id, info, set_request_context
11
-
12
-
13
- class LogifyMiddleware(BaseHTTPMiddleware):
14
- """FastAPI middleware for structured logging with request context."""
15
-
16
- def __init__(self, app, service_name: str = "fastapi", log_requests: bool = True):
17
- super().__init__(app)
18
- self.service_name = service_name
19
- self.log_requests = log_requests
20
- configure_logging(service_name=service_name)
21
-
22
- async def dispatch(self, request: Request, call_next):
23
- # Generate request ID and set context
24
- request_id = generate_request_id()
25
- start_time = time.time()
26
-
27
- # Extract client info
28
- client_host = getattr(request.client, "host", "unknown") if request.client else "unknown"
29
-
30
- set_request_context(
31
- request_id=request_id,
32
- method=request.method,
33
- path=request.url.path,
34
- query_params=str(request.query_params) if request.query_params else None,
35
- client_host=client_host,
36
- user_agent=request.headers.get("user-agent", ""),
37
- )
38
-
39
- if self.log_requests:
40
- info("Request started", method=request.method, path=request.url.path, request_id=request_id)
41
-
42
- try:
43
- response = await call_next(request)
44
-
45
- duration = time.time() - start_time
46
-
47
- # Add response info to context
48
- set_request_context(status_code=response.status_code, duration_seconds=duration)
49
-
50
- if self.log_requests:
51
- info(
52
- "Request completed",
53
- status_code=response.status_code,
54
- duration_seconds=duration,
55
- request_id=request_id,
56
- )
57
-
58
- return response
59
-
60
- except Exception as e:
61
- duration = time.time() - start_time
62
-
63
- if self.log_requests:
64
- error(
65
- "Request failed",
66
- error=e,
67
- duration_seconds=duration,
68
- request_id=request_id,
69
- )
70
-
71
- raise
72
-
73
- finally:
74
- clear_request_context()
75
-
76
-
77
- def setup_fastapi_logging(service_name: str = "fastapi"):
78
- """
79
- Set up FastAPI logging with json-logify.
80
- Call this when creating your FastAPI app.
81
- """
82
- configure_logging(service_name=service_name)
1
+ # """
2
+ # FastAPI integration for json-logify structured logging.
3
+ # """
4
+ #
5
+ # import time
6
+ #
7
+ # from fastapi import Request
8
+ # from starlette.middleware.base import BaseHTTPMiddleware
9
+ #
10
+ # from .core import clear_request_context, configure_logging, error, generate_request_id, info, set_request_context
11
+ #
12
+ #
13
+ # class LogifyMiddleware(BaseHTTPMiddleware):
14
+ # """FastAPI middleware for structured logging with request context."""
15
+ #
16
+ # def __init__(self, app, service_name: str = "fastapi", log_requests: bool = True):
17
+ # super().__init__(app)
18
+ # self.service_name = service_name
19
+ # self.log_requests = log_requests
20
+ # configure_logging(service_name=service_name)
21
+ #
22
+ # async def dispatch(self, request: Request, call_next):
23
+ # # Generate request ID and set context
24
+ # request_id = generate_request_id()
25
+ # start_time = time.time()
26
+ #
27
+ # # Extract client info
28
+ # client_host = getattr(request.client, "host", "unknown") if request.client else "unknown"
29
+ #
30
+ # set_request_context(
31
+ # request_id=request_id,
32
+ # method=request.method,
33
+ # path=request.url.path,
34
+ # query_params=str(request.query_params) if request.query_params else None,
35
+ # client_host=client_host,
36
+ # user_agent=request.headers.get("user-agent", ""),
37
+ # )
38
+ #
39
+ # if self.log_requests:
40
+ # info("Request started", method=request.method, path=request.url.path, request_id=request_id)
41
+ #
42
+ # try:
43
+ # response = await call_next(request)
44
+ #
45
+ # duration = time.time() - start_time
46
+ #
47
+ # # Add response info to context
48
+ # set_request_context(status_code=response.status_code, duration_seconds=duration)
49
+ #
50
+ # if self.log_requests:
51
+ # info(
52
+ # "Request completed",
53
+ # status_code=response.status_code,
54
+ # duration_seconds=duration,
55
+ # request_id=request_id,
56
+ # )
57
+ #
58
+ # return response
59
+ #
60
+ # except Exception as e:
61
+ # duration = time.time() - start_time
62
+ #
63
+ # if self.log_requests:
64
+ # error(
65
+ # "Request failed",
66
+ # error=e,
67
+ # duration_seconds=duration,
68
+ # request_id=request_id,
69
+ # )
70
+ #
71
+ # raise
72
+ #
73
+ # finally:
74
+ # clear_request_context()
75
+ #
76
+ #
77
+ # def setup_fastapi_logging(service_name: str = "fastapi"):
78
+ # """
79
+ # Set up FastAPI logging with json-logify.
80
+ # Call this when creating your FastAPI app.
81
+ # """
82
+ # configure_logging(service_name=service_name)
logify/flask.py CHANGED
@@ -1,88 +1,88 @@
1
- """
2
- Flask integration for json-logify structured logging.
3
- """
4
-
5
- import time
6
-
7
- from flask import Flask, g, request
8
-
9
- from .core import clear_request_context, configure_logging, error, generate_request_id, info, set_request_context
10
-
11
-
12
- def init_logify(app: Flask, service_name: str = "flask", log_requests: bool = True):
13
- """
14
- Initialize json-logify for Flask application.
15
-
16
- Usage:
17
- from flask import Flask
18
- from logify.flask import init_logify
19
-
20
- app = Flask(__name__)
21
- init_logify(app, service_name="myapp")
22
- """
23
- configure_logging(service_name=service_name)
24
-
25
- @app.before_request
26
- def before_request():
27
- # Generate request ID and set context
28
- request_id = generate_request_id()
29
- g.logify_request_id = request_id
30
- g.logify_start_time = time.time()
31
-
32
- set_request_context(
33
- request_id=request_id,
34
- method=request.method,
35
- path=request.path,
36
- query_string=request.query_string.decode() if request.query_string else None,
37
- remote_addr=request.remote_addr,
38
- user_agent=request.headers.get("User-Agent", ""),
39
- )
40
-
41
- if log_requests:
42
- info("Request started", method=request.method, path=request.path, request_id=request_id)
43
-
44
- @app.after_request
45
- def after_request(response):
46
- try:
47
- # Calculate duration
48
- duration = time.time() - g.logify_start_time
49
-
50
- # Add response info to context
51
- set_request_context(
52
- status_code=response.status_code, duration_seconds=duration, content_length=response.content_length
53
- )
54
-
55
- if log_requests:
56
- info(
57
- "Request completed",
58
- status_code=response.status_code,
59
- duration_seconds=duration,
60
- request_id=g.logify_request_id,
61
- )
62
- except Exception:
63
- # If we can't access g, just return the response
64
- pass
65
-
66
- return response
67
-
68
- def teardown_request(exception):
69
- # Clear request context
70
- clear_request_context()
71
-
72
- if exception and log_requests:
73
- error(
74
- "Request failed",
75
- error=exception,
76
- request_id=getattr(g, "logify_request_id", "unknown"),
77
- )
78
-
79
- # Register teardown function
80
- app.teardown_appcontext(teardown_request)
81
-
82
-
83
- def setup_flask_logging(service_name: str = "flask"):
84
- """
85
- Set up Flask logging with json-logify.
86
- Call this when creating your Flask app.
87
- """
88
- configure_logging(service_name=service_name)
1
+ # """
2
+ # Flask integration for json-logify structured logging.
3
+ # """
4
+ #
5
+ # import time
6
+ #
7
+ # from flask import Flask, g, request
8
+ #
9
+ # from .core import clear_request_context, configure_logging, error, generate_request_id, info, set_request_context
10
+ #
11
+ #
12
+ # def init_logify(app: Flask, service_name: str = "flask", log_requests: bool = True):
13
+ # """
14
+ # Initialize json-logify for Flask application.
15
+ #
16
+ # Usage:
17
+ # from flask import Flask
18
+ # from logify.flask import init_logify
19
+ #
20
+ # app = Flask(__name__)
21
+ # init_logify(app, service_name="myapp")
22
+ # """
23
+ # configure_logging(service_name=service_name)
24
+ #
25
+ # @app.before_request
26
+ # def before_request():
27
+ # # Generate request ID and set context
28
+ # request_id = generate_request_id()
29
+ # g.logify_request_id = request_id
30
+ # g.logify_start_time = time.time()
31
+ #
32
+ # set_request_context(
33
+ # request_id=request_id,
34
+ # method=request.method,
35
+ # path=request.path,
36
+ # query_string=request.query_string.decode() if request.query_string else None,
37
+ # remote_addr=request.remote_addr,
38
+ # user_agent=request.headers.get("User-Agent", ""),
39
+ # )
40
+ #
41
+ # if log_requests:
42
+ # info("Request started", method=request.method, path=request.path, request_id=request_id)
43
+ #
44
+ # @app.after_request
45
+ # def after_request(response):
46
+ # try:
47
+ # # Calculate duration
48
+ # duration = time.time() - g.logify_start_time
49
+ #
50
+ # # Add response info to context
51
+ # set_request_context(
52
+ # status_code=response.status_code, duration_seconds=duration, content_length=response.content_length
53
+ # )
54
+ #
55
+ # if log_requests:
56
+ # info(
57
+ # "Request completed",
58
+ # status_code=response.status_code,
59
+ # duration_seconds=duration,
60
+ # request_id=g.logify_request_id,
61
+ # )
62
+ # except Exception:
63
+ # # If we can't access g, just return the response
64
+ # pass
65
+ #
66
+ # return response
67
+ #
68
+ # def teardown_request(exception):
69
+ # # Clear request context
70
+ # clear_request_context()
71
+ #
72
+ # if exception and log_requests:
73
+ # error(
74
+ # "Request failed",
75
+ # error=exception,
76
+ # request_id=getattr(g, "logify_request_id", "unknown"),
77
+ # )
78
+ #
79
+ # # Register teardown function
80
+ # app.teardown_appcontext(teardown_request)
81
+ #
82
+ #
83
+ # def setup_flask_logging(service_name: str = "flask"):
84
+ # """
85
+ # Set up Flask logging with json-logify.
86
+ # Call this when creating your Flask app.
87
+ # """
88
+ # configure_logging(service_name=service_name)
logify/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.0"
1
+ __version__ = "0.1.2"
@@ -1,11 +0,0 @@
1
- json_logify-0.1.0.dist-info/licenses/LICENSE,sha256=qRyWd6Y0_db_j_ECsAAEJW8-XMnhYUg_yK3c5UkOfNI,1077
2
- logify/__init__.py,sha256=t2Jjw7T4jRMIYvXJOTBraaW6MneifmS_gDKDlrRHmeU,1053
3
- logify/core.py,sha256=kiMraMhpH9GShY0lPhXAnBO0bTMe0a77cIVpWldecZY,5548
4
- logify/django.py,sha256=i5z9-nzrBF712ba1xkMpAsND-RLFopDLk8M7tsyTPzk,2526
5
- logify/fastapi.py,sha256=ACQPWGVfTexUUCQX2oh-s5NBT1q0q5UDARbNsKulamU,2567
6
- logify/flask.py,sha256=YPRsaFiojP-2buZCwNhm5JmpgBAakJAQsKy1hh6r8ek,2645
7
- logify/version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
8
- json_logify-0.1.0.dist-info/METADATA,sha256=Lu5bw9vf8mz16MTG0jkOXmKltqX1osvMl8E03aXRR30,7659
9
- json_logify-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
- json_logify-0.1.0.dist-info/top_level.txt,sha256=MRERkeaav8J-KtwGpiLDCnSNQ5e2JqKUMAlJnYVntao,7
11
- json_logify-0.1.0.dist-info/RECORD,,