|
| 1 | +# |
| 2 | +# __logging__.py - Network related classes for connecting with the C++ application part |
| 3 | +# |
| 4 | +# (C) 2025 Carl Zeiss GOM Metrology GmbH |
| 5 | +# |
| 6 | +# Use of this source code and binary forms of it, without modification, is permitted provided that |
| 7 | +# the following conditions are met: |
| 8 | +# |
| 9 | +# 1. Redistribution of this source code or binary forms of this with or without any modifications is |
| 10 | +# not allowed without specific prior written permission by GOM. |
| 11 | +# |
| 12 | +# As this source code is provided as glue logic for connecting the Python interpreter to the commands of |
| 13 | +# the GOM software any modification to this sources will not make sense and would affect a suitable functioning |
| 14 | +# and therefore shall be avoided, so consequently the redistribution of this source with or without any |
| 15 | +# modification in source or binary form is not permitted as it would lead to malfunctions of GOM Software. |
| 16 | +# |
| 17 | +# 2. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or |
| 18 | +# promote products derived from this software without specific prior written permission. |
| 19 | +# |
| 20 | +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED |
| 21 | +# WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A |
| 22 | +# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR |
| 23 | +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED |
| 24 | +# TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) |
| 25 | +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING |
| 26 | +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE |
| 27 | +# POSSIBILITY OF SUCH DAMAGE. |
| 28 | +# |
| 29 | + |
| 30 | +import enum |
| 31 | +import inspect |
| 32 | +import logging |
| 33 | +import os |
| 34 | +import uuid |
| 35 | + |
| 36 | +from datetime import datetime, timezone |
| 37 | + |
| 38 | + |
| 39 | +def get_default_log_dir(): |
| 40 | + """ |
| 41 | + Determines and creates the default directory for log files based on the operating system and user privileges. |
| 42 | +
|
| 43 | + On Windows, it uses the %ProgramData%\gom\log directory (defaulting to C:\ProgramData\gom\log if the environment variable is missing). |
| 44 | + On POSIX systems, it uses /var/log/gom if running as root, or ~/.local/share/gom/log for non-root users. |
| 45 | +
|
| 46 | + Returns: |
| 47 | + str: The absolute path to the default log directory. |
| 48 | + """ |
| 49 | + if os.name == "nt": |
| 50 | + # On Windows, get %ProgramData% (typically C:\ProgramData) |
| 51 | + program_data = os.environ.get("ProgramData") |
| 52 | + if not program_data: |
| 53 | + # Fallback if environment variable is missing |
| 54 | + program_data = r"C:\ProgramData" |
| 55 | + log_dir = os.path.join(program_data, "gom", "log") |
| 56 | + else: |
| 57 | + # On POSIX, use /var/log/gom or ~/.local/share/gom/log if not root |
| 58 | + if os.geteuid() == 0: |
| 59 | + log_dir = "/var/log/gom" |
| 60 | + else: |
| 61 | + log_dir = os.path.expanduser("~/.local/share/gom/log") |
| 62 | + |
| 63 | + os.makedirs(log_dir, exist_ok=True) |
| 64 | + |
| 65 | + return log_dir |
| 66 | + |
| 67 | + |
| 68 | +class MillisecondFormatter(logging.Formatter): |
| 69 | + """ |
| 70 | + A custom logging.Formatter that formats log record timestamps with millisecond precision. |
| 71 | +
|
| 72 | + Overrides the formatTime method to allow formatting of timestamps with milliseconds. |
| 73 | + If a date format string (datefmt) is provided and contains '%f', it will be replaced |
| 74 | + with the milliseconds component (first three digits of microseconds) of the timestamp. |
| 75 | +
|
| 76 | + Args: |
| 77 | + record (logging.LogRecord): The log record whose creation time is to be formatted. |
| 78 | + datefmt (str, optional): A date format string. If provided and contains '%f', it will |
| 79 | + be replaced with milliseconds. |
| 80 | +
|
| 81 | + Returns: |
| 82 | + str: The formatted time string with millisecond precision. |
| 83 | + """ |
| 84 | + |
| 85 | + def formatTime(self, record, datefmt=None): |
| 86 | + dt = datetime.fromtimestamp(record.created, tz=timezone.utc) |
| 87 | + if datefmt: |
| 88 | + s = dt.strftime(datefmt) |
| 89 | + # Replace %f with milliseconds (first 3 digits of microseconds) |
| 90 | + s = s.replace('%f', f"{dt.microsecond // 1000:03d}") |
| 91 | + return s |
| 92 | + return super().formatTime(record, datefmt) |
| 93 | + |
| 94 | + |
| 95 | +class ProtocolLogger: |
| 96 | + """ |
| 97 | + ProtocolLogger is a logging utility designed to record protocol-related events in a structured log file, matching the format and domain conventions of a corresponding C++ logging system. |
| 98 | +
|
| 99 | + Attributes: |
| 100 | + domain (str): The logging domain, set to 'scripting.core.protocol' to match the C++ domain. |
| 101 | +
|
| 102 | + Classes: |
| 103 | + EventType (enum.Enum): Enumeration of protocol event types, including CONNECTED, DISCONNECTED, REQUEST, RESPONSE, and ERROR. |
| 104 | +
|
| 105 | + Methods: |
| 106 | + __init__(log_dir=None): |
| 107 | + Initializes the ProtocolLogger instance. |
| 108 | + Determines the log directory (uses a default if not provided), constructs a log file name with a UTC timestamp and process ID, and sets up a file handler with a custom formatter for millisecond precision and ISO 8601 timestamps. |
| 109 | +
|
| 110 | + log(event_type, event_id, message): |
| 111 | + Logs a protocol event with the specified type, identifier, and message. |
| 112 | + Automatically includes the caller's filename and line number, formats the timestamp to match C++ conventions, and records the thread ID. |
| 113 | + Supports event_id as a UUID or string. |
| 114 | + """ |
| 115 | + |
| 116 | + domain = 'scripting.core.protocol' # Must match the C++ domain |
| 117 | + |
| 118 | + class EventType(enum.Enum): |
| 119 | + """ |
| 120 | + Enumeration of possible event types for logging purposes. |
| 121 | +
|
| 122 | + Attributes: |
| 123 | + CONNECTED (str): Indicates a successful connection event. |
| 124 | + DISCONNECTED (str): Indicates a disconnection event. |
| 125 | + REQUEST (str): Represents a request event. |
| 126 | + RESPONSE (str): Represents a response event. |
| 127 | + ERROR (str): Represents an error event. |
| 128 | + """ |
| 129 | + CONNECTED = "Connected" |
| 130 | + DISCONNECTED = "Disconnected" |
| 131 | + REQUEST = "Request" |
| 132 | + RESPONSE = "Response" |
| 133 | + ERROR = "Error" |
| 134 | + |
| 135 | + def __init__(self, log_dir=None): |
| 136 | + |
| 137 | + # Determine log directory |
| 138 | + if log_dir is None: |
| 139 | + log_dir = get_default_log_dir() |
| 140 | + |
| 141 | + # Compute log file name with start timestamp and process id |
| 142 | + start_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S") |
| 143 | + pid = os.getpid() |
| 144 | + log_filename = f"python_{start_time}_{pid}.log" |
| 145 | + log_path = os.path.join(log_dir, log_filename) |
| 146 | + |
| 147 | + self.logger = logging.getLogger(ProtocolLogger.domain) |
| 148 | + handler = logging.FileHandler(log_path, encoding="utf-8") |
| 149 | + formatter = MillisecondFormatter( |
| 150 | + '%(asctime)s TID%(thread)d INFO [%(name)s] %(event_type)s %(event_id)s %(message)s (%(filename)s:%(lineno)d)', |
| 151 | + "%Y-%m-%dT%H:%M:%S.%fZ" |
| 152 | + ) |
| 153 | + handler.setFormatter(formatter) |
| 154 | + self.logger.handlers = [] |
| 155 | + self.logger.addHandler(handler) |
| 156 | + self.logger.setLevel(logging.INFO) |
| 157 | + self.log_path = log_path |
| 158 | + |
| 159 | + def log(self, event_type, event_id, message): |
| 160 | + |
| 161 | + if isinstance(event_id, uuid.UUID): |
| 162 | + event_id = str(event_id) |
| 163 | + |
| 164 | + # Get caller's filename and line number from the call stack |
| 165 | + frame = inspect.currentframe() |
| 166 | + outer_frames = inspect.getouterframes(frame) |
| 167 | + if len(outer_frames) > 1: |
| 168 | + caller_frame = outer_frames[1] |
| 169 | + filename = os.path.basename(caller_frame.filename) |
| 170 | + lineno = caller_frame.lineno |
| 171 | + else: |
| 172 | + filename = "unknown" |
| 173 | + lineno = 0 |
| 174 | + extra = { |
| 175 | + 'event_type': event_type.value if isinstance(event_type, enum.Enum) else str(event_type), |
| 176 | + 'event_id': event_id |
| 177 | + } |
| 178 | + |
| 179 | + # Patch asctime to match C++ format (ISO 8601 with ms, Z) |
| 180 | + record = self.logger.makeRecord( |
| 181 | + self.logger.name, logging.INFO, filename, lineno, |
| 182 | + message, (), None, None, extra) |
| 183 | + record.asctime = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" |
| 184 | + try: |
| 185 | + import threading |
| 186 | + record.thread = threading.get_ident() |
| 187 | + except ImportError: |
| 188 | + record.thread = 1 |
| 189 | + record.event_type = event_type |
| 190 | + record.event_id = event_id |
| 191 | + record.filename = filename |
| 192 | + record.lineno = lineno |
| 193 | + record.name = self.logger.name |
| 194 | + self.logger.handle(record) |
| 195 | + |
| 196 | + |
| 197 | +# Example usage: |
| 198 | +if __name__ == "__main__": |
| 199 | + logger = ProtocolLogger() |
| 200 | + logger.log(ProtocolLogger.EventType.REQUEST.value, uuid.uuid4(), "Register") |
| 201 | + print(f"Log written to: {logger.log_path}") |
0 commit comments