Skip to content

Commit 64d99d6

Browse files
committed
fix: allow uploading a file from an in-memory stream
Closes #140. - express.py: replace the unreachable isinstance(source, IO) check with a duck-typed one; typing.IO is not runtime-checkable - InMemoryController: drop the __init__ that assigned stream before the model was initialised and overwrote a caller-supplied stream - default the stream to BytesIO, matching the byte-oriented get/put - declare IO in the upload_file / osw_upload_file signatures
1 parent 0d32bc2 commit 64d99d6

3 files changed

Lines changed: 90 additions & 13 deletions

File tree

‎src/osw/controller/file/memory.py‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,26 @@
11
import shutil
2-
from io import StringIO
2+
from io import BytesIO
33
from typing import IO, Any, Dict, List, Optional
44

5+
from pydantic.v1 import Field
6+
57
from osw.controller.file.base import FileController
68
from osw.core import model
79

810

911
class InMemoryController(FileController, model.LocalFile):
10-
"""File controller for local files"""
12+
"""File controller for in-memory streams"""
1113

1214
label: Optional[List[model.Label]] = [model.Label(text="Unnamed stream")]
1315
"""the label of the stream, e.g., the name of the file the stream
1416
originates from. Defaults to 'Unnamed stream'."""
15-
stream: IO
16-
"""the stream to the file"""
17+
stream: Any = Field(default_factory=BytesIO)
18+
"""the stream to the file, any file-like object. Defaults to an empty
19+
binary buffer. Byte-oriented, to match the get/put counterparts."""
1720

1821
class Config:
1922
arbitrary_types_allowed = True
2023

21-
def __init__(self, **kwargs):
22-
self.stream = StringIO()
23-
super().__init__(**kwargs)
24-
2524
def get(self) -> IO:
2625
return self.stream
2726

‎src/osw/express.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ def download_file(
231231

232232
def upload_file(
233233
self,
234-
source: Union["LocalFileController", "WikiFileController", str, Path],
234+
source: Union["LocalFileController", "WikiFileController", str, Path, IO],
235235
url_or_title: Optional[str] = None,
236236
overwrite: OVERWRITE_CLASS_OPTIONS = OverwriteOptions.true,
237237
delete_after_use: bool = False,
@@ -247,7 +247,7 @@ def upload_file(
247247
----------
248248
source
249249
The source file to upload. Can be a LocalFileController, WikiFileController,
250-
str or Path.
250+
str, Path or an open file-like object.
251251
url_or_title
252252
The URL or full page title of the WikiFile page to upload the file to. Used to
253253
overwrite autogenerated full page title on the target domain. If it is
@@ -635,7 +635,9 @@ def __init__(
635635
data["path"] = Path(source)
636636
data["source"] = Path(source)
637637
data["source_file_controller"] = LocalFileController(path=data.get("path"))
638-
elif isinstance(source, IO):
638+
# duck-typed: typing.IO is not runtime-checkable, isinstance(BytesIO(), IO)
639+
# is False, so an explicit isinstance check would never match a stream
640+
elif hasattr(source, "read"):
639641
data["source_file_controller"] = InMemoryController(stream=source)
640642
else:
641643
raise ValueError(
@@ -736,7 +738,7 @@ def __init__(
736738

737739

738740
def osw_upload_file(
739-
source: Union[LocalFileController, WikiFileController, str, Path],
741+
source: Union[LocalFileController, WikiFileController, str, Path, IO],
740742
url_or_title: Optional[str] = None,
741743
overwrite: OVERWRITE_CLASS_OPTIONS = OverwriteOptions.true,
742744
delete_after_use: bool = False,
@@ -756,7 +758,7 @@ def osw_upload_file(
756758
----------
757759
source
758760
The source file to upload. Can be a LocalFileController, WikiFileController,
759-
str or Path.
761+
str, Path or an open file-like object.
760762
url_or_title
761763
The URL or full page title of the WikiFile page to upload the file to. Used to
762764
overwrite autogenerated full page title on the target domain. If it is

‎tests/test_in_memory_upload.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Unit tests for uploading a file from an in-memory stream (issue #140).
2+
3+
Fully offline: no network, no live wiki. The upload path is cut short by
4+
replacing ``WikiFileController.from_other`` with a stub that records the
5+
source controller it was handed, so the dispatch in ``UploadFileResult`` can
6+
be checked without touching a wiki.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from io import BytesIO
12+
from unittest.mock import MagicMock
13+
14+
import pytest
15+
16+
import osw.express
17+
from osw.controller.file.memory import InMemoryController
18+
19+
20+
def test_controller_accepts_a_caller_supplied_stream():
21+
stream = BytesIO(b"payload")
22+
controller = InMemoryController(stream=stream)
23+
assert controller.get() is stream
24+
assert controller.get().read() == b"payload"
25+
26+
27+
def test_controller_defaults_to_an_empty_binary_buffer():
28+
controller = InMemoryController()
29+
assert isinstance(controller.stream, BytesIO)
30+
assert controller.stream.getvalue() == b""
31+
32+
33+
def test_controller_put_copies_into_the_stream():
34+
controller = InMemoryController()
35+
controller.put(BytesIO(b"payload"))
36+
assert controller.stream.getvalue() == b"payload"
37+
38+
39+
def test_upload_wraps_a_stream_in_an_in_memory_controller(monkeypatch):
40+
"""A BytesIO must reach WikiFileController as an InMemoryController.
41+
42+
Guards the duck-typed source check in UploadFileResult.__init__: an
43+
``isinstance(source, IO)`` test never matches, because typing.IO is not
44+
runtime-checkable.
45+
"""
46+
stream = BytesIO(b"payload")
47+
seen = {}
48+
49+
class _StopBeforeUpload(Exception):
50+
pass
51+
52+
def _fake_from_other(other, osw, **data):
53+
seen["source_file_controller"] = other
54+
raise _StopBeforeUpload
55+
56+
monkeypatch.setattr(
57+
osw.express.WikiFileController,
58+
"from_other",
59+
staticmethod(_fake_from_other),
60+
)
61+
62+
with pytest.raises(_StopBeforeUpload):
63+
osw.express.UploadFileResult(
64+
source=stream,
65+
osw_express=MagicMock(),
66+
target_fpt="File:Test.bin",
67+
)
68+
69+
controller = seen["source_file_controller"]
70+
assert isinstance(controller, InMemoryController)
71+
assert controller.get() is stream
72+
73+
74+
def test_upload_rejects_a_source_that_is_not_file_like():
75+
with pytest.raises(ValueError, match="must be a LocalFileController"):
76+
osw.express.UploadFileResult(source=object(), osw_express=MagicMock())

0 commit comments

Comments
 (0)