lfx-nightly 0.1.12.dev26__py3-none-any.whl → 0.1.12.dev28__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.
- lfx/base/agents/agent.py +25 -10
- lfx/base/agents/utils.py +22 -3
- lfx/base/data/base_file.py +28 -19
- lfx/components/agents/agent.py +1 -1
- lfx/components/data/__init__.py +0 -6
- lfx/components/data/file.py +1 -1
- lfx/components/data/mock_data.py +5 -8
- lfx/components/data/save_file.py +625 -0
- lfx/components/data/web_search.py +225 -11
- lfx/components/docling/docling_remote.py +4 -1
- lfx/components/input_output/chat.py +8 -1
- lfx/components/nvidia/nvidia.py +1 -4
- lfx/components/processing/__init__.py +3 -3
- lfx/components/processing/dataframe_to_toolset.py +259 -0
- lfx/components/processing/lambda_filter.py +3 -3
- lfx/graph/vertex/param_handler.py +49 -0
- lfx/interface/initialize/loading.py +114 -21
- lfx/schema/image.py +72 -19
- lfx/schema/message.py +7 -2
- lfx/schema/table.py +2 -0
- lfx/services/settings/base.py +7 -0
- lfx/utils/util.py +135 -0
- {lfx_nightly-0.1.12.dev26.dist-info → lfx_nightly-0.1.12.dev28.dist-info}/METADATA +1 -1
- {lfx_nightly-0.1.12.dev26.dist-info → lfx_nightly-0.1.12.dev28.dist-info}/RECORD +26 -27
- lfx/components/data/news_search.py +0 -164
- lfx/components/data/rss.py +0 -69
- lfx/components/processing/save_file.py +0 -225
- {lfx_nightly-0.1.12.dev26.dist-info → lfx_nightly-0.1.12.dev28.dist-info}/WHEEL +0 -0
- {lfx_nightly-0.1.12.dev26.dist-info → lfx_nightly-0.1.12.dev28.dist-info}/entry_points.txt +0 -0
|
@@ -182,6 +182,8 @@ class ParameterHandler:
|
|
|
182
182
|
params = self._handle_code_field(field_name, val, params)
|
|
183
183
|
elif field.get("type") in {"dict", "NestedDict"}:
|
|
184
184
|
params = self._handle_dict_field(field_name, val, params)
|
|
185
|
+
elif field.get("type") == "table":
|
|
186
|
+
params = self._handle_table_field(field_name, val, params, load_from_db_fields)
|
|
185
187
|
else:
|
|
186
188
|
params = self._handle_other_direct_types(field_name, field, val, params)
|
|
187
189
|
|
|
@@ -190,6 +192,53 @@ class ParameterHandler:
|
|
|
190
192
|
|
|
191
193
|
return params, load_from_db_fields
|
|
192
194
|
|
|
195
|
+
def _handle_table_field(
|
|
196
|
+
self,
|
|
197
|
+
field_name: str,
|
|
198
|
+
val: Any,
|
|
199
|
+
params: dict[str, Any],
|
|
200
|
+
load_from_db_fields: list[str] | None = None,
|
|
201
|
+
) -> dict[str, Any]:
|
|
202
|
+
"""Handle table field type with load_from_db column support."""
|
|
203
|
+
if load_from_db_fields is None:
|
|
204
|
+
load_from_db_fields = []
|
|
205
|
+
if val is None:
|
|
206
|
+
params[field_name] = []
|
|
207
|
+
return params
|
|
208
|
+
|
|
209
|
+
# Store the table data as-is for now
|
|
210
|
+
# The actual column processing will happen in the loading phase
|
|
211
|
+
if isinstance(val, list) and all(isinstance(item, dict) for item in val):
|
|
212
|
+
params[field_name] = val
|
|
213
|
+
else:
|
|
214
|
+
msg = f"Invalid value type {type(val)} for table field {field_name}"
|
|
215
|
+
raise ValueError(msg)
|
|
216
|
+
|
|
217
|
+
# Get table schema from the field to identify load_from_db columns
|
|
218
|
+
field_template = self.template_dict.get(field_name, {})
|
|
219
|
+
table_schema = field_template.get("table_schema", [])
|
|
220
|
+
|
|
221
|
+
# Track which columns need database loading
|
|
222
|
+
load_from_db_columns = []
|
|
223
|
+
for column_schema in table_schema:
|
|
224
|
+
if isinstance(column_schema, dict) and column_schema.get("load_from_db"):
|
|
225
|
+
load_from_db_columns.append(column_schema["name"])
|
|
226
|
+
elif hasattr(column_schema, "load_from_db") and column_schema.load_from_db:
|
|
227
|
+
load_from_db_columns.append(column_schema.name)
|
|
228
|
+
|
|
229
|
+
# Store metadata for later processing
|
|
230
|
+
if load_from_db_columns:
|
|
231
|
+
# Store table column metadata for the loading phase
|
|
232
|
+
table_load_metadata_key = f"{field_name}_load_from_db_columns"
|
|
233
|
+
params[table_load_metadata_key] = load_from_db_columns
|
|
234
|
+
|
|
235
|
+
# Add to load_from_db_fields so it gets processed
|
|
236
|
+
# We'll use a special naming convention to identify table fields
|
|
237
|
+
load_from_db_fields.append(f"table:{field_name}")
|
|
238
|
+
self.load_from_db_fields.append(f"table:{field_name}")
|
|
239
|
+
|
|
240
|
+
return params
|
|
241
|
+
|
|
193
242
|
def handle_optional_field(self, field_name: str, field: dict, params: dict[str, Any]) -> None:
|
|
194
243
|
"""Handle optional fields."""
|
|
195
244
|
if not field.get("required") and params.get(field_name) is None:
|
|
@@ -126,6 +126,88 @@ def load_from_env_vars(params, load_from_db_fields):
|
|
|
126
126
|
return params
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
async def update_table_params_with_load_from_db_fields(
|
|
130
|
+
custom_component: CustomComponent,
|
|
131
|
+
params: dict,
|
|
132
|
+
table_field_name: str,
|
|
133
|
+
*,
|
|
134
|
+
fallback_to_env_vars: bool = False,
|
|
135
|
+
) -> dict:
|
|
136
|
+
"""Update table parameters with load_from_db column values."""
|
|
137
|
+
# Get the table data and column metadata
|
|
138
|
+
table_data = params.get(table_field_name, [])
|
|
139
|
+
metadata_key = f"{table_field_name}_load_from_db_columns"
|
|
140
|
+
load_from_db_columns = params.pop(metadata_key, [])
|
|
141
|
+
|
|
142
|
+
if not table_data or not load_from_db_columns:
|
|
143
|
+
return params
|
|
144
|
+
|
|
145
|
+
async with session_scope() as session:
|
|
146
|
+
settings_service = get_settings_service()
|
|
147
|
+
is_noop_session = isinstance(session, NoopSession) or (
|
|
148
|
+
settings_service and settings_service.settings.use_noop_database
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Process each row in the table
|
|
152
|
+
updated_table_data = []
|
|
153
|
+
for row in table_data:
|
|
154
|
+
if not isinstance(row, dict):
|
|
155
|
+
updated_table_data.append(row)
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
updated_row = row.copy()
|
|
159
|
+
|
|
160
|
+
# Process each column that needs database loading
|
|
161
|
+
for column_name in load_from_db_columns:
|
|
162
|
+
if column_name not in updated_row:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# The column value should be the name of the global variable to lookup
|
|
166
|
+
variable_name = updated_row[column_name]
|
|
167
|
+
if not variable_name:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
if is_noop_session:
|
|
172
|
+
# Fallback to environment variables
|
|
173
|
+
key = os.getenv(variable_name)
|
|
174
|
+
if key:
|
|
175
|
+
logger.info(f"Using environment variable {variable_name} for table column {column_name}")
|
|
176
|
+
else:
|
|
177
|
+
logger.error(f"Environment variable {variable_name} is not set.")
|
|
178
|
+
else:
|
|
179
|
+
# Load from database
|
|
180
|
+
key = await custom_component.get_variable(
|
|
181
|
+
name=variable_name, field=f"{table_field_name}.{column_name}", session=session
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
except ValueError as e:
|
|
185
|
+
if "User id is not set" in str(e):
|
|
186
|
+
raise
|
|
187
|
+
logger.debug(str(e))
|
|
188
|
+
key = None
|
|
189
|
+
|
|
190
|
+
# If we couldn't get from database and fallback is enabled, try environment
|
|
191
|
+
if fallback_to_env_vars and key is None:
|
|
192
|
+
key = os.getenv(variable_name)
|
|
193
|
+
if key:
|
|
194
|
+
logger.info(f"Using environment variable {variable_name} for table column {column_name}")
|
|
195
|
+
else:
|
|
196
|
+
logger.error(f"Environment variable {variable_name} is not set.")
|
|
197
|
+
|
|
198
|
+
# Update the column value with the resolved value
|
|
199
|
+
updated_row[column_name] = key if key is not None else None
|
|
200
|
+
if key is None:
|
|
201
|
+
logger.warning(
|
|
202
|
+
f"Could not get value for {variable_name} in table column {column_name}. Setting it to None."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
updated_table_data.append(updated_row)
|
|
206
|
+
|
|
207
|
+
params[table_field_name] = updated_table_data
|
|
208
|
+
return params
|
|
209
|
+
|
|
210
|
+
|
|
129
211
|
async def update_params_with_load_from_db_fields(
|
|
130
212
|
custom_component: CustomComponent,
|
|
131
213
|
params,
|
|
@@ -142,27 +224,38 @@ async def update_params_with_load_from_db_fields(
|
|
|
142
224
|
logger.debug("Loading variables from environment variables because database is not available.")
|
|
143
225
|
return load_from_env_vars(params, load_from_db_fields)
|
|
144
226
|
for field in load_from_db_fields:
|
|
145
|
-
if
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
227
|
+
# Check if this is a table field (using our naming convention)
|
|
228
|
+
if field.startswith("table:"):
|
|
229
|
+
table_field_name = field[6:] # Remove "table:" prefix
|
|
230
|
+
params = await update_table_params_with_load_from_db_fields(
|
|
231
|
+
custom_component,
|
|
232
|
+
params,
|
|
233
|
+
table_field_name,
|
|
234
|
+
fallback_to_env_vars=fallback_to_env_vars,
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
# Handle regular field-level load_from_db
|
|
238
|
+
if field not in params or not params[field]:
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
key = await custom_component.get_variable(name=params[field], field=field, session=session)
|
|
243
|
+
except ValueError as e:
|
|
244
|
+
if any(reason in str(e) for reason in ["User id is not set", "variable not found."]):
|
|
245
|
+
raise
|
|
246
|
+
logger.debug(str(e))
|
|
247
|
+
key = None
|
|
248
|
+
|
|
249
|
+
if fallback_to_env_vars and key is None:
|
|
250
|
+
key = os.getenv(params[field])
|
|
251
|
+
if key:
|
|
252
|
+
logger.info(f"Using environment variable {params[field]} for {field}")
|
|
253
|
+
else:
|
|
254
|
+
logger.error(f"Environment variable {params[field]} is not set.")
|
|
255
|
+
|
|
256
|
+
params[field] = key if key is not None else None
|
|
257
|
+
if key is None:
|
|
258
|
+
logger.warning(f"Could not get value for {field}. Setting it to None.")
|
|
166
259
|
|
|
167
260
|
return params
|
|
168
261
|
|
lfx/schema/image.py
CHANGED
|
@@ -23,6 +23,9 @@ def is_image_file(file_path) -> bool:
|
|
|
23
23
|
|
|
24
24
|
def get_file_paths(files: list[str | dict]):
|
|
25
25
|
"""Get file paths for a list of files."""
|
|
26
|
+
if not files:
|
|
27
|
+
return []
|
|
28
|
+
|
|
26
29
|
storage_service = get_storage_service()
|
|
27
30
|
if not storage_service:
|
|
28
31
|
# Extract paths from dicts if present
|
|
@@ -31,7 +34,12 @@ def get_file_paths(files: list[str | dict]):
|
|
|
31
34
|
cache_dir = Path(user_cache_dir("langflow"))
|
|
32
35
|
|
|
33
36
|
for file in files:
|
|
37
|
+
if not file: # Skip empty/None files
|
|
38
|
+
continue
|
|
39
|
+
|
|
34
40
|
file_path = file["path"] if isinstance(file, dict) and "path" in file else file
|
|
41
|
+
if not file_path: # Skip empty paths
|
|
42
|
+
continue
|
|
35
43
|
|
|
36
44
|
# If it's a relative path like "flow_id/filename", resolve it to cache dir
|
|
37
45
|
path = Path(file_path)
|
|
@@ -52,13 +60,30 @@ def get_file_paths(files: list[str | dict]):
|
|
|
52
60
|
# Handle dict case
|
|
53
61
|
if storage_service is None:
|
|
54
62
|
continue
|
|
63
|
+
|
|
64
|
+
if not file: # Skip empty/None files
|
|
65
|
+
continue
|
|
66
|
+
|
|
55
67
|
if isinstance(file, dict) and "path" in file:
|
|
56
|
-
|
|
68
|
+
file_path_str = file["path"]
|
|
57
69
|
elif hasattr(file, "path") and file.path:
|
|
58
|
-
|
|
70
|
+
file_path_str = file.path
|
|
59
71
|
else:
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
file_path_str = file
|
|
73
|
+
|
|
74
|
+
if not file_path_str: # Skip empty paths
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
file_path = Path(file_path_str)
|
|
78
|
+
# Handle edge case where path might be just a filename without parent
|
|
79
|
+
if file_path.parent == Path():
|
|
80
|
+
flow_id, file_name = "", file_path.name
|
|
81
|
+
else:
|
|
82
|
+
flow_id, file_name = str(file_path.parent), file_path.name
|
|
83
|
+
|
|
84
|
+
if not file_name: # Skip if no filename
|
|
85
|
+
continue
|
|
86
|
+
|
|
62
87
|
file_paths.append(storage_service.build_full_path(flow_id=flow_id, file_name=file_name))
|
|
63
88
|
return file_paths
|
|
64
89
|
|
|
@@ -69,22 +94,31 @@ async def get_files(
|
|
|
69
94
|
convert_to_base64: bool = False,
|
|
70
95
|
):
|
|
71
96
|
"""Get files from storage service."""
|
|
97
|
+
if not file_paths:
|
|
98
|
+
return []
|
|
99
|
+
|
|
72
100
|
storage_service = get_storage_service()
|
|
73
101
|
if not storage_service:
|
|
74
102
|
# For testing purposes, read files directly when no storage service
|
|
75
103
|
file_objects: list[str | bytes] = []
|
|
76
104
|
for file_path_str in file_paths:
|
|
105
|
+
if not file_path_str: # Skip empty paths
|
|
106
|
+
continue
|
|
107
|
+
|
|
77
108
|
file_path = Path(file_path_str)
|
|
78
109
|
if file_path.exists():
|
|
79
110
|
# Use async read for compatibility
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
111
|
+
try:
|
|
112
|
+
async with aiofiles.open(file_path, "rb") as f:
|
|
113
|
+
file_content = await f.read()
|
|
114
|
+
if convert_to_base64:
|
|
115
|
+
file_base64 = base64.b64encode(file_content).decode("utf-8")
|
|
116
|
+
file_objects.append(file_base64)
|
|
117
|
+
else:
|
|
118
|
+
file_objects.append(file_content)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
msg = f"Error reading file {file_path}: {e}"
|
|
121
|
+
raise FileNotFoundError(msg) from e
|
|
88
122
|
else:
|
|
89
123
|
msg = f"File not found: {file_path}"
|
|
90
124
|
raise FileNotFoundError(msg)
|
|
@@ -92,16 +126,32 @@ async def get_files(
|
|
|
92
126
|
|
|
93
127
|
file_objects: list[str | bytes] = []
|
|
94
128
|
for file in file_paths:
|
|
129
|
+
if not file: # Skip empty file paths
|
|
130
|
+
continue
|
|
131
|
+
|
|
95
132
|
file_path = Path(file)
|
|
96
|
-
|
|
133
|
+
# Handle edge case where path might be just a filename without parent
|
|
134
|
+
if file_path.parent == Path():
|
|
135
|
+
flow_id, file_name = "", file_path.name
|
|
136
|
+
else:
|
|
137
|
+
flow_id, file_name = str(file_path.parent), file_path.name
|
|
138
|
+
|
|
139
|
+
if not file_name: # Skip if no filename
|
|
140
|
+
continue
|
|
141
|
+
|
|
97
142
|
if not storage_service:
|
|
98
143
|
continue
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
file_object = await storage_service.get_file(flow_id=flow_id, file_name=file_name)
|
|
147
|
+
if convert_to_base64:
|
|
148
|
+
file_base64 = base64.b64encode(file_object).decode("utf-8")
|
|
149
|
+
file_objects.append(file_base64)
|
|
150
|
+
else:
|
|
151
|
+
file_objects.append(file_object)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
msg = f"Error getting file {file} from storage: {e}"
|
|
154
|
+
raise FileNotFoundError(msg) from e
|
|
105
155
|
return file_objects
|
|
106
156
|
|
|
107
157
|
|
|
@@ -115,6 +165,9 @@ class Image(BaseModel):
|
|
|
115
165
|
"""Convert image to base64 string."""
|
|
116
166
|
if self.path:
|
|
117
167
|
files = get_files([self.path], convert_to_base64=True)
|
|
168
|
+
if not files:
|
|
169
|
+
msg = f"No files found or file could not be converted to base64: {self.path}"
|
|
170
|
+
raise ValueError(msg)
|
|
118
171
|
return files[0]
|
|
119
172
|
msg = "Image path is not set."
|
|
120
173
|
raise ValueError(msg)
|
lfx/schema/message.py
CHANGED
|
@@ -139,7 +139,8 @@ class Message(Data):
|
|
|
139
139
|
if self.sender == MESSAGE_SENDER_USER or not self.sender:
|
|
140
140
|
if self.files:
|
|
141
141
|
contents = [{"type": "text", "text": text}]
|
|
142
|
-
|
|
142
|
+
file_contents = self.get_file_content_dicts()
|
|
143
|
+
contents.extend(file_contents)
|
|
143
144
|
human_message = HumanMessage(content=contents)
|
|
144
145
|
else:
|
|
145
146
|
human_message = HumanMessage(content=text)
|
|
@@ -198,7 +199,11 @@ class Message(Data):
|
|
|
198
199
|
# Keep this async method for backwards compatibility
|
|
199
200
|
def get_file_content_dicts(self):
|
|
200
201
|
content_dicts = []
|
|
201
|
-
|
|
202
|
+
try:
|
|
203
|
+
files = get_file_paths(self.files)
|
|
204
|
+
except Exception as e: # noqa: BLE001
|
|
205
|
+
logger.error(f"Error getting file paths: {e}")
|
|
206
|
+
return content_dicts
|
|
202
207
|
|
|
203
208
|
for file in files:
|
|
204
209
|
if isinstance(file, Image):
|
lfx/schema/table.py
CHANGED
|
@@ -44,6 +44,8 @@ class Column(BaseModel):
|
|
|
44
44
|
disable_edit: bool = Field(default=False)
|
|
45
45
|
edit_mode: EditMode | None = Field(default=EditMode.POPOVER)
|
|
46
46
|
hidden: bool = Field(default=False)
|
|
47
|
+
load_from_db: bool = Field(default=False)
|
|
48
|
+
"""Whether this column's default value should be loaded from global variables"""
|
|
47
49
|
|
|
48
50
|
@model_validator(mode="after")
|
|
49
51
|
def set_display_name(self):
|
lfx/services/settings/base.py
CHANGED
|
@@ -226,6 +226,10 @@ class Settings(BaseSettings):
|
|
|
226
226
|
"""The host on which Langflow will run."""
|
|
227
227
|
port: int = 7860
|
|
228
228
|
"""The port on which Langflow will run."""
|
|
229
|
+
runtime_port: int | None = Field(default=None, exclude=True)
|
|
230
|
+
"""TEMPORARY: The port detected at runtime after checking for conflicts.
|
|
231
|
+
This field is system-managed only and will be removed in future versions
|
|
232
|
+
when strict port enforcement is implemented (errors will be raised if port unavailable)."""
|
|
229
233
|
workers: int = 1
|
|
230
234
|
"""The number of workers to run."""
|
|
231
235
|
log_level: str = "critical"
|
|
@@ -275,6 +279,9 @@ class Settings(BaseSettings):
|
|
|
275
279
|
mcp_server_enable_progress_notifications: bool = False
|
|
276
280
|
"""If set to False, Langflow will not send progress notifications in the MCP server."""
|
|
277
281
|
|
|
282
|
+
# Add projects to MCP servers automatically on creation
|
|
283
|
+
add_projects_to_mcp_servers: bool = True
|
|
284
|
+
"""If set to True, newly created projects will be added to the user's MCP servers config automatically."""
|
|
278
285
|
# MCP Composer
|
|
279
286
|
mcp_composer_enabled: bool = True
|
|
280
287
|
"""If set to False, Langflow will not start the MCP Composer service."""
|
lfx/utils/util.py
CHANGED
|
@@ -2,6 +2,7 @@ import difflib
|
|
|
2
2
|
import importlib
|
|
3
3
|
import inspect
|
|
4
4
|
import json
|
|
5
|
+
import os
|
|
5
6
|
import re
|
|
6
7
|
from functools import wraps
|
|
7
8
|
from pathlib import Path
|
|
@@ -16,6 +17,140 @@ from lfx.template.frontend_node.constants import FORCE_SHOW_FIELDS
|
|
|
16
17
|
from lfx.utils import constants
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
def detect_container_environment() -> str | None:
|
|
21
|
+
"""Detect if running in a container and return the appropriate container type.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
'docker' if running in Docker, 'podman' if running in Podman, None otherwise.
|
|
25
|
+
"""
|
|
26
|
+
# Check for .dockerenv file (Docker)
|
|
27
|
+
if Path("/.dockerenv").exists():
|
|
28
|
+
return "docker"
|
|
29
|
+
|
|
30
|
+
# Check cgroup for container indicators
|
|
31
|
+
try:
|
|
32
|
+
with Path("/proc/self/cgroup").open() as f:
|
|
33
|
+
content = f.read()
|
|
34
|
+
if "docker" in content:
|
|
35
|
+
return "docker"
|
|
36
|
+
if "podman" in content:
|
|
37
|
+
return "podman"
|
|
38
|
+
except (FileNotFoundError, PermissionError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
# Check environment variables (lowercase 'container' is the standard for Podman)
|
|
42
|
+
if os.getenv("container") == "podman": # noqa: SIM112
|
|
43
|
+
return "podman"
|
|
44
|
+
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_container_host() -> str | None:
|
|
49
|
+
"""Get the hostname to access host services from within a container.
|
|
50
|
+
|
|
51
|
+
Tries multiple methods to find the correct hostname:
|
|
52
|
+
1. host.containers.internal (Podman) or host.docker.internal (Docker)
|
|
53
|
+
2. Gateway IP from routing table (fallback for Linux)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The hostname or IP to use, or None if not in a container.
|
|
57
|
+
"""
|
|
58
|
+
import socket
|
|
59
|
+
|
|
60
|
+
# Check if we're in a container first
|
|
61
|
+
container_type = detect_container_environment()
|
|
62
|
+
if not container_type:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
# Try container-specific hostnames first based on detected type
|
|
66
|
+
if container_type == "podman":
|
|
67
|
+
# Podman: try host.containers.internal first
|
|
68
|
+
try:
|
|
69
|
+
socket.getaddrinfo("host.containers.internal", None)
|
|
70
|
+
except socket.gaierror:
|
|
71
|
+
pass
|
|
72
|
+
else:
|
|
73
|
+
return "host.containers.internal"
|
|
74
|
+
|
|
75
|
+
# Fallback to host.docker.internal (for Podman Desktop on macOS)
|
|
76
|
+
try:
|
|
77
|
+
socket.getaddrinfo("host.docker.internal", None)
|
|
78
|
+
except socket.gaierror:
|
|
79
|
+
pass
|
|
80
|
+
else:
|
|
81
|
+
return "host.docker.internal"
|
|
82
|
+
else:
|
|
83
|
+
# Docker: try host.docker.internal first
|
|
84
|
+
try:
|
|
85
|
+
socket.getaddrinfo("host.docker.internal", None)
|
|
86
|
+
except socket.gaierror:
|
|
87
|
+
pass
|
|
88
|
+
else:
|
|
89
|
+
return "host.docker.internal"
|
|
90
|
+
|
|
91
|
+
# Fallback to host.containers.internal (unlikely but possible)
|
|
92
|
+
try:
|
|
93
|
+
socket.getaddrinfo("host.containers.internal", None)
|
|
94
|
+
except socket.gaierror:
|
|
95
|
+
pass
|
|
96
|
+
else:
|
|
97
|
+
return "host.containers.internal"
|
|
98
|
+
|
|
99
|
+
# Fallback: try to get gateway IP from routing table (Linux containers)
|
|
100
|
+
try:
|
|
101
|
+
with Path("/proc/net/route").open() as f:
|
|
102
|
+
for line in f:
|
|
103
|
+
fields = line.strip().split()
|
|
104
|
+
min_field_count = 3 # Minimum fields needed: interface, destination, gateway
|
|
105
|
+
if len(fields) >= min_field_count and fields[1] == "00000000": # Default route
|
|
106
|
+
# Gateway is in hex format (little-endian)
|
|
107
|
+
gateway_hex = fields[2]
|
|
108
|
+
# Convert hex to IP address
|
|
109
|
+
# The hex is in little-endian format, so we read it backwards in pairs
|
|
110
|
+
octets = [gateway_hex[i : i + 2] for i in range(0, 8, 2)]
|
|
111
|
+
return ".".join(str(int(octet, 16)) for octet in reversed(octets))
|
|
112
|
+
except (FileNotFoundError, PermissionError, IndexError, ValueError):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def transform_localhost_url(url: str) -> str:
|
|
119
|
+
"""Transform localhost URLs to container-accessible hosts when running in a container.
|
|
120
|
+
|
|
121
|
+
Automatically detects if running inside a container and finds the appropriate host
|
|
122
|
+
address to replace localhost/127.0.0.1. Tries in order:
|
|
123
|
+
- host.docker.internal (if resolvable)
|
|
124
|
+
- host.containers.internal (if resolvable)
|
|
125
|
+
- Gateway IP from routing table (fallback)
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
url: The original URL
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Transformed URL with container-accessible host if applicable, otherwise the original URL.
|
|
132
|
+
|
|
133
|
+
Example:
|
|
134
|
+
>>> transform_localhost_url("http://localhost:5001")
|
|
135
|
+
# Returns "http://host.docker.internal:5001" if running in Docker and hostname resolves
|
|
136
|
+
# Returns "http://172.17.0.1:5001" if running in Docker on Linux (gateway IP fallback)
|
|
137
|
+
# Returns "http://localhost:5001" if not in a container
|
|
138
|
+
"""
|
|
139
|
+
container_host = get_container_host()
|
|
140
|
+
|
|
141
|
+
if not container_host:
|
|
142
|
+
return url
|
|
143
|
+
|
|
144
|
+
# Replace localhost and 127.0.0.1 with the container host
|
|
145
|
+
localhost_patterns = ["localhost", "127.0.0.1"]
|
|
146
|
+
|
|
147
|
+
for pattern in localhost_patterns:
|
|
148
|
+
if pattern in url:
|
|
149
|
+
return url.replace(pattern, container_host)
|
|
150
|
+
|
|
151
|
+
return url
|
|
152
|
+
|
|
153
|
+
|
|
19
154
|
def unescape_string(s: str):
|
|
20
155
|
# Replace escaped new line characters with actual new line characters
|
|
21
156
|
return s.replace("\\n", "\n")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lfx-nightly
|
|
3
|
-
Version: 0.1.12.
|
|
3
|
+
Version: 0.1.12.dev28
|
|
4
4
|
Summary: Langflow Executor - A lightweight CLI tool for executing and serving Langflow AI flows
|
|
5
5
|
Author-email: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
|
|
6
6
|
Requires-Python: <3.14,>=3.10
|