Skip to content

Commit 4fac6b0

Browse files
author
Vasileios Karakasis
authored
Merge pull request #1150 from vkarak/feat/log-format-rfc3339
[feat] Support RFC3339 compliant time zone formatting when logging time fields
2 parents 3f5e97f + a99bd86 commit 4fac6b0

File tree

5 files changed

+93
-30
lines changed

5 files changed

+93
-30
lines changed

docs/running.rst

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -849,7 +849,7 @@ All handlers accept the following set of attributes (keys) in their configuratio
849849

850850
* ``level``: (default: ``DEBUG``) The lowest level of log records that this handler can process.
851851
* ``format`` (default: ``'%(message)s'``): Format string for the printout of the log record.
852-
ReFrame supports all the `format strings <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`__ from Python's logging library and provides the following additional ones:
852+
ReFrame supports all the `log record attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`__ from Python's logging library and provides the following additional ones:
853853

854854
- ``check_environ``: The programming environment a test is currently executing for.
855855
- ``check_info``: Print live information of the currently executing check.
@@ -877,15 +877,20 @@ All handlers accept the following set of attributes (keys) in their configuratio
877877
- ``osgroup``: The group name of the OS user running ReFrame.
878878
- ``version``: The ReFrame version.
879879

880-
* ``datefmt`` (default: ``'%FT%T'``) The format that will be used for outputting timestamps (i.e., the ``%(asctime)s`` field).
881-
Acceptable formats must conform to standard library's `time.strftime() <https://docs.python.org/3.6/library/time.html#time.strftime>`__ function.
880+
* ``datefmt`` (default: ``'%FT%T'``) The format that will be used for outputting timestamps (i.e., the ``%(asctime)s`` and the ``%(check_job_completion_time)s`` fields).
881+
In addition to the format directives supported by the standard library's `time.strftime() <https://docs.python.org/3.6/library/time.html#time.strftime>`__ function, ReFrame allows you to use the ``%:z`` directive -- a GNU ``date`` extension -- that will print the time zone difference in a RFC3339 compliant way, i.e., ``+/-HH:MM`` instead of ``+/-HHMM``.
882882

883883
.. caution::
884884
The ``testcase_name`` logging attribute is replaced with the ``check_info``, which is now also configurable
885885

886886
.. versionchanged:: 2.10
887887

888888

889+
.. note::
890+
Support for fully RFC3339 compliant time zone formatting.
891+
892+
.. versionadded:: 3.0
893+
889894

