Skip to content

Commit b191b2b

Browse files
authored
fix(Files): Fix pyodide fstat not returning actual file size but 0 (#2649)
1 parent 98154b0 commit b191b2b

3 files changed

Lines changed: 58 additions & 3 deletions

File tree

cognite/client/_api/files.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import copy
55
import math
6+
import os
67
import warnings
78
from collections import defaultdict
89
from collections.abc import AsyncIterator, Sequence
@@ -14,6 +15,7 @@
1415

1516
from cognite.client._api_client import APIClient
1617
from cognite.client._constants import (
18+
_RUNNING_IN_BROWSER,
1719
DEFAULT_LIMIT_READ,
1820
FILE_DEFAULT_MULTIPART_SIZE,
1921
FILE_MAX_MULTIPART_COUNT,
@@ -503,7 +505,7 @@ async def upload_content(
503505
if not path.is_file():
504506
raise FileNotFoundError(path)
505507

506-
file_size = path.stat().st_size
508+
file_size = self._get_file_size(path)
507509
part_size, num_parts = self.calculate_part_size_and_count(file_size)
508510
session = await self.multipart_upload_content_session(
509511
parts=num_parts, external_id=external_id, instance_id=instance_id
@@ -641,7 +643,7 @@ async def upload(
641643
async def _upload_file_from_path(
642644
self, file_metadata: FileMetadataWrite, path: Path, overwrite: bool
643645
) -> FileMetadata:
644-
file_size = path.stat().st_size
646+
file_size = self._get_file_size(path)
645647
part_size, num_parts = self.calculate_part_size_and_count(file_size)
646648
session = await self.multipart_upload_session(
647649
parts=num_parts,
@@ -674,6 +676,17 @@ async def upload_part(part_no: int) -> None:
674676
async with session:
675677
await asyncio.gather(*(upload_part(i) for i in range(num_parts)))
676678

679+
@staticmethod
680+
def _get_file_size(path: Path) -> int:
681+
size = path.stat().st_size
682+
# Pyodide's File System Access API sometimes lies: stat may report st_size=0 even when there's readable
683+
# bytes on disk (that can be read normally...) Fall back to seek/tell, which reads the true size:
684+
if _RUNNING_IN_BROWSER and size == 0:
685+
with path.open("rb") as fh:
686+
fh.seek(0, os.SEEK_END)
687+
return fh.tell()
688+
return size
689+
677690
def calculate_part_size_and_count(self, file_size: int) -> tuple[int, int]:
678691
"""Calculate part size and count for a multipart upload, for a given file size.
679692

cognite/client/_sync_api/files.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""
22
===============================================================================
3-
1f615115f322d471c0ae15347e13f8c2
3+
d8fa16955b8c30a8af32a19dcb33c4a8
44
This file is auto-generated from the Async API modules, - do not edit manually!
55
===============================================================================
66
"""

tests/tests_unit/test_api/test_files.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from collections.abc import Callable, Iterator
77
from pathlib import Path
88
from tempfile import TemporaryDirectory
9+
from types import SimpleNamespace
910
from typing import TYPE_CHECKING, Any, NoReturn
1011
from unittest.mock import AsyncMock, patch
1112

@@ -14,6 +15,7 @@
1415
from pytest_httpx import HTTPXMock
1516

1617
from cognite.client import CogniteClient
18+
from cognite.client._api.files import FilesAPI
1719
from cognite.client._constants import FILE_MAX_MULTIPART_COUNT, FILE_MAX_MULTIPART_SIZE, FILE_MIN_MULTIPART_SIZE
1820
from cognite.client.config import global_config
1921
from cognite.client.data_classes import GeoLocation, GeoLocationFilter, Geometry, GeometryFilter, TimestampRange
@@ -1185,6 +1187,46 @@ def test_upload_semaphore_limits_concurrent_parts(
11851187
assert peak == concurrency_limit
11861188

11871189

1190+
@pytest.fixture
1191+
def lying_stat(monkeypatch: pytest.MonkeyPatch) -> None:
1192+
"""Monkeypatch Path.stat to report st_size=0, mimicking Pyodide's broken fstat."""
1193+
monkeypatch.setattr(Path, "stat", lambda _path, **_kw: SimpleNamespace(st_size=0))
1194+
1195+
1196+
class TestGetFileSize:
1197+
def test_browser_falls_back_to_seek_when_stat_lies(
1198+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, lying_stat: None
1199+
) -> None:
1200+
"""Pyodide /drive/... regression: stat reports 0 but the file -has- bytes."""
1201+
p = tmp_path / "lied_about.bin"
1202+
p.write_bytes(b"hey\nyo\n") # 7 bytes really on disk
1203+
# We have to pretend we are running in the browser with Pyodide:
1204+
monkeypatch.setattr("cognite.client._api.files._RUNNING_IN_BROWSER", True)
1205+
1206+
# Sanity-check that stat is now lying:
1207+
assert p.stat().st_size == 0
1208+
# The helper should still report correct size:
1209+
assert FilesAPI._get_file_size(p) == 7
1210+
1211+
def test_empty_file_in_browser_still_reports_zero(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
1212+
"""A genuinely empty file must still report size=0 in browser mode."""
1213+
p = tmp_path / "empty.bin"
1214+
p.touch()
1215+
monkeypatch.setattr("cognite.client._api.files._RUNNING_IN_BROWSER", True)
1216+
1217+
assert FilesAPI._get_file_size(p) == 0
1218+
1219+
def test_returns_stat_size_for_non_empty_file(self, tmp_path: Path) -> None:
1220+
p = tmp_path / "x.bin"
1221+
p.write_bytes(b"hello world")
1222+
assert FilesAPI._get_file_size(p) == 11
1223+
1224+
def test_returns_zero_for_genuinely_empty_file(self, tmp_path: Path) -> None:
1225+
p = tmp_path / "empty.bin"
1226+
p.touch()
1227+
assert FilesAPI._get_file_size(p) == 0
1228+
1229+
11881230
@pytest.fixture
11891231
def mock_files_empty(
11901232
httpx_mock: HTTPXMock, cognite_client: CogniteClient, async_client: AsyncCogniteClient

0 commit comments

Comments
 (0)