meerschaum 2.2.7__py3-none-any.whl → 2.3.0.dev1__py3-none-any.whl

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 (50) hide show
  1. meerschaum/__init__.py +4 -1
  2. meerschaum/_internal/arguments/_parser.py +44 -15
  3. meerschaum/_internal/entry.py +22 -1
  4. meerschaum/_internal/shell/Shell.py +129 -31
  5. meerschaum/actions/api.py +12 -12
  6. meerschaum/actions/attach.py +95 -0
  7. meerschaum/actions/delete.py +35 -26
  8. meerschaum/actions/show.py +119 -148
  9. meerschaum/actions/start.py +85 -75
  10. meerschaum/actions/stop.py +68 -39
  11. meerschaum/api/_events.py +18 -1
  12. meerschaum/api/_oauth2.py +2 -0
  13. meerschaum/api/_websockets.py +2 -2
  14. meerschaum/api/dash/jobs.py +5 -2
  15. meerschaum/api/routes/__init__.py +1 -0
  16. meerschaum/api/routes/_actions.py +122 -44
  17. meerschaum/api/routes/_jobs.py +340 -0
  18. meerschaum/api/routes/_pipes.py +5 -5
  19. meerschaum/config/_default.py +1 -0
  20. meerschaum/config/_paths.py +1 -0
  21. meerschaum/config/_shell.py +8 -3
  22. meerschaum/config/_version.py +1 -1
  23. meerschaum/config/static/__init__.py +8 -0
  24. meerschaum/connectors/__init__.py +9 -11
  25. meerschaum/connectors/api/APIConnector.py +18 -1
  26. meerschaum/connectors/api/_actions.py +60 -71
  27. meerschaum/connectors/api/_jobs.py +260 -0
  28. meerschaum/connectors/parse.py +23 -7
  29. meerschaum/plugins/__init__.py +89 -5
  30. meerschaum/utils/daemon/Daemon.py +255 -30
  31. meerschaum/utils/daemon/FileDescriptorInterceptor.py +5 -5
  32. meerschaum/utils/daemon/RotatingFile.py +10 -6
  33. meerschaum/utils/daemon/StdinFile.py +110 -0
  34. meerschaum/utils/daemon/__init__.py +13 -7
  35. meerschaum/utils/formatting/__init__.py +2 -1
  36. meerschaum/utils/formatting/_jobs.py +83 -54
  37. meerschaum/utils/formatting/_shell.py +6 -0
  38. meerschaum/utils/jobs/_Job.py +684 -0
  39. meerschaum/utils/jobs/__init__.py +245 -0
  40. meerschaum/utils/misc.py +18 -17
  41. meerschaum/utils/packages/_packages.py +2 -2
  42. meerschaum/utils/prompt.py +16 -8
  43. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev1.dist-info}/METADATA +9 -9
  44. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev1.dist-info}/RECORD +50 -44
  45. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev1.dist-info}/WHEEL +1 -1
  46. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev1.dist-info}/LICENSE +0 -0
  47. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev1.dist-info}/NOTICE +0 -0
  48. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev1.dist-info}/entry_points.txt +0 -0
  49. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev1.dist-info}/top_level.txt +0 -0
  50. {meerschaum-2.2.7.dist-info → meerschaum-2.3.0.dev1.dist-info}/zip-safe +0 -0
@@ -8,6 +8,7 @@ Manage running daemons via the Daemon class.
8
8
 
9
9
  from __future__ import annotations
10
10
  import os
11
+ import importlib
11
12
  import pathlib
12
13
  import json
13
14
  import shutil
@@ -21,7 +22,7 @@ from datetime import datetime, timezone
21
22
  import meerschaum as mrsm
22
23
  from meerschaum.utils.typing import (
23
24
  Optional, Dict, Any, SuccessTuple, Callable, List, Union,
24
- is_success_tuple,
25
+ is_success_tuple, Tuple,
25
26
  )
26
27
  from meerschaum.config import get_config
27
28
  from meerschaum.config.static import STATIC_CONFIG
@@ -34,6 +35,7 @@ from meerschaum.utils.packages import attempt_import
34
35
  from meerschaum.utils.venv import venv_exec
35
36
  from meerschaum.utils.daemon._names import get_new_daemon_name
36
37
  from meerschaum.utils.daemon.RotatingFile import RotatingFile
38
+ from meerschaum.utils.daemon.StdinFile import StdinFile
37
39
  from meerschaum.utils.threading import RepeatTimer
38
40
  from meerschaum.__main__ import _close_pools
39
41
 
@@ -43,6 +45,32 @@ _results = {}
43
45
  class Daemon:
44
46
  """
45
47
  Daemonize Python functions into background processes.
48
+
49
+ Examples
50
+ --------
51
+ >>> import meerschaum as mrsm
52
+ >>> from meerschaum.utils.daemons import Daemon
53
+ >>> daemon = Daemon(print, ('hi',))
54
+ >>> success, msg = daemon.run()
55
+ >>> print(daemon.log_text)
56
+
57
+ 2024-07-29 18:03 | hi
58
+ 2024-07-29 18:03 |
59
+ >>> daemon.run(allow_dirty_run=True)
60
+ >>> print(daemon.log_text)
61
+
62
+ 2024-07-29 18:03 | hi
63
+ 2024-07-29 18:03 |
64
+ 2024-07-29 18:05 | hi
65
+ 2024-07-29 18:05 |
66
+ >>> mrsm.pprint(daemon.properties)
67
+ {
68
+ 'label': 'print',
69
+ 'target': {'name': 'print', 'module': 'builtins', 'args': ['hi'], 'kw': {}},
70
+ 'result': None,
71
+ 'process': {'ended': '2024-07-29T18:03:33.752806'}
72
+ }
73
+
46
74
  """
47
75
 
48
76
  def __new__(
@@ -61,10 +89,57 @@ class Daemon:
61
89
  instance = instance.read_pickle()
62
90
  return instance
63
91
 
92
+ @classmethod
93
+ def from_properties_file(cls, daemon_id: str) -> Daemon:
94
+ """
95
+ Return a Daemon from a properties dictionary.
96
+ """
97
+ properties_path = cls._get_properties_path_from_daemon_id(daemon_id)
98
+ if not properties_path.exists():
99
+ raise OSError(f"Properties file '{properties_path}' does not exist.")
100
+
101
+ try:
102
+ with open(properties_path, 'r', encoding='utf-8') as f:
103
+ properties = json.load(f)
104
+ except Exception:
105
+ properties = {}
106
+
107
+ if not properties:
108
+ raise ValueError(f"No properties could be read for daemon '{daemon_id}'.")
109
+
110
+ daemon_id = properties_path.parent.name
111
+ target_cf = properties.get('target', {})
112
+ target_module_name = target_cf.get('module', None)
113
+ target_function_name = target_cf.get('name', None)
114
+ target_args = target_cf.get('args', None)
115
+ target_kw = target_cf.get('kw', None)
116
+ label = properties.get('label', None)
117
+
118
+ if None in [
119
+ target_module_name,
120
+ target_function_name,
121
+ target_args,
122
+ target_kw,
123
+ ]:
124
+ raise ValueError("Missing target function information.")
125
+
126
+ target_module = importlib.import_module(target_module_name)
127
+ target_function = getattr(target_module, target_function_name)
128
+
129
+ return Daemon(
130
+ daemon_id=daemon_id,
131
+ target=target_function,
132
+ target_args=target_args,
133
+ target_kw=target_kw,
134
+ properties=properties,
135
+ label=label,
136
+ )
137
+
138
+
64
139
  def __init__(
65
140
  self,
66
141
  target: Optional[Callable[[Any], Any]] = None,
67
- target_args: Optional[List[str]] = None,
142
+ target_args: Union[List[Any], Tuple[Any], None] = None,
68
143
  target_kw: Optional[Dict[str, Any]] = None,
69
144
  daemon_id: Optional[str] = None,
70
145
  label: Optional[str] = None,
@@ -76,7 +151,7 @@ class Daemon:
76
151
  target: Optional[Callable[[Any], Any]], default None,
77
152
  The function to execute in a child process.
78
153
 
79
- target_args: Optional[List[str]], default None
154
+ target_args: Union[List[Any], Tuple[Any], None], default None
80
155
  Positional arguments to pass to the target function.
81
156
 
82
157
  target_kw: Optional[Dict[str, Any]], default None
@@ -91,25 +166,54 @@ class Daemon:
91
166
  Label string to help identifiy a daemon.
92
167
  If `None`, use the function name instead.
93
168
 
94
- propterties: Optional[Dict[str, Any]], default None
169
+ properties: Optional[Dict[str, Any]], default None
95
170
  Override reading from the properties JSON by providing an existing dictionary.
96
171
  """
97
172
  _pickle = self.__dict__.get('_pickle', False)
98
173
  if daemon_id is not None:
99
174
  self.daemon_id = daemon_id
100
175
  if not self.pickle_path.exists() and not target and ('target' not in self.__dict__):
101
- raise Exception(
102
- f"Daemon '{self.daemon_id}' does not exist. "
103
- + "Pass a target to create a new Daemon."
104
- )
176
+
177
+ if not self.properties_path.exists():
178
+ raise Exception(
179
+ f"Daemon '{self.daemon_id}' does not exist. "
180
+ + "Pass a target to create a new Daemon."
181
+ )
182
+
183
+ try:
184
+ new_daemon = self.from_properties_file(daemon_id)
185
+ except Exception:
186
+ new_daemon = None
187
+
188
+ if new_daemon is not None:
189
+ new_daemon.write_pickle()
190
+ target = new_daemon.target
191
+ target_args = new_daemon.target_args
192
+ target_kw = new_daemon.target_kw
193
+ label = new_daemon.label
194
+ self._properties = new_daemon.properties
195
+ else:
196
+ try:
197
+ self.properties_path.unlink()
198
+ except Exception:
199
+ pass
200
+
201
+ raise Exception(
202
+ f"Could not recover daemon '{self.daemon_id}' "
203
+ + "from its properties file."
204
+ )
205
+
105
206
  if 'target' not in self.__dict__:
106
207
  if target is None:
107
208
  error("Cannot create a Daemon without a target.")
108
209
  self.target = target
109
- if 'target_args' not in self.__dict__:
110
- self.target_args = target_args if target_args is not None else []
111
- if 'target_kw' not in self.__dict__:
112
- self.target_kw = target_kw if target_kw is not None else {}
210
+
211
+ ### NOTE: We have to check self.__dict__ in case we un-pickling.
212
+ if '_target_args' not in self.__dict__:
213
+ self._target_args = target_args
214
+ if '_target_kw' not in self.__dict__:
215
+ self._target_kw = target_kw
216
+
113
217
  if 'label' not in self.__dict__:
114
218
  if label is None:
115
219
  label = (
@@ -188,6 +292,7 @@ class Daemon:
188
292
  try:
189
293
  os.environ['LINES'], os.environ['COLUMNS'] = str(int(lines)), str(int(columns))
190
294
  with self._daemon_context:
295
+ sys.stdin = self.stdin_file
191
296
  os.environ[STATIC_CONFIG['environment']['daemon_id']] = self.daemon_id
192
297
  self.rotating_log.refresh_files(start_interception=True)
193
298
  result = None
@@ -197,7 +302,7 @@ class Daemon:
197
302
 
198
303
  self._log_refresh_timer.start()
199
304
  self.properties['result'] = None
200
- self.write_properties()
305
+ self._capture_process_timestamp('began')
201
306
  result = self.target(*self.target_args, **self.target_kw)
202
307
  self.properties['result'] = result
203
308
  except (BrokenPipeError, KeyboardInterrupt, SystemExit):
@@ -250,7 +355,7 @@ class Daemon:
250
355
  if 'process' not in self.properties:
251
356
  self.properties['process'] = {}
252
357
 
253
- if process_key not in ('began', 'ended', 'paused'):
358
+ if process_key not in ('began', 'ended', 'paused', 'stopped'):
254
359
  raise ValueError(f"Invalid key '{process_key}'.")
255
360
 
256
361
  self.properties['process'][process_key] = (
@@ -290,6 +395,10 @@ class Daemon:
290
395
  if self.status == 'paused':
291
396
  return self.resume()
292
397
 
398
+ self._remove_stop_file()
399
+ if self.status == 'running':
400
+ return True, f"Daemon '{self}' is already running."
401
+
293
402
  self.mkdir_if_not_exists(allow_dirty_run)
294
403
  _write_pickle_success_tuple = self.write_pickle()
295
404
  if not _write_pickle_success_tuple[0]:
@@ -312,7 +421,8 @@ class Daemon:
312
421
  return _launch_success_bool, msg
313
422
 
314
423
  def kill(self, timeout: Union[int, float, None] = 8) -> SuccessTuple:
315
- """Forcibly terminate a running daemon.
424
+ """
425
+ Forcibly terminate a running daemon.
316
426
  Sends a SIGTERM signal to the process.
317
427
 
318
428
  Parameters
@@ -327,9 +437,11 @@ class Daemon:
327
437
  if self.status != 'paused':
328
438
  success, msg = self._send_signal(signal.SIGTERM, timeout=timeout)
329
439
  if success:
440
+ self._write_stop_file('kill')
330
441
  return success, msg
331
442
 
332
443
  if self.status == 'stopped':
444
+ self._write_stop_file('kill')
333
445
  return True, "Process has already stopped."
334
446
 
335
447
  process = self.process
@@ -345,13 +457,18 @@ class Daemon:
345
457
  self.pid_path.unlink()
346
458
  except Exception as e:
347
459
  pass
460
+
461
+ self._write_stop_file('kill')
348
462
  return True, "Success"
349
463
 
350
464
  def quit(self, timeout: Union[int, float, None] = None) -> SuccessTuple:
351
465
  """Gracefully quit a running daemon."""
352
466
  if self.status == 'paused':
353
467
  return self.kill(timeout)
468
+
354
469
  signal_success, signal_msg = self._send_signal(signal.SIGINT, timeout=timeout)
470
+ if signal_success:
471
+ self._write_stop_file('quit')
355
472
  return signal_success, signal_msg
356
473
 
357
474
  def pause(
@@ -380,6 +497,7 @@ class Daemon:
380
497
  if self.status == 'paused':
381
498
  return True, f"Daemon '{self.daemon_id}' is already paused."
382
499
 
500
+ self._write_stop_file('pause')
383
501
  try:
384
502
  self.process.suspend()
385
503
  except Exception as e:
@@ -418,10 +536,10 @@ class Daemon:
418
536
  )
419
537
 
420
538
  def resume(
421
- self,
422
- timeout: Union[int, float, None] = None,
423
- check_timeout_interval: Union[float, int, None] = None,
424
- ) -> SuccessTuple:
539
+ self,
540
+ timeout: Union[int, float, None] = None,
541
+ check_timeout_interval: Union[float, int, None] = None,
542
+ ) -> SuccessTuple:
425
543
  """
426
544
  Resume the daemon if it is paused.
427
545
 
@@ -443,6 +561,7 @@ class Daemon:
443
561
  if self.status == 'stopped':
444
562
  return False, f"Daemon '{self.daemon_id}' is stopped and cannot be resumed."
445
563
 
564
+ self._remove_stop_file()
446
565
  try:
447
566
  self.process.resume()
448
567
  except Exception as e:
@@ -472,6 +591,33 @@ class Daemon:
472
591
  + ('s' if timeout != 1 else '') + '.'
473
592
  )
474
593
 
594
+ def _write_stop_file(self, action: str) -> SuccessTuple:
595
+ """Write the stop file timestamp and action."""
596
+ if action not in ('quit', 'kill', 'pause'):
597
+ return False, f"Unsupported action '{action}'."
598
+
599
+ with open(self.stop_path, 'w+', encoding='utf-8') as f:
600
+ json.dump(
601
+ {
602
+ 'stop_time': datetime.now(timezone.utc).isoformat(),
603
+ 'action': action,
604
+ },
605
+ f
606
+ )
607
+
608
+ return True, "Success"
609
+
610
+ def _remove_stop_file(self) -> SuccessTuple:
611
+ """Remove the stop file"""
612
+ if not self.stop_path.exists():
613
+ return True, "Stop file does not exist."
614
+
615
+ try:
616
+ self.stop_path.unlink()
617
+ except Exception as e:
618
+ return False, f"Failed to remove stop file:\n{e}"
619
+
620
+ return True, "Success"
475
621
 
476
622
  def _handle_sigterm(self, signal_number: int, stack_frame: 'frame') -> None:
477
623
  """
@@ -638,6 +784,13 @@ class Daemon:
638
784
  """
639
785
  return self._get_properties_path_from_daemon_id(self.daemon_id)
640
786
 
787
+ @property
788
+ def stop_path(self) -> pathlib.Path:
789
+ """
790
+ Return the path for the stop file (created when manually stopped).
791
+ """
792
+ return self.path / '.stop.json'
793
+
641
794
  @property
642
795
  def log_path(self) -> pathlib.Path:
643
796
  """
@@ -645,6 +798,20 @@ class Daemon:
645
798
  """
646
799
  return LOGS_RESOURCES_PATH / (self.daemon_id + '.log')
647
800
 
801
+ @property
802
+ def stdin_file_path(self) -> pathlib.Path:
803
+ """
804
+ Return the stdin file path.
805
+ """
806
+ return self.path / 'input.stdin'
807
+
808
+ @property
809
+ def blocking_stdin_file_path(self) -> pathlib.Path:
810
+ """
811
+ Return the stdin file path.
812
+ """
813
+ return self.path / 'input.stdin.block'
814
+
648
815
  @property
649
816
  def log_offset_path(self) -> pathlib.Path:
650
817
  """
@@ -654,20 +821,44 @@ class Daemon:
654
821
 
655
822
  @property
656
823
  def rotating_log(self) -> RotatingFile:
824
+ """
825
+ The rotating log file for the daemon's output.
826
+ """
657
827
  if '_rotating_log' in self.__dict__:
658
828
  return self._rotating_log
659
829
 
830
+ write_timestamps = (
831
+ self.properties.get('logs', {}).get('write_timestamps', None)
832
+ )
833
+ if write_timestamps is None:
834
+ write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled')
835
+
660
836
  self._rotating_log = RotatingFile(
661
837
  self.log_path,
662
- redirect_streams = True,
663
- write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled'),
664
- timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format'),
838
+ redirect_streams=True,
839
+ write_timestamps=write_timestamps,
840
+ timestamp_format=get_config('jobs', 'logs', 'timestamps', 'format'),
665
841
  )
666
842
  return self._rotating_log
667
843
 
844
+ @property
845
+ def stdin_file(self):
846
+ """
847
+ Return the file handler for the stdin file.
848
+ """
849
+ if '_stdin_file' in self.__dict__:
850
+ return self._stdin_file
851
+
852
+ self._stdin_file = StdinFile(
853
+ self.stdin_file_path,
854
+ lock_file_path=self.blocking_stdin_file_path,
855
+ )
856
+ return self._stdin_file
857
+
668
858
  @property
669
859
  def log_text(self) -> Optional[str]:
670
- """Read the log files and return their contents.
860
+ """
861
+ Read the log files and return their contents.
671
862
  Returns `None` if the log file does not exist.
672
863
  """
673
864
  new_rotating_log = RotatingFile(
@@ -717,7 +908,8 @@ class Daemon:
717
908
 
718
909
  @property
719
910
  def pid(self) -> Union[int, None]:
720
- """Read the PID file and return its contents.
911
+ """
912
+ Read the PID file and return its contents.
721
913
  Returns `None` if the PID file does not exist.
722
914
  """
723
915
  if not self.pid_path.exists():
@@ -725,6 +917,8 @@ class Daemon:
725
917
  try:
726
918
  with open(self.pid_path, 'r', encoding='utf-8') as f:
727
919
  text = f.read()
920
+ if len(text) == 0:
921
+ return None
728
922
  pid = int(text.rstrip())
729
923
  except Exception as e:
730
924
  warn(e)
@@ -764,17 +958,21 @@ class Daemon:
764
958
  return None
765
959
  try:
766
960
  with open(self.properties_path, 'r', encoding='utf-8') as file:
767
- return json.load(file)
961
+ properties = json.load(file)
768
962
  except Exception as e:
769
- return {}
963
+ properties = {}
964
+
965
+ return properties
770
966
 
771
967
  def read_pickle(self) -> Daemon:
772
968
  """Read a Daemon's pickle file and return the `Daemon`."""
773
969
  import pickle, traceback
774
970
  if not self.pickle_path.exists():
775
971
  error(f"Pickle file does not exist for daemon '{self.daemon_id}'.")
972
+
776
973
  if self.pickle_path.stat().st_size == 0:
777
974
  error(f"Pickle was empty for daemon '{self.daemon_id}'.")
975
+
778
976
  try:
779
977
  with open(self.pickle_path, 'rb') as pickle_file:
780
978
  daemon = pickle.load(pickle_file)
@@ -921,9 +1119,9 @@ class Daemon:
921
1119
 
922
1120
 
923
1121
  def get_check_timeout_interval_seconds(
924
- self,
925
- check_timeout_interval: Union[int, float, None] = None,
926
- ) -> Union[int, float]:
1122
+ self,
1123
+ check_timeout_interval: Union[int, float, None] = None,
1124
+ ) -> Union[int, float]:
927
1125
  """
928
1126
  Return the interval value to check the status of timeouts.
929
1127
  """
@@ -931,6 +1129,33 @@ class Daemon:
931
1129
  return check_timeout_interval
932
1130
  return get_config('jobs', 'check_timeout_interval_seconds')
933
1131
 
1132
+ @property
1133
+ def target_args(self) -> Union[Tuple[Any], None]:
1134
+ """
1135
+ Return the positional arguments to pass to the target function.
1136
+ """
1137
+ target_args = (
1138
+ self.__dict__.get('_target_args', None)
1139
+ or self.properties.get('target', {}).get('args', None)
1140
+ )
1141
+ if target_args is None:
1142
+ return tuple([])
1143
+
1144
+ return tuple(target_args)
1145
+
1146
+ @property
1147
+ def target_kw(self) -> Union[Dict[str, Any], None]:
1148
+ """
1149
+ Return the keyword arguments to pass to the target function.
1150
+ """
1151
+ target_kw = (
1152
+ self.__dict__.get('_target_kw', None)
1153
+ or self.properties.get('target', {}).get('kw', None)
1154
+ )
1155
+ if target_kw is None:
1156
+ return {}
1157
+
1158
+ return {key: val for key, val in target_kw.items()}
934
1159
 
935
1160
  def __getstate__(self):
936
1161
  """
@@ -981,4 +1206,4 @@ class Daemon:
981
1206
  return self.daemon_id == other.daemon_id
982
1207
 
983
1208
  def __hash__(self):
984
- return hash(self.daemon_id)
1209
+ return hash(self.daemon_id)
@@ -112,7 +112,7 @@ class FileDescriptorInterceptor:
112
112
  except OSError as e:
113
113
  if e.errno != FD_CLOSED:
114
114
  warn(
115
- f"Error while trying to close the duplicated file descriptor:\n"
115
+ "Error while trying to close the duplicated file descriptor:\n"
116
116
  + f"{traceback.format_exc()}"
117
117
  )
118
118
 
@@ -121,7 +121,7 @@ class FileDescriptorInterceptor:
121
121
  except OSError as e:
122
122
  if e.errno != FD_CLOSED:
123
123
  warn(
124
- f"Error while trying to close the write-pipe "
124
+ "Error while trying to close the write-pipe "
125
125
  + "to the intercepted file descriptor:\n"
126
126
  + f"{traceback.format_exc()}"
127
127
  )
@@ -130,7 +130,7 @@ class FileDescriptorInterceptor:
130
130
  except OSError as e:
131
131
  if e.errno != FD_CLOSED:
132
132
  warn(
133
- f"Error while trying to close the read-pipe "
133
+ "Error while trying to close the read-pipe "
134
134
  + "to the intercepted file descriptor:\n"
135
135
  + f"{traceback.format_exc()}"
136
136
  )
@@ -140,7 +140,7 @@ class FileDescriptorInterceptor:
140
140
  except OSError as e:
141
141
  if e.errno != FD_CLOSED:
142
142
  warn(
143
- f"Error while trying to close the signal-read-pipe "
143
+ "Error while trying to close the signal-read-pipe "
144
144
  + "to the intercepted file descriptor:\n"
145
145
  + f"{traceback.format_exc()}"
146
146
  )
@@ -150,7 +150,7 @@ class FileDescriptorInterceptor:
150
150
  except OSError as e:
151
151
  if e.errno != FD_CLOSED:
152
152
  warn(
153
- f"Error while trying to close the signal-write-pipe "
153
+ "Error while trying to close the signal-write-pipe "
154
154
  + "to the intercepted file descriptor:\n"
155
155
  + f"{traceback.format_exc()}"
156
156
  )
@@ -63,6 +63,9 @@ class RotatingFile(io.IOBase):
63
63
 
64
64
  write_timestamps: bool, default False
65
65
  If `True`, prepend the current UTC timestamp to each line of the file.
66
+
67
+ timestamp_format: str, default '%Y-%m-%d %H:%M'
68
+ If `write_timestamps` is `True`, use this format for the timestamps.
66
69
  """
67
70
  self.file_path = pathlib.Path(file_path)
68
71
  if num_files_to_keep is None:
@@ -232,10 +235,10 @@ class RotatingFile(io.IOBase):
232
235
 
233
236
 
234
237
  def refresh_files(
235
- self,
236
- potential_new_len: int = 0,
237
- start_interception: bool = False,
238
- ) -> '_io.TextUIWrapper':
238
+ self,
239
+ potential_new_len: int = 0,
240
+ start_interception: bool = False,
241
+ ) -> '_io.TextUIWrapper':
239
242
  """
240
243
  Check the state of the subfiles.
241
244
  If the latest subfile is too large, create a new file and delete old ones.
@@ -339,7 +342,7 @@ class RotatingFile(io.IOBase):
339
342
 
340
343
  def get_timestamp_prefix_str(self) -> str:
341
344
  """
342
- Return the current minute prefixm string.
345
+ Return the current minute prefix string.
343
346
  """
344
347
  return datetime.now(timezone.utc).strftime(self.timestamp_format) + ' | '
345
348
 
@@ -568,7 +571,8 @@ class RotatingFile(io.IOBase):
568
571
  return
569
572
 
570
573
  self._cursor = (max_ix, position)
571
- self._current_file_obj.seek(position)
574
+ if self._current_file_obj is not None:
575
+ self._current_file_obj.seek(position)
572
576
 
573
577
 
574
578
  def flush(self) -> None: