Flowfile 0.5.3__py3-none-any.whl → 0.5.6__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 (153) hide show
  1. flowfile/__init__.py +16 -0
  2. flowfile/__main__.py +94 -1
  3. flowfile/web/static/assets/{AdminView-49392a9a.js → AdminView-c2c7942b.js} +1 -1
  4. flowfile/web/static/assets/{CloudConnectionView-f13f202b.js → CloudConnectionView-7a3042c6.js} +4 -4
  5. flowfile/web/static/assets/{CloudConnectionView-36bcd6df.css → CloudConnectionView-cf85f943.css} +17 -17
  6. flowfile/web/static/assets/{CloudStorageReader-0023d4a5.js → CloudStorageReader-709c4037.js} +8 -8
  7. flowfile/web/static/assets/{CloudStorageWriter-8e781e11.js → CloudStorageWriter-604c51a8.js} +8 -8
  8. flowfile/web/static/assets/ColumnActionInput-c44b7aee.css +159 -0
  9. flowfile/web/static/assets/ColumnActionInput-d63d6746.js +330 -0
  10. flowfile/web/static/assets/{ColumnSelector-8ad68ea9.js → ColumnSelector-0c8cd1cd.js} +1 -1
  11. flowfile/web/static/assets/ContextMenu-366bf1b4.js +9 -0
  12. flowfile/web/static/assets/ContextMenu-85cf5b44.js +9 -0
  13. flowfile/web/static/assets/ContextMenu-9d28ae6d.js +9 -0
  14. flowfile/web/static/assets/ContextMenu.vue_vue_type_script_setup_true_lang-774c517c.js +59 -0
  15. flowfile/web/static/assets/{CrossJoin-03df6938.js → CrossJoin-38e5b99a.js} +9 -9
  16. flowfile/web/static/assets/{CustomNode-8479239b.js → CustomNode-76e8f3f5.js} +27 -20
  17. flowfile/web/static/assets/CustomNode-edb9b939.css +42 -0
  18. flowfile/web/static/assets/{DatabaseConnectionSettings-869e3efd.js → DatabaseConnectionSettings-38155669.js} +4 -4
  19. flowfile/web/static/assets/{DatabaseConnectionSettings-e91df89a.css → DatabaseConnectionSettings-c20a1e16.css} +22 -20
  20. flowfile/web/static/assets/{DatabaseReader-c58b9552.js → DatabaseReader-2e549c8f.js} +13 -13
  21. flowfile/web/static/assets/{DatabaseReader-36898a00.css → DatabaseReader-5bf8c75b.css} +39 -44
  22. flowfile/web/static/assets/{DatabaseView-d26a9140.js → DatabaseView-dc877c29.js} +2 -2
  23. flowfile/web/static/assets/{DatabaseWriter-217a99f1.css → DatabaseWriter-bdcf2c8b.css} +27 -25
  24. flowfile/web/static/assets/{DatabaseWriter-4d05ddc7.js → DatabaseWriter-ffb91864.js} +12 -12
  25. flowfile/web/static/assets/{DesignerView-a6d0ee84.css → DesignerView-71d4e9a1.css} +429 -376
  26. flowfile/web/static/assets/{DesignerView-e6f5c0e8.js → DesignerView-a4466dab.js} +338 -183
  27. flowfile/web/static/assets/{DocumentationView-2e78ef1b.js → DocumentationView-979afc84.js} +3 -3
  28. flowfile/web/static/assets/{DocumentationView-fd46c656.css → DocumentationView-9ea6e871.css} +9 -9
  29. flowfile/web/static/assets/{ExploreData-7b54caca.js → ExploreData-e4b92aaf.js} +7 -7
  30. flowfile/web/static/assets/{ExternalSource-47ab05a3.css → ExternalSource-7ac7373f.css} +17 -17
  31. flowfile/web/static/assets/{ExternalSource-3fa399b2.js → ExternalSource-d08e7227.js} +9 -9
  32. flowfile/web/static/assets/{Filter-8cbbdbf3.js → Filter-7add806d.js} +9 -9
  33. flowfile/web/static/assets/{Formula-aac42b1e.js → Formula-36ab24d2.js} +9 -9
  34. flowfile/web/static/assets/{FuzzyMatch-cd9bbfca.js → FuzzyMatch-cc01bb04.js} +10 -10
  35. flowfile/web/static/assets/{GraphSolver-c24dec17.css → GraphSolver-4b4d7db9.css} +4 -4
  36. flowfile/web/static/assets/{GraphSolver-c7e6780e.js → GraphSolver-4fb98f3b.js} +11 -11
  37. flowfile/web/static/assets/GroupBy-5792782d.css +9 -0
  38. flowfile/web/static/assets/{GroupBy-93c5d22b.js → GroupBy-b3c8f429.js} +9 -9
  39. flowfile/web/static/assets/{Join-a19b2de2.js → Join-096b7b26.js} +10 -10
  40. flowfile/web/static/assets/{LoginView-0df4ed0a.js → LoginView-c33a246a.js} +1 -1
  41. flowfile/web/static/assets/{ManualInput-3702e677.css → ManualInput-39111f19.css} +48 -48
  42. flowfile/web/static/assets/{ManualInput-8d3374b2.js → ManualInput-7307e9b1.js} +55 -13
  43. flowfile/web/static/assets/{MultiSelect-ad1b6243.js → MultiSelect-14822c48.js} +2 -2
  44. flowfile/web/static/assets/{MultiSelect.vue_vue_type_script_setup_true_lang-e278950d.js → MultiSelect.vue_vue_type_script_setup_true_lang-90c4d340.js} +1 -1
  45. flowfile/web/static/assets/{NodeDesigner-40b647c9.js → NodeDesigner-5036c392.js} +171 -69
  46. flowfile/web/static/assets/{NodeDesigner-5f53be3f.css → NodeDesigner-94cd4dd3.css} +190 -190
  47. flowfile/web/static/assets/{NumericInput-7100234c.js → NumericInput-15cf3b72.js} +2 -2
  48. flowfile/web/static/assets/{NumericInput.vue_vue_type_script_setup_true_lang-5130219f.js → NumericInput.vue_vue_type_script_setup_true_lang-91e679d7.js} +1 -1
  49. flowfile/web/static/assets/{Output-f5efd2aa.js → Output-1f8ed42c.js} +13 -12
  50. flowfile/web/static/assets/{Output-35e97000.css → Output-692dd25d.css} +10 -10
  51. flowfile/web/static/assets/{Pivot-d981d23c.js → Pivot-0e153f4e.js} +10 -10
  52. flowfile/web/static/assets/{PivotValidation-63de1f73.js → PivotValidation-5a4f7c79.js} +1 -1
  53. flowfile/web/static/assets/{PivotValidation-39386e95.js → PivotValidation-81ec2a33.js} +1 -1
  54. flowfile/web/static/assets/{PolarsCode-f9d69217.js → PolarsCode-a39f15ac.js} +7 -7
  55. flowfile/web/static/assets/PopOver-ddcfe4f6.js +138 -0
  56. flowfile/web/static/assets/{Read-aec2e377.js → Read-39b63932.js} +15 -14
  57. flowfile/web/static/assets/{Read-36e7bd51.css → Read-90f366bc.css} +13 -13
  58. flowfile/web/static/assets/{RecordCount-78ed6845.js → RecordCount-e9048ccd.js} +6 -6
  59. flowfile/web/static/assets/{RecordId-2156e890.js → RecordId-ad02521d.js} +9 -9
  60. flowfile/web/static/assets/{SQLQueryComponent-48c72f5b.js → SQLQueryComponent-2eeecf0b.js} +3 -3
  61. flowfile/web/static/assets/SQLQueryComponent-edb90b98.css +29 -0
  62. flowfile/web/static/assets/{Sample-1352ca74.js → Sample-9a68c23d.js} +6 -6
  63. flowfile/web/static/assets/{SecretSelector-22b5ff89.js → SecretSelector-2429f35a.js} +2 -2
  64. flowfile/web/static/assets/{SecretsView-17df66ee.js → SecretsView-c6afc915.js} +2 -2
  65. flowfile/web/static/assets/{Select-0aee4c54.js → Select-fcd002b6.js} +9 -9
  66. flowfile/web/static/assets/{SettingsSection-cd341bb6.js → SettingsSection-5ce15962.js} +1 -1
  67. flowfile/web/static/assets/{SettingsSection-0784e157.js → SettingsSection-c6b1362c.js} +1 -1
  68. flowfile/web/static/assets/{SettingsSection-f2002a6d.js → SettingsSection-cebb91d5.js} +1 -1
  69. flowfile/web/static/assets/SetupView-2d12e01f.js +160 -0
  70. flowfile/web/static/assets/SetupView-ec26f76a.css +230 -0
  71. flowfile/web/static/assets/{SingleSelect-460cc0ea.js → SingleSelect-b67de4eb.js} +2 -2
  72. flowfile/web/static/assets/{SingleSelect.vue_vue_type_script_setup_true_lang-30741bb2.js → SingleSelect.vue_vue_type_script_setup_true_lang-eedb70eb.js} +1 -1
  73. flowfile/web/static/assets/{SliderInput-5d926864.js → SliderInput-fd8134ac.js} +1 -1
  74. flowfile/web/static/assets/Sort-4abb7fae.css +9 -0
  75. flowfile/web/static/assets/{Sort-3cdc971b.js → Sort-c005a573.js} +9 -9
  76. flowfile/web/static/assets/{TextInput-a2d0bfbd.js → TextInput-1bb31dab.js} +2 -2
  77. flowfile/web/static/assets/{TextInput.vue_vue_type_script_setup_true_lang-abad1ca2.js → TextInput.vue_vue_type_script_setup_true_lang-a51fe730.js} +1 -1
  78. flowfile/web/static/assets/{TextToRows-918945f7.js → TextToRows-4f363753.js} +9 -9
  79. flowfile/web/static/assets/{ToggleSwitch-f0ef5196.js → ToggleSwitch-ca0f2e5e.js} +2 -2
  80. flowfile/web/static/assets/{ToggleSwitch.vue_vue_type_script_setup_true_lang-5605c793.js → ToggleSwitch.vue_vue_type_script_setup_true_lang-49aa41d8.js} +1 -1
  81. flowfile/web/static/assets/{UnavailableFields-54d2f518.css → UnavailableFields-394a1f78.css} +13 -13
  82. flowfile/web/static/assets/{UnavailableFields-bdad6144.js → UnavailableFields-f6147968.js} +4 -4
  83. flowfile/web/static/assets/{Union-e8ab8c86.js → Union-c65f17b7.js} +6 -6
  84. flowfile/web/static/assets/Unique-2b705521.css +3 -0
  85. flowfile/web/static/assets/{Unique-8cd4f976.js → Unique-a1d96fb2.js} +12 -12
  86. flowfile/web/static/assets/{Unpivot-710a2948.css → Unpivot-b6ad6427.css} +6 -6
  87. flowfile/web/static/assets/{Unpivot-8da14095.js → Unpivot-c2657ff3.js} +11 -11
  88. flowfile/web/static/assets/{UnpivotValidation-6f7d89ff.js → UnpivotValidation-28e29a3b.js} +1 -1
  89. flowfile/web/static/assets/{VueGraphicWalker-3fb312e1.js → VueGraphicWalker-2fc3ddd4.js} +1 -1
  90. flowfile/web/static/assets/{api-24483f0d.js → api-df48ec50.js} +1 -1
  91. flowfile/web/static/assets/{api-8b81fa73.js → api-ee542cf7.js} +1 -1
  92. flowfile/web/static/assets/{dropDown-3d8dc5fa.css → dropDown-1d6acbd9.css} +26 -26
  93. flowfile/web/static/assets/{dropDown-ac0fda9d.js → dropDown-7576a76a.js} +3 -3
  94. flowfile/web/static/assets/{fullEditor-5497a84a.js → fullEditor-7583bef5.js} +3 -3
  95. flowfile/web/static/assets/{fullEditor-a0be62b3.css → fullEditor-fe9f7e18.css} +3 -3
  96. flowfile/web/static/assets/{genericNodeSettings-99014e1d.js → genericNodeSettings-0155288b.js} +2 -3
  97. flowfile/web/static/assets/{index-3ba44389.js → index-057d770d.js} +2 -2
  98. flowfile/web/static/assets/{index-07dda503.js → index-aeec439d.js} +1 -1
  99. flowfile/web/static/assets/{index-fb6493ae.js → index-ca6799de.js} +2293 -196
  100. flowfile/web/static/assets/{index-e6289dd0.css → index-d60c9dd4.css} +560 -10
  101. flowfile/web/static/assets/nodeInput-d478b9ac.js +2 -0
  102. flowfile/web/static/assets/{outputCsv-8f8ba42d.js → outputCsv-c492b15e.js} +3 -3
  103. flowfile/web/static/assets/outputCsv-cc84e09f.css +2499 -0
  104. flowfile/web/static/assets/{outputExcel-393f4fef.js → outputExcel-13bfa10f.js} +1 -1
  105. flowfile/web/static/assets/{outputParquet-07c81f65.js → outputParquet-9be1523a.js} +1 -1
  106. flowfile/web/static/assets/{readCsv-07f6d9ad.js → readCsv-5a49a8c9.js} +1 -1
  107. flowfile/web/static/assets/{readExcel-ed69bc8f.js → readExcel-27c30ad8.js} +3 -3
  108. flowfile/web/static/assets/{readParquet-e3ed4528.js → readParquet-446bde68.js} +1 -1
  109. flowfile/web/static/assets/{secrets.api-002e7d7e.js → secrets.api-34431884.js} +1 -1
  110. flowfile/web/static/assets/{selectDynamic-80b92899.js → selectDynamic-5754a2b1.js} +2 -3
  111. flowfile/web/static/assets/{vue-codemirror.esm-0965f39f.js → vue-codemirror.esm-8f46fb36.js} +1 -1
  112. flowfile/web/static/assets/{vue-content-loader.es-c506ad97.js → vue-content-loader.es-808fe33a.js} +1 -1
  113. flowfile/web/static/index.html +2 -2
  114. {flowfile-0.5.3.dist-info → flowfile-0.5.6.dist-info}/METADATA +2 -2
  115. {flowfile-0.5.3.dist-info → flowfile-0.5.6.dist-info}/RECORD +139 -134
  116. flowfile_core/auth/secrets.py +56 -13
  117. flowfile_core/fileExplorer/funcs.py +26 -4
  118. flowfile_core/flowfile/code_generator/__init__.py +11 -0
  119. flowfile_core/flowfile/code_generator/code_generator.py +347 -2
  120. flowfile_core/flowfile/flow_data_engine/flow_data_engine.py +13 -1
  121. flowfile_core/flowfile/flow_data_engine/subprocess_operations/subprocess_operations.py +12 -0
  122. flowfile_core/flowfile/flow_graph.py +2 -0
  123. flowfile_core/flowfile/flow_node/flow_node.py +52 -28
  124. flowfile_core/flowfile/node_designer/__init__.py +4 -0
  125. flowfile_core/flowfile/node_designer/ui_components.py +144 -1
  126. flowfile_core/main.py +2 -4
  127. flowfile_core/routes/public.py +43 -1
  128. flowfile_core/schemas/cloud_storage_schemas.py +39 -15
  129. flowfile_core/secret_manager/secret_manager.py +107 -6
  130. flowfile_frame/__init__.py +11 -0
  131. flowfile_frame/database/__init__.py +36 -0
  132. flowfile_frame/database/connection_manager.py +205 -0
  133. flowfile_frame/database/frame_helpers.py +249 -0
  134. flowfile_worker/configs.py +31 -15
  135. flowfile_worker/secrets.py +105 -15
  136. flowfile_worker/spawner.py +10 -6
  137. flowfile/web/static/assets/ContextMenu-26d4dd27.css +0 -26
  138. flowfile/web/static/assets/ContextMenu-31ee57f0.js +0 -41
  139. flowfile/web/static/assets/ContextMenu-69a74055.js +0 -41
  140. flowfile/web/static/assets/ContextMenu-8e2051c6.js +0 -41
  141. flowfile/web/static/assets/ContextMenu-8ec1729e.css +0 -26
  142. flowfile/web/static/assets/ContextMenu-9b310c60.css +0 -26
  143. flowfile/web/static/assets/CustomNode-59e99a86.css +0 -32
  144. flowfile/web/static/assets/GroupBy-be7ac0bf.css +0 -51
  145. flowfile/web/static/assets/PopOver-b22f049e.js +0 -939
  146. flowfile/web/static/assets/SQLQueryComponent-1c2f26b4.css +0 -27
  147. flowfile/web/static/assets/Sort-8a871341.css +0 -51
  148. flowfile/web/static/assets/Unique-9fb2f567.css +0 -51
  149. flowfile/web/static/assets/nodeInput-0eb13f1a.js +0 -2
  150. flowfile/web/static/assets/outputCsv-b9a072af.css +0 -2499
  151. {flowfile-0.5.3.dist-info → flowfile-0.5.6.dist-info}/WHEEL +0 -0
  152. {flowfile-0.5.3.dist-info → flowfile-0.5.6.dist-info}/entry_points.txt +0 -0
  153. {flowfile-0.5.3.dist-info → flowfile-0.5.6.dist-info}/licenses/LICENSE +0 -0
