auditX 0.1.0__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.
auditx/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """
2
+ auditX — ERP-grade audit logging for trading and service businesses.
3
+
4
+ Install:
5
+ pip install auditX
6
+
7
+ Quick start:
8
+ from auditx import audit, RequestContext, BusinessModule, AuditAction
9
+
10
+ RequestContext.set(user="admin", company_id="CO-001", branch_id="BR-YGN")
11
+ audit.log_transaction(
12
+ "Sales invoice posted",
13
+ module=BusinessModule.SALES,
14
+ action=AuditAction.POST,
15
+ reference_no="SI-2026-0042",
16
+ amount=1_250_000,
17
+ )
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any, Optional
23
+
24
+ from auditx.core import (
25
+ AuditAction,
26
+ AuditEntry,
27
+ AuditLogger,
28
+ BusinessModule,
29
+ LogLevel,
30
+ RequestContext,
31
+ )
32
+
33
+ __version__ = "0.1.0"
34
+ __all__ = [
35
+ "__version__",
36
+ "AuditAction",
37
+ "AuditEntry",
38
+ "AuditLogger",
39
+ "BusinessModule",
40
+ "LogLevel",
41
+ "RequestContext",
42
+ "audit",
43
+ "configure",
44
+ "create_logger",
45
+ ]
46
+
47
+ _default_logger: Optional[AuditLogger] = None
48
+
49
+
50
+ def create_logger(**kwargs: Any) -> AuditLogger:
51
+ """Create a new AuditLogger instance with custom settings."""
52
+ return AuditLogger(**kwargs)
53
+
54
+
55
+ def configure(**kwargs: Any) -> AuditLogger:
56
+ """Replace the global `audit` singleton (e.g. set log_dir, disable console)."""
57
+ global _default_logger
58
+ _default_logger = AuditLogger(**kwargs)
59
+ return _default_logger
60
+
61
+
62
+ def _get_default_logger() -> AuditLogger:
63
+ global _default_logger
64
+ if _default_logger is None:
65
+ _default_logger = AuditLogger(log_dir="logs")
66
+ return _default_logger
67
+
68
+
69
+ class _AuditProxy:
70
+ """Lazy proxy so `from auditx import audit` uses the configured singleton."""
71
+
72
+ def __getattr__(self, name: str) -> Any:
73
+ return getattr(_get_default_logger(), name)
74
+
75
+ def __repr__(self) -> str:
76
+ return repr(_get_default_logger())
77
+
78
+
79
+ audit = _AuditProxy()
auditx/__main__.py ADDED
@@ -0,0 +1,75 @@
1
+ """Run demo: python -m auditx"""
2
+
3
+ from auditx.core import AuditAction, BusinessModule
4
+ from auditx import RequestContext, audit
5
+
6
+
7
+ def main() -> None:
8
+ RequestContext.set(
9
+ user="admin",
10
+ user_role="manager",
11
+ company_id="CO-001",
12
+ branch_id="BR-YGN",
13
+ ip="192.168.1.10",
14
+ )
15
+
16
+ print("auditX demo — writing logs to ./logs/\n")
17
+
18
+ audit.log_security("User login successful", action=AuditAction.LOGIN, user="admin", success=True)
19
+
20
+ audit.log_transaction(
21
+ "Sales invoice posted",
22
+ module=BusinessModule.SALES,
23
+ action=AuditAction.POST,
24
+ reference_no="SI-2026-0042",
25
+ amount=1_250_000,
26
+ entity_type="sales_invoice",
27
+ entity_id="inv-42",
28
+ party="Golden Trading Co.",
29
+ )
30
+
31
+ audit.log_inventory(
32
+ "Stock issued for sales order",
33
+ action=AuditAction.TRANSFER,
34
+ product_id="SKU-1001",
35
+ product_name="LED Panel 24W",
36
+ quantity=50,
37
+ warehouse_id="WH-MAIN",
38
+ reference_no="SI-2026-0042",
39
+ )
40
+
41
+ audit.log_service(
42
+ "Service job completed",
43
+ action=AuditAction.UPDATE,
44
+ job_id="SVC-889",
45
+ customer_id="CUST-220",
46
+ technician="U Kyaw",
47
+ service_type="AC Maintenance",
48
+ status_label="completed",
49
+ amount=85_000,
50
+ )
51
+
52
+ audit.log_change(
53
+ "Customer credit limit updated",
54
+ module=BusinessModule.CRM,
55
+ action=AuditAction.UPDATE,
56
+ entity_type="customer",
57
+ entity_id="CUST-220",
58
+ old_values={"credit_limit": 500_000},
59
+ new_values={"credit_limit": 1_000_000},
60
+ )
61
+
62
+ for _ in range(7):
63
+ audit.log_security(
64
+ "Failed login attempt",
65
+ action=AuditAction.LOGIN,
66
+ user="attacker",
67
+ ip="192.168.1.50",
68
+ success=False,
69
+ )
70
+
71
+ print("\nDone. Check logs/audit.jsonl and logs/security.jsonl")
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
auditx/core.py ADDED
@@ -0,0 +1,564 @@
1
+ """Core audit logger implementation for auditX."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import json
7
+ import logging
8
+ import socket
9
+ import threading
10
+ import uuid
11
+ from dataclasses import asdict, dataclass, field
12
+ from enum import Enum
13
+ from pathlib import Path
14
+ from typing import Any, Callable, Optional
15
+
16
+ try:
17
+ from colorama import Fore, init
18
+
19
+ init(autoreset=True)
20
+ _COLORAMA = True
21
+ except ImportError:
22
+ _COLORAMA = False
23
+ Fore = type("Fore", (), {"GREEN": "", "YELLOW": "", "RED": "", "CYAN": "", "WHITE": "", "MAGENTA": "", "BLUE": ""})()
24
+
25
+
26
+ class LogLevel(str, Enum):
27
+ DEBUG = "DEBUG"
28
+ INFO = "INFO"
29
+ NORMAL = "NORMAL"
30
+ WARNING = "WARNING"
31
+ ERROR = "ERROR"
32
+ CRITICAL = "CRITICAL"
33
+ AUDIT = "AUDIT"
34
+ SECURITY = "SECURITY"
35
+
36
+
37
+ class BusinessModule(str, Enum):
38
+ AUTH = "auth"
39
+ SALES = "sales"
40
+ PURCHASE = "purchase"
41
+ INVENTORY = "inventory"
42
+ ACCOUNTING = "accounting"
43
+ SERVICE = "service"
44
+ CRM = "crm"
45
+ HR = "hr"
46
+ REPORTING = "reporting"
47
+ SYSTEM = "system"
48
+
49
+
50
+ class AuditAction(str, Enum):
51
+ CREATE = "CREATE"
52
+ READ = "READ"
53
+ UPDATE = "UPDATE"
54
+ DELETE = "DELETE"
55
+ APPROVE = "APPROVE"
56
+ REJECT = "REJECT"
57
+ VOID = "VOID"
58
+ POST = "POST"
59
+ PAYMENT = "PAYMENT"
60
+ REFUND = "REFUND"
61
+ TRANSFER = "TRANSFER"
62
+ ADJUST = "ADJUST"
63
+ LOGIN = "LOGIN"
64
+ LOGOUT = "LOGOUT"
65
+ EXPORT = "EXPORT"
66
+ IMPORT = "IMPORT"
67
+
68
+
69
+ @dataclass
70
+ class AuditEntry:
71
+ timestamp: str
72
+ level: str
73
+ module: str
74
+ action: str
75
+ description: str
76
+ user: str = "SYSTEM"
77
+ user_role: str = ""
78
+ company_id: str = ""
79
+ branch_id: str = ""
80
+ entity_type: str = ""
81
+ entity_id: str = ""
82
+ reference_no: str = ""
83
+ amount: Optional[float] = None
84
+ currency: str = "MMK"
85
+ old_values: dict[str, Any] = field(default_factory=dict)
86
+ new_values: dict[str, Any] = field(default_factory=dict)
87
+ metadata: dict[str, Any] = field(default_factory=dict)
88
+ request_id: str = ""
89
+ session_id: str = ""
90
+ ip: str = ""
91
+ hostname: str = ""
92
+ method: str = ""
93
+ endpoint: str = ""
94
+ status: int = 0
95
+ success: bool = True
96
+
97
+ def to_dict(self) -> dict[str, Any]:
98
+ data = asdict(self)
99
+ return {k: v for k, v in data.items() if v not in (None, "", {}, [])}
100
+
101
+
102
+ class RequestContext:
103
+ """Carries user and tenant context across a request or background job."""
104
+
105
+ _local = threading.local()
106
+
107
+ @classmethod
108
+ def set(
109
+ cls,
110
+ *,
111
+ user: str = "SYSTEM",
112
+ user_role: str = "",
113
+ company_id: str = "",
114
+ branch_id: str = "",
115
+ session_id: str = "",
116
+ request_id: str = "",
117
+ ip: str = "",
118
+ ) -> None:
119
+ cls._local.user = user
120
+ cls._local.user_role = user_role
121
+ cls._local.company_id = company_id
122
+ cls._local.branch_id = branch_id
123
+ cls._local.session_id = session_id
124
+ cls._local.request_id = request_id or str(uuid.uuid4())
125
+ cls._local.ip = ip
126
+
127
+ @classmethod
128
+ def get(cls) -> dict[str, str]:
129
+ return {
130
+ "user": getattr(cls._local, "user", "SYSTEM"),
131
+ "user_role": getattr(cls._local, "user_role", ""),
132
+ "company_id": getattr(cls._local, "company_id", ""),
133
+ "branch_id": getattr(cls._local, "branch_id", ""),
134
+ "session_id": getattr(cls._local, "session_id", ""),
135
+ "request_id": getattr(cls._local, "request_id", ""),
136
+ "ip": getattr(cls._local, "ip", ""),
137
+ }
138
+
139
+ @classmethod
140
+ def clear(cls) -> None:
141
+ for attr in ("user", "user_role", "company_id", "branch_id", "session_id", "request_id", "ip"):
142
+ if hasattr(cls._local, attr):
143
+ delattr(cls._local, attr)
144
+
145
+
146
+ class AuditLogger:
147
+ """
148
+ ERP audit logger with structured JSON trails, console output, and domain helpers.
149
+
150
+ Log files (under log_dir):
151
+ - audit.jsonl — immutable audit trail (one JSON object per line)
152
+ - app.log — human-readable application log
153
+ - security.jsonl — security-sensitive events
154
+ """
155
+
156
+ _LEVEL_COLORS = {
157
+ LogLevel.DEBUG.value: Fore.WHITE,
158
+ LogLevel.INFO.value: Fore.CYAN,
159
+ LogLevel.NORMAL.value: Fore.GREEN,
160
+ LogLevel.WARNING.value: Fore.YELLOW,
161
+ LogLevel.ERROR.value: Fore.RED,
162
+ LogLevel.CRITICAL.value: Fore.RED,
163
+ LogLevel.AUDIT.value: Fore.MAGENTA,
164
+ LogLevel.SECURITY.value: Fore.RED,
165
+ "EXTREME": Fore.RED,
166
+ }
167
+
168
+ _METHOD_COLORS = {
169
+ "GET": Fore.CYAN,
170
+ "POST": Fore.GREEN,
171
+ "PUT": Fore.YELLOW,
172
+ "PATCH": Fore.YELLOW,
173
+ "DELETE": Fore.RED,
174
+ }
175
+
176
+ def __init__(
177
+ self,
178
+ log_dir: str | Path = "logs",
179
+ app_log_file: str = "app.log",
180
+ audit_log_file: str = "audit.jsonl",
181
+ security_log_file: str = "security.jsonl",
182
+ max_file_bytes: int = 10 * 1024 * 1024,
183
+ backup_count: int = 5,
184
+ console: bool = True,
185
+ on_audit: Optional[Callable[[AuditEntry], None]] = None,
186
+ ):
187
+ self.log_dir = Path(log_dir)
188
+ self.log_dir.mkdir(parents=True, exist_ok=True)
189
+
190
+ self.app_log_path = self.log_dir / app_log_file
191
+ self.audit_log_path = self.log_dir / audit_log_file
192
+ self.security_log_path = self.log_dir / security_log_file
193
+
194
+ self.max_file_bytes = max_file_bytes
195
+ self.backup_count = backup_count
196
+ self.console = console
197
+ self.on_audit = on_audit
198
+
199
+ self.hostname = socket.gethostname()
200
+ try:
201
+ self.default_ip = socket.gethostbyname(self.hostname)
202
+ except socket.gaierror:
203
+ self.default_ip = "127.0.0.1"
204
+
205
+ self._lock = threading.Lock()
206
+ self._ip_counter: dict[str, int] = {}
207
+ self._rate_limit_window: dict[str, datetime.datetime] = {}
208
+
209
+ logger_name = f"auditx.app.{id(self)}"
210
+ self._app_logger = logging.getLogger(logger_name)
211
+ if not self._app_logger.handlers:
212
+ handler = logging.FileHandler(self.app_log_path, encoding="utf-8")
213
+ handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
214
+ self._app_logger.addHandler(handler)
215
+ self._app_logger.setLevel(logging.DEBUG)
216
+ self._app_logger.propagate = False
217
+
218
+ @staticmethod
219
+ def _iso_timestamp() -> str:
220
+ return datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat(timespec="seconds")
221
+
222
+ @staticmethod
223
+ def _display_timestamp() -> str:
224
+ return datetime.datetime.now().strftime("%d/%m/%Y %I:%M:%S %p")
225
+
226
+ def _format_level(self, level: str) -> str:
227
+ level = level.upper()
228
+ if _COLORAMA:
229
+ color = self._LEVEL_COLORS.get(level, Fore.WHITE)
230
+ return color + level
231
+ return level
232
+
233
+ def _format_method(self, method: str) -> str:
234
+ method = method.upper()
235
+ if _COLORAMA:
236
+ color = self._METHOD_COLORS.get(method, Fore.WHITE)
237
+ return color + method
238
+ return method
239
+
240
+ def _rotate_if_needed(self, path: Path) -> None:
241
+ if not path.exists() or path.stat().st_size < self.max_file_bytes:
242
+ return
243
+ for i in range(self.backup_count - 1, 0, -1):
244
+ src = path.with_suffix(path.suffix + f".{i}")
245
+ dst = path.with_suffix(path.suffix + f".{i + 1}")
246
+ if src.exists():
247
+ src.replace(dst)
248
+ path.replace(path.with_suffix(path.suffix + ".1"))
249
+
250
+ def _append_line(self, path: Path, line: str) -> None:
251
+ with self._lock:
252
+ self._rotate_if_needed(path)
253
+ with path.open("a", encoding="utf-8") as f:
254
+ f.write(line + "\n")
255
+
256
+ def _emit_console(self, entry: AuditEntry) -> None:
257
+ if not self.console:
258
+ return
259
+ ts = self._display_timestamp()
260
+ method = self._format_method(entry.method) if entry.method else "N/A"
261
+ level = self._format_level(entry.level)
262
+ ref = f" [{entry.reference_no}]" if entry.reference_no else ""
263
+ entity = f" {entry.entity_type}:{entry.entity_id}" if entry.entity_type else ""
264
+ amount = f" {entry.amount:,.2f} {entry.currency}" if entry.amount is not None else ""
265
+ print(
266
+ f"{ts} | {level} | {entry.module}/{entry.action} | "
267
+ f"{method} {entry.endpoint} | {entry.status} | "
268
+ f"{entry.description}{ref}{entity}{amount} | "
269
+ f"{entry.user}@{entry.branch_id or 'HQ'} | req:{entry.request_id[:8]} | IP:{entry.ip}"
270
+ )
271
+
272
+ def _write_audit(self, entry: AuditEntry) -> None:
273
+ self._append_line(self.audit_log_path, json.dumps(entry.to_dict(), ensure_ascii=False, default=str))
274
+
275
+ if entry.level in (LogLevel.SECURITY.value, LogLevel.CRITICAL.value, "EXTREME"):
276
+ self._append_line(self.security_log_path, json.dumps(entry.to_dict(), ensure_ascii=False, default=str))
277
+
278
+ if self.on_audit:
279
+ try:
280
+ self.on_audit(entry)
281
+ except Exception as exc:
282
+ self._app_logger.error("on_audit callback failed: %s", exc)
283
+
284
+ def _build_entry(
285
+ self,
286
+ *,
287
+ description: str,
288
+ level: str = LogLevel.AUDIT.value,
289
+ module: str = BusinessModule.SYSTEM.value,
290
+ action: str = AuditAction.READ.value,
291
+ user: Optional[str] = None,
292
+ user_role: str = "",
293
+ company_id: str = "",
294
+ branch_id: str = "",
295
+ entity_type: str = "",
296
+ entity_id: str = "",
297
+ reference_no: str = "",
298
+ amount: Optional[float] = None,
299
+ currency: str = "MMK",
300
+ old_values: Optional[dict[str, Any]] = None,
301
+ new_values: Optional[dict[str, Any]] = None,
302
+ metadata: Optional[dict[str, Any]] = None,
303
+ request_id: str = "",
304
+ session_id: str = "",
305
+ ip: Optional[str] = None,
306
+ method: str = "",
307
+ endpoint: str = "",
308
+ status: int = 200,
309
+ success: bool = True,
310
+ ) -> AuditEntry:
311
+ ctx = RequestContext.get()
312
+ return AuditEntry(
313
+ timestamp=self._iso_timestamp(),
314
+ level=level.upper(),
315
+ module=module,
316
+ action=action,
317
+ description=description,
318
+ user=user or ctx.get("user", "SYSTEM"),
319
+ user_role=user_role or ctx.get("user_role", ""),
320
+ company_id=company_id or ctx.get("company_id", ""),
321
+ branch_id=branch_id or ctx.get("branch_id", ""),
322
+ entity_type=entity_type,
323
+ entity_id=str(entity_id) if entity_id else "",
324
+ reference_no=reference_no,
325
+ amount=amount,
326
+ currency=currency,
327
+ old_values=old_values or {},
328
+ new_values=new_values or {},
329
+ metadata=metadata or {},
330
+ request_id=request_id or ctx.get("request_id", "") or str(uuid.uuid4()),
331
+ session_id=session_id or ctx.get("session_id", ""),
332
+ ip=ip or ctx.get("ip") or self.default_ip,
333
+ hostname=self.hostname,
334
+ method=method.upper() if method else "",
335
+ endpoint=endpoint,
336
+ status=status,
337
+ success=success,
338
+ )
339
+
340
+ def check_rate_limit(self, ip: str, limit: int = 5, window_seconds: int = 60) -> bool:
341
+ now = datetime.datetime.now()
342
+ window_start = self._rate_limit_window.get(ip)
343
+
344
+ if window_start is None or (now - window_start).total_seconds() > window_seconds:
345
+ self._rate_limit_window[ip] = now
346
+ self._ip_counter[ip] = 1
347
+ return True
348
+
349
+ self._ip_counter[ip] = self._ip_counter.get(ip, 0) + 1
350
+ return self._ip_counter[ip] <= limit
351
+
352
+ def reset_rate_limit(self, ip: str) -> None:
353
+ self._ip_counter.pop(ip, None)
354
+ self._rate_limit_window.pop(ip, None)
355
+
356
+ def log(
357
+ self,
358
+ description: str,
359
+ *,
360
+ level: str = LogLevel.AUDIT.value,
361
+ module: str = BusinessModule.SYSTEM.value,
362
+ action: str = AuditAction.READ.value,
363
+ **kwargs: Any,
364
+ ) -> AuditEntry:
365
+ entry = self._build_entry(
366
+ description=description,
367
+ level=level,
368
+ module=module,
369
+ action=action,
370
+ **kwargs,
371
+ )
372
+ self._emit_console(entry)
373
+ self._write_audit(entry)
374
+ return entry
375
+
376
+ def log_change(
377
+ self,
378
+ description: str,
379
+ *,
380
+ module: BusinessModule | str,
381
+ action: AuditAction | str,
382
+ entity_type: str,
383
+ entity_id: str,
384
+ old_values: dict[str, Any],
385
+ new_values: dict[str, Any],
386
+ reference_no: str = "",
387
+ **kwargs: Any,
388
+ ) -> AuditEntry:
389
+ return self.log(
390
+ description=description,
391
+ level=LogLevel.AUDIT.value,
392
+ module=module.value if isinstance(module, BusinessModule) else module,
393
+ action=action.value if isinstance(action, AuditAction) else action,
394
+ entity_type=entity_type,
395
+ entity_id=entity_id,
396
+ reference_no=reference_no,
397
+ old_values=old_values,
398
+ new_values=new_values,
399
+ **kwargs,
400
+ )
401
+
402
+ def log_transaction(
403
+ self,
404
+ description: str,
405
+ *,
406
+ module: BusinessModule,
407
+ action: AuditAction,
408
+ reference_no: str,
409
+ amount: float,
410
+ currency: str = "MMK",
411
+ entity_type: str = "",
412
+ entity_id: str = "",
413
+ party: str = "",
414
+ **kwargs: Any,
415
+ ) -> AuditEntry:
416
+ metadata = kwargs.pop("metadata", {}) or {}
417
+ if party:
418
+ metadata["party"] = party
419
+ return self.log(
420
+ description=description,
421
+ level=LogLevel.AUDIT.value,
422
+ module=module.value,
423
+ action=action.value,
424
+ reference_no=reference_no,
425
+ amount=amount,
426
+ currency=currency,
427
+ entity_type=entity_type,
428
+ entity_id=entity_id,
429
+ metadata=metadata,
430
+ **kwargs,
431
+ )
432
+
433
+ def log_inventory(
434
+ self,
435
+ description: str,
436
+ *,
437
+ action: AuditAction,
438
+ product_id: str,
439
+ product_name: str = "",
440
+ quantity: float,
441
+ unit: str = "pcs",
442
+ warehouse_id: str = "",
443
+ reference_no: str = "",
444
+ **kwargs: Any,
445
+ ) -> AuditEntry:
446
+ metadata = kwargs.pop("metadata", {}) or {}
447
+ metadata.update({"product_name": product_name, "quantity": quantity, "unit": unit, "warehouse_id": warehouse_id})
448
+ return self.log(
449
+ description=description,
450
+ level=LogLevel.AUDIT.value,
451
+ module=BusinessModule.INVENTORY.value,
452
+ action=action.value,
453
+ entity_type="product",
454
+ entity_id=product_id,
455
+ reference_no=reference_no,
456
+ metadata=metadata,
457
+ **kwargs,
458
+ )
459
+
460
+ def log_service(
461
+ self,
462
+ description: str,
463
+ *,
464
+ action: AuditAction,
465
+ job_id: str,
466
+ customer_id: str = "",
467
+ technician: str = "",
468
+ service_type: str = "",
469
+ status_label: str = "",
470
+ amount: Optional[float] = None,
471
+ **kwargs: Any,
472
+ ) -> AuditEntry:
473
+ metadata = kwargs.pop("metadata", {}) or {}
474
+ metadata.update({
475
+ "customer_id": customer_id,
476
+ "technician": technician,
477
+ "service_type": service_type,
478
+ "status": status_label,
479
+ })
480
+ return self.log(
481
+ description=description,
482
+ level=LogLevel.AUDIT.value,
483
+ module=BusinessModule.SERVICE.value,
484
+ action=action.value,
485
+ entity_type="service_job",
486
+ entity_id=job_id,
487
+ amount=amount,
488
+ metadata=metadata,
489
+ **kwargs,
490
+ )
491
+
492
+ def log_security(
493
+ self,
494
+ description: str,
495
+ *,
496
+ action: AuditAction = AuditAction.LOGIN,
497
+ user: str = "UNKNOWN",
498
+ ip: Optional[str] = None,
499
+ success: bool = True,
500
+ **kwargs: Any,
501
+ ) -> AuditEntry:
502
+ level = LogLevel.SECURITY.value if not success else LogLevel.AUDIT.value
503
+ check_ip = ip or RequestContext.get().get("ip") or self.default_ip
504
+
505
+ if not self.check_rate_limit(check_ip):
506
+ description = "Rate limit exceeded - possible brute force"
507
+ level = LogLevel.CRITICAL.value
508
+ success = False
509
+
510
+ return self.log(
511
+ description=description,
512
+ level=level,
513
+ module=BusinessModule.AUTH.value,
514
+ action=action.value,
515
+ user=user,
516
+ ip=check_ip,
517
+ success=success,
518
+ **kwargs,
519
+ )
520
+
521
+ def debug(self, message: str, **extra: Any) -> None:
522
+ self._app_logger.debug(self._format_app_message(message, extra))
523
+
524
+ def info(self, message: str, **extra: Any) -> None:
525
+ self._app_logger.info(self._format_app_message(message, extra))
526
+
527
+ def warning(self, message: str, **extra: Any) -> None:
528
+ self._app_logger.warning(self._format_app_message(message, extra))
529
+
530
+ def error(self, message: str, **extra: Any) -> None:
531
+ self._app_logger.error(self._format_app_message(message, extra))
532
+
533
+ def critical(self, message: str, **extra: Any) -> None:
534
+ self._app_logger.critical(self._format_app_message(message, extra))
535
+
536
+ @staticmethod
537
+ def _format_app_message(message: str, extra: dict[str, Any]) -> str:
538
+ if not extra:
539
+ return message
540
+ return f"{message} | {json.dumps(extra, ensure_ascii=False, default=str)}"
541
+
542
+ def auditing(
543
+ self,
544
+ description: str,
545
+ level: str = "NORMAL",
546
+ user: str = "SYSTEM",
547
+ method: str = "N/A",
548
+ endpoint: str = "/",
549
+ status: int = 200,
550
+ ip: Optional[str] = None,
551
+ **kwargs: Any,
552
+ ) -> AuditEntry:
553
+ return self.log(
554
+ description=description,
555
+ level=level,
556
+ module=kwargs.pop("module", BusinessModule.SYSTEM.value),
557
+ action=kwargs.pop("action", AuditAction.READ.value),
558
+ user=user,
559
+ method=method,
560
+ endpoint=endpoint,
561
+ status=status,
562
+ ip=ip,
563
+ **kwargs,
564
+ )
@@ -0,0 +1,192 @@
1
+ Metadata-Version: 2.4
2
+ Name: auditX
3
+ Version: 0.1.0
4
+ Summary: ERP-grade audit logging for trading and service businesses
5
+ Author: Myanmar Smart Business Hub
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/MyanmarSmartBusinessHub/auditX
8
+ Project-URL: Documentation, https://github.com/MyanmarSmartBusinessHub/auditX#readme
9
+ Keywords: audit,logging,erp,compliance,trading,inventory
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: System :: Logging
20
+ Classifier: Topic :: Office/Business
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: colorama>=0.4.6
24
+ Provides-Extra: dev
25
+ Requires-Dist: build; extra == "dev"
26
+ Requires-Dist: twine; extra == "dev"
27
+
28
+ # auditX
29
+
30
+ ERP-grade audit logging for **trading** and **service** businesses. Track who did what, when, and where — with structured JSON trails suitable for compliance, SIEM, and multi-branch operations.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install auditX
36
+ ```
37
+
38
+ **From source (development):**
39
+
40
+ ```bash
41
+ git clone https://github.com/MyanmarSmartBusinessHub/auditX.git
42
+ cd auditX
43
+ pip install -e .
44
+ ```
45
+
46
+ ## Quick start
47
+
48
+ ```python
49
+ from auditx import audit, RequestContext, BusinessModule, AuditAction
50
+
51
+ # Set user/tenant context once per request or job
52
+ RequestContext.set(
53
+ user="admin",
54
+ user_role="manager",
55
+ company_id="CO-001",
56
+ branch_id="BR-YGN",
57
+ ip="192.168.1.10",
58
+ )
59
+
60
+ # Security / auth
61
+ audit.log_security("User login successful", action=AuditAction.LOGIN, user="admin")
62
+
63
+ # Trading — sales, purchase, payments
64
+ audit.log_transaction(
65
+ "Sales invoice posted",
66
+ module=BusinessModule.SALES,
67
+ action=AuditAction.POST,
68
+ reference_no="SI-2026-0042",
69
+ amount=1_250_000,
70
+ entity_type="sales_invoice",
71
+ entity_id="inv-42",
72
+ party="Golden Trading Co.",
73
+ )
74
+
75
+ # Inventory
76
+ audit.log_inventory(
77
+ "Stock issued for sales order",
78
+ action=AuditAction.TRANSFER,
79
+ product_id="SKU-1001",
80
+ product_name="LED Panel 24W",
81
+ quantity=50,
82
+ warehouse_id="WH-MAIN",
83
+ reference_no="SI-2026-0042",
84
+ )
85
+
86
+ # Service jobs
87
+ audit.log_service(
88
+ "Service job completed",
89
+ action=AuditAction.UPDATE,
90
+ job_id="SVC-889",
91
+ customer_id="CUST-220",
92
+ technician="U Kyaw",
93
+ service_type="AC Maintenance",
94
+ status_label="completed",
95
+ amount=85_000,
96
+ )
97
+
98
+ # Field-level change audit (before/after)
99
+ audit.log_change(
100
+ "Customer credit limit updated",
101
+ module=BusinessModule.CRM,
102
+ action=AuditAction.UPDATE,
103
+ entity_type="customer",
104
+ entity_id="CUST-220",
105
+ old_values={"credit_limit": 500_000},
106
+ new_values={"credit_limit": 1_000_000},
107
+ )
108
+ ```
109
+
110
+ ## Run the demo
111
+
112
+ ```bash
113
+ python -m auditx
114
+ # or after install:
115
+ auditx-demo
116
+ ```
117
+
118
+ ## Configuration
119
+
120
+ ```python
121
+ from auditx import configure, create_logger
122
+
123
+ # Reconfigure the global singleton at app startup
124
+ configure(log_dir="/var/log/my-erp", console=False)
125
+
126
+ # Or create separate loggers per tenant/service
127
+ tenant_logger = create_logger(log_dir="logs/tenant-acme", console=False)
128
+ tenant_logger.log_transaction(...)
129
+ ```
130
+
131
+ ### Log files
132
+
133
+ | File | Purpose |
134
+ |------|---------|
135
+ | `logs/audit.jsonl` | Immutable audit trail (one JSON object per line) |
136
+ | `logs/security.jsonl` | Auth failures, rate limits, critical security events |
137
+ | `logs/app.log` | General application messages (`audit.info()`, etc.) |
138
+
139
+ ## Flask / FastAPI middleware pattern
140
+
141
+ ```python
142
+ from auditx import RequestContext, audit, AuditAction
143
+
144
+ def set_audit_context(user, request):
145
+ RequestContext.set(
146
+ user=user.username,
147
+ user_role=user.role,
148
+ company_id=user.company_id,
149
+ branch_id=user.branch_id,
150
+ request_id=request.headers.get("X-Request-ID", ""),
151
+ ip=request.remote_addr,
152
+ )
153
+
154
+ # On login
155
+ audit.log_security("Login", action=AuditAction.LOGIN, user=username, success=True)
156
+
157
+ # On logout
158
+ RequestContext.clear()
159
+ ```
160
+
161
+ ## Database hook (optional)
162
+
163
+ Persist audit entries to your database:
164
+
165
+ ```python
166
+ def save_to_db(entry):
167
+ db.execute(
168
+ "INSERT INTO audit_log (data) VALUES (?)",
169
+ [json.dumps(entry.to_dict())],
170
+ )
171
+
172
+ from auditx import configure
173
+ configure(log_dir="logs", on_audit=save_to_db)
174
+ ```
175
+
176
+ ## Public API
177
+
178
+ | Export | Description |
179
+ |--------|-------------|
180
+ | `audit` | Global logger singleton |
181
+ | `AuditLogger` | Create custom logger instances |
182
+ | `configure()` | Reconfigure the global singleton |
183
+ | `create_logger()` | Factory for new logger instances |
184
+ | `RequestContext` | Thread-local user/tenant context |
185
+ | `AuditEntry` | Structured audit record dataclass |
186
+ | `BusinessModule` | `SALES`, `PURCHASE`, `INVENTORY`, `SERVICE`, etc. |
187
+ | `AuditAction` | `CREATE`, `UPDATE`, `POST`, `PAYMENT`, `LOGIN`, etc. |
188
+ | `LogLevel` | `DEBUG`, `INFO`, `AUDIT`, `SECURITY`, `CRITICAL`, etc. |
189
+
190
+ ## License
191
+
192
+ MIT
@@ -0,0 +1,8 @@
1
+ auditx/__init__.py,sha256=TKXAtacKRYIvnt0DC3jPx7c0gG7_3EQCvYDfrO3ia6c,1892
2
+ auditx/__main__.py,sha256=6bMUnEUlpm_KDaWpLDrNJLH_zgy1MaO1ZQ9sJ4FWIZs,2025
3
+ auditx/core.py,sha256=yTNb-DnyTSmRVOos7paM8XO2utjBmcrTzX0L1aSvFJU,18690
4
+ auditx-0.1.0.dist-info/METADATA,sha256=YYkTldmFz0H-_R8QKTkclaZ1NN2I3b_xFTusO3juvZc,5285
5
+ auditx-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ auditx-0.1.0.dist-info/entry_points.txt,sha256=LhIWp6BS3EpIgX2a9Z2pB3CAGIfNTvwlf3KkNZDJCEk,53
7
+ auditx-0.1.0.dist-info/top_level.txt,sha256=pBVlpYKvdJyWQ_hmeEG1vjf9T7gxzcLelfVCwElSo08,7
8
+ auditx-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ auditx-demo = auditx.__main__:main
@@ -0,0 +1 @@
1
+ auditx