Skip to content

Commit d689cf9

Browse files
author
Murdo B. Maclachlan
committed
Created initial program.
1 parent 195d963 commit d689cf9

File tree

5 files changed

+341
-1
lines changed

5 files changed

+341
-1
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
### 0.1.0
2+
3+
**New**
4+
5+
- Created initial program and documentation. (@MurdoMaclachlan)

README.md

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,77 @@
11
# smooth_logger
22

3-
A simple logger made primarily for my own personal use. Was made out of a combination of necessity and being so lazy that I overflowed into being productive and instead of searching for a library that suited my needs, I wrote my own.
3+
A simple logger made primarily for my own personal use. Was made out of a combination of necessity and being so lazy that I overflowed into being productive and instead of searching for a library that suited my needs, I wrote my own.
4+
5+
## Installation
6+
7+
smooth-logger can be installed through pip. Either download the latest release from Codeberg/GitHub, or do `pip install smooth-logger` to install from PyPi. For the latest commits, check the `dev` branches on the repositories.
8+
9+
smooth-logger was written in Python 3.9, but should work with Python 3.5 and up. A minimum of 3.5 is required due to the project's use of type hinting, which was introduced in that version.
10+
11+
smooth-logger supports Linux, macOS and Windows.
12+
13+
## Usage
14+
15+
Usage of smooth-logger is, as it should be, quite simple.
16+
17+
The `Logger` model provides a number of methods for your use:
18+
19+
- `Logger.clean()` erases all log entries currently in memory.
20+
- `Logger.define_output_path()` is primarily intended as an internal method; it detects the user's operating system and home folder and, using the provided program name and creates a log folder in the appropriate location (`~/.config/{program_name}` on Linux and macOS, `AppData\Roaming\{program_name}` on Windows).
21+
- `Logger.get()` allows you to retrieve either the most recent log entry or all log entries, optionally filtered by scope.
22+
- `Logger.get_time()` returns the full date & time, or optionally just the date, in ISO-8601 formatting.
23+
- `Logger.init_bar()` initialises the `ProgressBar` model imported from the `smooth_progress` dependency.
24+
- `Logger.notify()` sends a desktop notification using the `plyer` dependency.
25+
- `Logger.new()` creates and, depending on scope, prints a new log entry.
26+
- `Logger.output()` saves all log entries of appropriate scope to the log file and cleans the log array for the next group of log entries. A new log file is created for each new day. This method only attempts to create or update the log file if there are entries of an appropriate scope to be written to it; if there are none, it just executes `Logger.clean()`.
27+
28+
When initialising the Logger, you can optionally provide values to associate with each scope:
29+
30+
- 0: disabled, do not print to console or save to log file
31+
- 1: enabled, print to console but do not save to log file
32+
- 2: maximum, print to console and save to log file
33+
34+
The scopes available, along with their default values and suggested use cases, are:
35+
36+
- DEBUG (0): Information for debugging the program.
37+
- ERROR (2): Errors that the program can recover from but impact functionality or performance.
38+
- FATAL (2): Errors that mean the program must continue; handled crashes.
39+
- INFO (1): General information for the user.
40+
- WARNING (2): Things that have no immediate impact to functionality but could cause errors later on.
41+
42+
Here is a simple example showing the initialisation of the logger:
43+
44+
```
45+
import smooth_logger
46+
47+
Log = smooth_logger.Logger("Program")
48+
Log.new("This is a log message!", "INFO")
49+
```
50+
51+
## Roadmap
52+
53+
A roadmap of planned future improvements and features:
54+
55+
- Allow the creation of custom scopes. These would be instance-specific and not hard saved in any way. Suggested format and example:
56+
57+
```
58+
Log.add_scope(name: str, description: str, default_value: int)
59+
60+
Log.add_scope("NEWSCOPE", "A new scope of mine!", 1)
61+
```
62+
63+
Potentially also allow removal of scopes. In this situation, default scopes should be removable, but doing so should log a warning.
64+
65+
66+
- Allow editing of the values of existing scopes post-initialisation. For example:
67+
68+
```
69+
Log.edit_scope(name: str, new_value: int)
70+
71+
Log.edit_scope("DEBUG", 1)
72+
```
73+
74+
to temporarily enable debug statements. This feature would probably see the most use from custom scopes.
75+
76+
77+
- Add an optional argument `notify: bool` to `Logger.new()` to allow log entries to be created and notified in one statement, rather than the two currently required.