@@ -1,3 +1,4 @@
1
+ import threading
1
2
  from collections.abc import Callable, Generator
2
3
  from time import sleep
3
4
  from typing import Any, Literal, Optional
@@ -128,6 +129,7 @@ class FlowNode:
128
129
  self._cache_progress = None
129
130
  self._schema_callback = None
130
131
  self._state_needs_reset = False
132
+ self._execution_lock = threading.RLock() # Protects concurrent access to get_resulting_data
131
133
 
132
134
  @property
133
135
  def state_needs_reset(self) -> bool:
@@ -147,10 +149,11 @@ class FlowNode:
147
149
  """
148
150
  self._state_needs_reset = v
149
151
 
150
- @staticmethod
151
- def create_schema_callback_from_function(f: Callable) -> Callable[[], list[FlowfileColumn]]:
152
+ def create_schema_callback_from_function(self, f: Callable) -> Callable[[], list[FlowfileColumn]]:
152
153
  """Wraps a node's function to create a schema callback that extracts the schema.
153
154
 
155
+ Thread-safe: uses _execution_lock to prevent concurrent execution with get_resulting_data.
156
+
154
157
  Args:
155
158
  f: The node's core function that returns a FlowDataEngine instance.
156
159
 
@@ -161,7 +164,8 @@ class FlowNode:
161
164
  def schema_callback() -> list[FlowfileColumn]:
