Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
Changelog
=========

0.4.5
-----

* Analyzers that can't be loaded properly are now skipped when restoring
application state (e.g. if the video or design file has been moved since the
last run)

* Added a 404 page with instructions in case application is run without UI

* Other minor fixes

0.4.4
-----

Expand Down
9 changes: 3 additions & 6 deletions docs/source/python-library.rst
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ PerspectiveTransform

.. automodule:: shapeflow.plugins.PerspectiveTransform
:members:
:private-members: _Config, _Transform
:private-members: _Config
:show-inheritance:

.. _filters:
Expand All @@ -171,15 +171,15 @@ HsvRangeFilter

.. automodule:: shapeflow.plugins.HsvRangeFilter
:members:
:private-members: _Config, _Filter
:private-members: _Config
:show-inheritance:

BackgroundFilter
^^^^^^^^^^^^^^^^

.. automodule:: shapeflow.plugins.BackgroundFilter
:members:
:private-members: _Config, _Filter
:private-members: _Config
:show-inheritance:

.. _features:
Expand All @@ -192,23 +192,20 @@ PixelSum

.. automodule:: shapeflow.plugins.PixelSum
:members:
:private-members: _Feature
:show-inheritance:

Area_mm2
^^^^^^^^

.. automodule:: shapeflow.plugins.Area_mm2
:members:
:private-members: _Feature
:show-inheritance:

Volume_uL
^^^^^^^^^

.. automodule:: shapeflow.plugins.Volume_uL
:members:
:private-members: _Config, _Feature
:show-inheritance:


Expand Down
3 changes: 3 additions & 0 deletions docs/source/troubleshooting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ Application won’t run

in the the ``shapeflow`` root directory.

Application runs, but the interface won't load (properly)
---------------------------------------------------------

* ``sf.py`` runs fine, but the page says **404 not found**

* Check if you have a folder ``ui/dist/`` in your ``shapeflow`` directory and
Expand Down
9 changes: 6 additions & 3 deletions shapeflow/core/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,13 @@ def _drop(self, key: str):
del self._cache[key]

def _is_cached(self, method, *args):
key = self._get_key(method, *args)
if self._cache is None:
raise CacheAccessError
return self._get_key(method, *args) in self._cache
if settings.cache.do_cache:
raise CacheAccessError
else:
return False
return key in self._cache

def cached_call(self, method, *args, **kwargs): # todo: kwargs necessary?
"""Call a method or get the result from the cache if available.
Expand Down Expand Up @@ -879,7 +883,6 @@ def launch(self) -> bool:

return self.launched
else:
log.warning(f"{self.__class__.__qualname__} can not be launched.") # todo: try to be more verbose
return False

def get_name(self) -> str:
Expand Down
13 changes: 10 additions & 3 deletions shapeflow/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -850,10 +850,17 @@ def load_state(self) -> None:
analyzer._set_id(id)
analyzer.set_eventstreamer(self._server.eventstreamer)

analyzer.launch()
ok = analyzer.launch()

if ok:
self._add(analyzer)
self._history.add_analysis(analyzer, model)
else:
log.error(
f"Failed to restore analyzer '{id}' from previous application state. "
f"This probably happened because the video or design file(s) have been moved or deleted"
)

self._add(analyzer)
self._history.add_analysis(analyzer, model)
except FileNotFoundError:
pass
except EOFError:
Expand Down
2 changes: 1 addition & 1 deletion shapeflow/plugins/Area_mm2.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@


@extend(FeatureType, True)
class _Feature(MaskFunction):
class Area_mm2(MaskFunction):
"""Convert :mod:`~shapeflow.plugins.PixelSum` to an area in mm²,
taking into account the DPI of the design file.
"""
Expand Down
4 changes: 2 additions & 2 deletions shapeflow/plugins/BackgroundFilter.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

@extend(ConfigType, True)
class _Config(FilterConfig):
"""Configuration for :class:`shapeflow.plugins.BackgroundFilter._Filter`
"""Configuration for :class:`shapeflow.plugins.BackgroundFilter.BackgroundFilter`
"""
color: HsvColor = Field(default=HsvColor())
"""See :attr:`shapeflow.plugins.HsvRangeFilter._Config.color`
Expand Down Expand Up @@ -54,7 +54,7 @@ def c1(self) -> HsvColor:


@extend(FilterType, True)
class _Filter(FilterInterface):
class BackgroundFilter(FilterInterface):
"""Filters out colors outside of a :class:`~shapeflow.maths.colors.HsvColor`
radius around a center color and inverts the resulting image.
"""
Expand Down
4 changes: 2 additions & 2 deletions shapeflow/plugins/HsvRangeFilter.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

@extend(ConfigType, True)
class _Config(FilterConfig):
"""Configuration for :class:`shapeflow.plugins.HsvRangeFilter._Filter`
"""Configuration for :class:`shapeflow.plugins.HsvRangeFilter.HsvRangeFilter`
"""
color: HsvColor = Field(default_factory=HsvColor)
"""The center color.
Expand Down Expand Up @@ -80,7 +80,7 @@ def c1(self) -> HsvColor:


@extend(FilterType, True)
class _Filter(FilterInterface):
class HsvRangeFilter(FilterInterface):
"""Filters out colors outside of a :class:`~shapeflow.maths.colors.HsvColor`
radius around a center color.
"""
Expand Down
2 changes: 1 addition & 1 deletion shapeflow/plugins/PerspectiveTransform.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class _Config(TransformConfig): # todo: not really necessary?


@extend(TransformType, True)
class _Transform(TransformInterface):
class PerspectiveTransform(TransformInterface):
"""Wraps ``OpenCV``’s `getPerspectiveTransform <https://docs.opencv.org/2.4.13.7/modules/imgproc/doc/geometric_transformations.html?#getperspectivetransform>`_
function to estimate the transformation matrix and `warpPerspective <https://docs.opencv.org/2.4.13.7/modules/imgproc/doc/geometric_transformations.html?#warpperspective>`_
to apply it to a video frame or a coordinate.
Expand Down
2 changes: 1 addition & 1 deletion shapeflow/plugins/PixelSum.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@