setup.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from setuptools import setup, find_packages
2+
3+
4+
def readme():
5+
return open('README.md', 'r').read()
6+
7+
8+
setup(
9+
name="smooth_logger",
10+
version="0.1.0",
11+
author="Murdo Maclachlan",
12+
author_email="[email protected]",
13+
description=(
14+
"A simple logger made primarily for my own personal use. Made from a"
15+
+ " combination of necessity and so much sloth that it overflowed into"
16+
+ " productivity."
17+
),
18+
long_description=readme(),
19+
long_description_content_type="text/markdown",
20+
url="https://codeberg.org/MurdoMaclachlan/smooth_logger",
21+
packages=find_packages(),
22+
install_requires=[
23+
"plyer",
24+
"smooth_progress"
25+
],
26+
classifiers=[
27+
"Programming Language :: Python :: 3.5",
28+
"Programming Language :: Python :: 3.7",
29+
"Programming Language :: Python :: 3.8",
30+
"Programming Language :: Python :: 3.9",
31+
"Programming Language :: Python :: 3.10",
32+
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
33+
"Operating System :: OS Independent",
34+
],
35+
license='AGPLv3+'
36+
)

smooth_logger/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .logger import Logger
2+
from .logger import LogEntry

smooth_logger/logger.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
from datetime import datetime
2+
from os import environ, makedirs
3+
from os.path import expanduser, isdir
4+
from plyer import notification
5+
from smooth_progress import ProgressBar
6+
from sys import platform
7+
from time import time
8+
from typing import Dict, List, Union
9+
10+
11+
class Logger:
12+
"""Class for controlling the entirety of logging. The logging works on a scope-based
13+
system where (almost) every message has a defined scope, and the scopes are each
14+
associated with a specific value between 0 and 2 inclusive. The meanings of the
15+
values are as follows:
16+
17+
0: disabled, do not print to console or save to log file
18+
1: enabled, print to console but do not save to log file
19+
2: maximum, print to console and save to log file
20+
"""
21+
def __init__(
22+
self: object, program_name: str, debug: int = 0, error: int = 2, fatal: int = 2,
23+
info: int = 1, warning: int = 2
24+
) -> None:
25+
self.bar: Union[ProgressBar, None] = None
26+
self.__is_empty: bool = True
27+
self.__log: List[LogEntry] = []
28+
self.__notifier: object = notification
29+
self.__program_name: str = program_name
30+
self.__path: str = self.define_output_path(expanduser("~"), platform)
31+
self.__scopes: Dict[str, int] = {
32+
"DEBUG": debug, # information for debugging the program
33+
"ERROR": error, # errors the program can recover from
34+
"FATAL": fatal, # errors that mean the program cannot continue
35+
"INFO": info, # general information for the user
36+
"WARNING": warning # things that could cause errors later on
37+
}
38+
self.__write_logs = False
39+
40+
def clean(self: object) -> None:
41+
del self.__log[:]
42+
self.__is_empty = True
43+
self.__write_logs = False
44+
45+
def define_output_path(self: object, home: str, os: str) -> str:
46+
"""Detects OS and defines the appropriate save paths for the config and data.
47+
Exits on detecting an unspported OS. Supported OSes are: Linux, MacOS, Windows.
48+
49+
:arg home: string; the user's home folder
50+
:arg os: string; the user's operating system
51+
52+
:return: a single string dict containing the newly-defined output path
53+
"""
54+
os = "".join(list(os)[:3])
55+
56+
# Route for a supported operating system
57+
if os in ["dar", "lin", "win"]:
58+
59+
path = (
60+
environ["APPDATA"] + f"\\{self.__program_name}\logs"
61+
if os == "win" else
62+
f"{home}/.config/{self.__program_name}/logs"
63+
)
64+
65+
# Create any missing directories
66+
if not isdir(path):
67+
print(f"Making path: {path}")
68+
makedirs(path, exist_ok=True)
69+
return path
70+
71+
# Exit if the operating system is unsupported
72+
else:
73+
print(f"FATAL: Unsupported operating system: {os}, exiting.")
74+
exit()
75+
76+
def get(
77+
self: object, mode: str = "all", scope: str = None
78+
) -> Union[List[str], str, None]:
79+
"""Returns item(s) in the log. What entries are returned can be controlled by
80+
passing optional arguments.
81+
82+
:arg mode: optional, string; options are 'all' and 'recent'.
83+
:arg scope: optional, string; if passed, only entries with matching scope will
84+
be returned.
85+
86+
:return: a single log entry (string), list of log entries (string array), or an
87+
empty string on a failure.
88+
"""
89+
if self.__is_empty:
90+
pass
91+
elif scope is None:
92+
# Tuple indexing provides a succint way to determine what to return
93+
return (self.__log, self.__log[len(self.__log)-1])[mode == "recent"]
94+
else:
95+
# Return all log entries with a matching scope
96+
if mode == "all":
97+
data = []
98+
for i in self.__log:
99+
if i.scope == scope:
100+
data.append(i)
101+
if data:
102+
return data
103+
# Return the most recent log entry with a matching scope; for this purpose,
104+
# we reverse the list then iterate through it.
105+
elif mode == "recent":
106+
for i in self.__log.reverse():
107+
if i.scope == scope:
108+
return self.__log[i]
109+
else:
110+
self.new("Unknown mode passed to Logger.get().", "WARNING")
111+
# Return an empty string to indicate failure if no entries were found
112+
return ""
113+
114+
def get_time(self: object, method: str = "time") -> str:
115+
"""Gets the current time and parses it to a human-readable format.
116+
117+
:arg method: string; the method to calculate the timestamp; either 'time' or
118+
'date'.
119+
120+
:return: a single date string formatted either 'YYYY-MM-DD HH:MM:SS' or
121+
'YYYY-MM-DD'
122+
"""
123+
if method in ["time", "date"]:
124+
return datetime.fromtimestamp(time()).strftime(
125+
("%Y-%m-%d", "%Y-%m-%d %H:%M:%S")[method == "time"]
126+
)
127+
else:
128+
print("ERROR: Bad method passed to Logger.get_time().")
129+
return ""
130+
131+
def init_bar(self: object, limit: int) -> None:
132+
"""Initiate and open the progress bar.
133+
134+
:arg limit: int; the number of increments it should take to fill the bar.
135+
"""
136+
self.bar = ProgressBar(limit=limit)
137+
self.bar.open()
138+
139+
def notify(self: object, message: str) -> None:
140+
"""Display a desktop notification with a given message.
141+
142+
:arg message: string; the message to display in the notification.
143+
"""
144+
self.__notifier.notify(title=self.__program_name, message=message)
145+
146+
def new(
147+
self: object,
148+
message: str, scope: str, do_not_print: bool = False
149+
) -> bool:
150+
"""Initiates a new log entry and prints it to the console. Optionally, if
151+
do_not_print is passed as True, it will only save the log and will not print
152+
anything (unless the scope is 'NOSCOPE'; these messages are always printed).
153+
154+
:arg message: string; the messaage to log.
155+
:arg scope: string; the scope of the message (e.g. debug, error, info).
156+
:arg do_not_print: optional, bool; False by default.
157+
158+
:return: boolean success status.
159+
"""
160+
if scope in self.__scopes or scope == "NOSCOPE":
161+
# TODO: sperate some of this into submethods
162+
163+
# Setup variables
164+
output = (self.__scopes[scope] == 2) if scope != "NOSCOPE" else False
165+
isBar: bool = (self.bar is not None) and self.bar.opened
166+
167+
# Create and save the log entry
168+
if isBar and len(message) < len(self.bar.state):
169+
message += " " * (len(self.bar.state) - len(message))
170+
entry = LogEntry(message, output, scope, self.get_time())
171+
self.__log.append(entry)
172+
173+
# Print the message, if required
174+
if scope == "NOSCOPE":
175+
print(entry.rendered)
176+
elif self.__scopes[scope]:
177+
print(entry.rendered if not do_not_print else None)
178+
179+
# Re-print bar, if required
180+
if isBar:
181+
print(self.bar.state, end="\r", flush=True)
182+
183+
# Amend boolean states
184+
if not self.__write_logs:
185+
self.__write_logs = output
186+
self.__is_empty = False
187+
188+
return True
189+
else:
190+
self.new("Unknown scope passed to Logger.new()", "WARNING")
191+
return False
192+
193+
def output(self: object) -> None:
194+
"""Write all log entries with scopes set to save to a log file in a data folder
195+
in the working directory, creating the folder and file if they do not exist.
196+
The log files are marked with the date, so each new day, a new file will be
197+
created.
198+
"""
199+
if self.__write_logs:
200+
with open(
201+
f"{self.__path}/log-{self.get_time(method='date')}.txt", "at+"
202+
) as log_file:
203+
for line in self.__log:
204+
if line.output:
205+
log_file.write(line.rendered + "\n")
206+
self.clean()
207+
208+
209+
class LogEntry:
210+
"""Represents a single entry within the log, storing its timestamp, scope and
211+
message. This makes it easier to select certain log entries using the
212+
Logger.get() method.
213+
"""
214+
def __init__(self: object, message: str, output: bool, scope: str, timestamp: str):
215+
self.message = message
216+
self.output = output
217+
self.scope = scope
218+
self.timestamp = timestamp
219+
self.rendered = (
220+
f"[{timestamp}] {scope}: {message}"
221+
if scope != "NOSCOPE" else
222+
f"{message}"
223+
)

0 commit comments

Comments
 (0)