162
165
  try:
163
166
  logger.info("Executing the schema callback function based on the node function")
164
- return f().schema
167
+ with self._execution_lock:
168
+ return f().schema
165
169
  except Exception as e:
166
170
  logger.warning(f"Error with the schema callback: {e}")
167
171
  return []
@@ -575,6 +579,7 @@ class FlowNode:
575
579
  """Executes the node's function to produce the actual output data.
576
580
 
577
581
  Handles both regular functions and external data sources.
582
+ Thread-safe: uses _execution_lock to prevent concurrent execution.
578
583
 
579
584
  Returns:
580
585
  A FlowDataEngine instance containing the result, or None on error.
@@ -583,30 +588,40 @@ class FlowNode:
583
588
  Exception: Propagates exceptions from the node's function execution.
584
589
  """
585
590
  if self.is_setup:
586
- if self.results.resulting_data is None and self.results.errors is None:
587
- self.print("getting resulting data")
588
- try:
589
- if isinstance(self.function, FlowDataEngine):
590
- fl: FlowDataEngine = self.function
591
- elif self.node_type == "external_source":
592
- fl: FlowDataEngine = self.function()
593
- fl.collect_external()
594
- self.node_settings.streamable = False
595
- else:
596
- try:
597
- fl = self._function(*[v.get_resulting_data() for v in self.all_inputs])
598
- except Exception as e:
599
- raise e
600
- fl.set_streamable(self.node_settings.streamable)
601
- self.results.resulting_data = fl
602
- self.node_schema.result_schema = fl.schema
603
- except Exception as e:
604
- self.results.resulting_data = FlowDataEngine()
605
- self.results.errors = str(e)
606
- self.node_stats.has_run_with_current_setup = False
607
- self.node_stats.has_completed_last_run = False
608
- raise e
609
- return self.results.resulting_data
591
+ with self._execution_lock:
592
+ if self.results.resulting_data is None and self.results.errors is None:
593
+ self.print("getting resulting data")
594
+ try:
595
+ if isinstance(self.function, FlowDataEngine):
596
+ fl: FlowDataEngine = self.function
597
+ elif self.node_type == "external_source":
598
+ fl: FlowDataEngine = self.function()
599
+ fl.collect_external()
600
+ self.node_settings.streamable = False
601
+ else:
602
+ try:
603
+ self.print("Collecting input data from all inputs")
604
+ input_data = []
605
+ for i, v in enumerate(self.all_inputs):
606
+ self.print(f"Getting resulting data from input {i} (node {v.node_id})")
607
+ input_result = v.get_resulting_data()
608
+ self.print(f"Input {i} data type: {type(input_result)}, dataframe type: {type(input_result.data_frame) if input_result else 'None'}")
609
+ input_data.append(input_result)
610
+ self.print(f"All {len(input_data)} inputs collected, calling node function")
611
+ fl = self._function(*input_data)
612
+ self.print(f"Node function returned, result type: {type(fl)}")
613
+ except Exception as e:
614
+ raise e
615
+ fl.set_streamable(self.node_settings.streamable)
616
+ self.results.resulting_data = fl
617
+ self.node_schema.result_schema = fl.schema
618
+ except Exception as e:
619
+ self.results.resulting_data = FlowDataEngine()
620
+ self.results.errors = str(e)
621
+ self.node_stats.has_run_with_current_setup = False
622
+ self.node_stats.has_completed_last_run = False
623
+ raise e
624
+ return self.results.resulting_data
610
625
 
