diff --git a/direct/src/directnotify/DirectNotify.py b/direct/src/directnotify/DirectNotify.py index d2e2ba4a3e..f7c7e6c964 100644 --- a/direct/src/directnotify/DirectNotify.py +++ b/direct/src/directnotify/DirectNotify.py @@ -2,6 +2,10 @@ DirectNotify module: this module contains the DirectNotify class """ +from __future__ import annotations + +from panda3d.core import StreamWriter + from . import Notifier from . import Logger @@ -12,39 +16,39 @@ class DirectNotify: mulitple notify categories via a dictionary of Notifiers. """ - def __init__(self): + def __init__(self) -> None: """ DirectNotify class keeps a dictionary of Notfiers """ - self.__categories = {} + self.__categories: dict[str, Notifier.Notifier] = {} # create a default log file self.logger = Logger.Logger() # This will get filled in later by ShowBase.py with a # C++-level StreamWriter object for writing to standard # output. - self.streamWriter = None + self.streamWriter: StreamWriter | None = None - def __str__(self): + def __str__(self) -> str: """ Print handling routine """ return "DirectNotify categories: %s" % (self.__categories) #getters and setters - def getCategories(self): + def getCategories(self) -> list[str]: """ Return list of category dictionary keys """ return list(self.__categories.keys()) - def getCategory(self, categoryName): + def getCategory(self, categoryName: str) -> Notifier.Notifier | None: """getCategory(self, string) Return the category with given name if present, None otherwise """ return self.__categories.get(categoryName, None) - def newCategory(self, categoryName, logger=None): + def newCategory(self, categoryName: str, logger: Logger.Logger | None = None) -> Notifier.Notifier: """newCategory(self, string) Make a new notify category named categoryName. Return new category if no such category exists, else return existing category @@ -52,9 +56,11 @@ class DirectNotify: if categoryName not in self.__categories: self.__categories[categoryName] = Notifier.Notifier(categoryName, logger) self.setDconfigLevel(categoryName) - return self.getCategory(categoryName) + notifier = self.getCategory(categoryName) + assert notifier is not None + return notifier - def setDconfigLevel(self, categoryName): + def setDconfigLevel(self, categoryName: str) -> None: """ Check to see if this category has a dconfig variable to set the notify severity and then set that level. You cannot @@ -77,40 +83,42 @@ class DirectNotify: level = 'error' category = self.getCategory(categoryName) + assert category is not None, f'failed to find category: {categoryName!r}' # Note - this print statement is making it difficult to # achieve "no output unless there's an error" operation - Josh # print ("Setting DirectNotify category: " + categoryName + # " to severity: " + level) if level == "error": - category.setWarning(0) - category.setInfo(0) - category.setDebug(0) + category.setWarning(False) + category.setInfo(False) + category.setDebug(False) elif level == "warning": - category.setWarning(1) - category.setInfo(0) - category.setDebug(0) + category.setWarning(True) + category.setInfo(False) + category.setDebug(False) elif level == "info": - category.setWarning(1) - category.setInfo(1) - category.setDebug(0) + category.setWarning(True) + category.setInfo(True) + category.setDebug(False) elif level == "debug": - category.setWarning(1) - category.setInfo(1) - category.setDebug(1) + category.setWarning(True) + category.setInfo(True) + category.setDebug(True) else: print("DirectNotify: unknown notify level: " + str(level) + " for category: " + str(categoryName)) - def setDconfigLevels(self): + def setDconfigLevels(self) -> None: for categoryName in self.getCategories(): self.setDconfigLevel(categoryName) - def setVerbose(self): + def setVerbose(self) -> None: for categoryName in self.getCategories(): category = self.getCategory(categoryName) - category.setWarning(1) - category.setInfo(1) - category.setDebug(1) + assert category is not None + category.setWarning(True) + category.setInfo(True) + category.setDebug(True) def popupControls(self, tl = None): # Don't use a regular import, to prevent ModuleFinder from picking @@ -119,5 +127,5 @@ class DirectNotify: NotifyPanel = importlib.import_module('direct.tkpanels.NotifyPanel') NotifyPanel.NotifyPanel(self, tl) - def giveNotify(self,cls): + def giveNotify(self, cls) -> None: cls.notify = self.newCategory(cls.__name__) diff --git a/direct/src/directnotify/Logger.py b/direct/src/directnotify/Logger.py index 0c5aeaab04..21418592ec 100644 --- a/direct/src/directnotify/Logger.py +++ b/direct/src/directnotify/Logger.py @@ -1,27 +1,30 @@ """Logger module: contains the logger class which creates and writes data to log files on disk""" +from __future__ import annotations + +import io import time import math class Logger: - def __init__(self, fileName="log"): + def __init__(self, fileName: str = "log") -> None: """ Logger constructor """ - self.__timeStamp = 1 + self.__timeStamp = True self.__startTime = 0.0 - self.__logFile = None + self.__logFile: io.TextIOWrapper | None = None self.__logFileName = fileName - def setTimeStamp(self, enable): + def setTimeStamp(self, enable: bool) -> None: """ Toggle time stamp printing with log entries on and off """ self.__timeStamp = enable - def getTimeStamp(self): + def getTimeStamp(self) -> bool: """ Return whether or not we are printing time stamps with log entries """ @@ -29,24 +32,25 @@ class Logger: # logging control - def resetStartTime(self): + def resetStartTime(self) -> None: """ Reset the start time of the log file for time stamps """ self.__startTime = time.time() - def log(self, entryString): + def log(self, entryString: str) -> None: """log(self, string) Print the given string to the log file""" if self.__logFile is None: self.__openLogFile() + assert self.__logFile is not None if self.__timeStamp: self.__logFile.write(self.__getTimeStamp()) self.__logFile.write(entryString + '\n') # logging functions - def __openLogFile(self): + def __openLogFile(self) -> None: """ Open a file for logging error/warning messages """ @@ -56,14 +60,14 @@ class Logger: logFileName = self.__logFileName + "." + st self.__logFile = open(logFileName, "w") - def __closeLogFile(self): + def __closeLogFile(self) -> None: """ Close the error/warning output file """ if self.__logFile is not None: self.__logFile.close() - def __getTimeStamp(self): + def __getTimeStamp(self) -> str: """ Return the offset between current time and log file startTime """ diff --git a/direct/src/directnotify/Notifier.py b/direct/src/directnotify/Notifier.py index 8a49ab5564..bf3f14779a 100644 --- a/direct/src/directnotify/Notifier.py +++ b/direct/src/directnotify/Notifier.py @@ -2,11 +2,16 @@ Notifier module: contains methods for handling information output for the programmer/user """ + +from __future__ import annotations + +from .Logger import Logger from .LoggerGlobal import defaultLogger from direct.showbase import PythonUtil from panda3d.core import ConfigVariableBool, NotifyCategory, StreamWriter, Notify import time import sys +from typing import NoReturn class NotifierException(Exception): @@ -20,13 +25,13 @@ class Notifier: # messages instead of writing them to the console. This is # particularly useful for integrating the Python notify system # with the C++ notify system. - streamWriter = None + streamWriter: StreamWriter | None = None if ConfigVariableBool('notify-integrate', True): streamWriter = StreamWriter(Notify.out(), False) showTime = ConfigVariableBool('notify-timestamp', False) - def __init__(self, name, logger=None): + def __init__(self, name: str, logger: Logger | None = None) -> None: """ Parameters: name (str): a string name given to this Notifier instance. @@ -42,12 +47,12 @@ class Notifier: self.__logger = logger # Global default levels are initialized here - self.__info = 1 - self.__warning = 1 - self.__debug = 0 - self.__logging = 0 + self.__info = True + self.__warning = True + self.__debug = False + self.__logging = False - def setServerDelta(self, delta, timezone): + def setServerDelta(self, delta: float, timezone: int) -> None: """ Call this method on any Notify object to globally change the timestamp printed for each line of all Notify objects. @@ -65,7 +70,7 @@ class Notifier: self.info("Notify clock adjusted by %s (and timezone adjusted by %s hours) to synchronize with server." % (PythonUtil.formatElapsedSeconds(delta), (time.timezone - timezone) / 3600)) - def getTime(self): + def getTime(self) -> str: """ Return the time as a string suitable for printing at the head of any notify message @@ -74,14 +79,14 @@ class Notifier: # the task is out of focus on win32. time.clock doesn't have this problem. return time.strftime(":%m-%d-%Y %H:%M:%S ", time.localtime(time.time() + self.serverDelta)) - def getOnlyTime(self): + def getOnlyTime(self) -> str: """ Return the time as a string. The Only in the name is referring to not showing the date. """ return time.strftime("%H:%M:%S", time.localtime(time.time() + self.serverDelta)) - def __str__(self): + def __str__(self) -> str: """ Print handling routine """ @@ -89,26 +94,26 @@ class Notifier: (self.__name, self.__info, self.__warning, self.__debug, self.__logging) # Severity funcs - def setSeverity(self, severity): + def setSeverity(self, severity: int) -> None: from panda3d.core import NSDebug, NSInfo, NSWarning, NSError if severity >= NSError: - self.setWarning(0) - self.setInfo(0) - self.setDebug(0) + self.setWarning(False) + self.setInfo(False) + self.setDebug(False) elif severity == NSWarning: - self.setWarning(1) - self.setInfo(0) - self.setDebug(0) + self.setWarning(True) + self.setInfo(False) + self.setDebug(False) elif severity == NSInfo: - self.setWarning(1) - self.setInfo(1) - self.setDebug(0) + self.setWarning(True) + self.setInfo(True) + self.setDebug(False) elif severity <= NSDebug: - self.setWarning(1) - self.setInfo(1) - self.setDebug(1) + self.setWarning(True) + self.setInfo(True) + self.setDebug(True) - def getSeverity(self): + def getSeverity(self) -> int: from panda3d.core import NSDebug, NSInfo, NSWarning, NSError if self.getDebug(): return NSDebug @@ -120,7 +125,7 @@ class Notifier: return NSError # error funcs - def error(self, errorString, exception=NotifierException): + def error(self, errorString: object, exception: type[Exception] = NotifierException) -> NoReturn: """ Raise an exception with given string and optional type: Exception: error @@ -134,7 +139,7 @@ class Notifier: raise exception(errorString) # warning funcs - def warning(self, warningString): + def warning(self, warningString: object) -> int: """ Issue the warning message if warn flag is on """ @@ -148,20 +153,20 @@ class Notifier: self.__print(string) return 1 # to allow assert myNotify.warning("blah") - def setWarning(self, enable): + def setWarning(self, enable: bool) -> None: """ Enable/Disable the printing of warning messages """ self.__warning = enable - def getWarning(self): + def getWarning(self) -> bool: """ Return whether the printing of warning messages is on or off """ return self.__warning # debug funcs - def debug(self, debugString): + def debug(self, debugString: object) -> int: """ Issue the debug message if debug flag is on """ @@ -175,20 +180,20 @@ class Notifier: self.__print(string) return 1 # to allow assert myNotify.debug("blah") - def setDebug(self, enable): + def setDebug(self, enable: bool) -> None: """ Enable/Disable the printing of debug messages """ self.__debug = enable - def getDebug(self): + def getDebug(self) -> bool: """ Return whether the printing of debug messages is on or off """ return self.__debug # info funcs - def info(self, infoString): + def info(self, infoString: object) -> int: """ Print the given informational string, if info flag is on """ @@ -202,39 +207,39 @@ class Notifier: self.__print(string) return 1 # to allow assert myNotify.info("blah") - def getInfo(self): + def getInfo(self) -> bool: """ Return whether the printing of info messages is on or off """ return self.__info - def setInfo(self, enable): + def setInfo(self, enable: bool) -> None: """ Enable/Disable informational message printing """ self.__info = enable # log funcs - def __log(self, logEntry): + def __log(self, logEntry: str) -> None: """ Determine whether to send informational message to the logger """ if self.__logging: self.__logger.log(logEntry) - def getLogging(self): + def getLogging(self) -> bool: """ Return 1 if logging enabled, 0 otherwise """ return self.__logging - def setLogging(self, enable): + def setLogging(self, enable: bool) -> None: """ Set the logging flag to int (1=on, 0=off) """ self.__logging = enable - def __print(self, string): + def __print(self, string: str) -> None: """ Prints the string to output followed by a newline. """ @@ -285,7 +290,7 @@ class Notifier: self.__print(string) return 1 # to allow assert self.notify.debugStateCall(self) - def debugCall(self, debugString=''): + def debugCall(self, debugString: object = '') -> int: """ If this notify is in debug mode, print the time of the call followed by the notifier category and diff --git a/direct/src/directnotify/RotatingLog.py b/direct/src/directnotify/RotatingLog.py index 1502ad8994..f73ab4315b 100755 --- a/direct/src/directnotify/RotatingLog.py +++ b/direct/src/directnotify/RotatingLog.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import os import time +from typing import Iterable class RotatingLog: @@ -8,7 +11,12 @@ class RotatingLog: to a new file if the prior file is too large or after a time interval. """ - def __init__(self, path="./log_file", hourInterval=24, megabyteLimit=1024): + def __init__( + self, + path: str = "./log_file", + hourInterval: int | None = 24, + megabyteLimit: int | None = 1024, + ) -> None: """ Args: path: a full or partial path with file name. @@ -28,33 +36,33 @@ class RotatingLog: if megabyteLimit is not None: self.sizeLimit = megabyteLimit*1024*1024 - def __del__(self): + def __del__(self) -> None: self.close() - def close(self): + def close(self) -> None: if hasattr(self, "file"): self.file.flush() self.file.close() self.closed = self.file.closed del self.file else: - self.closed = 1 + self.closed = True - def shouldRotate(self): + def shouldRotate(self) -> bool: """ Returns a bool about whether a new log file should be created and written to (while at the same time stopping output to the old log file and closing it). """ if not hasattr(self, "file"): - return 1 + return True if self.timeLimit is not None and time.time() > self.timeLimit: - return 1 + return True if self.sizeLimit is not None and self.file.tell() > self.sizeLimit: - return 1 - return 0 + return True + return False - def filePath(self): + def filePath(self) -> str: dateString = time.strftime("%Y_%m_%d_%H", time.localtime()) for i in range(26): limit = self.sizeLimit @@ -65,7 +73,7 @@ class RotatingLog: # Maybe we should clear the self.sizeLimit here... maybe. return path - def rotate(self): + def rotate(self) -> None: """ Rotate the log now. You normally shouldn't need to call this. See write(). @@ -88,12 +96,13 @@ class RotatingLog: #self.newlines = self.file.newlines # Python 2.3, maybe if self.timeLimit is not None and time.time() > self.timeLimit: + assert self.timeInterval is not None self.timeLimit=time.time()+self.timeInterval else: # We'll keep writing to the old file, if available. print("RotatingLog error: Unable to open new log file \"%s\"." % (path,)) - def write(self, data): + def write(self, data: str) -> int | None: """ Write the data to either the current log or a new one, depending on the return of shouldRotate() and whether @@ -105,14 +114,15 @@ class RotatingLog: r = self.file.write(data) self.file.flush() return r + return None - def flush(self): + def flush(self) -> None: return self.file.flush() - def fileno(self): + def fileno(self) -> int: return self.file.fileno() - def isatty(self): + def isatty(self) -> bool: return self.file.isatty() def __next__(self): @@ -131,14 +141,14 @@ class RotatingLog: def xreadlines(self): return self.file.xreadlines() - def seek(self, offset, whence=0): + def seek(self, offset: int, whence: int = 0) -> int: return self.file.seek(offset, whence) - def tell(self): + def tell(self) -> int: return self.file.tell() - def truncate(self, size): + def truncate(self, size: int | None) -> int: return self.file.truncate(size) - def writelines(self, sequence): + def writelines(self, sequence: Iterable[str]) -> None: return self.file.writelines(sequence) diff --git a/direct/src/distributed/DistributedCartesianGrid.py b/direct/src/distributed/DistributedCartesianGrid.py index 88c0af7ce9..39e8b212dd 100755 --- a/direct/src/distributed/DistributedCartesianGrid.py +++ b/direct/src/distributed/DistributedCartesianGrid.py @@ -23,7 +23,7 @@ GRID_Z_OFFSET = 0.0 class DistributedCartesianGrid(DistributedNode, CartesianGridBase): notify = directNotify.newCategory("DistributedCartesianGrid") - notify.setDebug(0) + notify.setDebug(False) VisualizeGrid = ConfigVariableBool("visualize-cartesian-grid", False) diff --git a/tests/directnotify/test_DirectNotify.py b/tests/directnotify/test_DirectNotify.py new file mode 100644 index 0000000000..c98aeb2646 --- /dev/null +++ b/tests/directnotify/test_DirectNotify.py @@ -0,0 +1,57 @@ +import pytest +from panda3d import core +from direct.directnotify import DirectNotify, Logger, Notifier + +CATEGORY_NAME = 'test' + + +@pytest.fixture +def notify(): + notify = DirectNotify.DirectNotify() + notify.newCategory(CATEGORY_NAME) + return notify + + +def test_categories(): + notify = DirectNotify.DirectNotify() + assert len(notify.getCategories()) == 0 + assert notify.getCategory(CATEGORY_NAME) is None + notifier = notify.newCategory(CATEGORY_NAME, logger=Logger.Logger()) + assert isinstance(notifier, Notifier.Notifier) + assert notify.getCategories() == [CATEGORY_NAME] + + +def test_setDconfigLevels(notify): + config = core.ConfigVariableString('notify-level-' + CATEGORY_NAME, '') + notifier = notify.getCategory(CATEGORY_NAME) + config.value = 'error' + notify.setDconfigLevels() + assert notifier.getSeverity() == core.NS_error + config.value = 'warning' + notify.setDconfigLevels() + assert notifier.getSeverity() == core.NS_warning + config.value = 'info' + notify.setDconfigLevels() + assert notifier.getSeverity() == core.NS_info + config.value = 'debug' + notify.setDconfigLevels() + assert notifier.getSeverity() == core.NS_debug + + +def test_setVerbose(notify): + notifier = notify.getCategory(CATEGORY_NAME) + notifier.setWarning(False) + notifier.setInfo(False) + notifier.setDebug(False) + notify.setVerbose() + assert notifier.getWarning() + assert notifier.getInfo() + assert notifier.getDebug() + + +def test_giveNotify(notify): + class HasNotify: + notify = None + + notify.giveNotify(HasNotify) + assert isinstance(HasNotify.notify, Notifier.Notifier) diff --git a/tests/directnotify/test_Logger.py b/tests/directnotify/test_Logger.py new file mode 100644 index 0000000000..1f0aa76f94 --- /dev/null +++ b/tests/directnotify/test_Logger.py @@ -0,0 +1,21 @@ +import re +from direct.directnotify import Logger + +LOG_TEXT = 'Arbitrary log text' + + +def test_logging(tmp_path): + log_filename = str(tmp_path / 'log') + logger = Logger.Logger(log_filename) + + assert logger.getTimeStamp() + logger.log(LOG_TEXT) + logger.setTimeStamp(False) + assert not logger.getTimeStamp() + logger.log(LOG_TEXT) + logger._Logger__closeLogFile() + + log_file, = tmp_path.iterdir() + log_text = log_file.read_text() + pattern = rf'\d\d:\d\d:\d\d:\d\d: {LOG_TEXT}\n{LOG_TEXT}\n' + assert re.match(pattern, log_text) diff --git a/tests/directnotify/test_Notifier.py b/tests/directnotify/test_Notifier.py new file mode 100644 index 0000000000..66acc4c5ce --- /dev/null +++ b/tests/directnotify/test_Notifier.py @@ -0,0 +1,87 @@ +import io +import re +import time +import pytest +from panda3d import core +from direct.directnotify import Logger, Notifier + +NOTIFIER_NAME = 'Test notifier' +DEBUG_LOG = 'Debug log' +INFO_LOG = 'Info log' +WARNING_LOG = 'Warning log' +ERROR_LOG = 'Error log' + + +@pytest.fixture +def log_io(): + return io.StringIO() + + +@pytest.fixture +def notifier(log_io): + logger = Logger.Logger() + logger.setTimeStamp(False) + logger._Logger__logFile = log_io + notifier = Notifier.Notifier(NOTIFIER_NAME, logger) + notifier.setLogging(True) + return notifier + + +def test_setServerDelta(): + notifier = Notifier.Notifier(NOTIFIER_NAME) + notifier.setServerDelta(4.2, time.timezone) + assert Notifier.Notifier.serverDelta == 4 + Notifier.Notifier.serverDelta = 0 + + +def test_logging(notifier, log_io): + notifier.setLogging(False) + assert not notifier.getLogging() + notifier.info(INFO_LOG) + assert log_io.getvalue() == '' + + notifier.setLogging(True) + assert notifier.getLogging() + notifier.info(INFO_LOG) + assert log_io.getvalue() == f':{NOTIFIER_NAME}: {INFO_LOG}\n' + + +@pytest.mark.parametrize('severity', (core.NS_debug, core.NS_info, core.NS_warning, core.NS_error)) +def test_severity(severity, notifier, log_io): + notifier.setSeverity(severity) + assert notifier.getSeverity() == severity + + with pytest.raises(Notifier.NotifierException): + notifier.error(ERROR_LOG) + warning_return = notifier.warning(WARNING_LOG) + info_return = notifier.info(INFO_LOG) + debug_return = notifier.debug(DEBUG_LOG) + assert warning_return and info_return and debug_return + + expected_logs = [ + f'{Notifier.NotifierException}: {NOTIFIER_NAME}(error): {ERROR_LOG}', + f':{NOTIFIER_NAME}(warning): {WARNING_LOG}', + f':{NOTIFIER_NAME}: {INFO_LOG}', + f':{NOTIFIER_NAME}(debug): {DEBUG_LOG}', + ] + del expected_logs[6-severity:] + assert log_io.getvalue() == '\n'.join(expected_logs) + '\n' + + +def test_custom_exception(notifier): + class CustomException(Exception): + pass + + with pytest.raises(CustomException): + notifier.error(ERROR_LOG, CustomException) + + +def test_debugCall(notifier, log_io): + notifier.setDebug(False) + return_value = notifier.debugCall(DEBUG_LOG) + assert return_value + assert log_io.getvalue() == '' + notifier.setDebug(True) + notifier.debugCall(DEBUG_LOG) + pattern = rf':\d\d:\d\d:\d\d:{NOTIFIER_NAME} "{DEBUG_LOG}" test_debugCall\(.*\)\n' + assert re.match(pattern, log_io.getvalue()) diff --git a/tests/directnotify/test_RotatingLog.py b/tests/directnotify/test_RotatingLog.py new file mode 100644 index 0000000000..5ca66503c5 --- /dev/null +++ b/tests/directnotify/test_RotatingLog.py @@ -0,0 +1,45 @@ +import pytest +from direct.directnotify import RotatingLog + +LOG_TEXT = 'Arbitrary log text' + + +@pytest.fixture +def log_dir(tmp_path): + log_dir = tmp_path / 'logs' + log_dir.mkdir() + return log_dir + + +@pytest.fixture +def rotating_log(log_dir): + log_filename = str(log_dir / 'log') + rotating_log = RotatingLog.RotatingLog(log_filename) + yield rotating_log + rotating_log.close() + + +def test_rotation(rotating_log, log_dir): + rotating_log.sizeLimit = -1 + rotating_log.write('1') + rotating_log.write('2') + written = [f.read_text() for f in log_dir.iterdir()] + assert written == ['1', '2'] or written == ['2', '1'] + + +def test_wrapper_methods(rotating_log, log_dir): + rotating_log.write('') + log_file, = log_dir.iterdir() + + assert rotating_log.fileno() == rotating_log.file.fileno() + assert rotating_log.isatty() == rotating_log.file.isatty() + + rotating_log.writelines([LOG_TEXT] * 10) + assert not log_file.read_text() + rotating_log.flush() + assert log_file.read_text() == LOG_TEXT * 10 + + assert rotating_log.tell() == len(LOG_TEXT) * 10 + rotating_log.seek(len(LOG_TEXT)) + rotating_log.truncate(None) + assert log_file.read_text() == LOG_TEXT