pypomes-logging 0.0.1__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.
- pypomes_logging-0.0.1/.gitignore +16 -0
- pypomes_logging-0.0.1/LICENSE +21 -0
- pypomes_logging-0.0.1/PKG-INFO +18 -0
- pypomes_logging-0.0.1/README.md +0 -0
- pypomes_logging-0.0.1/pyproject.toml +32 -0
- pypomes_logging-0.0.1/src/pypomes_logging/__init__.py +20 -0
- pypomes_logging-0.0.1/src/pypomes_logging/logging_pomes.py +321 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 GT Nunes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pypomes_logging
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A collection of Python pomes, pennyeach (logging module)
|
|
5
|
+
Project-URL: Homepage, https://github.com/TheWiseCoder/PyPomes-Logging
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/TheWiseCoder/PyPomes-Logging/issues
|
|
7
|
+
Author-email: GT Nunes <wisecoder01@gmail.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: flask>=3.0.2
|
|
14
|
+
Requires-Dist: pip>=24.0
|
|
15
|
+
Requires-Dist: pypomes-core>=0.8.3
|
|
16
|
+
Requires-Dist: python-dateutil>=2.8.2
|
|
17
|
+
Requires-Dist: setuptools>=68.0.0
|
|
18
|
+
Requires-Dist: wheel>=0.42.0
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [
|
|
3
|
+
"hatchling>=1.22.2"
|
|
4
|
+
]
|
|
5
|
+
build-backend = "hatchling.build"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "pypomes_logging"
|
|
9
|
+
version = "0.0.1"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name="GT Nunes", email="wisecoder01@gmail.com" }
|
|
12
|
+
]
|
|
13
|
+
description = "A collection of Python pomes, pennyeach (logging module)"
|
|
14
|
+
readme = "README.md"
|
|
15
|
+
requires-python = ">=3.10"
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent"
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"Flask>=3.0.2",
|
|
23
|
+
"pip>=24.0",
|
|
24
|
+
"pypomes-core>=0.8.3",
|
|
25
|
+
"python-dateutil>=2.8.2",
|
|
26
|
+
"setuptools>=68.0.0",
|
|
27
|
+
"wheel>=0.42.0"
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
"Homepage" = "https://github.com/TheWiseCoder/PyPomes-Logging"
|
|
32
|
+
"Bug Tracker" = "https://github.com/TheWiseCoder/PyPomes-Logging/issues"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .logging_pomes import (
|
|
2
|
+
LOGGING_ID, LOGGING_LEVEL, LOGGING_FORMAT, LOGGING_STYLE,
|
|
3
|
+
LOGGING_FILE_PATH, LOGGING_FILE_MODE, PYPOMES_LOGGER,
|
|
4
|
+
logging_get_entries, logging_get_entries_from_request,
|
|
5
|
+
logging_log_msgs, logging_log_debug, logging_log_error,
|
|
6
|
+
logging_log_info, logging_log_critical, logging_log_warning,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
# logging_pomes
|
|
11
|
+
"LOGGING_ID", "LOGGING_LEVEL", "LOGGING_FORMAT", "LOGGING_STYLE",
|
|
12
|
+
"LOGGING_FILE_PATH", "LOGGING_FILE_MODE", "PYPOMES_LOGGER",
|
|
13
|
+
"logging_get_entries", "logging_get_entries_from_request",
|
|
14
|
+
"logging_log_msgs", "logging_log_debug", "logging_log_error",
|
|
15
|
+
"logging_log_info", "logging_log_critical", "logging_log_warning",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
from importlib.metadata import version
|
|
19
|
+
__version__ = version("pypomes_logging")
|
|
20
|
+
__version_info__ = tuple(int(i) for i in __version__.split(".") if i.isdigit())
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from dateutil import parser
|
|
5
|
+
from flask import Request, Response, send_file
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Final, Literal, TextIO
|
|
9
|
+
|
|
10
|
+
from pypomes_core import (
|
|
11
|
+
APP_PREFIX, DATETIME_FORMAT_INV, TEMP_DIR,
|
|
12
|
+
env_get_str, env_get_path
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def __get_logging_level(level: Literal["debug", "info", "warning", "error", "critical"]) -> int:
|
|
17
|
+
"""
|
|
18
|
+
Translate the log severity string *level* into the logging's internal severity value.
|
|
19
|
+
|
|
20
|
+
:param level: the string log severity
|
|
21
|
+
:return: the internal logging severity value
|
|
22
|
+
"""
|
|
23
|
+
result: int | None
|
|
24
|
+
match level:
|
|
25
|
+
case "debug":
|
|
26
|
+
result = logging.DEBUG # 10
|
|
27
|
+
case "info":
|
|
28
|
+
result = logging.INFO # 20
|
|
29
|
+
case "warning":
|
|
30
|
+
result = logging.WARN # 30
|
|
31
|
+
case "error":
|
|
32
|
+
result = logging.ERROR # 40
|
|
33
|
+
case "critical":
|
|
34
|
+
result = logging.CRITICAL # 50
|
|
35
|
+
case _:
|
|
36
|
+
result = logging.NOTSET # 0
|
|
37
|
+
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
LOGGING_ID: Final[str] = env_get_str(f"{APP_PREFIX}_LOGGING_ID", f"{APP_PREFIX}")
|
|
42
|
+
LOGGING_FORMAT: Final[str] = env_get_str(f"{APP_PREFIX}_LOGGING_FORMAT",
|
|
43
|
+
"{asctime} {levelname:1.1} {thread:5d} "
|
|
44
|
+
"{module:20.20} {funcName:20.20} {lineno:3d} {message}")
|
|
45
|
+
LOGGING_STYLE: Final[str] = env_get_str(f"{APP_PREFIX}_LOGGING_STYLE", "{")
|
|
46
|
+
|
|
47
|
+
LOGGING_FILE_PATH: Final[Path] = env_get_path(f"{APP_PREFIX}_LOGGING_FILE_PATH",
|
|
48
|
+
TEMP_DIR / f"{APP_PREFIX}.log")
|
|
49
|
+
LOGGING_FILE_MODE: Final[str] = env_get_str(f"{APP_PREFIX}_LOGGING_FILE_MODE", "a")
|
|
50
|
+
|
|
51
|
+
# define and configure the logger
|
|
52
|
+
PYPOMES_LOGGER: Final[logging.Logger] = logging.getLogger(LOGGING_ID)
|
|
53
|
+
|
|
54
|
+
# define the logging severity level
|
|
55
|
+
# noinspection PyTypeChecker
|
|
56
|
+
LOGGING_LEVEL: Final[int] = __get_logging_level(env_get_str(f"{APP_PREFIX}_LOGGING_LEVEL"))
|
|
57
|
+
|
|
58
|
+
# configure the logger
|
|
59
|
+
# noinspection PyTypeChecker
|
|
60
|
+
logging.basicConfig(filename=LOGGING_FILE_PATH,
|
|
61
|
+
filemode=LOGGING_FILE_MODE,
|
|
62
|
+
format=LOGGING_FORMAT,
|
|
63
|
+
datefmt=DATETIME_FORMAT_INV,
|
|
64
|
+
style=LOGGING_STYLE,
|
|
65
|
+
level=LOGGING_LEVEL)
|
|
66
|
+
for _handler in logging.root.handlers:
|
|
67
|
+
_handler.addFilter(logging.Filter(LOGGING_ID))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def logging_get_entries(errors: list[str],
|
|
71
|
+
log_level: Literal["debug", "info", "warning", "error", "critical"] = None,
|
|
72
|
+
log_from: str = None, log_to: str = None,
|
|
73
|
+
log_path: Path | str = LOGGING_FILE_PATH) -> BytesIO:
|
|
74
|
+
"""
|
|
75
|
+
Extract and return all entries in the logging file *log_path*.
|
|
76
|
+
|
|
77
|
+
It is expected for this logging file to be compliant with *PYPOMES_LOGGER*'s default format.
|
|
78
|
+
The extraction meets the criteria specified by *log_level*, and by the inclusive interval *[log_from, log_to]*.
|
|
79
|
+
|
|
80
|
+
:param errors: incidental error messages
|
|
81
|
+
:param log_level: the logging level (defaults to all levels)
|
|
82
|
+
:param log_from: the initial timestamp (defaults to unspecified)
|
|
83
|
+
:param log_to: the finaL timestamp (defaults to unspecified)
|
|
84
|
+
:param log_path: the path of the log file
|
|
85
|
+
:return: the logging entries meeting the specified criteria
|
|
86
|
+
"""
|
|
87
|
+
# inicializa variável de retorno
|
|
88
|
+
result: BytesIO | None = None
|
|
89
|
+
|
|
90
|
+
# obtain the logging level
|
|
91
|
+
# noinspection PyTypeChecker
|
|
92
|
+
logging_level: int = __get_logging_level(log_level)
|
|
93
|
+
|
|
94
|
+
# obtain the initial timestamp
|
|
95
|
+
from_stamp: datetime | None = None
|
|
96
|
+
if log_from:
|
|
97
|
+
from_stamp = parser.parse(log_from)
|
|
98
|
+
if not from_stamp:
|
|
99
|
+
errors.append(f"Value '{from_stamp}' of 'from' attribute invalid")
|
|
100
|
+
|
|
101
|
+
# obtain the final timestamp
|
|
102
|
+
to_stamp: datetime | None = None
|
|
103
|
+
if log_to:
|
|
104
|
+
to_stamp = parser.parse(log_to)
|
|
105
|
+
if not to_stamp or \
|
|
106
|
+
(from_stamp and from_stamp > to_stamp):
|
|
107
|
+
errors.append(f"Value '{to_stamp}' of 'to' attribute invalid")
|
|
108
|
+
|
|
109
|
+
file_path: Path = Path(log_path)
|
|
110
|
+
# does the log file exist ?
|
|
111
|
+
if not Path.exists(file_path):
|
|
112
|
+
# no, report the error
|
|
113
|
+
errors.append(f"File '{file_path}' not found")
|
|
114
|
+
|
|
115
|
+
# any error ?
|
|
116
|
+
if len(errors) == 0:
|
|
117
|
+
# no, proceed
|
|
118
|
+
result = BytesIO()
|
|
119
|
+
with Path.open(file_path) as f:
|
|
120
|
+
line: str = f.readline()
|
|
121
|
+
while line:
|
|
122
|
+
items: list[str] = line.split(maxsplit=3)
|
|
123
|
+
# noinspection PyTypeChecker
|
|
124
|
+
msg_level: int = __get_logging_level(items[2])
|
|
125
|
+
if msg_level >= logging_level:
|
|
126
|
+
timestamp: datetime = parser.parse(f"{items[0]} {items[1]}")
|
|
127
|
+
if (not from_stamp or timestamp >= from_stamp) and \
|
|
128
|
+
(not to_stamp or timestamp <= to_stamp):
|
|
129
|
+
result.write(line.encode())
|
|
130
|
+
line = f.readline()
|
|
131
|
+
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def logging_get_entries_from_request(request: Request, as_attachment: bool = False) -> Response:
|
|
136
|
+
"""
|
|
137
|
+
Retrieve from the log file, and return, the entries matching the criteria specified.
|
|
138
|
+
|
|
139
|
+
These criteria are specified in the query string of the HTTP request, according to the pattern
|
|
140
|
+
*path=<log-path>&level=<log-level>&from=YYYYMMDDhhmmss&to=YYYYMMDDhhmmss>*
|
|
141
|
+
|
|
142
|
+
All criteria are optional:
|
|
143
|
+
- path: the path of the log file
|
|
144
|
+
- level: the logging level of the entries
|
|
145
|
+
- from: the start timestamp
|
|
146
|
+
- to: the finish timestamp
|
|
147
|
+
|
|
148
|
+
:param request: the HTTP request
|
|
149
|
+
:param as_attachment: indicate to browser that it should offer to save the file, or just display it
|
|
150
|
+
:return: file containing the log entries requested on success, or incidental errors on fail
|
|
151
|
+
"""
|
|
152
|
+
# declare the return variable
|
|
153
|
+
result: Response
|
|
154
|
+
|
|
155
|
+
# initialize the error messages list
|
|
156
|
+
errors: list[str] = []
|
|
157
|
+
|
|
158
|
+
# obtain the logging level
|
|
159
|
+
log_level: str = request.args.get("level")
|
|
160
|
+
|
|
161
|
+
# obtain the initial and final timestamps
|
|
162
|
+
log_from: str = request.args.get("from")
|
|
163
|
+
log_to: str = request.args.get("to")
|
|
164
|
+
|
|
165
|
+
# obtain the path for the log file
|
|
166
|
+
log_path: str = request.args.get("path") or LOGGING_FILE_PATH
|
|
167
|
+
|
|
168
|
+
# retrieve the log entries
|
|
169
|
+
# noinspection PyTypeChecker
|
|
170
|
+
log_entries: BytesIO = logging_get_entries(errors, log_level, log_from, log_to, log_path)
|
|
171
|
+
|
|
172
|
+
# any error ?
|
|
173
|
+
if len(errors) == 0:
|
|
174
|
+
# no, return the log entries requested as an attached file
|
|
175
|
+
base: str = "entries" if not log_from or not log_to else \
|
|
176
|
+
(
|
|
177
|
+
f"{''.join(ch for ch in log_from if ch.isdigit())}"
|
|
178
|
+
f"{'_'.join(ch for ch in log_to if ch.isdigit())}"
|
|
179
|
+
)
|
|
180
|
+
log_file = f"log_{base}.log"
|
|
181
|
+
log_entries.seek(0)
|
|
182
|
+
result = send_file(path_or_file=log_entries,
|
|
183
|
+
mimetype="text/plain",
|
|
184
|
+
as_attachment=as_attachment,
|
|
185
|
+
download_name=log_file)
|
|
186
|
+
else:
|
|
187
|
+
# yes, report the failure
|
|
188
|
+
result = Response(json.dumps({"errors": errors}), status=400, mimetype="application/json")
|
|
189
|
+
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def logging_log_msgs(msgs: list[str], output_dev: TextIO = None,
|
|
194
|
+
log_level: Literal["debug", "info", "warning", "error", "critical"] = "error",
|
|
195
|
+
logger: logging.Logger = PYPOMES_LOGGER) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Write all messages in *msgs* to *logger*'s logging file, and to *output_dev*.
|
|
198
|
+
|
|
199
|
+
The output device is tipically *sys.stdout* or *sys.stderr*.
|
|
200
|
+
|
|
201
|
+
:param msgs: the messages list
|
|
202
|
+
:param output_dev: output device where the message is to be printed (None for no device printing)
|
|
203
|
+
:param log_level: the logging level, defaults to 'error' (None for no logging)
|
|
204
|
+
:param logger: the logger to use
|
|
205
|
+
"""
|
|
206
|
+
# define the log writer
|
|
207
|
+
log_writer: callable = None
|
|
208
|
+
match log_level:
|
|
209
|
+
case "debug":
|
|
210
|
+
log_writer = logger.debug
|
|
211
|
+
case "info":
|
|
212
|
+
log_writer = logger.info
|
|
213
|
+
case "warning":
|
|
214
|
+
log_writer = logger.warning
|
|
215
|
+
case "error":
|
|
216
|
+
log_writer = logger.error
|
|
217
|
+
case "critical":
|
|
218
|
+
log_writer = logger.critical
|
|
219
|
+
|
|
220
|
+
# traverse the messages list
|
|
221
|
+
for msg in msgs:
|
|
222
|
+
# has the log writer been defined ?
|
|
223
|
+
if log_writer:
|
|
224
|
+
# yes, log the message
|
|
225
|
+
log_writer(msg)
|
|
226
|
+
|
|
227
|
+
# write to output
|
|
228
|
+
__write_to_output(msg, output_dev)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def logging_log_debug(msg: str, output_dev: TextIO = None,
|
|
232
|
+
logger: logging.Logger = PYPOMES_LOGGER) -> None:
|
|
233
|
+
"""
|
|
234
|
+
Write debug-level message *msg* to *logger*'s logging file, and to *output_dev*.
|
|
235
|
+
|
|
236
|
+
The output device is tipically *sys.stdout* or *sys.stderr*.
|
|
237
|
+
|
|
238
|
+
:param msg: the message to log
|
|
239
|
+
:param output_dev: output device where the message is to be printed (None for no device printing)
|
|
240
|
+
:param logger: the logger to use
|
|
241
|
+
"""
|
|
242
|
+
# log the message
|
|
243
|
+
logger.debug(msg)
|
|
244
|
+
__write_to_output(msg, output_dev)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def logging_log_info(msg: str, output_dev: TextIO = None,
|
|
248
|
+
logger: logging.Logger = PYPOMES_LOGGER) -> None:
|
|
249
|
+
"""
|
|
250
|
+
Write info-level message *msg* to *logger*'s logging file, and to *output_dev*.
|
|
251
|
+
|
|
252
|
+
The output device is tipically *sys.stdout* or *sys.stderr*.
|
|
253
|
+
|
|
254
|
+
:param msg: the message to log
|
|
255
|
+
:param output_dev: output device where the message is to be printed (None for no device printing)
|
|
256
|
+
:param logger: the logger to use
|
|
257
|
+
"""
|
|
258
|
+
# log the message
|
|
259
|
+
logger.info(msg)
|
|
260
|
+
__write_to_output(msg, output_dev)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def logging_log_warning(msg: str, output_dev: TextIO = None,
|
|
264
|
+
logger: logging.Logger = PYPOMES_LOGGER) -> None:
|
|
265
|
+
"""
|
|
266
|
+
Write warning-level message *msg* to *logger*'s logging file, and to *output_dev*.
|
|
267
|
+
|
|
268
|
+
The output device is tipically *sys.stdout* or *sys.stderr*.
|
|
269
|
+
|
|
270
|
+
:param msg: the message to log
|
|
271
|
+
:param output_dev: output device where the message is to be printed (None for no device printing)
|
|
272
|
+
:param logger: the logger to use
|
|
273
|
+
"""
|
|
274
|
+
# log the message
|
|
275
|
+
logger.warning(msg)
|
|
276
|
+
__write_to_output(msg, output_dev)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def logging_log_error(msg: str, output_dev: TextIO = None,
|
|
280
|
+
logger: logging.Logger = PYPOMES_LOGGER) -> None:
|
|
281
|
+
"""
|
|
282
|
+
Write error-level message *msg* to *logger*'s logging file, and to *output_dev*.
|
|
283
|
+
|
|
284
|
+
The output device is tipically *sys.stdout* or *sys.stderr*.
|
|
285
|
+
|
|
286
|
+
:param msg: the message to log
|
|
287
|
+
:param output_dev: output device where the message is to be printed (None for no device printing)
|
|
288
|
+
:param logger: the logger to use
|
|
289
|
+
"""
|
|
290
|
+
# log the message
|
|
291
|
+
logger.error(msg)
|
|
292
|
+
__write_to_output(msg, output_dev)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def logging_log_critical(msg: str, output_dev: TextIO = None,
|
|
296
|
+
logger: logging.Logger = PYPOMES_LOGGER) -> None:
|
|
297
|
+
"""
|
|
298
|
+
Write critical-level message *msg* to *logger*'s logging file, and to *output_dev*.
|
|
299
|
+
|
|
300
|
+
The output device is tipically *sys.stdout* or *sys.stderr*.
|
|
301
|
+
|
|
302
|
+
:param msg: the message to log
|
|
303
|
+
:param output_dev: output device where the message is to be printed (None for no device printing)
|
|
304
|
+
:param logger: the logger to use
|
|
305
|
+
"""
|
|
306
|
+
# log the message
|
|
307
|
+
logger.critical(msg)
|
|
308
|
+
__write_to_output(msg, output_dev)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def __write_to_output(msg: str, output_dev: TextIO) -> None:
|
|
312
|
+
|
|
313
|
+
# has the output device been defined ?
|
|
314
|
+
if output_dev:
|
|
315
|
+
# yes, write the message to it
|
|
316
|
+
output_dev.write(msg)
|
|
317
|
+
|
|
318
|
+
# is the output device 'stderr' ou 'stdout' ?
|
|
319
|
+
if output_dev.name.startswith("<std"):
|
|
320
|
+
# yes, skip to the next line
|
|
321
|
+
output_dev.write("\n")
|