611
626
  def _predicted_data_getter(self) -> FlowDataEngine | None:
612
627
  """Internal helper to get a predicted data result.
@@ -844,11 +859,18 @@ class FlowNode:
844
859
  self.results.resulting_data = self.get_resulting_data()
845
860
  self.node_stats.has_run_with_current_setup = True
846
861
  return
862
+
847
863
  try:
848
- self.get_resulting_data()
864
+ result_data = self.get_resulting_data()
865
+ # Use 'is not None' instead of truthiness check to avoid triggering __len__()
866
+ # which calls .collect() on the LazyFrame and can cause issues
867
+ if result_data is None:
868
+ self.results.errors = "Error with creating the lazy frame, most likely due to invalid graph"
869
+ raise Exception("get_resulting_data returned None")
849
870
  except Exception as e:
850
871
  self.results.errors = "Error with creating the lazy frame, most likely due to invalid graph"
851
872
  raise e
873
+
852
874
  if not performance_mode:
853
875
  external_df_fetcher = ExternalDfFetcher(
854
876
  lf=self.get_resulting_data().data_frame,
@@ -858,6 +880,7 @@ class FlowNode:
858
880
  node_id=self.node_id,
859
881
  )
860
882
  self._fetch_cached_df = external_df_fetcher
883
+
861
884
  try:
862
885
  lf = external_df_fetcher.get_result()
863
886
  self.results.resulting_data = FlowDataEngine(
@@ -869,6 +892,7 @@ class FlowNode:
869
892
  node_id=self.node_id,
870
893
  ).result,
871
894
  )
895
+
872
896
  if not performance_mode:
873
897
  self.store_example_data_generator(external_df_fetcher)
874
898
  self.node_stats.has_run_with_current_setup = True
@@ -15,7 +15,9 @@ from .custom_node import CustomNodeBase, NodeSettings
15
15
 
16
16
  # Import all UI components so they can be used directly
17
17
  from .ui_components import (
18
+ ActionOption,
18
19
  AvailableSecrets,
20
+ ColumnActionInput,
19
21
  ColumnSelector,
20
22
  IncomingColumns,
21
23
  MultiSelect,
@@ -42,6 +44,8 @@ __all__ = [
42
44
  "MultiSelect",
43
45
  "NodeSettings",
44
46
  "ColumnSelector",
47
+ "ColumnActionInput",
48
+ "ActionOption",
45
49
  "IncomingColumns",
46
50
  "AvailableSecrets",
47
51
  "SecretSelector",
@@ -1,4 +1,4 @@
1
- from typing import Any, Literal
1
+ from typing import Any, Literal, NamedTuple
2
2
 
3
3
  from pydantic import BaseModel, Field, SecretStr, computed_field
4
4
 
@@ -11,6 +11,29 @@ from flowfile_core.types import DataType, TypeSpec
11
11
  InputType = Literal["text", "number", "secret", "array", "date", "boolean"]
12
12
 
13
13
 
14
+ class ActionOption(NamedTuple):
15
+ """
16
+ A named tuple representing an action option with a value and display label.
17
+
18
+ Use this to define actions with custom labels in ColumnActionInput:
19
+ actions=[
20
+ ActionOption("sum", "Sum"),
21
+ ActionOption("avg", "Average"),
22
+ "count" # plain strings also work
23
+ ]
24
+ """
25
+
26
+ value: str
27
+ """The internal value used in the data."""
28
+
29
+ label: str
30
+ """The display label shown in the UI."""
31
+
32
+
33
+ # Type alias for action specifications - accepts strings or ActionOption tuples
34
+ ActionSpec = str | ActionOption
35
+
36
+
14
37
  def normalize_input_to_data_types(v: Any) -> Literal["ALL"] | list[DataType]:
15
38
  """
