Skip to content

Commit 0b5252d

Browse files
authored
fix(ingestion): ingest non-existent absolute-looking strings as text [SDK-234] (gh #3887, follow-up to #3892) (#4155)
## Description Follow-up to #3892 (now merged to `dev`), closing the remaining half of #3887. #3892 stops the Windows `ValueError` crash by guarding the absolute-path branch of `save_data_item_to_storage` with `is_absolute()`. This PR fixes the other half of the report: a `/`-prefixed string that is **not** an actual file (e.g. a text note like `/remember to call Bob about the meeting`) should be ingested as text, not turned into a `file://` URI pointing at a file that does not exist. The absolute-path branch converted unconditionally, whereas the relative-path branch right below it already required the file to exist (`abs_path.is_file()`). I made the two consistent by reusing that same, already-computed `abs_path`: an absolute-looking string is only turned into a `file://` URI when it points at an existing file — otherwise it falls through to text ingestion, on every platform. The `ACCEPT_LOCAL_FILE_PATH=false` rejection of *existing* local files is unchanged. While tracing the callers I found the dry-run estimator (`cognee/modules/cognify/estimator.py`) deliberately mirrors this routing, and it was raising "file does not exist" for missing absolute paths. Since a real run now treats those as text, I updated `_path_candidate` to match (missing path → raw text; the gate rejects only *existing* absolute files) and adjusted its two tests. Behavior note: because the branch now keys on "existing file" (`is_file()`), a handful of absolute-looking strings that are **not** regular files also route to text instead of a `file://` URI — an existing *directory* path, a broken symlink, and pathological paths that error during `resolve()` (embedded null / over-length). This is intended and safe: none of those are ingestible files, and directories are normally expanded upstream by `resolve_data_directories` before reaching here. It also repairs a latent downstream break — before this change a `/`-prefixed note produced a broken `file://` URI that `get_data_file_path` turned into a non-existent path and the loader then failed on. > #3892 has merged to `dev`; this PR is rebased on top of it and now contains only the follow-up commits. ## Acceptance Criteria * `save_data_item_to_storage("/remember to call Bob about the meeting")` is ingested as text — no `file://` URI, no crash — on Windows, macOS and Linux. * An existing absolute file path still converts to a `file://` URI; with `ACCEPT_LOCAL_FILE_PATH=false` it still raises `IngestionError`. * The dry-run estimate prices a missing absolute path as text instead of erroring out. * The regression tests run on the regular (Linux) unit-test CI, not only the Windows OS matrix. Verified locally: 38 passed, 1 skipped (the genuine drive-anchored Windows case); `ruff format` + `ruff check` clean. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Code refactoring - [ ] Other (please specify): ## Pre-submission Checklist - [x] **I have tested my changes thoroughly before submitting this PR** (See `CONTRIBUTING.md`) - [x] **This PR contains minimal changes necessary to address the issue/feature** - [x] My code follows the project's coding standards and style guidelines - [x] I have added tests that prove my fix is effective or that my feature works - [ ] I have added necessary documentation (if applicable) - [x] All new and existing tests pass - [x] I have searched existing PRs to ensure this change hasn't been submitted already - [x] I have linked any relevant issues in the description - [x] My commits have clear and descriptive messages ## DCO Affirmation I affirm that all code in every commit of this pull request conforms to the terms of the Topoteretes Developer Certificate of Origin.
2 parents 4821d0e + f3b33bb commit 0b5252d

4 files changed

Lines changed: 155 additions & 62 deletions

File tree

cognee/modules/cognify/estimator.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -335,12 +335,14 @@ def _accept_local_file_path() -> bool:
335335
def _path_candidate(value: str) -> Optional[Path]:
336336
"""The local path this string refers to, or None when it is raw text.
337337
338-
Mirrors ``save_data_item_to_storage``: remote URLs and missing absolute
339-
paths are loud errors, ``file://`` URIs resolve to local paths, everything
340-
else that is not an existing file is raw text. Scheme is checked before the
341-
newline guard so a trailing-newline URL cannot slip through as text. When
342-
``ACCEPT_LOCAL_FILE_PATH`` is disabled, path references are rejected and
343-
relative strings are raw text, exactly as in a real run.
338+
Mirrors ``save_data_item_to_storage``: remote URLs are loud errors and
339+
``file://`` URIs resolve to local paths, but a bare string is a file only
340+
when it points at one that exists. An absolute-looking string that is not an
341+
existing file (e.g. a "/"-prefixed text note) is raw text, exactly as a real
342+
run now ingests it. Scheme is checked before the newline guard so a
343+
trailing-newline URL cannot slip through as text. When
344+
``ACCEPT_LOCAL_FILE_PATH`` is disabled, an existing *absolute* file path is
345+
rejected while relative paths and non-existent strings stay raw text.
344346
"""
345347
scheme = urlparse(value).scheme.lower()
346348
if scheme in _UNSUPPORTED_SCHEMES:
@@ -356,20 +358,24 @@ def _path_candidate(value: str) -> Optional[Path]:
356358
if "\n" in value or "\r" in value or len(value) > 4096:
357359
return None
358360

359-
if not _accept_local_file_path():
360-
if value.startswith("/"):
361-
raise ValueError(f"Local files are not accepted, got {value!r}.")
362-
return None
363-
364361
try:
365362
path = Path(value)
366-
if path.exists():
367-
return path
363+
exists = path.exists()
368364
except (OSError, ValueError):
369365
return None
370-
if value.startswith("/"):
371-
# A real run treats absolute paths as file references and would fail too.
372-
raise ValueError(f"dry_run file input does not exist: {value!r}.")
366+
367+
if exists:
368+
if not _accept_local_file_path():
369+
# Mirror the ACCEPT_LOCAL_FILE_PATH gate: an existing *absolute* file
370+
# path is rejected, while an existing *relative* path falls through to
371+
# raw text (save_data_item_to_storage has no reject branch for it).
372+
if value.startswith("/"):
373+
raise ValueError(f"Local files are not accepted, got {value!r}.")
374+
return None
375+
return path
376+
377+
# A non-existent path — absolute or relative — is raw text. A real run no
378+
# longer treats a "/"-prefixed string as a file reference unless it exists.
373379
return None
374380

375381

cognee/tasks/ingestion/save_data_item_to_storage.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,27 @@ async def save_data_item_to_storage(data_item: Union[BinaryIO, str, Any]) -> str
7272
else:
7373
raise IngestionError(message="Local files are not accepted.")
7474

75-
# data is an absolute file path
75+
# data is an absolute file path that points at an existing file
7676
elif (
77-
data_item.startswith("/")
78-
or (os.name == "nt" and len(data_item) > 1 and data_item[1] == ":")
79-
) and Path(os.path.normpath(data_item)).is_absolute():
77+
(
78+
data_item.startswith("/")
79+
or (os.name == "nt" and len(data_item) > 1 and data_item[1] == ":")
80+
)
81+
and Path(os.path.normpath(data_item)).is_absolute()
82+
and abs_path.is_file()
83+
):
8084
# Handle both Unix absolute paths (/path) and Windows absolute paths (C:\path).
81-
# The is_absolute() guard matters on Windows: a POSIX-style "/path" (or a
82-
# drive-relative "C:path") normalizes to a *drive-relative* WindowsPath, and
83-
# Path.as_uri() raises ValueError for it. Such strings are not usable file
84-
# paths on this platform and continue to the relative-path/text handling below.
85+
#
86+
# is_absolute() guard: on Windows a POSIX-style "/path" (or a drive-relative
87+
# "C:path") normalizes to a *drive-relative* WindowsPath, and Path.as_uri()
88+
# raises ValueError for it. Such strings are not usable file paths on this
89+
# platform and continue to the relative-path/text handling below.
90+
#
91+
# abs_path.is_file() guard: only convert an absolute-looking string to a
92+
# file:// URI when it actually points at an existing file, mirroring the
93+
# relative-path branch below. A "/"-prefixed string that is not an existing
94+
# file (e.g. a plain text note such as "/remember to call Bob") falls through
95+
# to text ingestion on every platform instead of becoming a broken file:// URI.
8596
if settings.accept_local_file_path:
8697
# Normalize path separators before creating file URL
8798
normalized_path = os.path.normpath(data_item)

cognee/tests/unit/modules/cognify/test_estimator.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,10 @@ async def test_directories_are_rejected(tmp_path):
240240

241241

242242
@pytest.mark.asyncio
243-
async def test_missing_absolute_path_is_rejected():
244-
with pytest.raises(ValueError, match="does not exist"):
245-
await estimator._input_to_texts("/no/such/file.txt")
243+
async def test_missing_absolute_path_is_raw_text():
244+
# A "/"-prefixed string that is not an existing file is ingested as text by
245+
# a real run (see #3887), so dry_run must price it as text, not reject it.
246+
assert await estimator._input_to_texts("/no/such/file.txt") == ["/no/such/file.txt"]
246247

247248

248249
@pytest.mark.asyncio
@@ -254,10 +255,15 @@ async def test_local_paths_are_rejected_when_gate_disabled(tmp_path, monkeypatch
254255
file_path = tmp_path / "notes.txt"
255256
file_path.write_text("stored text")
256257

258+
# An existing local file is rejected, whether named by file:// URI or by an
259+
# absolute path — the gate must reject exactly what a real run rejects.
257260
with pytest.raises(ValueError, match="not accepted"):
258261
await estimator._input_to_texts(file_path.as_uri())
259262
with pytest.raises(ValueError, match="not accepted"):
260-
await estimator._input_to_texts("/no/such/file.txt")
263+
await estimator._input_to_texts(str(file_path))
264+
# A non-existent absolute path is raw text even with the gate off: a real run
265+
# saves it as text because it is not an existing local file (see #3887).
266+
assert await estimator._input_to_texts("/no/such/file.txt") == ["/no/such/file.txt"]
261267
# A real run treats a relative path to an existing file as raw text when
262268
# the gate is off.
263269
monkeypatch.chdir(tmp_path)
Lines changed: 104 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
1+
import importlib
2+
import ntpath
13
import os
2-
from unittest.mock import AsyncMock, patch
4+
from types import SimpleNamespace
5+
from unittest.mock import AsyncMock
36

47
import pytest
58

9+
from cognee.modules.ingestion.exceptions import IngestionError
610
from cognee.tasks.ingestion.save_data_item_to_storage import save_data_item_to_storage
711

12+
# The package __init__ rebinds the name ``save_data_item_to_storage`` to the
13+
# function, so ``import ... as mod`` would yield the function, not the module.
14+
# import_module returns the real module object (for patching its globals).
15+
mod = importlib.import_module("cognee.tasks.ingestion.save_data_item_to_storage")
16+
817

918
@pytest.mark.asyncio
10-
async def test_existing_absolute_path_returns_file_uri(tmp_path):
19+
async def test_existing_absolute_file_returns_file_uri(tmp_path):
1120
file_path = tmp_path / "note.txt"
1221
file_path.write_text("hello", encoding="utf-8")
1322

@@ -17,47 +26,108 @@ async def test_existing_absolute_path_returns_file_uri(tmp_path):
1726

1827

1928
@pytest.mark.asyncio
20-
@pytest.mark.skipif(os.name == "nt", reason="POSIX absolute-path semantics")
21-
async def test_posix_absolute_path_behavior_unchanged_on_posix():
22-
# On POSIX, "/"-prefixed strings are genuine absolute paths and convert to
23-
# file URIs whether or not the file exists (pre-existing behavior).
24-
result = await save_data_item_to_storage("/nonexistent/path/file.txt")
29+
async def test_nonexistent_absolute_path_is_ingested_as_text(monkeypatch):
30+
"""A "/"-prefixed string that is not an existing file is ingested as text.
31+
32+
Regression test for #3887. On every platform, a plain text note that happens
33+
to start with "/" (e.g. slash-command-style content) must be saved as text
34+
rather than turned into a file:// URI for a file that does not exist. On
35+
Windows the pre-fix code additionally crashed here with
36+
``ValueError: relative path can't be expressed as a file URI``.
37+
"""
38+
save_mock = AsyncMock(return_value="text-file-path")
39+
monkeypatch.setattr(mod, "save_data_to_file", save_mock)
40+
41+
note = "/remember to call Bob about the meeting"
42+
result = await save_data_item_to_storage(note)
2543

26-
assert result == "file:///nonexistent/path/file.txt"
44+
assert result == "text-file-path"
45+
save_mock.assert_awaited_once_with(note)
2746

2847

2948
@pytest.mark.asyncio
30-
@pytest.mark.skipif(os.name != "nt", reason="Windows drive-relative path semantics")
31-
async def test_posix_style_string_falls_back_to_text_on_windows():
32-
"""A "/"-prefixed string that is not a usable path on Windows is ingested as text.
33-
34-
Regression test: os.path.normpath("/x") produces a drive-relative
35-
WindowsPath, and Path.as_uri() raised ValueError ("relative paths can't
36-
be expressed as file URIs") out of add() for any string starting with
37-
"/" — both POSIX-style paths and plain text.
49+
async def test_windows_style_paths_do_not_crash_and_fall_back_to_text(monkeypatch):
50+
"""Windows-style path normalization, simulated on any OS.
51+
52+
``ntpath.normpath`` turns a "/"-prefixed or drive-relative string into a
53+
backslash path, which ``pathlib`` treats as *relative* on POSIX exactly as
54+
``WindowsPath`` does on Windows (``is_absolute()`` is False, ``as_uri()``
55+
raises). Combined with the existence guard, such inputs — which do not name
56+
an existing file — are ingested as text on every platform instead of raising
57+
the Windows ``ValueError: relative path can't be expressed as a file URI``
58+
that #3887 reported.
59+
60+
We replace the module's ``os`` *reference* (not the shared ``os`` singleton),
61+
so ``os.name``/``os.path.normpath`` behave like Windows inside the function
62+
while ``pathlib`` keeps using the real platform for ``Path``/``Path.cwd()``.
63+
The genuine drive-anchored Windows path (``C:\\...``) that points at an
64+
existing file is covered by the Windows-only test below and the OS-matrix CI.
3865
"""
39-
with patch(
40-
"cognee.tasks.ingestion.save_data_item_to_storage.save_data_to_file",
41-
new_callable=AsyncMock,
42-
return_value="text-file-path",
43-
) as mock_save:
44-
result = await save_data_item_to_storage("/remember to call Bob about the meeting")
66+
fake_os = SimpleNamespace(name="nt", path=SimpleNamespace(normpath=ntpath.normpath))
67+
save_mock = AsyncMock(return_value="text-file-path")
68+
monkeypatch.setattr(mod, "save_data_to_file", save_mock)
69+
monkeypatch.setattr(mod, "os", fake_os)
70+
71+
for item in ["/no/such/windows/path.txt", "C:name.txt"]:
72+
save_mock.reset_mock()
73+
result = await save_data_item_to_storage(item)
74+
assert result == "text-file-path"
75+
save_mock.assert_awaited_once_with(item)
76+
77+
78+
@pytest.mark.asyncio
79+
async def test_existing_absolute_file_rejected_when_gate_disabled(tmp_path, monkeypatch):
80+
# An existing absolute local file is still rejected when
81+
# ACCEPT_LOCAL_FILE_PATH is off — the fix must not weaken that gate.
82+
file_path = tmp_path / "note.txt"
83+
file_path.write_text("hello", encoding="utf-8")
84+
monkeypatch.setattr(mod.settings, "accept_local_file_path", False)
85+
86+
with pytest.raises(IngestionError, match="Local files are not accepted"):
87+
await save_data_item_to_storage(str(file_path))
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_nonexistent_absolute_path_is_text_even_when_gate_disabled(monkeypatch):
92+
# The ACCEPT_LOCAL_FILE_PATH gate rejects existing *local files*; a
93+
# non-existent absolute-looking string is not a local file, so it is still
94+
# ingested as text even with the gate off (the reject branch is only reached
95+
# once is_file() is true). Mirrors the estimator's gate-off behavior.
96+
save_mock = AsyncMock(return_value="text-file-path")
97+
monkeypatch.setattr(mod, "save_data_to_file", save_mock)
98+
monkeypatch.setattr(mod.settings, "accept_local_file_path", False)
99+
100+
result = await save_data_item_to_storage("/no/such/file.txt")
45101

46102
assert result == "text-file-path"
47-
mock_save.assert_awaited_once_with("/remember to call Bob about the meeting")
103+
save_mock.assert_awaited_once_with("/no/such/file.txt")
48104

49105

50106
@pytest.mark.asyncio
51-
@pytest.mark.skipif(os.name != "nt", reason="Windows drive-relative path semantics")
52-
async def test_drive_relative_string_falls_back_to_text_on_windows():
53-
# "C:name.txt" matches the Windows-path arm but is drive-relative, not
54-
# absolute; it used to raise the same ValueError from Path.as_uri().
55-
with patch(
56-
"cognee.tasks.ingestion.save_data_item_to_storage.save_data_to_file",
57-
new_callable=AsyncMock,
58-
return_value="text-file-path",
59-
) as mock_save:
60-
result = await save_data_item_to_storage("C:drive-relative-note.txt")
107+
async def test_existing_directory_is_ingested_as_text(tmp_path, monkeypatch):
108+
# An existing directory is not a file (is_file() is False), so it falls
109+
# through to text ingestion rather than becoming a file:// URI. (In the
110+
# normal add() flow directories are expanded upstream by
111+
# resolve_data_directories.) The dry-run estimator deliberately diverges
112+
# here — it rejects directories with a clearer message; see
113+
# test_estimator.test_directories_are_rejected.
114+
save_mock = AsyncMock(return_value="text-file-path")
115+
monkeypatch.setattr(mod, "save_data_to_file", save_mock)
116+
117+
result = await save_data_item_to_storage(str(tmp_path))
61118

62119
assert result == "text-file-path"
63-
mock_save.assert_awaited_once()
120+
save_mock.assert_awaited_once_with(str(tmp_path))
121+
122+
123+
@pytest.mark.asyncio
124+
@pytest.mark.skipif(os.name != "nt", reason="Windows absolute-path semantics")
125+
async def test_genuine_windows_absolute_path_returns_file_uri(tmp_path):
126+
# A real Windows absolute path (C:\...) to an existing file still converts
127+
# to a file:// URI. Runs only on Windows, where tmp_path is drive-anchored.
128+
file_path = tmp_path / "note.txt"
129+
file_path.write_text("hello", encoding="utf-8")
130+
131+
result = await save_data_item_to_storage(str(file_path))
132+
133+
assert result == file_path.as_uri()

0 commit comments

Comments
 (0)