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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ 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,5 @@
1
+ # logged_example/__init__.py
2
+ from .Suroot import _Suroot
3
+
4
+ __all__ = ['StateLogic']
5
+ __version__ = "0.1.0"
@@ -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()