16
39
  Normalizes a wide variety of inputs to either 'ALL' or a sorted list of DataType enums.
@@ -376,3 +399,123 @@ class SecretSelector(FlowfileInComponent):
376
399
  if self.name_prefix:
377
400
  data["name_prefix"] = self.name_prefix
378
401
  return data
402
+
403
+
404
+ class ColumnActionInput(FlowfileInComponent):
405
+ """
406
+ A generic UI component for configuring column-based transformations.
407
+
408
+ This component allows users to select columns, choose an action/transformation,
409
+ and optionally rename the output. It can be configured for many use cases:
410
+ rolling windows, aggregations, string transformations, type conversions, etc.
411
+
412
+ The component displays:
413
+ - A list of available columns (filterable by data type)
414
+ - A table of configured operations with: Column, Action, Output Name
415
+ - Optional group by and order by selectors
416
+
417
+ Example - Rolling Window:
418
+ ColumnActionInput(
419
+ label="Rolling Calculations",
420
+ actions=["sum", "mean", "min", "max"],
421
+ output_name_template="{column}_rolling_{action}",
422
+ show_group_by=True,
423
+ show_order_by=True,
424
+ data_types="Numeric"
425
+ )
426
+
427
+ Example - String Transformations:
428
+ ColumnActionInput(
429
+ label="String Operations",
430
+ actions=["upper", "lower", "trim", "reverse"],
431
+ output_name_template="{column}_{action}",
432
+ data_types="String"
433
+ )
434
+
435
+ Example - Aggregations with ActionOption:
436
+ ColumnActionInput(
437
+ label="Aggregations",
438
+ actions=[
439
+ ActionOption("sum", "Sum"),
440
+ ActionOption("count", "Count"),
441
+ ActionOption("mean", "Average"),
442
+ "min", # plain strings also work
443
+ ],
444
+ output_name_template="{column}_{action}",
445
+ show_group_by=True
446
+ )
447
+ """
448
+
449
+ component_type: Literal["ColumnActionInput"] = "ColumnActionInput"
450
+ input_type: InputType = "array"
451
+
452
+ # Configurable actions - list of action names or ActionOption tuples
453
+ actions: list[ActionSpec] = Field(default_factory=list)
454
+ """Actions available for selection. Can be strings or ActionOption(value, label) tuples."""
455
+
456
+ # Template for auto-generating output names
457
+ # Supports placeholders: {column}, {action}
458
+ output_name_template: str = "{column}_{action}"
459
+ """Template for generating default output names. Use {column} and {action} placeholders."""
460
+
461
+ # Optional grouping/ordering support
462
+ show_group_by: bool = False
463
+ """Whether to show the group by column selector."""
464
+
465
+ show_order_by: bool = False
466
+ """Whether to show the order by column selector."""
467
+
468
+ # Type filtering for column selection
469
+ data_type_filter_input: TypeSpec = Field(default="ALL", alias="data_types", repr=False, exclude=True)
470
+ """Filter columns by data type. Defaults to ALL."""
471
+
472
+ class Config:
473
+ arbitrary_types_allowed = True
474
+
475
+ def __init__(self, **data):
476
+ super().__init__(**data)
477
+ # Initialize value if not set
478
+ if self.value is None:
479
+ self.value = {
480
+ "rows": [],
481
+ "group_by_columns": [],
482
+ "order_by_column": None,
483
+ }
484
+
485
+ def set_value(self, value: Any):
486
+ """
487
+ Sets the value from frontend.
488
+ """
489
+ self.value = value
490
+ return self
491
+
492
+ @computed_field
493
+ @property
494
+ def data_types_filter(self) -> Literal["ALL"] | list[DataType]:
495
+ """
496
+ A computed field that normalizes the `data_type_filter_input` into a
497
+ standardized format for the frontend.
498
+ """
499
+ return normalize_input_to_data_types(self.data_type_filter_input)
500
+
501
+ def model_dump(self, **kwargs) -> dict:
502
+ """
503
+ Serializes the component for the frontend.
504
+ """
505
+ data = super().model_dump(**kwargs)
506
+ # Normalize actions to list of {value, label} objects for frontend
507
+ normalized_actions = []
508
+ for action in self.actions:
509
+ if isinstance(action, tuple):
510
+ normalized_actions.append({"value": action[0], "label": action[1]})
511
+ else:
512
+ normalized_actions.append({"value": action, "label": action})
513
+ data["actions"] = normalized_actions
514
+ data["output_name_template"] = self.output_name_template
515
+ data["show_group_by"] = self.show_group_by
516
+ data["show_order_by"] = self.show_order_by
517
+ if "data_types_filter" in data and data["data_types_filter"] != "ALL":
518
+ data["data_types"] = sorted([dt.value for dt in data["data_types_filter"]])
519
+ else:
520
+ data["data_types"] = "ALL"
521
+ return data
flowfile_core/main.py CHANGED
@@ -4,10 +4,9 @@ import signal
4
4
  from contextlib import asynccontextmanager
