diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..38d67d5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +bin/ +venv/ +.git/ +.buildozer/ +.pytest_cache/ +.tox/ diff --git a/.gitignore b/.gitignore index 72364f9..5aa99e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Custom +*.swp + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..763bf0c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,25 @@ +sudo: required + +language: generic + +services: + - docker + +env: + global: + - DISPLAY=:99.0 + matrix: + - TAG=zbarcam-linux DOCKERFILE=dockerfiles/Dockerfile-linux COMMAND='tox' + +before_install: + - sudo apt update -qq > /dev/null + - sudo apt install --yes --no-install-recommends xvfb + +install: + - docker build --tag=$TAG --file=$DOCKERFILE --build-arg CI . + +before_script: + - sh -e /etc/init.d/xvfb start + +script: + - travis_wait docker run -e DISPLAY -e CI -v /tmp/.X11-unix:/tmp/.X11-unix $TAG $COMMAND diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fec422..8abc0fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Change Log -## [20190910] +## [2019.0911] + + - Move to new garden layout + - Setup Docker testing + - Split kvlang file from Python code + - Setup Continuous Integration testing, refs #1 + - Setup linting, refs #2 + - Unit tests `platform_api` module, refs #3 + - Publish to PyPI, refs #4 + - Introduce `Makefile`, refs #8 + +## [2019.0910] - Initial release before revamp diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d1bf10a --- /dev/null +++ b/Makefile @@ -0,0 +1,85 @@ +VENV_NAME=venv +PIP=$(VENV_NAME)/bin/pip +TOX=`which tox` +GARDEN=$(VENV_NAME)/bin/garden +PYTHON=$(VENV_NAME)/bin/python +ISORT=$(VENV_NAME)/bin/isort +FLAKE8=$(VENV_NAME)/bin/flake8 +TWINE=`which twine` +SOURCES=src/ tests/ setup.py setup_meta.py +# using full path so it can be used outside the root dir +SPHINXBUILD=$(shell realpath venv/bin/sphinx-build) +DOCS_DIR=doc +SYSTEM_DEPENDENCIES= \ + libpython$(PYTHON_VERSION)-dev \ + libsdl2-dev \ + libzbar-dev \ + tox \ + virtualenv +OS=$(shell lsb_release -si) +PYTHON_MAJOR_VERSION=3 +PYTHON_MINOR_VERSION=6 +PYTHON_VERSION=$(PYTHON_MAJOR_VERSION).$(PYTHON_MINOR_VERSION) +PYTHON_WITH_VERSION=python$(PYTHON_VERSION) + + +all: system_dependencies virtualenv + +venv: + test -d venv || virtualenv -p $(PYTHON_WITH_VERSION) venv + +virtualenv: venv + $(PIP) install Cython==0.28.6 + $(PIP) install -r requirements.txt + +virtualenv/test: virtualenv + $(PIP) install -r requirements/requirements-test.txt + +system_dependencies: +ifeq ($(OS), Ubuntu) + sudo apt install --yes --no-install-recommends $(SYSTEM_DEPENDENCIES) +endif + +run/linux: virtualenv + $(PYTHON) src/main.py --debug + +run: run/linux + +test: + $(TOX) + +lint/isort-check: virtualenv + $(ISORT) --check-only --recursive --diff $(SOURCES) + +lint/isort-fix: virtualenv + $(ISORT) --recursive $(SOURCES) + +lint/flake8: virtualenv + $(FLAKE8) $(SOURCES) + +lint: lint/isort-check lint/flake8 + +docs/clean: + rm -rf $(DOCS_DIR)/build/ + +docs: + cd $(DOCS_DIR) && SPHINXBUILD=$(SPHINXBUILD) make html + +release/clean: + rm -rf dist/ build/ + +release/build: release/clean + $(PYTHON) setup.py sdist bdist_wheel + $(PYTHON) setup_meta.py sdist bdist_wheel + $(TWINE) check dist/* + +release/upload: + $(TWINE) upload dist/* + +clean: release/clean docs/clean + py3clean src/ + find . -type d -name "__pycache__" -exec rm -r {} + + find . -type d -name "*.egg-info" -exec rm -r {} + + +clean/all: clean + rm -rf $(VENV_NAME) .tox/ diff --git a/README.md b/README.md index 17aa28a..fdf81e2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # XCamera: Android-optimized camera widget +[![Build Status](https://travis-ci.com/kivy-garden/xcamera.svg?branch=develop)](https://travis-ci.com/kivy-garden/xcamera) +[![PyPI version](https://badge.fury.io/py/xcamera.svg)](https://badge.fury.io/py/xcamera) + XCamera is a widget which extends the standard Kivy Camera widget with more functionality. In particular: @@ -15,6 +18,7 @@ functionality. In particular: was before. Screenshot: + ![screenshot](/screenshot.png?raw=True "Screenshot") Notes: @@ -32,6 +36,23 @@ Notes: requests are welcome :) ## Install +```sh +pip install xcamera +``` + +## Demo +A full working demo is available in [src/main.py](https://github.com/kivy-garden/xcamera/blob/master/src/main.py). +You can run it via: +```sh +make run +``` + +## Contribute +To play with the project, install system dependencies and Python requirements using the [Makefile](Makefile). +```sh +make ``` -garden install xcamera +Then verify everything is OK by running tests. +```sh +make test ``` diff --git a/deploy.sh b/deploy.sh deleted file mode 100755 index 434fee3..0000000 --- a/deploy.sh +++ /dev/null @@ -1,13 +0,0 @@ -# deploy to kivy launcher on android -ROOT=/tmp/xcamera-example -XCAMERA=$ROOT/libs/garden/garden.xcamera - -echo rm -rf $ROOT -mkdir -p $XCAMERA - -cp example/main.py example/android.txt $ROOT -cp *.py $XCAMERA -cp -r data $XCAMERA - -adb push $ROOT /sdcard/kivy/xcamera -adb logcat -s python diff --git a/dockerfiles/Dockerfile-linux b/dockerfiles/Dockerfile-linux new file mode 100644 index 0000000..b8cef3e --- /dev/null +++ b/dockerfiles/Dockerfile-linux @@ -0,0 +1,62 @@ +# Docker image for installing dependencies on Linux and running tests. +# Build with: +# docker build --tag=xcamera-linux --file=dockerfiles/Dockerfile-linux . +# Run with: +# docker run xcamera-linux /bin/sh -c 'make test' +# Or using the entry point shortcut: +# docker run xcamera-linux 'make test' +# For running UI: +# xhost +"local:docker@" +# docker run -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix xcamera-linux 'make uitest' +# Or for interactive shell: +# docker run -it --rm xcamera-linux +FROM ubuntu:18.04 + +ENV USER="user" +ENV HOME_DIR="/home/${USER}" +ENV WORK_DIR="${HOME_DIR}" + +# configure locale +RUN apt update -qq > /dev/null && apt install --yes --no-install-recommends \ + locales && \ + locale-gen en_US.UTF-8 +ENV LANG="en_US.UTF-8" \ + LANGUAGE="en_US.UTF-8" \ + LC_ALL="en_US.UTF-8" + +# install system dependencies +RUN apt install --yes --no-install-recommends \ + build-essential \ + ccache \ + cmake \ + curl \ + libsdl2-dev \ + libsdl2-image-dev \ + libsdl2-mixer-dev \ + libsdl2-ttf-dev \ + libpython3.6-dev \ + libpython3.7-dev \ + libzbar-dev \ + lsb-release \ + make \ + pkg-config \ + python3.6 \ + python3.6-dev \ + python3.7 \ + python3.7-dev \ + sudo \ + tox \ + virtualenv + +# prepare non root env +RUN useradd --create-home --shell /bin/bash ${USER} +# with sudo access and no password +RUN usermod -append --groups sudo ${USER} +RUN echo "%sudo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers + +USER ${USER} +WORKDIR ${WORK_DIR} +COPY . ${WORK_DIR} + +# RUN make +ENTRYPOINT ["./dockerfiles/start.sh"] diff --git a/dockerfiles/start.sh b/dockerfiles/start.sh new file mode 100755 index 0000000..bdc4a52 --- /dev/null +++ b/dockerfiles/start.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -e + +# if a some command has been passed to container, executes it and exit, +# otherwise runs bash +if [[ $@ ]]; then + eval $@ +else + /bin/bash +fi diff --git a/example/android.txt b/example/android.txt deleted file mode 100644 index d1f2e1a..0000000 --- a/example/android.txt +++ /dev/null @@ -1,3 +0,0 @@ -title=XCamera example -author=Antonio Cuni -orientation=portrait,landscape diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e893041 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Kivy==1.11.1 +opencv-python==4.1.1.26 diff --git a/requirements/requirements-test.txt b/requirements/requirements-test.txt new file mode 100644 index 0000000..7424ff6 --- /dev/null +++ b/requirements/requirements-test.txt @@ -0,0 +1,3 @@ +flake8 +isort +pytest diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..64d7e41 --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +import os + +from setuptools import find_namespace_packages, setup + +from src.kivy_garden.xcamera import version + + +def read(fname): + with open(os.path.join(os.path.dirname(__file__), fname)) as f: + return f.read() + + +# exposing the params so it can be imported +setup_params = { + 'name': 'kivy_garden.xcamera', + 'version': version.__version__, + 'description': 'Real time Barcode and QR Code scanner Edit', + 'long_description': read('README.md'), + 'long_description_content_type': 'text/markdown', + 'author': 'Antonio Cuni', + 'url': 'https://github.com/kivy-garden/xcamera', + 'packages': find_namespace_packages(where='src'), + 'package_data': { + 'kivy_garden.xcamera': ['*.kv'], + 'kivy_garden.xcamera.data': ['*.ttf', '*.wav'], + }, + 'package_dir': {'': 'src'}, + 'install_requires': [ + 'kivy', + ], +} + + +def run_setup(): + setup(**setup_params) + + +# makes sure the setup doesn't run at import time +if __name__ == '__main__': + run_setup() diff --git a/setup_meta.py b/setup_meta.py new file mode 100644 index 0000000..9d6029c --- /dev/null +++ b/setup_meta.py @@ -0,0 +1,14 @@ +""" +Creates a distribution alias that just installs kivy_garden.xcamera. +""" +from setuptools import setup + +from setup import setup_params + +setup_params.update({ + 'install_requires': ['kivy_garden.xcamera'], + 'name': 'xcamera', +}) + + +setup(**setup_params) diff --git a/src/kivy_garden/xcamera/__init__.py b/src/kivy_garden/xcamera/__init__.py new file mode 100644 index 0000000..e2c5931 --- /dev/null +++ b/src/kivy_garden/xcamera/__init__.py @@ -0,0 +1,15 @@ +""" +Exposes `XCamera` directly in `xcamera` rather than `xcamera.xcamera`. +Also note this may break `pip` since all imports within `xcamera.py` would be +required at setup time. This is because `version.py` (same directory) is used +by the `setup.py` file. +Hence we're not exposing `XCamera` if `pip` is detected. +""" +import os + +project_dir = os.path.abspath( + os.path.join(__file__, os.pardir, os.pardir, os.pardir, os.pardir)) +using_pip = os.path.basename(project_dir).startswith('pip-') +# only exposes `XCamera` if not within `pip` ongoing install +if not using_pip: + from .xcamera import XCamera # noqa diff --git a/android_api.py b/src/kivy_garden/xcamera/android_api.py similarity index 96% rename from android_api.py rename to src/kivy_garden/xcamera/android_api.py index ee0ad33..851ab39 100644 --- a/android_api.py +++ b/src/kivy_garden/xcamera/android_api.py @@ -1,10 +1,13 @@ -from __future__ import absolute_import -from jnius import autoclass, PythonJavaClass, java_method, JavaException from kivy.logger import Logger +from jnius import JavaException, PythonJavaClass, autoclass, java_method + Camera = autoclass('android.hardware.Camera') AndroidActivityInfo = autoclass('android.content.pm.ActivityInfo') AndroidPythonActivity = autoclass('org.renpy.android.PythonActivity') +PORTRAIT = AndroidActivityInfo.SCREEN_ORIENTATION_PORTRAIT +LANDSCAPE = AndroidActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + class ShutterCallback(PythonJavaClass): __javainterfaces__ = ('android.hardware.Camera$ShutterCallback', ) @@ -72,13 +75,11 @@ def take_picture(camera_widget, filename, on_success): Logger.info('Error when calling autofocus: {}'.format(e)) -PORTRAIT = AndroidActivityInfo.SCREEN_ORIENTATION_PORTRAIT -LANDSCAPE = AndroidActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - def set_orientation(value): previous = get_orientation() AndroidPythonActivity.mActivity.setRequestedOrientation(value) return previous + def get_orientation(): return AndroidPythonActivity.mActivity.getRequestedOrientation() diff --git a/data/xcamera/icons.ttf b/src/kivy_garden/xcamera/data/icons.ttf similarity index 100% rename from data/xcamera/icons.ttf rename to src/kivy_garden/xcamera/data/icons.ttf diff --git a/data/xcamera/shutter.wav b/src/kivy_garden/xcamera/data/shutter.wav similarity index 100% rename from data/xcamera/shutter.wav rename to src/kivy_garden/xcamera/data/shutter.wav diff --git a/platform_api.py b/src/kivy_garden/xcamera/platform_api.py similarity index 85% rename from platform_api.py rename to src/kivy_garden/xcamera/platform_api.py index 1e8df22..ea0236d 100644 --- a/platform_api.py +++ b/src/kivy_garden/xcamera/platform_api.py @@ -1,17 +1,18 @@ -from __future__ import absolute_import from kivy.utils import platform + def play_shutter(): # bah, apparently we need to delay the import of kivy.core.audio, lese # kivy cannot find a camera provider, at lease on linux. Maybe a # gstreamer/pygame issue? from kivy.core.audio import SoundLoader - sound = SoundLoader.load("data/xcamera/shutter.wav") + sound = SoundLoader.load("data/shutter.wav") sound.play() if platform == 'android': - from .android_api import * + from .android_api import ( + LANDSCAPE, PORTRAIT, take_picture, set_orientation, get_orientation) else: diff --git a/src/kivy_garden/xcamera/version.py b/src/kivy_garden/xcamera/version.py new file mode 100644 index 0000000..76f8958 --- /dev/null +++ b/src/kivy_garden/xcamera/version.py @@ -0,0 +1 @@ +__version__ = '2019.0911' diff --git a/src/kivy_garden/xcamera/xcamera.kv b/src/kivy_garden/xcamera/xcamera.kv new file mode 100644 index 0000000..b8af343 --- /dev/null +++ b/src/kivy_garden/xcamera/xcamera.kv @@ -0,0 +1,41 @@ +#:import xcamera kivy_garden.xcamera.xcamera + + + icon_color: (0, 0, 0, 1) + _down_color: xcamera.darker(self.icon_color) + icon_size: dp(50) + + canvas.before: + Color: + rgba: self.icon_color if self.state == 'normal' else self._down_color + Ellipse: + pos: self.pos + size: self.size + + size_hint: None, None + size: self.icon_size, self.icon_size + font_size: self.icon_size/2 + + +: + # \ue800 corresponds to the camera icon in the font + icon: u"[font=data/icons.ttf]\ue800[/font]" + icon_color: (0.13, 0.58, 0.95, 0.8) + icon_size: dp(70) + + id: camera + resolution: 640, 480 # 1920, 1080 + allow_stretch: True + + # Shoot button + XCameraIconButton: + id: shoot_button + markup: True + text: root.icon + icon_color: root.icon_color + icon_size: root.icon_size + on_release: root.shoot() + + # position + right: root.width - dp(10) + center_y: root.center_y diff --git a/__init__.py b/src/kivy_garden/xcamera/xcamera.py similarity index 54% rename from __init__.py rename to src/kivy_garden/xcamera/xcamera.py index a9f0d4d..c4362a1 100644 --- a/__init__.py +++ b/src/kivy_garden/xcamera/xcamera.py @@ -1,18 +1,19 @@ -from __future__ import absolute_import - -import os import datetime +import os + from kivy.lang import Builder -from kivy.uix.camera import Camera -from kivy.uix.label import Label -from kivy.uix.behaviors import ButtonBehavior from kivy.properties import ObjectProperty from kivy.resources import resource_add_path -from .platform_api import take_picture, set_orientation, LANDSCAPE +from kivy.uix.behaviors import ButtonBehavior +from kivy.uix.camera import Camera +from kivy.uix.label import Label + +from .platform_api import LANDSCAPE, set_orientation, take_picture ROOT = os.path.dirname(os.path.abspath(__file__)) resource_add_path(ROOT) + def darker(color, factor=0.5): r, g, b, a = color r *= factor @@ -20,50 +21,6 @@ def darker(color, factor=0.5): b *= factor return r, g, b, a -kv = """ -#:import xcamera kivy.garden.xcamera - - - icon_color: (0, 0, 0, 1) - _down_color: xcamera.darker(self.icon_color) - icon_size: dp(50) - - canvas.before: - Color: - rgba: self.icon_color if self.state == 'normal' else self._down_color - Ellipse: - pos: self.pos - size: self.size - - size_hint: None, None - size: self.icon_size, self.icon_size - font_size: self.icon_size/2 - - -: - # \ue800 corresponds to the camera icon in the font - icon: u"[font=data/xcamera/icons.ttf]\ue800[/font]" - icon_color: (0.13, 0.58, 0.95, 0.8) - icon_size: dp(70) - - id: camera - resolution: 640, 480 # 1920, 1080 - allow_stretch: True - - # Shoot button - XCameraIconButton: - id: shoot_button - markup: True - text: root.icon - icon_color: root.icon_color - icon_size: root.icon_size - on_release: root.shoot() - - # position - right: root.width - dp(10) - center_y: root.center_y -""" -Builder.load_string(kv) class XCameraIconButton(ButtonBehavior, Label): pass @@ -74,6 +31,10 @@ class XCamera(Camera): _previous_orientation = None __events__ = ('on_picture_taken',) + def __init__(self, **kwargs): + Builder.load_file(os.path.join(ROOT, "xcamera.kv")) + super().__init__(**kwargs) + def on_picture_taken(self, filename): """ This event is fired every time a picture has been taken @@ -85,7 +46,6 @@ def get_filename(self): def shoot(self): def on_success(filename): self.dispatch('on_picture_taken', filename) - # filename = self.get_filename() if self.directory: filename = os.path.join(self.directory, filename) @@ -97,4 +57,3 @@ def force_landscape(self): def restore_orientation(self): if self._previous_orientation is not None: set_orientation(self._previous_orientation) - diff --git a/example/main.py b/src/main.py old mode 100644 new mode 100755 similarity index 92% rename from example/main.py rename to src/main.py index b12bb28..e192666 --- a/example/main.py +++ b/src/main.py @@ -1,9 +1,8 @@ -import datetime -from kivy.lang import Builder from kivy.app import App +from kivy.lang import Builder kv = """ -#:import XCamera kivy.garden.xcamera.XCamera +#:import XCamera kivy_garden.xcamera.XCamera FloatLayout: orientation: 'vertical' @@ -34,5 +33,6 @@ def build(self): def picture_taken(self, obj, filename): print('Picture taken and saved to {}'.format(filename)) + if __name__ == '__main__': CameraApp().run() diff --git a/tests/kivy_garden/xcamera/test_platform_api.py b/tests/kivy_garden/xcamera/test_platform_api.py new file mode 100644 index 0000000..5c6bffe --- /dev/null +++ b/tests/kivy_garden/xcamera/test_platform_api.py @@ -0,0 +1,35 @@ +from unittest import mock + +from kivy_garden.xcamera import platform_api + + +class TestPlatformAPI: + """ + Tests `platform_api` module. + """ + + def test_play_shutter(self): + with mock.patch('kivy.core.audio.audio_sdl2.SoundSDL2.play') as m_play: + platform_api.play_shutter() + assert m_play.mock_calls == [mock.call()] + + def test_take_picture(self): + m_camera_widget = mock.Mock() + filename = 'filename.png' + m_on_success = mock.Mock() + with mock.patch.object(platform_api, 'play_shutter') as m_play_shutter: + platform_api.take_picture(m_camera_widget, filename, m_on_success) + assert m_play_shutter.mock_calls == [mock.call()] + assert m_camera_widget.mock_calls == [ + mock.call.texture.save(filename, flipped=False)] + assert m_on_success.mock_calls == [mock.call(filename)] + + def test_set_orientation(self): + value = platform_api.LANDSCAPE + assert platform_api.set_orientation(value) == platform_api.PORTRAIT + assert platform_api.set_orientation(value) == platform_api.LANDSCAPE + + def test_get_orientation(self): + value = platform_api.LANDSCAPE + platform_api.set_orientation(value) + assert platform_api.get_orientation() == value diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..7622c6f --- /dev/null +++ b/tox.ini @@ -0,0 +1,24 @@ +[tox] +envlist = pep8,isort-check,py36 +# no setup.py to be ran +skipsdist = True + +[testenv] +setenv = + PYTHONPATH = {toxinidir}/src/ +deps = + -r{toxinidir}/requirements.txt + -r{toxinidir}/requirements/requirements-test.txt +commands = pytest tests/ + +[testenv:pep8] +commands = flake8 src/ tests/ setup.py setup_meta.py + +[testenv:isort-check] +commands = + isort --check-only --recursive --diff src/ tests/ setup.py setup_meta.py + + +[flake8] +ignore = + E501, # Line too long (82 > 79 characters)