fb-tools 2.4.2__tar.gz → 2.5.0__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.
Files changed (65) hide show
  1. {fb_tools-2.4.2/lib/fb_tools.egg-info → fb_tools-2.5.0}/PKG-INFO +1 -1
  2. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/__init__.py +3 -1
  3. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/app.py +26 -17
  4. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/cfg_app.py +28 -19
  5. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/common.py +131 -2
  6. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/ddns/__init__.py +190 -162
  7. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/ddns/config.py +137 -83
  8. fb_tools-2.5.0/lib/fb_tools/ddns/errors.py +139 -0
  9. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/ddns/myip_app.py +7 -6
  10. fb_tools-2.5.0/lib/fb_tools/ddns/update_app.py +791 -0
  11. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/errors.py +170 -1
  12. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/handling_obj.py +63 -18
  13. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/local_version.py +1 -1
  14. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/multi_config/__init__.py +4 -2
  15. {fb_tools-2.4.2 → fb_tools-2.5.0/lib/fb_tools.egg-info}/PKG-INFO +1 -1
  16. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools.egg-info/SOURCES.txt +2 -0
  17. fb_tools-2.5.0/locale/de/LC_MESSAGES/fb_tools.mo +0 -0
  18. {fb_tools-2.4.2 → fb_tools-2.5.0}/locale/de/LC_MESSAGES/fb_tools.po +456 -286
  19. fb_tools-2.5.0/locale/en/LC_MESSAGES/fb_tools.mo +0 -0
  20. {fb_tools-2.4.2 → fb_tools-2.5.0}/locale/en/LC_MESSAGES/fb_tools.po +445 -273
  21. {fb_tools-2.4.2 → fb_tools-2.5.0}/locale/fb_tools.pot +442 -270
  22. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/test_00_errors.py +19 -6
  23. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/test_05_common.py +82 -11
  24. fb_tools-2.5.0/test/test_25_ddns_update_status.py +210 -0
  25. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/test_40_app_modules.py +22 -1
  26. fb_tools-2.4.2/lib/fb_tools/ddns/update_app.py +0 -426
  27. fb_tools-2.4.2/locale/de/LC_MESSAGES/fb_tools.mo +0 -0
  28. fb_tools-2.4.2/locale/en/LC_MESSAGES/fb_tools.mo +0 -0
  29. {fb_tools-2.4.2 → fb_tools-2.5.0}/LICENSE +0 -0
  30. {fb_tools-2.4.2 → fb_tools-2.5.0}/MANIFEST.in +0 -0
  31. {fb_tools-2.4.2 → fb_tools-2.5.0}/README.md +0 -0
  32. {fb_tools-2.4.2 → fb_tools-2.5.0}/bin/get-file-to-remove +0 -0
  33. {fb_tools-2.4.2 → fb_tools-2.5.0}/bin/myip +0 -0
  34. {fb_tools-2.4.2 → fb_tools-2.5.0}/bin/update-ddns +0 -0
  35. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/argparse_actions.py +0 -0
  36. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/collections.py +0 -0
  37. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/config.py +0 -0
  38. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/get_file_rm_app.py +0 -0
  39. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/handler/__init__.py +0 -0
  40. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/handler/lock.py +0 -0
  41. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/mailaddress.py +0 -0
  42. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/merge.py +0 -0
  43. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/multi_config/files.py +0 -0
  44. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/multi_config/inits.py +0 -0
  45. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/multi_config/read.py +0 -0
  46. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/obj.py +0 -0
  47. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/pidfile.py +0 -0
  48. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools/xlate.py +0 -0
  49. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools.egg-info/dependency_links.txt +0 -0
  50. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools.egg-info/requires.txt +0 -0
  51. {fb_tools-2.4.2 → fb_tools-2.5.0}/lib/fb_tools.egg-info/top_level.txt +0 -0
  52. {fb_tools-2.4.2 → fb_tools-2.5.0}/setup.cfg +0 -0
  53. {fb_tools-2.4.2 → fb_tools-2.5.0}/setup.py +0 -0
  54. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/call_script.sh +0 -0
  55. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/call_sleep.sh +0 -0
  56. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/do_sleep +0 -0
  57. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/general.py +0 -0
  58. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/test_10_base_object.py +0 -0
  59. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/test_13_collections.py +0 -0
  60. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/test_15_mailaddress.py +0 -0
  61. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/test_17_multicfg.py +0 -0
  62. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/test_18_pidfile.py +0 -0
  63. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/test_20_handling_object.py +0 -0
  64. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/test_30_base_handler.py +0 -0
  65. {fb_tools-2.4.2 → fb_tools-2.5.0}/test/test_33_lock.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fb_tools