5
5
 
6
6
  import uvicorn
7
- from fastapi import FastAPI
7
+ from fastapi import BackgroundTasks, FastAPI
8
8
  from fastapi.middleware.cors import CORSMiddleware
9
9
 
10
- from flowfile_core import ServerRun
11
10
  from flowfile_core.configs.flow_logger import clear_all_flow_logs
12
11
  from flowfile_core.configs.settings import (
13
12
  SERVER_HOST,
@@ -91,7 +90,7 @@ app.include_router(user_defined_components_router, prefix="/user_defined_compone
91
90
 
92
91
 
93
92
  @app.post("/shutdown")
94
- async def shutdown():
93
+ async def shutdown(background_tasks: BackgroundTasks):
95
94
  """An API endpoint to gracefully shut down the server.
96
95
 
97
96
  This endpoint sets a flag that the Uvicorn server checks, allowing it
@@ -99,7 +98,6 @@ async def shutdown():
99
98
  after the HTTP response has been sent.
100
99
  """
101
100
  # Use a background task to trigger the shutdown after the response is sent
102
- background_tasks = ServerRun()
103
101
  background_tasks.add_task(trigger_shutdown)
104
102
  return {"message": "Server is shutting down"}
105
103
 
@@ -1,11 +1,53 @@
1
+ import os
2
+
1
3
  from fastapi import APIRouter
2
4
  from fastapi.responses import RedirectResponse
5
+ from pydantic import BaseModel
6
+
7
+ from flowfile_core.auth.secrets import generate_master_key, is_master_key_configured
3
8
 
4
- # Router setup
5
9
  router = APIRouter()
6
10
 
7
11
 
12
+ class SetupStatus(BaseModel):
13
+ """Response model for the setup status endpoint."""
14
+
15
+ setup_required: bool
16
+ master_key_configured: bool
17
+ mode: str
18
+
19
+
20
+ class GeneratedKey(BaseModel):
21
+ """Response model for the generate key endpoint."""
22
+
23
+ key: str
24
+ instructions: str
25
+
26
+
8
27
  @router.get("/", tags=["admin"])
9
28
  async def docs_redirect():
10
29
  """Redirects to the documentation page."""
11
30
  return RedirectResponse(url="/docs")
31
+
32
+
33
+ @router.get("/health/status", response_model=SetupStatus, tags=["health"])
34
+ async def get_setup_status():
35
+ """Get the current setup status of the application."""
36
+ mode = os.environ.get("FLOWFILE_MODE", "electron")
37
+ master_key_ok = is_master_key_configured()
38
+ return SetupStatus(
39
+ setup_required=not master_key_ok,
40
+ master_key_configured=master_key_ok,
41
+ mode=mode,
42
+ )
43
+
44
+
45
+ @router.post("/setup/generate-key", response_model=GeneratedKey, tags=["setup"])
46
+ async def generate_key():
47
+ """Generate a new master encryption key."""
48
+ key = generate_master_key()
49
+ instructions = (
50
+ f'Add to your .env file:\n FLOWFILE_MASTER_KEY="{key}"\n\n'
51
+ "Then restart: docker-compose down && docker-compose up"
52
+ )
53
+ return GeneratedKey(key=key, instructions=instructions)
@@ -14,14 +14,20 @@ AuthMethod = Literal[
14
14
  ]
15
15
 
16
16
 
17
- def encrypt_for_worker(secret_value: SecretStr | None) -> str | None:
17
+ def encrypt_for_worker(secret_value: SecretStr | None, user_id: int) -> str | None:
18
18
  """
19
- Encrypts a secret value for use in worker contexts.
20
- This is a placeholder function that simulates encryption.
21
- In practice, you would use a secure encryption method.
19
+ Encrypts a secret value for use in worker contexts using per-user key derivation.
20
+
21
+ Args:
22
+ secret_value: The secret value to encrypt
23
+ user_id: The user ID for key derivation
24
+
25
+ Returns:
26
+ Encrypted secret with embedded user_id, or None if secret_value is None
22
27
  """
23
28
  if secret_value is not None:
24
- return encrypt_secret(secret_value.get_secret_value())
29
+ return encrypt_secret(secret_value.get_secret_value(), user_id)
30
+ return None
25
31
 
26
32
 
27
33
  class AuthSettingsInput(BaseModel):
@@ -80,25 +86,31 @@ class FullCloudStorageConnection(AuthSettingsInput):
80
86
  endpoint_url: str | None = None
81
87
  verify_ssl: bool = True
82
88
 
83
- def get_worker_interface(self) -> "FullCloudStorageConnectionWorkerInterface":
89
+ def get_worker_interface(self, user_id: int) -> "FullCloudStorageConnectionWorkerInterface":
84
90
  """
85
- Convert to a public interface model without secrets.
91
+ Convert to a worker interface model with encrypted secrets.
92
+
93
+ Args:
94
+ user_id: The user ID for per-user key derivation
95
+
96
+ Returns:
97
+ FullCloudStorageConnectionWorkerInterface with encrypted secrets
86
98
  """
87
99
  return FullCloudStorageConnectionWorkerInterface(
88
100
  storage_type=self.storage_type,
89
101
  auth_method=self.auth_method,
90
102
  connection_name=self.connection_name,
91
103
  aws_allow_unsafe_html=self.aws_allow_unsafe_html,
92
- aws_secret_access_key=encrypt_for_worker(self.aws_secret_access_key),
104
+ aws_secret_access_key=encrypt_for_worker(self.aws_secret_access_key, user_id),
93
105
  aws_region=self.aws_region,
94
106
  aws_access_key_id=self.aws_access_key_id,
95
107
  aws_role_arn=self.aws_role_arn,
96
- aws_session_token=encrypt_for_worker(self.aws_session_token),
108
+ aws_session_token=encrypt_for_worker(self.aws_session_token, user_id),
97
109
  azure_account_name=self.azure_account_name,
98
110
  azure_tenant_id=self.azure_tenant_id,
99
- azure_account_key=encrypt_for_worker(self.azure_account_key),
111
+ azure_account_key=encrypt_for_worker(self.azure_account_key, user_id),
100
112
  azure_client_id=self.azure_client_id,
101
- azure_client_secret=encrypt_for_worker(self.azure_client_secret),
113
+ azure_client_secret=encrypt_for_worker(self.azure_client_secret, user_id),
102
114
  endpoint_url=self.endpoint_url,
103
115
  verify_ssl=self.verify_ssl,
104
116
  )
@@ -202,18 +214,30 @@ def get_cloud_storage_write_settings_worker_interface(
202
214
  write_settings: CloudStorageWriteSettings,
203
215
  connection: FullCloudStorageConnection,
204
216
  lf: pl.LazyFrame,
217
+ user_id: int,
205
218
  flowfile_flow_id: int = 1,
206
219
  flowfile_node_id: int | str = -1,
207
220
  ) -> CloudStorageWriteSettingsWorkerInterface:
208
221
  """
209
- Convert to a worker interface model with hashed secrets.
222
+ Convert to a worker interface model with encrypted secrets.
223
+
224
+ Args:
225
+ write_settings: Cloud storage write settings
226
+ connection: Full cloud storage connection with secrets
227
+ lf: LazyFrame to serialize
228
+ user_id: User ID for per-user key derivation
229
+ flowfile_flow_id: Flow ID for tracking
230
+ flowfile_node_id: Node ID for tracking
231
+
232
+ Returns:
233
+ CloudStorageWriteSettingsWorkerInterface ready for worker
210
234
  """
211
235
  operation = base64.b64encode(lf.serialize()).decode()
212
236
 
213
237
  return CloudStorageWriteSettingsWorkerInterface(
214
238
  operation=operation,
215
239
  write_settings=write_settings.get_write_setting_worker_interface(),
216
- connection=connection.get_worker_interface(),
217
- flowfile_flow_id=flowfile_flow_id, # Default value, can be overridden
218
- flowfile_node_id=flowfile_node_id, # Default value, can be overridden
240
+ connection=connection.get_worker_interface(user_id),
241
+ flowfile_flow_id=flowfile_flow_id,
242
+ flowfile_node_id=flowfile_node_id,
219
243
  )
@@ -1,4 +1,8 @@
1
+ import base64
2
+
1
3
  from cryptography.fernet import Fernet
4
+ from cryptography.hazmat.primitives import hashes
5
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
2
6
  from fastapi.exceptions import HTTPException
3
7
  from pydantic import SecretStr
4
8
  from sqlalchemy import and_
@@ -9,21 +13,118 @@ from flowfile_core.auth.secrets import get_master_key
9
13
  from flowfile_core.database import models as db_models
10
14
  from flowfile_core.database.connection import get_db_context
11
15
 
16
+ # Version identifier for key derivation scheme (allows future migrations)
17
+ KEY_DERIVATION_VERSION = b"flowfile-secrets-v1"
18
+
19
+ # Encrypted secret format: $ffsec$1${user_id}${fernet_token}
20
+ # This embeds the user_id so the worker can derive the correct key
21
+ SECRET_FORMAT_PREFIX = "$ffsec$1$"
22
+
23
+
24
+ def derive_user_key(user_id: int) -> bytes:
25
+ """
26
+ Derive a user-specific encryption key from the master key using HKDF.
27
+
28
+ This provides cryptographic isolation between users - each user's secrets
29
+ are encrypted with a unique key derived from the master key.
30
+
31
+ Args:
32
+ user_id: The unique identifier for the user
33
+
34
+ Returns:
35
+ bytes: A 32-byte URL-safe base64-encoded key suitable for Fernet
36
+ """
37
+ master_key = get_master_key().encode()
12
38
 
13
- def encrypt_secret(secret_value):
14
- """Encrypt a secret value using the master key."""
39
+ # Use HKDF to derive a user-specific key
40
+ hkdf = HKDF(
41
+ algorithm=hashes.SHA256(),
42
+ length=32, # Fernet requires 32 bytes
43
+ salt=KEY_DERIVATION_VERSION, # Static salt is fine for key derivation
44
+ info=f"user-{user_id}".encode(), # User-specific context
45
+ )
46
+
47
+ # Derive raw key material and encode for Fernet
48
+ derived_key = hkdf.derive(master_key)
49
+ return base64.urlsafe_b64encode(derived_key)
50
+
51
+
52
+ def _encrypt_with_master_key(secret_value: str) -> str:
53
+ """Legacy encryption using master key directly (for backward compatibility)."""
15
54
  key = get_master_key().encode()
16
55
  f = Fernet(key)
17
56
  return f.encrypt(secret_value.encode()).decode()
18
57
 
19
58
 
20
- def decrypt_secret(encrypted_value) -> SecretStr:
21
- """Decrypt an encrypted value using the master key."""
59
+ def _decrypt_with_master_key(encrypted_value: str) -> SecretStr:
60
+ """Legacy decryption using master key directly (for backward compatibility)."""
22
61
  key = get_master_key().encode()
23
62
  f = Fernet(key)
24
63
  return SecretStr(f.decrypt(encrypted_value.encode()).decode())
25
64
 
26
65
 
66
+ def encrypt_secret(secret_value: str, user_id: int) -> str:
67
+ """
68
+ Encrypt a secret value using a user-specific derived key.
69
+
70
+ The encrypted format embeds the user_id so it can be decrypted
71
+ without knowing the user context (e.g., by the worker service).
72
+
73
+ Format: $ffsec$1${user_id}${fernet_token}
74
+
75
+ Args:
76
+ secret_value: The plaintext secret to encrypt
77
+ user_id: The user ID to derive the encryption key for
78
+
79
+ Returns:
80
+ str: The encrypted value with embedded user_id
81
+ """
82
+ key = derive_user_key(user_id)
83
+ f = Fernet(key)
84
+ fernet_token = f.encrypt(secret_value.encode()).decode()
85
+ return f"{SECRET_FORMAT_PREFIX}{user_id}${fernet_token}"
86
+
87
+
88
+ def decrypt_secret(encrypted_value: str, user_id: int | None = None) -> SecretStr:
89
+ """
90
+ Decrypt an encrypted value.
91
+
92
+ Supports both new format (with embedded user_id) and legacy format.
93
+ - New format: $ffsec$1${user_id}${fernet_token} - user_id extracted automatically
94
+ - Legacy format: raw Fernet token - requires user_id parameter or uses master key
95
+
96
+ Args:
97
+ encrypted_value: The encrypted secret
98
+ user_id: Optional user ID (required for legacy secrets, ignored for new format)
99
+
100
+ Returns:
101
+ SecretStr: The decrypted value wrapped in SecretStr
102
+ """
103
+ # Check for new versioned format with embedded user_id
104
+ if encrypted_value.startswith(SECRET_FORMAT_PREFIX):
105
+ # Parse: $ffsec$1${user_id}${fernet_token}
106
+ remainder = encrypted_value[len(SECRET_FORMAT_PREFIX):]
107
+ parts = remainder.split("$", 1)
108
+ if len(parts) != 2:
109
+ raise ValueError("Invalid encrypted secret format")
110
+
111
+ embedded_user_id = int(parts[0])
112
+ fernet_token = parts[1]
113
+
114
+ key = derive_user_key(embedded_user_id)
115
+ f = Fernet(key)
116
+ return SecretStr(f.decrypt(fernet_token.encode()).decode())
117
+
118
+ # Legacy format - use provided user_id or fall back to master key
119
+ if user_id is not None:
120
+ key = derive_user_key(user_id)
121
+ f = Fernet(key)
122
+ return SecretStr(f.decrypt(encrypted_value.encode()).decode())
123
+
124
+ # Fall back to master key for legacy secrets without user context
125
+ return _decrypt_with_master_key(encrypted_value)
126
+
127
+
27
128
  def get_encrypted_secret(current_user_id: int, secret_name: str) -> str | None:
28
129
  with get_db_context() as db:
29
130
  user_id = current_user_id
@@ -39,13 +140,13 @@ def get_encrypted_secret(current_user_id: int, secret_name: str) -> str | None:
39
140
 
40
141
 
41
142
  def store_secret(db: Session, secret: SecretInput, user_id: int) -> db_models.Secret:
42
- encrypted_value = encrypt_secret(secret.value.get_secret_value())
143
+ encrypted_value = encrypt_secret(secret.value.get_secret_value(), user_id)
43
144
 
44
145
  # Store in database
45
146
  db_secret = db_models.Secret(
46
147
  name=secret.name,
47
148
  encrypted_value=encrypted_value,
48
- iv="", # Not used with Fernet
149
+ iv="", # Legacy field, not used with current encryption
49
150
  user_id=user_id,
50
151
  )
51
152
  db.add(db_secret)
@@ -54,6 +54,17 @@ from flowfile_frame.cloud_storage.secret_manager import (
54
54
  get_all_available_cloud_storage_connections,
55
55
  )
56
56
 
57
+ # Database I/O
58
+ from flowfile_frame.database import (
59
+ create_database_connection,
60
+ create_database_connection_if_not_exists,
61
+ del_database_connection,
62
+ get_all_available_database_connections,
63
+ get_database_connection_by_name,
64
+ read_database,
65
+ write_database,
66
+ )
67
+
57
68
  # Commonly used functions
58
69
  from flowfile_frame.expr import ( # noqa: F401
59
70
  col,