Skip to content

feat(storage): add GCS and Azure Blob storage backends - #14458

Open
akashtomy wants to merge 5 commits into
langflow-ai:release-1.12.0from
akashtomy:feat/gcs-storage-backend
Open

feat(storage): add GCS and Azure Blob storage backends#14458
akashtomy wants to merge 5 commits into
langflow-ai:release-1.12.0from
akashtomy:feat/gcs-storage-backend

Conversation

@akashtomy

@akashtomy akashtomy commented Aug 7, 2026

Copy link
Copy Markdown

Summary

Langflow's file storage currently supports local disk and AWS S3. For teams running on Google Cloud or Azure, that means either accepting vendor lock-in to AWS for file storage regardless of where the rest of their infrastructure lives, or falling back to local disk (which breaks in any multi-replica/ephemeral-pod deployment). This PR closes that gap by making Langflow's storage layer cloud-agnostic across the three major providers.

Adds two new object-storage backends alongside the existing local and S3 backends, so LANGFLOW_STORAGE_TYPE now supports local, s3, gcs, and azure interchangeably with no other code changes required to switch providers:

  • GCSStorageService (src/backend/base/langflow/services/storage/gcs.py) - wraps the official google-cloud-storage client, offloading its sync calls via asyncio.to_thread. Auth via GOOGLE_APPLICATION_CREDENTIALS or Application Default Credentials.
  • AzureBlobStorageService (src/backend/base/langflow/services/storage/azure_blob.py) - uses the native azure-storage-blob asyncio client (also works against ADLS Gen2 accounts via the flat Blob API). Auth via AZURE_STORAGE_CONNECTION_STRING or DefaultAzureCredential (managed identity / workload identity / service principal / az login), matching the credential-resolution conventions each cloud's own tooling already expects.

Both are drop-in siblings of S3StorageService, not one-off implementations: same _validate_identifiers path-traversal guard (defense in depth for GHSA-rcjh-r59h-gq37), same error-code-to-exception mapping (FileNotFoundError/PermissionError/RuntimeError), same build_full_path/parse_file_path key layout, and wired into StorageServiceFactory the same way via LANGFLOW_STORAGE_TYPE. Deployers can move a Langflow instance between AWS, GCP, and Azure - or run different environments on different clouds - by changing environment variables only.

Also fixes a routing gap so the new backends are actually cloud-agnostic end-to-end, not just at the storage-service layer: several lfx call sites backing the Read File, Write File, CSV Agent, and JSON Agent components special-cased storage_type == "s3" to decide whether to read through the storage service or the local filesystem. That would have silently broken those components for GCS/Azure users even though file upload/save worked fine. Added is_remote_storage_type() in lfx/base/data/storage_utils.py and swapped every S3-only check for it across base_file.py, file.py, csv_agent.py, json_agent.py, and utils.py, so all four backends behave identically from the component layer's perspective.

Docs updated in concepts-file-management.mdx: new backend sections (auth options, required IAM/RBAC permissions per provider), and the environment variable reference table now documents all four backends together.

Test plan

  • uv run pytest src/backend/tests/unit/services/storage/ - 241 passed
  • cd src/lfx && uv run pytest tests/unit/base/data/test_storage_utils.py tests/unit/base/data/test_base_file.py tests/unit/base/data/test_utils.py tests/unit/components/langchain_utilities/test_csv_agent.py tests/unit/components/langchain_utilities/test_json_agent.py - 95 passed (includes new parametrized coverage proving GCS/Azure route through the storage service the same way S3 does)
  • ruff check clean on all touched files
  • Manual end-to-end test against a real GCS bucket / Azure container (not yet done - happy to if a maintainer wants it before merge)

Summary by CodeRabbit

  • New Features

    • Added Google Cloud Storage and Azure Blob Storage as supported file-storage options.
    • Supports uploading, downloading, streaming, listing, deleting, and retrieving file sizes across supported remote backends.
    • Added configurable authentication, prefixes, containers, buckets, and metadata tagging.
  • Bug Fixes

    • File processing now correctly recognizes all supported remote-storage providers, not only S3.
    • Improved validation prevents unsafe file and flow paths.
  • Documentation

    • Expanded storage setup, configuration, credentials, permissions, and environment-variable guidance.

Akash Thomas and others added 2 commits August 7, 2026 17:33
* docs: update file management docs for GCS and Azure backends
* feat(storage): add GCSStorageService implementation
* feat(storage): add AzureBlobStorageService implementation
* docs: document GCS and Azure configuration options
* docs: update storage environment variables table
The Read File, Write File, CSV Agent, and JSON Agent code paths in lfx
special-cased storage_type == "s3" to decide whether to read through the
storage service instead of the local filesystem. That left GCS and Azure
files unreadable by those components even though the new GCSStorageService
and AzureBlobStorageService worked fine at the storage-service layer.

Adds is_remote_storage_type() in storage_utils.py and swaps every s3-only
check for it across base_file.py, file.py, csv_agent.py, json_agent.py,
and utils.py.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 24b8f28d-e166-40e0-bb40-b2c35279a625

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The change adds Google Cloud Storage and Azure Blob Storage backends, integrates them with storage selection and file processing, adds dependency and path-validation coverage, updates starter projects, and documents provider configuration.

Changes

Cloud storage backends