3
- Version: 2.4.2
3
+ Version: 2.5.0
4
4
  Summary: Modules for common used objects, error classes and methods.
5
5
  Home-page: https://github.com/fbrehm/python_fb_tools
6
6
  Author: Frank Brehm
@@ -12,8 +12,10 @@ DDNS_CFG_BASENAME = 'ddns.ini'
12
12
  MAX_TIMEOUT = 3600
13
13
  UTF8_ENCODING = 'utf-8'
14
14
  DEFAULT_ENCODING = UTF8_ENCODING
15
+ DEFAULT_TERMINAL_WIDTH = 99
16
+ DEFAULT_TERMINAL_HEIGHT = 40
15
17
 
16
- __version__ = '2.4.2'
18
+ __version__ = '2.5.0'
17
19
 
18
20
  from .mailaddress import MailAddress, QualifiedMailAddress, MailAddressList # noqa
19
21
  from .multi_config import BaseMultiConfig # noqa
@@ -38,7 +38,7 @@ from .xlate import __lib_dir__ as __xlate_lib_dir__
38
38
  from .xlate import __mo_file__ as __xlate_mo_file__
39
39
  from .xlate import __module_dir__ as __xlate_module_dir__
40
40
 
41
- __version__ = '2.2.1'
41
+ __version__ = '2.2.2'
42
42
  LOG = logging.getLogger(__name__)
43
43
 
