meerschaum 2.3.5.dev0__py3-none-any.whl → 2.4.0__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 (99) hide show
  1. meerschaum/_internal/arguments/__init__.py +2 -1
  2. meerschaum/_internal/arguments/_parse_arguments.py +88 -12
  3. meerschaum/_internal/docs/index.py +3 -2
  4. meerschaum/_internal/entry.py +42 -20
  5. meerschaum/_internal/shell/Shell.py +38 -44
  6. meerschaum/_internal/term/TermPageHandler.py +2 -3
  7. meerschaum/_internal/term/__init__.py +13 -11
  8. meerschaum/actions/api.py +26 -23
  9. meerschaum/actions/bootstrap.py +38 -11
  10. meerschaum/actions/copy.py +3 -3
  11. meerschaum/actions/delete.py +4 -1
  12. meerschaum/actions/register.py +1 -3
  13. meerschaum/actions/stack.py +24 -19
  14. meerschaum/actions/start.py +41 -41
  15. meerschaum/actions/sync.py +53 -52
  16. meerschaum/api/__init__.py +48 -14
  17. meerschaum/api/_events.py +26 -17
  18. meerschaum/api/_oauth2.py +2 -2
  19. meerschaum/api/_websockets.py +5 -4
  20. meerschaum/api/dash/__init__.py +7 -16
  21. meerschaum/api/dash/callbacks/__init__.py +1 -0
  22. meerschaum/api/dash/callbacks/dashboard.py +52 -58
  23. meerschaum/api/dash/callbacks/jobs.py +15 -16
  24. meerschaum/api/dash/callbacks/login.py +16 -10
  25. meerschaum/api/dash/callbacks/pipes.py +41 -0
  26. meerschaum/api/dash/callbacks/plugins.py +1 -1
  27. meerschaum/api/dash/callbacks/register.py +15 -11
  28. meerschaum/api/dash/components.py +54 -59
  29. meerschaum/api/dash/jobs.py +5 -9
  30. meerschaum/api/dash/pages/__init__.py +1 -0
  31. meerschaum/api/dash/pages/pipes.py +19 -0
  32. meerschaum/api/dash/pipes.py +86 -58
  33. meerschaum/api/dash/plugins.py +6 -4
  34. meerschaum/api/dash/sessions.py +176 -0
  35. meerschaum/api/dash/users.py +3 -41
  36. meerschaum/api/dash/webterm.py +12 -17
  37. meerschaum/api/resources/static/js/terminado.js +1 -1
  38. meerschaum/api/routes/_actions.py +4 -118
  39. meerschaum/api/routes/_jobs.py +45 -24
  40. meerschaum/api/routes/_login.py +4 -4
  41. meerschaum/api/routes/_pipes.py +3 -3
  42. meerschaum/api/routes/_webterm.py +5 -6
  43. meerschaum/config/_default.py +15 -3
  44. meerschaum/config/_version.py +1 -1
  45. meerschaum/config/stack/__init__.py +64 -21
  46. meerschaum/config/static/__init__.py +6 -0
  47. meerschaum/connectors/{Connector.py → _Connector.py} +19 -13
  48. meerschaum/connectors/__init__.py +24 -14
  49. meerschaum/connectors/api/{APIConnector.py → _APIConnector.py} +3 -1
  50. meerschaum/connectors/api/__init__.py +2 -1
  51. meerschaum/connectors/api/_actions.py +22 -36
  52. meerschaum/connectors/api/_jobs.py +1 -0
  53. meerschaum/connectors/parse.py +18 -16
  54. meerschaum/connectors/poll.py +30 -24
  55. meerschaum/connectors/sql/__init__.py +3 -1
  56. meerschaum/connectors/sql/_pipes.py +172 -197
  57. meerschaum/connectors/sql/_plugins.py +45 -43
  58. meerschaum/connectors/sql/_users.py +46 -38
  59. meerschaum/connectors/valkey/_ValkeyConnector.py +535 -0
  60. meerschaum/connectors/valkey/__init__.py +10 -0
  61. meerschaum/connectors/valkey/_fetch.py +75 -0
  62. meerschaum/connectors/valkey/_pipes.py +844 -0
  63. meerschaum/connectors/valkey/_plugins.py +265 -0
  64. meerschaum/connectors/valkey/_users.py +305 -0
  65. meerschaum/core/Pipe/__init__.py +3 -0
  66. meerschaum/core/Pipe/_attributes.py +1 -2
  67. meerschaum/core/Pipe/_clear.py +16 -13
  68. meerschaum/core/Pipe/_copy.py +106 -0
  69. meerschaum/core/Pipe/_data.py +165 -101
  70. meerschaum/core/Pipe/_drop.py +4 -4
  71. meerschaum/core/Pipe/_dtypes.py +14 -14
  72. meerschaum/core/Pipe/_edit.py +15 -14
  73. meerschaum/core/Pipe/_sync.py +134 -53
  74. meerschaum/core/Pipe/_verify.py +11 -11
  75. meerschaum/core/User/_User.py +14 -12
  76. meerschaum/jobs/_Job.py +27 -14
  77. meerschaum/jobs/__init__.py +7 -2
  78. meerschaum/jobs/systemd.py +20 -8
  79. meerschaum/plugins/_Plugin.py +17 -13
  80. meerschaum/utils/_get_pipes.py +14 -20
  81. meerschaum/utils/dataframe.py +291 -101
  82. meerschaum/utils/dtypes/__init__.py +31 -6
  83. meerschaum/utils/dtypes/sql.py +4 -4
  84. meerschaum/utils/formatting/_shell.py +5 -6
  85. meerschaum/utils/misc.py +3 -3
  86. meerschaum/utils/packages/__init__.py +14 -9
  87. meerschaum/utils/packages/_packages.py +2 -0
  88. meerschaum/utils/prompt.py +1 -1
  89. meerschaum/utils/schedule.py +1 -0
  90. {meerschaum-2.3.5.dev0.dist-info → meerschaum-2.4.0.dist-info}/METADATA +7 -1
  91. {meerschaum-2.3.5.dev0.dist-info → meerschaum-2.4.0.dist-info}/RECORD +98 -89
  92. {meerschaum-2.3.5.dev0.dist-info → meerschaum-2.4.0.dist-info}/WHEEL +1 -1
  93. meerschaum/api/dash/actions.py +0 -255
  94. /meerschaum/connectors/sql/{SQLConnector.py → _SQLConnector.py} +0 -0
  95. {meerschaum-2.3.5.dev0.dist-info → meerschaum-2.4.0.dist-info}/LICENSE +0 -0
  96. {meerschaum-2.3.5.dev0.dist-info → meerschaum-2.4.0.dist-info}/NOTICE +0 -0
  97. {meerschaum-2.3.5.dev0.dist-info → meerschaum-2.4.0.dist-info}/entry_points.txt +0 -0
  98. {meerschaum-2.3.5.dev0.dist-info → meerschaum-2.4.0.dist-info}/top_level.txt +0 -0
  99. {meerschaum-2.3.5.dev0.dist-info → meerschaum-2.4.0.dist-info}/zip-safe +0 -0