Layer / File(s) Summary
Remote backend detection
src/lfx/src/lfx/base/data/*, src/lfx/src/lfx/components/..., src/backend/base/langflow/initial_setup/starter_projects/*, src/lfx/tests/unit/base/data/test_storage_utils.py
Remote storage detection now supports S3, GCS, and Azure across file loading, validation, filtering, CSV/JSON handling, Docling processing, image validation, and starter projects.
GCS backend and integration
src/backend/base/langflow/services/storage/gcs.py, src/backend/base/langflow/services/storage/factory.py, src/backend/tests/.../storage/test_gcs*, src/backend/tests/unit/services/test_storage_parse_file_path.py
Adds asynchronous GCS initialization, path handling, upload, retrieval, streaming, listing, deletion, size lookup, teardown, error mapping, and tests.
Azure Blob backend and integration
src/backend/base/langflow/services/storage/azure_blob.py, src/backend/tests/.../storage/test_azure*, src/backend/tests/unit/services/test_storage_parse_file_path.py
Adds Azure authentication, validation, path handling, upload, retrieval, streaming, listing, deletion, size lookup, teardown, error mapping, and tests.
Configuration and documentation
src/backend/base/pyproject.toml, src/backend/base/langflow/services/storage/__init__.py, src/lfx/src/lfx/services/settings/groups/storage.py, docs/docs/Develop/concepts-file-management.mdx, src/backend/tests/unit/services/storage/test_*_dependency.py
Adds cloud-storage dependencies and exports. Documents GCS and Azure settings, credentials, permissions, prefixes, and tags. Tests dependency declarations.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StorageServiceFactory
  participant StorageService
  participant CloudProvider
  StorageServiceFactory->>StorageService: create gcs or azure service
  StorageService->>CloudProvider: save, retrieve, stream, list, delete, or size request
  CloudProvider-->>StorageService: result or object data
  StorageService-->>StorageServiceFactory: return operation result
Loading

Possibly related PRs

Suggested reviewers: jordanrfrazier, cristhianzl

🚥 Pre-merge checks | ✅ 7 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Test Quality And Coverage ⚠️ Warning The standard unit suite tests only initialization, append rejection, and path validation. Normal GCS/Azure operations rely on credential-gated integrations, and factory/component routing lacks dire... Add offline async mock tests for upload, download, stream cleanup, list, delete, sizes, tags, and error mappings for both services; test factory selection and GCS/Azure routing. Gate ADC correctly.
✅ Passed checks (7 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding GCS and Azure Blob Storage backends.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Test Coverage For New Implementations ✅ Passed The PR adds named backend unit tests, real GCS/Azure integration suites, path/dependency tests, and parameterized GCS/Azure routing tests covering storage operations.
Test File Naming And Structure ✅ Passed PR tests use pytest test_*.py names, logical unit/integration directories, descriptive test names, async markers, cleanup fixtures, and positive, negative, and edge-case coverage; no frontend tests...
Excessive Mock Usage Warning ✅ Passed Mocks isolate cloud SDKs and constructor dependencies; routing tests assert calls, while GCS and Azure integration suites use real clients for file operations, streaming, listing, deletion, and sizes.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the enhancement New feature or request label Aug 7, 2026
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Aug 7, 2026
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Aug 7, 2026
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Aug 7, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (6)
src/lfx/src/lfx/base/data/base_file.py (1)

927-927: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename is_s3_storage to reflect the generalized check.

is_s3_storage now holds the result of is_remote_storage_type(), which is true for S3, GCS, and Azure. The name still implies S3-only, which can mislead a reader debugging a GCS or Azure path later.

Rename it to is_remote_storage (or similar) to match its actual meaning.

♻️ Proposed rename
-        is_s3_storage = is_remote_storage_type(settings.storage_type)
+        is_remote_storage = is_remote_storage_type(settings.storage_type)
         final_files = []
         ignored_files = []

         for file in files:
             # For local storage, verify the path is actually a file
             # For S3 storage, paths are virtual keys that don't exist locally
-            if not is_s3_storage and not file.path.is_file():
+            if not is_remote_storage and not file.path.is_file():
                 self.log(f"Not a file: {file.path.name}")
                 continue

             # Validate file extension
             extension = file.path.suffix[1:].lower() if file.path.suffix else ""
             if extension not in self.valid_extensions:
                 # For local storage, optionally ignore unsupported extensions
-                if not is_s3_storage and self.ignore_unsupported_extensions:
+                if not is_remote_storage and self.ignore_unsupported_extensions:
                     ignored_files.append(file.path.name)
                     continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lfx/src/lfx/base/data/base_file.py` at line 927, Rename the local
variable is_s3_storage to is_remote_storage wherever it is declared and
referenced in the surrounding flow, preserving the existing
is_remote_storage_type(settings.storage_type) behavior for all supported remote
storage providers.
src/lfx/src/lfx/base/data/utils.py (1)

333-342: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update stale "S3 storage" wording in comments and docstrings to cover GCS and Azure.

The underlying checks in all six sites were correctly generalized to is_remote_storage_type(), which now covers S3, GCS, and Azure. The comments and docstrings next to these checks still say "S3 storage" only. This can mislead a future maintainer into thinking the code path is S3-specific when it is not.

  • src/lfx/src/lfx/base/data/utils.py#L333-L342: Update the parse_text_file_to_data docstring ("For S3 storage, this will use async operations to fetch the file") to say "For remote (S3/GCS/Azure) storage."
  • src/lfx/src/lfx/base/data/base_file.py#L713-L716: Update the add_file comment ("When using object storage (S3), file paths are storage keys") to name all three remote backends.
  • src/lfx/src/lfx/base/data/base_file.py#L444-L448: Update the load_files_path comment ("For S3 storage, paths are virtual storage keys") to name all three remote backends.
  • src/lfx/src/lfx/base/data/base_file.py#L463-L465: Update the load_files_structured_helper comment ("For S3 storage, download file bytes first") to name all three remote backends.
  • src/lfx/src/lfx/components/langchain_utilities/csv_agent.py#L219-L229: Update the _get_local_path docstring and comment ("downloading from S3 storage if necessary" / "If using S3 storage, download the file to temp") to name all three remote backends.
  • src/lfx/src/lfx/components/langchain_utilities/json_agent.py#L38-L48: Update the _get_local_path docstring and comment (same S3-only wording) to name all three remote backends.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lfx/src/lfx/base/data/utils.py` around lines 333 - 342, Update the stale
S3-only documentation to consistently describe remote storage as S3/GCS/Azure.
In src/lfx/src/lfx/base/data/utils.py lines 333-342, update
parse_text_file_to_data’s docstring; in src/lfx/src/lfx/base/data/base_file.py
lines 713-716, 444-448, and 463-465, update the add_file, load_files_path, and
load_files_structured_helper comments; and in
src/lfx/src/lfx/components/langchain_utilities/csv_agent.py lines 219-229 and
src/lfx/src/lfx/components/langchain_utilities/json_agent.py lines 38-48, update
each _get_local_path docstring/comment. No behavioral code changes are needed.
src/backend/base/pyproject.toml (1)

129-131: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider keeping the cloud SDKs as extras only.

gcs.py and azure_blob.py import their SDKs lazily and raise a clear ImportError with install instructions. That design supports extras-only installation. Declaring google-cloud-storage, azure-storage-blob, and azure-identity as hard runtime dependencies adds a large transitive tree (google-api-core, grpcio pulls, msal, cryptography) to every langflow-base install, including local-storage users.

If the goal is out-of-the-box support, keep the current layout. If install size matters, move these three to the gcs and azure extras only and update test_gcs_dependency.py and test_azure_dependency.py accordingly.

Also applies to: 425-436

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/pyproject.toml` around lines 129 - 131, Move
google-cloud-storage, azure-storage-blob, and azure-identity from the base
runtime dependencies into the existing gcs and azure extras in pyproject.toml.
Update test_gcs_dependency.py and test_azure_dependency.py to validate the
extras-only dependency layout while preserving the lazy imports and clear
ImportError guidance in gcs.py and azure_blob.py.
src/backend/base/langflow/services/storage/azure_blob.py (1)

232-243: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Merge the duplicated failure paths.

The HttpResponseError fall-through and the generic Exception handler build the same message and raise the same error. Collapse them.

♻️ Proposed refactor
         except HttpResponseError as e:
             if e.status_code == 403:  # noqa: PLR2004
                 msg = "Access denied to Azure Blob container. Please check your role assignment and permissions"
                 logger.exception(f"Error saving file {file_name} to Azure Blob storage in flow {flow_id}: {msg}")
                 raise PermissionError(msg) from e
-            logger.exception(f"Error saving file {file_name} to Azure Blob storage in flow {flow_id}")
-            msg = f"Failed to save file to Azure Blob storage: {e}"
-            raise RuntimeError(msg) from e
+            raise
         except Exception as e:
             logger.exception(f"Error saving file {file_name} to Azure Blob storage in flow {flow_id}")
             msg = f"Failed to save file to Azure Blob storage: {e}"
             raise RuntimeError(msg) from e
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/storage/azure_blob.py` around lines 232 -
243, Merge the non-403 HttpResponseError fall-through with the generic handler
in the save-file exception flow, preserving the shared logging, RuntimeError
message, and exception chaining. Keep the dedicated 403 PermissionError branch
unchanged, and update the handler structure around the visible HttpResponseError
and Exception catches without altering other behavior.
src/backend/tests/unit/services/storage/test_gcs_storage_service.py (1)

89-89: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant @pytest.mark.asyncio markers across the four new test files. The repository configures pytest-asyncio with asyncio_mode = "auto", so async tests are collected without an explicit marker. Every new test file adds the marker on classes and module-level async tests.

  • src/backend/tests/unit/services/storage/test_gcs_storage_service.py#L89-L89: remove the markers at Line 89 and Line 145.
  • src/backend/tests/unit/services/storage/test_azure_storage_service.py#L124-L124: remove the markers at Line 124 and Line 180.
  • src/backend/tests/integration/storage/test_gcs_storage_service.py#L72-L72: remove the class-level markers at Lines 72, 99, 167, 197, 244, 268, 305, and 347.
  • src/backend/tests/integration/storage/test_azure_storage_service.py#L80-L80: remove the class-level markers at Lines 80, 107, 175, 205, 252, 276, 313, and 355.

Based on learnings: pytest-asyncio is configured with asyncio_mode = 'auto' in pyproject.toml, so async tests are auto-detected and do not need pytest.mark.asyncio.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/storage/test_gcs_storage_service.py` at line
89, Remove all redundant pytest.mark.asyncio decorators from the four new
storage test files:
src/backend/tests/unit/services/storage/test_gcs_storage_service.py lines 89 and
145; src/backend/tests/unit/services/storage/test_azure_storage_service.py lines
124 and 180; src/backend/tests/integration/storage/test_gcs_storage_service.py
lines 72, 99, 167, 197, 244, 268, 305, and 347; and
src/backend/tests/integration/storage/test_azure_storage_service.py lines 80,
107, 175, 205, 252, 276, 313, and 355. Keep the async tests unchanged otherwise,
relying on the repository’s asyncio_mode = "auto" configuration.

Source: Learnings

src/backend/base/langflow/services/storage/gcs.py (1)

369-375: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Close the GCS client in teardown.

storage.Client can keep an active HTTP session. Release it explicitly during teardown if the installed Google Cloud Storage client supports Client.close().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/storage/gcs.py` around lines 369 - 375,
Update GCS storage service teardown to close the storage.Client when the
installed client exposes Client.close(), while safely preserving teardown for
versions without that method. Invoke the close operation from teardown before
logging completion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/docs/Develop/concepts-file-management.mdx`:
- Around line 257-258: Update the Default column for
LANGFLOW_OBJECT_STORAGE_BUCKET_NAME and LANGFLOW_OBJECT_STORAGE_PREFIX to match
the configured StorageSettings defaults, langflow-bucket and files. Revise the
prefix description to remove the claim that files are stored at the root when
unset.
- Line 242: Update the credential-order description in the Azure storage
configuration paragraph to match azure-identity 1.25.3: EnvironmentCredential,
WorkloadIdentityCredential, ManagedIdentityCredential, then developer tool
credentials. Replace the current managed identity, AKS workload identity,
service principal, and az login ordering while preserving the surrounding
configuration guidance.

In `@src/backend/base/langflow/initial_setup/starter_projects/Portfolio` Website
Code Generator.json:
- Line 563: Update _get_local_file_for_docling to use provider-neutral wording
whenever validating remote paths: revise its comments, docstring, and “Invalid
S3 path format” error to refer to remote storage or include the configured
storage type, while preserving the existing validation behavior for GCS, Azure,
and other backends.

In `@src/backend/base/langflow/initial_setup/starter_projects/Text` Sentiment
Analysis.json:
- Line 1143: Update _get_local_file_for_docling to use provider-neutral “remote
storage” terminology in its comments and invalid-path error message, rather than
referring specifically to S3. If useful, include settings.storage_type in the
diagnostic while preserving the existing validation behavior for all remote
backends.

In `@src/backend/base/langflow/services/storage/azure_blob.py`:
- Around line 277-313: Update get_file_stream to create a single Azure
downloader after obtaining the blob’s properties, passing the properties ETag as
the downloader’s match condition to snapshot the blob. Replace the per-chunk
download_blob(offset=..., length=...) loop with iteration over
downloader.chunks(), yielding each SDK-provided chunk and preserving the
existing not-found handling.

In `@src/backend/base/langflow/services/storage/gcs.py`:
- Around line 340-367: Update get_file_size to explicitly load the blob metadata
before reading Blob.size, then treat a missing or unavailable size as
FileNotFoundError and return the loaded integer size. Preserve the existing
blob-not-found handling and use the existing blob retrieval flow rather than
returning blob.size directly.

In `@src/backend/tests/integration/storage/test_gcs_storage_service.py`:
- Around line 23-27: Update the _gcp_credentials fixture to allow tests when
either GOOGLE_APPLICATION_CREDENTIALS is set or Application Default Credentials
are available, skipping only when neither credential source can be used.
Preserve the existing skip message behavior for missing credentials.
- Around line 239-241: Split the shared cleanup suppression so each independent
delete_file call has its own contextlib.suppress(Exception) block. Apply this in
src/backend/tests/integration/storage/test_gcs_storage_service.py:239-241 for
the test_flow_id and other_flow_id deletes, and in
src/backend/tests/integration/storage/test_azure_storage_service.py:247-249 for
the corresponding deletes, ensuring the second cleanup still runs if the first
fails.

---

Nitpick comments:
In `@src/backend/base/langflow/services/storage/azure_blob.py`:
- Around line 232-243: Merge the non-403 HttpResponseError fall-through with the
generic handler in the save-file exception flow, preserving the shared logging,
RuntimeError message, and exception chaining. Keep the dedicated 403
PermissionError branch unchanged, and update the handler structure around the
visible HttpResponseError and Exception catches without altering other behavior.

In `@src/backend/base/langflow/services/storage/gcs.py`:
- Around line 369-375: Update GCS storage service teardown to close the
storage.Client when the installed client exposes Client.close(), while safely
preserving teardown for versions without that method. Invoke the close operation
from teardown before logging completion.

In `@src/backend/base/pyproject.toml`:
- Around line 129-131: Move google-cloud-storage, azure-storage-blob, and
azure-identity from the base runtime dependencies into the existing gcs and
azure extras in pyproject.toml. Update test_gcs_dependency.py and
test_azure_dependency.py to validate the extras-only dependency layout while
preserving the lazy imports and clear ImportError guidance in gcs.py and
azure_blob.py.

In `@src/backend/tests/unit/services/storage/test_gcs_storage_service.py`:
- Line 89: Remove all redundant pytest.mark.asyncio decorators from the four new
storage test files:
src/backend/tests/unit/services/storage/test_gcs_storage_service.py lines 89 and
145; src/backend/tests/unit/services/storage/test_azure_storage_service.py lines
124 and 180; src/backend/tests/integration/storage/test_gcs_storage_service.py
lines 72, 99, 167, 197, 244, 268, 305, and 347; and
src/backend/tests/integration/storage/test_azure_storage_service.py lines 80,
107, 175, 205, 252, 276, 313, and 355. Keep the async tests unchanged otherwise,
relying on the repository’s asyncio_mode = "auto" configuration.

In `@src/lfx/src/lfx/base/data/base_file.py`:
- Line 927: Rename the local variable is_s3_storage to is_remote_storage
wherever it is declared and referenced in the surrounding flow, preserving the
existing is_remote_storage_type(settings.storage_type) behavior for all
supported remote storage providers.

In `@src/lfx/src/lfx/base/data/utils.py`:
- Around line 333-342: Update the stale S3-only documentation to consistently
describe remote storage as S3/GCS/Azure. In src/lfx/src/lfx/base/data/utils.py
lines 333-342, update parse_text_file_to_data’s docstring; in
src/lfx/src/lfx/base/data/base_file.py lines 713-716, 444-448, and 463-465,
update the add_file, load_files_path, and load_files_structured_helper comments;
and in src/lfx/src/lfx/components/langchain_utilities/csv_agent.py lines 219-229
and src/lfx/src/lfx/components/langchain_utilities/json_agent.py lines 38-48,
update each _get_local_path docstring/comment. No behavioral code changes are
needed.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 422b15c5-ecf9-45cc-9f4a-69e52051cc11

📥 Commits

Reviewing files that changed from the base of the PR and between 3e5692b and 15167f1.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (26)
  • docs/docs/Develop/concepts-file-management.mdx
  • src/backend/base/langflow/initial_setup/starter_projects/Financial Report Parser.json
  • src/backend/base/langflow/initial_setup/starter_projects/Hybrid Search RAG.json
  • src/backend/base/langflow/initial_setup/starter_projects/Portfolio Website Code Generator.json
  • src/backend/base/langflow/initial_setup/starter_projects/Text Sentiment Analysis.json
  • src/backend/base/langflow/services/storage/__init__.py
  • src/backend/base/langflow/services/storage/azure_blob.py
  • src/backend/base/langflow/services/storage/factory.py
  • src/backend/base/langflow/services/storage/gcs.py
  • src/backend/base/pyproject.toml
  • src/backend/tests/integration/storage/test_azure_storage_service.py
  • src/backend/tests/integration/storage/test_gcs_storage_service.py
  • src/backend/tests/unit/services/storage/test_azure_dependency.py
  • src/backend/tests/unit/services/storage/test_azure_storage_service.py
  • src/backend/tests/unit/services/storage/test_gcs_dependency.py
  • src/backend/tests/unit/services/storage/test_gcs_storage_service.py
  • src/backend/tests/unit/services/test_storage_parse_file_path.py
  • src/lfx/src/lfx/_assets/component_index.json
  • src/lfx/src/lfx/base/data/base_file.py
  • src/lfx/src/lfx/base/data/storage_utils.py
  • src/lfx/src/lfx/base/data/utils.py
  • src/lfx/src/lfx/components/files_and_knowledge/file.py
  • src/lfx/src/lfx/components/langchain_utilities/csv_agent.py
  • src/lfx/src/lfx/components/langchain_utilities/json_agent.py
  • src/lfx/src/lfx/services/settings/groups/storage.py
  • src/lfx/tests/unit/base/data/test_storage_utils.py

* `AZURE_CONNECTION_STRING`: Your storage account's connection string, from **Access keys** in the Azure Portal.
* `AZURE_ACCOUNT_NAME`: Your storage account name, used only if you're authenticating with `DefaultAzureCredential` instead of a connection string.

If you don't set `AZURE_STORAGE_CONNECTION_STRING`, Langflow falls back to [`DefaultAzureCredential`](https://learn.microsoft.com/en-us/python/api/azure-identity/azure.identity.defaultazurecredential), which resolves credentials from a managed identity, AKS workload identity, a service principal (`AZURE_CLIENT_ID`/`AZURE_TENANT_ID`/`AZURE_CLIENT_SECRET`), or an existing `az login` session, in that order. You can also set `AZURE_STORAGE_ACCOUNT_URL` directly instead of `AZURE_STORAGE_ACCOUNT_NAME` if your account uses a non-default endpoint.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file excerpt =="
sed -n '220,255p' docs/docs/Develop/concepts-file-management.mdx || true

echo
echo "== azure identity usage in repo =="
rg -n "DefaultAzureCredential|AZURE_(CLIENT|TENANT|SECRET|IDENTITY|TOKEN|CONNECTION_STRING|ACCOUNT_(NAME|URL))|ClientSecretCredential|ManagedIdentityCredential" -S .

echo
echo "== lockfile/package hints for azure-identity =="
for f in pylock.toml requirements.txt requirements-dev.txt poetry.lock uv.lock Pipfile.lock setup.cfg pyproject.toml; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    rg -n "azure-identity|azure.storage.blob|azure.identity|DefaultAzureCredential" "$f" -C 2 || true
  fi
done

Repository: langflow-ai/langflow

Length of output: 8385


🌐 Web query:

Azure Identity 1.19.0 DefaultAzureCredential credential order service principal workload identity managed identity Azure CLI Developer credentials documentation

💡 Result:

In Azure Identity 1.19.0, DefaultAzureCredential attempts to authenticate by traversing a preconfigured chain of credentials in a specific order, stopping as soon as one successfully provides a token [1][2]. The authentication order for DefaultAzureCredential is as follows [1][3][4]: 1. EnvironmentCredential: Uses environment variables to authenticate a service principal [3][5]. 2. WorkloadIdentityCredential: Authenticates using Microsoft Entra Workload ID when deployed to a compatible Azure host (e.g., Kubernetes) [3][6]. 3. ManagedIdentityCredential: Authenticates using the managed identity assigned to the Azure resource [3][4]. 4. VisualStudioCredential: Authenticates using the account logged into Visual Studio [3]. 5. VisualStudioCodeCredential: Authenticates using the account logged into the Azure Resources extension in VS Code [1][7]. 6. AzureCliCredential: Authenticates using the account logged into the Azure CLI (az login) [4][6]. 7. AzurePowerShellCredential: Authenticates using the account logged into Azure PowerShell (Connect-AzAccount) [2][6]. 8. AzureDeveloperCliCredential: Authenticates using the account logged into the Azure Developer CLI (azd auth login) [8][2]. 9. InteractiveBrowserCredential / BrokerCredential: Optionally authenticates via an interactive browser or system broker (if enabled and configured, typically requiring the Azure.Identity.Broker package) [1][7][9]. Key behaviors: - Development vs. Production: DefaultAzureCredential is designed to simplify development by combining production-ready host credentials (like Managed Identity and Workload Identity) with developer-focused credentials [10][3]. - Resilience: As of version 1.10.1 and later, DefaultAzureCredential will continue to the next credential in the chain if a developer tool credential (e.g., Azure CLI) fails to retrieve a token [10]. - Configuration: You can customize this flow, such as excluding specific credentials, by passing DefaultAzureCredentialOptions to the constructor [9][2]. Credentials requiring user interaction, such as InteractiveBrowserCredential, are excluded by default [9][11].

Citations:


🌐 Web query:

Azure Identity Python 1.25.3 DefaultAzureCredential credential order EnvironmentCredential WorkloadIdentityCredential ManagedIdentityCredential AzureCliCredential

💡 Result:

In the Azure Identity library for Python (version 1.25.3), DefaultAzureCredential executes a specific sequence of credential providers to authenticate requests. When an access token is required, it attempts to use the following credentials in this order, stopping as soon as one successfully provides a token [1][2]: 1. EnvironmentCredential: Uses service principal information provided through environment variables [3][1][4]. 2. WorkloadIdentityCredential: Used if the application is deployed to a host with Workload Identity enabled [3][1][4]. 3. ManagedIdentityCredential: Used if the application is deployed to an Azure host with Managed Identity enabled [3][1][4]. 4. SharedTokenCacheCredential: On Windows, uses identities signed into Microsoft applications like Visual Studio [1][2]. 5. Visual Studio Code: Uses the identity logged into the Azure Resources extension in VS Code [1][2]. 6. AzureCliCredential: Uses the identity authenticated via the Azure CLI (az login) [3][1][4]. 7. AzurePowerShellCredential: Uses the identity authenticated via Azure PowerShell (Connect-AzAccount) [1][2]. 8. AzureDeveloperCliCredential: Uses the identity authenticated via the Azure Developer CLI (azd auth login) [1][2]. 9. Brokered authentication: On Windows and WSL, uses the default account via Web Account Manager (WAM) if the azure-identity-broker package is installed [1][2]. As of version 1.14.0 and continuing through 1.25.3, DefaultAzureCredential utilizes a continuation policy for developer credentials [5][6]. If a developer-focused credential (such as AzureCliCredential) attempts to retrieve a token and fails, DefaultAzureCredential will catch the error and continue to the next credential in the chain [5][6]. In contrast, service-deployed credentials (like ManagedIdentityCredential) will stop the flow and raise an exception if they are able to attempt token retrieval but fail [5][6].

Citations:


Use the correct DefaultAzureCredential order.

This page describes Langflow’s pinned azure-identity 1.25.3, where the default chain is EnvironmentCredential, WorkloadIdentityCredential, then ManagedIdentityCredential, followed by developer tool credentials. Replace the “managed identity, AKS workload identity, service principal, az login” order with the documented credential sequence.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/docs/Develop/concepts-file-management.mdx` at line 242, Update the
credential-order description in the Azure storage configuration paragraph to
match azure-identity 1.25.3: EnvironmentCredential, WorkloadIdentityCredential,
ManagedIdentityCredential, then developer tool credentials. Replace the current
managed identity, AKS workload identity, service principal, and az login
ordering while preserving the surrounding configuration guidance.

Comment on lines +257 to +258
| `LANGFLOW_OBJECT_STORAGE_BUCKET_NAME` | String | Not set | The name of the S3 bucket, GCS bucket, or Azure Blob container to use for file storage. Required when `LANGFLOW_STORAGE_TYPE` is `s3`, `gcs`, or `azure`. |
| `LANGFLOW_OBJECT_STORAGE_PREFIX` | String | Not set | Optional prefix/folder path within the bucket or container where files will be stored. If not set, files are stored at the root. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document the configured defaults.

StorageSettings sets LANGFLOW_OBJECT_STORAGE_BUCKET_NAME to langflow-bucket and LANGFLOW_OBJECT_STORAGE_PREFIX to files. The table states that both are unset and that an unset prefix stores files at the root. This gives users an incorrect key layout.

Update the Default column and remove the root-storage claim. The source contract is src/lfx/src/lfx/services/settings/groups/storage.py:9-12.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/docs/Develop/concepts-file-management.mdx` around lines 257 - 258,
Update the Default column for LANGFLOW_OBJECT_STORAGE_BUCKET_NAME and
LANGFLOW_OBJECT_STORAGE_PREFIX to match the configured StorageSettings defaults,
langflow-bucket and files. Revise the prefix description to remove the claim
that files are stored at the root when unset.

@@ -560,7 +560,7 @@
"show": true,
"title_case": false,
"type": "code",
"value": "\"\"\"Enhanced file component with Docling support and process isolation.\n\nNotes:\n-----\n- ALL Docling parsing/export runs in a separate OS process to prevent memory\n growth and native library state from impacting the main Langflow process.\n- Standard text/structured parsing continues to use existing BaseFileComponent\n utilities (and optional threading via `parallel_load_data`).\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport json\nimport subprocess\nimport sys\nimport textwrap\nimport threading\nimport time\nfrom contextvars import ContextVar\nfrom copy import deepcopy\nfrom pathlib import Path\nfrom tempfile import NamedTemporaryFile\nfrom typing import Any\n\nfrom lfx.base.data.base_file import BaseFileComponent\nfrom lfx.base.data.storage_utils import parse_storage_path, read_file_bytes, validate_image_content_type\nfrom lfx.base.data.utils import TEXT_FILE_TYPES, parallel_load_data, parse_text_file_to_data\nfrom lfx.inputs import SortableListInput\nfrom lfx.inputs.inputs import DropdownInput, MessageTextInput, StrInput\nfrom lfx.io import BoolInput, FileInput, IntInput, Output, SecretStrInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame # noqa: TC001\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service, get_storage_service\nfrom lfx.utils.async_helpers import run_until_complete\nfrom lfx.utils.validate_cloud import is_astra_cloud_environment\n\n_FILE_TOOL_CANCEL_EVENT: ContextVar[threading.Event | None] = ContextVar(\"file_tool_cancel_event\", default=None)\n_FILE_TOOL_CANCEL_WAIT_SECONDS = 6\n_FILE_TOOL_MAX_CONCURRENT_LOADS = 4\n_FILE_TOOL_LIMITER_ATTRIBUTE = \"_lfx_file_tool_load_limiter\"\n_FILE_TOOL_PROCESS_REAP_SECONDS = 2\n\n\nclass _FileToolCancelledError(RuntimeError):\n \"\"\"Stop synchronous file loading after its async tool call is cancelled.\"\"\"\n\n\ndef _raise_if_file_tool_cancelled(cancel_event: threading.Event | None = None) -> None:\n event = cancel_event or _FILE_TOOL_CANCEL_EVENT.get()\n if event is not None and event.is_set():\n raise _FileToolCancelledError\n\n\ndef _kill_and_reap_process(proc: subprocess.Popen) -> None:\n try:\n proc.kill()\n except ProcessLookupError:\n pass\n except OSError:\n logger.exception(\"Failed to kill cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while killing cancelled Docling subprocess\")\n\n try:\n proc.communicate(timeout=_FILE_TOOL_PROCESS_REAP_SECONDS)\n except subprocess.TimeoutExpired:\n logger.warning(\"Timed out draining cancelled Docling subprocess; waiting for process exit\")\n except OSError:\n logger.exception(\"Failed to drain cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while draining cancelled Docling subprocess\")\n else:\n return\n\n try:\n proc.wait(timeout=_FILE_TOOL_PROCESS_REAP_SECONDS)\n except subprocess.TimeoutExpired:\n logger.exception(\"Cancelled Docling subprocess could not be reaped within the cleanup timeout\")\n except OSError:\n logger.exception(\"Failed to reap cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while reaping cancelled Docling subprocess\")\n\n\ndef _get_file_tool_limiter() -> asyncio.Semaphore:\n \"\"\"Return the bounded loader admission gate for the current event loop.\"\"\"\n loop = asyncio.get_running_loop()\n limiter = getattr(loop, _FILE_TOOL_LIMITER_ATTRIBUTE, None)\n if limiter is None:\n limiter = asyncio.Semaphore(_FILE_TOOL_MAX_CONCURRENT_LOADS)\n setattr(loop, _FILE_TOOL_LIMITER_ATTRIBUTE, limiter)\n return limiter\n\n\ndef _log_abandoned_file_tool_result(task: asyncio.Task) -> None:\n \"\"\"Observe unexpected failures from a synchronous loader that outlived its caller.\"\"\"\n try:\n error = task.exception()\n except asyncio.CancelledError:\n return\n if error is not None and not isinstance(error, _FileToolCancelledError):\n logger.error(\"Abandoned file loader failed after its tool call was cancelled\", exc_info=error)\n\n\ndef _get_storage_location_options():\n \"\"\"Get storage location options, filtering out Local if in Astra cloud environment.\"\"\"\n all_options = [{\"name\": \"AWS\", \"icon\": \"Amazon\"}, {\"name\": \"Google Drive\", \"icon\": \"google\"}]\n if is_astra_cloud_environment():\n return all_options\n return [{\"name\": \"Local\", \"icon\": \"hard-drive\"}, *all_options]\n\n\nclass FileComponent(BaseFileComponent):\n \"\"\"File component with optional Docling processing (isolated in a subprocess).\"\"\"\n\n display_name = \"Read File\"\n # description is now a dynamic property - see get_tool_description()\n _base_description = \"Loads and returns the content from uploaded files.\"\n documentation: str = \"https://docs.langflow.org/read-file\"\n icon = \"file-text\"\n name = \"File\"\n add_tool_output = True # Enable tool mode toggle without requiring tool_mode inputs\n\n # Extensions that can be processed without Docling (using standard text parsing)\n TEXT_EXTENSIONS = TEXT_FILE_TYPES\n\n # Extensions that require Docling for processing (images, advanced office formats, etc.)\n DOCLING_ONLY_EXTENSIONS = [\n \"adoc\",\n \"asciidoc\",\n \"asc\",\n \"bmp\",\n \"dotx\",\n \"dotm\",\n \"docm\",\n \"jpg\",\n \"jpeg\",\n \"png\",\n \"potx\",\n \"ppsx\",\n \"pptm\",\n \"potm\",\n \"ppsm\",\n \"pptx\",\n \"tiff\",\n \"xls\",\n \"xlsx\",\n \"xhtml\",\n \"webp\",\n ]\n\n # Docling-supported/compatible extensions; TEXT_FILE_TYPES are supported by the base loader.\n VALID_EXTENSIONS = [\n *TEXT_EXTENSIONS,\n *DOCLING_ONLY_EXTENSIONS,\n ]\n\n # Fixed export settings used when markdown export is requested.\n EXPORT_FORMAT = \"Markdown\"\n IMAGE_MODE = \"placeholder\"\n\n _base_inputs = deepcopy(BaseFileComponent.get_base_inputs())\n\n for input_item in _base_inputs:\n if isinstance(input_item, FileInput) and input_item.name == \"path\":\n input_item.real_time_refresh = True\n input_item.tool_mode = False # Disable tool mode for file upload input\n input_item.required = False # Make it optional so it doesn't error in tool mode\n break\n\n inputs = [\n SortableListInput(\n name=\"storage_location\",\n display_name=\"Storage Location\",\n placeholder=\"Select Location\",\n info=\"Choose where to read the file from.\",\n options=_get_storage_location_options(),\n real_time_refresh=True,\n limit=1,\n value=[{\"name\": \"Local\", \"icon\": \"hard-drive\"}],\n advanced=True,\n ),\n *_base_inputs,\n StrInput(\n name=\"file_path_str\",\n display_name=\"File Path\",\n info=(\n \"Path to the file to read. Used when component is called as a tool. \"\n \"If not provided, will use the uploaded file from 'path' input.\"\n ),\n show=False,\n advanced=True,\n tool_mode=True, # Required for Toolset toggle, but _get_tools() ignores this parameter\n required=False,\n ),\n # AWS S3 specific inputs\n SecretStrInput(\n name=\"aws_access_key_id\",\n display_name=\"AWS Access Key ID\",\n info=\"AWS Access key ID.\",\n show=False,\n advanced=False,\n required=True,\n ),\n SecretStrInput(\n name=\"aws_secret_access_key\",\n display_name=\"AWS Secret Key\",\n info=\"AWS Secret Key.\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"bucket_name\",\n display_name=\"S3 Bucket Name\",\n info=\"Enter the name of the S3 bucket.\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"aws_region\",\n display_name=\"AWS Region\",\n info=\"AWS region (e.g., us-east-1, eu-west-1).\",\n show=False,\n advanced=False,\n ),\n StrInput(\n name=\"s3_file_key\",\n display_name=\"S3 File Key\",\n info=\"The key (path) of the file in S3 bucket.\",\n show=False,\n advanced=False,\n required=True,\n ),\n # Google Drive specific inputs\n SecretStrInput(\n name=\"service_account_key\",\n display_name=\"GCP Credentials Secret Key\",\n info=\"Your Google Cloud Platform service account JSON key as a secret string (complete JSON content).\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"file_id\",\n display_name=\"Google Drive File ID\",\n info=(\"The Google Drive file ID to read. The file must be shared with the service account email.\"),\n show=False,\n advanced=False,\n required=True,\n ),\n BoolInput(\n name=\"advanced_mode\",\n display_name=\"Advanced Parser\",\n value=False,\n real_time_refresh=True,\n info=(\n \"Enable advanced document processing and export with Docling for PDFs, images, and office documents. \"\n \"Note that advanced document processing can consume significant resources.\"\n ),\n # Disabled in cloud\n show=not is_astra_cloud_environment(),\n ),\n DropdownInput(\n name=\"pipeline\",\n display_name=\"Pipeline\",\n info=\"Docling pipeline to use\",\n options=[\"standard\", \"vlm\"],\n value=\"standard\",\n advanced=True,\n real_time_refresh=True,\n ),\n DropdownInput(\n name=\"ocr_engine\",\n display_name=\"OCR Engine\",\n info=\"OCR engine to use. Only available when pipeline is set to 'standard'.\",\n options=[\"None\", \"easyocr\"],\n value=\"easyocr\",\n show=False,\n advanced=True,\n ),\n StrInput(\n name=\"md_image_placeholder\",\n display_name=\"Image placeholder\",\n info=\"Specify the image placeholder for markdown exports.\",\n value=\"<!-- image -->\",\n advanced=True,\n show=False,\n ),\n StrInput(\n name=\"md_page_break_placeholder\",\n display_name=\"Page break placeholder\",\n info=\"Add this placeholder between pages in the markdown output.\",\n value=\"\",\n advanced=True,\n show=False,\n ),\n MessageTextInput(\n name=\"doc_key\",\n display_name=\"Doc Key\",\n info=\"The key to use for the DoclingDocument column.\",\n value=\"doc\",\n advanced=True,\n show=False,\n ),\n # Deprecated input retained for backward-compatibility.\n BoolInput(\n name=\"use_multithreading\",\n display_name=\"[Deprecated] Use Multithreading\",\n advanced=True,\n value=True,\n info=\"Set 'Processing Concurrency' greater than 1 to enable multithreading.\",\n ),\n IntInput(\n name=\"concurrency_multithreading\",\n display_name=\"Processing Concurrency\",\n advanced=True,\n info=\"When multiple files are being processed, the number of files to process concurrently.\",\n value=1,\n ),\n BoolInput(\n name=\"markdown\",\n display_name=\"Markdown Export\",\n info=\"Export processed documents to Markdown format. Only available when advanced mode is enabled.\",\n value=False,\n show=False,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Raw Content\", name=\"message\", method=\"load_files_message\", tool_mode=True),\n ]\n\n # ------------------------------ Tool description with file names --------------\n\n def get_tool_description(self) -> str:\n \"\"\"Return a dynamic description that includes the names of uploaded files.\n\n This helps the Agent understand which files are available to read.\n \"\"\"\n base_description = type(self)._base_description # noqa: SLF001\n\n # Get the list of uploaded file paths\n file_paths = getattr(self, \"path\", None)\n if not file_paths:\n return base_description\n\n # Ensure it's a list\n if not isinstance(file_paths, list):\n file_paths = [file_paths]\n\n # Extract just the file names from the paths\n file_names = []\n for fp in file_paths:\n if fp:\n name = Path(fp).name\n file_names.append(name)\n\n if file_names:\n files_str = \", \".join(file_names)\n return f\"{base_description} Available files: {files_str}. Call this tool to read these files.\"\n\n return base_description\n\n @property\n def description(self) -> str:\n \"\"\"Dynamic description property that includes uploaded file names.\"\"\"\n return self.get_tool_description()\n\n async def _get_tools(self) -> list:\n \"\"\"Override to create a tool without parameters.\n\n The Read File component should use the files already uploaded via UI,\n not accept file paths from the Agent (which wouldn't know the internal paths).\n \"\"\"\n from langchain_core.tools import StructuredTool\n from pydantic import BaseModel\n\n # Empty schema - no parameters needed\n class EmptySchema(BaseModel):\n \"\"\"No parameters required - uses pre-uploaded files.\"\"\"\n\n async def read_files_tool() -> str:\n \"\"\"Read the content of uploaded files.\"\"\"\n cancel_event = threading.Event()\n cancel_token = _FILE_TOOL_CANCEL_EVENT.set(cancel_event)\n try:\n if getattr(self, \"advanced_mode\", False):\n # In advanced mode, use the markdown output path so that the\n # tool shares the same Docling processing as the advanced\n # outputs rather than triggering a second subprocess via\n # load_files_message.\n self.markdown = True\n loader = self.load_files_markdown\n else:\n loader = self.load_files_message\n # Both loaders are blocking (file IO plus, in advanced mode, a Docling\n # subprocess). Run them off the event loop so streaming/heartbeats on\n # the same loop keep flowing while the agent waits for this tool. Keep\n # admission bounded until the real worker exits so cancelled standard\n # parsers cannot build an unbounded default-executor backlog.\n load_limiter = _get_file_tool_limiter()\n await load_limiter.acquire()\n try:\n loader_task = asyncio.create_task(asyncio.to_thread(loader))\n except BaseException:\n load_limiter.release()\n raise\n loader_task.add_done_callback(lambda _task: load_limiter.release())\n try:\n result = await asyncio.shield(loader_task)\n except asyncio.CancelledError:\n cancel_event.set()\n # Give cooperative cleanup time to kill/reap Docling and remove\n # temporary files before releasing this tool invocation.\n try:\n await asyncio.wait_for(\n asyncio.shield(loader_task),\n timeout=_FILE_TOOL_CANCEL_WAIT_SECONDS,\n )\n except _FileToolCancelledError:\n pass\n except asyncio.TimeoutError:\n pass\n except Exception: # noqa: BLE001 - cancellation stays dominant over loader cleanup\n logger.exception(\"File loader failed while cleaning up a cancelled tool call\")\n finally:\n if not loader_task.done():\n loader_task.add_done_callback(_log_abandoned_file_tool_result)\n raise\n if hasattr(result, \"get_text\"):\n return result.get_text()\n if hasattr(result, \"text\"):\n return result.text\n return str(result)\n except (FileNotFoundError, ValueError, OSError, RuntimeError) as e:\n return f\"Error reading files: {e}\"\n finally:\n _FILE_TOOL_CANCEL_EVENT.reset(cancel_token)\n\n description = self.get_tool_description()\n\n tool = StructuredTool(\n name=\"load_files_message\",\n description=description,\n coroutine=read_files_tool,\n args_schema=EmptySchema,\n handle_tool_error=True,\n tags=[\"load_files_message\"],\n metadata={\n \"display_name\": \"Read File\",\n \"display_description\": description,\n },\n )\n\n return [tool]\n\n # ------------------------------ UI helpers --------------------------------------\n\n def _path_value(self, template: dict) -> list[str]:\n \"\"\"Return the list of currently selected file paths from the template.\"\"\"\n return template.get(\"path\", {}).get(\"file_path\", [])\n\n def _disable_docling_fields_in_cloud(self, build_config: dict[str, Any]) -> None:\n \"\"\"Disable all Docling-related fields in cloud environments.\"\"\"\n if \"advanced_mode\" in build_config:\n build_config[\"advanced_mode\"][\"show\"] = False\n build_config[\"advanced_mode\"][\"value\"] = False\n # Hide all Docling-related fields\n docling_fields = (\"pipeline\", \"ocr_engine\", \"doc_key\", \"md_image_placeholder\", \"md_page_break_placeholder\")\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = False\n # Also disable OCR engine specifically\n if \"ocr_engine\" in build_config:\n build_config[\"ocr_engine\"][\"value\"] = \"None\"\n\n def update_build_config(\n self,\n build_config: dict[str, Any],\n field_value: Any,\n field_name: str | None = None,\n ) -> dict[str, Any]:\n \"\"\"Show/hide Advanced Parser and related fields based on selection context.\"\"\"\n # Update storage location options dynamically based on cloud environment\n if \"storage_location\" in build_config:\n updated_options = _get_storage_location_options()\n build_config[\"storage_location\"][\"options\"] = updated_options\n\n # Handle storage location selection\n if field_name == \"storage_location\":\n # Extract selected storage location\n selected = [location[\"name\"] for location in field_value] if isinstance(field_value, list) else []\n\n # Hide all storage-specific fields first\n storage_fields = [\n \"aws_access_key_id\",\n \"aws_secret_access_key\",\n \"bucket_name\",\n \"aws_region\",\n \"s3_file_key\",\n \"service_account_key\",\n \"file_id\",\n ]\n\n for f_name in storage_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = False\n\n # Show fields based on selected storage location\n if len(selected) == 1:\n location = selected[0]\n\n if location == \"Local\":\n # Show file upload input for local storage\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = True\n\n elif location == \"AWS\":\n # Hide file upload input, show AWS fields\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = False\n\n aws_fields = [\n \"aws_access_key_id\",\n \"aws_secret_access_key\",\n \"bucket_name\",\n \"aws_region\",\n \"s3_file_key\",\n ]\n for f_name in aws_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = True\n build_config[f_name][\"advanced\"] = False\n\n elif location == \"Google Drive\":\n # Hide file upload input, show Google Drive fields\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = False\n\n gdrive_fields = [\"service_account_key\", \"file_id\"]\n for f_name in gdrive_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = True\n build_config[f_name][\"advanced\"] = False\n # No storage location selected - show file upload by default\n elif \"path\" in build_config:\n build_config[\"path\"][\"show\"] = True\n\n return build_config\n\n if field_name == \"path\":\n paths = self._path_value(build_config)\n\n # Disable in cloud environments\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n else:\n # If all files can be processed by docling, do so\n allow_advanced = all(not file_path.endswith((\".csv\", \".xlsx\", \".parquet\")) for file_path in paths)\n build_config[\"advanced_mode\"][\"show\"] = allow_advanced\n if not allow_advanced:\n build_config[\"advanced_mode\"][\"value\"] = False\n docling_fields = (\n \"pipeline\",\n \"ocr_engine\",\n \"doc_key\",\n \"md_image_placeholder\",\n \"md_page_break_placeholder\",\n )\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = False\n\n # Docling Processing\n elif field_name == \"advanced_mode\":\n # Disable in cloud environments - don't show Docling fields even if advanced_mode is toggled\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n else:\n docling_fields = (\n \"pipeline\",\n \"ocr_engine\",\n \"doc_key\",\n \"md_image_placeholder\",\n \"md_page_break_placeholder\",\n )\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = bool(field_value)\n if field == \"pipeline\":\n build_config[field][\"advanced\"] = not bool(field_value)\n\n elif field_name == \"pipeline\":\n # Disable in cloud environments - don't show OCR engine even if pipeline is changed\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n elif field_value == \"standard\":\n build_config[\"ocr_engine\"][\"show\"] = True\n build_config[\"ocr_engine\"][\"value\"] = \"easyocr\"\n else:\n build_config[\"ocr_engine\"][\"show\"] = False\n build_config[\"ocr_engine\"][\"value\"] = \"None\"\n\n return build_config\n\n def update_outputs(self, frontend_node: dict[str, Any], field_name: str, field_value: Any) -> dict[str, Any]: # noqa: ARG002\n \"\"\"Dynamically show outputs based on file count/type and advanced mode.\"\"\"\n if field_name not in [\"path\", \"advanced_mode\", \"pipeline\"]:\n return frontend_node\n\n template = frontend_node.get(\"template\", {})\n paths = self._path_value(template)\n if not paths:\n return frontend_node\n\n frontend_node[\"outputs\"] = []\n if len(paths) == 1:\n file_path = paths[0] if field_name == \"path\" else frontend_node[\"template\"][\"path\"][\"file_path\"][0]\n if file_path.endswith((\".csv\", \".xlsx\", \".parquet\")):\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Structured Content\",\n name=\"dataframe\",\n method=\"load_files_structured\",\n tool_mode=True,\n ),\n )\n elif file_path.endswith(\".json\"):\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Structured Content\", name=\"json\", method=\"load_files_json\", tool_mode=True),\n )\n\n advanced_mode = frontend_node.get(\"template\", {}).get(\"advanced_mode\", {}).get(\"value\", False)\n if advanced_mode:\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Structured Output\",\n name=\"advanced_dataframe\",\n method=\"load_files_dataframe\",\n tool_mode=True,\n ),\n )\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Markdown\", name=\"advanced_markdown\", method=\"load_files_markdown\", tool_mode=True\n ),\n )\n frontend_node[\"outputs\"].append(\n Output(display_name=\"File Path\", name=\"path\", method=\"load_files_path\", tool_mode=True),\n )\n else:\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Raw Content\", name=\"message\", method=\"load_files_message\", tool_mode=True),\n )\n frontend_node[\"outputs\"].append(\n Output(display_name=\"File Path\", name=\"path\", method=\"load_files_path\", tool_mode=True),\n )\n else:\n # Multiple files => DataFrame output; advanced parser disabled\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Files\", name=\"dataframe\", method=\"load_files\", tool_mode=True)\n )\n\n return frontend_node\n\n # ------------------------------ Core processing ----------------------------------\n\n def _get_selected_storage_location(self) -> str:\n \"\"\"Get the selected storage location from the SortableListInput.\"\"\"\n if hasattr(self, \"storage_location\") and self.storage_location:\n if isinstance(self.storage_location, list) and len(self.storage_location) > 0:\n return self.storage_location[0].get(\"name\", \"\")\n if isinstance(self.storage_location, dict):\n return self.storage_location.get(\"name\", \"\")\n return \"Local\" # Default to Local if not specified\n\n def _validate_and_resolve_paths(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Override to handle file_path_str input from tool mode and cloud storage.\n\n Priority:\n 1. Cloud storage (AWS/Google Drive) if selected\n 2. file_path_str (if provided by the tool call)\n 3. path (uploaded file from UI)\n \"\"\"\n storage_location = self._get_selected_storage_location()\n\n # Handle AWS S3\n if storage_location == \"AWS\":\n return self._read_from_aws_s3()\n\n # Handle Google Drive\n if storage_location == \"Google Drive\":\n return self._read_from_google_drive()\n\n # Handle Local storage\n # Check if file_path_str is provided (from tool mode)\n file_path_str = getattr(self, \"file_path_str\", None)\n if file_path_str:\n # Use the string path from tool mode\n from pathlib import Path\n\n from lfx.schema.data import Data\n\n # Use same resolution logic as BaseFileComponent (support storage paths)\n path_str = str(file_path_str)\n if parse_storage_path(path_str):\n try:\n resolved_path = Path(self.get_full_path(path_str))\n except (ValueError, AttributeError):\n resolved_path = Path(self.resolve_path(path_str))\n else:\n resolved_path = Path(self.resolve_path(path_str))\n\n # Security: confine tool-mode reads to the storage dir in restricted (multi-tenant)\n # mode so a tenant cannot read arbitrary server files via file_path_str.\n from lfx.utils.file_path_security import component_file_access_scopes, enforce_local_file_access\n\n resolved_path = enforce_local_file_access(resolved_path, scope_ids=component_file_access_scopes(self))\n\n if not resolved_path.exists():\n msg = f\"File or directory not found: {file_path_str}\"\n self.log(msg)\n if not self.silent_errors:\n raise ValueError(msg)\n return []\n\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(resolved_path)})\n return [BaseFileComponent.BaseFile(data_obj, resolved_path, delete_after_processing=False)]\n\n # Otherwise use the default implementation (uses path FileInput)\n return super()._validate_and_resolve_paths()\n\n def _read_from_aws_s3(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Read file from AWS S3.\"\"\"\n from lfx.base.data.cloud_storage_utils import create_s3_client, validate_aws_credentials\n\n # Validate AWS credentials\n validate_aws_credentials(self)\n if not getattr(self, \"s3_file_key\", None):\n msg = \"S3 File Key is required\"\n raise ValueError(msg)\n\n # Create S3 client\n s3_client = create_s3_client(self)\n\n # Download file to temp location\n import tempfile\n\n # Get file extension from S3 key\n file_extension = Path(self.s3_file_key).suffix or \"\"\n\n with tempfile.NamedTemporaryFile(mode=\"wb\", suffix=file_extension, delete=False) as temp_file:\n temp_file_path = temp_file.name\n try:\n s3_client.download_fileobj(self.bucket_name, self.s3_file_key, temp_file)\n except Exception as e:\n # Clean up temp file on failure\n with contextlib.suppress(OSError):\n Path(temp_file_path).unlink()\n msg = f\"Failed to download file from S3: {e}\"\n raise RuntimeError(msg) from e\n\n # Create BaseFile object\n from lfx.schema.data import Data\n\n temp_path = Path(temp_file_path)\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(temp_path)})\n return [BaseFileComponent.BaseFile(data_obj, temp_path, delete_after_processing=True)]\n\n def _read_from_google_drive(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Read file from Google Drive.\"\"\"\n import tempfile\n\n from googleapiclient.http import MediaIoBaseDownload\n\n from lfx.base.data.cloud_storage_utils import create_google_drive_service\n\n # Validate Google Drive credentials\n if not getattr(self, \"service_account_key\", None):\n msg = \"GCP Credentials Secret Key is required for Google Drive storage\"\n raise ValueError(msg)\n if not getattr(self, \"file_id\", None):\n msg = \"Google Drive File ID is required\"\n raise ValueError(msg)\n\n # Create Google Drive service with read-only scope\n drive_service = create_google_drive_service(\n self.service_account_key, scopes=[\"https://www.googleapis.com/auth/drive.readonly\"]\n )\n\n # Get file metadata to determine file name and extension\n try:\n file_metadata = drive_service.files().get(fileId=self.file_id, fields=\"name,mimeType\").execute()\n file_name = file_metadata.get(\"name\", \"download\")\n except Exception as e:\n msg = (\n f\"Unable to access file with ID '{self.file_id}'. \"\n f\"Error: {e!s}. \"\n \"Please ensure: 1) The file ID is correct, 2) The file exists, \"\n \"3) The service account has been granted access to this file.\"\n )\n raise ValueError(msg) from e\n\n # Download file to temp location\n file_extension = Path(file_name).suffix or \"\"\n with tempfile.NamedTemporaryFile(mode=\"wb\", suffix=file_extension, delete=False) as temp_file:\n temp_file_path = temp_file.name\n try:\n request = drive_service.files().get_media(fileId=self.file_id)\n downloader = MediaIoBaseDownload(temp_file, request)\n done = False\n while not done:\n _status, done = downloader.next_chunk()\n except Exception as e:\n # Clean up temp file on failure\n with contextlib.suppress(OSError):\n Path(temp_file_path).unlink()\n msg = f\"Failed to download file from Google Drive: {e}\"\n raise RuntimeError(msg) from e\n\n # Create BaseFile object\n from lfx.schema.data import Data\n\n temp_path = Path(temp_file_path)\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(temp_path)})\n return [BaseFileComponent.BaseFile(data_obj, temp_path, delete_after_processing=True)]\n\n def _is_docling_compatible(self, file_path: str) -> bool:\n \"\"\"Lightweight extension gate for Docling-compatible types.\"\"\"\n docling_exts = (\n \".adoc\",\n \".asciidoc\",\n \".asc\",\n \".bmp\",\n \".csv\",\n \".dotx\",\n \".dotm\",\n \".docm\",\n \".docx\",\n \".htm\",\n \".html\",\n \".jpg\",\n \".jpeg\",\n \".json\",\n \".md\",\n \".pdf\",\n \".png\",\n \".potx\",\n \".ppsx\",\n \".pptm\",\n \".potm\",\n \".ppsm\",\n \".pptx\",\n \".tiff\",\n \".txt\",\n \".xls\",\n \".xlsx\",\n \".xhtml\",\n \".xml\",\n \".webp\",\n )\n return file_path.lower().endswith(docling_exts)\n\n async def _get_local_file_for_docling(self, file_path: str) -> tuple[str, bool]:\n \"\"\"Get a local file path for Docling processing, downloading from S3 if needed.\n\n Args:\n file_path: Either a local path or S3 key (format \"flow_id/filename\")\n\n Returns:\n tuple[str, bool]: (local_path, should_delete) where should_delete indicates\n if this is a temporary file that should be cleaned up\n \"\"\"\n settings = get_settings_service().settings\n if settings.storage_type == \"local\":\n return file_path, False\n\n # S3 storage - download to temp file\n parsed = parse_storage_path(file_path)\n if not parsed:\n msg = f\"Invalid S3 path format: {file_path}. Expected 'flow_id/filename'\"\n raise ValueError(msg)\n\n storage_service = get_storage_service()\n flow_id, filename = parsed\n\n # Get file content from S3\n content = await storage_service.get_file(flow_id, filename)\n\n suffix = Path(filename).suffix\n with NamedTemporaryFile(mode=\"wb\", suffix=suffix, delete=False) as tmp_file:\n tmp_file.write(content)\n temp_path = tmp_file.name\n\n return temp_path, True\n\n def _process_docling_in_subprocess(self, file_path: str) -> Data | None:\n \"\"\"Run Docling in a separate OS process and map the result to a Data object.\n\n We avoid multiprocessing pickling by launching `python -c \"<script>\"` and\n passing JSON config via stdin. The child prints a JSON result to stdout.\n\n For S3 storage, the file is downloaded to a temp file first.\n \"\"\"\n if not file_path:\n return None\n\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n _raise_if_file_tool_cancelled(cancel_event)\n settings = get_settings_service().settings\n if settings.storage_type == \"s3\":\n local_path, should_delete = run_until_complete(self._get_local_file_for_docling(file_path))\n else:\n local_path = file_path\n should_delete = False\n\n try:\n _raise_if_file_tool_cancelled(cancel_event)\n return self._process_docling_subprocess_impl(local_path, file_path)\n finally:\n # Clean up temp file if we created one\n if should_delete:\n with contextlib.suppress(Exception):\n Path(local_path).unlink() # Ignore cleanup errors\n\n def _process_docling_subprocess_impl(self, local_file_path: str, original_file_path: str) -> Data | None:\n \"\"\"Implementation of Docling subprocess processing.\n\n Args:\n local_file_path: Path to local file to process\n original_file_path: Original file path to include in metadata\n Returns:\n Data object with processed content\n \"\"\"\n args: dict[str, Any] = {\n \"file_path\": local_file_path,\n \"markdown\": bool(self.markdown),\n \"image_mode\": str(self.IMAGE_MODE),\n \"md_image_placeholder\": str(self.md_image_placeholder),\n \"md_page_break_placeholder\": str(self.md_page_break_placeholder),\n \"pipeline\": str(self.pipeline),\n \"ocr_engine\": (\n self.ocr_engine if self.ocr_engine and self.ocr_engine != \"None\" and self.pipeline != \"vlm\" else None\n ),\n }\n\n # Child script for isolating the docling processing\n child_script = textwrap.dedent(\n r\"\"\"\n import json, sys\n\n def try_imports():\n try:\n from docling.datamodel.base_models import ConversionStatus, InputFormat # type: ignore\n from docling.document_converter import DocumentConverter # type: ignore\n from docling_core.types.doc import ImageRefMode # type: ignore\n return ConversionStatus, InputFormat, DocumentConverter, ImageRefMode, \"latest\"\n except Exception as e:\n raise e\n\n def create_converter(strategy, input_format, DocumentConverter, pipeline, ocr_engine):\n # --- Standard PDF/IMAGE pipeline (your existing behavior), with optional OCR ---\n if pipeline == \"standard\":\n try:\n from docling.datamodel.pipeline_options import PdfPipelineOptions # type: ignore\n from docling.document_converter import PdfFormatOption # type: ignore\n\n pipe = PdfPipelineOptions()\n pipe.do_ocr = False\n\n if ocr_engine:\n try:\n from docling.models.factories import get_ocr_factory # type: ignore\n pipe.do_ocr = True\n fac = get_ocr_factory(allow_external_plugins=False)\n pipe.ocr_options = fac.create_options(kind=ocr_engine)\n except Exception:\n # If OCR setup fails, disable it\n pipe.do_ocr = False\n\n fmt = {}\n if hasattr(input_format, \"PDF\"):\n fmt[getattr(input_format, \"PDF\")] = PdfFormatOption(pipeline_options=pipe)\n if hasattr(input_format, \"IMAGE\"):\n fmt[getattr(input_format, \"IMAGE\")] = PdfFormatOption(pipeline_options=pipe)\n\n return DocumentConverter(format_options=fmt)\n except Exception:\n return DocumentConverter()\n\n # --- Vision-Language Model (VLM) pipeline ---\n if pipeline == \"vlm\":\n try:\n from docling.datamodel.pipeline_options import VlmPipelineOptions\n from docling.datamodel.vlm_model_specs import GRANITEDOCLING_MLX, GRANITEDOCLING_TRANSFORMERS\n from docling.document_converter import PdfFormatOption\n from docling.pipeline.vlm_pipeline import VlmPipeline\n\n vl_pipe = VlmPipelineOptions(\n vlm_options=GRANITEDOCLING_TRANSFORMERS,\n )\n\n if sys.platform == \"darwin\":\n try:\n import mlx_vlm\n vl_pipe.vlm_options = GRANITEDOCLING_MLX\n except ImportError as e:\n raise e\n\n # VLM paths generally don't need OCR; keep OCR off by default here.\n fmt = {}\n if hasattr(input_format, \"PDF\"):\n fmt[getattr(input_format, \"PDF\")] = PdfFormatOption(\n pipeline_cls=VlmPipeline,\n pipeline_options=vl_pipe\n )\n if hasattr(input_format, \"IMAGE\"):\n fmt[getattr(input_format, \"IMAGE\")] = PdfFormatOption(\n pipeline_cls=VlmPipeline,\n pipeline_options=vl_pipe\n )\n\n return DocumentConverter(format_options=fmt)\n except Exception as e:\n raise e\n\n # --- Fallback: default converter with no special options ---\n return DocumentConverter()\n\n def export_markdown(document, ImageRefMode, image_mode, img_ph, pg_ph):\n try:\n mode = getattr(ImageRefMode, image_mode.upper(), image_mode)\n return document.export_to_markdown(\n image_mode=mode,\n image_placeholder=img_ph,\n page_break_placeholder=pg_ph,\n )\n except Exception:\n try:\n return document.export_to_text()\n except Exception:\n return str(document)\n\n def to_rows(doc_dict):\n rows = []\n for t in doc_dict.get(\"texts\", []):\n prov = t.get(\"prov\") or []\n page_no = None\n if prov and isinstance(prov, list) and isinstance(prov[0], dict):\n page_no = prov[0].get(\"page_no\")\n rows.append({\n \"page_no\": page_no,\n \"label\": t.get(\"label\"),\n \"text\": t.get(\"text\"),\n \"level\": t.get(\"level\"),\n })\n return rows\n\n def main():\n cfg = json.loads(sys.stdin.read())\n file_path = cfg[\"file_path\"]\n markdown = cfg[\"markdown\"]\n image_mode = cfg[\"image_mode\"]\n img_ph = cfg[\"md_image_placeholder\"]\n pg_ph = cfg[\"md_page_break_placeholder\"]\n pipeline = cfg[\"pipeline\"]\n ocr_engine = cfg.get(\"ocr_engine\")\n meta = {\"file_path\": file_path}\n\n try:\n ConversionStatus, InputFormat, DocumentConverter, ImageRefMode, strategy = try_imports()\n converter = create_converter(strategy, InputFormat, DocumentConverter, pipeline, ocr_engine)\n try:\n res = converter.convert(file_path)\n except Exception as e:\n print(json.dumps({\"ok\": False, \"error\": f\"Docling conversion error: {e}\", \"meta\": meta}))\n return\n\n ok = False\n if hasattr(res, \"status\"):\n try:\n ok = (res.status == ConversionStatus.SUCCESS) or (str(res.status).lower() == \"success\")\n except Exception:\n ok = (str(res.status).lower() == \"success\")\n if not ok and hasattr(res, \"document\"):\n ok = getattr(res, \"document\", None) is not None\n if not ok:\n print(json.dumps({\"ok\": False, \"error\": \"Docling conversion failed\", \"meta\": meta}))\n return\n\n doc = getattr(res, \"document\", None)\n if doc is None:\n print(json.dumps({\"ok\": False, \"error\": \"Docling produced no document\", \"meta\": meta}))\n return\n\n # Extract DoclingDocument metadata\n if hasattr(doc, \"name\") and doc.name:\n meta[\"name\"] = doc.name\n if hasattr(doc, \"origin\") and doc.origin is not None:\n origin = doc.origin\n if hasattr(origin, \"filename\") and origin.filename:\n meta[\"filename\"] = origin.filename\n if hasattr(origin, \"binary_hash\") and origin.binary_hash:\n meta[\"document_id\"] = str(origin.binary_hash)\n if hasattr(origin, \"mimetype\") and origin.mimetype:\n meta[\"mimetype\"] = origin.mimetype\n\n if markdown:\n text = export_markdown(doc, ImageRefMode, image_mode, img_ph, pg_ph)\n print(json.dumps({\"ok\": True, \"mode\": \"markdown\", \"text\": text, \"meta\": meta}))\n return\n\n # structured\n try:\n doc_dict = doc.export_to_dict()\n except Exception as e:\n print(json.dumps({\"ok\": False, \"error\": f\"Docling export_to_dict failed: {e}\", \"meta\": meta}))\n return\n\n rows = to_rows(doc_dict)\n print(json.dumps({\"ok\": True, \"mode\": \"structured\", \"doc\": rows, \"meta\": meta}))\n except Exception as e:\n print(\n json.dumps({\n \"ok\": False,\n \"error\": f\"Docling processing error: {e}\",\n \"meta\": {\"file_path\": file_path},\n })\n )\n\n if __name__ == \"__main__\":\n main()\n \"\"\"\n )\n\n # The path is passed as JSON over stdin to an argument-list subprocess, so shell\n # metacharacters are ordinary filename characters here.\n if not isinstance(args[\"file_path\"], str):\n return Data(data={\"error\": \"Unsafe file path detected.\", \"file_path\": args[\"file_path\"]})\n\n # Use communicate() in bounded intervals so stdout/stderr are drained while the\n # child runs without losing the heartbeat that keeps the SSE event stream alive.\n docling_timeout = 600 # 10 minutes; large PDFs with OCR may need this\n poll_interval = 5 # seconds between progress heartbeats\n\n proc = subprocess.Popen( # noqa: S603\n [sys.executable, \"-u\", \"-c\", child_script],\n stdin=subprocess.PIPE,\n stdout=subprocess.PIPE,\n stderr=subprocess.PIPE,\n )\n\n start = time.monotonic()\n input_bytes: bytes | None = json.dumps(args).encode(\"utf-8\")\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n try:\n while True:\n _raise_if_file_tool_cancelled(cancel_event)\n elapsed = time.monotonic() - start\n if elapsed >= docling_timeout:\n proc.kill()\n proc.communicate()\n return Data(\n data={\n \"error\": (\n f\"Docling processing timed out after {docling_timeout}s. \"\n \"Consider using the standalone Docling component for large documents.\"\n ),\n \"file_path\": original_file_path,\n },\n )\n try:\n stdout_bytes, stderr_bytes = proc.communicate(\n input=input_bytes,\n timeout=min(poll_interval, docling_timeout - elapsed),\n )\n _raise_if_file_tool_cancelled(cancel_event)\n break\n except subprocess.TimeoutExpired:\n # communicate() retains partially written input and collected output across\n # retries, so subsequent calls continue draining without resending stdin.\n input_bytes = None\n elapsed = time.monotonic() - start\n self.log(f\"Docling processing in progress ({int(elapsed)}s elapsed)...\")\n finally:\n if (cancel_event is not None and cancel_event.is_set()) or proc.poll() is None:\n _kill_and_reap_process(proc)\n\n if not stdout_bytes:\n err_msg = stderr_bytes.decode(\"utf-8\", errors=\"replace\") if stderr_bytes else \"no output from child process\"\n return Data(data={\"error\": f\"Docling subprocess error: {err_msg}\", \"file_path\": original_file_path})\n\n try:\n result = json.loads(stdout_bytes.decode(\"utf-8\"))\n except Exception as e: # noqa: BLE001\n err_msg = stderr_bytes.decode(\"utf-8\", errors=\"replace\")\n return Data(\n data={\n \"error\": f\"Invalid JSON from Docling subprocess: {e}. stderr={err_msg}\",\n \"file_path\": original_file_path,\n },\n )\n\n if not result.get(\"ok\"):\n error_msg = result.get(\"error\", \"Unknown Docling error\")\n # Override meta file_path with original_file_path to ensure correct path matching\n meta = result.get(\"meta\", {})\n meta[\"file_path\"] = original_file_path\n return Data(data={\"error\": error_msg, **meta})\n\n meta = result.get(\"meta\", {})\n # Override meta file_path with original_file_path to ensure correct path matching\n # The subprocess returns the temp file path, but we need the original S3/local path for rollup_data\n meta[\"file_path\"] = original_file_path\n if result.get(\"mode\") == \"markdown\":\n exported_content = str(result.get(\"text\", \"\"))\n return Data(\n text=exported_content,\n data={\"exported_content\": exported_content, \"export_format\": self.EXPORT_FORMAT, **meta},\n )\n\n rows = list(result.get(\"doc\", []))\n return Data(data={\"doc\": rows, \"export_format\": self.EXPORT_FORMAT, **meta})\n\n def process_files(\n self,\n file_list: list[BaseFileComponent.BaseFile],\n ) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Process input files.\n\n - advanced_mode => Docling in a separate process.\n - Otherwise => standard parsing in current process (optionally threaded).\n \"\"\"\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n _raise_if_file_tool_cancelled(cancel_event)\n if not file_list:\n msg = \"No files to process.\"\n raise ValueError(msg)\n\n # Validate image files to detect content/extension mismatches\n # This prevents API errors like \"Image does not match the provided media type\"\n image_extensions = {\"jpeg\", \"jpg\", \"png\", \"gif\", \"webp\", \"bmp\", \"tiff\"}\n settings = get_settings_service().settings\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n extension = file.path.suffix[1:].lower()\n if extension in image_extensions:\n # Read bytes based on storage type\n try:\n if settings.storage_type == \"s3\":\n # For S3 storage, use storage service to read file bytes\n file_path_str = str(file.path)\n content = run_until_complete(read_file_bytes(file_path_str))\n else:\n # For local storage, read bytes directly from filesystem\n content = file.path.read_bytes()\n\n is_valid, error_msg = validate_image_content_type(\n str(file.path),\n content=content,\n )\n if not is_valid:\n self.log(error_msg)\n if not self.silent_errors:\n raise ValueError(error_msg)\n except (OSError, FileNotFoundError) as e:\n self.log(f\"Could not read file for validation: {e}\")\n # Continue - let it fail later with better error\n\n # Validate that files requiring Docling are only processed when advanced mode is enabled\n if not self.advanced_mode:\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n extension = file.path.suffix[1:].lower()\n if extension in self.DOCLING_ONLY_EXTENSIONS:\n if is_astra_cloud_environment():\n msg = (\n f\"File '{file.path.name}' has extension '.{extension}' which requires \"\n f\"Advanced Parser mode. Advanced Parser is not available in cloud environments.\"\n )\n else:\n msg = (\n f\"File '{file.path.name}' has extension '.{extension}' which requires \"\n f\"Advanced Parser mode. Please enable 'Advanced Parser' to process this file.\"\n )\n self.log(msg)\n raise ValueError(msg)\n\n def process_file_standard(file_path: str, *, silent_errors: bool = False) -> Data | None:\n try:\n _raise_if_file_tool_cancelled(cancel_event)\n result = parse_text_file_to_data(file_path, silent_errors=silent_errors)\n _raise_if_file_tool_cancelled(cancel_event)\n except _FileToolCancelledError:\n raise\n except FileNotFoundError as e:\n self.log(f\"File not found: {file_path}. Error: {e}\")\n if not silent_errors:\n raise\n return None\n except Exception as e:\n self.log(f\"Unexpected error processing {file_path}: {e}\")\n if not silent_errors:\n raise\n return None\n else:\n return result\n\n docling_compatible = all(self._is_docling_compatible(str(f.path)) for f in file_list)\n\n # Advanced path: Check if ALL files are compatible with Docling\n if self.advanced_mode and docling_compatible:\n final_return: list[BaseFileComponent.BaseFile] = []\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n file_path = str(file.path)\n advanced_data: Data | None = self._process_docling_in_subprocess(file_path)\n _raise_if_file_tool_cancelled(cancel_event)\n\n # Handle None case - Docling processing failed or returned None\n if advanced_data is None:\n error_data = Data(\n data={\n \"file_path\": file_path,\n \"error\": \"Docling processing returned no result. Check logs for details.\",\n },\n )\n final_return.extend(self.rollup_data([file], [error_data]))\n continue\n\n # --- UNNEST: expand each element in `doc` to its own Data row\n payload = getattr(advanced_data, \"data\", {}) or {}\n\n # Check for errors first\n if \"error\" in payload:\n error_msg = payload.get(\"error\", \"Unknown error\")\n error_data = Data(\n data={\n \"file_path\": file_path,\n \"error\": error_msg,\n **{k: v for k, v in payload.items() if k not in (\"error\", \"file_path\")},\n },\n )\n final_return.extend(self.rollup_data([file], [error_data]))\n continue\n\n doc_rows = payload.get(\"doc\")\n if isinstance(doc_rows, list) and doc_rows:\n # Non-empty list of structured rows\n rows: list[Data | None] = [\n Data(\n data={\n \"file_path\": file_path,\n **(item if isinstance(item, dict) else {\"value\": item}),\n },\n )\n for item in doc_rows\n ]\n final_return.extend(self.rollup_data([file], rows))\n elif isinstance(doc_rows, list) and not doc_rows:\n # Empty list - file was processed but no text content found\n # Create a Data object indicating no content was extracted\n self.log(f\"No text extracted from '{file_path}', creating placeholder data\")\n empty_data = Data(\n data={\n \"file_path\": file_path,\n \"text\": \"(No text content extracted from image)\",\n \"info\": \"Image processed successfully but contained no extractable text\",\n **{k: v for k, v in payload.items() if k != \"doc\"},\n },\n )\n final_return.extend(self.rollup_data([file], [empty_data]))\n else:\n # If not structured, keep as-is (e.g., markdown export or error dict)\n # Ensure file_path is set for proper rollup matching\n if not payload.get(\"file_path\"):\n payload[\"file_path\"] = file_path\n # Create new Data with file_path\n advanced_data = Data(\n data=payload,\n text=getattr(advanced_data, \"text\", None),\n )\n final_return.extend(self.rollup_data([file], [advanced_data]))\n return final_return\n\n # Standard multi-file (or single non-advanced) path\n concurrency = max(1, self.concurrency_multithreading)\n\n file_paths = [str(f.path) for f in file_list]\n self.log(f\"Starting parallel processing of {len(file_paths)} files with concurrency: {concurrency}.\")\n my_data = parallel_load_data(\n file_paths,\n silent_errors=self.silent_errors,\n load_function=process_file_standard,\n max_concurrency=concurrency,\n )\n _raise_if_file_tool_cancelled(cancel_event)\n return self.rollup_data(file_list, my_data)\n\n # ------------------------------ Output helpers -----------------------------------\n\n def load_files_helper(self) -> DataFrame:\n result = self.load_files()\n\n # Result is a DataFrame - check if it has any rows\n if result.empty:\n msg = \"Could not extract content from the provided file(s).\"\n raise ValueError(msg)\n\n # Check for error column with error messages\n if \"error\" in result.columns:\n errors = result[\"error\"].dropna().tolist()\n if errors and not any(col in result.columns for col in [\"text\", \"doc\", \"exported_content\"]):\n raise ValueError(errors[0])\n\n return result\n\n def load_files_dataframe(self) -> DataFrame:\n \"\"\"Load files using advanced Docling processing and export to DataFrame format.\"\"\"\n self.markdown = False\n return self.load_files_helper()\n\n def load_files_markdown(self) -> Message:\n \"\"\"Load files using advanced Docling processing and export to Markdown format.\"\"\"\n self.markdown = True\n result = self.load_files_helper()\n\n # Result is a DataFrame - check for text or exported_content columns\n if \"text\" in result.columns and not result[\"text\"].isna().all():\n text_values = result[\"text\"].dropna().tolist()\n if text_values:\n return Message(text=str(text_values[0]))\n\n if \"exported_content\" in result.columns and not result[\"exported_content\"].isna().all():\n content_values = result[\"exported_content\"].dropna().tolist()\n if content_values:\n return Message(text=str(content_values[0]))\n\n # Return empty message with info that no text was found\n return Message(text=\"(No text content extracted from file)\")\n"
"value": "\"\"\"Enhanced file component with Docling support and process isolation.\n\nNotes:\n-----\n- ALL Docling parsing/export runs in a separate OS process to prevent memory\n growth and native library state from impacting the main Langflow process.\n- Standard text/structured parsing continues to use existing BaseFileComponent\n utilities (and optional threading via `parallel_load_data`).\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport json\nimport subprocess\nimport sys\nimport textwrap\nimport threading\nimport time\nfrom contextvars import ContextVar\nfrom copy import deepcopy\nfrom pathlib import Path\nfrom tempfile import NamedTemporaryFile\nfrom typing import Any\n\nfrom lfx.base.data.base_file import BaseFileComponent\nfrom lfx.base.data.storage_utils import (\n is_remote_storage_type,\n parse_storage_path,\n read_file_bytes,\n validate_image_content_type,\n)\nfrom lfx.base.data.utils import TEXT_FILE_TYPES, parallel_load_data, parse_text_file_to_data\nfrom lfx.inputs import SortableListInput\nfrom lfx.inputs.inputs import DropdownInput, MessageTextInput, StrInput\nfrom lfx.io import BoolInput, FileInput, IntInput, Output, SecretStrInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame # noqa: TC001\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service, get_storage_service\nfrom lfx.utils.async_helpers import run_until_complete\nfrom lfx.utils.validate_cloud import is_astra_cloud_environment\n\n_FILE_TOOL_CANCEL_EVENT: ContextVar[threading.Event | None] = ContextVar(\"file_tool_cancel_event\", default=None)\n_FILE_TOOL_CANCEL_WAIT_SECONDS = 6\n_FILE_TOOL_MAX_CONCURRENT_LOADS = 4\n_FILE_TOOL_LIMITER_ATTRIBUTE = \"_lfx_file_tool_load_limiter\"\n_FILE_TOOL_PROCESS_REAP_SECONDS = 2\n\n\nclass _FileToolCancelledError(RuntimeError):\n \"\"\"Stop synchronous file loading after its async tool call is cancelled.\"\"\"\n\n\ndef _raise_if_file_tool_cancelled(cancel_event: threading.Event | None = None) -> None:\n event = cancel_event or _FILE_TOOL_CANCEL_EVENT.get()\n if event is not None and event.is_set():\n raise _FileToolCancelledError\n\n\ndef _kill_and_reap_process(proc: subprocess.Popen) -> None:\n try:\n proc.kill()\n except ProcessLookupError:\n pass\n except OSError:\n logger.exception(\"Failed to kill cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while killing cancelled Docling subprocess\")\n\n try:\n proc.communicate(timeout=_FILE_TOOL_PROCESS_REAP_SECONDS)\n except subprocess.TimeoutExpired:\n logger.warning(\"Timed out draining cancelled Docling subprocess; waiting for process exit\")\n except OSError:\n logger.exception(\"Failed to drain cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while draining cancelled Docling subprocess\")\n else:\n return\n\n try:\n proc.wait(timeout=_FILE_TOOL_PROCESS_REAP_SECONDS)\n except subprocess.TimeoutExpired:\n logger.exception(\"Cancelled Docling subprocess could not be reaped within the cleanup timeout\")\n except OSError:\n logger.exception(\"Failed to reap cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while reaping cancelled Docling subprocess\")\n\n\ndef _get_file_tool_limiter() -> asyncio.Semaphore:\n \"\"\"Return the bounded loader admission gate for the current event loop.\"\"\"\n loop = asyncio.get_running_loop()\n limiter = getattr(loop, _FILE_TOOL_LIMITER_ATTRIBUTE, None)\n if limiter is None:\n limiter = asyncio.Semaphore(_FILE_TOOL_MAX_CONCURRENT_LOADS)\n setattr(loop, _FILE_TOOL_LIMITER_ATTRIBUTE, limiter)\n return limiter\n\n\ndef _log_abandoned_file_tool_result(task: asyncio.Task) -> None:\n \"\"\"Observe unexpected failures from a synchronous loader that outlived its caller.\"\"\"\n try:\n error = task.exception()\n except asyncio.CancelledError:\n return\n if error is not None and not isinstance(error, _FileToolCancelledError):\n logger.error(\"Abandoned file loader failed after its tool call was cancelled\", exc_info=error)\n\n\ndef _get_storage_location_options():\n \"\"\"Get storage location options, filtering out Local if in Astra cloud environment.\"\"\"\n all_options = [{\"name\": \"AWS\", \"icon\": \"Amazon\"}, {\"name\": \"Google Drive\", \"icon\": \"google\"}]\n if is_astra_cloud_environment():\n return all_options\n return [{\"name\": \"Local\", \"icon\": \"hard-drive\"}, *all_options]\n\n\nclass FileComponent(BaseFileComponent):\n \"\"\"File component with optional Docling processing (isolated in a subprocess).\"\"\"\n\n display_name = \"Read File\"\n # description is now a dynamic property - see get_tool_description()\n _base_description = \"Loads and returns the content from uploaded files.\"\n documentation: str = \"https://docs.langflow.org/read-file\"\n icon = \"file-text\"\n name = \"File\"\n add_tool_output = True # Enable tool mode toggle without requiring tool_mode inputs\n\n # Extensions that can be processed without Docling (using standard text parsing)\n TEXT_EXTENSIONS = TEXT_FILE_TYPES\n\n # Extensions that require Docling for processing (images, advanced office formats, etc.)\n DOCLING_ONLY_EXTENSIONS = [\n \"adoc\",\n \"asciidoc\",\n \"asc\",\n \"bmp\",\n \"dotx\",\n \"dotm\",\n \"docm\",\n \"jpg\",\n \"jpeg\",\n \"png\",\n \"potx\",\n \"ppsx\",\n \"pptm\",\n \"potm\",\n \"ppsm\",\n \"pptx\",\n \"tiff\",\n \"xls\",\n \"xlsx\",\n \"xhtml\",\n \"webp\",\n ]\n\n # Docling-supported/compatible extensions; TEXT_FILE_TYPES are supported by the base loader.\n VALID_EXTENSIONS = [\n *TEXT_EXTENSIONS,\n *DOCLING_ONLY_EXTENSIONS,\n ]\n\n # Fixed export settings used when markdown export is requested.\n EXPORT_FORMAT = \"Markdown\"\n IMAGE_MODE = \"placeholder\"\n\n _base_inputs = deepcopy(BaseFileComponent.get_base_inputs())\n\n for input_item in _base_inputs:\n if isinstance(input_item, FileInput) and input_item.name == \"path\":\n input_item.real_time_refresh = True\n input_item.tool_mode = False # Disable tool mode for file upload input\n input_item.required = False # Make it optional so it doesn't error in tool mode\n break\n\n inputs = [\n SortableListInput(\n name=\"storage_location\",\n display_name=\"Storage Location\",\n placeholder=\"Select Location\",\n info=\"Choose where to read the file from.\",\n options=_get_storage_location_options(),\n real_time_refresh=True,\n limit=1,\n value=[{\"name\": \"Local\", \"icon\": \"hard-drive\"}],\n advanced=True,\n ),\n *_base_inputs,\n StrInput(\n name=\"file_path_str\",\n display_name=\"File Path\",\n info=(\n \"Path to the file to read. Used when component is called as a tool. \"\n \"If not provided, will use the uploaded file from 'path' input.\"\n ),\n show=False,\n advanced=True,\n tool_mode=True, # Required for Toolset toggle, but _get_tools() ignores this parameter\n required=False,\n ),\n # AWS S3 specific inputs\n SecretStrInput(\n name=\"aws_access_key_id\",\n display_name=\"AWS Access Key ID\",\n info=\"AWS Access key ID.\",\n show=False,\n advanced=False,\n required=True,\n ),\n SecretStrInput(\n name=\"aws_secret_access_key\",\n display_name=\"AWS Secret Key\",\n info=\"AWS Secret Key.\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"bucket_name\",\n display_name=\"S3 Bucket Name\",\n info=\"Enter the name of the S3 bucket.\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"aws_region\",\n display_name=\"AWS Region\",\n info=\"AWS region (e.g., us-east-1, eu-west-1).\",\n show=False,\n advanced=False,\n ),\n StrInput(\n name=\"s3_file_key\",\n display_name=\"S3 File Key\",\n info=\"The key (path) of the file in S3 bucket.\",\n show=False,\n advanced=False,\n required=True,\n ),\n # Google Drive specific inputs\n SecretStrInput(\n name=\"service_account_key\",\n display_name=\"GCP Credentials Secret Key\",\n info=\"Your Google Cloud Platform service account JSON key as a secret string (complete JSON content).\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"file_id\",\n display_name=\"Google Drive File ID\",\n info=(\"The Google Drive file ID to read. The file must be shared with the service account email.\"),\n show=False,\n advanced=False,\n required=True,\n ),\n BoolInput(\n name=\"advanced_mode\",\n display_name=\"Advanced Parser\",\n value=False,\n real_time_refresh=True,\n info=(\n \"Enable advanced document processing and export with Docling for PDFs, images, and office documents. \"\n \"Note that advanced document processing can consume significant resources.\"\n ),\n # Disabled in cloud\n show=not is_astra_cloud_environment(),\n ),\n DropdownInput(\n name=\"pipeline\",\n display_name=\"Pipeline\",\n info=\"Docling pipeline to use\",\n options=[\"standard\", \"vlm\"],\n value=\"standard\",\n advanced=True,\n real_time_refresh=True,\n ),\n DropdownInput(\n name=\"ocr_engine\",\n display_name=\"OCR Engine\",\n info=\"OCR engine to use. Only available when pipeline is set to 'standard'.\",\n options=[\"None\", \"easyocr\"],\n value=\"easyocr\",\n show=False,\n advanced=True,\n ),\n StrInput(\n name=\"md_image_placeholder\",\n display_name=\"Image placeholder\",\n info=\"Specify the image placeholder for markdown exports.\",\n value=\"<!-- image -->\",\n advanced=True,\n show=False,\n ),\n StrInput(\n name=\"md_page_break_placeholder\",\n display_name=\"Page break placeholder\",\n info=\"Add this placeholder between pages in the markdown output.\",\n value=\"\",\n advanced=True,\n show=False,\n ),\n MessageTextInput(\n name=\"doc_key\",\n display_name=\"Doc Key\",\n info=\"The key to use for the DoclingDocument column.\",\n value=\"doc\",\n advanced=True,\n show=False,\n ),\n # Deprecated input retained for backward-compatibility.\n BoolInput(\n name=\"use_multithreading\",\n display_name=\"[Deprecated] Use Multithreading\",\n advanced=True,\n value=True,\n info=\"Set 'Processing Concurrency' greater than 1 to enable multithreading.\",\n ),\n IntInput(\n name=\"concurrency_multithreading\",\n display_name=\"Processing Concurrency\",\n advanced=True,\n info=\"When multiple files are being processed, the number of files to process concurrently.\",\n value=1,\n ),\n BoolInput(\n name=\"markdown\",\n display_name=\"Markdown Export\",\n info=\"Export processed documents to Markdown format. Only available when advanced mode is enabled.\",\n value=False,\n show=False,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Raw Content\", name=\"message\", method=\"load_files_message\", tool_mode=True),\n ]\n\n # ------------------------------ Tool description with file names --------------\n\n def get_tool_description(self) -> str:\n \"\"\"Return a dynamic description that includes the names of uploaded files.\n\n This helps the Agent understand which files are available to read.\n \"\"\"\n base_description = type(self)._base_description # noqa: SLF001\n\n # Get the list of uploaded file paths\n file_paths = getattr(self, \"path\", None)\n if not file_paths:\n return base_description\n\n # Ensure it's a list\n if not isinstance(file_paths, list):\n file_paths = [file_paths]\n\n # Extract just the file names from the paths\n file_names = []\n for fp in file_paths:\n if fp:\n name = Path(fp).name\n file_names.append(name)\n\n if file_names:\n files_str = \", \".join(file_names)\n return f\"{base_description} Available files: {files_str}. Call this tool to read these files.\"\n\n return base_description\n\n @property\n def description(self) -> str:\n \"\"\"Dynamic description property that includes uploaded file names.\"\"\"\n return self.get_tool_description()\n\n async def _get_tools(self) -> list:\n \"\"\"Override to create a tool without parameters.\n\n The Read File component should use the files already uploaded via UI,\n not accept file paths from the Agent (which wouldn't know the internal paths).\n \"\"\"\n from langchain_core.tools import StructuredTool\n from pydantic import BaseModel\n\n # Empty schema - no parameters needed\n class EmptySchema(BaseModel):\n \"\"\"No parameters required - uses pre-uploaded files.\"\"\"\n\n async def read_files_tool() -> str:\n \"\"\"Read the content of uploaded files.\"\"\"\n cancel_event = threading.Event()\n cancel_token = _FILE_TOOL_CANCEL_EVENT.set(cancel_event)\n try:\n if getattr(self, \"advanced_mode\", False):\n # In advanced mode, use the markdown output path so that the\n # tool shares the same Docling processing as the advanced\n # outputs rather than triggering a second subprocess via\n # load_files_message.\n self.markdown = True\n loader = self.load_files_markdown\n else:\n loader = self.load_files_message\n # Both loaders are blocking (file IO plus, in advanced mode, a Docling\n # subprocess). Run them off the event loop so streaming/heartbeats on\n # the same loop keep flowing while the agent waits for this tool. Keep\n # admission bounded until the real worker exits so cancelled standard\n # parsers cannot build an unbounded default-executor backlog.\n load_limiter = _get_file_tool_limiter()\n await load_limiter.acquire()\n try:\n loader_task = asyncio.create_task(asyncio.to_thread(loader))\n except BaseException:\n load_limiter.release()\n raise\n loader_task.add_done_callback(lambda _task: load_limiter.release())\n try:\n result = await asyncio.shield(loader_task)\n except asyncio.CancelledError:\n cancel_event.set()\n # Give cooperative cleanup time to kill/reap Docling and remove\n # temporary files before releasing this tool invocation.\n try:\n await asyncio.wait_for(\n asyncio.shield(loader_task),\n timeout=_FILE_TOOL_CANCEL_WAIT_SECONDS,\n )\n except _FileToolCancelledError:\n pass\n except asyncio.TimeoutError:\n pass\n except Exception: # noqa: BLE001 - cancellation stays dominant over loader cleanup\n logger.exception(\"File loader failed while cleaning up a cancelled tool call\")\n finally:\n if not loader_task.done():\n loader_task.add_done_callback(_log_abandoned_file_tool_result)\n raise\n if hasattr(result, \"get_text\"):\n return result.get_text()\n if hasattr(result, \"text\"):\n return result.text\n return str(result)\n except (FileNotFoundError, ValueError, OSError, RuntimeError) as e:\n return f\"Error reading files: {e}\"\n finally:\n _FILE_TOOL_CANCEL_EVENT.reset(cancel_token)\n\n description = self.get_tool_description()\n\n tool = StructuredTool(\n name=\"load_files_message\",\n description=description,\n coroutine=read_files_tool,\n args_schema=EmptySchema,\n handle_tool_error=True,\n tags=[\"load_files_message\"],\n metadata={\n \"display_name\": \"Read File\",\n \"display_description\": description,\n },\n )\n\n return [tool]\n\n # ------------------------------ UI helpers --------------------------------------\n\n def _path_value(self, template: dict) -> list[str]:\n \"\"\"Return the list of currently selected file paths from the template.\"\"\"\n return template.get(\"path\", {}).get(\"file_path\", [])\n\n def _disable_docling_fields_in_cloud(self, build_config: dict[str, Any]) -> None:\n \"\"\"Disable all Docling-related fields in cloud environments.\"\"\"\n if \"advanced_mode\" in build_config:\n build_config[\"advanced_mode\"][\"show\"] = False\n build_config[\"advanced_mode\"][\"value\"] = False\n # Hide all Docling-related fields\n docling_fields = (\"pipeline\", \"ocr_engine\", \"doc_key\", \"md_image_placeholder\", \"md_page_break_placeholder\")\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = False\n # Also disable OCR engine specifically\n if \"ocr_engine\" in build_config:\n build_config[\"ocr_engine\"][\"value\"] = \"None\"\n\n def update_build_config(\n self,\n build_config: dict[str, Any],\n field_value: Any,\n field_name: str | None = None,\n ) -> dict[str, Any]:\n \"\"\"Show/hide Advanced Parser and related fields based on selection context.\"\"\"\n # Update storage location options dynamically based on cloud environment\n if \"storage_location\" in build_config:\n updated_options = _get_storage_location_options()\n build_config[\"storage_location\"][\"options\"] = updated_options\n\n # Handle storage location selection\n if field_name == \"storage_location\":\n # Extract selected storage location\n selected = [location[\"name\"] for location in field_value] if isinstance(field_value, list) else []\n\n # Hide all storage-specific fields first\n storage_fields = [\n \"aws_access_key_id\",\n \"aws_secret_access_key\",\n \"bucket_name\",\n \"aws_region\",\n \"s3_file_key\",\n \"service_account_key\",\n \"file_id\",\n ]\n\n for f_name in storage_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = False\n\n # Show fields based on selected storage location\n if len(selected) == 1:\n location = selected[0]\n\n if location == \"Local\":\n # Show file upload input for local storage\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = True\n\n elif location == \"AWS\":\n # Hide file upload input, show AWS fields\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = False\n\n aws_fields = [\n \"aws_access_key_id\",\n \"aws_secret_access_key\",\n \"bucket_name\",\n \"aws_region\",\n \"s3_file_key\",\n ]\n for f_name in aws_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = True\n build_config[f_name][\"advanced\"] = False\n\n elif location == \"Google Drive\":\n # Hide file upload input, show Google Drive fields\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = False\n\n gdrive_fields = [\"service_account_key\", \"file_id\"]\n for f_name in gdrive_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = True\n build_config[f_name][\"advanced\"] = False\n # No storage location selected - show file upload by default\n elif \"path\" in build_config:\n build_config[\"path\"][\"show\"] = True\n\n return build_config\n\n if field_name == \"path\":\n paths = self._path_value(build_config)\n\n # Disable in cloud environments\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n else:\n # If all files can be processed by docling, do so\n allow_advanced = all(not file_path.endswith((\".csv\", \".xlsx\", \".parquet\")) for file_path in paths)\n build_config[\"advanced_mode\"][\"show\"] = allow_advanced\n if not allow_advanced:\n build_config[\"advanced_mode\"][\"value\"] = False\n docling_fields = (\n \"pipeline\",\n \"ocr_engine\",\n \"doc_key\",\n \"md_image_placeholder\",\n \"md_page_break_placeholder\",\n )\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = False\n\n # Docling Processing\n elif field_name == \"advanced_mode\":\n # Disable in cloud environments - don't show Docling fields even if advanced_mode is toggled\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n else:\n docling_fields = (\n \"pipeline\",\n \"ocr_engine\",\n \"doc_key\",\n \"md_image_placeholder\",\n \"md_page_break_placeholder\",\n )\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = bool(field_value)\n if field == \"pipeline\":\n build_config[field][\"advanced\"] = not bool(field_value)\n\n elif field_name == \"pipeline\":\n # Disable in cloud environments - don't show OCR engine even if pipeline is changed\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n elif field_value == \"standard\":\n build_config[\"ocr_engine\"][\"show\"] = True\n build_config[\"ocr_engine\"][\"value\"] = \"easyocr\"\n else:\n build_config[\"ocr_engine\"][\"show\"] = False\n build_config[\"ocr_engine\"][\"value\"] = \"None\"\n\n return build_config\n\n def update_outputs(self, frontend_node: dict[str, Any], field_name: str, field_value: Any) -> dict[str, Any]: # noqa: ARG002\n \"\"\"Dynamically show outputs based on file count/type and advanced mode.\"\"\"\n if field_name not in [\"path\", \"advanced_mode\", \"pipeline\"]:\n return frontend_node\n\n template = frontend_node.get(\"template\", {})\n paths = self._path_value(template)\n if not paths:\n return frontend_node\n\n frontend_node[\"outputs\"] = []\n if len(paths) == 1:\n file_path = paths[0] if field_name == \"path\" else frontend_node[\"template\"][\"path\"][\"file_path\"][0]\n if file_path.endswith((\".csv\", \".xlsx\", \".parquet\")):\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Structured Content\",\n name=\"dataframe\",\n method=\"load_files_structured\",\n tool_mode=True,\n ),\n )\n elif file_path.endswith(\".json\"):\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Structured Content\", name=\"json\", method=\"load_files_json\", tool_mode=True),\n )\n\n advanced_mode = frontend_node.get(\"template\", {}).get(\"advanced_mode\", {}).get(\"value\", False)\n if advanced_mode:\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Structured Output\",\n name=\"advanced_dataframe\",\n method=\"load_files_dataframe\",\n tool_mode=True,\n ),\n )\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Markdown\", name=\"advanced_markdown\", method=\"load_files_markdown\", tool_mode=True\n ),\n )\n frontend_node[\"outputs\"].append(\n Output(display_name=\"File Path\", name=\"path\", method=\"load_files_path\", tool_mode=True),\n )\n else:\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Raw Content\", name=\"message\", method=\"load_files_message\", tool_mode=True),\n )\n frontend_node[\"outputs\"].append(\n Output(display_name=\"File Path\", name=\"path\", method=\"load_files_path\", tool_mode=True),\n )\n else:\n # Multiple files => DataFrame output; advanced parser disabled\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Files\", name=\"dataframe\", method=\"load_files\", tool_mode=True)\n )\n\n return frontend_node\n\n # ------------------------------ Core processing ----------------------------------\n\n def _get_selected_storage_location(self) -> str:\n \"\"\"Get the selected storage location from the SortableListInput.\"\"\"\n if hasattr(self, \"storage_location\") and self.storage_location:\n if isinstance(self.storage_location, list) and len(self.storage_location) > 0:\n return self.storage_location[0].get(\"name\", \"\")\n if isinstance(self.storage_location, dict):\n return self.storage_location.get(\"name\", \"\")\n return \"Local\" # Default to Local if not specified\n\n def _validate_and_resolve_paths(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Override to handle file_path_str input from tool mode and cloud storage.\n\n Priority:\n 1. Cloud storage (AWS/Google Drive) if selected\n 2. file_path_str (if provided by the tool call)\n 3. path (uploaded file from UI)\n \"\"\"\n storage_location = self._get_selected_storage_location()\n\n # Handle AWS S3\n if storage_location == \"AWS\":\n return self._read_from_aws_s3()\n\n # Handle Google Drive\n if storage_location == \"Google Drive\":\n return self._read_from_google_drive()\n\n # Handle Local storage\n # Check if file_path_str is provided (from tool mode)\n file_path_str = getattr(self, \"file_path_str\", None)\n if file_path_str:\n # Use the string path from tool mode\n from pathlib import Path\n\n from lfx.schema.data import Data\n\n # Use same resolution logic as BaseFileComponent (support storage paths)\n path_str = str(file_path_str)\n if parse_storage_path(path_str):\n try:\n resolved_path = Path(self.get_full_path(path_str))\n except (ValueError, AttributeError):\n resolved_path = Path(self.resolve_path(path_str))\n else:\n resolved_path = Path(self.resolve_path(path_str))\n\n # Security: confine tool-mode reads to the storage dir in restricted (multi-tenant)\n # mode so a tenant cannot read arbitrary server files via file_path_str.\n from lfx.utils.file_path_security import component_file_access_scopes, enforce_local_file_access\n\n resolved_path = enforce_local_file_access(resolved_path, scope_ids=component_file_access_scopes(self))\n\n if not resolved_path.exists():\n msg = f\"File or directory not found: {file_path_str}\"\n self.log(msg)\n if not self.silent_errors:\n raise ValueError(msg)\n return []\n\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(resolved_path)})\n return [BaseFileComponent.BaseFile(data_obj, resolved_path, delete_after_processing=False)]\n\n # Otherwise use the default implementation (uses path FileInput)\n return super()._validate_and_resolve_paths()\n\n def _read_from_aws_s3(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Read file from AWS S3.\"\"\"\n from lfx.base.data.cloud_storage_utils import create_s3_client, validate_aws_credentials\n\n # Validate AWS credentials\n validate_aws_credentials(self)\n if not getattr(self, \"s3_file_key\", None):\n msg = \"S3 File Key is required\"\n raise ValueError(msg)\n\n # Create S3 client\n s3_client = create_s3_client(self)\n\n # Download file to temp location\n import tempfile\n\n # Get file extension from S3 key\n file_extension = Path(self.s3_file_key).suffix or \"\"\n\n with tempfile.NamedTemporaryFile(mode=\"wb\", suffix=file_extension, delete=False) as temp_file:\n temp_file_path = temp_file.name\n try:\n s3_client.download_fileobj(self.bucket_name, self.s3_file_key, temp_file)\n except Exception as e:\n # Clean up temp file on failure\n with contextlib.suppress(OSError):\n Path(temp_file_path).unlink()\n msg = f\"Failed to download file from S3: {e}\"\n raise RuntimeError(msg) from e\n\n # Create BaseFile object\n from lfx.schema.data import Data\n\n temp_path = Path(temp_file_path)\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(temp_path)})\n return [BaseFileComponent.BaseFile(data_obj, temp_path, delete_after_processing=True)]\n\n def _read_from_google_drive(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Read file from Google Drive.\"\"\"\n import tempfile\n\n from googleapiclient.http import MediaIoBaseDownload\n\n from lfx.base.data.cloud_storage_utils import create_google_drive_service\n\n # Validate Google Drive credentials\n if not getattr(self, \"service_account_key\", None):\n msg = \"GCP Credentials Secret Key is required for Google Drive storage\"\n raise ValueError(msg)\n if not getattr(self, \"file_id\", None):\n msg = \"Google Drive File ID is required\"\n raise ValueError(msg)\n\n # Create Google Drive service with read-only scope\n drive_service = create_google_drive_service(\n self.service_account_key, scopes=[\"https://www.googleapis.com/auth/drive.readonly\"]\n )\n\n # Get file metadata to determine file name and extension\n try:\n file_metadata = drive_service.files().get(fileId=self.file_id, fields=\"name,mimeType\").execute()\n file_name = file_metadata.get(\"name\", \"download\")\n except Exception as e:\n msg = (\n f\"Unable to access file with ID '{self.file_id}'. \"\n f\"Error: {e!s}. \"\n \"Please ensure: 1) The file ID is correct, 2) The file exists, \"\n \"3) The service account has been granted access to this file.\"\n )\n raise ValueError(msg) from e\n\n # Download file to temp location\n file_extension = Path(file_name).suffix or \"\"\n with tempfile.NamedTemporaryFile(mode=\"wb\", suffix=file_extension, delete=False) as temp_file:\n temp_file_path = temp_file.name\n try:\n request = drive_service.files().get_media(fileId=self.file_id)\n downloader = MediaIoBaseDownload(temp_file, request)\n done = False\n while not done:\n _status, done = downloader.next_chunk()\n except Exception as e:\n # Clean up temp file on failure\n with contextlib.suppress(OSError):\n Path(temp_file_path).unlink()\n msg = f\"Failed to download file from Google Drive: {e}\"\n raise RuntimeError(msg) from e\n\n # Create BaseFile object\n from lfx.schema.data import Data\n\n temp_path = Path(temp_file_path)\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(temp_path)})\n return [BaseFileComponent.BaseFile(data_obj, temp_path, delete_after_processing=True)]\n\n def _is_docling_compatible(self, file_path: str) -> bool:\n \"\"\"Lightweight extension gate for Docling-compatible types.\"\"\"\n docling_exts = (\n \".adoc\",\n \".asciidoc\",\n \".asc\",\n \".bmp\",\n \".csv\",\n \".dotx\",\n \".dotm\",\n \".docm\",\n \".docx\",\n \".htm\",\n \".html\",\n \".jpg\",\n \".jpeg\",\n \".json\",\n \".md\",\n \".pdf\",\n \".png\",\n \".potx\",\n \".ppsx\",\n \".pptm\",\n \".potm\",\n \".ppsm\",\n \".pptx\",\n \".tiff\",\n \".txt\",\n \".xls\",\n \".xlsx\",\n \".xhtml\",\n \".xml\",\n \".webp\",\n )\n return file_path.lower().endswith(docling_exts)\n\n async def _get_local_file_for_docling(self, file_path: str) -> tuple[str, bool]:\n \"\"\"Get a local file path for Docling processing, downloading from S3 if needed.\n\n Args:\n file_path: Either a local path or S3 key (format \"flow_id/filename\")\n\n Returns:\n tuple[str, bool]: (local_path, should_delete) where should_delete indicates\n if this is a temporary file that should be cleaned up\n \"\"\"\n settings = get_settings_service().settings\n if settings.storage_type == \"local\":\n return file_path, False\n\n # S3 storage - download to temp file\n parsed = parse_storage_path(file_path)\n if not parsed:\n msg = f\"Invalid S3 path format: {file_path}. Expected 'flow_id/filename'\"\n raise ValueError(msg)\n\n storage_service = get_storage_service()\n flow_id, filename = parsed\n\n # Get file content from S3\n content = await storage_service.get_file(flow_id, filename)\n\n suffix = Path(filename).suffix\n with NamedTemporaryFile(mode=\"wb\", suffix=suffix, delete=False) as tmp_file:\n tmp_file.write(content)\n temp_path = tmp_file.name\n\n return temp_path, True\n\n def _process_docling_in_subprocess(self, file_path: str) -> Data | None:\n \"\"\"Run Docling in a separate OS process and map the result to a Data object.\n\n We avoid multiprocessing pickling by launching `python -c \"<script>\"` and\n passing JSON config via stdin. The child prints a JSON result to stdout.\n\n For S3 storage, the file is downloaded to a temp file first.\n \"\"\"\n if not file_path:\n return None\n\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n _raise_if_file_tool_cancelled(cancel_event)\n settings = get_settings_service().settings\n if is_remote_storage_type(settings.storage_type):\n local_path, should_delete = run_until_complete(self._get_local_file_for_docling(file_path))\n else:\n local_path = file_path\n should_delete = False\n\n try:\n _raise_if_file_tool_cancelled(cancel_event)\n return self._process_docling_subprocess_impl(local_path, file_path)\n finally:\n # Clean up temp file if we created one\n if should_delete:\n with contextlib.suppress(Exception):\n Path(local_path).unlink() # Ignore cleanup errors\n\n def _process_docling_subprocess_impl(self, local_file_path: str, original_file_path: str) -> Data | None:\n \"\"\"Implementation of Docling subprocess processing.\n\n Args:\n local_file_path: Path to local file to process\n original_file_path: Original file path to include in metadata\n Returns:\n Data object with processed content\n \"\"\"\n args: dict[str, Any] = {\n \"file_path\": local_file_path,\n \"markdown\": bool(self.markdown),\n \"image_mode\": str(self.IMAGE_MODE),\n \"md_image_placeholder\": str(self.md_image_placeholder),\n \"md_page_break_placeholder\": str(self.md_page_break_placeholder),\n \"pipeline\": str(self.pipeline),\n \"ocr_engine\": (\n self.ocr_engine if self.ocr_engine and self.ocr_engine != \"None\" and self.pipeline != \"vlm\" else None\n ),\n }\n\n # Child script for isolating the docling processing\n child_script = textwrap.dedent(\n r\"\"\"\n import json, sys\n\n def try_imports():\n try:\n from docling.datamodel.base_models import ConversionStatus, InputFormat # type: ignore\n from docling.document_converter import DocumentConverter # type: ignore\n from docling_core.types.doc import ImageRefMode # type: ignore\n return ConversionStatus, InputFormat, DocumentConverter, ImageRefMode, \"latest\"\n except Exception as e:\n raise e\n\n def create_converter(strategy, input_format, DocumentConverter, pipeline, ocr_engine):\n # --- Standard PDF/IMAGE pipeline (your existing behavior), with optional OCR ---\n if pipeline == \"standard\":\n try:\n from docling.datamodel.pipeline_options import PdfPipelineOptions # type: ignore\n from docling.document_converter import PdfFormatOption # type: ignore\n\n pipe = PdfPipelineOptions()\n pipe.do_ocr = False\n\n if ocr_engine:\n try:\n from docling.models.factories import get_ocr_factory # type: ignore\n pipe.do_ocr = True\n fac = get_ocr_factory(allow_external_plugins=False)\n pipe.ocr_options = fac.create_options(kind=ocr_engine)\n except Exception:\n # If OCR setup fails, disable it\n pipe.do_ocr = False\n\n fmt = {}\n if hasattr(input_format, \"PDF\"):\n fmt[getattr(input_format, \"PDF\")] = PdfFormatOption(pipeline_options=pipe)\n if hasattr(input_format, \"IMAGE\"):\n fmt[getattr(input_format, \"IMAGE\")] = PdfFormatOption(pipeline_options=pipe)\n\n return DocumentConverter(format_options=fmt)\n except Exception:\n return DocumentConverter()\n\n # --- Vision-Language Model (VLM) pipeline ---\n if pipeline == \"vlm\":\n try:\n from docling.datamodel.pipeline_options import VlmPipelineOptions\n from docling.datamodel.vlm_model_specs import GRANITEDOCLING_MLX, GRANITEDOCLING_TRANSFORMERS\n from docling.document_converter import PdfFormatOption\n from docling.pipeline.vlm_pipeline import VlmPipeline\n\n vl_pipe = VlmPipelineOptions(\n vlm_options=GRANITEDOCLING_TRANSFORMERS,\n )\n\n if sys.platform == \"darwin\":\n try:\n import mlx_vlm\n vl_pipe.vlm_options = GRANITEDOCLING_MLX\n except ImportError as e:\n raise e\n\n # VLM paths generally don't need OCR; keep OCR off by default here.\n fmt = {}\n if hasattr(input_format, \"PDF\"):\n fmt[getattr(input_format, \"PDF\")] = PdfFormatOption(\n pipeline_cls=VlmPipeline,\n pipeline_options=vl_pipe\n )\n if hasattr(input_format, \"IMAGE\"):\n fmt[getattr(input_format, \"IMAGE\")] = PdfFormatOption(\n pipeline_cls=VlmPipeline,\n pipeline_options=vl_pipe\n )\n\n return DocumentConverter(format_options=fmt)\n except Exception as e:\n raise e\n\n # --- Fallback: default converter with no special options ---\n return DocumentConverter()\n\n def export_markdown(document, ImageRefMode, image_mode, img_ph, pg_ph):\n try:\n mode = getattr(ImageRefMode, image_mode.upper(), image_mode)\n return document.export_to_markdown(\n image_mode=mode,\n image_placeholder=img_ph,\n page_break_placeholder=pg_ph,\n )\n except Exception:\n try:\n return document.export_to_text()\n except Exception:\n return str(document)\n\n def to_rows(doc_dict):\n rows = []\n for t in doc_dict.get(\"texts\", []):\n prov = t.get(\"prov\") or []\n page_no = None\n if prov and isinstance(prov, list) and isinstance(prov[0], dict):\n page_no = prov[0].get(\"page_no\")\n rows.append({\n \"page_no\": page_no,\n \"label\": t.get(\"label\"),\n \"text\": t.get(\"text\"),\n \"level\": t.get(\"level\"),\n })\n return rows\n\n def main():\n cfg = json.loads(sys.stdin.read())\n file_path = cfg[\"file_path\"]\n markdown = cfg[\"markdown\"]\n image_mode = cfg[\"image_mode\"]\n img_ph = cfg[\"md_image_placeholder\"]\n pg_ph = cfg[\"md_page_break_placeholder\"]\n pipeline = cfg[\"pipeline\"]\n ocr_engine = cfg.get(\"ocr_engine\")\n meta = {\"file_path\": file_path}\n\n try:\n ConversionStatus, InputFormat, DocumentConverter, ImageRefMode, strategy = try_imports()\n converter = create_converter(strategy, InputFormat, DocumentConverter, pipeline, ocr_engine)\n try:\n res = converter.convert(file_path)\n except Exception as e:\n print(json.dumps({\"ok\": False, \"error\": f\"Docling conversion error: {e}\", \"meta\": meta}))\n return\n\n ok = False\n if hasattr(res, \"status\"):\n try:\n ok = (res.status == ConversionStatus.SUCCESS) or (str(res.status).lower() == \"success\")\n except Exception:\n ok = (str(res.status).lower() == \"success\")\n if not ok and hasattr(res, \"document\"):\n ok = getattr(res, \"document\", None) is not None\n if not ok:\n print(json.dumps({\"ok\": False, \"error\": \"Docling conversion failed\", \"meta\": meta}))\n return\n\n doc = getattr(res, \"document\", None)\n if doc is None:\n print(json.dumps({\"ok\": False, \"error\": \"Docling produced no document\", \"meta\": meta}))\n return\n\n # Extract DoclingDocument metadata\n if hasattr(doc, \"name\") and doc.name:\n meta[\"name\"] = doc.name\n if hasattr(doc, \"origin\") and doc.origin is not None:\n origin = doc.origin\n if hasattr(origin, \"filename\") and origin.filename:\n meta[\"filename\"] = origin.filename\n if hasattr(origin, \"binary_hash\") and origin.binary_hash:\n meta[\"document_id\"] = str(origin.binary_hash)\n if hasattr(origin, \"mimetype\") and origin.mimetype:\n meta[\"mimetype\"] = origin.mimetype\n\n if markdown:\n text = export_markdown(doc, ImageRefMode, image_mode, img_ph, pg_ph)\n print(json.dumps({\"ok\": True, \"mode\": \"markdown\", \"text\": text, \"meta\": meta}))\n return\n\n # structured\n try:\n doc_dict = doc.export_to_dict()\n except Exception as e:\n print(json.dumps({\"ok\": False, \"error\": f\"Docling export_to_dict failed: {e}\", \"meta\": meta}))\n return\n\n rows = to_rows(doc_dict)\n print(json.dumps({\"ok\": True, \"mode\": \"structured\", \"doc\": rows, \"meta\": meta}))\n except Exception as e:\n print(\n json.dumps({\n \"ok\": False,\n \"error\": f\"Docling processing error: {e}\",\n \"meta\": {\"file_path\": file_path},\n })\n )\n\n if __name__ == \"__main__\":\n main()\n \"\"\"\n )\n\n # The path is passed as JSON over stdin to an argument-list subprocess, so shell\n # metacharacters are ordinary filename characters here.\n if not isinstance(args[\"file_path\"], str):\n return Data(data={\"error\": \"Unsafe file path detected.\", \"file_path\": args[\"file_path\"]})\n\n # Use communicate() in bounded intervals so stdout/stderr are drained while the\n # child runs without losing the heartbeat that keeps the SSE event stream alive.\n docling_timeout = 600 # 10 minutes; large PDFs with OCR may need this\n poll_interval = 5 # seconds between progress heartbeats\n\n proc = subprocess.Popen( # noqa: S603\n [sys.executable, \"-u\", \"-c\", child_script],\n stdin=subprocess.PIPE,\n stdout=subprocess.PIPE,\n stderr=subprocess.PIPE,\n )\n\n start = time.monotonic()\n input_bytes: bytes | None = json.dumps(args).encode(\"utf-8\")\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n try:\n while True:\n _raise_if_file_tool_cancelled(cancel_event)\n elapsed = time.monotonic() - start\n if elapsed >= docling_timeout:\n proc.kill()\n proc.communicate()\n return Data(\n data={\n \"error\": (\n f\"Docling processing timed out after {docling_timeout}s. \"\n \"Consider using the standalone Docling component for large documents.\"\n ),\n \"file_path\": original_file_path,\n },\n )\n try:\n stdout_bytes, stderr_bytes = proc.communicate(\n input=input_bytes,\n timeout=min(poll_interval, docling_timeout - elapsed),\n )\n _raise_if_file_tool_cancelled(cancel_event)\n break\n except subprocess.TimeoutExpired:\n # communicate() retains partially written input and collected output across\n # retries, so subsequent calls continue draining without resending stdin.\n input_bytes = None\n elapsed = time.monotonic() - start\n self.log(f\"Docling processing in progress ({int(elapsed)}s elapsed)...\")\n finally:\n if (cancel_event is not None and cancel_event.is_set()) or proc.poll() is None:\n _kill_and_reap_process(proc)\n\n if not stdout_bytes:\n err_msg = stderr_bytes.decode(\"utf-8\", errors=\"replace\") if stderr_bytes else \"no output from child process\"\n return Data(data={\"error\": f\"Docling subprocess error: {err_msg}\", \"file_path\": original_file_path})\n\n try:\n result = json.loads(stdout_bytes.decode(\"utf-8\"))\n except Exception as e: # noqa: BLE001\n err_msg = stderr_bytes.decode(\"utf-8\", errors=\"replace\")\n return Data(\n data={\n \"error\": f\"Invalid JSON from Docling subprocess: {e}. stderr={err_msg}\",\n \"file_path\": original_file_path,\n },\n )\n\n if not result.get(\"ok\"):\n error_msg = result.get(\"error\", \"Unknown Docling error\")\n # Override meta file_path with original_file_path to ensure correct path matching\n meta = result.get(\"meta\", {})\n meta[\"file_path\"] = original_file_path\n return Data(data={\"error\": error_msg, **meta})\n\n meta = result.get(\"meta\", {})\n # Override meta file_path with original_file_path to ensure correct path matching\n # The subprocess returns the temp file path, but we need the original S3/local path for rollup_data\n meta[\"file_path\"] = original_file_path\n if result.get(\"mode\") == \"markdown\":\n exported_content = str(result.get(\"text\", \"\"))\n return Data(\n text=exported_content,\n data={\"exported_content\": exported_content, \"export_format\": self.EXPORT_FORMAT, **meta},\n )\n\n rows = list(result.get(\"doc\", []))\n return Data(data={\"doc\": rows, \"export_format\": self.EXPORT_FORMAT, **meta})\n\n def process_files(\n self,\n file_list: list[BaseFileComponent.BaseFile],\n ) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Process input files.\n\n - advanced_mode => Docling in a separate process.\n - Otherwise => standard parsing in current process (optionally threaded).\n \"\"\"\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n _raise_if_file_tool_cancelled(cancel_event)\n if not file_list:\n msg = \"No files to process.\"\n raise ValueError(msg)\n\n # Validate image files to detect content/extension mismatches\n # This prevents API errors like \"Image does not match the provided media type\"\n image_extensions = {\"jpeg\", \"jpg\", \"png\", \"gif\", \"webp\", \"bmp\", \"tiff\"}\n settings = get_settings_service().settings\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n extension = file.path.suffix[1:].lower()\n if extension in image_extensions:\n # Read bytes based on storage type\n try:\n if is_remote_storage_type(settings.storage_type):\n # For S3 storage, use storage service to read file bytes\n file_path_str = str(file.path)\n content = run_until_complete(read_file_bytes(file_path_str))\n else:\n # For local storage, read bytes directly from filesystem\n content = file.path.read_bytes()\n\n is_valid, error_msg = validate_image_content_type(\n str(file.path),\n content=content,\n )\n if not is_valid:\n self.log(error_msg)\n if not self.silent_errors:\n raise ValueError(error_msg)\n except (OSError, FileNotFoundError) as e:\n self.log(f\"Could not read file for validation: {e}\")\n # Continue - let it fail later with better error\n\n # Validate that files requiring Docling are only processed when advanced mode is enabled\n if not self.advanced_mode:\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n extension = file.path.suffix[1:].lower()\n if extension in self.DOCLING_ONLY_EXTENSIONS:\n if is_astra_cloud_environment():\n msg = (\n f\"File '{file.path.name}' has extension '.{extension}' which requires \"\n f\"Advanced Parser mode. Advanced Parser is not available in cloud environments.\"\n )\n else:\n msg = (\n f\"File '{file.path.name}' has extension '.{extension}' which requires \"\n f\"Advanced Parser mode. Please enable 'Advanced Parser' to process this file.\"\n )\n self.log(msg)\n raise ValueError(msg)\n\n def process_file_standard(file_path: str, *, silent_errors: bool = False) -> Data | None:\n try:\n _raise_if_file_tool_cancelled(cancel_event)\n result = parse_text_file_to_data(file_path, silent_errors=silent_errors)\n _raise_if_file_tool_cancelled(cancel_event)\n except _FileToolCancelledError:\n raise\n except FileNotFoundError as e:\n self.log(f\"File not found: {file_path}. Error: {e}\")\n if not silent_errors:\n raise\n return None\n except Exception as e:\n self.log(f\"Unexpected error processing {file_path}: {e}\")\n if not silent_errors:\n raise\n return None\n else:\n return result\n\n docling_compatible = all(self._is_docling_compatible(str(f.path)) for f in file_list)\n\n # Advanced path: Check if ALL files are compatible with Docling\n if self.advanced_mode and docling_compatible:\n final_return: list[BaseFileComponent.BaseFile] = []\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n file_path = str(file.path)\n advanced_data: Data | None = self._process_docling_in_subprocess(file_path)\n _raise_if_file_tool_cancelled(cancel_event)\n\n # Handle None case - Docling processing failed or returned None\n if advanced_data is None:\n error_data = Data(\n data={\n \"file_path\": file_path,\n \"error\": \"Docling processing returned no result. Check logs for details.\",\n },\n )\n final_return.extend(self.rollup_data([file], [error_data]))\n continue\n\n # --- UNNEST: expand each element in `doc` to its own Data row\n payload = getattr(advanced_data, \"data\", {}) or {}\n\n # Check for errors first\n if \"error\" in payload:\n error_msg = payload.get(\"error\", \"Unknown error\")\n error_data = Data(\n data={\n \"file_path\": file_path,\n \"error\": error_msg,\n **{k: v for k, v in payload.items() if k not in (\"error\", \"file_path\")},\n },\n )\n final_return.extend(self.rollup_data([file], [error_data]))\n continue\n\n doc_rows = payload.get(\"doc\")\n if isinstance(doc_rows, list) and doc_rows:\n # Non-empty list of structured rows\n rows: list[Data | None] = [\n Data(\n data={\n \"file_path\": file_path,\n **(item if isinstance(item, dict) else {\"value\": item}),\n },\n )\n for item in doc_rows\n ]\n final_return.extend(self.rollup_data([file], rows))\n elif isinstance(doc_rows, list) and not doc_rows:\n # Empty list - file was processed but no text content found\n # Create a Data object indicating no content was extracted\n self.log(f\"No text extracted from '{file_path}', creating placeholder data\")\n empty_data = Data(\n data={\n \"file_path\": file_path,\n \"text\": \"(No text content extracted from image)\",\n \"info\": \"Image processed successfully but contained no extractable text\",\n **{k: v for k, v in payload.items() if k != \"doc\"},\n },\n )\n final_return.extend(self.rollup_data([file], [empty_data]))\n else:\n # If not structured, keep as-is (e.g., markdown export or error dict)\n # Ensure file_path is set for proper rollup matching\n if not payload.get(\"file_path\"):\n payload[\"file_path\"] = file_path\n # Create new Data with file_path\n advanced_data = Data(\n data=payload,\n text=getattr(advanced_data, \"text\", None),\n )\n final_return.extend(self.rollup_data([file], [advanced_data]))\n return final_return\n\n # Standard multi-file (or single non-advanced) path\n concurrency = max(1, self.concurrency_multithreading)\n\n file_paths = [str(f.path) for f in file_list]\n self.log(f\"Starting parallel processing of {len(file_paths)} files with concurrency: {concurrency}.\")\n my_data = parallel_load_data(\n file_paths,\n silent_errors=self.silent_errors,\n load_function=process_file_standard,\n max_concurrency=concurrency,\n )\n _raise_if_file_tool_cancelled(cancel_event)\n return self.rollup_data(file_list, my_data)\n\n # ------------------------------ Output helpers -----------------------------------\n\n def load_files_helper(self) -> DataFrame:\n result = self.load_files()\n\n # Result is a DataFrame - check if it has any rows\n if result.empty:\n msg = \"Could not extract content from the provided file(s).\"\n raise ValueError(msg)\n\n # Check for error column with error messages\n if \"error\" in result.columns:\n errors = result[\"error\"].dropna().tolist()\n if errors and not any(col in result.columns for col in [\"text\", \"doc\", \"exported_content\"]):\n raise ValueError(errors[0])\n\n return result\n\n def load_files_dataframe(self) -> DataFrame:\n \"\"\"Load files using advanced Docling processing and export to DataFrame format.\"\"\"\n self.markdown = False\n return self.load_files_helper()\n\n def load_files_markdown(self) -> Message:\n \"\"\"Load files using advanced Docling processing and export to Markdown format.\"\"\"\n self.markdown = True\n result = self.load_files_helper()\n\n # Result is a DataFrame - check for text or exported_content columns\n if \"text\" in result.columns and not result[\"text\"].isna().all():\n text_values = result[\"text\"].dropna().tolist()\n if text_values:\n return Message(text=str(text_values[0]))\n\n if \"exported_content\" in result.columns and not result[\"exported_content\"].isna().all():\n content_values = result[\"exported_content\"].dropna().tolist()\n if content_values:\n return Message(text=str(content_values[0]))\n\n # Return empty message with info that no text was found\n return Message(text=\"(No text content extracted from file)\")\n"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use provider-neutral error text for all remote backends.

When settings.storage_type is gcs or azure, _get_local_file_for_docling can still raise Invalid S3 path format and describe the input as an S3 key. Update the comments, docstring, and error message to say remote storage, or include the actual storage type. Otherwise, GCS and Azure users receive incorrect remediation guidance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/initial_setup/starter_projects/Portfolio` Website
Code Generator.json at line 563, Update _get_local_file_for_docling to use
provider-neutral wording whenever validating remote paths: revise its comments,
docstring, and “Invalid S3 path format” error to refer to remote storage or
include the configured storage type, while preserving the existing validation
behavior for GCS, Azure, and other backends.

@@ -1140,7 +1140,7 @@
"show": true,
"title_case": false,
"type": "code",
"value": "\"\"\"Enhanced file component with Docling support and process isolation.\n\nNotes:\n-----\n- ALL Docling parsing/export runs in a separate OS process to prevent memory\n growth and native library state from impacting the main Langflow process.\n- Standard text/structured parsing continues to use existing BaseFileComponent\n utilities (and optional threading via `parallel_load_data`).\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport json\nimport subprocess\nimport sys\nimport textwrap\nimport threading\nimport time\nfrom contextvars import ContextVar\nfrom copy import deepcopy\nfrom pathlib import Path\nfrom tempfile import NamedTemporaryFile\nfrom typing import Any\n\nfrom lfx.base.data.base_file import BaseFileComponent\nfrom lfx.base.data.storage_utils import parse_storage_path, read_file_bytes, validate_image_content_type\nfrom lfx.base.data.utils import TEXT_FILE_TYPES, parallel_load_data, parse_text_file_to_data\nfrom lfx.inputs import SortableListInput\nfrom lfx.inputs.inputs import DropdownInput, MessageTextInput, StrInput\nfrom lfx.io import BoolInput, FileInput, IntInput, Output, SecretStrInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame # noqa: TC001\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service, get_storage_service\nfrom lfx.utils.async_helpers import run_until_complete\nfrom lfx.utils.validate_cloud import is_astra_cloud_environment\n\n_FILE_TOOL_CANCEL_EVENT: ContextVar[threading.Event | None] = ContextVar(\"file_tool_cancel_event\", default=None)\n_FILE_TOOL_CANCEL_WAIT_SECONDS = 6\n_FILE_TOOL_MAX_CONCURRENT_LOADS = 4\n_FILE_TOOL_LIMITER_ATTRIBUTE = \"_lfx_file_tool_load_limiter\"\n_FILE_TOOL_PROCESS_REAP_SECONDS = 2\n\n\nclass _FileToolCancelledError(RuntimeError):\n \"\"\"Stop synchronous file loading after its async tool call is cancelled.\"\"\"\n\n\ndef _raise_if_file_tool_cancelled(cancel_event: threading.Event | None = None) -> None:\n event = cancel_event or _FILE_TOOL_CANCEL_EVENT.get()\n if event is not None and event.is_set():\n raise _FileToolCancelledError\n\n\ndef _kill_and_reap_process(proc: subprocess.Popen) -> None:\n try:\n proc.kill()\n except ProcessLookupError:\n pass\n except OSError:\n logger.exception(\"Failed to kill cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while killing cancelled Docling subprocess\")\n\n try:\n proc.communicate(timeout=_FILE_TOOL_PROCESS_REAP_SECONDS)\n except subprocess.TimeoutExpired:\n logger.warning(\"Timed out draining cancelled Docling subprocess; waiting for process exit\")\n except OSError:\n logger.exception(\"Failed to drain cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while draining cancelled Docling subprocess\")\n else:\n return\n\n try:\n proc.wait(timeout=_FILE_TOOL_PROCESS_REAP_SECONDS)\n except subprocess.TimeoutExpired:\n logger.exception(\"Cancelled Docling subprocess could not be reaped within the cleanup timeout\")\n except OSError:\n logger.exception(\"Failed to reap cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while reaping cancelled Docling subprocess\")\n\n\ndef _get_file_tool_limiter() -> asyncio.Semaphore:\n \"\"\"Return the bounded loader admission gate for the current event loop.\"\"\"\n loop = asyncio.get_running_loop()\n limiter = getattr(loop, _FILE_TOOL_LIMITER_ATTRIBUTE, None)\n if limiter is None:\n limiter = asyncio.Semaphore(_FILE_TOOL_MAX_CONCURRENT_LOADS)\n setattr(loop, _FILE_TOOL_LIMITER_ATTRIBUTE, limiter)\n return limiter\n\n\ndef _log_abandoned_file_tool_result(task: asyncio.Task) -> None:\n \"\"\"Observe unexpected failures from a synchronous loader that outlived its caller.\"\"\"\n try:\n error = task.exception()\n except asyncio.CancelledError:\n return\n if error is not None and not isinstance(error, _FileToolCancelledError):\n logger.error(\"Abandoned file loader failed after its tool call was cancelled\", exc_info=error)\n\n\ndef _get_storage_location_options():\n \"\"\"Get storage location options, filtering out Local if in Astra cloud environment.\"\"\"\n all_options = [{\"name\": \"AWS\", \"icon\": \"Amazon\"}, {\"name\": \"Google Drive\", \"icon\": \"google\"}]\n if is_astra_cloud_environment():\n return all_options\n return [{\"name\": \"Local\", \"icon\": \"hard-drive\"}, *all_options]\n\n\nclass FileComponent(BaseFileComponent):\n \"\"\"File component with optional Docling processing (isolated in a subprocess).\"\"\"\n\n display_name = \"Read File\"\n # description is now a dynamic property - see get_tool_description()\n _base_description = \"Loads and returns the content from uploaded files.\"\n documentation: str = \"https://docs.langflow.org/read-file\"\n icon = \"file-text\"\n name = \"File\"\n add_tool_output = True # Enable tool mode toggle without requiring tool_mode inputs\n\n # Extensions that can be processed without Docling (using standard text parsing)\n TEXT_EXTENSIONS = TEXT_FILE_TYPES\n\n # Extensions that require Docling for processing (images, advanced office formats, etc.)\n DOCLING_ONLY_EXTENSIONS = [\n \"adoc\",\n \"asciidoc\",\n \"asc\",\n \"bmp\",\n \"dotx\",\n \"dotm\",\n \"docm\",\n \"jpg\",\n \"jpeg\",\n \"png\",\n \"potx\",\n \"ppsx\",\n \"pptm\",\n \"potm\",\n \"ppsm\",\n \"pptx\",\n \"tiff\",\n \"xls\",\n \"xlsx\",\n \"xhtml\",\n \"webp\",\n ]\n\n # Docling-supported/compatible extensions; TEXT_FILE_TYPES are supported by the base loader.\n VALID_EXTENSIONS = [\n *TEXT_EXTENSIONS,\n *DOCLING_ONLY_EXTENSIONS,\n ]\n\n # Fixed export settings used when markdown export is requested.\n EXPORT_FORMAT = \"Markdown\"\n IMAGE_MODE = \"placeholder\"\n\n _base_inputs = deepcopy(BaseFileComponent.get_base_inputs())\n\n for input_item in _base_inputs:\n if isinstance(input_item, FileInput) and input_item.name == \"path\":\n input_item.real_time_refresh = True\n input_item.tool_mode = False # Disable tool mode for file upload input\n input_item.required = False # Make it optional so it doesn't error in tool mode\n break\n\n inputs = [\n SortableListInput(\n name=\"storage_location\",\n display_name=\"Storage Location\",\n placeholder=\"Select Location\",\n info=\"Choose where to read the file from.\",\n options=_get_storage_location_options(),\n real_time_refresh=True,\n limit=1,\n value=[{\"name\": \"Local\", \"icon\": \"hard-drive\"}],\n advanced=True,\n ),\n *_base_inputs,\n StrInput(\n name=\"file_path_str\",\n display_name=\"File Path\",\n info=(\n \"Path to the file to read. Used when component is called as a tool. \"\n \"If not provided, will use the uploaded file from 'path' input.\"\n ),\n show=False,\n advanced=True,\n tool_mode=True, # Required for Toolset toggle, but _get_tools() ignores this parameter\n required=False,\n ),\n # AWS S3 specific inputs\n SecretStrInput(\n name=\"aws_access_key_id\",\n display_name=\"AWS Access Key ID\",\n info=\"AWS Access key ID.\",\n show=False,\n advanced=False,\n required=True,\n ),\n SecretStrInput(\n name=\"aws_secret_access_key\",\n display_name=\"AWS Secret Key\",\n info=\"AWS Secret Key.\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"bucket_name\",\n display_name=\"S3 Bucket Name\",\n info=\"Enter the name of the S3 bucket.\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"aws_region\",\n display_name=\"AWS Region\",\n info=\"AWS region (e.g., us-east-1, eu-west-1).\",\n show=False,\n advanced=False,\n ),\n StrInput(\n name=\"s3_file_key\",\n display_name=\"S3 File Key\",\n info=\"The key (path) of the file in S3 bucket.\",\n show=False,\n advanced=False,\n required=True,\n ),\n # Google Drive specific inputs\n SecretStrInput(\n name=\"service_account_key\",\n display_name=\"GCP Credentials Secret Key\",\n info=\"Your Google Cloud Platform service account JSON key as a secret string (complete JSON content).\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"file_id\",\n display_name=\"Google Drive File ID\",\n info=(\"The Google Drive file ID to read. The file must be shared with the service account email.\"),\n show=False,\n advanced=False,\n required=True,\n ),\n BoolInput(\n name=\"advanced_mode\",\n display_name=\"Advanced Parser\",\n value=False,\n real_time_refresh=True,\n info=(\n \"Enable advanced document processing and export with Docling for PDFs, images, and office documents. \"\n \"Note that advanced document processing can consume significant resources.\"\n ),\n # Disabled in cloud\n show=not is_astra_cloud_environment(),\n ),\n DropdownInput(\n name=\"pipeline\",\n display_name=\"Pipeline\",\n info=\"Docling pipeline to use\",\n options=[\"standard\", \"vlm\"],\n value=\"standard\",\n advanced=True,\n real_time_refresh=True,\n ),\n DropdownInput(\n name=\"ocr_engine\",\n display_name=\"OCR Engine\",\n info=\"OCR engine to use. Only available when pipeline is set to 'standard'.\",\n options=[\"None\", \"easyocr\"],\n value=\"easyocr\",\n show=False,\n advanced=True,\n ),\n StrInput(\n name=\"md_image_placeholder\",\n display_name=\"Image placeholder\",\n info=\"Specify the image placeholder for markdown exports.\",\n value=\"<!-- image -->\",\n advanced=True,\n show=False,\n ),\n StrInput(\n name=\"md_page_break_placeholder\",\n display_name=\"Page break placeholder\",\n info=\"Add this placeholder between pages in the markdown output.\",\n value=\"\",\n advanced=True,\n show=False,\n ),\n MessageTextInput(\n name=\"doc_key\",\n display_name=\"Doc Key\",\n info=\"The key to use for the DoclingDocument column.\",\n value=\"doc\",\n advanced=True,\n show=False,\n ),\n # Deprecated input retained for backward-compatibility.\n BoolInput(\n name=\"use_multithreading\",\n display_name=\"[Deprecated] Use Multithreading\",\n advanced=True,\n value=True,\n info=\"Set 'Processing Concurrency' greater than 1 to enable multithreading.\",\n ),\n IntInput(\n name=\"concurrency_multithreading\",\n display_name=\"Processing Concurrency\",\n advanced=True,\n info=\"When multiple files are being processed, the number of files to process concurrently.\",\n value=1,\n ),\n BoolInput(\n name=\"markdown\",\n display_name=\"Markdown Export\",\n info=\"Export processed documents to Markdown format. Only available when advanced mode is enabled.\",\n value=False,\n show=False,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Raw Content\", name=\"message\", method=\"load_files_message\", tool_mode=True),\n ]\n\n # ------------------------------ Tool description with file names --------------\n\n def get_tool_description(self) -> str:\n \"\"\"Return a dynamic description that includes the names of uploaded files.\n\n This helps the Agent understand which files are available to read.\n \"\"\"\n base_description = type(self)._base_description # noqa: SLF001\n\n # Get the list of uploaded file paths\n file_paths = getattr(self, \"path\", None)\n if not file_paths:\n return base_description\n\n # Ensure it's a list\n if not isinstance(file_paths, list):\n file_paths = [file_paths]\n\n # Extract just the file names from the paths\n file_names = []\n for fp in file_paths:\n if fp:\n name = Path(fp).name\n file_names.append(name)\n\n if file_names:\n files_str = \", \".join(file_names)\n return f\"{base_description} Available files: {files_str}. Call this tool to read these files.\"\n\n return base_description\n\n @property\n def description(self) -> str:\n \"\"\"Dynamic description property that includes uploaded file names.\"\"\"\n return self.get_tool_description()\n\n async def _get_tools(self) -> list:\n \"\"\"Override to create a tool without parameters.\n\n The Read File component should use the files already uploaded via UI,\n not accept file paths from the Agent (which wouldn't know the internal paths).\n \"\"\"\n from langchain_core.tools import StructuredTool\n from pydantic import BaseModel\n\n # Empty schema - no parameters needed\n class EmptySchema(BaseModel):\n \"\"\"No parameters required - uses pre-uploaded files.\"\"\"\n\n async def read_files_tool() -> str:\n \"\"\"Read the content of uploaded files.\"\"\"\n cancel_event = threading.Event()\n cancel_token = _FILE_TOOL_CANCEL_EVENT.set(cancel_event)\n try:\n if getattr(self, \"advanced_mode\", False):\n # In advanced mode, use the markdown output path so that the\n # tool shares the same Docling processing as the advanced\n # outputs rather than triggering a second subprocess via\n # load_files_message.\n self.markdown = True\n loader = self.load_files_markdown\n else:\n loader = self.load_files_message\n # Both loaders are blocking (file IO plus, in advanced mode, a Docling\n # subprocess). Run them off the event loop so streaming/heartbeats on\n # the same loop keep flowing while the agent waits for this tool. Keep\n # admission bounded until the real worker exits so cancelled standard\n # parsers cannot build an unbounded default-executor backlog.\n load_limiter = _get_file_tool_limiter()\n await load_limiter.acquire()\n try:\n loader_task = asyncio.create_task(asyncio.to_thread(loader))\n except BaseException:\n load_limiter.release()\n raise\n loader_task.add_done_callback(lambda _task: load_limiter.release())\n try:\n result = await asyncio.shield(loader_task)\n except asyncio.CancelledError:\n cancel_event.set()\n # Give cooperative cleanup time to kill/reap Docling and remove\n # temporary files before releasing this tool invocation.\n try:\n await asyncio.wait_for(\n asyncio.shield(loader_task),\n timeout=_FILE_TOOL_CANCEL_WAIT_SECONDS,\n )\n except _FileToolCancelledError:\n pass\n except asyncio.TimeoutError:\n pass\n except Exception: # noqa: BLE001 - cancellation stays dominant over loader cleanup\n logger.exception(\"File loader failed while cleaning up a cancelled tool call\")\n finally:\n if not loader_task.done():\n loader_task.add_done_callback(_log_abandoned_file_tool_result)\n raise\n if hasattr(result, \"get_text\"):\n return result.get_text()\n if hasattr(result, \"text\"):\n return result.text\n return str(result)\n except (FileNotFoundError, ValueError, OSError, RuntimeError) as e:\n return f\"Error reading files: {e}\"\n finally:\n _FILE_TOOL_CANCEL_EVENT.reset(cancel_token)\n\n description = self.get_tool_description()\n\n tool = StructuredTool(\n name=\"load_files_message\",\n description=description,\n coroutine=read_files_tool,\n args_schema=EmptySchema,\n handle_tool_error=True,\n tags=[\"load_files_message\"],\n metadata={\n \"display_name\": \"Read File\",\n \"display_description\": description,\n },\n )\n\n return [tool]\n\n # ------------------------------ UI helpers --------------------------------------\n\n def _path_value(self, template: dict) -> list[str]:\n \"\"\"Return the list of currently selected file paths from the template.\"\"\"\n return template.get(\"path\", {}).get(\"file_path\", [])\n\n def _disable_docling_fields_in_cloud(self, build_config: dict[str, Any]) -> None:\n \"\"\"Disable all Docling-related fields in cloud environments.\"\"\"\n if \"advanced_mode\" in build_config:\n build_config[\"advanced_mode\"][\"show\"] = False\n build_config[\"advanced_mode\"][\"value\"] = False\n # Hide all Docling-related fields\n docling_fields = (\"pipeline\", \"ocr_engine\", \"doc_key\", \"md_image_placeholder\", \"md_page_break_placeholder\")\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = False\n # Also disable OCR engine specifically\n if \"ocr_engine\" in build_config:\n build_config[\"ocr_engine\"][\"value\"] = \"None\"\n\n def update_build_config(\n self,\n build_config: dict[str, Any],\n field_value: Any,\n field_name: str | None = None,\n ) -> dict[str, Any]:\n \"\"\"Show/hide Advanced Parser and related fields based on selection context.\"\"\"\n # Update storage location options dynamically based on cloud environment\n if \"storage_location\" in build_config:\n updated_options = _get_storage_location_options()\n build_config[\"storage_location\"][\"options\"] = updated_options\n\n # Handle storage location selection\n if field_name == \"storage_location\":\n # Extract selected storage location\n selected = [location[\"name\"] for location in field_value] if isinstance(field_value, list) else []\n\n # Hide all storage-specific fields first\n storage_fields = [\n \"aws_access_key_id\",\n \"aws_secret_access_key\",\n \"bucket_name\",\n \"aws_region\",\n \"s3_file_key\",\n \"service_account_key\",\n \"file_id\",\n ]\n\n for f_name in storage_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = False\n\n # Show fields based on selected storage location\n if len(selected) == 1:\n location = selected[0]\n\n if location == \"Local\":\n # Show file upload input for local storage\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = True\n\n elif location == \"AWS\":\n # Hide file upload input, show AWS fields\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = False\n\n aws_fields = [\n \"aws_access_key_id\",\n \"aws_secret_access_key\",\n \"bucket_name\",\n \"aws_region\",\n \"s3_file_key\",\n ]\n for f_name in aws_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = True\n build_config[f_name][\"advanced\"] = False\n\n elif location == \"Google Drive\":\n # Hide file upload input, show Google Drive fields\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = False\n\n gdrive_fields = [\"service_account_key\", \"file_id\"]\n for f_name in gdrive_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = True\n build_config[f_name][\"advanced\"] = False\n # No storage location selected - show file upload by default\n elif \"path\" in build_config:\n build_config[\"path\"][\"show\"] = True\n\n return build_config\n\n if field_name == \"path\":\n paths = self._path_value(build_config)\n\n # Disable in cloud environments\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n else:\n # If all files can be processed by docling, do so\n allow_advanced = all(not file_path.endswith((\".csv\", \".xlsx\", \".parquet\")) for file_path in paths)\n build_config[\"advanced_mode\"][\"show\"] = allow_advanced\n if not allow_advanced:\n build_config[\"advanced_mode\"][\"value\"] = False\n docling_fields = (\n \"pipeline\",\n \"ocr_engine\",\n \"doc_key\",\n \"md_image_placeholder\",\n \"md_page_break_placeholder\",\n )\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = False\n\n # Docling Processing\n elif field_name == \"advanced_mode\":\n # Disable in cloud environments - don't show Docling fields even if advanced_mode is toggled\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n else:\n docling_fields = (\n \"pipeline\",\n \"ocr_engine\",\n \"doc_key\",\n \"md_image_placeholder\",\n \"md_page_break_placeholder\",\n )\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = bool(field_value)\n if field == \"pipeline\":\n build_config[field][\"advanced\"] = not bool(field_value)\n\n elif field_name == \"pipeline\":\n # Disable in cloud environments - don't show OCR engine even if pipeline is changed\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n elif field_value == \"standard\":\n build_config[\"ocr_engine\"][\"show\"] = True\n build_config[\"ocr_engine\"][\"value\"] = \"easyocr\"\n else:\n build_config[\"ocr_engine\"][\"show\"] = False\n build_config[\"ocr_engine\"][\"value\"] = \"None\"\n\n return build_config\n\n def update_outputs(self, frontend_node: dict[str, Any], field_name: str, field_value: Any) -> dict[str, Any]: # noqa: ARG002\n \"\"\"Dynamically show outputs based on file count/type and advanced mode.\"\"\"\n if field_name not in [\"path\", \"advanced_mode\", \"pipeline\"]:\n return frontend_node\n\n template = frontend_node.get(\"template\", {})\n paths = self._path_value(template)\n if not paths:\n return frontend_node\n\n frontend_node[\"outputs\"] = []\n if len(paths) == 1:\n file_path = paths[0] if field_name == \"path\" else frontend_node[\"template\"][\"path\"][\"file_path\"][0]\n if file_path.endswith((\".csv\", \".xlsx\", \".parquet\")):\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Structured Content\",\n name=\"dataframe\",\n method=\"load_files_structured\",\n tool_mode=True,\n ),\n )\n elif file_path.endswith(\".json\"):\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Structured Content\", name=\"json\", method=\"load_files_json\", tool_mode=True),\n )\n\n advanced_mode = frontend_node.get(\"template\", {}).get(\"advanced_mode\", {}).get(\"value\", False)\n if advanced_mode:\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Structured Output\",\n name=\"advanced_dataframe\",\n method=\"load_files_dataframe\",\n tool_mode=True,\n ),\n )\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Markdown\", name=\"advanced_markdown\", method=\"load_files_markdown\", tool_mode=True\n ),\n )\n frontend_node[\"outputs\"].append(\n Output(display_name=\"File Path\", name=\"path\", method=\"load_files_path\", tool_mode=True),\n )\n else:\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Raw Content\", name=\"message\", method=\"load_files_message\", tool_mode=True),\n )\n frontend_node[\"outputs\"].append(\n Output(display_name=\"File Path\", name=\"path\", method=\"load_files_path\", tool_mode=True),\n )\n else:\n # Multiple files => DataFrame output; advanced parser disabled\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Files\", name=\"dataframe\", method=\"load_files\", tool_mode=True)\n )\n\n return frontend_node\n\n # ------------------------------ Core processing ----------------------------------\n\n def _get_selected_storage_location(self) -> str:\n \"\"\"Get the selected storage location from the SortableListInput.\"\"\"\n if hasattr(self, \"storage_location\") and self.storage_location:\n if isinstance(self.storage_location, list) and len(self.storage_location) > 0:\n return self.storage_location[0].get(\"name\", \"\")\n if isinstance(self.storage_location, dict):\n return self.storage_location.get(\"name\", \"\")\n return \"Local\" # Default to Local if not specified\n\n def _validate_and_resolve_paths(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Override to handle file_path_str input from tool mode and cloud storage.\n\n Priority:\n 1. Cloud storage (AWS/Google Drive) if selected\n 2. file_path_str (if provided by the tool call)\n 3. path (uploaded file from UI)\n \"\"\"\n storage_location = self._get_selected_storage_location()\n\n # Handle AWS S3\n if storage_location == \"AWS\":\n return self._read_from_aws_s3()\n\n # Handle Google Drive\n if storage_location == \"Google Drive\":\n return self._read_from_google_drive()\n\n # Handle Local storage\n # Check if file_path_str is provided (from tool mode)\n file_path_str = getattr(self, \"file_path_str\", None)\n if file_path_str:\n # Use the string path from tool mode\n from pathlib import Path\n\n from lfx.schema.data import Data\n\n # Use same resolution logic as BaseFileComponent (support storage paths)\n path_str = str(file_path_str)\n if parse_storage_path(path_str):\n try:\n resolved_path = Path(self.get_full_path(path_str))\n except (ValueError, AttributeError):\n resolved_path = Path(self.resolve_path(path_str))\n else:\n resolved_path = Path(self.resolve_path(path_str))\n\n # Security: confine tool-mode reads to the storage dir in restricted (multi-tenant)\n # mode so a tenant cannot read arbitrary server files via file_path_str.\n from lfx.utils.file_path_security import component_file_access_scopes, enforce_local_file_access\n\n resolved_path = enforce_local_file_access(resolved_path, scope_ids=component_file_access_scopes(self))\n\n if not resolved_path.exists():\n msg = f\"File or directory not found: {file_path_str}\"\n self.log(msg)\n if not self.silent_errors:\n raise ValueError(msg)\n return []\n\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(resolved_path)})\n return [BaseFileComponent.BaseFile(data_obj, resolved_path, delete_after_processing=False)]\n\n # Otherwise use the default implementation (uses path FileInput)\n return super()._validate_and_resolve_paths()\n\n def _read_from_aws_s3(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Read file from AWS S3.\"\"\"\n from lfx.base.data.cloud_storage_utils import create_s3_client, validate_aws_credentials\n\n # Validate AWS credentials\n validate_aws_credentials(self)\n if not getattr(self, \"s3_file_key\", None):\n msg = \"S3 File Key is required\"\n raise ValueError(msg)\n\n # Create S3 client\n s3_client = create_s3_client(self)\n\n # Download file to temp location\n import tempfile\n\n # Get file extension from S3 key\n file_extension = Path(self.s3_file_key).suffix or \"\"\n\n with tempfile.NamedTemporaryFile(mode=\"wb\", suffix=file_extension, delete=False) as temp_file:\n temp_file_path = temp_file.name\n try:\n s3_client.download_fileobj(self.bucket_name, self.s3_file_key, temp_file)\n except Exception as e:\n # Clean up temp file on failure\n with contextlib.suppress(OSError):\n Path(temp_file_path).unlink()\n msg = f\"Failed to download file from S3: {e}\"\n raise RuntimeError(msg) from e\n\n # Create BaseFile object\n from lfx.schema.data import Data\n\n temp_path = Path(temp_file_path)\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(temp_path)})\n return [BaseFileComponent.BaseFile(data_obj, temp_path, delete_after_processing=True)]\n\n def _read_from_google_drive(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Read file from Google Drive.\"\"\"\n import tempfile\n\n from googleapiclient.http import MediaIoBaseDownload\n\n from lfx.base.data.cloud_storage_utils import create_google_drive_service\n\n # Validate Google Drive credentials\n if not getattr(self, \"service_account_key\", None):\n msg = \"GCP Credentials Secret Key is required for Google Drive storage\"\n raise ValueError(msg)\n if not getattr(self, \"file_id\", None):\n msg = \"Google Drive File ID is required\"\n raise ValueError(msg)\n\n # Create Google Drive service with read-only scope\n drive_service = create_google_drive_service(\n self.service_account_key, scopes=[\"https://www.googleapis.com/auth/drive.readonly\"]\n )\n\n # Get file metadata to determine file name and extension\n try:\n file_metadata = drive_service.files().get(fileId=self.file_id, fields=\"name,mimeType\").execute()\n file_name = file_metadata.get(\"name\", \"download\")\n except Exception as e:\n msg = (\n f\"Unable to access file with ID '{self.file_id}'. \"\n f\"Error: {e!s}. \"\n \"Please ensure: 1) The file ID is correct, 2) The file exists, \"\n \"3) The service account has been granted access to this file.\"\n )\n raise ValueError(msg) from e\n\n # Download file to temp location\n file_extension = Path(file_name).suffix or \"\"\n with tempfile.NamedTemporaryFile(mode=\"wb\", suffix=file_extension, delete=False) as temp_file:\n temp_file_path = temp_file.name\n try:\n request = drive_service.files().get_media(fileId=self.file_id)\n downloader = MediaIoBaseDownload(temp_file, request)\n done = False\n while not done:\n _status, done = downloader.next_chunk()\n except Exception as e:\n # Clean up temp file on failure\n with contextlib.suppress(OSError):\n Path(temp_file_path).unlink()\n msg = f\"Failed to download file from Google Drive: {e}\"\n raise RuntimeError(msg) from e\n\n # Create BaseFile object\n from lfx.schema.data import Data\n\n temp_path = Path(temp_file_path)\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(temp_path)})\n return [BaseFileComponent.BaseFile(data_obj, temp_path, delete_after_processing=True)]\n\n def _is_docling_compatible(self, file_path: str) -> bool:\n \"\"\"Lightweight extension gate for Docling-compatible types.\"\"\"\n docling_exts = (\n \".adoc\",\n \".asciidoc\",\n \".asc\",\n \".bmp\",\n \".csv\",\n \".dotx\",\n \".dotm\",\n \".docm\",\n \".docx\",\n \".htm\",\n \".html\",\n \".jpg\",\n \".jpeg\",\n \".json\",\n \".md\",\n \".pdf\",\n \".png\",\n \".potx\",\n \".ppsx\",\n \".pptm\",\n \".potm\",\n \".ppsm\",\n \".pptx\",\n \".tiff\",\n \".txt\",\n \".xls\",\n \".xlsx\",\n \".xhtml\",\n \".xml\",\n \".webp\",\n )\n return file_path.lower().endswith(docling_exts)\n\n async def _get_local_file_for_docling(self, file_path: str) -> tuple[str, bool]:\n \"\"\"Get a local file path for Docling processing, downloading from S3 if needed.\n\n Args:\n file_path: Either a local path or S3 key (format \"flow_id/filename\")\n\n Returns:\n tuple[str, bool]: (local_path, should_delete) where should_delete indicates\n if this is a temporary file that should be cleaned up\n \"\"\"\n settings = get_settings_service().settings\n if settings.storage_type == \"local\":\n return file_path, False\n\n # S3 storage - download to temp file\n parsed = parse_storage_path(file_path)\n if not parsed:\n msg = f\"Invalid S3 path format: {file_path}. Expected 'flow_id/filename'\"\n raise ValueError(msg)\n\n storage_service = get_storage_service()\n flow_id, filename = parsed\n\n # Get file content from S3\n content = await storage_service.get_file(flow_id, filename)\n\n suffix = Path(filename).suffix\n with NamedTemporaryFile(mode=\"wb\", suffix=suffix, delete=False) as tmp_file:\n tmp_file.write(content)\n temp_path = tmp_file.name\n\n return temp_path, True\n\n def _process_docling_in_subprocess(self, file_path: str) -> Data | None:\n \"\"\"Run Docling in a separate OS process and map the result to a Data object.\n\n We avoid multiprocessing pickling by launching `python -c \"<script>\"` and\n passing JSON config via stdin. The child prints a JSON result to stdout.\n\n For S3 storage, the file is downloaded to a temp file first.\n \"\"\"\n if not file_path:\n return None\n\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n _raise_if_file_tool_cancelled(cancel_event)\n settings = get_settings_service().settings\n if settings.storage_type == \"s3\":\n local_path, should_delete = run_until_complete(self._get_local_file_for_docling(file_path))\n else:\n local_path = file_path\n should_delete = False\n\n try:\n _raise_if_file_tool_cancelled(cancel_event)\n return self._process_docling_subprocess_impl(local_path, file_path)\n finally:\n # Clean up temp file if we created one\n if should_delete:\n with contextlib.suppress(Exception):\n Path(local_path).unlink() # Ignore cleanup errors\n\n def _process_docling_subprocess_impl(self, local_file_path: str, original_file_path: str) -> Data | None:\n \"\"\"Implementation of Docling subprocess processing.\n\n Args:\n local_file_path: Path to local file to process\n original_file_path: Original file path to include in metadata\n Returns:\n Data object with processed content\n \"\"\"\n args: dict[str, Any] = {\n \"file_path\": local_file_path,\n \"markdown\": bool(self.markdown),\n \"image_mode\": str(self.IMAGE_MODE),\n \"md_image_placeholder\": str(self.md_image_placeholder),\n \"md_page_break_placeholder\": str(self.md_page_break_placeholder),\n \"pipeline\": str(self.pipeline),\n \"ocr_engine\": (\n self.ocr_engine if self.ocr_engine and self.ocr_engine != \"None\" and self.pipeline != \"vlm\" else None\n ),\n }\n\n # Child script for isolating the docling processing\n child_script = textwrap.dedent(\n r\"\"\"\n import json, sys\n\n def try_imports():\n try:\n from docling.datamodel.base_models import ConversionStatus, InputFormat # type: ignore\n from docling.document_converter import DocumentConverter # type: ignore\n from docling_core.types.doc import ImageRefMode # type: ignore\n return ConversionStatus, InputFormat, DocumentConverter, ImageRefMode, \"latest\"\n except Exception as e:\n raise e\n\n def create_converter(strategy, input_format, DocumentConverter, pipeline, ocr_engine):\n # --- Standard PDF/IMAGE pipeline (your existing behavior), with optional OCR ---\n if pipeline == \"standard\":\n try:\n from docling.datamodel.pipeline_options import PdfPipelineOptions # type: ignore\n from docling.document_converter import PdfFormatOption # type: ignore\n\n pipe = PdfPipelineOptions()\n pipe.do_ocr = False\n\n if ocr_engine:\n try:\n from docling.models.factories import get_ocr_factory # type: ignore\n pipe.do_ocr = True\n fac = get_ocr_factory(allow_external_plugins=False)\n pipe.ocr_options = fac.create_options(kind=ocr_engine)\n except Exception:\n # If OCR setup fails, disable it\n pipe.do_ocr = False\n\n fmt = {}\n if hasattr(input_format, \"PDF\"):\n fmt[getattr(input_format, \"PDF\")] = PdfFormatOption(pipeline_options=pipe)\n if hasattr(input_format, \"IMAGE\"):\n fmt[getattr(input_format, \"IMAGE\")] = PdfFormatOption(pipeline_options=pipe)\n\n return DocumentConverter(format_options=fmt)\n except Exception:\n return DocumentConverter()\n\n # --- Vision-Language Model (VLM) pipeline ---\n if pipeline == \"vlm\":\n try:\n from docling.datamodel.pipeline_options import VlmPipelineOptions\n from docling.datamodel.vlm_model_specs import GRANITEDOCLING_MLX, GRANITEDOCLING_TRANSFORMERS\n from docling.document_converter import PdfFormatOption\n from docling.pipeline.vlm_pipeline import VlmPipeline\n\n vl_pipe = VlmPipelineOptions(\n vlm_options=GRANITEDOCLING_TRANSFORMERS,\n )\n\n if sys.platform == \"darwin\":\n try:\n import mlx_vlm\n vl_pipe.vlm_options = GRANITEDOCLING_MLX\n except ImportError as e:\n raise e\n\n # VLM paths generally don't need OCR; keep OCR off by default here.\n fmt = {}\n if hasattr(input_format, \"PDF\"):\n fmt[getattr(input_format, \"PDF\")] = PdfFormatOption(\n pipeline_cls=VlmPipeline,\n pipeline_options=vl_pipe\n )\n if hasattr(input_format, \"IMAGE\"):\n fmt[getattr(input_format, \"IMAGE\")] = PdfFormatOption(\n pipeline_cls=VlmPipeline,\n pipeline_options=vl_pipe\n )\n\n return DocumentConverter(format_options=fmt)\n except Exception as e:\n raise e\n\n # --- Fallback: default converter with no special options ---\n return DocumentConverter()\n\n def export_markdown(document, ImageRefMode, image_mode, img_ph, pg_ph):\n try:\n mode = getattr(ImageRefMode, image_mode.upper(), image_mode)\n return document.export_to_markdown(\n image_mode=mode,\n image_placeholder=img_ph,\n page_break_placeholder=pg_ph,\n )\n except Exception:\n try:\n return document.export_to_text()\n except Exception:\n return str(document)\n\n def to_rows(doc_dict):\n rows = []\n for t in doc_dict.get(\"texts\", []):\n prov = t.get(\"prov\") or []\n page_no = None\n if prov and isinstance(prov, list) and isinstance(prov[0], dict):\n page_no = prov[0].get(\"page_no\")\n rows.append({\n \"page_no\": page_no,\n \"label\": t.get(\"label\"),\n \"text\": t.get(\"text\"),\n \"level\": t.get(\"level\"),\n })\n return rows\n\n def main():\n cfg = json.loads(sys.stdin.read())\n file_path = cfg[\"file_path\"]\n markdown = cfg[\"markdown\"]\n image_mode = cfg[\"image_mode\"]\n img_ph = cfg[\"md_image_placeholder\"]\n pg_ph = cfg[\"md_page_break_placeholder\"]\n pipeline = cfg[\"pipeline\"]\n ocr_engine = cfg.get(\"ocr_engine\")\n meta = {\"file_path\": file_path}\n\n try:\n ConversionStatus, InputFormat, DocumentConverter, ImageRefMode, strategy = try_imports()\n converter = create_converter(strategy, InputFormat, DocumentConverter, pipeline, ocr_engine)\n try:\n res = converter.convert(file_path)\n except Exception as e:\n print(json.dumps({\"ok\": False, \"error\": f\"Docling conversion error: {e}\", \"meta\": meta}))\n return\n\n ok = False\n if hasattr(res, \"status\"):\n try:\n ok = (res.status == ConversionStatus.SUCCESS) or (str(res.status).lower() == \"success\")\n except Exception:\n ok = (str(res.status).lower() == \"success\")\n if not ok and hasattr(res, \"document\"):\n ok = getattr(res, \"document\", None) is not None\n if not ok:\n print(json.dumps({\"ok\": False, \"error\": \"Docling conversion failed\", \"meta\": meta}))\n return\n\n doc = getattr(res, \"document\", None)\n if doc is None:\n print(json.dumps({\"ok\": False, \"error\": \"Docling produced no document\", \"meta\": meta}))\n return\n\n # Extract DoclingDocument metadata\n if hasattr(doc, \"name\") and doc.name:\n meta[\"name\"] = doc.name\n if hasattr(doc, \"origin\") and doc.origin is not None:\n origin = doc.origin\n if hasattr(origin, \"filename\") and origin.filename:\n meta[\"filename\"] = origin.filename\n if hasattr(origin, \"binary_hash\") and origin.binary_hash:\n meta[\"document_id\"] = str(origin.binary_hash)\n if hasattr(origin, \"mimetype\") and origin.mimetype:\n meta[\"mimetype\"] = origin.mimetype\n\n if markdown:\n text = export_markdown(doc, ImageRefMode, image_mode, img_ph, pg_ph)\n print(json.dumps({\"ok\": True, \"mode\": \"markdown\", \"text\": text, \"meta\": meta}))\n return\n\n # structured\n try:\n doc_dict = doc.export_to_dict()\n except Exception as e:\n print(json.dumps({\"ok\": False, \"error\": f\"Docling export_to_dict failed: {e}\", \"meta\": meta}))\n return\n\n rows = to_rows(doc_dict)\n print(json.dumps({\"ok\": True, \"mode\": \"structured\", \"doc\": rows, \"meta\": meta}))\n except Exception as e:\n print(\n json.dumps({\n \"ok\": False,\n \"error\": f\"Docling processing error: {e}\",\n \"meta\": {\"file_path\": file_path},\n })\n )\n\n if __name__ == \"__main__\":\n main()\n \"\"\"\n )\n\n # The path is passed as JSON over stdin to an argument-list subprocess, so shell\n # metacharacters are ordinary filename characters here.\n if not isinstance(args[\"file_path\"], str):\n return Data(data={\"error\": \"Unsafe file path detected.\", \"file_path\": args[\"file_path\"]})\n\n # Use communicate() in bounded intervals so stdout/stderr are drained while the\n # child runs without losing the heartbeat that keeps the SSE event stream alive.\n docling_timeout = 600 # 10 minutes; large PDFs with OCR may need this\n poll_interval = 5 # seconds between progress heartbeats\n\n proc = subprocess.Popen( # noqa: S603\n [sys.executable, \"-u\", \"-c\", child_script],\n stdin=subprocess.PIPE,\n stdout=subprocess.PIPE,\n stderr=subprocess.PIPE,\n )\n\n start = time.monotonic()\n input_bytes: bytes | None = json.dumps(args).encode(\"utf-8\")\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n try:\n while True:\n _raise_if_file_tool_cancelled(cancel_event)\n elapsed = time.monotonic() - start\n if elapsed >= docling_timeout:\n proc.kill()\n proc.communicate()\n return Data(\n data={\n \"error\": (\n f\"Docling processing timed out after {docling_timeout}s. \"\n \"Consider using the standalone Docling component for large documents.\"\n ),\n \"file_path\": original_file_path,\n },\n )\n try:\n stdout_bytes, stderr_bytes = proc.communicate(\n input=input_bytes,\n timeout=min(poll_interval, docling_timeout - elapsed),\n )\n _raise_if_file_tool_cancelled(cancel_event)\n break\n except subprocess.TimeoutExpired:\n # communicate() retains partially written input and collected output across\n # retries, so subsequent calls continue draining without resending stdin.\n input_bytes = None\n elapsed = time.monotonic() - start\n self.log(f\"Docling processing in progress ({int(elapsed)}s elapsed)...\")\n finally:\n if (cancel_event is not None and cancel_event.is_set()) or proc.poll() is None:\n _kill_and_reap_process(proc)\n\n if not stdout_bytes:\n err_msg = stderr_bytes.decode(\"utf-8\", errors=\"replace\") if stderr_bytes else \"no output from child process\"\n return Data(data={\"error\": f\"Docling subprocess error: {err_msg}\", \"file_path\": original_file_path})\n\n try:\n result = json.loads(stdout_bytes.decode(\"utf-8\"))\n except Exception as e: # noqa: BLE001\n err_msg = stderr_bytes.decode(\"utf-8\", errors=\"replace\")\n return Data(\n data={\n \"error\": f\"Invalid JSON from Docling subprocess: {e}. stderr={err_msg}\",\n \"file_path\": original_file_path,\n },\n )\n\n if not result.get(\"ok\"):\n error_msg = result.get(\"error\", \"Unknown Docling error\")\n # Override meta file_path with original_file_path to ensure correct path matching\n meta = result.get(\"meta\", {})\n meta[\"file_path\"] = original_file_path\n return Data(data={\"error\": error_msg, **meta})\n\n meta = result.get(\"meta\", {})\n # Override meta file_path with original_file_path to ensure correct path matching\n # The subprocess returns the temp file path, but we need the original S3/local path for rollup_data\n meta[\"file_path\"] = original_file_path\n if result.get(\"mode\") == \"markdown\":\n exported_content = str(result.get(\"text\", \"\"))\n return Data(\n text=exported_content,\n data={\"exported_content\": exported_content, \"export_format\": self.EXPORT_FORMAT, **meta},\n )\n\n rows = list(result.get(\"doc\", []))\n return Data(data={\"doc\": rows, \"export_format\": self.EXPORT_FORMAT, **meta})\n\n def process_files(\n self,\n file_list: list[BaseFileComponent.BaseFile],\n ) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Process input files.\n\n - advanced_mode => Docling in a separate process.\n - Otherwise => standard parsing in current process (optionally threaded).\n \"\"\"\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n _raise_if_file_tool_cancelled(cancel_event)\n if not file_list:\n msg = \"No files to process.\"\n raise ValueError(msg)\n\n # Validate image files to detect content/extension mismatches\n # This prevents API errors like \"Image does not match the provided media type\"\n image_extensions = {\"jpeg\", \"jpg\", \"png\", \"gif\", \"webp\", \"bmp\", \"tiff\"}\n settings = get_settings_service().settings\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n extension = file.path.suffix[1:].lower()\n if extension in image_extensions:\n # Read bytes based on storage type\n try:\n if settings.storage_type == \"s3\":\n # For S3 storage, use storage service to read file bytes\n file_path_str = str(file.path)\n content = run_until_complete(read_file_bytes(file_path_str))\n else:\n # For local storage, read bytes directly from filesystem\n content = file.path.read_bytes()\n\n is_valid, error_msg = validate_image_content_type(\n str(file.path),\n content=content,\n )\n if not is_valid:\n self.log(error_msg)\n if not self.silent_errors:\n raise ValueError(error_msg)\n except (OSError, FileNotFoundError) as e:\n self.log(f\"Could not read file for validation: {e}\")\n # Continue - let it fail later with better error\n\n # Validate that files requiring Docling are only processed when advanced mode is enabled\n if not self.advanced_mode:\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n extension = file.path.suffix[1:].lower()\n if extension in self.DOCLING_ONLY_EXTENSIONS:\n if is_astra_cloud_environment():\n msg = (\n f\"File '{file.path.name}' has extension '.{extension}' which requires \"\n f\"Advanced Parser mode. Advanced Parser is not available in cloud environments.\"\n )\n else:\n msg = (\n f\"File '{file.path.name}' has extension '.{extension}' which requires \"\n f\"Advanced Parser mode. Please enable 'Advanced Parser' to process this file.\"\n )\n self.log(msg)\n raise ValueError(msg)\n\n def process_file_standard(file_path: str, *, silent_errors: bool = False) -> Data | None:\n try:\n _raise_if_file_tool_cancelled(cancel_event)\n result = parse_text_file_to_data(file_path, silent_errors=silent_errors)\n _raise_if_file_tool_cancelled(cancel_event)\n except _FileToolCancelledError:\n raise\n except FileNotFoundError as e:\n self.log(f\"File not found: {file_path}. Error: {e}\")\n if not silent_errors:\n raise\n return None\n except Exception as e:\n self.log(f\"Unexpected error processing {file_path}: {e}\")\n if not silent_errors:\n raise\n return None\n else:\n return result\n\n docling_compatible = all(self._is_docling_compatible(str(f.path)) for f in file_list)\n\n # Advanced path: Check if ALL files are compatible with Docling\n if self.advanced_mode and docling_compatible:\n final_return: list[BaseFileComponent.BaseFile] = []\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n file_path = str(file.path)\n advanced_data: Data | None = self._process_docling_in_subprocess(file_path)\n _raise_if_file_tool_cancelled(cancel_event)\n\n # Handle None case - Docling processing failed or returned None\n if advanced_data is None:\n error_data = Data(\n data={\n \"file_path\": file_path,\n \"error\": \"Docling processing returned no result. Check logs for details.\",\n },\n )\n final_return.extend(self.rollup_data([file], [error_data]))\n continue\n\n # --- UNNEST: expand each element in `doc` to its own Data row\n payload = getattr(advanced_data, \"data\", {}) or {}\n\n # Check for errors first\n if \"error\" in payload:\n error_msg = payload.get(\"error\", \"Unknown error\")\n error_data = Data(\n data={\n \"file_path\": file_path,\n \"error\": error_msg,\n **{k: v for k, v in payload.items() if k not in (\"error\", \"file_path\")},\n },\n )\n final_return.extend(self.rollup_data([file], [error_data]))\n continue\n\n doc_rows = payload.get(\"doc\")\n if isinstance(doc_rows, list) and doc_rows:\n # Non-empty list of structured rows\n rows: list[Data | None] = [\n Data(\n data={\n \"file_path\": file_path,\n **(item if isinstance(item, dict) else {\"value\": item}),\n },\n )\n for item in doc_rows\n ]\n final_return.extend(self.rollup_data([file], rows))\n elif isinstance(doc_rows, list) and not doc_rows:\n # Empty list - file was processed but no text content found\n # Create a Data object indicating no content was extracted\n self.log(f\"No text extracted from '{file_path}', creating placeholder data\")\n empty_data = Data(\n data={\n \"file_path\": file_path,\n \"text\": \"(No text content extracted from image)\",\n \"info\": \"Image processed successfully but contained no extractable text\",\n **{k: v for k, v in payload.items() if k != \"doc\"},\n },\n )\n final_return.extend(self.rollup_data([file], [empty_data]))\n else:\n # If not structured, keep as-is (e.g., markdown export or error dict)\n # Ensure file_path is set for proper rollup matching\n if not payload.get(\"file_path\"):\n payload[\"file_path\"] = file_path\n # Create new Data with file_path\n advanced_data = Data(\n data=payload,\n text=getattr(advanced_data, \"text\", None),\n )\n final_return.extend(self.rollup_data([file], [advanced_data]))\n return final_return\n\n # Standard multi-file (or single non-advanced) path\n concurrency = max(1, self.concurrency_multithreading)\n\n file_paths = [str(f.path) for f in file_list]\n self.log(f\"Starting parallel processing of {len(file_paths)} files with concurrency: {concurrency}.\")\n my_data = parallel_load_data(\n file_paths,\n silent_errors=self.silent_errors,\n load_function=process_file_standard,\n max_concurrency=concurrency,\n )\n _raise_if_file_tool_cancelled(cancel_event)\n return self.rollup_data(file_list, my_data)\n\n # ------------------------------ Output helpers -----------------------------------\n\n def load_files_helper(self) -> DataFrame:\n result = self.load_files()\n\n # Result is a DataFrame - check if it has any rows\n if result.empty:\n msg = \"Could not extract content from the provided file(s).\"\n raise ValueError(msg)\n\n # Check for error column with error messages\n if \"error\" in result.columns:\n errors = result[\"error\"].dropna().tolist()\n if errors and not any(col in result.columns for col in [\"text\", \"doc\", \"exported_content\"]):\n raise ValueError(errors[0])\n\n return result\n\n def load_files_dataframe(self) -> DataFrame:\n \"\"\"Load files using advanced Docling processing and export to DataFrame format.\"\"\"\n self.markdown = False\n return self.load_files_helper()\n\n def load_files_markdown(self) -> Message:\n \"\"\"Load files using advanced Docling processing and export to Markdown format.\"\"\"\n self.markdown = True\n result = self.load_files_helper()\n\n # Result is a DataFrame - check for text or exported_content columns\n if \"text\" in result.columns and not result[\"text\"].isna().all():\n text_values = result[\"text\"].dropna().tolist()\n if text_values:\n return Message(text=str(text_values[0]))\n\n if \"exported_content\" in result.columns and not result[\"exported_content\"].isna().all():\n content_values = result[\"exported_content\"].dropna().tolist()\n if content_values:\n return Message(text=str(content_values[0]))\n\n # Return empty message with info that no text was found\n return Message(text=\"(No text content extracted from file)\")\n"
"value": "\"\"\"Enhanced file component with Docling support and process isolation.\n\nNotes:\n-----\n- ALL Docling parsing/export runs in a separate OS process to prevent memory\n growth and native library state from impacting the main Langflow process.\n- Standard text/structured parsing continues to use existing BaseFileComponent\n utilities (and optional threading via `parallel_load_data`).\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport json\nimport subprocess\nimport sys\nimport textwrap\nimport threading\nimport time\nfrom contextvars import ContextVar\nfrom copy import deepcopy\nfrom pathlib import Path\nfrom tempfile import NamedTemporaryFile\nfrom typing import Any\n\nfrom lfx.base.data.base_file import BaseFileComponent\nfrom lfx.base.data.storage_utils import (\n is_remote_storage_type,\n parse_storage_path,\n read_file_bytes,\n validate_image_content_type,\n)\nfrom lfx.base.data.utils import TEXT_FILE_TYPES, parallel_load_data, parse_text_file_to_data\nfrom lfx.inputs import SortableListInput\nfrom lfx.inputs.inputs import DropdownInput, MessageTextInput, StrInput\nfrom lfx.io import BoolInput, FileInput, IntInput, Output, SecretStrInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame # noqa: TC001\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service, get_storage_service\nfrom lfx.utils.async_helpers import run_until_complete\nfrom lfx.utils.validate_cloud import is_astra_cloud_environment\n\n_FILE_TOOL_CANCEL_EVENT: ContextVar[threading.Event | None] = ContextVar(\"file_tool_cancel_event\", default=None)\n_FILE_TOOL_CANCEL_WAIT_SECONDS = 6\n_FILE_TOOL_MAX_CONCURRENT_LOADS = 4\n_FILE_TOOL_LIMITER_ATTRIBUTE = \"_lfx_file_tool_load_limiter\"\n_FILE_TOOL_PROCESS_REAP_SECONDS = 2\n\n\nclass _FileToolCancelledError(RuntimeError):\n \"\"\"Stop synchronous file loading after its async tool call is cancelled.\"\"\"\n\n\ndef _raise_if_file_tool_cancelled(cancel_event: threading.Event | None = None) -> None:\n event = cancel_event or _FILE_TOOL_CANCEL_EVENT.get()\n if event is not None and event.is_set():\n raise _FileToolCancelledError\n\n\ndef _kill_and_reap_process(proc: subprocess.Popen) -> None:\n try:\n proc.kill()\n except ProcessLookupError:\n pass\n except OSError:\n logger.exception(\"Failed to kill cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while killing cancelled Docling subprocess\")\n\n try:\n proc.communicate(timeout=_FILE_TOOL_PROCESS_REAP_SECONDS)\n except subprocess.TimeoutExpired:\n logger.warning(\"Timed out draining cancelled Docling subprocess; waiting for process exit\")\n except OSError:\n logger.exception(\"Failed to drain cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while draining cancelled Docling subprocess\")\n else:\n return\n\n try:\n proc.wait(timeout=_FILE_TOOL_PROCESS_REAP_SECONDS)\n except subprocess.TimeoutExpired:\n logger.exception(\"Cancelled Docling subprocess could not be reaped within the cleanup timeout\")\n except OSError:\n logger.exception(\"Failed to reap cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while reaping cancelled Docling subprocess\")\n\n\ndef _get_file_tool_limiter() -> asyncio.Semaphore:\n \"\"\"Return the bounded loader admission gate for the current event loop.\"\"\"\n loop = asyncio.get_running_loop()\n limiter = getattr(loop, _FILE_TOOL_LIMITER_ATTRIBUTE, None)\n if limiter is None:\n limiter = asyncio.Semaphore(_FILE_TOOL_MAX_CONCURRENT_LOADS)\n setattr(loop, _FILE_TOOL_LIMITER_ATTRIBUTE, limiter)\n return limiter\n\n\ndef _log_abandoned_file_tool_result(task: asyncio.Task) -> None:\n \"\"\"Observe unexpected failures from a synchronous loader that outlived its caller.\"\"\"\n try:\n error = task.exception()\n except asyncio.CancelledError:\n return\n if error is not None and not isinstance(error, _FileToolCancelledError):\n logger.error(\"Abandoned file loader failed after its tool call was cancelled\", exc_info=error)\n\n\ndef _get_storage_location_options():\n \"\"\"Get storage location options, filtering out Local if in Astra cloud environment.\"\"\"\n all_options = [{\"name\": \"AWS\", \"icon\": \"Amazon\"}, {\"name\": \"Google Drive\", \"icon\": \"google\"}]\n if is_astra_cloud_environment():\n return all_options\n return [{\"name\": \"Local\", \"icon\": \"hard-drive\"}, *all_options]\n\n\nclass FileComponent(BaseFileComponent):\n \"\"\"File component with optional Docling processing (isolated in a subprocess).\"\"\"\n\n display_name = \"Read File\"\n # description is now a dynamic property - see get_tool_description()\n _base_description = \"Loads and returns the content from uploaded files.\"\n documentation: str = \"https://docs.langflow.org/read-file\"\n icon = \"file-text\"\n name = \"File\"\n add_tool_output = True # Enable tool mode toggle without requiring tool_mode inputs\n\n # Extensions that can be processed without Docling (using standard text parsing)\n TEXT_EXTENSIONS = TEXT_FILE_TYPES\n\n # Extensions that require Docling for processing (images, advanced office formats, etc.)\n DOCLING_ONLY_EXTENSIONS = [\n \"adoc\",\n \"asciidoc\",\n \"asc\",\n \"bmp\",\n \"dotx\",\n \"dotm\",\n \"docm\",\n \"jpg\",\n \"jpeg\",\n \"png\",\n \"potx\",\n \"ppsx\",\n \"pptm\",\n \"potm\",\n \"ppsm\",\n \"pptx\",\n \"tiff\",\n \"xls\",\n \"xlsx\",\n \"xhtml\",\n \"webp\",\n ]\n\n # Docling-supported/compatible extensions; TEXT_FILE_TYPES are supported by the base loader.\n VALID_EXTENSIONS = [\n *TEXT_EXTENSIONS,\n *DOCLING_ONLY_EXTENSIONS,\n ]\n\n # Fixed export settings used when markdown export is requested.\n EXPORT_FORMAT = \"Markdown\"\n IMAGE_MODE = \"placeholder\"\n\n _base_inputs = deepcopy(BaseFileComponent.get_base_inputs())\n\n for input_item in _base_inputs:\n if isinstance(input_item, FileInput) and input_item.name == \"path\":\n input_item.real_time_refresh = True\n input_item.tool_mode = False # Disable tool mode for file upload input\n input_item.required = False # Make it optional so it doesn't error in tool mode\n break\n\n inputs = [\n SortableListInput(\n name=\"storage_location\",\n display_name=\"Storage Location\",\n placeholder=\"Select Location\",\n info=\"Choose where to read the file from.\",\n options=_get_storage_location_options(),\n real_time_refresh=True,\n limit=1,\n value=[{\"name\": \"Local\", \"icon\": \"hard-drive\"}],\n advanced=True,\n ),\n *_base_inputs,\n StrInput(\n name=\"file_path_str\",\n display_name=\"File Path\",\n info=(\n \"Path to the file to read. Used when component is called as a tool. \"\n \"If not provided, will use the uploaded file from 'path' input.\"\n ),\n show=False,\n advanced=True,\n tool_mode=True, # Required for Toolset toggle, but _get_tools() ignores this parameter\n required=False,\n ),\n # AWS S3 specific inputs\n SecretStrInput(\n name=\"aws_access_key_id\",\n display_name=\"AWS Access Key ID\",\n info=\"AWS Access key ID.\",\n show=False,\n advanced=False,\n required=True,\n ),\n SecretStrInput(\n name=\"aws_secret_access_key\",\n display_name=\"AWS Secret Key\",\n info=\"AWS Secret Key.\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"bucket_name\",\n display_name=\"S3 Bucket Name\",\n info=\"Enter the name of the S3 bucket.\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"aws_region\",\n display_name=\"AWS Region\",\n info=\"AWS region (e.g., us-east-1, eu-west-1).\",\n show=False,\n advanced=False,\n ),\n StrInput(\n name=\"s3_file_key\",\n display_name=\"S3 File Key\",\n info=\"The key (path) of the file in S3 bucket.\",\n show=False,\n advanced=False,\n required=True,\n ),\n # Google Drive specific inputs\n SecretStrInput(\n name=\"service_account_key\",\n display_name=\"GCP Credentials Secret Key\",\n info=\"Your Google Cloud Platform service account JSON key as a secret string (complete JSON content).\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"file_id\",\n display_name=\"Google Drive File ID\",\n info=(\"The Google Drive file ID to read. The file must be shared with the service account email.\"),\n show=False,\n advanced=False,\n required=True,\n ),\n BoolInput(\n name=\"advanced_mode\",\n display_name=\"Advanced Parser\",\n value=False,\n real_time_refresh=True,\n info=(\n \"Enable advanced document processing and export with Docling for PDFs, images, and office documents. \"\n \"Note that advanced document processing can consume significant resources.\"\n ),\n # Disabled in cloud\n show=not is_astra_cloud_environment(),\n ),\n DropdownInput(\n name=\"pipeline\",\n display_name=\"Pipeline\",\n info=\"Docling pipeline to use\",\n options=[\"standard\", \"vlm\"],\n value=\"standard\",\n advanced=True,\n real_time_refresh=True,\n ),\n DropdownInput(\n name=\"ocr_engine\",\n display_name=\"OCR Engine\",\n info=\"OCR engine to use. Only available when pipeline is set to 'standard'.\",\n options=[\"None\", \"easyocr\"],\n value=\"easyocr\",\n show=False,\n advanced=True,\n ),\n StrInput(\n name=\"md_image_placeholder\",\n display_name=\"Image placeholder\",\n info=\"Specify the image placeholder for markdown exports.\",\n value=\"<!-- image -->\",\n advanced=True,\n show=False,\n ),\n StrInput(\n name=\"md_page_break_placeholder\",\n display_name=\"Page break placeholder\",\n info=\"Add this placeholder between pages in the markdown output.\",\n value=\"\",\n advanced=True,\n show=False,\n ),\n MessageTextInput(\n name=\"doc_key\",\n display_name=\"Doc Key\",\n info=\"The key to use for the DoclingDocument column.\",\n value=\"doc\",\n advanced=True,\n show=False,\n ),\n # Deprecated input retained for backward-compatibility.\n BoolInput(\n name=\"use_multithreading\",\n display_name=\"[Deprecated] Use Multithreading\",\n advanced=True,\n value=True,\n info=\"Set 'Processing Concurrency' greater than 1 to enable multithreading.\",\n ),\n IntInput(\n name=\"concurrency_multithreading\",\n display_name=\"Processing Concurrency\",\n advanced=True,\n info=\"When multiple files are being processed, the number of files to process concurrently.\",\n value=1,\n ),\n BoolInput(\n name=\"markdown\",\n display_name=\"Markdown Export\",\n info=\"Export processed documents to Markdown format. Only available when advanced mode is enabled.\",\n value=False,\n show=False,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Raw Content\", name=\"message\", method=\"load_files_message\", tool_mode=True),\n ]\n\n # ------------------------------ Tool description with file names --------------\n\n def get_tool_description(self) -> str:\n \"\"\"Return a dynamic description that includes the names of uploaded files.\n\n This helps the Agent understand which files are available to read.\n \"\"\"\n base_description = type(self)._base_description # noqa: SLF001\n\n # Get the list of uploaded file paths\n file_paths = getattr(self, \"path\", None)\n if not file_paths:\n return base_description\n\n # Ensure it's a list\n if not isinstance(file_paths, list):\n file_paths = [file_paths]\n\n # Extract just the file names from the paths\n file_names = []\n for fp in file_paths:\n if fp:\n name = Path(fp).name\n file_names.append(name)\n\n if file_names:\n files_str = \", \".join(file_names)\n return f\"{base_description} Available files: {files_str}. Call this tool to read these files.\"\n\n return base_description\n\n @property\n def description(self) -> str:\n \"\"\"Dynamic description property that includes uploaded file names.\"\"\"\n return self.get_tool_description()\n\n async def _get_tools(self) -> list:\n \"\"\"Override to create a tool without parameters.\n\n The Read File component should use the files already uploaded via UI,\n not accept file paths from the Agent (which wouldn't know the internal paths).\n \"\"\"\n from langchain_core.tools import StructuredTool\n from pydantic import BaseModel\n\n # Empty schema - no parameters needed\n class EmptySchema(BaseModel):\n \"\"\"No parameters required - uses pre-uploaded files.\"\"\"\n\n async def read_files_tool() -> str:\n \"\"\"Read the content of uploaded files.\"\"\"\n cancel_event = threading.Event()\n cancel_token = _FILE_TOOL_CANCEL_EVENT.set(cancel_event)\n try:\n if getattr(self, \"advanced_mode\", False):\n # In advanced mode, use the markdown output path so that the\n # tool shares the same Docling processing as the advanced\n # outputs rather than triggering a second subprocess via\n # load_files_message.\n self.markdown = True\n loader = self.load_files_markdown\n else:\n loader = self.load_files_message\n # Both loaders are blocking (file IO plus, in advanced mode, a Docling\n # subprocess). Run them off the event loop so streaming/heartbeats on\n # the same loop keep flowing while the agent waits for this tool. Keep\n # admission bounded until the real worker exits so cancelled standard\n # parsers cannot build an unbounded default-executor backlog.\n load_limiter = _get_file_tool_limiter()\n await load_limiter.acquire()\n try:\n loader_task = asyncio.create_task(asyncio.to_thread(loader))\n except BaseException:\n load_limiter.release()\n raise\n loader_task.add_done_callback(lambda _task: load_limiter.release())\n try:\n result = await asyncio.shield(loader_task)\n except asyncio.CancelledError:\n cancel_event.set()\n # Give cooperative cleanup time to kill/reap Docling and remove\n # temporary files before releasing this tool invocation.\n try:\n await asyncio.wait_for(\n asyncio.shield(loader_task),\n timeout=_FILE_TOOL_CANCEL_WAIT_SECONDS,\n )\n except _FileToolCancelledError:\n pass\n except asyncio.TimeoutError:\n pass\n except Exception: # noqa: BLE001 - cancellation stays dominant over loader cleanup\n logger.exception(\"File loader failed while cleaning up a cancelled tool call\")\n finally:\n if not loader_task.done():\n loader_task.add_done_callback(_log_abandoned_file_tool_result)\n raise\n if hasattr(result, \"get_text\"):\n return result.get_text()\n if hasattr(result, \"text\"):\n return result.text\n return str(result)\n except (FileNotFoundError, ValueError, OSError, RuntimeError) as e:\n return f\"Error reading files: {e}\"\n finally:\n _FILE_TOOL_CANCEL_EVENT.reset(cancel_token)\n\n description = self.get_tool_description()\n\n tool = StructuredTool(\n name=\"load_files_message\",\n description=description,\n coroutine=read_files_tool,\n args_schema=EmptySchema,\n handle_tool_error=True,\n tags=[\"load_files_message\"],\n metadata={\n \"display_name\": \"Read File\",\n \"display_description\": description,\n },\n )\n\n return [tool]\n\n # ------------------------------ UI helpers --------------------------------------\n\n def _path_value(self, template: dict) -> list[str]:\n \"\"\"Return the list of currently selected file paths from the template.\"\"\"\n return template.get(\"path\", {}).get(\"file_path\", [])\n\n def _disable_docling_fields_in_cloud(self, build_config: dict[str, Any]) -> None:\n \"\"\"Disable all Docling-related fields in cloud environments.\"\"\"\n if \"advanced_mode\" in build_config:\n build_config[\"advanced_mode\"][\"show\"] = False\n build_config[\"advanced_mode\"][\"value\"] = False\n # Hide all Docling-related fields\n docling_fields = (\"pipeline\", \"ocr_engine\", \"doc_key\", \"md_image_placeholder\", \"md_page_break_placeholder\")\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = False\n # Also disable OCR engine specifically\n if \"ocr_engine\" in build_config:\n build_config[\"ocr_engine\"][\"value\"] = \"None\"\n\n def update_build_config(\n self,\n build_config: dict[str, Any],\n field_value: Any,\n field_name: str | None = None,\n ) -> dict[str, Any]:\n \"\"\"Show/hide Advanced Parser and related fields based on selection context.\"\"\"\n # Update storage location options dynamically based on cloud environment\n if \"storage_location\" in build_config:\n updated_options = _get_storage_location_options()\n build_config[\"storage_location\"][\"options\"] = updated_options\n\n # Handle storage location selection\n if field_name == \"storage_location\":\n # Extract selected storage location\n selected = [location[\"name\"] for location in field_value] if isinstance(field_value, list) else []\n\n # Hide all storage-specific fields first\n storage_fields = [\n \"aws_access_key_id\",\n \"aws_secret_access_key\",\n \"bucket_name\",\n \"aws_region\",\n \"s3_file_key\",\n \"service_account_key\",\n \"file_id\",\n ]\n\n for f_name in storage_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = False\n\n # Show fields based on selected storage location\n if len(selected) == 1:\n location = selected[0]\n\n if location == \"Local\":\n # Show file upload input for local storage\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = True\n\n elif location == \"AWS\":\n # Hide file upload input, show AWS fields\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = False\n\n aws_fields = [\n \"aws_access_key_id\",\n \"aws_secret_access_key\",\n \"bucket_name\",\n \"aws_region\",\n \"s3_file_key\",\n ]\n for f_name in aws_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = True\n build_config[f_name][\"advanced\"] = False\n\n elif location == \"Google Drive\":\n # Hide file upload input, show Google Drive fields\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = False\n\n gdrive_fields = [\"service_account_key\", \"file_id\"]\n for f_name in gdrive_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = True\n build_config[f_name][\"advanced\"] = False\n # No storage location selected - show file upload by default\n elif \"path\" in build_config:\n build_config[\"path\"][\"show\"] = True\n\n return build_config\n\n if field_name == \"path\":\n paths = self._path_value(build_config)\n\n # Disable in cloud environments\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n else:\n # If all files can be processed by docling, do so\n allow_advanced = all(not file_path.endswith((\".csv\", \".xlsx\", \".parquet\")) for file_path in paths)\n build_config[\"advanced_mode\"][\"show\"] = allow_advanced\n if not allow_advanced:\n build_config[\"advanced_mode\"][\"value\"] = False\n docling_fields = (\n \"pipeline\",\n \"ocr_engine\",\n \"doc_key\",\n \"md_image_placeholder\",\n \"md_page_break_placeholder\",\n )\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = False\n\n # Docling Processing\n elif field_name == \"advanced_mode\":\n # Disable in cloud environments - don't show Docling fields even if advanced_mode is toggled\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n else:\n docling_fields = (\n \"pipeline\",\n \"ocr_engine\",\n \"doc_key\",\n \"md_image_placeholder\",\n \"md_page_break_placeholder\",\n )\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = bool(field_value)\n if field == \"pipeline\":\n build_config[field][\"advanced\"] = not bool(field_value)\n\n elif field_name == \"pipeline\":\n # Disable in cloud environments - don't show OCR engine even if pipeline is changed\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n elif field_value == \"standard\":\n build_config[\"ocr_engine\"][\"show\"] = True\n build_config[\"ocr_engine\"][\"value\"] = \"easyocr\"\n else:\n build_config[\"ocr_engine\"][\"show\"] = False\n build_config[\"ocr_engine\"][\"value\"] = \"None\"\n\n return build_config\n\n def update_outputs(self, frontend_node: dict[str, Any], field_name: str, field_value: Any) -> dict[str, Any]: # noqa: ARG002\n \"\"\"Dynamically show outputs based on file count/type and advanced mode.\"\"\"\n if field_name not in [\"path\", \"advanced_mode\", \"pipeline\"]:\n return frontend_node\n\n template = frontend_node.get(\"template\", {})\n paths = self._path_value(template)\n if not paths:\n return frontend_node\n\n frontend_node[\"outputs\"] = []\n if len(paths) == 1:\n file_path = paths[0] if field_name == \"path\" else frontend_node[\"template\"][\"path\"][\"file_path\"][0]\n if file_path.endswith((\".csv\", \".xlsx\", \".parquet\")):\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Structured Content\",\n name=\"dataframe\",\n method=\"load_files_structured\",\n tool_mode=True,\n ),\n )\n elif file_path.endswith(\".json\"):\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Structured Content\", name=\"json\", method=\"load_files_json\", tool_mode=True),\n )\n\n advanced_mode = frontend_node.get(\"template\", {}).get(\"advanced_mode\", {}).get(\"value\", False)\n if advanced_mode:\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Structured Output\",\n name=\"advanced_dataframe\",\n method=\"load_files_dataframe\",\n tool_mode=True,\n ),\n )\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Markdown\", name=\"advanced_markdown\", method=\"load_files_markdown\", tool_mode=True\n ),\n )\n frontend_node[\"outputs\"].append(\n Output(display_name=\"File Path\", name=\"path\", method=\"load_files_path\", tool_mode=True),\n )\n else:\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Raw Content\", name=\"message\", method=\"load_files_message\", tool_mode=True),\n )\n frontend_node[\"outputs\"].append(\n Output(display_name=\"File Path\", name=\"path\", method=\"load_files_path\", tool_mode=True),\n )\n else:\n # Multiple files => DataFrame output; advanced parser disabled\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Files\", name=\"dataframe\", method=\"load_files\", tool_mode=True)\n )\n\n return frontend_node\n\n # ------------------------------ Core processing ----------------------------------\n\n def _get_selected_storage_location(self) -> str:\n \"\"\"Get the selected storage location from the SortableListInput.\"\"\"\n if hasattr(self, \"storage_location\") and self.storage_location:\n if isinstance(self.storage_location, list) and len(self.storage_location) > 0:\n return self.storage_location[0].get(\"name\", \"\")\n if isinstance(self.storage_location, dict):\n return self.storage_location.get(\"name\", \"\")\n return \"Local\" # Default to Local if not specified\n\n def _validate_and_resolve_paths(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Override to handle file_path_str input from tool mode and cloud storage.\n\n Priority:\n 1. Cloud storage (AWS/Google Drive) if selected\n 2. file_path_str (if provided by the tool call)\n 3. path (uploaded file from UI)\n \"\"\"\n storage_location = self._get_selected_storage_location()\n\n # Handle AWS S3\n if storage_location == \"AWS\":\n return self._read_from_aws_s3()\n\n # Handle Google Drive\n if storage_location == \"Google Drive\":\n return self._read_from_google_drive()\n\n # Handle Local storage\n # Check if file_path_str is provided (from tool mode)\n file_path_str = getattr(self, \"file_path_str\", None)\n if file_path_str:\n # Use the string path from tool mode\n from pathlib import Path\n\n from lfx.schema.data import Data\n\n # Use same resolution logic as BaseFileComponent (support storage paths)\n path_str = str(file_path_str)\n if parse_storage_path(path_str):\n try:\n resolved_path = Path(self.get_full_path(path_str))\n except (ValueError, AttributeError):\n resolved_path = Path(self.resolve_path(path_str))\n else:\n resolved_path = Path(self.resolve_path(path_str))\n\n # Security: confine tool-mode reads to the storage dir in restricted (multi-tenant)\n # mode so a tenant cannot read arbitrary server files via file_path_str.\n from lfx.utils.file_path_security import component_file_access_scopes, enforce_local_file_access\n\n resolved_path = enforce_local_file_access(resolved_path, scope_ids=component_file_access_scopes(self))\n\n if not resolved_path.exists():\n msg = f\"File or directory not found: {file_path_str}\"\n self.log(msg)\n if not self.silent_errors:\n raise ValueError(msg)\n return []\n\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(resolved_path)})\n return [BaseFileComponent.BaseFile(data_obj, resolved_path, delete_after_processing=False)]\n\n # Otherwise use the default implementation (uses path FileInput)\n return super()._validate_and_resolve_paths()\n\n def _read_from_aws_s3(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Read file from AWS S3.\"\"\"\n from lfx.base.data.cloud_storage_utils import create_s3_client, validate_aws_credentials\n\n # Validate AWS credentials\n validate_aws_credentials(self)\n if not getattr(self, \"s3_file_key\", None):\n msg = \"S3 File Key is required\"\n raise ValueError(msg)\n\n # Create S3 client\n s3_client = create_s3_client(self)\n\n # Download file to temp location\n import tempfile\n\n # Get file extension from S3 key\n file_extension = Path(self.s3_file_key).suffix or \"\"\n\n with tempfile.NamedTemporaryFile(mode=\"wb\", suffix=file_extension, delete=False) as temp_file:\n temp_file_path = temp_file.name\n try:\n s3_client.download_fileobj(self.bucket_name, self.s3_file_key, temp_file)\n except Exception as e:\n # Clean up temp file on failure\n with contextlib.suppress(OSError):\n Path(temp_file_path).unlink()\n msg = f\"Failed to download file from S3: {e}\"\n raise RuntimeError(msg) from e\n\n # Create BaseFile object\n from lfx.schema.data import Data\n\n temp_path = Path(temp_file_path)\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(temp_path)})\n return [BaseFileComponent.BaseFile(data_obj, temp_path, delete_after_processing=True)]\n\n def _read_from_google_drive(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Read file from Google Drive.\"\"\"\n import tempfile\n\n from googleapiclient.http import MediaIoBaseDownload\n\n from lfx.base.data.cloud_storage_utils import create_google_drive_service\n\n # Validate Google Drive credentials\n if not getattr(self, \"service_account_key\", None):\n msg = \"GCP Credentials Secret Key is required for Google Drive storage\"\n raise ValueError(msg)\n if not getattr(self, \"file_id\", None):\n msg = \"Google Drive File ID is required\"\n raise ValueError(msg)\n\n # Create Google Drive service with read-only scope\n drive_service = create_google_drive_service(\n self.service_account_key, scopes=[\"https://www.googleapis.com/auth/drive.readonly\"]\n )\n\n # Get file metadata to determine file name and extension\n try:\n file_metadata = drive_service.files().get(fileId=self.file_id, fields=\"name,mimeType\").execute()\n file_name = file_metadata.get(\"name\", \"download\")\n except Exception as e:\n msg = (\n f\"Unable to access file with ID '{self.file_id}'. \"\n f\"Error: {e!s}. \"\n \"Please ensure: 1) The file ID is correct, 2) The file exists, \"\n \"3) The service account has been granted access to this file.\"\n )\n raise ValueError(msg) from e\n\n # Download file to temp location\n file_extension = Path(file_name).suffix or \"\"\n with tempfile.NamedTemporaryFile(mode=\"wb\", suffix=file_extension, delete=False) as temp_file:\n temp_file_path = temp_file.name\n try:\n request = drive_service.files().get_media(fileId=self.file_id)\n downloader = MediaIoBaseDownload(temp_file, request)\n done = False\n while not done:\n _status, done = downloader.next_chunk()\n except Exception as e:\n # Clean up temp file on failure\n with contextlib.suppress(OSError):\n Path(temp_file_path).unlink()\n msg = f\"Failed to download file from Google Drive: {e}\"\n raise RuntimeError(msg) from e\n\n # Create BaseFile object\n from lfx.schema.data import Data\n\n temp_path = Path(temp_file_path)\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(temp_path)})\n return [BaseFileComponent.BaseFile(data_obj, temp_path, delete_after_processing=True)]\n\n def _is_docling_compatible(self, file_path: str) -> bool:\n \"\"\"Lightweight extension gate for Docling-compatible types.\"\"\"\n docling_exts = (\n \".adoc\",\n \".asciidoc\",\n \".asc\",\n \".bmp\",\n \".csv\",\n \".dotx\",\n \".dotm\",\n \".docm\",\n \".docx\",\n \".htm\",\n \".html\",\n \".jpg\",\n \".jpeg\",\n \".json\",\n \".md\",\n \".pdf\",\n \".png\",\n \".potx\",\n \".ppsx\",\n \".pptm\",\n \".potm\",\n \".ppsm\",\n \".pptx\",\n \".tiff\",\n \".txt\",\n \".xls\",\n \".xlsx\",\n \".xhtml\",\n \".xml\",\n \".webp\",\n )\n return file_path.lower().endswith(docling_exts)\n\n async def _get_local_file_for_docling(self, file_path: str) -> tuple[str, bool]:\n \"\"\"Get a local file path for Docling processing, downloading from S3 if needed.\n\n Args:\n file_path: Either a local path or S3 key (format \"flow_id/filename\")\n\n Returns:\n tuple[str, bool]: (local_path, should_delete) where should_delete indicates\n if this is a temporary file that should be cleaned up\n \"\"\"\n settings = get_settings_service().settings\n if settings.storage_type == \"local\":\n return file_path, False\n\n # S3 storage - download to temp file\n parsed = parse_storage_path(file_path)\n if not parsed:\n msg = f\"Invalid S3 path format: {file_path}. Expected 'flow_id/filename'\"\n raise ValueError(msg)\n\n storage_service = get_storage_service()\n flow_id, filename = parsed\n\n # Get file content from S3\n content = await storage_service.get_file(flow_id, filename)\n\n suffix = Path(filename).suffix\n with NamedTemporaryFile(mode=\"wb\", suffix=suffix, delete=False) as tmp_file:\n tmp_file.write(content)\n temp_path = tmp_file.name\n\n return temp_path, True\n\n def _process_docling_in_subprocess(self, file_path: str) -> Data | None:\n \"\"\"Run Docling in a separate OS process and map the result to a Data object.\n\n We avoid multiprocessing pickling by launching `python -c \"<script>\"` and\n passing JSON config via stdin. The child prints a JSON result to stdout.\n\n For S3 storage, the file is downloaded to a temp file first.\n \"\"\"\n if not file_path:\n return None\n\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n _raise_if_file_tool_cancelled(cancel_event)\n settings = get_settings_service().settings\n if is_remote_storage_type(settings.storage_type):\n local_path, should_delete = run_until_complete(self._get_local_file_for_docling(file_path))\n else:\n local_path = file_path\n should_delete = False\n\n try:\n _raise_if_file_tool_cancelled(cancel_event)\n return self._process_docling_subprocess_impl(local_path, file_path)\n finally:\n # Clean up temp file if we created one\n if should_delete:\n with contextlib.suppress(Exception):\n Path(local_path).unlink() # Ignore cleanup errors\n\n def _process_docling_subprocess_impl(self, local_file_path: str, original_file_path: str) -> Data | None:\n \"\"\"Implementation of Docling subprocess processing.\n\n Args:\n local_file_path: Path to local file to process\n original_file_path: Original file path to include in metadata\n Returns:\n Data object with processed content\n \"\"\"\n args: dict[str, Any] = {\n \"file_path\": local_file_path,\n \"markdown\": bool(self.markdown),\n \"image_mode\": str(self.IMAGE_MODE),\n \"md_image_placeholder\": str(self.md_image_placeholder),\n \"md_page_break_placeholder\": str(self.md_page_break_placeholder),\n \"pipeline\": str(self.pipeline),\n \"ocr_engine\": (\n self.ocr_engine if self.ocr_engine and self.ocr_engine != \"None\" and self.pipeline != \"vlm\" else None\n ),\n }\n\n # Child script for isolating the docling processing\n child_script = textwrap.dedent(\n r\"\"\"\n import json, sys\n\n def try_imports():\n try:\n from docling.datamodel.base_models import ConversionStatus, InputFormat # type: ignore\n from docling.document_converter import DocumentConverter # type: ignore\n from docling_core.types.doc import ImageRefMode # type: ignore\n return ConversionStatus, InputFormat, DocumentConverter, ImageRefMode, \"latest\"\n except Exception as e:\n raise e\n\n def create_converter(strategy, input_format, DocumentConverter, pipeline, ocr_engine):\n # --- Standard PDF/IMAGE pipeline (your existing behavior), with optional OCR ---\n if pipeline == \"standard\":\n try:\n from docling.datamodel.pipeline_options import PdfPipelineOptions # type: ignore\n from docling.document_converter import PdfFormatOption # type: ignore\n\n pipe = PdfPipelineOptions()\n pipe.do_ocr = False\n\n if ocr_engine:\n try:\n from docling.models.factories import get_ocr_factory # type: ignore\n pipe.do_ocr = True\n fac = get_ocr_factory(allow_external_plugins=False)\n pipe.ocr_options = fac.create_options(kind=ocr_engine)\n except Exception:\n # If OCR setup fails, disable it\n pipe.do_ocr = False\n\n fmt = {}\n if hasattr(input_format, \"PDF\"):\n fmt[getattr(input_format, \"PDF\")] = PdfFormatOption(pipeline_options=pipe)\n if hasattr(input_format, \"IMAGE\"):\n fmt[getattr(input_format, \"IMAGE\")] = PdfFormatOption(pipeline_options=pipe)\n\n return DocumentConverter(format_options=fmt)\n except Exception:\n return DocumentConverter()\n\n # --- Vision-Language Model (VLM) pipeline ---\n if pipeline == \"vlm\":\n try:\n from docling.datamodel.pipeline_options import VlmPipelineOptions\n from docling.datamodel.vlm_model_specs import GRANITEDOCLING_MLX, GRANITEDOCLING_TRANSFORMERS\n from docling.document_converter import PdfFormatOption\n from docling.pipeline.vlm_pipeline import VlmPipeline\n\n vl_pipe = VlmPipelineOptions(\n vlm_options=GRANITEDOCLING_TRANSFORMERS,\n )\n\n if sys.platform == \"darwin\":\n try:\n import mlx_vlm\n vl_pipe.vlm_options = GRANITEDOCLING_MLX\n except ImportError as e:\n raise e\n\n # VLM paths generally don't need OCR; keep OCR off by default here.\n fmt = {}\n if hasattr(input_format, \"PDF\"):\n fmt[getattr(input_format, \"PDF\")] = PdfFormatOption(\n pipeline_cls=VlmPipeline,\n pipeline_options=vl_pipe\n )\n if hasattr(input_format, \"IMAGE\"):\n fmt[getattr(input_format, \"IMAGE\")] = PdfFormatOption(\n pipeline_cls=VlmPipeline,\n pipeline_options=vl_pipe\n )\n\n return DocumentConverter(format_options=fmt)\n except Exception as e:\n raise e\n\n # --- Fallback: default converter with no special options ---\n return DocumentConverter()\n\n def export_markdown(document, ImageRefMode, image_mode, img_ph, pg_ph):\n try:\n mode = getattr(ImageRefMode, image_mode.upper(), image_mode)\n return document.export_to_markdown(\n image_mode=mode,\n image_placeholder=img_ph,\n page_break_placeholder=pg_ph,\n )\n except Exception:\n try:\n return document.export_to_text()\n except Exception:\n return str(document)\n\n def to_rows(doc_dict):\n rows = []\n for t in doc_dict.get(\"texts\", []):\n prov = t.get(\"prov\") or []\n page_no = None\n if prov and isinstance(prov, list) and isinstance(prov[0], dict):\n page_no = prov[0].get(\"page_no\")\n rows.append({\n \"page_no\": page_no,\n \"label\": t.get(\"label\"),\n \"text\": t.get(\"text\"),\n \"level\": t.get(\"level\"),\n })\n return rows\n\n def main():\n cfg = json.loads(sys.stdin.read())\n file_path = cfg[\"file_path\"]\n markdown = cfg[\"markdown\"]\n image_mode = cfg[\"image_mode\"]\n img_ph = cfg[\"md_image_placeholder\"]\n pg_ph = cfg[\"md_page_break_placeholder\"]\n pipeline = cfg[\"pipeline\"]\n ocr_engine = cfg.get(\"ocr_engine\")\n meta = {\"file_path\": file_path}\n\n try:\n ConversionStatus, InputFormat, DocumentConverter, ImageRefMode, strategy = try_imports()\n converter = create_converter(strategy, InputFormat, DocumentConverter, pipeline, ocr_engine)\n try:\n res = converter.convert(file_path)\n except Exception as e:\n print(json.dumps({\"ok\": False, \"error\": f\"Docling conversion error: {e}\", \"meta\": meta}))\n return\n\n ok = False\n if hasattr(res, \"status\"):\n try:\n ok = (res.status == ConversionStatus.SUCCESS) or (str(res.status).lower() == \"success\")\n except Exception:\n ok = (str(res.status).lower() == \"success\")\n if not ok and hasattr(res, \"document\"):\n ok = getattr(res, \"document\", None) is not None\n if not ok:\n print(json.dumps({\"ok\": False, \"error\": \"Docling conversion failed\", \"meta\": meta}))\n return\n\n doc = getattr(res, \"document\", None)\n if doc is None:\n print(json.dumps({\"ok\": False, \"error\": \"Docling produced no document\", \"meta\": meta}))\n return\n\n # Extract DoclingDocument metadata\n if hasattr(doc, \"name\") and doc.name:\n meta[\"name\"] = doc.name\n if hasattr(doc, \"origin\") and doc.origin is not None:\n origin = doc.origin\n if hasattr(origin, \"filename\") and origin.filename:\n meta[\"filename\"] = origin.filename\n if hasattr(origin, \"binary_hash\") and origin.binary_hash:\n meta[\"document_id\"] = str(origin.binary_hash)\n if hasattr(origin, \"mimetype\") and origin.mimetype:\n meta[\"mimetype\"] = origin.mimetype\n\n if markdown:\n text = export_markdown(doc, ImageRefMode, image_mode, img_ph, pg_ph)\n print(json.dumps({\"ok\": True, \"mode\": \"markdown\", \"text\": text, \"meta\": meta}))\n return\n\n # structured\n try:\n doc_dict = doc.export_to_dict()\n except Exception as e:\n print(json.dumps({\"ok\": False, \"error\": f\"Docling export_to_dict failed: {e}\", \"meta\": meta}))\n return\n\n rows = to_rows(doc_dict)\n print(json.dumps({\"ok\": True, \"mode\": \"structured\", \"doc\": rows, \"meta\": meta}))\n except Exception as e:\n print(\n json.dumps({\n \"ok\": False,\n \"error\": f\"Docling processing error: {e}\",\n \"meta\": {\"file_path\": file_path},\n })\n )\n\n if __name__ == \"__main__\":\n main()\n \"\"\"\n )\n\n # The path is passed as JSON over stdin to an argument-list subprocess, so shell\n # metacharacters are ordinary filename characters here.\n if not isinstance(args[\"file_path\"], str):\n return Data(data={\"error\": \"Unsafe file path detected.\", \"file_path\": args[\"file_path\"]})\n\n # Use communicate() in bounded intervals so stdout/stderr are drained while the\n # child runs without losing the heartbeat that keeps the SSE event stream alive.\n docling_timeout = 600 # 10 minutes; large PDFs with OCR may need this\n poll_interval = 5 # seconds between progress heartbeats\n\n proc = subprocess.Popen( # noqa: S603\n [sys.executable, \"-u\", \"-c\", child_script],\n stdin=subprocess.PIPE,\n stdout=subprocess.PIPE,\n stderr=subprocess.PIPE,\n )\n\n start = time.monotonic()\n input_bytes: bytes | None = json.dumps(args).encode(\"utf-8\")\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n try:\n while True:\n _raise_if_file_tool_cancelled(cancel_event)\n elapsed = time.monotonic() - start\n if elapsed >= docling_timeout:\n proc.kill()\n proc.communicate()\n return Data(\n data={\n \"error\": (\n f\"Docling processing timed out after {docling_timeout}s. \"\n \"Consider using the standalone Docling component for large documents.\"\n ),\n \"file_path\": original_file_path,\n },\n )\n try:\n stdout_bytes, stderr_bytes = proc.communicate(\n input=input_bytes,\n timeout=min(poll_interval, docling_timeout - elapsed),\n )\n _raise_if_file_tool_cancelled(cancel_event)\n break\n except subprocess.TimeoutExpired:\n # communicate() retains partially written input and collected output across\n # retries, so subsequent calls continue draining without resending stdin.\n input_bytes = None\n elapsed = time.monotonic() - start\n self.log(f\"Docling processing in progress ({int(elapsed)}s elapsed)...\")\n finally:\n if (cancel_event is not None and cancel_event.is_set()) or proc.poll() is None:\n _kill_and_reap_process(proc)\n\n if not stdout_bytes:\n err_msg = stderr_bytes.decode(\"utf-8\", errors=\"replace\") if stderr_bytes else \"no output from child process\"\n return Data(data={\"error\": f\"Docling subprocess error: {err_msg}\", \"file_path\": original_file_path})\n\n try:\n result = json.loads(stdout_bytes.decode(\"utf-8\"))\n except Exception as e: # noqa: BLE001\n err_msg = stderr_bytes.decode(\"utf-8\", errors=\"replace\")\n return Data(\n data={\n \"error\": f\"Invalid JSON from Docling subprocess: {e}. stderr={err_msg}\",\n \"file_path\": original_file_path,\n },\n )\n\n if not result.get(\"ok\"):\n error_msg = result.get(\"error\", \"Unknown Docling error\")\n # Override meta file_path with original_file_path to ensure correct path matching\n meta = result.get(\"meta\", {})\n meta[\"file_path\"] = original_file_path\n return Data(data={\"error\": error_msg, **meta})\n\n meta = result.get(\"meta\", {})\n # Override meta file_path with original_file_path to ensure correct path matching\n # The subprocess returns the temp file path, but we need the original S3/local path for rollup_data\n meta[\"file_path\"] = original_file_path\n if result.get(\"mode\") == \"markdown\":\n exported_content = str(result.get(\"text\", \"\"))\n return Data(\n text=exported_content,\n data={\"exported_content\": exported_content, \"export_format\": self.EXPORT_FORMAT, **meta},\n )\n\n rows = list(result.get(\"doc\", []))\n return Data(data={\"doc\": rows, \"export_format\": self.EXPORT_FORMAT, **meta})\n\n def process_files(\n self,\n file_list: list[BaseFileComponent.BaseFile],\n ) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Process input files.\n\n - advanced_mode => Docling in a separate process.\n - Otherwise => standard parsing in current process (optionally threaded).\n \"\"\"\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n _raise_if_file_tool_cancelled(cancel_event)\n if not file_list:\n msg = \"No files to process.\"\n raise ValueError(msg)\n\n # Validate image files to detect content/extension mismatches\n # This prevents API errors like \"Image does not match the provided media type\"\n image_extensions = {\"jpeg\", \"jpg\", \"png\", \"gif\", \"webp\", \"bmp\", \"tiff\"}\n settings = get_settings_service().settings\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n extension = file.path.suffix[1:].lower()\n if extension in image_extensions:\n # Read bytes based on storage type\n try:\n if is_remote_storage_type(settings.storage_type):\n # For S3 storage, use storage service to read file bytes\n file_path_str = str(file.path)\n content = run_until_complete(read_file_bytes(file_path_str))\n else:\n # For local storage, read bytes directly from filesystem\n content = file.path.read_bytes()\n\n is_valid, error_msg = validate_image_content_type(\n str(file.path),\n content=content,\n )\n if not is_valid:\n self.log(error_msg)\n if not self.silent_errors:\n raise ValueError(error_msg)\n except (OSError, FileNotFoundError) as e:\n self.log(f\"Could not read file for validation: {e}\")\n # Continue - let it fail later with better error\n\n # Validate that files requiring Docling are only processed when advanced mode is enabled\n if not self.advanced_mode:\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n extension = file.path.suffix[1:].lower()\n if extension in self.DOCLING_ONLY_EXTENSIONS:\n if is_astra_cloud_environment():\n msg = (\n f\"File '{file.path.name}' has extension '.{extension}' which requires \"\n f\"Advanced Parser mode. Advanced Parser is not available in cloud environments.\"\n )\n else:\n msg = (\n f\"File '{file.path.name}' has extension '.{extension}' which requires \"\n f\"Advanced Parser mode. Please enable 'Advanced Parser' to process this file.\"\n )\n self.log(msg)\n raise ValueError(msg)\n\n def process_file_standard(file_path: str, *, silent_errors: bool = False) -> Data | None:\n try:\n _raise_if_file_tool_cancelled(cancel_event)\n result = parse_text_file_to_data(file_path, silent_errors=silent_errors)\n _raise_if_file_tool_cancelled(cancel_event)\n except _FileToolCancelledError:\n raise\n except FileNotFoundError as e:\n self.log(f\"File not found: {file_path}. Error: {e}\")\n if not silent_errors:\n raise\n return None\n except Exception as e:\n self.log(f\"Unexpected error processing {file_path}: {e}\")\n if not silent_errors:\n raise\n return None\n else:\n return result\n\n docling_compatible = all(self._is_docling_compatible(str(f.path)) for f in file_list)\n\n # Advanced path: Check if ALL files are compatible with Docling\n if self.advanced_mode and docling_compatible:\n final_return: list[BaseFileComponent.BaseFile] = []\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n file_path = str(file.path)\n advanced_data: Data | None = self._process_docling_in_subprocess(file_path)\n _raise_if_file_tool_cancelled(cancel_event)\n\n # Handle None case - Docling processing failed or returned None\n if advanced_data is None:\n error_data = Data(\n data={\n \"file_path\": file_path,\n \"error\": \"Docling processing returned no result. Check logs for details.\",\n },\n )\n final_return.extend(self.rollup_data([file], [error_data]))\n continue\n\n # --- UNNEST: expand each element in `doc` to its own Data row\n payload = getattr(advanced_data, \"data\", {}) or {}\n\n # Check for errors first\n if \"error\" in payload:\n error_msg = payload.get(\"error\", \"Unknown error\")\n error_data = Data(\n data={\n \"file_path\": file_path,\n \"error\": error_msg,\n **{k: v for k, v in payload.items() if k not in (\"error\", \"file_path\")},\n },\n )\n final_return.extend(self.rollup_data([file], [error_data]))\n continue\n\n doc_rows = payload.get(\"doc\")\n if isinstance(doc_rows, list) and doc_rows:\n # Non-empty list of structured rows\n rows: list[Data | None] = [\n Data(\n data={\n \"file_path\": file_path,\n **(item if isinstance(item, dict) else {\"value\": item}),\n },\n )\n for item in doc_rows\n ]\n final_return.extend(self.rollup_data([file], rows))\n elif isinstance(doc_rows, list) and not doc_rows:\n # Empty list - file was processed but no text content found\n # Create a Data object indicating no content was extracted\n self.log(f\"No text extracted from '{file_path}', creating placeholder data\")\n empty_data = Data(\n data={\n \"file_path\": file_path,\n \"text\": \"(No text content extracted from image)\",\n \"info\": \"Image processed successfully but contained no extractable text\",\n **{k: v for k, v in payload.items() if k != \"doc\"},\n },\n )\n final_return.extend(self.rollup_data([file], [empty_data]))\n else:\n # If not structured, keep as-is (e.g., markdown export or error dict)\n # Ensure file_path is set for proper rollup matching\n if not payload.get(\"file_path\"):\n payload[\"file_path\"] = file_path\n # Create new Data with file_path\n advanced_data = Data(\n data=payload,\n text=getattr(advanced_data, \"text\", None),\n )\n final_return.extend(self.rollup_data([file], [advanced_data]))\n return final_return\n\n # Standard multi-file (or single non-advanced) path\n concurrency = max(1, self.concurrency_multithreading)\n\n file_paths = [str(f.path) for f in file_list]\n self.log(f\"Starting parallel processing of {len(file_paths)} files with concurrency: {concurrency}.\")\n my_data = parallel_load_data(\n file_paths,\n silent_errors=self.silent_errors,\n load_function=process_file_standard,\n max_concurrency=concurrency,\n )\n _raise_if_file_tool_cancelled(cancel_event)\n return self.rollup_data(file_list, my_data)\n\n # ------------------------------ Output helpers -----------------------------------\n\n def load_files_helper(self) -> DataFrame:\n result = self.load_files()\n\n # Result is a DataFrame - check if it has any rows\n if result.empty:\n msg = \"Could not extract content from the provided file(s).\"\n raise ValueError(msg)\n\n # Check for error column with error messages\n if \"error\" in result.columns:\n errors = result[\"error\"].dropna().tolist()\n if errors and not any(col in result.columns for col in [\"text\", \"doc\", \"exported_content\"]):\n raise ValueError(errors[0])\n\n return result\n\n def load_files_dataframe(self) -> DataFrame:\n \"\"\"Load files using advanced Docling processing and export to DataFrame format.\"\"\"\n self.markdown = False\n return self.load_files_helper()\n\n def load_files_markdown(self) -> Message:\n \"\"\"Load files using advanced Docling processing and export to Markdown format.\"\"\"\n self.markdown = True\n result = self.load_files_helper()\n\n # Result is a DataFrame - check for text or exported_content columns\n if \"text\" in result.columns and not result[\"text\"].isna().all():\n text_values = result[\"text\"].dropna().tolist()\n if text_values:\n return Message(text=str(text_values[0]))\n\n if \"exported_content\" in result.columns and not result[\"exported_content\"].isna().all():\n content_values = result[\"exported_content\"].dropna().tolist()\n if content_values:\n return Message(text=str(content_values[0]))\n\n # Return empty message with info that no text was found\n return Message(text=\"(No text content extracted from file)\")\n"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use provider-neutral diagnostics for remote Docling paths.

is_remote_storage_type() now routes GCS and Azure paths through _get_local_file_for_docling(), but that code still says “S3 storage” and raises “Invalid S3 path format” for every non-local backend. Update the comments and error message to use “remote storage” or include settings.storage_type.

Proposed fix
-        # S3 storage - download to temp file
+        # Remote storage - download to a temporary local file

-            msg = f"Invalid S3 path format: {file_path}. Expected 'flow_id/filename'"
+            msg = (
+                f"Invalid {settings.storage_type} path format: {file_path}. "
+                "Expected 'flow_id/filename'"
+            )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"value": "\"\"\"Enhanced file component with Docling support and process isolation.\n\nNotes:\n-----\n- ALL Docling parsing/export runs in a separate OS process to prevent memory\n growth and native library state from impacting the main Langflow process.\n- Standard text/structured parsing continues to use existing BaseFileComponent\n utilities (and optional threading via `parallel_load_data`).\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport json\nimport subprocess\nimport sys\nimport textwrap\nimport threading\nimport time\nfrom contextvars import ContextVar\nfrom copy import deepcopy\nfrom pathlib import Path\nfrom tempfile import NamedTemporaryFile\nfrom typing import Any\n\nfrom lfx.base.data.base_file import BaseFileComponent\nfrom lfx.base.data.storage_utils import (\n is_remote_storage_type,\n parse_storage_path,\n read_file_bytes,\n validate_image_content_type,\n)\nfrom lfx.base.data.utils import TEXT_FILE_TYPES, parallel_load_data, parse_text_file_to_data\nfrom lfx.inputs import SortableListInput\nfrom lfx.inputs.inputs import DropdownInput, MessageTextInput, StrInput\nfrom lfx.io import BoolInput, FileInput, IntInput, Output, SecretStrInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame # noqa: TC001\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service, get_storage_service\nfrom lfx.utils.async_helpers import run_until_complete\nfrom lfx.utils.validate_cloud import is_astra_cloud_environment\n\n_FILE_TOOL_CANCEL_EVENT: ContextVar[threading.Event | None] = ContextVar(\"file_tool_cancel_event\", default=None)\n_FILE_TOOL_CANCEL_WAIT_SECONDS = 6\n_FILE_TOOL_MAX_CONCURRENT_LOADS = 4\n_FILE_TOOL_LIMITER_ATTRIBUTE = \"_lfx_file_tool_load_limiter\"\n_FILE_TOOL_PROCESS_REAP_SECONDS = 2\n\n\nclass _FileToolCancelledError(RuntimeError):\n \"\"\"Stop synchronous file loading after its async tool call is cancelled.\"\"\"\n\n\ndef _raise_if_file_tool_cancelled(cancel_event: threading.Event | None = None) -> None:\n event = cancel_event or _FILE_TOOL_CANCEL_EVENT.get()\n if event is not None and event.is_set():\n raise _FileToolCancelledError\n\n\ndef _kill_and_reap_process(proc: subprocess.Popen) -> None:\n try:\n proc.kill()\n except ProcessLookupError:\n pass\n except OSError:\n logger.exception(\"Failed to kill cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while killing cancelled Docling subprocess\")\n\n try:\n proc.communicate(timeout=_FILE_TOOL_PROCESS_REAP_SECONDS)\n except subprocess.TimeoutExpired:\n logger.warning(\"Timed out draining cancelled Docling subprocess; waiting for process exit\")\n except OSError:\n logger.exception(\"Failed to drain cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while draining cancelled Docling subprocess\")\n else:\n return\n\n try:\n proc.wait(timeout=_FILE_TOOL_PROCESS_REAP_SECONDS)\n except subprocess.TimeoutExpired:\n logger.exception(\"Cancelled Docling subprocess could not be reaped within the cleanup timeout\")\n except OSError:\n logger.exception(\"Failed to reap cancelled Docling subprocess\")\n except Exception: # noqa: BLE001 - cleanup must not mask the caller's cancellation\n logger.exception(\"Unexpected error while reaping cancelled Docling subprocess\")\n\n\ndef _get_file_tool_limiter() -> asyncio.Semaphore:\n \"\"\"Return the bounded loader admission gate for the current event loop.\"\"\"\n loop = asyncio.get_running_loop()\n limiter = getattr(loop, _FILE_TOOL_LIMITER_ATTRIBUTE, None)\n if limiter is None:\n limiter = asyncio.Semaphore(_FILE_TOOL_MAX_CONCURRENT_LOADS)\n setattr(loop, _FILE_TOOL_LIMITER_ATTRIBUTE, limiter)\n return limiter\n\n\ndef _log_abandoned_file_tool_result(task: asyncio.Task) -> None:\n \"\"\"Observe unexpected failures from a synchronous loader that outlived its caller.\"\"\"\n try:\n error = task.exception()\n except asyncio.CancelledError:\n return\n if error is not None and not isinstance(error, _FileToolCancelledError):\n logger.error(\"Abandoned file loader failed after its tool call was cancelled\", exc_info=error)\n\n\ndef _get_storage_location_options():\n \"\"\"Get storage location options, filtering out Local if in Astra cloud environment.\"\"\"\n all_options = [{\"name\": \"AWS\", \"icon\": \"Amazon\"}, {\"name\": \"Google Drive\", \"icon\": \"google\"}]\n if is_astra_cloud_environment():\n return all_options\n return [{\"name\": \"Local\", \"icon\": \"hard-drive\"}, *all_options]\n\n\nclass FileComponent(BaseFileComponent):\n \"\"\"File component with optional Docling processing (isolated in a subprocess).\"\"\"\n\n display_name = \"Read File\"\n # description is now a dynamic property - see get_tool_description()\n _base_description = \"Loads and returns the content from uploaded files.\"\n documentation: str = \"https://docs.langflow.org/read-file\"\n icon = \"file-text\"\n name = \"File\"\n add_tool_output = True # Enable tool mode toggle without requiring tool_mode inputs\n\n # Extensions that can be processed without Docling (using standard text parsing)\n TEXT_EXTENSIONS = TEXT_FILE_TYPES\n\n # Extensions that require Docling for processing (images, advanced office formats, etc.)\n DOCLING_ONLY_EXTENSIONS = [\n \"adoc\",\n \"asciidoc\",\n \"asc\",\n \"bmp\",\n \"dotx\",\n \"dotm\",\n \"docm\",\n \"jpg\",\n \"jpeg\",\n \"png\",\n \"potx\",\n \"ppsx\",\n \"pptm\",\n \"potm\",\n \"ppsm\",\n \"pptx\",\n \"tiff\",\n \"xls\",\n \"xlsx\",\n \"xhtml\",\n \"webp\",\n ]\n\n # Docling-supported/compatible extensions; TEXT_FILE_TYPES are supported by the base loader.\n VALID_EXTENSIONS = [\n *TEXT_EXTENSIONS,\n *DOCLING_ONLY_EXTENSIONS,\n ]\n\n # Fixed export settings used when markdown export is requested.\n EXPORT_FORMAT = \"Markdown\"\n IMAGE_MODE = \"placeholder\"\n\n _base_inputs = deepcopy(BaseFileComponent.get_base_inputs())\n\n for input_item in _base_inputs:\n if isinstance(input_item, FileInput) and input_item.name == \"path\":\n input_item.real_time_refresh = True\n input_item.tool_mode = False # Disable tool mode for file upload input\n input_item.required = False # Make it optional so it doesn't error in tool mode\n break\n\n inputs = [\n SortableListInput(\n name=\"storage_location\",\n display_name=\"Storage Location\",\n placeholder=\"Select Location\",\n info=\"Choose where to read the file from.\",\n options=_get_storage_location_options(),\n real_time_refresh=True,\n limit=1,\n value=[{\"name\": \"Local\", \"icon\": \"hard-drive\"}],\n advanced=True,\n ),\n *_base_inputs,\n StrInput(\n name=\"file_path_str\",\n display_name=\"File Path\",\n info=(\n \"Path to the file to read. Used when component is called as a tool. \"\n \"If not provided, will use the uploaded file from 'path' input.\"\n ),\n show=False,\n advanced=True,\n tool_mode=True, # Required for Toolset toggle, but _get_tools() ignores this parameter\n required=False,\n ),\n # AWS S3 specific inputs\n SecretStrInput(\n name=\"aws_access_key_id\",\n display_name=\"AWS Access Key ID\",\n info=\"AWS Access key ID.\",\n show=False,\n advanced=False,\n required=True,\n ),\n SecretStrInput(\n name=\"aws_secret_access_key\",\n display_name=\"AWS Secret Key\",\n info=\"AWS Secret Key.\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"bucket_name\",\n display_name=\"S3 Bucket Name\",\n info=\"Enter the name of the S3 bucket.\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"aws_region\",\n display_name=\"AWS Region\",\n info=\"AWS region (e.g., us-east-1, eu-west-1).\",\n show=False,\n advanced=False,\n ),\n StrInput(\n name=\"s3_file_key\",\n display_name=\"S3 File Key\",\n info=\"The key (path) of the file in S3 bucket.\",\n show=False,\n advanced=False,\n required=True,\n ),\n # Google Drive specific inputs\n SecretStrInput(\n name=\"service_account_key\",\n display_name=\"GCP Credentials Secret Key\",\n info=\"Your Google Cloud Platform service account JSON key as a secret string (complete JSON content).\",\n show=False,\n advanced=False,\n required=True,\n ),\n StrInput(\n name=\"file_id\",\n display_name=\"Google Drive File ID\",\n info=(\"The Google Drive file ID to read. The file must be shared with the service account email.\"),\n show=False,\n advanced=False,\n required=True,\n ),\n BoolInput(\n name=\"advanced_mode\",\n display_name=\"Advanced Parser\",\n value=False,\n real_time_refresh=True,\n info=(\n \"Enable advanced document processing and export with Docling for PDFs, images, and office documents. \"\n \"Note that advanced document processing can consume significant resources.\"\n ),\n # Disabled in cloud\n show=not is_astra_cloud_environment(),\n ),\n DropdownInput(\n name=\"pipeline\",\n display_name=\"Pipeline\",\n info=\"Docling pipeline to use\",\n options=[\"standard\", \"vlm\"],\n value=\"standard\",\n advanced=True,\n real_time_refresh=True,\n ),\n DropdownInput(\n name=\"ocr_engine\",\n display_name=\"OCR Engine\",\n info=\"OCR engine to use. Only available when pipeline is set to 'standard'.\",\n options=[\"None\", \"easyocr\"],\n value=\"easyocr\",\n show=False,\n advanced=True,\n ),\n StrInput(\n name=\"md_image_placeholder\",\n display_name=\"Image placeholder\",\n info=\"Specify the image placeholder for markdown exports.\",\n value=\"<!-- image -->\",\n advanced=True,\n show=False,\n ),\n StrInput(\n name=\"md_page_break_placeholder\",\n display_name=\"Page break placeholder\",\n info=\"Add this placeholder between pages in the markdown output.\",\n value=\"\",\n advanced=True,\n show=False,\n ),\n MessageTextInput(\n name=\"doc_key\",\n display_name=\"Doc Key\",\n info=\"The key to use for the DoclingDocument column.\",\n value=\"doc\",\n advanced=True,\n show=False,\n ),\n # Deprecated input retained for backward-compatibility.\n BoolInput(\n name=\"use_multithreading\",\n display_name=\"[Deprecated] Use Multithreading\",\n advanced=True,\n value=True,\n info=\"Set 'Processing Concurrency' greater than 1 to enable multithreading.\",\n ),\n IntInput(\n name=\"concurrency_multithreading\",\n display_name=\"Processing Concurrency\",\n advanced=True,\n info=\"When multiple files are being processed, the number of files to process concurrently.\",\n value=1,\n ),\n BoolInput(\n name=\"markdown\",\n display_name=\"Markdown Export\",\n info=\"Export processed documents to Markdown format. Only available when advanced mode is enabled.\",\n value=False,\n show=False,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Raw Content\", name=\"message\", method=\"load_files_message\", tool_mode=True),\n ]\n\n # ------------------------------ Tool description with file names --------------\n\n def get_tool_description(self) -> str:\n \"\"\"Return a dynamic description that includes the names of uploaded files.\n\n This helps the Agent understand which files are available to read.\n \"\"\"\n base_description = type(self)._base_description # noqa: SLF001\n\n # Get the list of uploaded file paths\n file_paths = getattr(self, \"path\", None)\n if not file_paths:\n return base_description\n\n # Ensure it's a list\n if not isinstance(file_paths, list):\n file_paths = [file_paths]\n\n # Extract just the file names from the paths\n file_names = []\n for fp in file_paths:\n if fp:\n name = Path(fp).name\n file_names.append(name)\n\n if file_names:\n files_str = \", \".join(file_names)\n return f\"{base_description} Available files: {files_str}. Call this tool to read these files.\"\n\n return base_description\n\n @property\n def description(self) -> str:\n \"\"\"Dynamic description property that includes uploaded file names.\"\"\"\n return self.get_tool_description()\n\n async def _get_tools(self) -> list:\n \"\"\"Override to create a tool without parameters.\n\n The Read File component should use the files already uploaded via UI,\n not accept file paths from the Agent (which wouldn't know the internal paths).\n \"\"\"\n from langchain_core.tools import StructuredTool\n from pydantic import BaseModel\n\n # Empty schema - no parameters needed\n class EmptySchema(BaseModel):\n \"\"\"No parameters required - uses pre-uploaded files.\"\"\"\n\n async def read_files_tool() -> str:\n \"\"\"Read the content of uploaded files.\"\"\"\n cancel_event = threading.Event()\n cancel_token = _FILE_TOOL_CANCEL_EVENT.set(cancel_event)\n try:\n if getattr(self, \"advanced_mode\", False):\n # In advanced mode, use the markdown output path so that the\n # tool shares the same Docling processing as the advanced\n # outputs rather than triggering a second subprocess via\n # load_files_message.\n self.markdown = True\n loader = self.load_files_markdown\n else:\n loader = self.load_files_message\n # Both loaders are blocking (file IO plus, in advanced mode, a Docling\n # subprocess). Run them off the event loop so streaming/heartbeats on\n # the same loop keep flowing while the agent waits for this tool. Keep\n # admission bounded until the real worker exits so cancelled standard\n # parsers cannot build an unbounded default-executor backlog.\n load_limiter = _get_file_tool_limiter()\n await load_limiter.acquire()\n try:\n loader_task = asyncio.create_task(asyncio.to_thread(loader))\n except BaseException:\n load_limiter.release()\n raise\n loader_task.add_done_callback(lambda _task: load_limiter.release())\n try:\n result = await asyncio.shield(loader_task)\n except asyncio.CancelledError:\n cancel_event.set()\n # Give cooperative cleanup time to kill/reap Docling and remove\n # temporary files before releasing this tool invocation.\n try:\n await asyncio.wait_for(\n asyncio.shield(loader_task),\n timeout=_FILE_TOOL_CANCEL_WAIT_SECONDS,\n )\n except _FileToolCancelledError:\n pass\n except asyncio.TimeoutError:\n pass\n except Exception: # noqa: BLE001 - cancellation stays dominant over loader cleanup\n logger.exception(\"File loader failed while cleaning up a cancelled tool call\")\n finally:\n if not loader_task.done():\n loader_task.add_done_callback(_log_abandoned_file_tool_result)\n raise\n if hasattr(result, \"get_text\"):\n return result.get_text()\n if hasattr(result, \"text\"):\n return result.text\n return str(result)\n except (FileNotFoundError, ValueError, OSError, RuntimeError) as e:\n return f\"Error reading files: {e}\"\n finally:\n _FILE_TOOL_CANCEL_EVENT.reset(cancel_token)\n\n description = self.get_tool_description()\n\n tool = StructuredTool(\n name=\"load_files_message\",\n description=description,\n coroutine=read_files_tool,\n args_schema=EmptySchema,\n handle_tool_error=True,\n tags=[\"load_files_message\"],\n metadata={\n \"display_name\": \"Read File\",\n \"display_description\": description,\n },\n )\n\n return [tool]\n\n # ------------------------------ UI helpers --------------------------------------\n\n def _path_value(self, template: dict) -> list[str]:\n \"\"\"Return the list of currently selected file paths from the template.\"\"\"\n return template.get(\"path\", {}).get(\"file_path\", [])\n\n def _disable_docling_fields_in_cloud(self, build_config: dict[str, Any]) -> None:\n \"\"\"Disable all Docling-related fields in cloud environments.\"\"\"\n if \"advanced_mode\" in build_config:\n build_config[\"advanced_mode\"][\"show\"] = False\n build_config[\"advanced_mode\"][\"value\"] = False\n # Hide all Docling-related fields\n docling_fields = (\"pipeline\", \"ocr_engine\", \"doc_key\", \"md_image_placeholder\", \"md_page_break_placeholder\")\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = False\n # Also disable OCR engine specifically\n if \"ocr_engine\" in build_config:\n build_config[\"ocr_engine\"][\"value\"] = \"None\"\n\n def update_build_config(\n self,\n build_config: dict[str, Any],\n field_value: Any,\n field_name: str | None = None,\n ) -> dict[str, Any]:\n \"\"\"Show/hide Advanced Parser and related fields based on selection context.\"\"\"\n # Update storage location options dynamically based on cloud environment\n if \"storage_location\" in build_config:\n updated_options = _get_storage_location_options()\n build_config[\"storage_location\"][\"options\"] = updated_options\n\n # Handle storage location selection\n if field_name == \"storage_location\":\n # Extract selected storage location\n selected = [location[\"name\"] for location in field_value] if isinstance(field_value, list) else []\n\n # Hide all storage-specific fields first\n storage_fields = [\n \"aws_access_key_id\",\n \"aws_secret_access_key\",\n \"bucket_name\",\n \"aws_region\",\n \"s3_file_key\",\n \"service_account_key\",\n \"file_id\",\n ]\n\n for f_name in storage_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = False\n\n # Show fields based on selected storage location\n if len(selected) == 1:\n location = selected[0]\n\n if location == \"Local\":\n # Show file upload input for local storage\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = True\n\n elif location == \"AWS\":\n # Hide file upload input, show AWS fields\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = False\n\n aws_fields = [\n \"aws_access_key_id\",\n \"aws_secret_access_key\",\n \"bucket_name\",\n \"aws_region\",\n \"s3_file_key\",\n ]\n for f_name in aws_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = True\n build_config[f_name][\"advanced\"] = False\n\n elif location == \"Google Drive\":\n # Hide file upload input, show Google Drive fields\n if \"path\" in build_config:\n build_config[\"path\"][\"show\"] = False\n\n gdrive_fields = [\"service_account_key\", \"file_id\"]\n for f_name in gdrive_fields:\n if f_name in build_config:\n build_config[f_name][\"show\"] = True\n build_config[f_name][\"advanced\"] = False\n # No storage location selected - show file upload by default\n elif \"path\" in build_config:\n build_config[\"path\"][\"show\"] = True\n\n return build_config\n\n if field_name == \"path\":\n paths = self._path_value(build_config)\n\n # Disable in cloud environments\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n else:\n # If all files can be processed by docling, do so\n allow_advanced = all(not file_path.endswith((\".csv\", \".xlsx\", \".parquet\")) for file_path in paths)\n build_config[\"advanced_mode\"][\"show\"] = allow_advanced\n if not allow_advanced:\n build_config[\"advanced_mode\"][\"value\"] = False\n docling_fields = (\n \"pipeline\",\n \"ocr_engine\",\n \"doc_key\",\n \"md_image_placeholder\",\n \"md_page_break_placeholder\",\n )\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = False\n\n # Docling Processing\n elif field_name == \"advanced_mode\":\n # Disable in cloud environments - don't show Docling fields even if advanced_mode is toggled\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n else:\n docling_fields = (\n \"pipeline\",\n \"ocr_engine\",\n \"doc_key\",\n \"md_image_placeholder\",\n \"md_page_break_placeholder\",\n )\n for field in docling_fields:\n if field in build_config:\n build_config[field][\"show\"] = bool(field_value)\n if field == \"pipeline\":\n build_config[field][\"advanced\"] = not bool(field_value)\n\n elif field_name == \"pipeline\":\n # Disable in cloud environments - don't show OCR engine even if pipeline is changed\n if is_astra_cloud_environment():\n self._disable_docling_fields_in_cloud(build_config)\n elif field_value == \"standard\":\n build_config[\"ocr_engine\"][\"show\"] = True\n build_config[\"ocr_engine\"][\"value\"] = \"easyocr\"\n else:\n build_config[\"ocr_engine\"][\"show\"] = False\n build_config[\"ocr_engine\"][\"value\"] = \"None\"\n\n return build_config\n\n def update_outputs(self, frontend_node: dict[str, Any], field_name: str, field_value: Any) -> dict[str, Any]: # noqa: ARG002\n \"\"\"Dynamically show outputs based on file count/type and advanced mode.\"\"\"\n if field_name not in [\"path\", \"advanced_mode\", \"pipeline\"]:\n return frontend_node\n\n template = frontend_node.get(\"template\", {})\n paths = self._path_value(template)\n if not paths:\n return frontend_node\n\n frontend_node[\"outputs\"] = []\n if len(paths) == 1:\n file_path = paths[0] if field_name == \"path\" else frontend_node[\"template\"][\"path\"][\"file_path\"][0]\n if file_path.endswith((\".csv\", \".xlsx\", \".parquet\")):\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Structured Content\",\n name=\"dataframe\",\n method=\"load_files_structured\",\n tool_mode=True,\n ),\n )\n elif file_path.endswith(\".json\"):\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Structured Content\", name=\"json\", method=\"load_files_json\", tool_mode=True),\n )\n\n advanced_mode = frontend_node.get(\"template\", {}).get(\"advanced_mode\", {}).get(\"value\", False)\n if advanced_mode:\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Structured Output\",\n name=\"advanced_dataframe\",\n method=\"load_files_dataframe\",\n tool_mode=True,\n ),\n )\n frontend_node[\"outputs\"].append(\n Output(\n display_name=\"Markdown\", name=\"advanced_markdown\", method=\"load_files_markdown\", tool_mode=True\n ),\n )\n frontend_node[\"outputs\"].append(\n Output(display_name=\"File Path\", name=\"path\", method=\"load_files_path\", tool_mode=True),\n )\n else:\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Raw Content\", name=\"message\", method=\"load_files_message\", tool_mode=True),\n )\n frontend_node[\"outputs\"].append(\n Output(display_name=\"File Path\", name=\"path\", method=\"load_files_path\", tool_mode=True),\n )\n else:\n # Multiple files => DataFrame output; advanced parser disabled\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Files\", name=\"dataframe\", method=\"load_files\", tool_mode=True)\n )\n\n return frontend_node\n\n # ------------------------------ Core processing ----------------------------------\n\n def _get_selected_storage_location(self) -> str:\n \"\"\"Get the selected storage location from the SortableListInput.\"\"\"\n if hasattr(self, \"storage_location\") and self.storage_location:\n if isinstance(self.storage_location, list) and len(self.storage_location) > 0:\n return self.storage_location[0].get(\"name\", \"\")\n if isinstance(self.storage_location, dict):\n return self.storage_location.get(\"name\", \"\")\n return \"Local\" # Default to Local if not specified\n\n def _validate_and_resolve_paths(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Override to handle file_path_str input from tool mode and cloud storage.\n\n Priority:\n 1. Cloud storage (AWS/Google Drive) if selected\n 2. file_path_str (if provided by the tool call)\n 3. path (uploaded file from UI)\n \"\"\"\n storage_location = self._get_selected_storage_location()\n\n # Handle AWS S3\n if storage_location == \"AWS\":\n return self._read_from_aws_s3()\n\n # Handle Google Drive\n if storage_location == \"Google Drive\":\n return self._read_from_google_drive()\n\n # Handle Local storage\n # Check if file_path_str is provided (from tool mode)\n file_path_str = getattr(self, \"file_path_str\", None)\n if file_path_str:\n # Use the string path from tool mode\n from pathlib import Path\n\n from lfx.schema.data import Data\n\n # Use same resolution logic as BaseFileComponent (support storage paths)\n path_str = str(file_path_str)\n if parse_storage_path(path_str):\n try:\n resolved_path = Path(self.get_full_path(path_str))\n except (ValueError, AttributeError):\n resolved_path = Path(self.resolve_path(path_str))\n else:\n resolved_path = Path(self.resolve_path(path_str))\n\n # Security: confine tool-mode reads to the storage dir in restricted (multi-tenant)\n # mode so a tenant cannot read arbitrary server files via file_path_str.\n from lfx.utils.file_path_security import component_file_access_scopes, enforce_local_file_access\n\n resolved_path = enforce_local_file_access(resolved_path, scope_ids=component_file_access_scopes(self))\n\n if not resolved_path.exists():\n msg = f\"File or directory not found: {file_path_str}\"\n self.log(msg)\n if not self.silent_errors:\n raise ValueError(msg)\n return []\n\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(resolved_path)})\n return [BaseFileComponent.BaseFile(data_obj, resolved_path, delete_after_processing=False)]\n\n # Otherwise use the default implementation (uses path FileInput)\n return super()._validate_and_resolve_paths()\n\n def _read_from_aws_s3(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Read file from AWS S3.\"\"\"\n from lfx.base.data.cloud_storage_utils import create_s3_client, validate_aws_credentials\n\n # Validate AWS credentials\n validate_aws_credentials(self)\n if not getattr(self, \"s3_file_key\", None):\n msg = \"S3 File Key is required\"\n raise ValueError(msg)\n\n # Create S3 client\n s3_client = create_s3_client(self)\n\n # Download file to temp location\n import tempfile\n\n # Get file extension from S3 key\n file_extension = Path(self.s3_file_key).suffix or \"\"\n\n with tempfile.NamedTemporaryFile(mode=\"wb\", suffix=file_extension, delete=False) as temp_file:\n temp_file_path = temp_file.name\n try:\n s3_client.download_fileobj(self.bucket_name, self.s3_file_key, temp_file)\n except Exception as e:\n # Clean up temp file on failure\n with contextlib.suppress(OSError):\n Path(temp_file_path).unlink()\n msg = f\"Failed to download file from S3: {e}\"\n raise RuntimeError(msg) from e\n\n # Create BaseFile object\n from lfx.schema.data import Data\n\n temp_path = Path(temp_file_path)\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(temp_path)})\n return [BaseFileComponent.BaseFile(data_obj, temp_path, delete_after_processing=True)]\n\n def _read_from_google_drive(self) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Read file from Google Drive.\"\"\"\n import tempfile\n\n from googleapiclient.http import MediaIoBaseDownload\n\n from lfx.base.data.cloud_storage_utils import create_google_drive_service\n\n # Validate Google Drive credentials\n if not getattr(self, \"service_account_key\", None):\n msg = \"GCP Credentials Secret Key is required for Google Drive storage\"\n raise ValueError(msg)\n if not getattr(self, \"file_id\", None):\n msg = \"Google Drive File ID is required\"\n raise ValueError(msg)\n\n # Create Google Drive service with read-only scope\n drive_service = create_google_drive_service(\n self.service_account_key, scopes=[\"https://www.googleapis.com/auth/drive.readonly\"]\n )\n\n # Get file metadata to determine file name and extension\n try:\n file_metadata = drive_service.files().get(fileId=self.file_id, fields=\"name,mimeType\").execute()\n file_name = file_metadata.get(\"name\", \"download\")\n except Exception as e:\n msg = (\n f\"Unable to access file with ID '{self.file_id}'. \"\n f\"Error: {e!s}. \"\n \"Please ensure: 1) The file ID is correct, 2) The file exists, \"\n \"3) The service account has been granted access to this file.\"\n )\n raise ValueError(msg) from e\n\n # Download file to temp location\n file_extension = Path(file_name).suffix or \"\"\n with tempfile.NamedTemporaryFile(mode=\"wb\", suffix=file_extension, delete=False) as temp_file:\n temp_file_path = temp_file.name\n try:\n request = drive_service.files().get_media(fileId=self.file_id)\n downloader = MediaIoBaseDownload(temp_file, request)\n done = False\n while not done:\n _status, done = downloader.next_chunk()\n except Exception as e:\n # Clean up temp file on failure\n with contextlib.suppress(OSError):\n Path(temp_file_path).unlink()\n msg = f\"Failed to download file from Google Drive: {e}\"\n raise RuntimeError(msg) from e\n\n # Create BaseFile object\n from lfx.schema.data import Data\n\n temp_path = Path(temp_file_path)\n data_obj = Data(data={self.SERVER_FILE_PATH_FIELDNAME: str(temp_path)})\n return [BaseFileComponent.BaseFile(data_obj, temp_path, delete_after_processing=True)]\n\n def _is_docling_compatible(self, file_path: str) -> bool:\n \"\"\"Lightweight extension gate for Docling-compatible types.\"\"\"\n docling_exts = (\n \".adoc\",\n \".asciidoc\",\n \".asc\",\n \".bmp\",\n \".csv\",\n \".dotx\",\n \".dotm\",\n \".docm\",\n \".docx\",\n \".htm\",\n \".html\",\n \".jpg\",\n \".jpeg\",\n \".json\",\n \".md\",\n \".pdf\",\n \".png\",\n \".potx\",\n \".ppsx\",\n \".pptm\",\n \".potm\",\n \".ppsm\",\n \".pptx\",\n \".tiff\",\n \".txt\",\n \".xls\",\n \".xlsx\",\n \".xhtml\",\n \".xml\",\n \".webp\",\n )\n return file_path.lower().endswith(docling_exts)\n\n async def _get_local_file_for_docling(self, file_path: str) -> tuple[str, bool]:\n \"\"\"Get a local file path for Docling processing, downloading from S3 if needed.\n\n Args:\n file_path: Either a local path or S3 key (format \"flow_id/filename\")\n\n Returns:\n tuple[str, bool]: (local_path, should_delete) where should_delete indicates\n if this is a temporary file that should be cleaned up\n \"\"\"\n settings = get_settings_service().settings\n if settings.storage_type == \"local\":\n return file_path, False\n\n # S3 storage - download to temp file\n parsed = parse_storage_path(file_path)\n if not parsed:\n msg = f\"Invalid S3 path format: {file_path}. Expected 'flow_id/filename'\"\n raise ValueError(msg)\n\n storage_service = get_storage_service()\n flow_id, filename = parsed\n\n # Get file content from S3\n content = await storage_service.get_file(flow_id, filename)\n\n suffix = Path(filename).suffix\n with NamedTemporaryFile(mode=\"wb\", suffix=suffix, delete=False) as tmp_file:\n tmp_file.write(content)\n temp_path = tmp_file.name\n\n return temp_path, True\n\n def _process_docling_in_subprocess(self, file_path: str) -> Data | None:\n \"\"\"Run Docling in a separate OS process and map the result to a Data object.\n\n We avoid multiprocessing pickling by launching `python -c \"<script>\"` and\n passing JSON config via stdin. The child prints a JSON result to stdout.\n\n For S3 storage, the file is downloaded to a temp file first.\n \"\"\"\n if not file_path:\n return None\n\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n _raise_if_file_tool_cancelled(cancel_event)\n settings = get_settings_service().settings\n if is_remote_storage_type(settings.storage_type):\n local_path, should_delete = run_until_complete(self._get_local_file_for_docling(file_path))\n else:\n local_path = file_path\n should_delete = False\n\n try:\n _raise_if_file_tool_cancelled(cancel_event)\n return self._process_docling_subprocess_impl(local_path, file_path)\n finally:\n # Clean up temp file if we created one\n if should_delete:\n with contextlib.suppress(Exception):\n Path(local_path).unlink() # Ignore cleanup errors\n\n def _process_docling_subprocess_impl(self, local_file_path: str, original_file_path: str) -> Data | None:\n \"\"\"Implementation of Docling subprocess processing.\n\n Args:\n local_file_path: Path to local file to process\n original_file_path: Original file path to include in metadata\n Returns:\n Data object with processed content\n \"\"\"\n args: dict[str, Any] = {\n \"file_path\": local_file_path,\n \"markdown\": bool(self.markdown),\n \"image_mode\": str(self.IMAGE_MODE),\n \"md_image_placeholder\": str(self.md_image_placeholder),\n \"md_page_break_placeholder\": str(self.md_page_break_placeholder),\n \"pipeline\": str(self.pipeline),\n \"ocr_engine\": (\n self.ocr_engine if self.ocr_engine and self.ocr_engine != \"None\" and self.pipeline != \"vlm\" else None\n ),\n }\n\n # Child script for isolating the docling processing\n child_script = textwrap.dedent(\n r\"\"\"\n import json, sys\n\n def try_imports():\n try:\n from docling.datamodel.base_models import ConversionStatus, InputFormat # type: ignore\n from docling.document_converter import DocumentConverter # type: ignore\n from docling_core.types.doc import ImageRefMode # type: ignore\n return ConversionStatus, InputFormat, DocumentConverter, ImageRefMode, \"latest\"\n except Exception as e:\n raise e\n\n def create_converter(strategy, input_format, DocumentConverter, pipeline, ocr_engine):\n # --- Standard PDF/IMAGE pipeline (your existing behavior), with optional OCR ---\n if pipeline == \"standard\":\n try:\n from docling.datamodel.pipeline_options import PdfPipelineOptions # type: ignore\n from docling.document_converter import PdfFormatOption # type: ignore\n\n pipe = PdfPipelineOptions()\n pipe.do_ocr = False\n\n if ocr_engine:\n try:\n from docling.models.factories import get_ocr_factory # type: ignore\n pipe.do_ocr = True\n fac = get_ocr_factory(allow_external_plugins=False)\n pipe.ocr_options = fac.create_options(kind=ocr_engine)\n except Exception:\n # If OCR setup fails, disable it\n pipe.do_ocr = False\n\n fmt = {}\n if hasattr(input_format, \"PDF\"):\n fmt[getattr(input_format, \"PDF\")] = PdfFormatOption(pipeline_options=pipe)\n if hasattr(input_format, \"IMAGE\"):\n fmt[getattr(input_format, \"IMAGE\")] = PdfFormatOption(pipeline_options=pipe)\n\n return DocumentConverter(format_options=fmt)\n except Exception:\n return DocumentConverter()\n\n # --- Vision-Language Model (VLM) pipeline ---\n if pipeline == \"vlm\":\n try:\n from docling.datamodel.pipeline_options import VlmPipelineOptions\n from docling.datamodel.vlm_model_specs import GRANITEDOCLING_MLX, GRANITEDOCLING_TRANSFORMERS\n from docling.document_converter import PdfFormatOption\n from docling.pipeline.vlm_pipeline import VlmPipeline\n\n vl_pipe = VlmPipelineOptions(\n vlm_options=GRANITEDOCLING_TRANSFORMERS,\n )\n\n if sys.platform == \"darwin\":\n try:\n import mlx_vlm\n vl_pipe.vlm_options = GRANITEDOCLING_MLX\n except ImportError as e:\n raise e\n\n # VLM paths generally don't need OCR; keep OCR off by default here.\n fmt = {}\n if hasattr(input_format, \"PDF\"):\n fmt[getattr(input_format, \"PDF\")] = PdfFormatOption(\n pipeline_cls=VlmPipeline,\n pipeline_options=vl_pipe\n )\n if hasattr(input_format, \"IMAGE\"):\n fmt[getattr(input_format, \"IMAGE\")] = PdfFormatOption(\n pipeline_cls=VlmPipeline,\n pipeline_options=vl_pipe\n )\n\n return DocumentConverter(format_options=fmt)\n except Exception as e:\n raise e\n\n # --- Fallback: default converter with no special options ---\n return DocumentConverter()\n\n def export_markdown(document, ImageRefMode, image_mode, img_ph, pg_ph):\n try:\n mode = getattr(ImageRefMode, image_mode.upper(), image_mode)\n return document.export_to_markdown(\n image_mode=mode,\n image_placeholder=img_ph,\n page_break_placeholder=pg_ph,\n )\n except Exception:\n try:\n return document.export_to_text()\n except Exception:\n return str(document)\n\n def to_rows(doc_dict):\n rows = []\n for t in doc_dict.get(\"texts\", []):\n prov = t.get(\"prov\") or []\n page_no = None\n if prov and isinstance(prov, list) and isinstance(prov[0], dict):\n page_no = prov[0].get(\"page_no\")\n rows.append({\n \"page_no\": page_no,\n \"label\": t.get(\"label\"),\n \"text\": t.get(\"text\"),\n \"level\": t.get(\"level\"),\n })\n return rows\n\n def main():\n cfg = json.loads(sys.stdin.read())\n file_path = cfg[\"file_path\"]\n markdown = cfg[\"markdown\"]\n image_mode = cfg[\"image_mode\"]\n img_ph = cfg[\"md_image_placeholder\"]\n pg_ph = cfg[\"md_page_break_placeholder\"]\n pipeline = cfg[\"pipeline\"]\n ocr_engine = cfg.get(\"ocr_engine\")\n meta = {\"file_path\": file_path}\n\n try:\n ConversionStatus, InputFormat, DocumentConverter, ImageRefMode, strategy = try_imports()\n converter = create_converter(strategy, InputFormat, DocumentConverter, pipeline, ocr_engine)\n try:\n res = converter.convert(file_path)\n except Exception as e:\n print(json.dumps({\"ok\": False, \"error\": f\"Docling conversion error: {e}\", \"meta\": meta}))\n return\n\n ok = False\n if hasattr(res, \"status\"):\n try:\n ok = (res.status == ConversionStatus.SUCCESS) or (str(res.status).lower() == \"success\")\n except Exception:\n ok = (str(res.status).lower() == \"success\")\n if not ok and hasattr(res, \"document\"):\n ok = getattr(res, \"document\", None) is not None\n if not ok:\n print(json.dumps({\"ok\": False, \"error\": \"Docling conversion failed\", \"meta\": meta}))\n return\n\n doc = getattr(res, \"document\", None)\n if doc is None:\n print(json.dumps({\"ok\": False, \"error\": \"Docling produced no document\", \"meta\": meta}))\n return\n\n # Extract DoclingDocument metadata\n if hasattr(doc, \"name\") and doc.name:\n meta[\"name\"] = doc.name\n if hasattr(doc, \"origin\") and doc.origin is not None:\n origin = doc.origin\n if hasattr(origin, \"filename\") and origin.filename:\n meta[\"filename\"] = origin.filename\n if hasattr(origin, \"binary_hash\") and origin.binary_hash:\n meta[\"document_id\"] = str(origin.binary_hash)\n if hasattr(origin, \"mimetype\") and origin.mimetype:\n meta[\"mimetype\"] = origin.mimetype\n\n if markdown:\n text = export_markdown(doc, ImageRefMode, image_mode, img_ph, pg_ph)\n print(json.dumps({\"ok\": True, \"mode\": \"markdown\", \"text\": text, \"meta\": meta}))\n return\n\n # structured\n try:\n doc_dict = doc.export_to_dict()\n except Exception as e:\n print(json.dumps({\"ok\": False, \"error\": f\"Docling export_to_dict failed: {e}\", \"meta\": meta}))\n return\n\n rows = to_rows(doc_dict)\n print(json.dumps({\"ok\": True, \"mode\": \"structured\", \"doc\": rows, \"meta\": meta}))\n except Exception as e:\n print(\n json.dumps({\n \"ok\": False,\n \"error\": f\"Docling processing error: {e}\",\n \"meta\": {\"file_path\": file_path},\n })\n )\n\n if __name__ == \"__main__\":\n main()\n \"\"\"\n )\n\n # The path is passed as JSON over stdin to an argument-list subprocess, so shell\n # metacharacters are ordinary filename characters here.\n if not isinstance(args[\"file_path\"], str):\n return Data(data={\"error\": \"Unsafe file path detected.\", \"file_path\": args[\"file_path\"]})\n\n # Use communicate() in bounded intervals so stdout/stderr are drained while the\n # child runs without losing the heartbeat that keeps the SSE event stream alive.\n docling_timeout = 600 # 10 minutes; large PDFs with OCR may need this\n poll_interval = 5 # seconds between progress heartbeats\n\n proc = subprocess.Popen( # noqa: S603\n [sys.executable, \"-u\", \"-c\", child_script],\n stdin=subprocess.PIPE,\n stdout=subprocess.PIPE,\n stderr=subprocess.PIPE,\n )\n\n start = time.monotonic()\n input_bytes: bytes | None = json.dumps(args).encode(\"utf-8\")\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n try:\n while True:\n _raise_if_file_tool_cancelled(cancel_event)\n elapsed = time.monotonic() - start\n if elapsed >= docling_timeout:\n proc.kill()\n proc.communicate()\n return Data(\n data={\n \"error\": (\n f\"Docling processing timed out after {docling_timeout}s. \"\n \"Consider using the standalone Docling component for large documents.\"\n ),\n \"file_path\": original_file_path,\n },\n )\n try:\n stdout_bytes, stderr_bytes = proc.communicate(\n input=input_bytes,\n timeout=min(poll_interval, docling_timeout - elapsed),\n )\n _raise_if_file_tool_cancelled(cancel_event)\n break\n except subprocess.TimeoutExpired:\n # communicate() retains partially written input and collected output across\n # retries, so subsequent calls continue draining without resending stdin.\n input_bytes = None\n elapsed = time.monotonic() - start\n self.log(f\"Docling processing in progress ({int(elapsed)}s elapsed)...\")\n finally:\n if (cancel_event is not None and cancel_event.is_set()) or proc.poll() is None:\n _kill_and_reap_process(proc)\n\n if not stdout_bytes:\n err_msg = stderr_bytes.decode(\"utf-8\", errors=\"replace\") if stderr_bytes else \"no output from child process\"\n return Data(data={\"error\": f\"Docling subprocess error: {err_msg}\", \"file_path\": original_file_path})\n\n try:\n result = json.loads(stdout_bytes.decode(\"utf-8\"))\n except Exception as e: # noqa: BLE001\n err_msg = stderr_bytes.decode(\"utf-8\", errors=\"replace\")\n return Data(\n data={\n \"error\": f\"Invalid JSON from Docling subprocess: {e}. stderr={err_msg}\",\n \"file_path\": original_file_path,\n },\n )\n\n if not result.get(\"ok\"):\n error_msg = result.get(\"error\", \"Unknown Docling error\")\n # Override meta file_path with original_file_path to ensure correct path matching\n meta = result.get(\"meta\", {})\n meta[\"file_path\"] = original_file_path\n return Data(data={\"error\": error_msg, **meta})\n\n meta = result.get(\"meta\", {})\n # Override meta file_path with original_file_path to ensure correct path matching\n # The subprocess returns the temp file path, but we need the original S3/local path for rollup_data\n meta[\"file_path\"] = original_file_path\n if result.get(\"mode\") == \"markdown\":\n exported_content = str(result.get(\"text\", \"\"))\n return Data(\n text=exported_content,\n data={\"exported_content\": exported_content, \"export_format\": self.EXPORT_FORMAT, **meta},\n )\n\n rows = list(result.get(\"doc\", []))\n return Data(data={\"doc\": rows, \"export_format\": self.EXPORT_FORMAT, **meta})\n\n def process_files(\n self,\n file_list: list[BaseFileComponent.BaseFile],\n ) -> list[BaseFileComponent.BaseFile]:\n \"\"\"Process input files.\n\n - advanced_mode => Docling in a separate process.\n - Otherwise => standard parsing in current process (optionally threaded).\n \"\"\"\n cancel_event = _FILE_TOOL_CANCEL_EVENT.get()\n _raise_if_file_tool_cancelled(cancel_event)\n if not file_list:\n msg = \"No files to process.\"\n raise ValueError(msg)\n\n # Validate image files to detect content/extension mismatches\n # This prevents API errors like \"Image does not match the provided media type\"\n image_extensions = {\"jpeg\", \"jpg\", \"png\", \"gif\", \"webp\", \"bmp\", \"tiff\"}\n settings = get_settings_service().settings\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n extension = file.path.suffix[1:].lower()\n if extension in image_extensions:\n # Read bytes based on storage type\n try:\n if is_remote_storage_type(settings.storage_type):\n # For S3 storage, use storage service to read file bytes\n file_path_str = str(file.path)\n content = run_until_complete(read_file_bytes(file_path_str))\n else:\n # For local storage, read bytes directly from filesystem\n content = file.path.read_bytes()\n\n is_valid, error_msg = validate_image_content_type(\n str(file.path),\n content=content,\n )\n if not is_valid:\n self.log(error_msg)\n if not self.silent_errors:\n raise ValueError(error_msg)\n except (OSError, FileNotFoundError) as e:\n self.log(f\"Could not read file for validation: {e}\")\n # Continue - let it fail later with better error\n\n # Validate that files requiring Docling are only processed when advanced mode is enabled\n if not self.advanced_mode:\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n extension = file.path.suffix[1:].lower()\n if extension in self.DOCLING_ONLY_EXTENSIONS:\n if is_astra_cloud_environment():\n msg = (\n f\"File '{file.path.name}' has extension '.{extension}' which requires \"\n f\"Advanced Parser mode. Advanced Parser is not available in cloud environments.\"\n )\n else:\n msg = (\n f\"File '{file.path.name}' has extension '.{extension}' which requires \"\n f\"Advanced Parser mode. Please enable 'Advanced Parser' to process this file.\"\n )\n self.log(msg)\n raise ValueError(msg)\n\n def process_file_standard(file_path: str, *, silent_errors: bool = False) -> Data | None:\n try:\n _raise_if_file_tool_cancelled(cancel_event)\n result = parse_text_file_to_data(file_path, silent_errors=silent_errors)\n _raise_if_file_tool_cancelled(cancel_event)\n except _FileToolCancelledError:\n raise\n except FileNotFoundError as e:\n self.log(f\"File not found: {file_path}. Error: {e}\")\n if not silent_errors:\n raise\n return None\n except Exception as e:\n self.log(f\"Unexpected error processing {file_path}: {e}\")\n if not silent_errors:\n raise\n return None\n else:\n return result\n\n docling_compatible = all(self._is_docling_compatible(str(f.path)) for f in file_list)\n\n # Advanced path: Check if ALL files are compatible with Docling\n if self.advanced_mode and docling_compatible:\n final_return: list[BaseFileComponent.BaseFile] = []\n for file in file_list:\n _raise_if_file_tool_cancelled(cancel_event)\n file_path = str(file.path)\n advanced_data: Data | None = self._process_docling_in_subprocess(file_path)\n _raise_if_file_tool_cancelled(cancel_event)\n\n # Handle None case - Docling processing failed or returned None\n if advanced_data is None:\n error_data = Data(\n data={\n \"file_path\": file_path,\n \"error\": \"Docling processing returned no result. Check logs for details.\",\n },\n )\n final_return.extend(self.rollup_data([file], [error_data]))\n continue\n\n # --- UNNEST: expand each element in `doc` to its own Data row\n payload = getattr(advanced_data, \"data\", {}) or {}\n\n # Check for errors first\n if \"error\" in payload:\n error_msg = payload.get(\"error\", \"Unknown error\")\n error_data = Data(\n data={\n \"file_path\": file_path,\n \"error\": error_msg,\n **{k: v for k, v in payload.items() if k not in (\"error\", \"file_path\")},\n },\n )\n final_return.extend(self.rollup_data([file], [error_data]))\n continue\n\n doc_rows = payload.get(\"doc\")\n if isinstance(doc_rows, list) and doc_rows:\n # Non-empty list of structured rows\n rows: list[Data | None] = [\n Data(\n data={\n \"file_path\": file_path,\n **(item if isinstance(item, dict) else {\"value\": item}),\n },\n )\n for item in doc_rows\n ]\n final_return.extend(self.rollup_data([file], rows))\n elif isinstance(doc_rows, list) and not doc_rows:\n # Empty list - file was processed but no text content found\n # Create a Data object indicating no content was extracted\n self.log(f\"No text extracted from '{file_path}', creating placeholder data\")\n empty_data = Data(\n data={\n \"file_path\": file_path,\n \"text\": \"(No text content extracted from image)\",\n \"info\": \"Image processed successfully but contained no extractable text\",\n **{k: v for k, v in payload.items() if k != \"doc\"},\n },\n )\n final_return.extend(self.rollup_data([file], [empty_data]))\n else:\n # If not structured, keep as-is (e.g., markdown export or error dict)\n # Ensure file_path is set for proper rollup matching\n if not payload.get(\"file_path\"):\n payload[\"file_path\"] = file_path\n # Create new Data with file_path\n advanced_data = Data(\n data=payload,\n text=getattr(advanced_data, \"text\", None),\n )\n final_return.extend(self.rollup_data([file], [advanced_data]))\n return final_return\n\n # Standard multi-file (or single non-advanced) path\n concurrency = max(1, self.concurrency_multithreading)\n\n file_paths = [str(f.path) for f in file_list]\n self.log(f\"Starting parallel processing of {len(file_paths)} files with concurrency: {concurrency}.\")\n my_data = parallel_load_data(\n file_paths,\n silent_errors=self.silent_errors,\n load_function=process_file_standard,\n max_concurrency=concurrency,\n )\n _raise_if_file_tool_cancelled(cancel_event)\n return self.rollup_data(file_list, my_data)\n\n # ------------------------------ Output helpers -----------------------------------\n\n def load_files_helper(self) -> DataFrame:\n result = self.load_files()\n\n # Result is a DataFrame - check if it has any rows\n if result.empty:\n msg = \"Could not extract content from the provided file(s).\"\n raise ValueError(msg)\n\n # Check for error column with error messages\n if \"error\" in result.columns:\n errors = result[\"error\"].dropna().tolist()\n if errors and not any(col in result.columns for col in [\"text\", \"doc\", \"exported_content\"]):\n raise ValueError(errors[0])\n\n return result\n\n def load_files_dataframe(self) -> DataFrame:\n \"\"\"Load files using advanced Docling processing and export to DataFrame format.\"\"\"\n self.markdown = False\n return self.load_files_helper()\n\n def load_files_markdown(self) -> Message:\n \"\"\"Load files using advanced Docling processing and export to Markdown format.\"\"\"\n self.markdown = True\n result = self.load_files_helper()\n\n # Result is a DataFrame - check for text or exported_content columns\n if \"text\" in result.columns and not result[\"text\"].isna().all():\n text_values = result[\"text\"].dropna().tolist()\n if text_values:\n return Message(text=str(text_values[0]))\n\n if \"exported_content\" in result.columns and not result[\"exported_content\"].isna().all():\n content_values = result[\"exported_content\"].dropna().tolist()\n if content_values:\n return Message(text=str(content_values[0]))\n\n # Return empty message with info that no text was found\n return Message(text=\"(No text content extracted from file)\")\n"
async def _get_local_file_for_docling(self, file_path: str) -> tuple[str, bool]:
"""Get a local file path for Docling processing, downloading from S3 if needed.
Args:
file_path: Either a local path or S3 key (format "flow_id/filename")
Returns:
tuple[str, bool]: (local_path, should_delete) where should_delete indicates
if this is a temporary file that should be cleaned up
"""
settings = get_settings_service().settings
if settings.storage_type == "local":
return file_path, False
# Remote storage - download to a temporary local file
parsed = parse_storage_path(file_path)
if not parsed:
msg = (
f"Invalid {settings.storage_type} path format: {file_path}. "
"Expected 'flow_id/filename'"
)
raise ValueError(msg)
storage_service = get_storage_service()
flow_id, filename = parsed
# Get file content from remote storage
content = await storage_service.get_file(flow_id, filename)
suffix = Path(filename).suffix
with NamedTemporaryFile(mode="wb", suffix=suffix, delete=False) as tmp_file:
tmp_file.write(content)
temp_path = tmp_file.name
return temp_path, True
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/initial_setup/starter_projects/Text` Sentiment
Analysis.json at line 1143, Update _get_local_file_for_docling to use
provider-neutral “remote storage” terminology in its comments and invalid-path
error message, rather than referring specifically to S3. If useful, include
settings.storage_type in the diagnostic while preserving the existing validation
behavior for all remote backends.

Comment on lines +277 to +313
async def get_file_stream(self, flow_id: str, file_name: str, chunk_size: int = 8192) -> AsyncIterator[bytes]:
"""Retrieve a file from Azure Blob storage as a stream.

Each chunk is fetched via a ranged download so an arbitrary chunk_size can be
honored without buffering the whole blob in memory.

Args:
flow_id: The flow/user identifier for namespacing
file_name: The name of the file to retrieve
chunk_size: Size of chunks to yield (default: 8192 bytes)

Yields:
bytes: Chunks of the file content

Raises:
FileNotFoundError: If the file does not exist
"""
self._validate_identifiers(flow_id, file_name)
key = self.build_full_path(flow_id, file_name)

from azure.core.exceptions import ResourceNotFoundError

blob_client = self._get_blob_client(key)
try:
properties = await blob_client.get_blob_properties()
except ResourceNotFoundError as e:
await logger.awarning(f"File {file_name} not found in Azure Blob flow {flow_id}")
msg = f"File not found: {file_name}"
raise FileNotFoundError(msg) from e

total_size = properties.size
offset = 0
while offset < total_size:
length = min(chunk_size, total_size - offset)
downloader = await blob_client.download_blob(offset=offset, length=length)
yield await downloader.readall()
offset += length

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

azure-storage-blob aio download_blob chunks() max_chunk_get_size etag match_condition

💡 Result:

In the Azure SDK for Python (azure-storage-blob), interacting with blob downloads using asynchronous clients involves specific configurations for chunking, etag validation, and match conditions [1][2]. Configuration of Chunking Behavior The download chunk size is managed by the max_chunk_get_size parameter [1][2]. This is configured when instantiating a BlobClient or BlobServiceClient, rather than within the download_blob method itself [2][3]. The default value is 4 MiB (4 * 1024 * 1024 bytes) [1][2]. Another related parameter, max_single_get_size (default 32 MiB), controls the maximum size for a single-call download before the SDK switches to chunked downloads [2][3]. Using chunks When calling download_blob, the returned object is a StorageStreamDownloader [4][1]. The chunks method provides an asynchronous iterator that allows you to process the blob content in chunks [4][1]. This is particularly useful for memory-efficient handling of large blobs [2]. ETag and Match Conditions The etag and match_condition parameters are passed directly to the download_blob method [5][6]. - etag: The ETag string or wildcard () used to check the blob's state [5][7]. - match_condition: An instance of azure.core.MatchConditions [5][8]. Note that the ETag is generally only required when using specific MatchConditions, such as IfModified or IfNotModified [9]. Providing an ETag when it is not required by the match condition can occasionally lead to unexpected behavior in some SDK versions [9]. Example Usage blob_client = BlobClient( account_url=account_url, container_name=container_name, blob_name=blob_name, credential=credential, max_chunk_get_size=10241024*4 # 4 MiB) downloader = await blob_client.download_blob( etag="your-etag-value", match_condition=MatchConditions.IfNotModified) async for chunk in downloader.chunks: process(chunk)

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Repository files matching azure_blob.py:\n'
fd -a 'azure_blob\.py$' . | sed 's#^\./##' || true

printf '\nRelevant source slice:\n'
if [ -f src/backend/base/langflow/services/storage/azure_blob.py ]; then
  sed -n '240,320p' src/backend/base/langflow/services/storage/azure_blob.py | nl -ba -v240
fi

printf '\nDependency/version references for azure-storage-blob:\n'
rg -n "azure-storage-blob|azure\.storage\.blob|match_condition|IfNotModified|download_blob|chunks\(" pyproject.toml uv.lock requirements* src -g '!**/__pycache__/**' | head -n 120

Repository: langflow-ai/langflow

Length of output: 339


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Relevant source slice:\n'
sed -n '240,320p' src/backend/base/langflow/services/storage/azure_blob.py | awk '{printf "%6d: %s\n", NR+239, $0}'

printf '\nDependency/version references for azure-storage-blob:\n'
rg -n "azure-storage-blob|azure\.storage\.blob|match_condition|IfNotModified|download_blob|chunks\(" pyproject.toml uv.lock requirements* src -g '!**/__pycache__/**' | head -n 200

printf '\nNearby get_file implementation:\n'
sed -n '245,276p' src/backend/base/langflow/services/storage/azure_blob.py | awk '{printf "%6d: %s\n", NR+244, $0}'

Repository: langflow-ai/langflow

Length of output: 12396


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Lock file azure-storage-blob entries:\n'
sed -n '8528,8546p' uv.lock | awk '{printf "%6d: %s\n", NR+8527, $0}'
sed -n '950,956p' uv.lock | awk '{printf "%6d: %s\n", NR+949, $0}'

printf '\nBackend pyproject dependency context:\n'
sed -n '124,134p' src/backend/base/pyproject.toml | awk '{printf "%6d: %s\n", NR+123, $0}'

printf '\nStorage interface/get_file_stream usages:\n'
rg -n "get_file_stream|chunk_size" src/backend/base/langflow/services/storage src/backend/base/langflow -g '!**/__pycache__/**' | head -n 120

printf '\nRead-only Azure SDK verifier:\n'
python3 - <<'PY'
import sys
print("python", sys.version.split()[0])
try:
    import azure.storage.blob.aio as blob_aio
    import azure.core
    print("azure.storage.blob.aio available")
    print("python package", getattr(blob_aio.BlobServiceClient, "__module__", None))
    print("download_blob signatures", blob_aio.BlobClient.download_blob.__annotations__)
    print("chunks present", isinstance(blob_aio.StorageStreamDownloader.chunks, type(iter)))
    print("match conditions")
    print(dir(azure.core.MatchConditions))
except Exception as exc:
    print(type(exc).__name__, exc)
PY

Repository: langflow-ai/langflow

Length of output: 50377


🌐 Web query:

Azure Storage Blob Python SDK StorageStreamDownloader chunks default chunk size offset length download_blob default chunk size

💡 Result:

In the Azure Storage Blob Python SDK, downloading a blob is managed through the BlobClient, which returns a StorageStreamDownloader object when the download_blob method is called [1][2]. The behavior of this download, including chunking, is controlled by configuration options set on the client [3][2]. Default Chunk Sizes: The following configuration options determine how data is downloaded: 1. max_chunk_get_size: This is the maximum size for each individual chunk downloaded from the service. It defaults to 4 MiB (4 * 1024 * 1024 bytes) [3][2][4]. 2. max_single_get_size: This is the maximum size for a blob to be downloaded in a single HTTP request. If the total blob size (or the specified range) is larger than this value, the SDK automatically splits the download into multiple chunks, each up to max_chunk_get_size. It defaults to 32 MiB (32 * 1024 * 1024 bytes) [3][2][4]. Using chunks: The StorageStreamDownloader object provides a chunks method, which returns an iterator [5][1][2]. This allows you to iterate over the blob content in chunks. The size of these chunks is governed by the max_chunk_get_size configuration [3][2]. Offset and Length: When calling download_blob, you can specify optional offset and length parameters [1][2]: - offset: The starting byte position from which to begin the download [5][1]. - length: The total number of bytes to read [5][1]. If length is provided, the offset must also be set [5][1]. Providing these parameters allows you to download a specific section or range of the blob rather than the entire file [5][1]. You can tune these values during the instantiation of the BlobClient by passing them as keyword arguments [3]: blob_client = BlobClient( account_url=account_url, container_name=container_name, blob_name=blob_name, credential=credential, max_chunk_get_size=102410244, # Example: 4 MiB max_single_get_size=1024102432 # Example: 32 MiB)

Citations:


🌐 Web query:

Azure Storage Blob Python SDK source StorageStreamDownloader chunks implementation max_chunk_get_size constructor

💡 Result:

In the Azure Storage Blob Python SDK, max_chunk_get_size is a configuration parameter used to define the maximum chunk size for downloading a blob [1][2]. When a blob is downloaded, if its size exceeds the max_single_get_size (which defaults to 32 MiB), the SDK automatically fetches the remainder of the data in parallel chunks based on the max_chunk_get_size [1][2]. The default value for max_chunk_get_size is 4 MiB [1][2]. Key details regarding its implementation and usage: 1. Configuration: This parameter is typically set when instantiating a client (such as BlobClient, ContainerClient, or BlobServiceClient) [1]. It is part of the client's configuration options used by the StorageStreamDownloader to manage download operations [3][4]. 2. StorageStreamDownloader: This class handles the actual download process [3][4]. Internally, it utilizes a _ChunkDownloader component that references this chunk size to coordinate data retrieval from the Azure service [5]. 3. Parallelism: While max_chunk_get_size determines the size of individual chunk requests, the max_concurrency parameter (passed during the call to download_blob) controls how many of these chunks are downloaded in parallel [1]. 4. Source Implementation: Within the SDK's internal source code (_download.py), the max_chunk_get_size value is retrieved from the client's configuration object [5]. It is used to calculate byte ranges for requests and to determine chunk boundaries during the download stream's execution [6][5]. Example of setting this option: blob_client = BlobClient(..., max_single_get_size=1024102432, # 32 MiB max_chunk_get_size=102410244 # 4 MiB)

Citations:


Use SDK streaming and snapshot the blob before reading chunks.

get_file_stream calls a separate ranged download per yielded chunk, so chunk_size=8192 makes large files issue thousands of HTTP requests. A concurrent overwrite between get_blob_properties() and the ranges can also yield inconsistent bytes and a stale total_size. Create one downloader with ETag validation and stream downloader.chunks() instead of issuing per-chunk offset=length downloads.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/storage/azure_blob.py` around lines 277 -
313, Update get_file_stream to create a single Azure downloader after obtaining
the blob’s properties, passing the properties ETag as the downloader’s match
condition to snapshot the blob. Replace the per-chunk download_blob(offset=...,
length=...) loop with iteration over downloader.chunks(), yielding each
SDK-provided chunk and preserving the existing not-found handling.

Comment on lines +340 to +367
async def get_file_size(self, flow_id: str, file_name: str) -> int:
"""Get the size of a file in GCS.

Args:
flow_id: The flow/user identifier for namespacing
file_name: The name of the file

Returns:
int: Size of the file in bytes

Raises:
FileNotFoundError: If the file does not exist in GCS
"""
self._validate_identifiers(flow_id, file_name)
key = self.build_full_path(flow_id, file_name)

try:
blob = await asyncio.to_thread(self._get_existing_blob, key)
except Exception:
logger.exception(f"Error getting file size for {file_name} in GCS flow {flow_id}")
raise

if blob is None:
await logger.awarning(f"File {file_name} not found in GCS flow {flow_id}")
msg = f"File not found: {file_name}"
raise FileNotFoundError(msg)

return blob.size

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

google-cloud-storage Blob.size property type Optional int

💡 Result:

The property type for the size of a blob in Google Cloud Storage depends on the programming language and specific client library being used: In the Python client library (google-cloud-storage), the size property is represented as an int or NoneType [1][2]. Specifically, the Blob.size property returns an integer representing the size of the object in bytes, or None if the blob's resource has not been loaded from the server [3][1][2]. In the Java client library (google-cloud-storage), the size is accessed via the getSize method, which returns a Long (the wrapper class for the primitive long type) [4]. The documentation explicitly states that this returns the content length of the data in bytes [4]. In both cases, the value represents the object's size in bytes [3][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate GCS storage file"
fd -a 'gcs\.py$' . | sed 's#^\./##'

echo
echo "Relevant implementation lines"
sed -n '320,380p' src/backend/base/langflow/services/storage/gcs.py | cat -n

echo
echo "Search for dependency version constraints"
rg -n 'google-cloud-storage|google-api-core' pyproject.toml uv.lock Pipfile* requirements* 2>/dev/null || true

echo
echo "Search for get_file_size usages"
rg -n 'get_file_size|FileNotFoundError' src/backend/base/langflow/services/storage src/backend -g '*.py' | head -200

Repository: langflow-ai/langflow

Length of output: 27138


Handle Blob.size returning None.

google-cloud-storage declares Blob.size as int | None; None means the blob metadata has not been loaded. Return type is int, and callers treat a missing size as None. Fetch size explicitly, raise FileNotFoundError when unavailable, and return the loaded size.

🛠️ Proposed fix
-        return blob.size
+        size = blob.size
+        if size is None:
+            msg = f"Could not determine size for file: {file_name}"
+            raise FileNotFoundError(msg)
+        return size
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def get_file_size(self, flow_id: str, file_name: str) -> int:
"""Get the size of a file in GCS.
Args:
flow_id: The flow/user identifier for namespacing
file_name: The name of the file
Returns:
int: Size of the file in bytes
Raises:
FileNotFoundError: If the file does not exist in GCS
"""
self._validate_identifiers(flow_id, file_name)
key = self.build_full_path(flow_id, file_name)
try:
blob = await asyncio.to_thread(self._get_existing_blob, key)
except Exception:
logger.exception(f"Error getting file size for {file_name} in GCS flow {flow_id}")
raise
if blob is None:
await logger.awarning(f"File {file_name} not found in GCS flow {flow_id}")
msg = f"File not found: {file_name}"
raise FileNotFoundError(msg)
return blob.size
async def get_file_size(self, flow_id: str, file_name: str) -> int:
"""Get the size of a file in GCS.
Args:
flow_id: The flow/user identifier for namespacing
file_name: The name of the file
Returns:
int: Size of the file in bytes
Raises:
FileNotFoundError: If the file does not exist in GCS
"""
self._validate_identifiers(flow_id, file_name)
key = self.build_full_path(flow_id, file_name)
try:
blob = await asyncio.to_thread(self._get_existing_blob, key)
except Exception:
logger.exception(f"Error getting file size for {file_name} in GCS flow {flow_id}")
raise
if blob is None:
await logger.awarning(f"File {file_name} not found in GCS flow {flow_id}")
msg = f"File not found: {file_name}"
raise FileNotFoundError(msg)
size = blob.size
if size is None:
msg = f"Could not determine size for file: {file_name}"
raise FileNotFoundError(msg)
return size
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/storage/gcs.py` around lines 340 - 367,
Update get_file_size to explicitly load the blob metadata before reading
Blob.size, then treat a missing or unavailable size as FileNotFoundError and
return the loaded integer size. Preserve the existing blob-not-found handling
and use the existing blob retrieval flow rather than returning blob.size
directly.

Comment on lines +23 to +27
@pytest.fixture
def _gcp_credentials():
"""Verify GCP credentials are available via environment variables."""
if not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"):
pytest.skip("Missing required environment variable: GOOGLE_APPLICATION_CREDENTIALS")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The credentials check rejects Application Default Credentials.

The module docstring states that ADC is a supported credential source. This fixture skips unless GOOGLE_APPLICATION_CREDENTIALS is set, so an ADC-only CI runner never runs these tests. Accept either source.

🛠️ Proposed fix
 `@pytest.fixture`
 def _gcp_credentials():
-    """Verify GCP credentials are available via environment variables."""
-    if not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"):
-        pytest.skip("Missing required environment variable: GOOGLE_APPLICATION_CREDENTIALS")
+    """Verify GCP credentials are resolvable."""
+    if os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"):
+        return
+    try:
+        import google.auth
+
+        google.auth.default()
+    except Exception:  # noqa: BLE001
+        pytest.skip("No GCP credentials: set GOOGLE_APPLICATION_CREDENTIALS or configure ADC")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@pytest.fixture
def _gcp_credentials():
"""Verify GCP credentials are available via environment variables."""
if not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"):
pytest.skip("Missing required environment variable: GOOGLE_APPLICATION_CREDENTIALS")
`@pytest.fixture`
def _gcp_credentials():
"""Verify GCP credentials are resolvable."""
if os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"):
return
try:
import google.auth
google.auth.default()
except Exception: # noqa: BLE001
pytest.skip("No GCP credentials: set GOOGLE_APPLICATION_CREDENTIALS or configure ADC")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/integration/storage/test_gcs_storage_service.py` around
lines 23 - 27, Update the _gcp_credentials fixture to allow tests when either
GOOGLE_APPLICATION_CREDENTIALS is set or Application Default Credentials are
available, skipping only when neither credential source can be used. Preserve
the existing skip message behavior for missing credentials.

Comment on lines +239 to +241
with contextlib.suppress(Exception):
await gcs_storage_service.delete_file(test_flow_id, "file1.txt")
await gcs_storage_service.delete_file(other_flow_id, "file2.txt")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

One contextlib.suppress block guards two independent cleanup deletes. In both cleanup blocks, two delete calls share a single suppress context. If the first delete raises, the second never runs, and the blob for the second flow stays in the bucket or container. Give each delete its own suppress block.

  • src/backend/tests/integration/storage/test_gcs_storage_service.py#L239-L241: wrap the test_flow_id delete and the other_flow_id delete in separate contextlib.suppress(Exception) blocks.
  • src/backend/tests/integration/storage/test_azure_storage_service.py#L247-L249: apply the same split to the two delete_file calls.
🛠️ Proposed fix (GCS; apply the same shape to Azure)
         finally:
             with contextlib.suppress(Exception):
                 await gcs_storage_service.delete_file(test_flow_id, "file1.txt")
+            with contextlib.suppress(Exception):
                 await gcs_storage_service.delete_file(other_flow_id, "file2.txt")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
with contextlib.suppress(Exception):
await gcs_storage_service.delete_file(test_flow_id, "file1.txt")
await gcs_storage_service.delete_file(other_flow_id, "file2.txt")
with contextlib.suppress(Exception):
await gcs_storage_service.delete_file(test_flow_id, "file1.txt")
with contextlib.suppress(Exception):
await gcs_storage_service.delete_file(other_flow_id, "file2.txt")
📍 Affects 2 files
  • src/backend/tests/integration/storage/test_gcs_storage_service.py#L239-L241 (this comment)
  • src/backend/tests/integration/storage/test_azure_storage_service.py#L247-L249
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/integration/storage/test_gcs_storage_service.py` around
lines 239 - 241, Split the shared cleanup suppression so each independent
delete_file call has its own contextlib.suppress(Exception) block. Apply this in
src/backend/tests/integration/storage/test_gcs_storage_service.py:239-241 for
the test_flow_id and other_flow_id deletes, and in
src/backend/tests/integration/storage/test_azure_storage_service.py:247-249 for
the corresponding deletes, ensuring the second cleanup still runs if the first
fails.

Addresses CodeRabbit's review of the GCS/Azure storage backend PR:

- gcs.py: get_file_size now treats a blob with no populated size as
  not-found instead of returning a possibly-None int; teardown() closes
  the underlying client's HTTP session when the installed SDK supports it.
- azure_blob.py: get_file_stream now pins its download to the blob's ETag
  (MatchConditions.IfNotModified) and iterates the SDK's own chunk
  boundaries via downloader.chunks(), instead of independent ranged reads
  that could splice together two different versions of a blob overwritten
  mid-stream. save_file's duplicated 403/generic exception-handling paths
  are merged into a single handler.
- Renamed is_s3_storage -> is_remote_storage in base_file.py, and updated
  now-stale "S3 storage" wording in comments/docstrings across
  base_file.py, utils.py, file.py, csv_agent.py, and json_agent.py to
  reflect that S3/GCS/Azure are all handled the same way. Regenerated the
  starter-project JSON files and component index (via
  scripts/build_component_index.py and scripts/ci/update_starter_projects.py)
  so their embedded component code picks up the same wording fix.
- Removed redundant @pytest.mark.asyncio markers from the four new storage
  test files (this repo's pytest-asyncio runs in auto mode).
- Doc fixes: corrected the Azure DefaultAzureCredential resolution order
  and the actual defaults for LANGFLOW_OBJECT_STORAGE_BUCKET_NAME/PREFIX.
- Added unit tests covering the GCS get_file_size None-size case, GCS
  teardown's close()-if-supported behavior, and the Azure ETag-pinned
  get_file_stream behavior.

Considered and declined moving google-cloud-storage/azure-storage-blob/
azure-identity from hard dependencies to extras-only (also suggested in
review): make install_backend and CI's `uv sync` don't pass --extra gcs
or --extra azure, so that change would silently break the new backend
tests in CI without an accompanying Makefile/workflow change. Kept as
hard dependencies, consistent with the existing aiobotocore/S3 precedent.
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant