meerschaum 2.2.0.dev2__py3-none-any.whl → 2.2.0rc1__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.
@@ -49,7 +49,7 @@ def entry(sysargs: Optional[List[str]] = None) -> SuccessTuple:
49
49
 
50
50
  if args.get('schedule', None):
51
51
  from meerschaum.utils.schedule import schedule_function
52
- return schedule_function(entry_with_args, args['schedule'], **args)
52
+ return schedule_function(entry_with_args, **args)
53
53
  return entry_with_args(**args)
54
54
 
55
55
 
@@ -41,6 +41,7 @@ def show(
41
41
  'jobs' : _show_jobs,
42
42
  'logs' : _show_logs,
43
43
  'tags' : _show_tags,
44
+ 'schedules' : _show_schedules,
44
45
  }
45
46
  return choose_subaction(action, show_options, **kw)
46
47
 
@@ -577,6 +578,7 @@ def _show_logs(
577
578
  `show logs myjob myotherjob`
578
579
  """
579
580
  import os, pathlib, random, asyncio
581
+ from datetime import datetime
580
582
  from meerschaum.utils.packages import attempt_import, import_rich
581
583
  from meerschaum.utils.daemon import get_filtered_daemons, Daemon
582
584
  from meerschaum.utils.warnings import warn, info
@@ -622,13 +624,22 @@ def _show_logs(
622
624
  return _job_colors[daemon_id]
623
625
 
624
626
  def _follow_pretty_print():
625
- watchgod = attempt_import('watchgod')
627
+ watchfiles = attempt_import('watchfiles')
626
628
  rich = import_rich()
627
629
  rich_text = attempt_import('rich.text')
628
630
  _watch_daemon_ids = {d.daemon_id: d for d in daemons}
629
631
  info("Watching log files...")
630
632
 
631
633
  def _print_job_line(daemon, line):
634
+ date_prefix_str = line[:len('YYYY-MM-DD HH:mm')]
635
+ try:
636
+ line_timestamp = datetime.fromisoformat(date_prefix_str)
637
+ except Exception as e:
638
+ line_timestamp = None
639
+ if line_timestamp:
640
+ line = line[len('YYYY-MM-DD HH:mm | '):]
641
+ if len(line) == 0:
642
+ return
632
643
  text = rich_text.Text(daemon.daemon_id)
633
644
  text.append(
634
645
  _get_buffer_spaces(daemon.daemon_id) + '| '
@@ -676,8 +687,8 @@ def _show_logs(
676
687
  _print_log_lines(d)
677
688
 
678
689
  _quit = False
679
- async def _watch_logs():
680
- async for changes in watchgod.awatch(LOGS_RESOURCES_PATH):
690
+ def _watch_logs():
691
+ for changes in watchfiles.watch(LOGS_RESOURCES_PATH):
681
692
  if _quit:
682
693
  return
683
694
  for change in changes:
@@ -699,10 +710,9 @@ def _show_logs(
699
710
  if daemon is not None:
700
711
  _print_log_lines(daemon)
701
712
 
702
- loop = asyncio.new_event_loop()
703
713
  try:
704
- loop.run_until_complete(_watch_logs())
705
- except KeyboardInterrupt:
714
+ _watch_logs()
715
+ except KeyboardInterrupt as ki:
706
716
  _quit = True
707
717
 
708
718
  def _print_nopretty_log_text():
@@ -817,6 +827,57 @@ def _show_tags(
817
827
  return True, "Success"
818
828
 
819
829
 
830
+ def _show_schedules(
831
+ action: Optional[List[str]] = None,
832
+ nopretty: bool = False,
833
+ **kwargs: Any
834
+ ) -> SuccessTuple:
835
+ """
836
+ Print the upcoming timestamps according to the given schedule.
837
+
838
+ Examples:
839
+ show schedule 'daily starting 00:00'
840
+ show schedule 'every 12 hours and mon-fri starting 2024-01-01'
841
+ """
842
+ from meerschaum.utils.schedule import parse_schedule
843
+ from meerschaum.utils.misc import is_int
844
+ from meerschaum.utils.formatting import print_options
845
+ if not action:
846
+ return False, "Provide a schedule to be parsed."
847
+ schedule = action[0]
848
+ default_num_timestamps = 5
849
+ num_timestamps_str = action[1] if len(action) >= 2 else str(default_num_timestamps)
850
+ num_timestamps = (
851
+ int(num_timestamps_str)
852
+ if is_int(num_timestamps_str)
853
+ else default_num_timestamps
854
+ )
855
+ try:
856
+ trigger = parse_schedule(schedule)
857
+ except ValueError as e:
858
+ return False, str(e)
859
+
860
+ next_datetimes = []
861
+ for _ in range(num_timestamps):
862
+ try:
863
+ next_dt = trigger.next()
864
+ next_datetimes.append(next_dt)
865
+ except Exception as e:
866
+ break
867
+
868
+ print_options(
869
+ next_datetimes,
870
+ num_cols = 1,
871
+ nopretty = nopretty,
872
+ header = (
873
+ f"Next {min(num_timestamps, len(next_datetimes))} timestamps "
874
+ + f"for schedule '{schedule}':"
875
+ ),
876
+ )
877
+
878
+ return True, "Success"
879
+
880
+
820
881
 
821
882
  ### NOTE: This must be the final statement of the module.
822
883
  ### Any subactions added below these lines will not
@@ -2,4 +2,4 @@
2
2
  Specify the Meerschaum release version.
3
3
  """
4
4
 
5
- __version__ = "2.2.0.dev2"
5
+ __version__ = "2.2.0rc1"
@@ -36,7 +36,7 @@ class Daemon:
36
36
  def __new__(
37
37
  cls,
38
38
  *args,
39
- daemon_id : Optional[str] = None,
39
+ daemon_id: Optional[str] = None,
40
40
  **kw
41
41
  ):
42
42
  """
@@ -129,7 +129,7 @@ class Daemon:
129
129
  keep_daemon_output: bool, default True
130
130
  If `False`, delete the daemon's output directory upon exiting.
131
131
 
132
- allow_dirty_run :
132
+ allow_dirty_run, bool, default False:
133
133
  If `True`, run the daemon, even if the `daemon_id` directory exists.
134
134
  This option is dangerous because if the same `daemon_id` runs twice,
135
135
  the last to finish will overwrite the output of the first.
@@ -138,7 +138,11 @@ class Daemon:
138
138
  -------
139
139
  Nothing — this will exit the parent process.
140
140
  """
141
- import platform, sys, os
141
+ import platform, sys, os, traceback
142
+ from meerschaum.config._paths import LOGS_RESOURCES_PATH
143
+ from meerschaum.utils.warnings import warn
144
+ daemons_error_log_path = LOGS_RESOURCES_PATH / 'daemons_error.log'
145
+
142
146
  daemon = attempt_import('daemon')
143
147
 
144
148
  if platform.system() == 'Windows':
@@ -162,29 +166,37 @@ class Daemon:
162
166
  log_refresh_seconds = get_config('jobs', 'logs', 'refresh_files_seconds')
163
167
  self._log_refresh_timer = RepeatTimer(log_refresh_seconds, self.rotating_log.refresh_files)
164
168
 
165
- with self._daemon_context:
166
- try:
167
- with open(self.pid_path, 'w+') as f:
168
- f.write(str(os.getpid()))
169
-
170
- self._log_refresh_timer.start()
171
- result = self.target(*self.target_args, **self.target_kw)
172
- self.properties['result'] = result
173
- except Exception as e:
174
- warn(e, stacklevel=3)
175
- result = e
176
- finally:
177
- self._log_refresh_timer.cancel()
178
- self.rotating_log.close()
179
- if self.pid is None and self.pid_path.exists():
180
- self.pid_path.unlink()
169
+ try:
170
+ with self._daemon_context:
171
+ try:
172
+ with open(self.pid_path, 'w+', encoding='utf-8') as f:
173
+ f.write(str(os.getpid()))
181
174
 
182
- if keep_daemon_output:
183
- self._capture_process_timestamp('ended')
184
- else:
185
- self.cleanup()
175
+ self._log_refresh_timer.start()
176
+ result = self.target(*self.target_args, **self.target_kw)
177
+ self.properties['result'] = result
178
+ except Exception as e:
179
+ warn(e, stacklevel=3)
180
+ result = e
181
+ finally:
182
+ self._log_refresh_timer.cancel()
183
+ self.rotating_log.close()
184
+ if self.pid is None and self.pid_path.exists():
185
+ self.pid_path.unlink()
186
+
187
+ if keep_daemon_output:
188
+ self._capture_process_timestamp('ended')
189
+ else:
190
+ self.cleanup()
191
+
192
+ return result
193
+ except Exception as e:
194
+ daemon_error = traceback.format_exc()
195
+ with open(LOGS_RESOURCES_PATH, 'a+', encoding='utf-8') as f:
196
+ f.write(daemon_error)
186
197
 
187
- return result
198
+ if daemon_error:
199
+ warn("Encountered an error while starting the daemon '{self}':\n{daemon_error}")
188
200
 
189
201
 
190
202
  def _capture_process_timestamp(
@@ -452,10 +464,10 @@ class Daemon:
452
464
  if daemon_context is not None:
453
465
  daemon_context.close()
454
466
 
455
- _close_pools()
467
+ self.rotating_log.stop_log_fd_interception()
456
468
 
457
- ### NOTE: SystemExit() does not work here.
458
- sys.exit(0)
469
+ _close_pools()
470
+ raise SystemExit(0)
459
471
 
460
472
 
461
473
  def _handle_sigterm(self, signal_number: int, stack_frame: 'frame') -> None:
@@ -471,9 +483,10 @@ class Daemon:
471
483
  if daemon_context is not None:
472
484
  daemon_context.close()
473
485
 
474
- _close_pools()
486
+ self.rotating_log.stop_log_fd_interception()
475
487
 
476
- raise SystemExit()
488
+ _close_pools()
489
+ raise SystemExit(1)
477
490
 
478
491
 
479
492
  def _send_signal(
@@ -650,7 +663,11 @@ class Daemon:
650
663
  if '_rotating_log' in self.__dict__:
651
664
  return self._rotating_log
652
665
 
653
- self._rotating_log = RotatingFile(self.log_path, redirect_streams=True)
666
+ self._rotating_log = RotatingFile(
667
+ self.log_path,
668
+ redirect_streams = True,
669
+ write_timestamps = True,
670
+ )
654
671
  return self._rotating_log
655
672
 
656
673
 
@@ -663,6 +680,7 @@ class Daemon:
663
680
  self.rotating_log.file_path,
664
681
  num_files_to_keep = self.rotating_log.num_files_to_keep,
665
682
  max_file_size = self.rotating_log.max_file_size,
683
+ write_timestamps = True,
666
684
  )
667
685
  return new_rotating_log.read()
668
686
 
@@ -714,7 +732,7 @@ class Daemon:
714
732
  if not self.pid_path.exists():
715
733
  return None
716
734
  try:
717
- with open(self.pid_path, 'r') as f:
735
+ with open(self.pid_path, 'r', encoding='utf-8') as f:
718
736
  text = f.read()
719
737
  pid = int(text.rstrip())
720
738
  except Exception as e:
@@ -815,7 +833,7 @@ class Daemon:
815
833
  if self.properties is not None:
816
834
  try:
817
835
  self.path.mkdir(parents=True, exist_ok=True)
818
- with open(self.properties_path, 'w+') as properties_file:
836
+ with open(self.properties_path, 'w+', encoding='utf-8') as properties_file:
819
837
  json.dump(self.properties, properties_file)
820
838
  success, msg = True, 'Success'
821
839
  except Exception as e:
@@ -0,0 +1,60 @@
1
+ #! /usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # vim:fenc=utf-8
4
+
5
+ """
6
+ Intercept OS-level file descriptors.
7
+ """
8
+
9
+ import os
10
+ from datetime import datetime
11
+ from meerschaum.utils.typing import Callable
12
+
13
+ class FileDescriptorInterceptor:
14
+ """
15
+ A management class to intercept data written to a file descriptor.
16
+ """
17
+ def __init__(
18
+ self,
19
+ file_descriptor: int,
20
+ injection_hook: Callable[[], str],
21
+ ):
22
+ """
23
+ Parameters
24
+ ----------
25
+ file_descriptor: int
26
+ The OS file descriptor from which to read.
27
+
28
+ injection_hook: Callable[[], str]
29
+ A callable which returns a string to be injected into the written data.
30
+ """
31
+ self.injection_hook = injection_hook
32
+ self.original_file_descriptor = file_descriptor
33
+ self.new_file_descriptor = os.dup(file_descriptor)
34
+ self.read_pipe, self.write_pipe = os.pipe()
35
+ os.dup2(self.write_pipe, file_descriptor)
36
+
37
+ def start_interception(self):
38
+ """
39
+ Read from the file descriptor and write the modified data after injection.
40
+
41
+ NOTE: This is blocking and is meant to be run in a thread.
42
+ """
43
+ while True:
44
+ data = os.read(self.read_pipe, 1024)
45
+ if not data:
46
+ break
47
+ injected_str = self.injection_hook()
48
+ modified_data = data.replace(b'\n', f'\n{injected_str}'.encode('utf-8'))
49
+ os.write(self.new_file_descriptor, modified_data)
50
+
51
+ def stop_interception(self):
52
+ """
53
+ Restore the file descriptors and close the new pipes.
54
+ """
55
+ try:
56
+ os.dup2(self.new_file_descriptor, self.original_file_descriptor)
57
+ os.close(self.read_pipe)
58
+ os.close(self.write_pipe)
59
+ except OSError:
60
+ pass
@@ -13,9 +13,13 @@ import pathlib
13
13
  import traceback
14
14
  import sys
15
15
  import atexit
16
+ from datetime import datetime, timezone, timedelta
16
17
  from typing import List, Union, Optional, Tuple
17
18
  from meerschaum.config import get_config
18
19
  from meerschaum.utils.warnings import warn
20
+ from meerschaum.utils.misc import round_time
21
+ from meerschaum.utils.daemon.FileDescriptorInterceptor import FileDescriptorInterceptor
22
+ from meerschaum.utils.threading import Thread
19
23
  import meerschaum as mrsm
20
24
  daemon = mrsm.attempt_import('daemon')
21
25
 
@@ -33,6 +37,8 @@ class RotatingFile(io.IOBase):
33
37
  num_files_to_keep: Optional[int] = None,
34
38
  max_file_size: Optional[int] = None,
35
39
  redirect_streams: bool = False,
40
+ write_timestamps: bool = False,
41
+ timestamps_format: str = '%Y-%m-%d %H:%M | ',
36
42
  ):
37
43
  """
38
44
  Create a file-like object which manages other files.
@@ -54,6 +60,9 @@ class RotatingFile(io.IOBase):
54
60
 
55
61
  NOTE: Only set this to `True` if you are entering into a daemon context.
56
62
  Doing so will redirect `sys.stdout` and `sys.stderr` into the log files.
63
+
64
+ write_timestamps: bool, default False
65
+ If `True`, prepend the current UTC timestamp to each line of the file.
57
66
  """
58
67
  self.file_path = pathlib.Path(file_path)
59
68
  if num_files_to_keep is None:
@@ -68,6 +77,8 @@ class RotatingFile(io.IOBase):
68
77
  self.num_files_to_keep = num_files_to_keep
69
78
  self.max_file_size = max_file_size
70
79
  self.redirect_streams = redirect_streams
80
+ self.write_timestamps = write_timestamps
81
+ self.timestamps_format = timestamps_format
71
82
  self.subfile_regex_pattern = re.compile(
72
83
  r'^'
73
84
  + self.file_path.name
@@ -87,14 +98,34 @@ class RotatingFile(io.IOBase):
87
98
  atexit.register(self.close)
88
99
 
89
100
 
101
+
90
102
  def fileno(self):
91
103
  """
92
104
  Return the file descriptor for the latest subfile.
93
105
  """
106
+ import inspect
107
+ stack = inspect.stack()
108
+ parent_level = stack[1]
109
+ parent_module = parent_level[0].f_globals.get('__file__')
110
+ # if parent_module.endswith('daemon.py'):
111
+ # self._monkey_patch_os_write()
94
112
  self.refresh_files()
95
113
  return self._current_file_obj.fileno()
96
114
 
97
115
 
116
+ def _monkey_patch_os_write(self):
117
+ import os
118
+ import sys
119
+ import pathlib
120
+ path = pathlib.Path('/home/bmeares/test1.log')
121
+ original_write = os.write
122
+ def intercept(*args, **kwargs):
123
+ with open(path, 'w', encoding='utf-8') as f:
124
+ f.write(str(args))
125
+ original_write(*args, **kwargs)
126
+ os.write = intercept
127
+
128
+
98
129
  def get_latest_subfile_path(self) -> pathlib.Path:
99
130
  """
100
131
  Return the path for the latest subfile to which to write into.
@@ -247,8 +278,10 @@ class RotatingFile(io.IOBase):
247
278
  if is_first_run_with_logs or lost_latest_handle:
248
279
  self._current_file_obj = open(latest_subfile_path, 'a+', encoding='utf-8')
249
280
  if self.redirect_streams:
281
+ self.stop_log_fd_interception()
250
282
  daemon.daemon.redirect_stream(sys.stdout, self._current_file_obj)
251
283
  daemon.daemon.redirect_stream(sys.stderr, self._current_file_obj)
284
+ self.start_log_fd_interception()
252
285
 
253
286
  create_new_file = (
254
287
  (latest_subfile_index == -1)
@@ -269,9 +302,11 @@ class RotatingFile(io.IOBase):
269
302
  if self._previous_file_obj is not None:
270
303
  if self.redirect_streams:
271
304
  self._redirected_subfile_objects[old_subfile_index] = self._previous_file_obj
305
+ self.stop_log_fd_interception()
272
306
  daemon.daemon.redirect_stream(self._previous_file_obj, self._current_file_obj)
273
307
  daemon.daemon.redirect_stream(sys.stdout, self._current_file_obj)
274
308
  daemon.daemon.redirect_stream(sys.stderr, self._current_file_obj)
309
+ self.start_log_fd_interception()
275
310
  self.close(unused_only=True)
276
311
 
277
312
  ### Sanity check in case writing somehow fails.
@@ -279,6 +314,8 @@ class RotatingFile(io.IOBase):
279
314
  self._previous_file_obj is None
280
315
 
281
316
  self.delete(unused_only=True)
317
+
318
+
282
319
  return self._current_file_obj
283
320
 
284
321
 
@@ -311,6 +348,13 @@ class RotatingFile(io.IOBase):
311
348
  self._current_file_obj = None
312
349
 
313
350
 
351
+ def get_timestamp_prefix_str(self) -> str:
352
+ """
353
+ Return the current minute prefixm string.
354
+ """
355
+ return datetime.now(timezone.utc).strftime(self.timestamps_format)
356
+
357
+
314
358
  def write(self, data: str) -> None:
315
359
  """
316
360
  Write the given text into the latest subfile.
@@ -325,9 +369,15 @@ class RotatingFile(io.IOBase):
325
369
  if isinstance(data, bytes):
326
370
  data = data.decode('utf-8')
327
371
 
328
- self.refresh_files(potential_new_len=len(data))
372
+ prefix_str = self.get_timestamp_prefix_str() if self.write_timestamps else ""
373
+ suffix_str = "\n" if self.write_timestamps else ""
374
+ self.refresh_files(potential_new_len=len(prefix_str + data + suffix_str))
329
375
  try:
376
+ if prefix_str:
377
+ self._current_file_obj.write(prefix_str)
330
378
  self._current_file_obj.write(data)
379
+ if suffix_str:
380
+ self._current_file_obj.write(suffix_str)
331
381
  except Exception as e:
332
382
  warn(f"Failed to write to subfile:\n{traceback.format_exc()}")
333
383
  self.flush()
@@ -471,7 +521,7 @@ class RotatingFile(io.IOBase):
471
521
  subfile_object = self.subfile_objects[subfile_index]
472
522
  for i in range(self.SEEK_BACK_ATTEMPTS):
473
523
  try:
474
- subfile_object.seek(max(seek_ix - i), 0)
524
+ subfile_object.seek(max((seek_ix - i), 0))
475
525
  subfile_lines = subfile_object.readlines()
476
526
  except UnicodeDecodeError:
477
527
  continue
@@ -538,6 +588,43 @@ class RotatingFile(io.IOBase):
538
588
  sys.stderr.flush()
539
589
 
540
590
 
591
+ def start_log_fd_interception(self):
592
+ """
593
+ Start the file descriptor monitoring threads.
594
+ """
595
+ self._stdout_interceptor = FileDescriptorInterceptor(
596
+ sys.stdout.fileno(),
597
+ self.get_timestamp_prefix_str,
598
+ )
599
+ self._stderr_interceptor = FileDescriptorInterceptor(
600
+ sys.stderr.fileno(),
601
+ self.get_timestamp_prefix_str,
602
+ )
603
+ self._stdout_interceptor_thread = Thread(target=self._stdout_interceptor.start_interception)
604
+ self._stderr_interceptor_thread = Thread(target=self._stderr_interceptor.start_interception)
605
+ self._stdout_interceptor_thread.start()
606
+ self._stderr_interceptor_thread.start()
607
+
608
+
609
+ def stop_log_fd_interception(self):
610
+ """
611
+ Stop the file descriptor monitoring threads.
612
+ """
613
+ stdout_interceptor = self.__dict__.get('_stdout_interceptor', None)
614
+ stderr_interceptor = self.__dict__.get('_stderr_interceptor', None)
615
+ stdout_interceptor_thread = self.__dict__.get('_stdout_interceptor_thread', None)
616
+ stderr_interceptor_thread = self.__dict__.get('_stderr_interceptor_thread', None)
617
+ if stdout_interceptor is None:
618
+ return
619
+ stdout_interceptor.stop_interception()
620
+ stderr_interceptor.stop_interception()
621
+ try:
622
+ stdout_interceptor_thread.join()
623
+ stderr_interceptor_thread.join()
624
+ except Exception:
625
+ pass
626
+
627
+
541
628
  def __repr__(self) -> str:
542
629
  """
543
630
  Return basic info for this `RotatingFile`.
@@ -35,6 +35,7 @@ _locks = {
35
35
  }
36
36
  _checked_for_updates = set()
37
37
  _is_installed_first_check: Dict[str, bool] = {}
38
+ _MRSM_PACKAGE_ARCHIVES_PREFIX: str = "https://meerschaum.io/files/archives/"
38
39
 
39
40
  def get_module_path(
40
41
  import_name: str,
@@ -640,9 +641,15 @@ def need_update(
640
641
 
641
642
  ### We might be depending on a prerelease.
642
643
  ### Sanity check that the required version is not greater than the installed version.
644
+ required_version = (
645
+ required_version.replace(_MRSM_PACKAGE_ARCHIVES_PREFIX, '')
646
+ .replace(' @ ', '').replace('wheels', '').replace('+mrsm', '').replace('/-', '')
647
+ .replace('-py3-none-any.whl', '')
648
+ )
649
+
643
650
  if 'a' in required_version:
644
- required_version = required_version.replace('a', '-dev')
645
- version = version.replace('a', '-dev')
651
+ required_version = required_version.replace('a', '-dev').replace('+mrsm', '')
652
+ version = version.replace('a', '-dev').replace('+mrsm', '')
646
653
  try:
647
654
  return (
648
655
  (not semver.Version.parse(version).match(required_version))
@@ -49,10 +49,10 @@ packages: Dict[str, Dict[str, str]] = {
49
49
  'daemon' : 'python-daemon>=0.2.3',
50
50
  'fasteners' : 'fasteners>=0.18.0',
51
51
  'psutil' : 'psutil>=5.8.0',
52
- 'watchgod' : 'watchgod>=0.7.0',
52
+ 'watchfiles' : 'watchfiles>=0.21.0',
53
53
  'dill' : 'dill>=0.3.3',
54
54
  'virtualenv' : 'virtualenv>=20.1.0',
55
- 'apscheduler' : 'apscheduler>=4.0.0a4',
55
+ 'apscheduler' : 'APScheduler>=4.0.0a5',
56
56
  },
57
57
  'drivers': {
58
58
  'cryptography' : 'cryptography>=38.0.1',
@@ -89,6 +89,7 @@ packages: Dict[str, Dict[str, str]] = {
89
89
  'pytest' : 'pytest>=6.2.2',
90
90
  'pytest_xdist' : 'pytest-xdist>=3.2.1',
91
91
  'heartrate' : 'heartrate>=0.2.1',
92
+ 'build' : 'build>=1.2.1',
92
93
  },
93
94
  'setup': {
94
95
  },
@@ -149,7 +150,6 @@ packages['api'] = {
149
150
  'passlib' : 'passlib>=1.7.4',
150
151
  'fastapi_login' : 'fastapi-login>=1.7.2',
151
152
  'multipart' : 'python-multipart>=0.0.5',
152
- # 'pydantic' : 'pydantic>2.0.0',
153
153
  'httpx' : 'httpx>=0.24.1',
154
154
  'websockets' : 'websockets>=11.0.3',
155
155
  }
@@ -12,7 +12,8 @@ from datetime import datetime, timezone, timedelta, timedelta
12
12
  import meerschaum as mrsm
13
13
  from meerschaum.utils.typing import Callable, Any, Optional, List, Dict
14
14
 
15
- INTERVAL_UNITS: List[str] = ['months', 'weeks', 'days', 'hours', 'minutes', 'seconds']
15
+ STARTING_KEYWORD: str = 'starting'
16
+ INTERVAL_UNITS: List[str] = ['months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'years']
16
17
  FREQUENCY_ALIASES: Dict[str, str] = {
17
18
  'daily': 'every 1 day',
18
19
  'hourly': 'every 1 hour',
@@ -20,6 +21,7 @@ FREQUENCY_ALIASES: Dict[str, str] = {
20
21
  'weekly': 'every 1 week',
21
22
  'monthly': 'every 1 month',
22
23
  'secondly': 'every 1 second',
24
+ 'yearly': 'every 1 year',
23
25
  }
24
26
  LOGIC_ALIASES: Dict[str, str] = {
25
27
  'and': '&',
@@ -27,7 +29,7 @@ LOGIC_ALIASES: Dict[str, str] = {
27
29
  ' through ': '-',
28
30
  ' thru ': '-',
29
31
  ' - ': '-',
30
- 'beginning': 'starting',
32
+ 'beginning': STARTING_KEYWORD,
31
33
  }
32
34
  CRON_DAYS_OF_WEEK: List[str] = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
33
35
  CRON_DAYS_OF_WEEK_ALIASES: Dict[str, str] = {
@@ -65,8 +67,8 @@ SCHEDULE_ALIASES: Dict[str, str] = {
65
67
  **CRON_DAYS_OF_WEEK_ALIASES,
66
68
  **CRON_MONTHS_ALIASES,
67
69
  }
68
- STARTING_KEYWORD: str = 'starting'
69
70
 
71
+ _scheduler = None
70
72
  def schedule_function(
71
73
  function: Callable[[Any], Any],
72
74
  schedule: str,
@@ -87,23 +89,35 @@ def schedule_function(
87
89
  The frequency schedule at which `function` should be executed (e.g. `'daily'`).
88
90
 
89
91
  """
90
- import warnings
92
+ import asyncio
91
93
  from meerschaum.utils.warnings import warn
92
94
  from meerschaum.utils.misc import filter_keywords, round_time
95
+ global _scheduler
93
96
  kw['debug'] = debug
94
97
  kw = filter_keywords(function, **kw)
95
98
 
96
99
  apscheduler = mrsm.attempt_import('apscheduler', lazy=False)
97
100
  now = round_time(datetime.now(timezone.utc), timedelta(minutes=1))
98
101
  trigger = parse_schedule(schedule, now=now)
102
+ _scheduler = apscheduler.AsyncScheduler()
103
+ try:
104
+ loop = asyncio.get_running_loop()
105
+ except RuntimeError:
106
+ loop = asyncio.new_event_loop()
107
+
108
+ async def run_scheduler():
109
+ async with _scheduler:
110
+ job = await _scheduler.add_schedule(function, trigger, args=args, kwargs=kw)
111
+ try:
112
+ await _scheduler.run_until_stopped()
113
+ except (KeyboardInterrupt, SystemExit) as e:
114
+ await _stop_scheduler()
115
+ raise e
99
116
 
100
- with apscheduler.Scheduler() as scheduler:
101
- job = scheduler.add_schedule(function, trigger, args=args, kwargs=kw)
102
- try:
103
- scheduler.run_until_stopped()
104
- except KeyboardInterrupt as e:
105
- scheduler.stop()
106
- scheduler.wait_until_stopped()
117
+ try:
118
+ loop.run_until_complete(run_scheduler())
119
+ except (KeyboardInterrupt, SystemExit) as e:
120
+ loop.run_until_complete(_stop_scheduler())
107
121
 
108
122
 
109
123
  def parse_schedule(schedule: str, now: Optional[datetime] = None):
@@ -134,7 +148,7 @@ def parse_schedule(schedule: str, now: Optional[datetime] = None):
134
148
 
135
149
  ### TODO Allow for combining `and` + `or` logic.
136
150
  if '&' in schedule and '|' in schedule:
137
- error(f"Cannot accept both 'and' + 'or' logic in the schedule frequency.", ValueError)
151
+ raise ValueError(f"Cannot accept both 'and' + 'or' logic in the schedule frequency.")
138
152
 
139
153
  join_str = '|' if '|' in schedule else '&'
140
154
  join_trigger = (
@@ -152,12 +166,6 @@ def parse_schedule(schedule: str, now: Optional[datetime] = None):
152
166
 
153
167
  has_seconds = 'second' in schedule
154
168
  has_minutes = 'minute' in schedule
155
- has_days = 'day' in schedule
156
- has_weeks = 'week' in schedule
157
- has_hours = 'hour' in schedule
158
- num_hourly_intervals = schedule.count('hour')
159
- divided_days = False
160
- divided_hours = False
161
169
 
162
170
  for schedule_part in schedule_parts:
163
171
 
@@ -168,10 +176,9 @@ def parse_schedule(schedule: str, now: Optional[datetime] = None):
168
176
  )
169
177
  schedule_unit = schedule_unit.rstrip('s') + 's'
170
178
  if schedule_unit not in INTERVAL_UNITS:
171
- error(
179
+ raise ValueError(
172
180
  f"Invalid interval '{schedule_unit}'.\n"
173
- + f" Accepted values are {items_str(INTERVAL_UNITS)}.",
174
- ValueError,
181
+ + f" Accepted values are {items_str(INTERVAL_UNITS)}."
175
182
  )
176
183
 
177
184
  schedule_num = (
@@ -180,30 +187,6 @@ def parse_schedule(schedule: str, now: Optional[datetime] = None):
180
187
  else float(schedule_num_str)
181
188
  )
182
189
 
183
- ### NOTE: When combining days or weeks with other schedules,
184
- ### we must divide one of the day-schedules by 2.
185
- ### TODO Remove this when APScheduler is patched.
186
- if (
187
- join_str == '&'
188
- and (has_days or has_weeks)
189
- and len(schedule_parts) > 1
190
- and not divided_days
191
- ):
192
- schedule_num /= 2
193
- divided_days = True
194
-
195
- ### NOTE: When combining multiple hourly intervals,
196
- ### one must be divided by 2.
197
- if (
198
- join_str == '&'
199
- # and num_hourly_intervals > 1
200
- and len(schedule_parts) > 1
201
- and not divided_hours
202
- ):
203
- print("divided hours")
204
- schedule_num /= 2
205
- # divided_hours = True
206
-
207
190
  trigger = (
208
191
  apscheduler_triggers_interval.IntervalTrigger(
209
192
  **{
@@ -211,12 +194,12 @@ def parse_schedule(schedule: str, now: Optional[datetime] = None):
211
194
  'start_time': starting_ts,
212
195
  }
213
196
  )
214
- if schedule_unit != 'months' else (
197
+ if schedule_unit not in ('months', 'years') else (
215
198
  apscheduler_triggers_calendarinterval.CalendarIntervalTrigger(
216
199
  **{
217
200
  schedule_unit: schedule_num,
218
201
  'start_date': starting_ts,
219
- # 'timezone': starting_ts.tzinfo, TODO Re-enable once APScheduler updates.
202
+ 'timezone': starting_ts.tzinfo,
220
203
  }
221
204
  )
222
205
  )
@@ -224,12 +207,15 @@ def parse_schedule(schedule: str, now: Optional[datetime] = None):
224
207
 
225
208
  ### Determine whether this is a pure cron string or a cron subset (e.g. 'may-aug')_.
226
209
  else:
227
- first_three_prefix = schedule_part[:3]
210
+ first_three_prefix = schedule_part[:3].lower()
211
+ first_four_prefix = schedule_part[:4].lower()
228
212
  cron_kw = {}
229
213
  if first_three_prefix in CRON_DAYS_OF_WEEK:
230
214
  cron_kw['day_of_week'] = schedule_part
231
215
  elif first_three_prefix in CRON_MONTHS:
232
216
  cron_kw['month'] = schedule_part
217
+ elif is_int(first_four_prefix) and len(first_four_prefix) == 4:
218
+ cron_kw['year'] = int(first_four_prefix)
233
219
  trigger = (
234
220
  apscheduler_triggers_cron.CronTrigger(
235
221
  **{
@@ -302,3 +288,10 @@ def parse_start_time(schedule: str, now: Optional[datetime] = None) -> datetime:
302
288
  if not starting_ts.tzinfo:
303
289
  starting_ts = starting_ts.replace(tzinfo=timezone.utc)
304
290
  return starting_ts
291
+
292
+
293
+ async def _stop_scheduler():
294
+ if _scheduler is None:
295
+ return
296
+ await _scheduler.stop()
297
+ await _scheduler.wait_until_stopped()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: meerschaum
3
- Version: 2.2.0.dev2
3
+ Version: 2.2.0rc1
4
4
  Summary: Sync Time-Series Pipes with Meerschaum
5
5
  Home-page: https://meerschaum.io
6
6
  Author: Bennett Meares
@@ -53,10 +53,10 @@ Requires-Dist: more-itertools >=8.7.0 ; extra == '_required'
53
53
  Requires-Dist: python-daemon >=0.2.3 ; extra == '_required'
54
54
  Requires-Dist: fasteners >=0.18.0 ; extra == '_required'
55
55
  Requires-Dist: psutil >=5.8.0 ; extra == '_required'
56
- Requires-Dist: watchgod >=0.7.0 ; extra == '_required'
56
+ Requires-Dist: watchfiles >=0.21.0 ; extra == '_required'
57
57
  Requires-Dist: dill >=0.3.3 ; extra == '_required'
58
58
  Requires-Dist: virtualenv >=20.1.0 ; extra == '_required'
59
- Requires-Dist: apscheduler >=4.0.0a4 ; extra == '_required'
59
+ Requires-Dist: APScheduler >=4.0.0a5 ; extra == '_required'
60
60
  Provides-Extra: api
61
61
  Requires-Dist: uvicorn[standard] >=0.22.0 ; extra == 'api'
62
62
  Requires-Dist: gunicorn >=20.1.0 ; extra == 'api'
@@ -102,10 +102,10 @@ Requires-Dist: more-itertools >=8.7.0 ; extra == 'api'
102
102
  Requires-Dist: python-daemon >=0.2.3 ; extra == 'api'
103
103
  Requires-Dist: fasteners >=0.18.0 ; extra == 'api'
104
104
  Requires-Dist: psutil >=5.8.0 ; extra == 'api'
105
- Requires-Dist: watchgod >=0.7.0 ; extra == 'api'
105
+ Requires-Dist: watchfiles >=0.21.0 ; extra == 'api'
106
106
  Requires-Dist: dill >=0.3.3 ; extra == 'api'
107
107
  Requires-Dist: virtualenv >=20.1.0 ; extra == 'api'
108
- Requires-Dist: apscheduler >=4.0.0a4 ; extra == 'api'
108
+ Requires-Dist: APScheduler >=4.0.0a5 ; extra == 'api'
109
109
  Requires-Dist: pprintpp >=0.4.0 ; extra == 'api'
110
110
  Requires-Dist: asciitree >=0.3.3 ; extra == 'api'
111
111
  Requires-Dist: typing-extensions >=4.7.1 ; extra == 'api'
@@ -148,6 +148,7 @@ Requires-Dist: mypy >=0.812.0 ; extra == 'dev-tools'
148
148
  Requires-Dist: pytest >=6.2.2 ; extra == 'dev-tools'
149
149
  Requires-Dist: pytest-xdist >=3.2.1 ; extra == 'dev-tools'
150
150
  Requires-Dist: heartrate >=0.2.1 ; extra == 'dev-tools'
151
+ Requires-Dist: build >=1.2.1 ; extra == 'dev-tools'
151
152
  Provides-Extra: docs
152
153
  Requires-Dist: mkdocs >=1.1.2 ; extra == 'docs'
153
154
  Requires-Dist: mkdocs-material >=6.2.5 ; extra == 'docs'
@@ -208,10 +209,10 @@ Requires-Dist: more-itertools >=8.7.0 ; extra == 'full'
208
209
  Requires-Dist: python-daemon >=0.2.3 ; extra == 'full'
209
210
  Requires-Dist: fasteners >=0.18.0 ; extra == 'full'
210
211
  Requires-Dist: psutil >=5.8.0 ; extra == 'full'
211
- Requires-Dist: watchgod >=0.7.0 ; extra == 'full'
212
+ Requires-Dist: watchfiles >=0.21.0 ; extra == 'full'
212
213
  Requires-Dist: dill >=0.3.3 ; extra == 'full'
213
214
  Requires-Dist: virtualenv >=20.1.0 ; extra == 'full'
214
- Requires-Dist: apscheduler >=4.0.0a4 ; extra == 'full'
215
+ Requires-Dist: APScheduler >=4.0.0a5 ; extra == 'full'
215
216
  Requires-Dist: cryptography >=38.0.1 ; extra == 'full'
216
217
  Requires-Dist: psycopg[binary] >=3.1.18 ; extra == 'full'
217
218
  Requires-Dist: PyMySQL >=0.9.0 ; extra == 'full'
@@ -292,10 +293,10 @@ Requires-Dist: more-itertools >=8.7.0 ; extra == 'sql'
292
293
  Requires-Dist: python-daemon >=0.2.3 ; extra == 'sql'
293
294
  Requires-Dist: fasteners >=0.18.0 ; extra == 'sql'
294
295
  Requires-Dist: psutil >=5.8.0 ; extra == 'sql'
295
- Requires-Dist: watchgod >=0.7.0 ; extra == 'sql'
296
+ Requires-Dist: watchfiles >=0.21.0 ; extra == 'sql'
296
297
  Requires-Dist: dill >=0.3.3 ; extra == 'sql'
297
298
  Requires-Dist: virtualenv >=20.1.0 ; extra == 'sql'
298
- Requires-Dist: apscheduler >=4.0.0a4 ; extra == 'sql'
299
+ Requires-Dist: APScheduler >=4.0.0a5 ; extra == 'sql'
299
300
  Provides-Extra: stack
300
301
  Requires-Dist: docker-compose >=1.29.2 ; extra == 'stack'
301
302
 
@@ -1,7 +1,7 @@
1
1
  meerschaum/__init__.py,sha256=mw_PhxT7155SW8d_S51T4-WTibL5g205CwMa3qX8qyA,1487
2
2
  meerschaum/__main__.py,sha256=8XGS2sJCtz6AMfBv5vm84mH1eS36h1sN1eL8SSmYtE4,2763
3
3
  meerschaum/_internal/__init__.py,sha256=ilC7utfKtin7GAvuN34fKyUQYfPyqH0Mm3MJF5iyEf4,169
4
- meerschaum/_internal/entry.py,sha256=c96Q4OsozbJevpqRkJG3Zf8PKcil5pMBZlw3FQ0t88o,4824
4
+ meerschaum/_internal/entry.py,sha256=Oga0OH808UtTJGvvkQQFRKjVCjSCRYjJe3M1A9-yf1k,4806
5
5
  meerschaum/_internal/arguments/__init__.py,sha256=HFciFQgo2ZOT19Mo6CpLhPYlpLYh2sNn1C9Lo7NMADc,519
6
6
  meerschaum/_internal/arguments/_parse_arguments.py,sha256=dNVDBvXYNgEw-TrlZJ9A6VPlG696EpQcQm6FOAhseqw,10249
7
7
  meerschaum/_internal/arguments/_parser.py,sha256=OtcrZK-_qV9a5qpdcP9NLKOGRevjmCU9fBLbB88px3c,13719
@@ -38,7 +38,7 @@ meerschaum/actions/register.py,sha256=l_21LWZCzKwJVex_xAXECC5WVW1VEuIX9HSp7CuyCw
38
38
  meerschaum/actions/reload.py,sha256=gMXeFBGVfyQ7uhKhYf6bLaDMD0fLPcA9BrLBSiuvWIc,508
39
39
  meerschaum/actions/setup.py,sha256=KkAGWcgwzl_L6A19fTmTX1KtBjW2FwD8QenLjPy0mQQ,3205
40
40
  meerschaum/actions/sh.py,sha256=fLfTJaacKu4sjLTRqEzzYlT2WbbdZBEczsKb6F-qAek,2026
41
- meerschaum/actions/show.py,sha256=2A_-kpCJ9h75kg2PGLvKWVfqKs3C2YS6cv2PtC3Oq78,26212
41
+ meerschaum/actions/show.py,sha256=MtPp6PNjbQiMcxqsfvU7_0rGvB8Z59-TURmEwvpvT6I,28057
42
42
  meerschaum/actions/sql.py,sha256=wYofwk1vGO96U2ncigGEfMtYMZeprz2FR1PRRZhkAPI,4311
43
43
  meerschaum/actions/stack.py,sha256=WMRMebyYwZGNlbnj6Ja09qvCSDNteFJOTa8_joHlnVo,5886
44
44
  meerschaum/actions/start.py,sha256=mNFWqxc_o9moavvDQWE4YoZF6b-SW2nKyw5MtwIj-90,18384
@@ -134,7 +134,7 @@ meerschaum/config/_preprocess.py,sha256=-AEA8m_--KivZwTQ1sWN6LTn5sio_fUr2XZ51BO6
134
134
  meerschaum/config/_read_config.py,sha256=WFZKIXZMDe_ca0ES7ivgM_mnwShvFxLdoeisT_X5-h0,14720
135
135
  meerschaum/config/_shell.py,sha256=s74cmJl8NrhM_Y1cB_P41_JDUYXV0g4WXnKFZWMtnrY,3551
136
136
  meerschaum/config/_sync.py,sha256=Q-sz5YcjL3CJS2Dyw4rVRQsz9th9GWa9o5F9D0Jrmn8,4120
137
- meerschaum/config/_version.py,sha256=C1chg72mvosNVsfDxJ0TiYiWC1iqAPXO56RhWOWpGHk,76
137
+ meerschaum/config/_version.py,sha256=N7N8FP8jZtOySQsoOturqBbQHE9lVrxPySO9oQt-NRo,74
138
138
  meerschaum/config/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
139
139
  meerschaum/config/stack/__init__.py,sha256=pKR7aDqqrGZjjNhbWbA9AMdfBjF_-zl7xtgVXk9B9Mg,9012
140
140
  meerschaum/config/stack/grafana/__init__.py,sha256=wzuoch_AK49lcn7lH2qTSJ_PPbSagF4lcweeipz_XiE,2010
@@ -204,14 +204,15 @@ meerschaum/utils/networking.py,sha256=Sr_eYUGW8_UV9-k9LqRFf7xLtbUcsDucODyLCRsFRU
204
204
  meerschaum/utils/pool.py,sha256=vkE42af4fjrTEJTxf6Ek3xGucm1MtEkpsSEiaVzNKHs,2655
205
205
  meerschaum/utils/process.py,sha256=tbEutHAg_Kn5UetOI-fduRjsafGOYX5tkLvpzqosgvc,7098
206
206
  meerschaum/utils/prompt.py,sha256=0mBFbgi_l9rCou9UnC_6qKTHkqyl1Z_jSRzfmc0xRXM,16490
207
- meerschaum/utils/schedule.py,sha256=VAlNZQHjGoAzsmGTiHbUMzf3kIxriPF9GjKKZZefyHQ,10350
207
+ meerschaum/utils/schedule.py,sha256=nXiOAHNq51WDS5NS4MPMDmv1MY7RV0TxRNXN69S_w1w,10020
208
208
  meerschaum/utils/sql.py,sha256=4sCNEpgUd6uFz6ySs4nnUMVaOT0YAvPM1ZlQYJTSF-0,46656
209
209
  meerschaum/utils/threading.py,sha256=fAXk7-FnbFvdU1FQ4vHKk5NeGbbTpTw7y9dRnlVayNI,2472
210
210
  meerschaum/utils/typing.py,sha256=L05wOXfWdn_nJ0KnZVr-2zdMYcqjdyOW_7InT3xe6-s,2807
211
211
  meerschaum/utils/warnings.py,sha256=0b5O2DBbhEAGnu6RAB1hlHSVmwL_hcR3EiMkExXmBJ0,6535
212
212
  meerschaum/utils/yaml.py,sha256=vbCrFjdapKsZ9wRRaI9Ih8dVUwZ-KHpSzfGhRcpDBgQ,3162
213
- meerschaum/utils/daemon/Daemon.py,sha256=vphcP9KzvJmnqjk9rHazxl6qO2YWzjyYLPdaVyyMlww,32192
214
- meerschaum/utils/daemon/RotatingFile.py,sha256=fNjLWENoTxbIOj9vTQcnHg2uC78ZKtel5L_snwnSbrI,19362
213
+ meerschaum/utils/daemon/Daemon.py,sha256=pqUYMjuP08IW6nQ8Cg2g_RyP0LAYi1gAi67_3fahxJ0,33047
214
+ meerschaum/utils/daemon/FileDescriptorInterceptor.py,sha256=avmQa43HISIAIu6dfB0FL5tnrfaIBjfp5M8tRV0nFbo,1813
215
+ meerschaum/utils/daemon/RotatingFile.py,sha256=wKb2Q93msgc0RPQTHr-nJ1hOwQnEoNNURlLg6Ea83M8,22775
215
216
  meerschaum/utils/daemon/__init__.py,sha256=7LZeLuakprirfQ_XjnMoOp5Z96cy83vs75oBJiXU19Q,8174
216
217
  meerschaum/utils/daemon/_names.py,sha256=Prf7xA2GWDbKR_9Xq9_5RTTIf9GNWY3Yt0s4tEU3JgM,4330
217
218
  meerschaum/utils/dtypes/__init__.py,sha256=JR9PViJTzhukZhq0QoPIs73HOnXZZr8OmfhAAD4OAUA,6261
@@ -221,16 +222,16 @@ meerschaum/utils/formatting/_jobs.py,sha256=s1lVcdMkzNj5Bqw-GsUhcguUFtahi5nQ-kg1
221
222
  meerschaum/utils/formatting/_pipes.py,sha256=wy0iWJFsFl3X2VloaiA_gp9Yx9w6tD3FQZvAQAqef4A,19492
222
223
  meerschaum/utils/formatting/_pprint.py,sha256=tgrT3FyGyu5CWJYysqK3kX1xdZYorlbOk9fcU_vt9Qg,3096
223
224
  meerschaum/utils/formatting/_shell.py,sha256=ox75O7VHDAiwzSvdMSJZhXLadvAqYJVeihU6WeZ2Ogc,3677
224
- meerschaum/utils/packages/__init__.py,sha256=P7nASOvBEcpaz1CBl2KGVQRZ1F1zkNflL34UGPH6lKw,56682
225
- meerschaum/utils/packages/_packages.py,sha256=qHmYl74vfMULlia3EU6qrurpKyAsBqQcmantUxBFb4E,7970
225
+ meerschaum/utils/packages/__init__.py,sha256=Ohwzw1GufuqQd-N2fkPSCXXGaSSDKB5_zVZ3S9atviM,57032
226
+ meerschaum/utils/packages/_packages.py,sha256=eSTwPNe2llLZFdBSofuKz903mxL7feNNUYb8YlkYjhQ,7968
226
227
  meerschaum/utils/packages/lazy_loader.py,sha256=VHnph3VozH29R4JnSSBfwtA5WKZYZQFT_GeQSShCnuc,2540
227
228
  meerschaum/utils/venv/_Venv.py,sha256=sBnlmxHdAh2bx8btfVoD79-H9-cYsv5lP02IIXkyECs,3553
228
229
  meerschaum/utils/venv/__init__.py,sha256=sj-n8scWH2NPDJGAxfpqzsYqVUt2jMEr-7Uq9G7YUNQ,23183
229
- meerschaum-2.2.0.dev2.dist-info/LICENSE,sha256=jG2zQEdRNt88EgHUWPpXVWmOrOduUQRx7MnYV9YIPaw,11359
230
- meerschaum-2.2.0.dev2.dist-info/METADATA,sha256=nrTJOaYvdtJzVMeX0tBuCt_D5N8ZVOAigQdUj7b3q8c,23902
231
- meerschaum-2.2.0.dev2.dist-info/NOTICE,sha256=OTA9Fcthjf5BRvWDDIcBC_xfLpeDV-RPZh3M-HQBRtQ,114
232
- meerschaum-2.2.0.dev2.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
233
- meerschaum-2.2.0.dev2.dist-info/entry_points.txt,sha256=5YBVzibw-0rNA_1VjB16z5GABsOGf-CDhW4yqH8C7Gc,88
234
- meerschaum-2.2.0.dev2.dist-info/top_level.txt,sha256=bNoSiDj0El6buocix-FRoAtJOeq1qOF5rRm2u9i7Q6A,11
235
- meerschaum-2.2.0.dev2.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
236
- meerschaum-2.2.0.dev2.dist-info/RECORD,,
230
+ meerschaum-2.2.0rc1.dist-info/LICENSE,sha256=jG2zQEdRNt88EgHUWPpXVWmOrOduUQRx7MnYV9YIPaw,11359
231
+ meerschaum-2.2.0rc1.dist-info/METADATA,sha256=78COJ__GnuF4XKU_sijVelE7Aj1KC0DxGf4RjOEz3Po,23964
232
+ meerschaum-2.2.0rc1.dist-info/NOTICE,sha256=OTA9Fcthjf5BRvWDDIcBC_xfLpeDV-RPZh3M-HQBRtQ,114
233
+ meerschaum-2.2.0rc1.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
234
+ meerschaum-2.2.0rc1.dist-info/entry_points.txt,sha256=5YBVzibw-0rNA_1VjB16z5GABsOGf-CDhW4yqH8C7Gc,88
235
+ meerschaum-2.2.0rc1.dist-info/top_level.txt,sha256=bNoSiDj0El6buocix-FRoAtJOeq1qOF5rRm2u9i7Q6A,11
236
+ meerschaum-2.2.0rc1.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
237
+ meerschaum-2.2.0rc1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.42.0)
2
+ Generator: bdist_wheel (0.43.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5