Spaces:
Sleeping
Sleeping
Merge branch 'dev' into feat/image-batch
Browse files- .github/workflows/python-pytest.yml +36 -0
- docs/dev_notes.md +46 -4
- pytest.ini +5 -0
- snippets/{test_upload.py → try_upload.py} +0 -0
- src/__init__.py +0 -0
- src/apptest/demo_whale_viewer.py +30 -0
- src/main.py +20 -6
- src/maps/obs_map.py +8 -1
- src/whale_gallery.py +5 -1
- src/whale_viewer.py +6 -6
- tests/README.md +10 -0
- tests/data/cakes.jpg +0 -0
- tests/data/cakes_no_exif_datetime.jpg +0 -0
- tests/data/cakes_no_exif_gps.jpg +0 -0
- tests/requirements.txt +6 -0
- tests/test_demo_whale_viewer.py +129 -0
- tests/test_input_handling.py +208 -0
- tests/test_whale_viewer.py +50 -0
.github/workflows/python-pytest.yml
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# This workflow will install Python dependencies, run tests and lint with a single version of Python
|
2 |
+
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
|
3 |
+
|
4 |
+
name: Execute tests with pytest
|
5 |
+
|
6 |
+
on:
|
7 |
+
push:
|
8 |
+
branches: [ "dev" ]
|
9 |
+
pull_request:
|
10 |
+
branches: [ "dev", "main" ]
|
11 |
+
permissions:
|
12 |
+
contents: read
|
13 |
+
jobs:
|
14 |
+
build:
|
15 |
+
runs-on: ubuntu-latest
|
16 |
+
steps:
|
17 |
+
- uses: actions/checkout@v4
|
18 |
+
- name: Set up Python 3.10
|
19 |
+
uses: actions/setup-python@v3
|
20 |
+
with:
|
21 |
+
python-version: "3.10"
|
22 |
+
- name: Install dependencies
|
23 |
+
run: |
|
24 |
+
python -m pip install --upgrade pip
|
25 |
+
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
26 |
+
if [ -f tests/requirements.txt ]; then pip install -r tests/requirements.txt; fi
|
27 |
+
# if [ -f pyproject.toml ]; then pip install -r pyproject.toml; fi
|
28 |
+
#- name: Lint with flake8
|
29 |
+
# run: |
|
30 |
+
# # stop the build if there are Python syntax errors or undefined names
|
31 |
+
# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
32 |
+
# # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
33 |
+
# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
34 |
+
- name: Test with pytest
|
35 |
+
run: |
|
36 |
+
pytest
|
docs/dev_notes.md
CHANGED
@@ -5,7 +5,7 @@ We set this up so it is hosted as a huggingface space. Each commit to `main` tri
|
|
5 |
For local testing, assuming you have all the required packages installed in a
|
6 |
conda env or virtualenv, and that env is activated:
|
7 |
|
8 |
-
```
|
9 |
cd src
|
10 |
streamlit run main.py
|
11 |
```
|
@@ -17,15 +17,17 @@ We have a CI action to presesnt the docs on github.io.
|
|
17 |
To validate locally, you need the deps listed in `requirements.txt` installed.
|
18 |
|
19 |
Run
|
20 |
-
```
|
21 |
mkdocs serve
|
22 |
```
|
|
|
23 |
And navigate to the wish server running locally, by default: http://127.0.0.1:8888/
|
24 |
|
25 |
This automatically watches for changes in the markdown files, but if you edit the
|
26 |
something else like the docstrings in py files, triggering a rebuild in another terminal
|
27 |
refreshes the site, without having to quit and restart the server.
|
28 |
-
|
|
|
29 |
mkdocs build -c
|
30 |
```
|
31 |
|
@@ -37,4 +39,44 @@ mkdocs build -c
|
|
37 |
|
38 |
# Set up a conda env
|
39 |
|
40 |
-
(Standard stuff)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
For local testing, assuming you have all the required packages installed in a
|
6 |
conda env or virtualenv, and that env is activated:
|
7 |
|
8 |
+
```bash
|
9 |
cd src
|
10 |
streamlit run main.py
|
11 |
```
|
|
|
17 |
To validate locally, you need the deps listed in `requirements.txt` installed.
|
18 |
|
19 |
Run
|
20 |
+
```bash
|
21 |
mkdocs serve
|
22 |
```
|
23 |
+
|
24 |
And navigate to the wish server running locally, by default: http://127.0.0.1:8888/
|
25 |
|
26 |
This automatically watches for changes in the markdown files, but if you edit the
|
27 |
something else like the docstrings in py files, triggering a rebuild in another terminal
|
28 |
refreshes the site, without having to quit and restart the server.
|
29 |
+
|
30 |
+
```bash
|
31 |
mkdocs build -c
|
32 |
```
|
33 |
|
|
|
39 |
|
40 |
# Set up a conda env
|
41 |
|
42 |
+
(Standard stuff)
|
43 |
+
|
44 |
+
|
45 |
+
# Testing
|
46 |
+
|
47 |
+
## local testing
|
48 |
+
To run the tests locally, we have the standard dependencies of the project, plus the test runner dependencies.
|
49 |
+
|
50 |
+
```bash
|
51 |
+
pip install -r tests/requirements.txt
|
52 |
+
```
|
53 |
+
|
54 |
+
(If we migrate to using toml config, the test reqs could be consolidated into an optional section)
|
55 |
+
|
56 |
+
|
57 |
+
**Running tests**
|
58 |
+
from the project root, simply run:
|
59 |
+
|
60 |
+
```bash
|
61 |
+
pytest
|
62 |
+
# or pick a specific test file to run
|
63 |
+
pytest tests/test_whale_viewer.py
|
64 |
+
```
|
65 |
+
|
66 |
+
To generate a coverage report to screen (also run the tests):
|
67 |
+
```bash
|
68 |
+
pytest --cov=src
|
69 |
+
```
|
70 |
+
|
71 |
+
To generate reports on pass rate and coverage, to files:
|
72 |
+
```bash
|
73 |
+
pytest --junit-xml=test-results.xml
|
74 |
+
pytest --cov-report=lcov --cov=src
|
75 |
+
```
|
76 |
+
|
77 |
+
|
78 |
+
## CI testing
|
79 |
+
|
80 |
+
Initially we have an action setup that runs all tests in the `tests` directory, within the `test/tests` branch.
|
81 |
+
|
82 |
+
TODO: Add some test report & coverage badges to the README.
|
pytest.ini
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
[pytest]
|
2 |
+
pythonpath = "src"
|
3 |
+
testpaths =
|
4 |
+
tests
|
5 |
+
|
snippets/{test_upload.py → try_upload.py}
RENAMED
File without changes
|
src/__init__.py
ADDED
File without changes
|
src/apptest/demo_whale_viewer.py
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# a minimal snippet for the whale viewer, for testing purposes
|
2 |
+
# - using AppTest to validate that the display_whale functionality
|
3 |
+
# is ok
|
4 |
+
# - currently placed in the src directory (not optimal) because
|
5 |
+
# I couldn't get pytest to pick it up from the tests directory.
|
6 |
+
# - TODO: find a cleaner solution for organisation (maybe just config to pytest?)
|
7 |
+
|
8 |
+
import streamlit as st
|
9 |
+
|
10 |
+
# to run streamlit from this subdir, we need the the src dir on the path
|
11 |
+
# NOTE: pytest doesn't need this to run the tests, but to develop the test
|
12 |
+
# harness is hard without running streamlit
|
13 |
+
import sys
|
14 |
+
from os import path
|
15 |
+
# src (parent from here)
|
16 |
+
src_dir = path.dirname( path.dirname( path.abspath(__file__) ) )
|
17 |
+
sys.path.append(src_dir)
|
18 |
+
|
19 |
+
|
20 |
+
import whale_viewer as sw_wv
|
21 |
+
|
22 |
+
# a menu to pick one of the images
|
23 |
+
title = st.title("Whale Viewer testing")
|
24 |
+
species = st.selectbox("Species", sw_wv.WHALE_CLASSES)
|
25 |
+
|
26 |
+
if species is not None:
|
27 |
+
# and display the image + reference
|
28 |
+
st.write(f"Selected species: {species}")
|
29 |
+
sw_wv.display_whale([species], 0, st)
|
30 |
+
|
src/main.py
CHANGED
@@ -94,8 +94,8 @@ def main() -> None:
|
|
94 |
#g_logger.warning("warning message")
|
95 |
|
96 |
# Streamlit app
|
97 |
-
|
98 |
-
|
99 |
st.session_state.tab_log = tab_log
|
100 |
|
101 |
|
@@ -118,6 +118,7 @@ def main() -> None:
|
|
118 |
with tab_map:
|
119 |
# visual structure: a couple of toggles at the top, then the map inlcuding a
|
120 |
# dropdown for tileset selection.
|
|
|
121 |
tab_map_ui_cols = st.columns(2)
|
122 |
with tab_map_ui_cols[0]:
|
123 |
show_db_points = st.toggle("Show Points from DB", True)
|
@@ -146,9 +147,13 @@ def main() -> None:
|
|
146 |
|
147 |
|
148 |
|
149 |
-
with
|
150 |
# the goal of this tab is to allow selection of the new obsvation's location by map click/adjust.
|
151 |
-
st.markdown("Coming later
|
|
|
|
|
|
|
|
|
152 |
|
153 |
st.write("Click on the map to capture a location.")
|
154 |
#m = folium.Map(location=visp_loc, zoom_start=7)
|
@@ -180,7 +185,7 @@ def main() -> None:
|
|
180 |
tab_log.info(f"{st.session_state.observations}")
|
181 |
|
182 |
df = pd.DataFrame(submitted_data, index=[0])
|
183 |
-
with
|
184 |
st.table(df)
|
185 |
|
186 |
|
@@ -192,12 +197,17 @@ def main() -> None:
|
|
192 |
# - these species are shown
|
193 |
# - the user can override the species prediction using the dropdown
|
194 |
# - an observation is uploaded if the user chooses.
|
|
|
|
|
|
|
|
|
195 |
|
196 |
if tab_inference.button("Identify with cetacean classifier"):
|
197 |
#pipe = pipeline("image-classification", model="Saving-Willy/cetacean-classifier", trust_remote_code=True)
|
198 |
cetacean_classifier = AutoModelForImageClassification.from_pretrained("Saving-Willy/cetacean-classifier",
|
199 |
revision=classifier_revision,
|
200 |
trust_remote_code=True)
|
|
|
201 |
|
202 |
if st.session_state.images is None:
|
203 |
# TODO: cleaner design to disable the button until data input done?
|
@@ -212,11 +222,15 @@ def main() -> None:
|
|
212 |
# purposes, an hotdog image classifier) which will be run locally.
|
213 |
# - this model predicts if the image is a hotdog or not, and returns probabilities
|
214 |
# - the input image is the same as for the ceteacean classifier - defined in the sidebar
|
|
|
|
|
|
|
|
|
|
|
215 |
|
216 |
if tab_hotdogs.button("Get Hotdog Prediction"):
|
217 |
|
218 |
pipeline_hot_dog = pipeline(task="image-classification", model="julien-c/hotdog-not-hotdog")
|
219 |
-
tab_hotdogs.title("Hot Dog? Or Not?")
|
220 |
|
221 |
if st.session_state.image is None:
|
222 |
st.info("Please upload an image first.")
|
|
|
94 |
#g_logger.warning("warning message")
|
95 |
|
96 |
# Streamlit app
|
97 |
+
tab_inference, tab_hotdogs, tab_map, tab_coords, tab_log, tab_gallery = \
|
98 |
+
st.tabs(["Cetecean classifier", "Hotdog classifier", "Map", "*:gray[Dev:coordinates]*", "Log", "Beautiful cetaceans"])
|
99 |
st.session_state.tab_log = tab_log
|
100 |
|
101 |
|
|
|
118 |
with tab_map:
|
119 |
# visual structure: a couple of toggles at the top, then the map inlcuding a
|
120 |
# dropdown for tileset selection.
|
121 |
+
sw_map.add_header_text()
|
122 |
tab_map_ui_cols = st.columns(2)
|
123 |
with tab_map_ui_cols[0]:
|
124 |
show_db_points = st.toggle("Show Points from DB", True)
|
|
|
147 |
|
148 |
|
149 |
|
150 |
+
with tab_coords:
|
151 |
# the goal of this tab is to allow selection of the new obsvation's location by map click/adjust.
|
152 |
+
st.markdown("Coming later! :construction:")
|
153 |
+
st.markdown(
|
154 |
+
f"""*The goal is to allow interactive definition for the coordinates of a new
|
155 |
+
observation, by click/drag points on the map.*""")
|
156 |
+
|
157 |
|
158 |
st.write("Click on the map to capture a location.")
|
159 |
#m = folium.Map(location=visp_loc, zoom_start=7)
|
|
|
185 |
tab_log.info(f"{st.session_state.observations}")
|
186 |
|
187 |
df = pd.DataFrame(submitted_data, index=[0])
|
188 |
+
with tab_coords:
|
189 |
st.table(df)
|
190 |
|
191 |
|
|
|
197 |
# - these species are shown
|
198 |
# - the user can override the species prediction using the dropdown
|
199 |
# - an observation is uploaded if the user chooses.
|
200 |
+
tab_inference.markdown("""
|
201 |
+
*Run classifer to identify the species of cetean on the uploaded image.
|
202 |
+
Once inference is complete, the top three predictions are shown.
|
203 |
+
You can override the prediction by selecting a species from the dropdown.*""")
|
204 |
|
205 |
if tab_inference.button("Identify with cetacean classifier"):
|
206 |
#pipe = pipeline("image-classification", model="Saving-Willy/cetacean-classifier", trust_remote_code=True)
|
207 |
cetacean_classifier = AutoModelForImageClassification.from_pretrained("Saving-Willy/cetacean-classifier",
|
208 |
revision=classifier_revision,
|
209 |
trust_remote_code=True)
|
210 |
+
|
211 |
|
212 |
if st.session_state.images is None:
|
213 |
# TODO: cleaner design to disable the button until data input done?
|
|
|
222 |
# purposes, an hotdog image classifier) which will be run locally.
|
223 |
# - this model predicts if the image is a hotdog or not, and returns probabilities
|
224 |
# - the input image is the same as for the ceteacean classifier - defined in the sidebar
|
225 |
+
tab_hotdogs.title("Hot Dog? Or Not?")
|
226 |
+
tab_hotdogs.write("""
|
227 |
+
*Run alternative classifer on input images. Here we are using
|
228 |
+
a binary classifier - hotdog or not - from
|
229 |
+
huggingface.co/julien-c/hotdog-not-hotdog.*""")
|
230 |
|
231 |
if tab_hotdogs.button("Get Hotdog Prediction"):
|
232 |
|
233 |
pipeline_hot_dog = pipeline(task="image-classification", model="julien-c/hotdog-not-hotdog")
|
|
|
234 |
|
235 |
if st.session_state.image is None:
|
236 |
st.info("Please upload an image first.")
|
src/maps/obs_map.py
CHANGED
@@ -189,4 +189,11 @@ def present_obs_map(dataset_id:str = "Saving-Willy/Happywhale-kaggle",
|
|
189 |
# this is just debug info --
|
190 |
#st.info("[D]" + str(metadata.column_names))
|
191 |
|
192 |
-
return st_data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
189 |
# this is just debug info --
|
190 |
#st.info("[D]" + str(metadata.column_names))
|
191 |
|
192 |
+
return st_data
|
193 |
+
|
194 |
+
|
195 |
+
def add_header_text() -> None:
|
196 |
+
"""
|
197 |
+
Add brief explainer text to the tab
|
198 |
+
"""
|
199 |
+
st.write("A map showing the observations in the dataset, with markers colored by species.")
|
src/whale_gallery.py
CHANGED
@@ -59,7 +59,11 @@ def render_whale_gallery(n_cols:int = 4) -> None:
|
|
59 |
""",
|
60 |
unsafe_allow_html=True,
|
61 |
)
|
62 |
-
|
|
|
|
|
|
|
|
|
63 |
cols = cycle(st.columns(n_cols))
|
64 |
for ix in range(len(sw_wv.df_whale_img_ref)):
|
65 |
img_name = sw_wv.df_whale_img_ref.iloc[ix].loc["WHALE_IMAGES"]
|
|
|
59 |
""",
|
60 |
unsafe_allow_html=True,
|
61 |
)
|
62 |
+
_n = len(sw_wv.df_whale_img_ref)
|
63 |
+
st.markdown(
|
64 |
+
f"""*The {_n} classes of cetaceans that our classifier can identify.
|
65 |
+
The links provide more information about each species, from NOAA or
|
66 |
+
wikipedia.*""")
|
67 |
cols = cycle(st.columns(n_cols))
|
68 |
for ix in range(len(sw_wv.df_whale_img_ref)):
|
69 |
img_name = sw_wv.df_whale_img_ref.iloc[ix].loc["WHALE_IMAGES"]
|
src/whale_viewer.py
CHANGED
@@ -1,5 +1,7 @@
|
|
1 |
from typing import List
|
2 |
import streamlit as st
|
|
|
|
|
3 |
from PIL import Image
|
4 |
import pandas as pd
|
5 |
import os
|
@@ -117,22 +119,20 @@ def format_whale_name(whale_class:str) -> str:
|
|
117 |
return whale_name
|
118 |
|
119 |
|
120 |
-
def display_whale(whale_classes:List[str], i:int, viewcontainer=None):
|
121 |
"""
|
122 |
Display whale image and reference to the provided viewcontainer.
|
123 |
|
124 |
Args:
|
125 |
whale_classes (List[str]): A list of whale class names.
|
126 |
i (int): The index of the whale class to display.
|
127 |
-
viewcontainer: The container
|
128 |
-
not provided, use the current
|
129 |
-
'with `container`' syntax)
|
130 |
|
131 |
Returns:
|
132 |
None
|
133 |
|
134 |
-
TODO: how to find the object type of viewcontainer.? they are just "deltagenerators" but
|
135 |
-
we want the result of the generator.. In any case, it works ok with either call signature.
|
136 |
"""
|
137 |
|
138 |
if viewcontainer is None:
|
|
|
1 |
from typing import List
|
2 |
import streamlit as st
|
3 |
+
from streamlit.delta_generator import DeltaGenerator
|
4 |
+
|
5 |
from PIL import Image
|
6 |
import pandas as pd
|
7 |
import os
|
|
|
119 |
return whale_name
|
120 |
|
121 |
|
122 |
+
def display_whale(whale_classes:List[str], i:int, viewcontainer:DeltaGenerator=None) -> None:
|
123 |
"""
|
124 |
Display whale image and reference to the provided viewcontainer.
|
125 |
|
126 |
Args:
|
127 |
whale_classes (List[str]): A list of whale class names.
|
128 |
i (int): The index of the whale class to display.
|
129 |
+
viewcontainer (streamlit.delta_generator.DeltaGenerator): The container
|
130 |
+
to display the whale information. If not provided, use the current
|
131 |
+
streamlit context (works via 'with `container`' syntax)
|
132 |
|
133 |
Returns:
|
134 |
None
|
135 |
|
|
|
|
|
136 |
"""
|
137 |
|
138 |
if viewcontainer is None:
|
tests/README.md
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Test report
|
2 |
+
|
3 |
+
- overall status: 
|
4 |
+
- more detailed test report: TODO
|
5 |
+
|
6 |
+
## Test coverage
|
7 |
+
|
8 |
+
- TODO
|
9 |
+
- For a summary: one way is using https://github.com/GaelGirodon/ci-badges-action, can add it as a post-pytest step to the CI
|
10 |
+
- For a table: try this https://github.com/coroo/pytest-coverage-commentator
|
tests/data/cakes.jpg
ADDED
![]() |
tests/data/cakes_no_exif_datetime.jpg
ADDED
![]() |
tests/data/cakes_no_exif_gps.jpg
ADDED
![]() |
tests/requirements.txt
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# tests
|
2 |
+
pytest~=8.3.4
|
3 |
+
pytest-cov~=6.0.0
|
4 |
+
# linting
|
5 |
+
#flake8
|
6 |
+
|
tests/test_demo_whale_viewer.py
ADDED
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from streamlit.testing.v1 import AppTest
|
2 |
+
import pytest # for the exception testing
|
3 |
+
|
4 |
+
import whale_viewer as sw_wv # for data
|
5 |
+
|
6 |
+
|
7 |
+
def test_selectbox_ok():
|
8 |
+
'''
|
9 |
+
test the snippet demoing whale viewer - relating to AppTest'able elements
|
10 |
+
|
11 |
+
we validate that
|
12 |
+
- there is one selectbox present, with initial value "beluga" and index 0
|
13 |
+
- the two markdown elems generated dynamically by the selection corresponds
|
14 |
+
|
15 |
+
- then changing the selection, we do the same checks again
|
16 |
+
|
17 |
+
- finally, we check there are the right number of options (26)
|
18 |
+
|
19 |
+
'''
|
20 |
+
at = AppTest.from_file("src/apptest/demo_whale_viewer.py").run()
|
21 |
+
assert len(at.selectbox) == 1
|
22 |
+
assert at.selectbox[0].value == "beluga"
|
23 |
+
assert at.selectbox[0].index == 0
|
24 |
+
|
25 |
+
# let's check that the markdown is right
|
26 |
+
# the first markdown should be "Selected species: beluga"
|
27 |
+
assert at.markdown[0].value == "Selected species: beluga"
|
28 |
+
# the second markdown should be "### :whale: #1: Beluga"
|
29 |
+
print("markdown 1: ", at.markdown[1].value)
|
30 |
+
assert at.markdown[1].value == "### :whale: #1: Beluga"
|
31 |
+
|
32 |
+
# now let's select a different element. index 4 is commersons_dolphin
|
33 |
+
v4 = "commersons_dolphin"
|
34 |
+
v4_str = v4.replace("_", " ").title()
|
35 |
+
|
36 |
+
at.selectbox[0].set_value(v4).run()
|
37 |
+
assert at.selectbox[0].value == v4
|
38 |
+
assert at.selectbox[0].index == 4
|
39 |
+
# the first markdown should be "Selected species: commersons_dolphin"
|
40 |
+
assert at.markdown[0].value == f"Selected species: {v4}"
|
41 |
+
# the second markdown should be "### :whale: #1: Commersons Dolphin"
|
42 |
+
assert at.markdown[1].value == f"### :whale: #1: {v4_str}"
|
43 |
+
|
44 |
+
# test there are the right number of options
|
45 |
+
print("PROPS=> ", dir(at.selectbox[0])) # no length unfortunately,
|
46 |
+
# test it dynamically intead.
|
47 |
+
# should be fine
|
48 |
+
at.selectbox[0].select_index(len(sw_wv.WHALE_CLASSES)-1).run()
|
49 |
+
# should fail
|
50 |
+
with pytest.raises(Exception):
|
51 |
+
at.selectbox[0].select_index(len(sw_wv.WHALE_CLASSES)).run()
|
52 |
+
|
53 |
+
def test_img_props():
|
54 |
+
'''
|
55 |
+
test the snippet demoing whale viewer - relating to the image
|
56 |
+
|
57 |
+
we validate that
|
58 |
+
- one image is displayed
|
59 |
+
- the caption corresponds to the data in WHALE_REFERENCES
|
60 |
+
- the url is a mock url
|
61 |
+
|
62 |
+
- then changing the image, we do the same checks again
|
63 |
+
|
64 |
+
'''
|
65 |
+
at = AppTest.from_file("src/apptest/demo_whale_viewer.py").run()
|
66 |
+
ix = 0 # we didn't interact with the dropdown, so it should be the first one
|
67 |
+
# could fetch the property - maybe better in case code example changes
|
68 |
+
ix = at.selectbox[0].index
|
69 |
+
|
70 |
+
elem = at.get("imgs") # hmm, apparently the naming is not consistent with the other AppTest f/w.
|
71 |
+
# type(elem[0]) -> "streamlit.testing.v1.element_tree.UnknownElement" haha
|
72 |
+
assert len(elem) == 1
|
73 |
+
img0 = elem[0]
|
74 |
+
|
75 |
+
# we can't check the image, but maybe the alt text?
|
76 |
+
#assert at.image[0].alt == "beluga" # no, doesn't have that property.
|
77 |
+
|
78 |
+
# for v1.39, the proto comes back something like this:
|
79 |
+
exp_proto = '''
|
80 |
+
imgs {
|
81 |
+
caption: "https://www.fisheries.noaa.gov/species/beluga-whale"
|
82 |
+
url: "/mock/media/6a21db178fcd99b82817906fc716a5c35117f4daa1d1c1d3c16ae1c8.png"
|
83 |
+
}
|
84 |
+
width: -3
|
85 |
+
'''
|
86 |
+
# from the proto string we can look for <itemtype>: "<value>" pairs and make a dictionary
|
87 |
+
import re
|
88 |
+
|
89 |
+
def parse_proto(proto_str):
|
90 |
+
pattern = r'(\w+):\s*"([^"]+)"'
|
91 |
+
matches = re.findall(pattern, proto_str)
|
92 |
+
return {key: value for key, value in matches}
|
93 |
+
|
94 |
+
parsed_proto = parse_proto(str(img0.proto))
|
95 |
+
# we're expecting the caption to be WHALE_REFERENCES[ix]
|
96 |
+
print(parsed_proto)
|
97 |
+
assert "caption" in parsed_proto
|
98 |
+
assert parsed_proto["caption"] == sw_wv.WHALE_REFERENCES[ix]
|
99 |
+
assert "url" in parsed_proto
|
100 |
+
assert parsed_proto["url"].startswith("/mock/media")
|
101 |
+
|
102 |
+
print(sw_wv.WHALE_REFERENCES[ix])
|
103 |
+
|
104 |
+
# now let's switch to another index
|
105 |
+
ix = 15
|
106 |
+
v15 = sw_wv.WHALE_CLASSES[ix]
|
107 |
+
v15_str = v15.replace("_", " ").title()
|
108 |
+
at.selectbox[0].set_value(v15).run()
|
109 |
+
|
110 |
+
elem = at.get("imgs")
|
111 |
+
img0 = elem[0]
|
112 |
+
print("[INFO] image 0 after adjusting dropdown:")
|
113 |
+
print(img0.type, type(img0.proto))#, "\t", i0.value) # it doesn't have a value
|
114 |
+
print(img0.proto)
|
115 |
+
|
116 |
+
|
117 |
+
parsed_proto = parse_proto(str(img0.proto))
|
118 |
+
# we're expecting the caption to be WHALE_REFERENCES[ix]
|
119 |
+
print(parsed_proto)
|
120 |
+
assert "caption" in parsed_proto
|
121 |
+
assert parsed_proto["caption"] == sw_wv.WHALE_REFERENCES[ix]
|
122 |
+
assert "url" in parsed_proto
|
123 |
+
assert parsed_proto["url"].startswith("/mock/media")
|
124 |
+
|
125 |
+
|
126 |
+
|
127 |
+
|
128 |
+
|
129 |
+
|
tests/test_input_handling.py
ADDED
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pytest
|
2 |
+
from pathlib import Path
|
3 |
+
|
4 |
+
from input_handling import is_valid_email, is_valid_number
|
5 |
+
from input_handling import get_image_datetime, get_image_latlon, decimal_coords
|
6 |
+
|
7 |
+
# generate tests for is_valid_email
|
8 |
+
# - test with valid email
|
9 |
+
# - basic email with @ and .
|
10 |
+
# - test with email with multiple .
|
11 |
+
# - test with empty email
|
12 |
+
# - test with None email
|
13 |
+
# - test with non-string email
|
14 |
+
# - test with invalid email
|
15 |
+
# - test with email without @
|
16 |
+
# - test with email without .
|
17 |
+
# - test with email without domain
|
18 |
+
# - test with email without username
|
19 |
+
# - test with email without TLD
|
20 |
+
# - test with email with multiple @
|
21 |
+
# - test with email starting with the + sign
|
22 |
+
|
23 |
+
|
24 |
+
def test_is_valid_email_valid():
|
25 |
+
assert is_valid_email("[email protected]")
|
26 |
+
assert is_valid_email("[email protected]")
|
27 |
+
assert is_valid_email("[email protected]")
|
28 |
+
assert is_valid_email("[email protected]")
|
29 |
+
assert is_valid_email("[email protected]")
|
30 |
+
|
31 |
+
def test_is_valid_email_empty():
|
32 |
+
assert not is_valid_email("")
|
33 |
+
|
34 |
+
def test_is_valid_email_none():
|
35 |
+
with pytest.raises(TypeError):
|
36 |
+
is_valid_email(None)
|
37 |
+
|
38 |
+
def test_is_valid_email_non_string():
|
39 |
+
with pytest.raises(TypeError):
|
40 |
+
is_valid_email(123)
|
41 |
+
|
42 |
+
|
43 |
+
def test_is_valid_email_invalid():
|
44 |
+
assert not is_valid_email("a.bc")
|
45 |
+
assert not is_valid_email("a@bc")
|
46 |
+
assert not is_valid_email("a.b@cc")
|
47 |
+
assert not is_valid_email("@b.cc")
|
48 |
+
assert not is_valid_email("[email protected]")
|
49 |
+
assert not is_valid_email("a@b.")
|
50 |
+
assert not is_valid_email("a@bb.")
|
51 |
+
assert not is_valid_email("[email protected].")
|
52 |
+
assert not is_valid_email("a@[email protected]")
|
53 |
+
|
54 |
+
# not sure how xfails come through the CI pipeline yet.
|
55 |
+
# maybe better to just comment out this stuff until pipeline is setup, then can check /extend
|
56 |
+
@pytest.mark.xfail(reason="Bug identified, but while setting up CI having failing tests causes more headache")
|
57 |
+
def test_is_valid_email_invalid_plus():
|
58 |
+
assert not is_valid_email("[email protected]")
|
59 |
+
assert not is_valid_email("[email protected]")
|
60 |
+
|
61 |
+
|
62 |
+
def test_is_valid_number_valid():
|
63 |
+
# with a sign or without, fractional or integer are all valid
|
64 |
+
assert is_valid_number("123")
|
65 |
+
assert is_valid_number("123.456")
|
66 |
+
assert is_valid_number("-123")
|
67 |
+
assert is_valid_number("-123.456")
|
68 |
+
assert is_valid_number("+123")
|
69 |
+
assert is_valid_number("+123.456")
|
70 |
+
|
71 |
+
def test_is_valid_number_empty():
|
72 |
+
assert not is_valid_number("")
|
73 |
+
|
74 |
+
def test_is_valid_number_none():
|
75 |
+
with pytest.raises(TypeError):
|
76 |
+
is_valid_number(None)
|
77 |
+
|
78 |
+
def test_is_valid_number_invalid():
|
79 |
+
# func should return False for strings that are not numbers
|
80 |
+
assert not is_valid_number("abc")
|
81 |
+
assert not is_valid_number("123abc")
|
82 |
+
assert not is_valid_number("abc123")
|
83 |
+
assert not is_valid_number("123.456.789")
|
84 |
+
assert not is_valid_number("123,456")
|
85 |
+
assert not is_valid_number("123-456")
|
86 |
+
assert not is_valid_number("123+456")
|
87 |
+
def test_is_valid_number_valid():
|
88 |
+
assert is_valid_number("123")
|
89 |
+
assert is_valid_number("123.456")
|
90 |
+
assert is_valid_number("-123")
|
91 |
+
assert is_valid_number("-123.456")
|
92 |
+
assert is_valid_number("+123")
|
93 |
+
assert is_valid_number("+123.456")
|
94 |
+
|
95 |
+
def test_is_valid_number_empty():
|
96 |
+
assert not is_valid_number("")
|
97 |
+
|
98 |
+
def test_is_valid_number_none():
|
99 |
+
with pytest.raises(TypeError):
|
100 |
+
is_valid_number(None)
|
101 |
+
|
102 |
+
def test_is_valid_number_invalid():
|
103 |
+
assert not is_valid_number("abc")
|
104 |
+
assert not is_valid_number("123abc")
|
105 |
+
assert not is_valid_number("abc123")
|
106 |
+
assert not is_valid_number("123.456.789")
|
107 |
+
assert not is_valid_number("123,456")
|
108 |
+
assert not is_valid_number("123-456")
|
109 |
+
assert not is_valid_number("123+456")
|
110 |
+
|
111 |
+
|
112 |
+
|
113 |
+
# tests for get_image_datetime
|
114 |
+
# - testing with a valid image with complete, valid metadata
|
115 |
+
# - testing with a valid image with incomplete metadata (missing datetime info -- that's a legitimate case we should handle)
|
116 |
+
# - testing with a valid image with incomplete metadata (missing GPS info -- should not affect the datetime extraction)
|
117 |
+
# - testing with a valid image with no metadata
|
118 |
+
# - timezones too
|
119 |
+
|
120 |
+
|
121 |
+
test_data_pth = Path('tests/data/')
|
122 |
+
def test_get_image_datetime():
|
123 |
+
|
124 |
+
# this image has lat, lon, and datetime
|
125 |
+
f1 = test_data_pth / 'cakes.jpg'
|
126 |
+
assert get_image_datetime(f1) == "2024:10:24 15:59:45"
|
127 |
+
#"+02:00"
|
128 |
+
# hmm, the full datetime requires timezone, which is called OffsetTimeOriginal
|
129 |
+
|
130 |
+
# missing GPS loc: this should not interfere with the datetime
|
131 |
+
f2 = test_data_pth / 'cakes_no_exif_gps.jpg'
|
132 |
+
assert get_image_datetime(f2) == "2024:10:24 15:59:45"
|
133 |
+
|
134 |
+
# missng datetime -> expect None
|
135 |
+
f3 = test_data_pth / 'cakes_no_exif_datetime.jpg'
|
136 |
+
assert get_image_datetime(f3) == None
|
137 |
+
|
138 |
+
|
139 |
+
def test_get_image_latlon():
|
140 |
+
# this image has lat, lon, and datetime
|
141 |
+
f1 = test_data_pth / 'cakes.jpg'
|
142 |
+
assert get_image_latlon(f1) == (46.51860277777778, 6.562075)
|
143 |
+
|
144 |
+
# missing GPS loc
|
145 |
+
f2 = test_data_pth / 'cakes_no_exif_gps.jpg'
|
146 |
+
assert get_image_latlon(f2) == None
|
147 |
+
|
148 |
+
# missng datetime -> expect gps not affected
|
149 |
+
f3 = test_data_pth / 'cakes_no_exif_datetime.jpg'
|
150 |
+
assert get_image_latlon(f3) == (46.51860277777778, 6.562075)
|
151 |
+
|
152 |
+
# tests for get_image_latlon with empty file
|
153 |
+
def test_get_image_latlon_empty():
|
154 |
+
assert get_image_latlon("") == None
|
155 |
+
|
156 |
+
# tests for decimal_coords
|
157 |
+
# - without input, py raises TypeError
|
158 |
+
# - with the wrong length of input (expecting 3 elements in the tuple), expect ValueError
|
159 |
+
# - with string inputs instead of numeric, we get a TypeError (should the func bother checking this? happens as built in)
|
160 |
+
# - with ref direction not in ['N', 'S', 'E', 'W'], expect ValueError, try X, x, NW.
|
161 |
+
# - with valid inputs, expect the correct output
|
162 |
+
|
163 |
+
|
164 |
+
# test data for decimal_coords: (deg,min,sec), ref, expected output
|
165 |
+
coords_conversion_data = [
|
166 |
+
((30, 1, 2), 'W', -30.01722222),
|
167 |
+
((30, 1, 2), 'E', 30.01722222),
|
168 |
+
((30, 1, 2), 'N', 30.01722222),
|
169 |
+
((30, 1, 2), 'S', -30.01722222),
|
170 |
+
((46, 31, 6.97), 'N', 46.51860278),
|
171 |
+
((6, 33, 43.47), 'E', 6.56207500)
|
172 |
+
]
|
173 |
+
@pytest.mark.parametrize("input_coords, ref, expected_output", coords_conversion_data)
|
174 |
+
def test_decimal_coords(input_coords, ref, expected_output):
|
175 |
+
assert decimal_coords(input_coords, ref) == pytest.approx(expected_output)
|
176 |
+
|
177 |
+
def test_decimal_coords_no_input():
|
178 |
+
with pytest.raises(TypeError):
|
179 |
+
decimal_coords()
|
180 |
+
|
181 |
+
def test_decimal_coords_wrong_length():
|
182 |
+
with pytest.raises(ValueError):
|
183 |
+
decimal_coords((1, 2), 'W')
|
184 |
+
|
185 |
+
with pytest.raises(ValueError):
|
186 |
+
decimal_coords((30,), 'W')
|
187 |
+
|
188 |
+
with pytest.raises(ValueError):
|
189 |
+
decimal_coords((30, 1, 2, 4), 'W')
|
190 |
+
|
191 |
+
def test_decimal_coords_non_numeric():
|
192 |
+
with pytest.raises(TypeError):
|
193 |
+
decimal_coords(('1', '2', '3'), 'W')
|
194 |
+
|
195 |
+
|
196 |
+
def test_decimal_coords_invalid_ref():
|
197 |
+
with pytest.raises(ValueError):
|
198 |
+
decimal_coords((30, 1, 2), 'X')
|
199 |
+
|
200 |
+
with pytest.raises(ValueError):
|
201 |
+
decimal_coords((30, 1, 2), 'x')
|
202 |
+
|
203 |
+
with pytest.raises(ValueError):
|
204 |
+
decimal_coords((30, 1, 2), 'NW')
|
205 |
+
|
206 |
+
|
207 |
+
|
208 |
+
|
tests/test_whale_viewer.py
ADDED
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pytest
|
2 |
+
from pathlib import Path
|
3 |
+
|
4 |
+
from whale_viewer import format_whale_name
|
5 |
+
|
6 |
+
# testing format_whale_name
|
7 |
+
# - testing with valid whale names
|
8 |
+
# - testing with invalid whale names
|
9 |
+
# - empty string
|
10 |
+
# - with the wrong datatype
|
11 |
+
|
12 |
+
def test_format_whale_name_ok():
|
13 |
+
# some with 1 word, most with 2 words, others with 3 or 4.
|
14 |
+
assert format_whale_name("right_whale") == "Right Whale"
|
15 |
+
assert format_whale_name("blue_whale") == "Blue Whale"
|
16 |
+
assert format_whale_name("humpback_whale") == "Humpback Whale"
|
17 |
+
assert format_whale_name("sperm_whale") == "Sperm Whale"
|
18 |
+
assert format_whale_name("fin_whale") == "Fin Whale"
|
19 |
+
assert format_whale_name("sei_whale") == "Sei Whale"
|
20 |
+
assert format_whale_name("minke_whale") == "Minke Whale"
|
21 |
+
assert format_whale_name("gray_whale") == "Gray Whale"
|
22 |
+
assert format_whale_name("bowhead_whale") == "Bowhead Whale"
|
23 |
+
assert format_whale_name("beluga") == "Beluga"
|
24 |
+
|
25 |
+
assert format_whale_name("long_finned_pilot_whale") == "Long Finned Pilot Whale"
|
26 |
+
assert format_whale_name("melon_headed_whale") == "Melon Headed Whale"
|
27 |
+
assert format_whale_name("pantropic_spotted_dolphin") == "Pantropic Spotted Dolphin"
|
28 |
+
assert format_whale_name("spotted_dolphin") == "Spotted Dolphin"
|
29 |
+
assert format_whale_name("killer_whale") == "Killer Whale"
|
30 |
+
|
31 |
+
|
32 |
+
def test_format_whale_name_invalid():
|
33 |
+
# not so clear what this would be, except perhaps a string that has gone through the fucn alrealdy?
|
34 |
+
assert format_whale_name("Right Whale") == "Right Whale"
|
35 |
+
assert format_whale_name("Blue Whale") == "Blue Whale"
|
36 |
+
assert format_whale_name("Long Finned Pilot Whale") == "Long Finned Pilot Whale"
|
37 |
+
|
38 |
+
# testing with empty string
|
39 |
+
def test_format_whale_name_empty():
|
40 |
+
assert format_whale_name("") == ""
|
41 |
+
|
42 |
+
# testing with the wrong datatype
|
43 |
+
# we should get a TypeError - currently it fails with a AttributeError
|
44 |
+
@pytest.mark.xfail
|
45 |
+
def test_format_whale_name_none():
|
46 |
+
with pytest.raises(TypeError):
|
47 |
+
format_whale_name(None)
|
48 |
+
|
49 |
+
|
50 |
+
# display_whale requires UI to test it.
|