44
44
  SIGNAL_NAMES = {
@@ -353,6 +353,30 @@ class BaseApplication(HandlingObject):
353
353
 
354
354
  return res
355
355
 
356
+ # -------------------------------------------------------------------------
357
+ def _get_log_formatter(self, is_term=True):
358
+
359
+ # create formatter
360
+ if is_term:
361
+ format_str = ''
362
+ if self.verbose > 1:
363
+ format_str = '[%(asctime)s]: '
364
+ format_str += self.appname + ': '
365
+ else:
366
+ format_str = '[%(asctime)s]: ' + self.appname + ': '
367
+ if self.verbose:
368
+ if self.verbose > 1:
369
+ format_str += '%(name)s(%(lineno)d) %(funcName)s() '
370
+ else:
371
+ format_str += '%(name)s '
372
+ format_str += '%(levelname)s - %(message)s'
373
+ if is_term and self.terminal_has_colors:
374
+ formatter = ColoredFormatter(format_str)
375
+ else:
376
+ formatter = logging.Formatter(format_str)
377
+
378
+ return formatter
379
+
356
380
  # -------------------------------------------------------------------------
357
381
  def init_logging(self):
358
382
  """
@@ -376,22 +400,7 @@ class BaseApplication(HandlingObject):
376
400
  root_logger = logging.getLogger()
377
401
  root_logger.setLevel(root_loglevel)
378
402
 
379
- # create formatter
380
- format_str = ''
381
- if self.verbose:
382
- format_str = '[%(asctime)s]: '
383
- format_str += self.appname + ': '
384
- if self.verbose:
385
- if self.verbose > 1:
386
- format_str += '%(name)s(%(lineno)d) %(funcName)s() '
387
- else:
388
- format_str += '%(name)s '
389
- format_str += '%(levelname)s - %(message)s'
390
- formatter = None
391
- if self.terminal_has_colors:
392
- formatter = ColoredFormatter(format_str)
393
- else:
394
- formatter = logging.Formatter(format_str)
403
+ formatter = self._get_log_formatter()
395
404
 
396
405
  # create log handler for console output
397
406
  lh_console = logging.StreamHandler(sys.stderr)
@@ -27,11 +27,17 @@ from .argparse_actions import CfgFileOptionAction
27
27
  from .argparse_actions import LogFileOptionAction
28
28
  # from .error import FbCfgAppError
29
29
  from .common import pp, to_bool
30
+ from .errors import CommonPathError
31
+ from .errors import DirectoryAccessError
32
+ from .errors import DirectoryNotDirError
33
+ from .errors import DirectoryNotExistsError
34
+ from .errors import FileAccessError
35
+ from .errors import FileNotRegularFileError
30
36
  from .errors import MultiConfigError
31
37
  from .multi_config import BaseMultiConfig
32
38
  from .xlate import XLATOR
33
39
 
34
- __version__ = '2.2.5'
40
+ __version__ = '2.3.1'
35
41
  LOG = logging.getLogger(__name__)
36
42
 
37
43
 
@@ -42,6 +48,8 @@ _ = XLATOR.gettext
42
48
  class FbConfigApplication(BaseApplication):
43
49
  """Class for configured application objects."""
44
50
 
51
+ default_logfile = None
52
+
45
53
  # -------------------------------------------------------------------------
46
54
  def __init__(
47
55
  self, version=__pkg_version__, cfg_class=BaseMultiConfig, initialized=False,
@@ -58,7 +66,7 @@ class FbConfigApplication(BaseApplication):
58
66
  self._use_chardet = True
59
67
  self.use_chardet = use_chardet
60
68
  self._additional_cfg_file = None
61
- self._logfile = None
69
+ self._logfile = self.default_logfile
62
70
  self._cfg_class = cfg_class
63
71
  self._append_appname_to_stems = append_appname_to_stems
64
72
  self._config_dir = config_dir
@@ -139,10 +147,14 @@ class FbConfigApplication(BaseApplication):
139
147
  help=_('Configuration files to use additional to the standard configuration files.'),
140
148
  )
141
149
 
150
+ help_txt = _('A logfile for storing all logging output.')
151
+ if self.default_logfile:
152
+ help_txt += ' ' + _('Default: {!r}.').format(str(self.default_logfile))
153
+
142
154
  cfg_options.add_argument(
143
155
  '--logfile', '--log',
144
156
  metavar=_('FILE'), dest='logfile', action=LogFileOptionAction,
145
- help=_('A logfile for storing all logging output.'),
157
+ help=help_txt,
146
158
  )
147
159
 
148
160
  # -------------------------------------------------------------------------
@@ -201,7 +213,11 @@ class FbConfigApplication(BaseApplication):
201
213
  self.logfile = self.cfg.logfile.resolve()
202
214
 
203
215
  if self.logfile:
204
- self.init_file_logging()
216
+ try:
217
+ self.init_file_logging()
218
+ except CommonPathError as e:
219
+ LOG.error(_('Got a {c}: {e}.').format(c=e.__class__.__name__, e=e))
220
+ self.exit(8)
205
221
 
206
222
  # -------------------------------------------------------------------------
207
223
  def init_file_logging(self):
@@ -209,33 +225,26 @@ class FbConfigApplication(BaseApplication):
209
225
  if not self.logfile:
210
226
  return
211
227
 
228
+ if not self.do_init_logging:
229
+ return
230
+
212
231
  logdir = self.logfile.parent
213
232
 
214
233
  # Checking the parent directory of the Logfile
215
234
  if not logdir.exists():
216
- msg = _('Directory {!r} does not exists.').format(str(logdir))
217
- LOG.error(msg)
218
- self.exit(6)
235
+ raise DirectoryNotExistsError(logdir)
219
236
  if not logdir.is_dir():
220
- msg = _('Path {!r} exists, but is not a directory.').format(str(logdir))
221
- LOG.error(msg)
222
- self.exit(6)
237
+ raise DirectoryNotDirError(logdir)
223
238
 
224
239
  # Checking logfile, if it is already existing
225
240
  if self.logfile.exists():
226
241
  if not self.logfile.is_file():
227
- msg = _('File {!r} is not a regular file.').format(str(self.logfile))
228
- LOG.error(msg)
229
- self.exit(6)
242
+ raise FileNotRegularFileError(self.logfile)
230
243
  if not os.access(self.logfile, os.W_OK):
231
- msg = _('File {!r} is not writeable.').format(self.logfile)
232
- LOG.error(msg)
233
- self.exit(6)
244
+ raise FileAccessError(self.logfile, _('file is not writeable.'))
234
245
  else:
235
246
  if not os.access(logdir, os.W_OK):
236
- msg = _('Directory {!r} is not writeable.').format(str(logdir))
237
- LOG.error(msg)
238
- self.exit(6)
247
+ raise DirectoryAccessError(logdir, _('no write access to directory.'))
239
248
 
240
249
  LOG.debug(_('Start logging into file {!r} ...').format(str(self.logfile)))
241
250
 
@@ -34,10 +34,10 @@ except ImportError:
34
34
  import six
35
35
 
36
36
  # Own modules
37
-
37
+ from .errors import InvalidTimeIntervalError
38
38
  from .xlate import XLATOR
39
39
 
40
- __version__ = '2.0.2'
40
+ __version__ = '2.1.0'
41
41
 
42
42
  _ = XLATOR.gettext
43
43
 
@@ -91,6 +91,18 @@ RE_UNIT_EIBYTES = re.compile(r'^\s*Ei(?:B(?:yte)?)?\s*$', re.IGNORECASE)
91
91
  RE_UNIT_ZBYTES = re.compile(r'^\s*Z(?:B(?:yte)?)?\s*$', re.IGNORECASE)
92
92
  RE_UNIT_ZIBYTES = re.compile(r'^\s*Zi(?:B(?:yte)?)?\s*$', re.IGNORECASE)
93
93
 
94
+ DEBUG_TIMEINTERVAL2DELTA = False
95
+
96
+ RE_UNIT_TIME_SECOND = re.compile(
97
+ r'\s*(\d+(?:\.\d*)?)\s*(?:s(?:ec(?:onds?)?)?)?(?:\s*,)?', re.IGNORECASE)
98
+ RE_UNIT_TIME_MINUTE = re.compile(
99
+ r'\s*(\d+(?:\.\d*)?)\s*m(?:in(?:utes?)?)?(?:\s*,)?', re.IGNORECASE)
100
+ RE_UNIT_TIME_HOUR = re.compile(r'\s*(\d+(?:\.\d*)?)\s*h(?:ours?)?(?:\s*,)?', re.IGNORECASE)
101
+ RE_UNIT_TIME_DAY = re.compile(r'\s*(\d+(?:\.\d*)?)\s*d(?:ays?)?(?:\s*,)?', re.IGNORECASE)
102
+ RE_UNIT_TIME_WEEK = re.compile(r'\s*(\d+(?:\.\d*)?)\s*w(?:eeks?)?(?:\s*,)?', re.IGNORECASE)
103
+ RE_UNIT_TIME_MONTH = re.compile(r'\s*(\d+(?:\.\d*)?)\s*mon(?:ths?)?(?:\s*,)?', re.IGNORECASE)
104
+ RE_UNIT_TIME_YEAR = re.compile(r'\s*(\d+(?:\.\d*)?)\s*y(?:ears?)?(?:\s*,)?', re.IGNORECASE)
105
+
94
106
 
95
107
  # =============================================================================
96
108
  def pp(value, indent=4, width=99, depth=None):
@@ -842,6 +854,123 @@ def indent(text, prefix, initial_prefix=None, predicate=None):
842
854
 
843
855
  return ''.join(lines)
844
856
 
857
+ # =============================================================================
858
+ def set_debug_timeinterval2delta(enable_debug=False):
859
+ """Set the module property DEBUG_TIMEINTERVAL2DELTA to a boolean value."""
860
+ global DEBUG_TIMEINTERVAL2DELTA
861
+
862
+ if to_bool(enable_debug):
863
+ DEBUG_TIMEINTERVAL2DELTA = True
864
+ else:
865
+ DEBUG_TIMEINTERVAL2DELTA = False
866
+
867
+ # =============================================================================
868
+ def timeinterval2delta(interval):
869
+ """
870
+ Try to convert the given textual time interval into a datetime.timeinterval object.
871
+
872
+ The interval may consists of tokens of float values with a measurement unit.
873
+ If a token is a float value without a measurement unit, then seconds are assumed.
874
+ Valid measurement units (as regular expressions) are:
875
+ * 's(ec(onds?)?)?'
876
+ * 'm(in(utes?)?)?' == 60 seconds
877
+ * 'h(ours?)*' == 3600 seconds
878
+ * 'd(ays?)?' == 3600 * 24 seconds
879
+ * 'w(eeks?)?' == 3600 * 24 * 7 seconds
880
+ * 'mon(ths?)?' == 3600 * 24 * 30 seconds
881
+ * 'y(ears?)?' == 3600 * 24 * 365 seconds
882
+
883
+ @raise InvalidTimeIntervalError: if the interval could not be interpreted
884
+ """
885
+ if interval is None:
886
+ raise InvalidTimeIntervalError(None)
887
+ intrvl = str(interval).strip()
888
+ if intrvl == '':
889
+ raise InvalidTimeIntervalError(interval)
890
+
891
+ seconds = 0
892
+
893
+ if DEBUG_TIMEINTERVAL2DELTA:
894
+ LOG.debug('Start value: {!r}'.format(intrvl))
895
+
896
+ m = RE_UNIT_TIME_YEAR.search(intrvl)
897
+ if m:
898
+ years = float(m[1])
899
+ intrvl = RE_UNIT_TIME_YEAR.sub('', intrvl, 1)
900
+ seconds += years * 3600 * 24 * 365
901
+ if DEBUG_TIMEINTERVAL2DELTA:
902
+ LOG.debug(
903
+ 'After year: match {m!r}, years {y!r}, new value {i!r}, new secs {s}.'.format(
904
+ m=m[1], y=years, i=intrvl, s=seconds))
905
+
906
+ m = RE_UNIT_TIME_MONTH.search(intrvl)
907
+ if m:
908
+ months = float(m[1])
909
+ intrvl = RE_UNIT_TIME_MONTH.sub('', intrvl, 1)
910
+ seconds += months * 3600 * 24 * 30
911
+ if DEBUG_TIMEINTERVAL2DELTA:
912
+ LOG.debug(
913
+ 'After months: match {m!r}, months {mo!r}, new value {i!r}, new secs {s}.'.format(
914
+ m=m[1], mo=months, i=intrvl, s=seconds))
915
+
916
+ m = RE_UNIT_TIME_WEEK.search(intrvl)
917
+ if m:
918
+ weeks = float(m[1])
919
+ intrvl = RE_UNIT_TIME_WEEK.sub('', intrvl, 1)
920
+ seconds += weeks * 3600 * 24 * 7
921
+ if DEBUG_TIMEINTERVAL2DELTA:
922
+ LOG.debug(
923
+ 'After weeks: match {m!r}, weeks {w!r}, new value {i!r}, new secs {s}.'.format(
924
+ m=m[1], w=weeks, i=intrvl, s=seconds))
925
+
926
+ m = RE_UNIT_TIME_DAY.search(intrvl)
927
+ if m:
928
+ days = float(m[1])
929
+ intrvl = RE_UNIT_TIME_DAY.sub('', intrvl, 1)
930
+ seconds += days * 3600 * 24
931
+ if DEBUG_TIMEINTERVAL2DELTA:
932
+ LOG.debug(
933
+ 'After days: match {m!r}, days {d!r}, new value {i!r}, new secs {s}.'.format(
934
+ m=m[1], d=days, i=intrvl, s=seconds))
935
+
936
+ m = RE_UNIT_TIME_HOUR.search(intrvl)
937
+ if m:
938
+ hours = float(m[1])
939
+ intrvl = RE_UNIT_TIME_HOUR.sub('', intrvl, 1)
940
+ seconds += hours * 3600
941
+ if DEBUG_TIMEINTERVAL2DELTA:
942
+ LOG.debug(
943
+ 'After hours: match {m!r}, hours {h!r}, new value {i!r}, new secs {s}.'.format(
944
+ m=m[1], h=hours, i=intrvl, s=seconds))
945
+
946
+ m = RE_UNIT_TIME_MINUTE.search(intrvl)
947
+ if m:
948
+ minutes = float(m[1])
949
+ intrvl = RE_UNIT_TIME_MINUTE.sub('', intrvl, 1)
950
+ seconds += minutes * 60
951
+ if DEBUG_TIMEINTERVAL2DELTA:
952
+ LOG.debug(
953
+ 'After minutes: match {m!r}, minutes {mi!r}, new value {i!r}, '
954
+ 'new secs {s}.'.format(m=m[1], mi=minutes, i=intrvl, s=seconds))
955
+
956
+ m = RE_UNIT_TIME_SECOND.search(intrvl)
957
+ if m:
958
+ secs = float(m[1])
959
+ intrvl = RE_UNIT_TIME_SECOND.sub('', intrvl, 1)
960
+ seconds += secs
961
+ if DEBUG_TIMEINTERVAL2DELTA:
962
+ LOG.debug(
963
+ 'After seconds: match {m!r}, seconds {se!r}, new value {i!r}, '
964
+ 'new secs {s}.'.format(m=m[1], se=secs, i=intrvl, s=seconds))
965
+
966
+ intrvl = intrvl.strip()
967
+ if DEBUG_TIMEINTERVAL2DELTA:
968
+ LOG.debug('Final value: {i!r}, seconds at last: {s}'.format(i=intrvl, s=seconds))
969
+ if intrvl != '':
970
+ raise InvalidTimeIntervalError(interval)
971
+
972
+ return datetime.timedelta(seconds=seconds)
973
+
845
974
 
846
975
  # =============================================================================
847
976