rmm commited on
Commit
7f8b3b0
·
1 Parent(s): 5312fc3

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
+