@@ -26,8 +26,6 @@ from meerschaum.utils.typing import (
26
26
  SuccessTuple,
27
27
  Dict,
28
28
  List,
29
- Iterable,
30
- Generator,
31
29
  )
32
30
  from meerschaum.utils.warnings import warn, error
33
31
 
@@ -266,7 +264,6 @@ def sync(
266
264
  **kw
267
265
  )
268
266
  )
269
-
270
267
  except Exception as e:
271
268
  get_console().print_exception(
272
269
  suppress=[
@@ -369,6 +366,11 @@ def sync(
369
366
 
370
367
  ### Cast to a dataframe and ensure datatypes are what we expect.
371
368
  df = self.enforce_dtypes(df, chunksize=chunksize, debug=debug)
369
+
370
+ ### Capture `numeric` and `json` columns.
371
+ self._persist_new_json_columns(df, debug=debug)
372
+ self._persist_new_numeric_columns(df, debug=debug)
373
+
372
374
  if debug:
373
375
  dprint(
374
376
  "DataFrame to sync:\n"
@@ -554,14 +556,15 @@ def exists(
554
556
 
555
557
 
556
558
  def filter_existing(
557
- self,
558
- df: 'pd.DataFrame',
559
- safe_copy: bool = True,
560
- date_bound_only: bool = False,
561
- chunksize: Optional[int] = -1,
562
- debug: bool = False,
563
- **kw
564
- ) -> Tuple['pd.DataFrame', 'pd.DataFrame', 'pd.DataFrame']:
559
+ self,
560
+ df: 'pd.DataFrame',
561
+ safe_copy: bool = True,
562
+ date_bound_only: bool = False,
563
+ include_unchanged_columns: bool = False,
564
+ chunksize: Optional[int] = -1,
565
+ debug: bool = False,
566
+ **kw
567
+ ) -> Tuple['pd.DataFrame', 'pd.DataFrame', 'pd.DataFrame']:
565
568
  """
566
569
  Inspect a dataframe and filter out rows which already exist in the pipe.
567
570
 
@@ -569,7 +572,7 @@ def filter_existing(
569
572
  ----------
570
573
  df: 'pd.DataFrame'
571
574
  The dataframe to inspect and filter.
572
-
575
+
573
576
  safe_copy: bool, default True
574
577
  If `True`, create a copy before comparing and modifying the dataframes.
575
578
  Setting to `False` may mutate the DataFrames.
@@ -578,6 +581,10 @@ def filter_existing(
578
581
  date_bound_only: bool, default False
579
582
  If `True`, only use the datetime index to fetch the sample dataframe.
580
583
 
584
+ include_unchanged_columns: bool, default False
585
+ If `True`, include the backtrack columns which haven't changed in the update dataframe.
586
+ This is useful if you can't update individual keys.
587
+
581
588
  chunksize: Optional[int], default -1
582
589
  The `chunksize` used when fetching existing data.
583
590
 
@@ -605,7 +612,7 @@ def filter_existing(
605
612
  from meerschaum.config import get_config
606
613
  pd = import_pandas()
607
614
  pandas = attempt_import('pandas')
608
- if not 'dataframe' in str(type(df)).lower():
615
+ if 'dataframe' not in str(type(df)).lower():
609
616
  df = self.enforce_dtypes(df, chunksize=chunksize, debug=debug)
610
617
  is_dask = 'dask' in df.__module__
611
618
  if is_dask:
@@ -615,8 +622,21 @@ def filter_existing(
615
622
  else:
616
623
  merge = pd.merge
617
624
  NA = pd.NA
625
+
626
+ def get_empty_df():
627
+ empty_df = pd.DataFrame([])
628
+ dtypes = dict(df.dtypes) if df is not None else {}
629
+ dtypes.update(self.dtypes)
630
+ pd_dtypes = {
631
+ col: to_pandas_dtype(str(typ))
632
+ for col, typ in dtypes.items()
633
+ }
634
+ return add_missing_cols_to_df(empty_df, pd_dtypes)
635
+
618
636
  if df is None:
619
- return df, df, df
637
+ empty_df = get_empty_df()
638
+ return empty_df, empty_df, empty_df
639
+
620
640
  if (df.empty if not is_dask else len(df) == 0):
621
641
  return df, df, df
622
642
 
@@ -633,7 +653,7 @@ def filter_existing(
633
653
  if min_dt_val is not None and 'datetime' in str(dt_type)
634
654
  else min_dt_val
635
655
  )
636
- except Exception as e:
656
+ except Exception:
637
657
  min_dt = None
638
658
  if not ('datetime' in str(type(min_dt))) or str(min_dt) == 'NaT':
639
659
  if 'int' not in str(type(min_dt)).lower():
@@ -643,7 +663,7 @@ def filter_existing(
643
663
  begin = (
644
664
  round_time(
645
665
  min_dt,
646
- to = 'down'
666
+ to='down'
647
667
  ) - timedelta(minutes=1)
648
668
  )
649
669
  elif dt_type and 'int' in dt_type.lower():
@@ -661,7 +681,7 @@ def filter_existing(
661
681
  if max_dt_val is not None and 'datetime' in str(dt_type)
662
682
  else max_dt_val
663
683
  )
664
- except Exception as e:
684
+ except Exception:
665
685
  import traceback
666
686
  traceback.print_exc()
667
687
  max_dt = None
@@ -674,14 +694,14 @@ def filter_existing(
674
694
  end = (
675
695
  round_time(
676
696
  max_dt,
677
- to = 'down'
697
+ to='down'
678
698
  ) + timedelta(minutes=1)
679
699
  )
680
700
  elif dt_type and 'int' in dt_type.lower():
681
701
  end = max_dt + 1
682
702
 
683
703
  if max_dt is not None and min_dt is not None and min_dt > max_dt:
684
- warn(f"Detected minimum datetime greater than maximum datetime.")
704
+ warn("Detected minimum datetime greater than maximum datetime.")
685
705
 
686
706
  if begin is not None and end is not None and begin > end:
687
707
  if isinstance(begin, datetime):
@@ -710,13 +730,18 @@ def filter_existing(
710
730
  dprint(f"Looking at data between '{begin}' and '{end}':", **kw)
711
731
 
712
732
  backtrack_df = self.get_data(
713
- begin = begin,
714
- end = end,
715
- chunksize = chunksize,
716
- params = params,
717
- debug = debug,
733
+ begin=begin,
734
+ end=end,
735
+ chunksize=chunksize,
736
+ params=params,
737
+ debug=debug,
718
738
  **kw
719
739
  )
740
+ if backtrack_df is None:
741
+ if debug:
742
+ dprint(f"No backtrack data was found for {self}.")
743
+ return df, get_empty_df(), df
744
+
720
745
  if debug:
721
746
  dprint(f"Existing data for {self}:\n" + str(backtrack_df), **kw)
722
747
  dprint(f"Existing dtypes for {self}:\n" + str(backtrack_df.dtypes))
@@ -743,18 +768,19 @@ def filter_existing(
743
768
  filter_unseen_df(
744
769
  backtrack_df,
745
770
  df,
746
- dtypes = {
771
+ dtypes={
747
772
  col: to_pandas_dtype(typ)
748
773
  for col, typ in self_dtypes.items()
749
774
  },
750
- safe_copy = safe_copy,
751
- debug = debug
775
+ safe_copy=safe_copy,
776
+ debug=debug
752
777
  ),
753
778
  on_cols_dtypes,
754
779
  )
755
780
 
756
781
  ### Cast dicts or lists to strings so we can merge.
757
782
  serializer = functools.partial(json.dumps, sort_keys=True, separators=(',', ':'), default=str)
783
+
758
784
  def deserializer(x):
759
785
  return json.loads(x) if isinstance(x, str) else x
760
786
 
@@ -767,12 +793,12 @@ def filter_existing(
767
793
  casted_cols = set(unhashable_delta_cols + unhashable_backtrack_cols)
768
794
 
769
795
  joined_df = merge(
770
- delta_df.fillna(NA),
771
- backtrack_df.fillna(NA),
772
- how = 'left',
773
- on = on_cols,
774
- indicator = True,
775
- suffixes = ('', '_old'),
796
+ delta_df.infer_objects(copy=False).fillna(NA),
797
+ backtrack_df.infer_objects(copy=False).fillna(NA),
798
+ how='left',
799
+ on=on_cols,
800
+ indicator=True,
801
+ suffixes=('', '_old'),
776
802
  ) if on_cols else delta_df
777
803
  for col in casted_cols:
778
804
  if col in joined_df.columns:
@@ -782,20 +808,13 @@ def filter_existing(
782
808
 
783
809
  ### Determine which rows are completely new.
784
810
  new_rows_mask = (joined_df['_merge'] == 'left_only') if on_cols else None
785
- cols = list(backtrack_df.columns)
811
+ cols = list(delta_df.columns)
786
812
 
787
813
  unseen_df = (
788
- (
789
- joined_df
790
- .where(new_rows_mask)
791
- .dropna(how='all')[cols]
792
- .reset_index(drop=True)
793
- ) if not is_dask else (
794
- joined_df
795
- .where(new_rows_mask)
796
- .dropna(how='all')[cols]
797
- .reset_index(drop=True)
798
- )
814
+ joined_df
815
+ .where(new_rows_mask)
816
+ .dropna(how='all')[cols]
817
+ .reset_index(drop=True)
799
818
  ) if on_cols else delta_df
800
819
 
801
820
  ### Rows that have already been inserted but values have changed.
@@ -804,20 +823,33 @@ def filter_existing(
804
823
  .where(~new_rows_mask)
805
824
  .dropna(how='all')[cols]
806
825
  .reset_index(drop=True)
807
- ) if on_cols else None
826
+ ) if on_cols else get_empty_df()
827
+
828
+ if include_unchanged_columns and on_cols:
829
+ unchanged_backtrack_cols = [
830
+ col
831
+ for col in backtrack_df.columns
832
+ if col in on_cols or col not in update_df.columns
833
+ ]
834
+ update_df = merge(
835
+ backtrack_df[unchanged_backtrack_cols],
836
+ update_df,
837
+ how='inner',
838
+ on=on_cols,
839
+ )
808
840
 
809
841
  return unseen_df, update_df, delta_df
810
842
 
811
843
 
812
844
  @staticmethod
813
845
  def _get_chunk_label(
814
- chunk: Union[
815
- 'pd.DataFrame',
816
- List[Dict[str, Any]],
817
- Dict[str, List[Any]]
818
- ],
819
- dt_col: str,
820
- ) -> str:
846
+ chunk: Union[
847
+ 'pd.DataFrame',
848
+ List[Dict[str, Any]],
849
+ Dict[str, List[Any]]
850
+ ],
851
+ dt_col: str,
852
+ ) -> str:
821
853
  """
822
854
  Return the min - max label for the chunk.
823
855
  """
@@ -870,3 +902,52 @@ def get_num_workers(self, workers: Optional[int] = None) -> int:
870
902
  (desired_workers - current_num_connections),
871
903
  1,
872
904
  )
905
+
906
+
907
+ def _persist_new_numeric_columns(self, df, debug: bool = False) -> SuccessTuple:
908
+ """
909
+ Check for new numeric columns and update the parameters.
910
+ """
911
+ from meerschaum.utils.dataframe import get_numeric_cols
912
+ numeric_cols = get_numeric_cols(df)
913
+ existing_numeric_cols = [col for col, typ in self.dtypes.items() if typ == 'numeric']
914
+ new_numeric_cols = [col for col in numeric_cols if col not in existing_numeric_cols]
915
+ if not new_numeric_cols:
916
+ return True, "Success"
917
+
918
+ dtypes = self.parameters.get('dtypes', {})
919
+ dtypes.update({col: 'numeric' for col in numeric_cols})
920
+ self.parameters['dtypes'] = dtypes
921
+ if not self.temporary:
922
+ edit_success, edit_msg = self.edit(interactive=False, debug=debug)
923
+ if not edit_success:
924
+ warn(f"Unable to update NUMERIC dtypes for {self}:\n{edit_msg}")
925
+
926
+ return edit_success, edit_msg
927
+
928
+ return True, "Success"
929
+
930
+
931
+ def _persist_new_json_columns(self, df, debug: bool = False) -> SuccessTuple:
932
+ """
933
+ Check for new JSON columns and update the parameters.
934
+ """
935
+ from meerschaum.utils.dataframe import get_json_cols
936
+ json_cols = get_json_cols(df)
937
+ existing_json_cols = [col for col, typ in self.dtypes.items() if typ == 'json']
938
+ new_json_cols = [col for col in json_cols if col not in existing_json_cols]
939
+ if not new_json_cols:
940
+ return True, "Success"
941
+
942
+ dtypes = self.parameters.get('dtypes', {})
943
+ dtypes.update({col: 'json' for col in json_cols})
944
+ self.parameters['dtypes'] = dtypes
945
+
946
+ if not self.temporary:
947
+ edit_success, edit_msg = self.edit(interactive=False, debug=debug)
948
+ if not edit_success:
949
+ warn(f"Unable to update JSON dtypes for {self}:\n{edit_msg}")
950
+
951
+ return edit_success, edit_msg
952
+
953
+ return True, "Success"
@@ -12,17 +12,17 @@ from meerschaum.utils.warnings import warn, info
12
12
  from meerschaum.utils.debug import dprint
13
13
 
14
14
  def verify(
15
- self,
16
- begin: Union[datetime, int, None] = None,
17
- end: Union[datetime, int, None] = None,
18
- params: Optional[Dict[str, Any]] = None,
19
- chunk_interval: Union[timedelta, int, None] = None,
20
- bounded: Optional[bool] = None,
21
- deduplicate: bool = False,
22
- workers: Optional[int] = None,
23
- debug: bool = False,
24
- **kwargs: Any
25
- ) -> SuccessTuple:
15
+ self,
16
+ begin: Union[datetime, int, None] = None,
17
+ end: Union[datetime, int, None] = None,
18
+ params: Optional[Dict[str, Any]] = None,
19
+ chunk_interval: Union[timedelta, int, None] = None,
20
+ bounded: Optional[bool] = None,
21
+ deduplicate: bool = False,
22
+ workers: Optional[int] = None,
23
+ debug: bool = False,
24
+ **kwargs: Any
25
+ ) -> SuccessTuple:
26
26
  """
27
27
  Verify the contents of the pipe by resyncing its interval.
28
28
 
@@ -11,7 +11,7 @@ import os
11
11
  import hashlib
12
12
  import hmac
13
13
  from binascii import b2a_base64, a2b_base64, Error as _BinAsciiError
14
- from meerschaum.utils.typing import Optional, Dict, Any, Tuple
14
+ from meerschaum.utils.typing import Optional, Dict, Any, Union
15
15
  from meerschaum.config.static import STATIC_CONFIG
16
16
  from meerschaum.utils.warnings import warn
17
17
 
@@ -19,10 +19,10 @@ from meerschaum.utils.warnings import warn
19
19
  __all__ = ('hash_password', 'verify_password', 'User')
20
20
 
21
21
  def hash_password(
22
- password: str,
23
- salt: Optional[bytes] = None,
24
- rounds: Optional[int] = None,
25
- ) -> str:
22
+ password: str,
23
+ salt: Optional[bytes] = None,
24
+ rounds: Optional[int] = None,
25
+ ) -> str:
26
26
  """
27
27
  Return an encoded hash string from the given password.
28
28
 
@@ -68,9 +68,9 @@ def hash_password(
68
68
 
69
69
 
70
70
  def verify_password(
71
- password: str,
72
- password_hash: str,
73
- ) -> bool:
71
+ password: str,
72
+ password_hash: str,
73
+ ) -> bool:
74
74
  """
75
75
  Return `True` if the password matches the provided hash.
76
76
 
@@ -197,26 +197,28 @@ class User:
197
197
  return self._attributes
198
198
 
199
199
  @property
200
- def instance_connector(self) -> meerschaum.connectors.Connector:
201
- """ """
200
+ def instance_connector(self) -> 'mrsm.connectors.Connector':
202
201
  from meerschaum.connectors.parse import parse_instance_keys
203
202
  if '_instance_connector' not in self.__dict__:
204
203
  self._instance_connector = parse_instance_keys(self._instance_keys)
205
204
  return self._instance_connector
206
205
 
207
206
  @property
208
- def user_id(self) -> int:
207
+ def user_id(self) -> Union[int, str, None]:
209
208
  """NOTE: This causes recursion with the API,
210
209
  so don't try to get fancy with read-only attributes.
211
210
  """
212
211
  return self._user_id
213
212
 
214
213
  @user_id.setter
215
- def user_id(self, user_id):
214
+ def user_id(self, user_id: Union[int, str, None]):
216
215
  self._user_id = user_id
217
216
 
218
217
  @property
219
218
  def password_hash(self):
219
+ """
220
+ Return the hash of the user's password.
221
+ """
220
222
  _password_hash = self.__dict__.get('_password_hash', None)
221
223
  if _password_hash is not None:
222
224
  return _password_hash
meerschaum/jobs/_Job.py CHANGED
@@ -10,10 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import shlex
12
12
  import asyncio
13
- import threading
14
- import json
15
13
  import pathlib
16
- import os
17
14
  import sys
18
15
  import traceback
19
16
  from functools import partial
@@ -60,9 +57,10 @@ class Job:
60
57
  sysargs: Union[List[str], str, None] = None,
61
58
  env: Optional[Dict[str, str]] = None,
62
59
  executor_keys: Optional[str] = None,
60
+ delete_after_completion: bool = False,
63
61
  _properties: Optional[Dict[str, Any]] = None,
64
- _rotating_log = None,
65
- _stdin_file = None,
62
+ _rotating_log=None,
63
+ _stdin_file=None,
66
64
  _status_hook: Optional[Callable[[], str]] = None,
67
65
  _result_hook: Optional[Callable[[], SuccessTuple]] = None,
68
66
  _externally_managed: bool = False,
@@ -85,6 +83,9 @@ class Job:
85
83
  executor_keys: Optional[str], default None
86
84
  If provided, execute the job remotely on an API instance, e.g. 'api:main'.
87
85
 
86
+ delete_after_completion: bool, default False
87
+ If `True`, delete this job when it has finished executing.
88
+
88
89
  _properties: Optional[Dict[str, Any]], default None
89
90
  If provided, use this to patch the daemon's properties.
90
91
  """
@@ -146,6 +147,9 @@ class Job:
146
147
  if env:
147
148
  self._properties_patch.update({'env': env})
148
149
 
150
+ if delete_after_completion:
151
+ self._properties_patch.update({'delete_after_completion': delete_after_completion})
152
+
149
153
  daemon_sysargs = (
150
154
  self._daemon.properties.get('target', {}).get('args', [None])[0]
151
155
  if self._daemon is not None
@@ -199,13 +203,11 @@ class Job:
199
203
  jobs_dir = root_dir / DAEMON_RESOURCES_PATH.name
200
204
  daemon_dir = jobs_dir / daemon_id
201
205
  pid_file = daemon_dir / 'process.pid'
202
- properties_path = daemon_dir / 'properties.json'
203
- pickle_path = daemon_dir / 'pickle.pkl'
204
206
 
205
207
  if pid_file.exists():
206
208
  with open(pid_file, 'r', encoding='utf-8') as f:
207
209
  daemon_pid = int(f.read())
208
-
210
+
209
211
  if pid != daemon_pid:
210
212
  raise EnvironmentError(f"Differing PIDs: {pid=}, {daemon_pid=}")
211
213
  else:
@@ -245,7 +247,7 @@ class Job:
245
247
  return True, f"{self} is already running."
246
248
 
247
249
  success, msg = self.daemon.run(
248
- keep_daemon_output=True,
250
+ keep_daemon_output=(not self.delete_after_completion),
249
251
  allow_dirty_run=True,
250
252
  )
251
253
  if not success:
@@ -407,7 +409,6 @@ class Job:
407
409
  )
408
410
  return asyncio.run(monitor_logs_coroutine)
409
411
 
410
-
411
412
  async def monitor_logs_async(
412
413
  self,
413
414
  callback_function: Callable[[str], None] = partial(print, end='', flush=True),
@@ -418,8 +419,8 @@ class Job:
418
419
  strip_timestamps: bool = False,
419
420
  accept_input: bool = True,
420
421
  _logs_path: Optional[pathlib.Path] = None,
421
- _log = None,
422
- _stdin_file = None,
422
+ _log=None,
423
+ _stdin_file=None,
423
424
  debug: bool = False,
424
425
  ):
425
426
  """
@@ -466,6 +467,7 @@ class Job:
466
467
  input_callback_function=input_callback_function,
467
468
  stop_callback_function=stop_callback_function,
468
469
  stop_on_exit=stop_on_exit,
470
+ strip_timestamps=strip_timestamps,
469
471
  accept_input=accept_input,
470
472
  debug=debug,
471
473
  )
@@ -557,7 +559,6 @@ class Job:
557
559
  for task in pending:
558
560
  task.cancel()
559
561
  except asyncio.exceptions.CancelledError:
560
- print('cancelled?')
561
562
  pass
562
563
  finally:
563
564
  combined_event.set()
@@ -870,7 +871,9 @@ class Job:
870
871
  """
871
872
  Return the job's Daemon label (joined sysargs).
872
873
  """
873
- return shlex.join(self.sysargs).replace(' + ', '\n+ ')
874
+ from meerschaum._internal.arguments import compress_pipeline_sysargs
875
+ sysargs = compress_pipeline_sysargs(self.sysargs)
876
+ return shlex.join(sysargs).replace(' + ', '\n+ ')
874
877
 
875
878
  @property
876
879
  def _externally_managed_file(self) -> pathlib.Path:
@@ -916,6 +919,16 @@ class Job:
916
919
  self._env = {**default_env, **_env}
917
920
  return self._env
918
921
 
922
+ @property
923
+ def delete_after_completion(self) -> bool:
924
+ """
925
+ Return whether this job is configured to delete itself after completion.
926
+ """
927
+ if '_delete_after_completion' in self.__dict__:
928
+ return self.__dict__.get('_delete_after_completion', False)
929
+
930
+ self._delete_after_completion = self.daemon.properties.get('delete_after_completion', False)
931
+ return self._delete_after_completion
919
932
 
920
933
  def __str__(self) -> str:
921
934
  sysargs = self.sysargs
@@ -9,9 +9,9 @@ Higher-level utilities for managing `meerschaum.utils.daemon.Daemon`.
9
9
  import pathlib
10
10
 
11
11
  import meerschaum as mrsm
12
- from meerschaum.utils.typing import Dict, Optional, List, Callable, Any, SuccessTuple
12
+ from meerschaum.utils.typing import Dict, Optional, List, SuccessTuple
13
13
 
14
- from meerschaum.jobs._Job import Job, StopMonitoringLogs
14
+ from meerschaum.jobs._Job import Job
15
15
  from meerschaum.jobs._Executor import Executor
16
16
 
17
17
  __all__ = (
@@ -403,9 +403,14 @@ def get_executor_keys_from_context() -> str:
403
403
  if _context_keys is not None:
404
404
  return _context_keys
405
405
 
406
+ from meerschaum.config import get_config
406
407
  from meerschaum.config.paths import ROOT_DIR_PATH, DEFAULT_ROOT_DIR_PATH
407
408
  from meerschaum.utils.misc import is_systemd_available
408
409
 
410
+ configured_executor = get_config('meerschaum', 'executor', warn=False)
411
+ if configured_executor is not None:
412
+ return configured_executor
413
+
409
414
  _context_keys = (
410
415
  'systemd'
411
416
  if is_systemd_available() and ROOT_DIR_PATH == DEFAULT_ROOT_DIR_PATH
@@ -42,7 +42,12 @@ class SystemdExecutor(Executor):
42
42
  return [
43
43
  service_name[len('mrsm-'):(-1 * len('.service'))]
44
44
  for service_name in os.listdir(SYSTEMD_USER_RESOURCES_PATH)
45
- if service_name.startswith('mrsm-') and service_name.endswith('.service')
45
+ if (
46
+ service_name.startswith('mrsm-')
47
+ and service_name.endswith('.service')
48
+ ### Check for broken symlinks.
49
+ and (SYSTEMD_USER_RESOURCES_PATH / service_name).exists()
50
+ )
46
51
  ]
47
52
 
48
53
  def get_job_exists(self, name: str, debug: bool = False) -> bool:
@@ -146,6 +151,11 @@ class SystemdExecutor(Executor):
146
151
  STATIC_CONFIG['environment']['systemd_log_path']: service_logs_path.as_posix(),
147
152
  STATIC_CONFIG['environment']['systemd_result_path']: result_path.as_posix(),
148
153
  STATIC_CONFIG['environment']['systemd_stdin_path']: socket_path.as_posix(),
154
+ STATIC_CONFIG['environment']['systemd_delete_job']: (
155
+ '1'
156
+ if job.delete_after_completion
157
+ else '0',
158
+ ),
149
159
  })
150
160
 
151
161
  ### Allow for user-defined environment variables.
@@ -603,7 +613,8 @@ class SystemdExecutor(Executor):
603
613
 
604
614
  check_timeout_interval = get_config('jobs', 'check_timeout_interval_seconds')
605
615
  loop_start = time.perf_counter()
606
- while (time.perf_counter() - loop_start) < get_config('jobs', 'timeout_seconds'):
616
+ timeout_seconds = get_config('jobs', 'timeout_seconds')
617
+ while (time.perf_counter() - loop_start) < timeout_seconds:
607
618
  if self.get_job_status(name, debug=debug) == 'stopped':
608
619
  return True, 'Success'
609
620
 
@@ -630,12 +641,14 @@ class SystemdExecutor(Executor):
630
641
  Delete a job's service.
631
642
  """
632
643
  from meerschaum.config.paths import SYSTEMD_LOGS_RESOURCES_PATH
644
+ job = self.get_hidden_job(name, debug=debug)
633
645
 
634
- _ = self.stop_job(name, debug=debug)
635
- _ = self.run_command(
636
- ['disable', self.get_service_name(name, debug=debug)],
637
- debug=debug,
638
- )
646
+ if not job.delete_after_completion:
647
+ _ = self.stop_job(name, debug=debug)
648
+ _ = self.run_command(
649
+ ['disable', self.get_service_name(name, debug=debug)],
650
+ debug=debug,
651
+ )
639
652
 
640
653
  service_job_path = self.get_service_job_path(name, debug=debug)
641
654
  try:
@@ -666,7 +679,6 @@ class SystemdExecutor(Executor):
666
679
  warn(e)
667
680
  return False, str(e)
668
681
 
669
- job = self.get_hidden_job(name, debug=debug)
670
682
  _ = job.delete()
671
683
 
672
684
  return self.run_command(['daemon-reload'], debug=debug)