@extend(FeatureType, True)
class _Feature(MaskFunction):
class PixelSum(MaskFunction):
"""The most basic feature: it just returns the number of
``True`` pixels the filtered frame.
"""
Expand Down
4 changes: 2 additions & 2 deletions shapeflow/plugins/Volume_uL.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@

@extend(ConfigType, True)
class _Config(FeatureConfig):
"""Configuration for :class:`shapeflow.plugins.Volume_uL._Feature`
"""Configuration for :class:`shapeflow.plugins.Volume_uL.Volume_uL`
"""
h: float = Field(default=0.153, description='height (mm)')
"""The channel height of the chip.
"""


@extend(FeatureType, True)
class _Feature(MaskFunction):
class Volume_uL(MaskFunction):
"""Multiply :mod:`~shapeflow.plugins.Area_mm2` by a channel height in mm
to estimate the volume in µL.
"""
Expand Down
38 changes: 31 additions & 7 deletions shapeflow/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from threading import Thread, Event, Lock
from typing import Optional

from flask import Flask, send_from_directory, jsonify, request, Response, make_response, abort
import flask
from flask import Flask, jsonify, request, Response, make_response, abort
import waitress
import webbrowser

Expand All @@ -19,9 +20,10 @@

log = shapeflow.get_logger(__name__)
UI = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
, 'ui', 'dist'
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'ui'
)
DIST = os.path.join(UI, 'dist')


class ServerThread(Thread, metaclass=util.Singleton):
Expand Down Expand Up @@ -88,10 +90,31 @@ def __init__(self):
app.config.from_object(__name__)
app.config['JSON_SORT_KEYS'] = False

@app.route('/', defaults={'file': 'index.html'}, methods=['GET'])
@app.route('/', methods=['GET'])
def _index():
try:
r = self.get_file('index.html')

# Make sure the index page isn't cached so we can pick up missing ui/dist/ right away
r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
r.headers["Pragma"] = "no-cache"
r.headers["Expires"] = "0"
r.headers['Cache-Control'] = 'public, max-age=0'

return r
except FileNotFoundError:
if not os.path.isdir(DIST) or not os.listdir(DIST):
log.error(f"no compiled UI in '{DIST}'")
log.error(f"follow the instructions on the 404 page to restore the application")

return flask.send_from_directory(UI, '404.html'), 404

@app.route('/<path:file>', methods=['GET'])
def _get_file(file: str):
return self.get_file(file)
try:
return self.get_file(file)
except FileNotFoundError:
abort(404)

@app.route('/api/<path:address>', methods=['GET', 'POST', 'PUT'])
def _call_api(address: str):
Expand Down Expand Up @@ -148,11 +171,12 @@ def get_file(self, file: str):
"""
self.active()

