Spaces:
Sleeping
Sleeping
rmm
commited on
Commit
·
b0e0bde
1
Parent(s):
dca913f
test: first steps with AppTest and a mock multi-file file_uploader
Browse files- a test fixture that generates the right interface but fake data inside
the file objects
- a demo script instantiating a few minimal components (the
file_uploader, a callback for the on_change handling, and some visual
elements that are dynamically created when files are uploaded)
- some work towards a second fixture that loads real data too, not
used in any tests yet
src/apptest/demo_multifile_upload.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# a minimal snippet for validating the upload sequence, for testing purposes (with AppTest)
|
| 2 |
+
|
| 3 |
+
import streamlit as st
|
| 4 |
+
|
| 5 |
+
# to run streamlit from this subdir, we need the the src dir on the path
|
| 6 |
+
# NOTE: pytest doesn't need this to run the tests, but to develop the test
|
| 7 |
+
# harness is hard without running streamlit
|
| 8 |
+
import sys
|
| 9 |
+
from os import path
|
| 10 |
+
# src (parent from here)
|
| 11 |
+
src_dir = path.dirname( path.dirname( path.abspath(__file__) ) )
|
| 12 |
+
sys.path.append(src_dir)
|
| 13 |
+
|
| 14 |
+
# we aim to validate:
|
| 15 |
+
# - user uploads multple files via FileUploader (with key=file_uploader_data)
|
| 16 |
+
# - they get buffered into session state
|
| 17 |
+
# - some properties are extracted from the files, and are displayed in a visual
|
| 18 |
+
# element so we can validate them with apptest.
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
from input.input_handling import (
|
| 22 |
+
spoof_metadata, is_valid_email,
|
| 23 |
+
get_image_datetime, get_image_latlon,
|
| 24 |
+
init_input_data_session_states
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
def buffer_uploaded_files():
|
| 28 |
+
st.write("buffering files! ")
|
| 29 |
+
uploaded_files = st.session_state.file_uploader_data
|
| 30 |
+
for ix, file in enumerate(uploaded_files):
|
| 31 |
+
image_datetime_raw = get_image_datetime(file)
|
| 32 |
+
latitude0, longitude0 = get_image_latlon(file)
|
| 33 |
+
#st.write(f"- file {ix}: {file.name}")
|
| 34 |
+
#st.write(f" - datetime: {image_datetime_raw}")
|
| 35 |
+
#st.write(f" - lat/lon: {latitude0}, {longitude0}")
|
| 36 |
+
s = f"index: {ix}, name: {file.name}, datetime: {image_datetime_raw}, lat: {latitude0}, lon:{longitude0}"
|
| 37 |
+
st.text_area(f"{file.name}", value=s, key=f"metadata_{ix}")
|
| 38 |
+
print(s)
|
| 39 |
+
|
| 40 |
+
init_input_data_session_states()
|
| 41 |
+
|
| 42 |
+
with st.sidebar:
|
| 43 |
+
author_email = st.text_input("Author Email", spoof_metadata.get('author_email', ""),
|
| 44 |
+
key="input_author_email")
|
| 45 |
+
if author_email and not is_valid_email(author_email):
|
| 46 |
+
st.error("Please enter a valid email address.")
|
| 47 |
+
|
| 48 |
+
st.file_uploader(
|
| 49 |
+
"Upload one or more images", type=["png", 'jpg', 'jpeg', 'webp'],
|
| 50 |
+
accept_multiple_files=True,
|
| 51 |
+
key="file_uploader_data",
|
| 52 |
+
on_change=buffer_uploaded_files
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# this is the callback that would be triggered by the FileUploader
|
| 56 |
+
# - unfortunately, we get into a mess now
|
| 57 |
+
# - in real app, this runs twice and breaks (because of the duplicate keys)
|
| 58 |
+
# - in the test, if we don't run manually, we don't get the frontend elements to validate
|
| 59 |
+
# - if we remove the on_change, both run ok. but it deviates from the true app.
|
| 60 |
+
# - possible ways forward?
|
| 61 |
+
# - could we patch the on_change, or substitute the buffer_uploaded_files?
|
| 62 |
+
if (1 and "file_uploader_data" in st.session_state and
|
| 63 |
+
len(st.session_state.file_uploader_data) ):
|
| 64 |
+
print(f"buffering files: {len(st.session_state.file_uploader_data)}")
|
| 65 |
+
buffer_uploaded_files()
|
tests/test_demo_multifile_upload.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Protocol, runtime_checkable
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from io import BytesIO
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
from unittest.mock import MagicMock, patch
|
| 9 |
+
from streamlit.testing.v1 import AppTest
|
| 10 |
+
|
| 11 |
+
# - tests for apptest/demo_multifile_upload
|
| 12 |
+
|
| 13 |
+
# zero test: no inputs -> empty session state
|
| 14 |
+
# (or maybe even non-existent session state; for file_uploader we are not allowed to initialise the keyed variable, st borks)
|
| 15 |
+
|
| 16 |
+
# many test: list of 2 inputs -> session state with 2 files
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# for expectations
|
| 21 |
+
from input.input_handling import spoof_metadata
|
| 22 |
+
from input.input_validator import get_image_datetime, get_image_latlon
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@runtime_checkable
|
| 26 |
+
class UploadedFile(Protocol):
|
| 27 |
+
name: str
|
| 28 |
+
size: int
|
| 29 |
+
type: str
|
| 30 |
+
#RANDO: str
|
| 31 |
+
_file_urls: list
|
| 32 |
+
|
| 33 |
+
def getvalue(self) -> bytes: ...
|
| 34 |
+
def read(self) -> bytes: ...
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class MockUploadedFile(BytesIO):
|
| 38 |
+
def __init__(self,
|
| 39 |
+
initial_bytes: bytes,
|
| 40 |
+
*,
|
| 41 |
+
name: str,
|
| 42 |
+
size: int,
|
| 43 |
+
type: str):
|
| 44 |
+
super().__init__(initial_bytes)
|
| 45 |
+
self.name = name # Simulate a filename
|
| 46 |
+
self.size = size # Simulate file size
|
| 47 |
+
self.type = type # Simulate MIME type
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@pytest.fixture
|
| 51 |
+
def mock_uploadedFile():
|
| 52 |
+
def _mock_uploadedFile(name: str, size: int, type: str):
|
| 53 |
+
test_data = b'test data'
|
| 54 |
+
# now load some real data, if fname exists
|
| 55 |
+
base = Path(__file__).parent.parent
|
| 56 |
+
fname = Path(base / f"tests/data/{name}")
|
| 57 |
+
|
| 58 |
+
if fname.exists():
|
| 59 |
+
with open(fname, 'rb') as f:
|
| 60 |
+
#test_data = BytesIO(f.read())
|
| 61 |
+
test_data = f.read()
|
| 62 |
+
else:
|
| 63 |
+
#print(f"[DDDD] {name}, {size}, {type} not found")
|
| 64 |
+
raise FileNotFoundError(f"file {fname} not found ({name}, {size}, {type})")
|
| 65 |
+
|
| 66 |
+
return MockUploadedFile(
|
| 67 |
+
test_data, name=name, size=size, type=type,)
|
| 68 |
+
|
| 69 |
+
return _mock_uploadedFile
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@pytest.fixture
|
| 73 |
+
def mock_uploadedFileNoRealData():
|
| 74 |
+
class MockGUIClassFakeData(MagicMock):
|
| 75 |
+
def __init__(self, *args, **kwargs):
|
| 76 |
+
super().__init__(*args, **kwargs)
|
| 77 |
+
name = kwargs.get('fname', 'image2.jpg')
|
| 78 |
+
size = kwargs.get('size', 123456)
|
| 79 |
+
type = kwargs.get('type', 'image/jpeg')
|
| 80 |
+
self.bytes_io = MockUploadedFile(
|
| 81 |
+
b"test data", name=name, size=size, type=type)
|
| 82 |
+
self.get_data = MagicMock(return_value=self.bytes_io)
|
| 83 |
+
# it seems unclear to me which member attributes get set by the MockUploadedFile constructor
|
| 84 |
+
# - for some reason, size and type get set, but name does not, and results in
|
| 85 |
+
# <MockGUIClass name='mock.name' id='<12345>'>.
|
| 86 |
+
# so let's sjust explicitly set all the relevant attributes here.
|
| 87 |
+
self.name = name
|
| 88 |
+
self.size = size
|
| 89 |
+
self.type = type
|
| 90 |
+
|
| 91 |
+
return MockGUIClassFakeData
|
| 92 |
+
|
| 93 |
+
@pytest.fixture
|
| 94 |
+
def mock_uploadedFile_List(mock_uploadedFileNoRealData):
|
| 95 |
+
def create_list_of_mocks(num_files=3, **kwargs):
|
| 96 |
+
return [mock_uploadedFileNoRealData(**kwargs) for _ in range(num_files)]
|
| 97 |
+
return create_list_of_mocks
|
| 98 |
+
|
| 99 |
+
@pytest.fixture
|
| 100 |
+
def mock_uploadedFile_List_ImageData(mock_uploadedFile):
|
| 101 |
+
def create_list_of_mocks_realdata(num_files=3, **kwargs):
|
| 102 |
+
print(f"[D] [mock_uploadedFile_List_Img-internal] num_files: {num_files}")
|
| 103 |
+
data = [
|
| 104 |
+
{"name": "cakes.jpg", "size": 1234, "type": "image/jpeg"},
|
| 105 |
+
{"name": "cakes_no_exif_datetime.jpg", "size": 12345, "type": "image/jpeg"},
|
| 106 |
+
{"name": "cakes_no_exif_gps.jpg", "size": 123456, "type": "image/jpeg"},
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
_the_files = []
|
| 110 |
+
for i in range(num_files):
|
| 111 |
+
_the_files.append( mock_uploadedFile(**data[i]))
|
| 112 |
+
|
| 113 |
+
print(f"========== finished init of {num_files} mock_uploaded files | {len(_the_files)} ==========")
|
| 114 |
+
return _the_files
|
| 115 |
+
|
| 116 |
+
#return [mock_uploadedFile(**kwargs) for _ in range(num_files)]
|
| 117 |
+
return create_list_of_mocks_realdata
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_no_input_no_interaction():
|
| 122 |
+
at = AppTest.from_file("src/apptest/demo_multifile_upload.py").run()
|
| 123 |
+
|
| 124 |
+
assert at.session_state.observations == {}
|
| 125 |
+
assert at.session_state.input_author_email == spoof_metadata.get("author_email")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@patch("streamlit.file_uploader")
|
| 130 |
+
def test_mockupload_list(mock_file_uploader_rtn: MagicMock, mock_uploadedFile_List):
|
| 131 |
+
# Create a list of 2 mock files
|
| 132 |
+
mock_files = mock_uploadedFile_List(num_files=2, fname="test.jpg", size=100, type="image/jpeg")
|
| 133 |
+
|
| 134 |
+
# Set the return value of the mocked file_uploader to the list of mock files
|
| 135 |
+
mock_file_uploader_rtn.return_value = mock_files
|
| 136 |
+
|
| 137 |
+
# Run the Streamlit app
|
| 138 |
+
at = AppTest.from_file("src/apptest/demo_multifile_upload.py").run()
|
| 139 |
+
|
| 140 |
+
# put the mocked file_upload into session state, as if it were the result of a file upload, with the key 'file_uploader_data'
|
| 141 |
+
at.session_state["file_uploader_data"] = mock_files
|
| 142 |
+
|
| 143 |
+
#print(f"[I] session state: {at.session_state}")
|
| 144 |
+
#print(f"[I] uploaded files: {at.session_state.file_uploader_data}")
|
| 145 |
+
|
| 146 |
+
if 1:
|
| 147 |
+
print(f"[I] uploaded files: {at.session_state.file_uploader_data}")
|
| 148 |
+
for _f in at.session_state.file_uploader_data:
|
| 149 |
+
print(f"[I] props: {dir(_f)}")
|
| 150 |
+
print(f"[I] name: {_f.name}")
|
| 151 |
+
print(f"[I] size: {_f.size}")
|
| 152 |
+
print(f"[I] type: {_f.type}")
|
| 153 |
+
print(f"[I] data : {type(_f)} | {type(_f.return_value)} | {_f}")
|
| 154 |
+
# lets make an image from it.
|
| 155 |
+
#im = Image.open(_f)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# Verify behavior in the app
|
| 162 |
+
assert len(at.session_state.file_uploader_data) == 2
|
| 163 |
+
|
| 164 |
+
assert at.session_state.file_uploader_data[0].size == 100 # Check properties of the first file
|
| 165 |
+
assert at.session_state.file_uploader_data[1].name == "test.jpg" # Check properties of the second file
|
| 166 |
+
|