ChronicleLogger 0.1.0__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.
- chroniclelogger-0.1.0/PKG-INFO +21 -0
- chroniclelogger-0.1.0/README.md +0 -0
- chroniclelogger-0.1.0/pyproject.toml +39 -0
- chroniclelogger-0.1.0/setup.cfg +4 -0
- chroniclelogger-0.1.0/src/ChronicleLogger.egg-info/PKG-INFO +21 -0
- chroniclelogger-0.1.0/src/ChronicleLogger.egg-info/SOURCES.txt +10 -0
- chroniclelogger-0.1.0/src/ChronicleLogger.egg-info/dependency_links.txt +1 -0
- chroniclelogger-0.1.0/src/ChronicleLogger.egg-info/top_level.txt +1 -0
- chroniclelogger-0.1.0/src/chronicle_logger/ChronicleLogger.py +240 -0
- chroniclelogger-0.1.0/src/chronicle_logger/Suroot.py +65 -0
- chroniclelogger-0.1.0/src/chronicle_logger/__init__.py +5 -0
- chroniclelogger-0.1.0/test/test_chronicle_logger.py +99 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ChronicleLogger
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Privilege-aware, auto-rotating daily logger for Linux daemons & CLI tools (Cython-optimized)
|
|
5
|
+
Author-email: Wilgat Wong <wilgat.wong@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Wilgat/ChronicleLogger
|
|
8
|
+
Project-URL: Repository, https://github.com/Wilgat/ChronicleLogger
|
|
9
|
+
Project-URL: Issues, https://github.com/Wilgat/ChronicleLogger/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/Wilgat/ChronicleLogger#readme
|
|
11
|
+
Keywords: logging,cython,linux,daemon,sudo,privilege,log rotation,daily log
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Cython
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Topic :: System :: Logging
|
|
18
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
19
|
+
Classifier: Intended Audience :: Developers
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# pyproject.toml
|
|
2
|
+
[build-system]
|
|
3
|
+
requires = ["setuptools>=61", "wheel", "Cython"]
|
|
4
|
+
build-backend = "setuptools.build_meta"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "ChronicleLogger"
|
|
8
|
+
dynamic = ["version"]
|
|
9
|
+
description = "Privilege-aware, auto-rotating daily logger for Linux daemons & CLI tools (Cython-optimized)"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
authors = [{ name = "Wilgat Wong", email = "wilgat.wong@gmail.com" }]
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
requires-python = ">=3.8"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Cython",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Topic :: System :: Logging",
|
|
21
|
+
"Development Status :: 5 - Production/Stable",
|
|
22
|
+
"Intended Audience :: Developers"
|
|
23
|
+
]
|
|
24
|
+
keywords = ["logging", "cython", "linux", "daemon", "sudo", "privilege", "log rotation", "daily log"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/Wilgat/ChronicleLogger"
|
|
28
|
+
Repository = "https://github.com/Wilgat/ChronicleLogger"
|
|
29
|
+
Issues = "https://github.com/Wilgat/ChronicleLogger/issues"
|
|
30
|
+
Documentation = "https://github.com/Wilgat/ChronicleLogger#readme"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.package-data]
|
|
36
|
+
"chronicle_logger" = ["*.so", "*.pyx"]
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.dynamic]
|
|
39
|
+
version = { attr = "chronicle_logger.__version__" }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ChronicleLogger
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Privilege-aware, auto-rotating daily logger for Linux daemons & CLI tools (Cython-optimized)
|
|
5
|
+
Author-email: Wilgat Wong <wilgat.wong@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Wilgat/ChronicleLogger
|
|
8
|
+
Project-URL: Repository, https://github.com/Wilgat/ChronicleLogger
|
|
9
|
+
Project-URL: Issues, https://github.com/Wilgat/ChronicleLogger/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/Wilgat/ChronicleLogger#readme
|
|
11
|
+
Keywords: logging,cython,linux,daemon,sudo,privilege,log rotation,daily log
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Cython
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Topic :: System :: Logging
|
|
18
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
19
|
+
Classifier: Intended Audience :: Developers
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/ChronicleLogger.egg-info/PKG-INFO
|
|
4
|
+
src/ChronicleLogger.egg-info/SOURCES.txt
|
|
5
|
+
src/ChronicleLogger.egg-info/dependency_links.txt
|
|
6
|
+
src/ChronicleLogger.egg-info/top_level.txt
|
|
7
|
+
src/chronicle_logger/ChronicleLogger.py
|
|
8
|
+
src/chronicle_logger/Suroot.py
|
|
9
|
+
src/chronicle_logger/__init__.py
|
|
10
|
+
test/test_chronicle_logger.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
chronicle_logger
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# src/chronicle_logger/ChronicleLogger.py
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import ctypes
|
|
5
|
+
import tarfile
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
# Correct import for your actual file: Suroot.py (capital S)
|
|
10
|
+
from .Suroot import _Suroot
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
basestring
|
|
14
|
+
except NameError:
|
|
15
|
+
basestring = str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# baseDir should be independent
|
|
19
|
+
# It should never be affected by root/sudo/normal user
|
|
20
|
+
# It is for cross-application configuration, not logging
|
|
21
|
+
# Getting the parent of logDir() is trivial if needed
|
|
22
|
+
# We should not couple them
|
|
23
|
+
#
|
|
24
|
+
# baseDir() → /var/myapp ← user sets this explicitly
|
|
25
|
+
# /home/user/.myapp
|
|
26
|
+
# /opt/myapp
|
|
27
|
+
#
|
|
28
|
+
# logDir() → /var/log/myapp ← automatically derived only if not set
|
|
29
|
+
# ~/.app/myapp/log
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ChronicleLogger:
|
|
33
|
+
CLASSNAME = "ChronicleLogger"
|
|
34
|
+
MAJOR_VERSION = 0
|
|
35
|
+
MINOR_VERSION = 1
|
|
36
|
+
PATCH_VERSION = 0
|
|
37
|
+
|
|
38
|
+
LOG_ARCHIVE_DAYS = 7
|
|
39
|
+
LOG_REMOVAL_DAYS = 30
|
|
40
|
+
|
|
41
|
+
def __init__(self, logname=b"app", logdir=b"", basedir=b""):
|
|
42
|
+
self.__logname__ = None
|
|
43
|
+
self.__basedir__ = None
|
|
44
|
+
self.__logdir__ = None
|
|
45
|
+
self.__old_logfile_path__ = ctypes.c_char_p(b"")
|
|
46
|
+
self.__is_python__ = None
|
|
47
|
+
|
|
48
|
+
if not logname or logname in (b"", ""):
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
self.logName(logname)
|
|
52
|
+
if logdir:
|
|
53
|
+
self.logDir(logdir)
|
|
54
|
+
else:
|
|
55
|
+
self.logDir("") # triggers default path + directory creation
|
|
56
|
+
self.baseDir(basedir if basedir else "")
|
|
57
|
+
|
|
58
|
+
self.__current_logfile_path__ = self._get_log_filename()
|
|
59
|
+
self.ensure_directory_exists(self.__logdir__)
|
|
60
|
+
|
|
61
|
+
if self._has_write_permission(self.__current_logfile_path__):
|
|
62
|
+
self.write_to_file("\n")
|
|
63
|
+
|
|
64
|
+
def strToByte(self, value):
|
|
65
|
+
if isinstance(value, basestring):
|
|
66
|
+
return value.encode()
|
|
67
|
+
elif value is None or isinstance(value, bytes):
|
|
68
|
+
return value
|
|
69
|
+
raise TypeError(f"Expected str/bytes/None, got {type(value).__name__}")
|
|
70
|
+
|
|
71
|
+
def byteToStr(self, value):
|
|
72
|
+
if value is None or isinstance(value, basestring):
|
|
73
|
+
return value
|
|
74
|
+
elif isinstance(value, bytes):
|
|
75
|
+
return value.decode()
|
|
76
|
+
raise TypeError(f"Expected str/bytes/None, got {type(value).__name__}")
|
|
77
|
+
|
|
78
|
+
def inPython(self):
|
|
79
|
+
if self.__is_python__ is None:
|
|
80
|
+
self.__is_python__ = 'python' in sys.executable.lower()
|
|
81
|
+
return self.__is_python__
|
|
82
|
+
|
|
83
|
+
def logName(self, logname=None):
|
|
84
|
+
if logname is not None:
|
|
85
|
+
self.__logname__ = self.strToByte(logname)
|
|
86
|
+
if self.inPython():
|
|
87
|
+
name = self.__logname__.decode()
|
|
88
|
+
name = re.sub(r'(?<!^)(?=[A-Z])', '-', name).lower()
|
|
89
|
+
self.__logname__ = name.encode()
|
|
90
|
+
else:
|
|
91
|
+
return self.__logname__.decode()
|
|
92
|
+
|
|
93
|
+
def __set_base_dir__(self, basedir=b""):
|
|
94
|
+
basedir_str = self.byteToStr(basedir)
|
|
95
|
+
if not basedir_str:
|
|
96
|
+
appname = self.__logname__.decode()
|
|
97
|
+
if _Suroot.should_use_system_paths():
|
|
98
|
+
path = f"/var/{appname}"
|
|
99
|
+
else:
|
|
100
|
+
home = os.path.expanduser("~")
|
|
101
|
+
path = os.path.join(home, f".app/{appname}")
|
|
102
|
+
self.__basedir__ = path
|
|
103
|
+
else:
|
|
104
|
+
self.__basedir__ = basedir_str
|
|
105
|
+
|
|
106
|
+
def baseDir(self, basedir=None):
|
|
107
|
+
if basedir is not None:
|
|
108
|
+
self.__set_base_dir__(basedir)
|
|
109
|
+
else:
|
|
110
|
+
if self.__basedir__ is None:
|
|
111
|
+
self.__set_base_dir__()
|
|
112
|
+
return self.__basedir__
|
|
113
|
+
|
|
114
|
+
def __set_log_dir__(self, logdir=b""):
|
|
115
|
+
logdir_str = self.byteToStr(logdir)
|
|
116
|
+
if logdir_str:
|
|
117
|
+
self.__logdir__ = logdir_str
|
|
118
|
+
else:
|
|
119
|
+
appname = self.__logname__.decode()
|
|
120
|
+
if _Suroot.should_use_system_paths():
|
|
121
|
+
self.__logdir__ = f"/var/log/{appname}"
|
|
122
|
+
else:
|
|
123
|
+
home = os.path.expanduser("~")
|
|
124
|
+
self.__logdir__ = os.path.join(home, f".app/{appname}", "log")
|
|
125
|
+
|
|
126
|
+
def logDir(self, logdir=None):
|
|
127
|
+
if logdir is not None:
|
|
128
|
+
self.__set_log_dir__(logdir)
|
|
129
|
+
else:
|
|
130
|
+
if self.__logdir__ is None:
|
|
131
|
+
self.__set_log_dir__()
|
|
132
|
+
return self.__logdir__
|
|
133
|
+
|
|
134
|
+
def isDebug(self):
|
|
135
|
+
if not hasattr(self, '__is_debug__'):
|
|
136
|
+
self.__is_debug__ = (
|
|
137
|
+
os.getenv("DEBUG", "").lower() == "show" or
|
|
138
|
+
os.getenv("debug", "").lower() == "show"
|
|
139
|
+
)
|
|
140
|
+
return self.__is_debug__
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def class_version():
|
|
144
|
+
return f"{ChronicleLogger.CLASSNAME} v{ChronicleLogger.MAJOR_VERSION}.{ChronicleLogger.MINOR_VERSION}.{ChronicleLogger.PATCH_VERSION}"
|
|
145
|
+
|
|
146
|
+
def ensure_directory_exists(self, dir_path):
|
|
147
|
+
if dir_path and not os.path.exists(dir_path):
|
|
148
|
+
try:
|
|
149
|
+
os.makedirs(dir_path)
|
|
150
|
+
print(f"Created directory: {dir_path}")
|
|
151
|
+
except Exception as e:
|
|
152
|
+
self.log_message(f"Failed to create directory {dir_path}: {e}", level="ERROR")
|
|
153
|
+
|
|
154
|
+
def _get_log_filename(self):
|
|
155
|
+
date_str = datetime.now().strftime('%Y%m%d')
|
|
156
|
+
filename = f"{self.__logdir__}/{self.__logname__.decode()}-{date_str}.log"
|
|
157
|
+
return ctypes.c_char_p(filename.encode()).value
|
|
158
|
+
|
|
159
|
+
def log_message(self, message, level=b"INFO", component=b""):
|
|
160
|
+
pid = os.getpid()
|
|
161
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
162
|
+
|
|
163
|
+
component_str = f" @{self.byteToStr(component)}" if component else ""
|
|
164
|
+
message_str = self.byteToStr(message)
|
|
165
|
+
level_str = self.byteToStr(level).upper()
|
|
166
|
+
|
|
167
|
+
log_entry = f"[{timestamp}] pid:{pid} [{level_str}]{component_str} :] {message_str}\n"
|
|
168
|
+
|
|
169
|
+
new_path = self._get_log_filename()
|
|
170
|
+
|
|
171
|
+
if self.__old_logfile_path__ != new_path:
|
|
172
|
+
self.log_rotation()
|
|
173
|
+
self.__old_logfile_path__ = new_path
|
|
174
|
+
if self.isDebug():
|
|
175
|
+
header = f"[{timestamp}] pid:{pid} [INFO] @logger :] Using {new_path.decode()}\n"
|
|
176
|
+
log_entry = header + log_entry
|
|
177
|
+
|
|
178
|
+
if self._has_write_permission(new_path):
|
|
179
|
+
if level_str in ("ERROR", "CRITICAL", "FATAL"):
|
|
180
|
+
print(log_entry.strip(), file=sys.stderr)
|
|
181
|
+
else:
|
|
182
|
+
print(log_entry.strip())
|
|
183
|
+
self.write_to_file(log_entry)
|
|
184
|
+
|
|
185
|
+
def _has_write_permission(self, file_path):
|
|
186
|
+
try:
|
|
187
|
+
with open(file_path, 'a'):
|
|
188
|
+
return True
|
|
189
|
+
except (PermissionError, IOError):
|
|
190
|
+
print(f"Permission denied for writing to {file_path}", file=sys.stderr)
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
def write_to_file(self, log_entry):
|
|
194
|
+
with open(self.__current_logfile_path__, 'a', encoding='utf-8') as f:
|
|
195
|
+
f.write(log_entry)
|
|
196
|
+
|
|
197
|
+
def log_rotation(self):
|
|
198
|
+
if not os.path.exists(self.__logdir__) or not os.listdir(self.__logdir__):
|
|
199
|
+
return
|
|
200
|
+
self.archive_old_logs()
|
|
201
|
+
self.remove_old_logs()
|
|
202
|
+
|
|
203
|
+
def archive_old_logs(self):
|
|
204
|
+
try:
|
|
205
|
+
for file in os.listdir(self.__logdir__):
|
|
206
|
+
if file.endswith(".log"):
|
|
207
|
+
date_part = file.split('-')[-1].split('.')[0]
|
|
208
|
+
try:
|
|
209
|
+
log_date = datetime.strptime(date_part, '%Y%m%d')
|
|
210
|
+
if (datetime.now() - log_date).days > self.LOG_ARCHIVE_DAYS:
|
|
211
|
+
self._archive_log(file)
|
|
212
|
+
except ValueError:
|
|
213
|
+
continue
|
|
214
|
+
except Exception as e:
|
|
215
|
+
print(f"Error during archive: {e}", file=sys.stderr)
|
|
216
|
+
|
|
217
|
+
def _archive_log(self, filename):
|
|
218
|
+
log_path = os.path.join(self.__logdir__, filename)
|
|
219
|
+
archive_path = log_path + ".tar.gz"
|
|
220
|
+
try:
|
|
221
|
+
with tarfile.open(archive_path, "w:gz") as tar:
|
|
222
|
+
tar.add(log_path, arcname=filename)
|
|
223
|
+
os.remove(log_path)
|
|
224
|
+
print(f"Archived log file: {archive_path}")
|
|
225
|
+
except Exception as e:
|
|
226
|
+
print(f"Error archiving {filename}: {e}", file=sys.stderr)
|
|
227
|
+
|
|
228
|
+
def remove_old_logs(self):
|
|
229
|
+
try:
|
|
230
|
+
for file in os.listdir(self.__logdir__):
|
|
231
|
+
if file.endswith(".log"):
|
|
232
|
+
date_part = file.split('-')[-1].split('.')[0]
|
|
233
|
+
try:
|
|
234
|
+
log_date = datetime.strptime(date_part, '%Y%m%d')
|
|
235
|
+
if (datetime.now() - log_date).days > self.LOG_REMOVAL_DAYS:
|
|
236
|
+
os.remove(os.path.join(self.__logdir__, file))
|
|
237
|
+
except ValueError:
|
|
238
|
+
continue
|
|
239
|
+
except Exception as e:
|
|
240
|
+
print(f"Error during removal: {e}", file=sys.stderr)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# src/ChronicleLogger/Suroot.py # Note: Filename without underscore for consistency
|
|
2
|
+
# Minimal, safe, non-interactive root/sudo detector
|
|
3
|
+
# ONLY for internal use by ChronicleLogger
|
|
4
|
+
import os
|
|
5
|
+
from subprocess import Popen, DEVNULL
|
|
6
|
+
|
|
7
|
+
class _Suroot:
|
|
8
|
+
"""
|
|
9
|
+
Tiny, zero-dependency, non-interactive privilege detector.
|
|
10
|
+
Used by ChronicleLogger to decide log directory (/var/log vs ~/.app).
|
|
11
|
+
NEVER prompts, NEVER prints, safe in CI/CD and tests.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
CLASSNAME = "Suroot"
|
|
15
|
+
MAJOR_VERSION = 0
|
|
16
|
+
MINOR_VERSION = 1
|
|
17
|
+
PATCH_VERSION = 0
|
|
18
|
+
|
|
19
|
+
_is_root = None
|
|
20
|
+
_can_sudo_nopasswd = None
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def class_version():
|
|
24
|
+
"""Return the class name and version string."""
|
|
25
|
+
return f"{_Suroot.CLASSNAME} v{_Suroot.MAJOR_VERSION}.{_Suroot.MINOR_VERSION}.{_Suroot.PATCH_VERSION}"
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def is_root() -> bool:
|
|
29
|
+
"""Are we currently running as root (euid == 0)?"""
|
|
30
|
+
if _Suroot._is_root is None:
|
|
31
|
+
_Suroot._is_root = os.geteuid() == 0
|
|
32
|
+
return _Suroot._is_root
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def can_sudo_without_password() -> bool:
|
|
36
|
+
"""Can we run 'sudo' commands without being asked for a password?"""
|
|
37
|
+
if _Suroot._can_sudo_nopasswd is not None:
|
|
38
|
+
return _Suroot._can_sudo_nopasswd
|
|
39
|
+
|
|
40
|
+
if _Suroot.is_root():
|
|
41
|
+
_Suroot._can_sudo_nopasswd = True
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
proc = Popen(
|
|
46
|
+
["sudo", "-n", "true"],
|
|
47
|
+
stdin=DEVNULL,
|
|
48
|
+
stdout=DEVNULL,
|
|
49
|
+
stderr=DEVNULL,
|
|
50
|
+
)
|
|
51
|
+
proc.communicate(timeout=5)
|
|
52
|
+
_Suroot._can_sudo_nopasswd = proc.returncode == 0
|
|
53
|
+
except Exception:
|
|
54
|
+
_Suroot._can_sudo_nopasswd = False
|
|
55
|
+
|
|
56
|
+
return _Suroot._can_sudo_nopasswd
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def should_use_system_paths() -> bool:
|
|
60
|
+
"""
|
|
61
|
+
Final decision method used by ChronicleLogger.
|
|
62
|
+
Returns True → use /var/log and /var/<app>
|
|
63
|
+
Returns False → use ~/.app/<app>/log
|
|
64
|
+
"""
|
|
65
|
+
return _Suroot.is_root() or _Suroot.can_sudo_without_password()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# test/test_chronicle_logger.py
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tarfile
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
TEST_DIR = os.path.dirname(__file__)
|
|
11
|
+
SRC_DIR = os.path.abspath(os.path.join(TEST_DIR, "..", "src"))
|
|
12
|
+
sys.path.insert(0, SRC_DIR)
|
|
13
|
+
|
|
14
|
+
from chronicle_logger.ChronicleLogger import ChronicleLogger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def log_dir(tmp_path):
|
|
19
|
+
return tmp_path / "log"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def logger(log_dir):
|
|
24
|
+
return ChronicleLogger(logname="TestApp", logdir=str(log_dir))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_directory_created_on_init_when_logdir_given(log_dir):
|
|
28
|
+
assert not log_dir.exists()
|
|
29
|
+
ChronicleLogger(logname="TestApp", logdir=str(log_dir))
|
|
30
|
+
assert log_dir.exists()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_logname_becomes_kebab_case():
|
|
34
|
+
logger = ChronicleLogger(logname="TestApp")
|
|
35
|
+
assert logger.logName() == "test-app"
|
|
36
|
+
|
|
37
|
+
logger = ChronicleLogger(logname="HelloWorld")
|
|
38
|
+
assert logger.logName() == "hello-world"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@patch('chronicle_logger.ChronicleLogger.ChronicleLogger.inPython', return_value=False)
|
|
42
|
+
def test_logname_unchanged_in_cython_binary(mock):
|
|
43
|
+
logger = ChronicleLogger(logname="PreserveCase")
|
|
44
|
+
logger.logName("PreserveCase")
|
|
45
|
+
assert logger.logName() == "PreserveCase"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_basedir_is_user_defined_and_independent(tmp_path):
|
|
49
|
+
custom = str(tmp_path / "myconfig")
|
|
50
|
+
logger = ChronicleLogger(logname="App", basedir=custom)
|
|
51
|
+
assert logger.baseDir() == custom
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@patch('chronicle_logger.Suroot._Suroot.should_use_system_paths', return_value=True)
|
|
55
|
+
def test_logdir_uses_system_path_when_privileged_and_not_set(mock):
|
|
56
|
+
logger = ChronicleLogger(logname="RootApp")
|
|
57
|
+
assert logger.logDir() == "/var/log/root-app"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@patch('chronicle_logger.Suroot._Suroot.should_use_system_paths', return_value=False)
|
|
61
|
+
def test_logdir_uses_user_path_when_not_privileged_and_not_set(mock):
|
|
62
|
+
logger = ChronicleLogger(logname="UserApp")
|
|
63
|
+
expected = os.path.join(os.path.expanduser("~"), ".app/user-app", "log")
|
|
64
|
+
assert logger.logDir() == expected
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_logdir_custom_path_overrides_everything(log_dir):
|
|
68
|
+
logger = ChronicleLogger(logname="AnyApp", logdir=str(log_dir))
|
|
69
|
+
assert logger.logDir() == str(log_dir)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_log_message_writes_correct_filename(logger, log_dir):
|
|
73
|
+
logger.log_message("Hello!", level="INFO")
|
|
74
|
+
today = datetime.now().strftime("%Y%m%d")
|
|
75
|
+
logfile = log_dir / f"test-app-{today}.log" # ← test-app, not testapp
|
|
76
|
+
assert logfile.exists()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize("level", ["ERROR", "CRITICAL", "FATAL"])
|
|
80
|
+
def test_error_levels_go_to_stderr(logger, level, capsys):
|
|
81
|
+
logger.log_message("Boom!", level=level)
|
|
82
|
+
captured = capsys.readouterr()
|
|
83
|
+
assert "Boom!" in captured.err
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_archive_old_logs(log_dir):
|
|
87
|
+
logger = ChronicleLogger(logname="TestApp", logdir=str(log_dir))
|
|
88
|
+
old_file = log_dir / f"test-app-{(datetime.now() - timedelta(days=10)).strftime('%Y%m%d')}.log"
|
|
89
|
+
old_file.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
old_file.write_text("old")
|
|
91
|
+
logger.archive_old_logs()
|
|
92
|
+
assert (log_dir / f"{old_file.name}.tar.gz").exists()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_debug_mode(monkeypatch):
|
|
96
|
+
monkeypatch.delenv("DEBUG", raising=False)
|
|
97
|
+
assert not ChronicleLogger(logname="A").isDebug()
|
|
98
|
+
monkeypatch.setenv("DEBUG", "show")
|
|
99
|
+
assert ChronicleLogger(logname="B").isDebug()
|