890895
File log handlers
891896
"""""""""""""""""

reframe/core/logging.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
import numbers
66
import os
77
import pprint
8+
import re
89
import shutil
910
import sys
1011
import socket
11-
from datetime import datetime
12+
import time
1213

1314
import reframe
1415
import reframe.utility.color as color
@@ -132,6 +133,34 @@ def close(self):
132133
super().close()
133134

134135

136+
def _format_time_rfc3339(timestamp, datefmt):
137+
tz_suffix = time.strftime('%z', timestamp)
138+
tz_rfc3339 = tz_suffix[:-2] + ':' + tz_suffix[-2:]
139+
140+
# Python < 3.7 truncates the `%`, whereas later versions don't
141+
return re.sub(r'(%)?\:z', tz_rfc3339, time.strftime(datefmt, timestamp))
142+
143+
144+
class RFC3339Formatter(logging.Formatter):
145+
def formatTime(self, record, datefmt=None):
146+
datefmt = datefmt or self.default_time_format
147+
if '%:z' not in datefmt:
148+
return super().formatTime(record, datefmt)
149+
else:
150+
timestamp = self.converter(record.created)
151+
return _format_time_rfc3339(timestamp, datefmt)
152+
153+
def format(self, record):
154+
datefmt = self.datefmt or self.default_time_format
155+
if record.check_job_completion_time is not None:
156+
ct = self.converter(record.check_job_completion_time)
157+
record.check_job_completion_time = _format_time_rfc3339(
158+
ct, datefmt
159+
)
160+
161+
return super().format(record)
162+
163+
135164
def load_from_dict(logging_config):
136165
if not isinstance(logging_config, collections.abc.Mapping):
137166
raise TypeError('logging configuration is not a dict')
@@ -177,8 +206,7 @@ def _create_file_handler(handler_config):
177206
timestamp = handler_config.get('timestamp', None)
178207
if timestamp:
179208
basename, ext = os.path.splitext(filename)
180-
filename = '%s_%s%s' % (basename,
181-
datetime.now().strftime(timestamp), ext)
209+
filename = '%s_%s%s' % (basename, time.strftime(timestamp), ext)
182210

183211
append = handler_config.get('append', False)
184212
return logging.handlers.RotatingFileHandler(filename,
@@ -305,7 +333,7 @@ def _extract_handlers(handlers_list):
305333
level = handler_config.get('level', 'debug').lower()
306334
fmt = handler_config.get('format', '%(message)s')
307335
datefmt = handler_config.get('datefmt', '%FT%T')
308-
hdlr.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
336+
hdlr.setFormatter(RFC3339Formatter(fmt=fmt, datefmt=datefmt))
309337
hdlr.setLevel(_check_level(level))
310338
handlers.append(hdlr)
311339

@@ -428,11 +456,7 @@ def _update_check_extras(self):
428456
if self.check.job:
429457
self.extra['check_jobid'] = self.check.job.jobid
430458
if self.check.job.completion_time:
431-
# Use the logging handlers' date format to format
432-
# completion_time
433-
# NOTE: All handlers use the same date format
434-
fmt = self.logger.handlers[0].formatter.datefmt
435-
ct = self.check.job.completion_time.strftime(fmt)
459+
ct = self.check.job.completion_time
436460
self.extra['check_job_completion_time'] = ct
437461

438462
def log_performance(self, level, tag, value, ref,

reframe/core/schedulers/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#
44

55
import abc
6-
from datetime import datetime
6+
import time
77

88
import reframe.core.environments as env
99
import reframe.core.fields as fields
@@ -17,6 +17,8 @@
1717
class JobScheduler(abc.ABC):
1818
@abc.abstractmethod
1919
def completion_time(self, job):
20+
'''The completion time of this job expressed in seconds from the Epoch.
21+
'''
2022
pass
2123

2224
@abc.abstractmethod
@@ -332,7 +334,7 @@ def wait(self):
332334
raise JobNotStartedError('cannot wait an unstarted job')
333335

334336
self.scheduler.wait(self)
335-
self._completion_time = self._completion_time or datetime.now()
337+
self._completion_time = self._completion_time or time.time()
336338

337339
def cancel(self):
338340
if self.jobid is None:
@@ -346,7 +348,7 @@ def finished(self):
346348

347349
done = self.scheduler.finished(self)
348350
if done:
349-
self._completion_time = self._completion_time or datetime.now()
351+
self._completion_time = self._completion_time or time.time()
350352

351353
return done
352354

reframe/core/schedulers/slurm.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def completion_time(self, job):
103103
not slurm_state_completed(job.state)):
104104
return self._completion_time
105105

106-
with env.temp_environment(variables={'SLURM_TIME_FORMAT': 'standard'}):
106+
with env.temp_environment(variables={'SLURM_TIME_FORMAT': '%s'}):
107107
completed = os_ext.run_command(
108108
'sacct -S %s -P -j %s -o jobid,end' %
109109
(datetime.now().strftime('%F'), job.jobid),
@@ -116,11 +116,7 @@ def completion_time(self, job):
116116
if not state_match:
117117
return None
118118

119-
self._completion_time = max(
120-
datetime.strptime(s.group('end'), '%Y-%m-%dT%H:%M:%S')
121-
for s in state_match
122-
)
123-
119+
self._completion_time = max(float(s.group('end')) for s in state_match)
124120
return self._completion_time
125121

126122
def _format_option(self, var, option):

unittests/test_logging.py

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,42 @@
22
import logging.handlers
33
import os
44
import sys
5+
import re
56
import tempfile
7+
import time
68
import unittest
79
from datetime import datetime
810

911
import reframe as rfm
1012
import reframe.core.logging as rlog
1113
from reframe.core.exceptions import ConfigError, ReframeError
14+
from reframe.core.launchers.registry import getlauncher
15+
from reframe.core.schedulers import Job
16+
from reframe.core.schedulers.registry import getscheduler
1217

1318

14-
class RandomCheck(rfm.RegressionTest):
19+
class _FakeCheck(rfm.RegressionTest):
1520
pass
1621

1722

23+
def _setup_fake_check():
24+
# A bit hacky, but we don't want to run a full test every time
25+
test = _FakeCheck()
26+
test._job = Job.create(getscheduler('local')(),
27+
getlauncher('local')(),
28+
'fakejob')
29+
test.job._completion_time = time.time()
30+
return test
31+
32+
1833
class TestLogger(unittest.TestCase):
1934
def setUp(self):
2035
tmpfd, self.logfile = tempfile.mkstemp()
2136
os.close(tmpfd)
2237

2338
self.logger = rlog.Logger('reframe')
2439
self.handler = logging.handlers.RotatingFileHandler(self.logfile)
25-
self.formatter = logging.Formatter(
40+
self.formatter = rlog.RFC3339Formatter(
2641
fmt='[%(asctime)s] %(levelname)s: %(check_name)s: %(message)s',
2742
datefmt='%FT%T')
2843

@@ -33,15 +48,16 @@ def setUp(self):
3348
self.logger_without_check = rlog.LoggerAdapter(self.logger)
3449

3550
# Logger adapter with an associated check
36-
self.logger_with_check = rlog.LoggerAdapter(self.logger, RandomCheck())
51+
self.logger_with_check = rlog.LoggerAdapter(self.logger,
52+
_setup_fake_check())
3753

3854
def tearDown(self):
3955
os.remove(self.logfile)
4056

41-
def found_in_logfile(self, string):
57+
def found_in_logfile(self, pattern):
4258
found = False
43-
with open(self.logfile, 'rt') as f:
44-
found = string in f.read()
59+
with open(self.logfile, 'rt') as fp:
60+
found = re.search(pattern, fp.read()) is not None
4561

4662
return found
4763

@@ -65,7 +81,7 @@ def test_check_logger(self):
6581
self.assertTrue(os.path.exists(self.logfile))
6682
self.assertTrue(self.found_in_logfile('info'))
6783
self.assertTrue(self.found_in_logfile('verbose'))
68-
self.assertTrue(self.found_in_logfile('RandomCheck'))
84+
self.assertTrue(self.found_in_logfile('_FakeCheck'))
6985

7086
def test_handler_types(self):
7187
self.assertTrue(issubclass(logging.Handler, rlog.Handler))
@@ -97,6 +113,26 @@ def test_logger_levels(self):
97113
self.assertFalse(self.found_in_logfile('bar'))
98114
self.assertTrue(self.found_in_logfile('foo'))
99115

116+
def test_rfc3339_timezone_extension(self):
117+
self.formatter = rlog.RFC3339Formatter(
118+
fmt=('[%(asctime)s] %(levelname)s: %(check_name)s: '
119+
'ct:%(check_job_completion_time)s: %(message)s'),
120+
datefmt='%FT%T%:z')
121+
self.handler.setFormatter(self.formatter)
122+
self.logger_with_check.info('foo')
123+
self.logger_without_check.info('foo')
124+
assert not self.found_in_logfile(r'%%:z')
125+
assert self.found_in_logfile(r'\[.+(\+|-)\d\d:\d\d\]')
126+
assert self.found_in_logfile(r'ct:.+(\+|-)\d\d:\d\d')
127+
128+
def test_rfc3339_timezone_wrong_directive(self):
129+
self.formatter = rlog.RFC3339Formatter(
130+
fmt='[%(asctime)s] %(levelname)s: %(check_name)s: %(message)s',
131+
datefmt='%FT%T:z')
132+
self.handler.setFormatter(self.formatter)
133+
self.logger_without_check.info('foo')
134+
assert self.found_in_logfile(':z')
135+
100136

101137
class TestLoggingConfiguration(unittest.TestCase):
102138
def setUp(self):
@@ -116,7 +152,7 @@ def setUp(self):
116152
}
117153
]
118154
}
119-
self.check = RandomCheck()
155+
self.check = _FakeCheck()
120156

121157
def tearDown(self):
122158
if os.path.exists(self.logfile):
@@ -403,7 +439,7 @@ def test_logging_context_check(self):
403439

404440
rlog.getlogger().error('error outside context')
405441
self.assertTrue(self.found_in_logfile(
406-
'RandomCheck: %s: error from context' % sys.argv[0]))
442+
'_FakeCheck: %s: error from context' % sys.argv[0]))
407443
self.assertTrue(self.found_in_logfile(
408444
'reframe: %s: error outside context' % sys.argv[0]))
409445

0 commit comments

Comments
 (0)