path = os.path.join(UI, *file.split("/"))
path = os.path.join(DIST, *file.split("/"))
if not os.path.isfile(path):
log.error(f"no such file: '{file}'")
raise FileNotFoundError
log.debug(f"serving '{file}'")
return send_from_directory(
return flask.send_from_directory(
os.path.dirname(path),
os.path.basename(path)
)
Expand Down
8 changes: 4 additions & 4 deletions shapeflow/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -984,7 +984,7 @@ def can_launch(self) -> bool:
log.warning(f"invalid video file: {self.config.video_path}")
if self.config.design_path is not None:
design_ok = os.path.isfile(self.config.design_path)
if not video_ok:
if not design_ok:
log.warning(f"invalid design file: {self.config.design_path}")

return video_ok and design_ok
Expand Down Expand Up @@ -1710,10 +1710,10 @@ def load_config(self) -> None:
self._set_config(config)
self.commit()

log.info(f'config ~ database: {config}')
log.info(f'loaded as {self.config}')
log.debug(f'config ~ database: {config}')
log.debug(f'loaded as {self.config}')
else:
log.warning('could not load config')
log.warning('could not load config from database')


@property # todo: this was deprecated, right?
Expand Down
4 changes: 2 additions & 2 deletions test/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import yaml
from pydantic import Field, validator

from shapeflow.plugins.HsvRangeFilter import _Filter
from shapeflow.plugins.HsvRangeFilter import HsvRangeFilter
from shapeflow.video import *
from shapeflow.core.config import Factory, BaseConfig, VERSION, CLASS
from shapeflow.core import EnforcedStr
Expand Down Expand Up @@ -32,7 +32,7 @@ def test_comparisons(self):
self.assertNotEqual(ColorSpace('hsv'), ColorSpace('bgr'))

def test_factory(self):
self.assertEqual(_Filter, FilterType('hsv range').get())
self.assertEqual(HsvRangeFilter, FilterType('hsv range').get())

def test_subclassing(self):
class TestEnfStr(EnforcedStr):
Expand Down
57 changes: 56 additions & 1 deletion test/test_server.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import os
import shutil
import unittest
from unittest.mock import patch
from contextlib import contextmanager

import time
import json
import subprocess

import flask
from flask.testing import FlaskClient

from shapeflow import settings, ROOTDIR, save_settings
from shapeflow.server import ShapeflowServer, UI, DIST

CACHE = os.path.join(ROOTDIR, 'test_main-cache')
DB = os.path.join(ROOTDIR, 'test_main-history.db')
Expand Down Expand Up @@ -169,6 +174,56 @@ def test_set_settings_restart(self):
post(api('set_settings'), data=json.dumps({'settings': first_settings}))
time.sleep(10)


@patch('os.path.isfile')
@patch('flask.send_from_directory')
@patch('os.path.isdir', lambda _: True)
@patch('os.listdir', lambda _: ['index.html'])
class FlaskTest(unittest.TestCase):
client: FlaskClient

def setUp(self) -> None:
sfs = ShapeflowServer()
sfs._app.config['TESTING'] = True

self.client = sfs._app.test_client()

def test_serve_index_200(self, send_from_directory, isfile):
isfile.return_value = True
send_from_directory.return_value = flask.Response()

r = self.client.get('/')

send_from_directory.assert_called_with(DIST, 'index.html')
self.assertEqual(r.status_code, 200)

def test_serve_index_404(self, send_from_directory, isfile):
isfile.return_value = False
send_from_directory.return_value = flask.Response()

r = self.client.get('/')

send_from_directory.assert_called_with(UI, '404.html')
self.assertEqual(r.status_code, 404)

def test_serve_file_200(self, send_from_directory, isfile):
isfile.return_value = True
send_from_directory.return_value = flask.Response()

r = self.client.get('/something.txt')

send_from_directory.assert_called_with(DIST, 'something.txt')
self.assertEqual(r.status_code, 200)

def test_serve_file_404(self, send_from_directory, isfile):
isfile.return_value = False
send_from_directory.return_value = flask.Response()

r = self.client.get('/something.txt')

send_from_directory.assert_not_called()
self.assertEqual(r.status_code, 404)


if __name__ == '__main__':
unittest.main()

Loading