diff --git a/.travis.yml b/.travis.yml index ea242c89c3..16af18f65e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,14 +12,6 @@ services: - docker before_install: - - travis_retry sudo apt update -qq - # to successfully send the coveralls reports we need pyOpenSSL - - travis_retry sudo apt install -qq --no-install-recommends - python2.7 python3 python3-venv python3-virtualenv python3-pip - python3-setuptools python3-openssl - # (venv/virtualenv are both used by tests/test_pythonpackage.py) - - sudo pip install tox>=2.0 - - sudo pip3 install coveralls # https://github.com/travis-ci/travis-ci/issues/6069#issuecomment-266546552 - git remote set-branches --add origin master - git fetch @@ -33,6 +25,21 @@ jobs: include: - stage: lint name: "Tox tests and coverage" + language: python + python: 3.7 + before_script: + # We need to escape virtualenv for `test_pythonpackage_basic.test_virtualenv` + # See also: https://github.com/travis-ci/travis-ci/issues/8589 + - type -t deactivate && deactivate || true + - export PATH=/opt/python/3.7/bin:$PATH + # Install tox & virtualenv + # Note: venv/virtualenv are both used by tests/test_pythonpackage.py + - pip3.7 install -U virtualenv + - pip3.7 install tox>=2.0 + # Install coveralls & dependencies + # Note: pyOpenSSL needed to send the coveralls reports + - pip3.7 install pyOpenSSL + - pip3.7 install coveralls script: # we want to fail fast on tox errors without having to `docker build` first - tox -- tests/ --ignore tests/test_pythonpackage.py diff --git a/pythonforandroid/util.py b/pythonforandroid/util.py index 9c007c2142..ba392049b6 100644 --- a/pythonforandroid/util.py +++ b/pythonforandroid/util.py @@ -1,8 +1,6 @@ import contextlib from os.path import exists, join from os import getcwd, chdir, makedirs, walk, uname -import io -import json import sh import shutil import sys @@ -62,79 +60,6 @@ def ensure_dir(filename): makedirs(filename) -class JsonStore(object): - """Replacement of shelve using json, needed for support python 2 and 3. - """ - - def __init__(self, filename): - super(JsonStore, self).__init__() - self.filename = filename - self.data = {} - if exists(filename): - try: - with io.open(filename, encoding='utf-8') as fd: - self.data = json.load(fd) - except ValueError: - print("Unable to read the state.db, content will be replaced.") - - def __getitem__(self, key): - return self.data[key] - - def __setitem__(self, key, value): - self.data[key] = value - self.sync() - - def __delitem__(self, key): - del self.data[key] - self.sync() - - def __contains__(self, item): - return item in self.data - - def get(self, item, default=None): - return self.data.get(item, default) - - def keys(self): - return self.data.keys() - - def remove_all(self, prefix): - for key in self.data.keys()[:]: - if not key.startswith(prefix): - continue - del self.data[key] - self.sync() - - def sync(self): - # http://stackoverflow.com/questions/12309269/write-json-data-to-file-in-python/14870531#14870531 - if IS_PY3: - with open(self.filename, 'w') as fd: - json.dump(self.data, fd, ensure_ascii=False) - else: - with io.open(self.filename, 'w', encoding='utf-8') as fd: - fd.write(unicode(json.dumps(self.data, ensure_ascii=False))) # noqa F821 - - -def which(program, path_env): - '''Locate an executable in the system.''' - import os - - def is_exe(fpath): - return os.path.isfile(fpath) and os.access(fpath, os.X_OK) - - fpath, fname = os.path.split(program) - if fpath: - if is_exe(program): - return program - else: - for path in path_env.split(os.pathsep): - path = path.strip('"') - exe_file = os.path.join(path, program) - if is_exe(exe_file): - return exe_file - - return None - - def get_virtualenv_executable(): virtualenv = None if virtualenv is None: diff --git a/tests/test_archs.py b/tests/test_archs.py index ffdc8e1cfe..39bf261654 100644 --- a/tests/test_archs.py +++ b/tests/test_archs.py @@ -46,6 +46,12 @@ class ArchSetUpBaseClass(object): + """ + An class object which is intended to be used as a base class to configure + an inherited class of `unittest.TestCase`. This class will override the + `setUp` method. + """ + ctx = None def setUp(self): @@ -63,6 +69,12 @@ def setUp(self): class TestArch(ArchSetUpBaseClass, unittest.TestCase): + """ + An inherited class of `ArchSetUpBaseClass` and `unittest.TestCase` which + will be used to perform tests for the base class + :class:`~pythonforandroid.archs.Arch`. + """ + def test_arch(self): arch = Arch(self.ctx) with self.assertRaises(AttributeError) as e1: @@ -81,14 +93,28 @@ def test_arch(self): class TestArchARM(ArchSetUpBaseClass, unittest.TestCase): - # Here we mock two functions: - # - `ensure_dir` because we don't want to create any directory - # - `find_executable` because otherwise we will - # get an error when trying to find the compiler (we are setting some fake - # paths for our android sdk and ndk so probably will not exist) + """ + An inherited class of `ArchSetUpBaseClass` and `unittest.TestCase` which + will be used to perform tests for :class:`~pythonforandroid.archs.ArchARM`. + """ + @mock.patch("pythonforandroid.archs.find_executable") @mock.patch("pythonforandroid.build.ensure_dir") def test_arch_arm(self, mock_ensure_dir, mock_find_executable): + """ + Test that class :class:`~pythonforandroid.archs.ArchARM` returns some + expected attributes and environment variables. + + .. note:: + Here we mock two methods: + + - `ensure_dir` because we don't want to create any directory + - `find_executable` because otherwise we will + get an error when trying to find the compiler (we are setting + some fake paths for our android sdk and ndk so probably will + not exist) + + """ mock_find_executable.return_value = "arm-linux-androideabi-gcc" mock_ensure_dir.return_value = True @@ -147,16 +173,30 @@ def test_arch_arm(self, mock_ensure_dir, mock_find_executable): class TestArchARMv7a(ArchSetUpBaseClass, unittest.TestCase): - # Here we mock the same functions than the previous tests plus `glob`, - # so we make sure that the glob result is the expected even if the folder - # doesn't exist, which is probably the case. This has to be done because - # here we tests the `get_env` with clang + """ + An inherited class of `ArchSetUpBaseClass` and `unittest.TestCase` which + will be used to perform tests for + :class:`~pythonforandroid.archs.ArchARMv7_a`. + """ + @mock.patch("pythonforandroid.archs.glob") @mock.patch("pythonforandroid.archs.find_executable") @mock.patch("pythonforandroid.build.ensure_dir") def test_arch_armv7a( self, mock_ensure_dir, mock_find_executable, mock_glob ): + """ + Test that class :class:`~pythonforandroid.archs.ArchARMv7_a` returns + some expected attributes and environment variables. + + .. note:: + Here we mock the same functions than + :meth:`TestArchARM.test_arch_arm` plus `glob`, so we make sure that + the glob result is the expected even if the folder doesn't exist, + which is probably the case. This has to be done because here we + tests the `get_env` with clang + + """ mock_find_executable.return_value = "arm-linux-androideabi-gcc" mock_ensure_dir.return_value = True mock_glob.return_value = ["llvm"] @@ -197,9 +237,21 @@ def test_arch_armv7a( class TestArchX86(ArchSetUpBaseClass, unittest.TestCase): + """ + An inherited class of `ArchSetUpBaseClass` and `unittest.TestCase` which + will be used to perform tests for :class:`~pythonforandroid.archs.Archx86`. + """ + @mock.patch("pythonforandroid.archs.find_executable") @mock.patch("pythonforandroid.build.ensure_dir") def test_arch_x86(self, mock_ensure_dir, mock_find_executable): + """ + Test that class :class:`~pythonforandroid.archs.Archx86` returns + some expected attributes and environment variables. + + .. note:: Here we mock the same functions than + :meth:`TestArchARM.test_arch_arm` + """ mock_find_executable.return_value = "arm-linux-androideabi-gcc" mock_ensure_dir.return_value = True @@ -220,9 +272,22 @@ def test_arch_x86(self, mock_ensure_dir, mock_find_executable): class TestArchX86_64(ArchSetUpBaseClass, unittest.TestCase): + """ + An inherited class of `ArchSetUpBaseClass` and `unittest.TestCase` which + will be used to perform tests for + :class:`~pythonforandroid.archs.Archx86_64`. + """ + @mock.patch("pythonforandroid.archs.find_executable") @mock.patch("pythonforandroid.build.ensure_dir") def test_arch_x86_64(self, mock_ensure_dir, mock_find_executable): + """ + Test that class :class:`~pythonforandroid.archs.Archx86_64` returns + some expected attributes and environment variables. + + .. note:: Here we mock the same functions than + :meth:`TestArchARM.test_arch_arm` + """ mock_find_executable.return_value = "arm-linux-androideabi-gcc" mock_ensure_dir.return_value = True @@ -242,9 +307,22 @@ def test_arch_x86_64(self, mock_ensure_dir, mock_find_executable): class TestArchAArch64(ArchSetUpBaseClass, unittest.TestCase): + """ + An inherited class of `ArchSetUpBaseClass` and `unittest.TestCase` which + will be used to perform tests for + :class:`~pythonforandroid.archs.ArchAarch_64`. + """ + @mock.patch("pythonforandroid.archs.find_executable") @mock.patch("pythonforandroid.build.ensure_dir") def test_arch_aarch_64(self, mock_ensure_dir, mock_find_executable): + """ + Test that class :class:`~pythonforandroid.archs.ArchAarch_64` returns + some expected attributes and environment variables. + + .. note:: Here we mock the same functions than + :meth:`TestArchARM.test_arch_arm` + """ mock_find_executable.return_value = "arm-linux-androideabi-gcc" mock_ensure_dir.return_value = True diff --git a/tests/test_distribution.py b/tests/test_distribution.py index 8a3dee605d..1716060513 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -27,7 +27,14 @@ class TestDistribution(unittest.TestCase): + """ + An inherited class of `unittest.TestCase`to test the module + :mod:`~pythonforandroid.distribution`. + """ + def setUp(self): + """Configure a :class:`~pythonforandroid.build.Context` so we can + perform our unittests""" self.ctx = Context() self.ctx.ndk_api = 21 self.ctx.android_api = 27 @@ -42,8 +49,8 @@ def setUp(self): ] def setUp_distribution_with_bootstrap(self, bs, **kwargs): - # extend the setUp by configuring a distribution, because some test - # needs a distribution to be set to be properly tested + """Extend the setUp by configuring a distribution, because some test + needs a distribution to be set to be properly tested""" self.ctx.bootstrap = bs self.ctx.bootstrap.distribution = Distribution.get_distribution( self.ctx, @@ -53,9 +60,13 @@ def setUp_distribution_with_bootstrap(self, bs, **kwargs): ) def tearDown(self): + """Here we make sure that we reset a possible bootstrap created in + `setUp_distribution_with_bootstrap`""" self.ctx.bootstrap = None def test_properties(self): + """Test that some attributes has the expected result (for now, we check + that `__repr__` and `__str__` return the proper values""" self.setUp_distribution_with_bootstrap( Bootstrap().get_bootstrap("sdl2", self.ctx) ) @@ -69,29 +80,36 @@ def test_properties(self): @mock.patch("pythonforandroid.distribution.exists") def test_folder_exist(self, mock_exists): + """Test that method + :meth:`~pythonforandroid.distribution.Distribution.folder_exist` is + called once with the proper arguments.""" self.setUp_distribution_with_bootstrap( Bootstrap().get_bootstrap("sdl2", self.ctx) ) self.ctx.bootstrap.distribution.folder_exists() - mock_exists.assert_called_with( + mock_exists.assert_called_once_with( self.ctx.bootstrap.distribution.dist_dir ) @mock.patch("pythonforandroid.distribution.rmtree") def test_delete(self, mock_rmtree): - + """Test that method + :meth:`~pythonforandroid.distribution.Distribution.delete` is + called once with the proper arguments.""" self.setUp_distribution_with_bootstrap( Bootstrap().get_bootstrap("sdl2", self.ctx) ) self.ctx.bootstrap.distribution.delete() - mock_rmtree.assert_called_with( + mock_rmtree.assert_called_once_with( self.ctx.bootstrap.distribution.dist_dir ) @mock.patch("pythonforandroid.distribution.exists") def test_get_distribution_no_name(self, mock_exists): - + """Test that method + :meth:`~pythonforandroid.distribution.Distribution.get_distribution` + returns the proper result which should `unnamed_dist_1`.""" mock_exists.return_value = False self.ctx.bootstrap = Bootstrap().get_bootstrap("sdl2", self.ctx) dist = Distribution.get_distribution(self.ctx) @@ -100,6 +118,9 @@ def test_get_distribution_no_name(self, mock_exists): @mock.patch("pythonforandroid.util.chdir") @mock.patch("pythonforandroid.distribution.open", create=True) def test_save_info(self, mock_open_dist_info, mock_chdir): + """Test that method + :meth:`~pythonforandroid.distribution.Distribution.save_info` + is called once with the proper arguments.""" self.setUp_distribution_with_bootstrap( Bootstrap().get_bootstrap("sdl2", self.ctx) ) @@ -119,6 +140,15 @@ def test_save_info(self, mock_open_dist_info, mock_chdir): def test_get_distributions( self, mock_glob, mock_exists, mock_open_dist_info ): + """Test that method + :meth:`~pythonforandroid.distribution.Distribution.get_distributions` + returns some expected values: + + - A list of instances of class + `~pythonforandroid.distribution.Distribution + - That one of the distributions returned in the result has the + proper values (`name`, `ndk_api` and `recipes`) + """ self.setUp_distribution_with_bootstrap( Bootstrap().get_bootstrap("sdl2", self.ctx) ) @@ -146,6 +176,10 @@ def test_get_distributions( def test_get_distributions_error_ndk_api( self, mock_glob, mock_exists, mock_open_dist_info ): + """Test method + :meth:`~pythonforandroid.distribution.Distribution.get_distributions` + in case that `ndk_api` is not set..which should return a `None`. + """ dist_info_data_no_ndk_api = dist_info_data.copy() dist_info_data_no_ndk_api.pop("ndk_api") self.setUp_distribution_with_bootstrap( @@ -169,6 +203,12 @@ def test_get_distributions_error_ndk_api( def test_get_distributions_error_ndk_api_mismatch( self, mock_glob, mock_exists, mock_get_dists ): + """Test that method + :meth:`~pythonforandroid.distribution.Distribution.get_distribution` + raises an error in case that we have some distribution already build, + with a given `name` and `ndk_api`, and we try to get another + distribution with the same `name` but different `ndk_api`. + """ expected_dist = Distribution.get_distribution( self.ctx, name="test_prj", recipes=["python3", "kivy"] ) @@ -189,10 +229,15 @@ def test_get_distributions_error_ndk_api_mismatch( ) def test_get_distributions_error_extra_dist_dirs(self): + """Test that method + :meth:`~pythonforandroid.distribution.Distribution.get_distributions` + raises an exception of + :class:`~pythonforandroid.util.BuildInterruptingException` in case that + we supply the kwargs `extra_dist_dirs`. + """ self.setUp_distribution_with_bootstrap( Bootstrap().get_bootstrap("sdl2", self.ctx) ) - with self.assertRaises(BuildInterruptingException) as e: self.ctx.bootstrap.distribution.get_distributions( self.ctx, extra_dist_dirs=["/fake/extra/dist_dirs"] @@ -205,6 +250,13 @@ def test_get_distributions_error_extra_dist_dirs(self): @mock.patch("pythonforandroid.distribution.Distribution.get_distributions") def test_get_distributions_possible_dists(self, mock_get_dists): + """Test that method + :meth:`~pythonforandroid.distribution.Distribution.get_distributions` + returns the proper + `:class:`~pythonforandroid.distribution.Distribution` in case that we + already have it build and we request the same + `:class:`~pythonforandroid.distribution.Distribution`. + """ expected_dist = Distribution.get_distribution( self.ctx, name="test_prj", recipes=["python3", "kivy"] ) diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000000..9568514890 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,183 @@ +import os +import types +import unittest + +try: + from unittest import mock +except ImportError: + # `Python 2` or lower than `Python 3.3` does not + # have the `unittest.mock` module built-in + import mock +from pythonforandroid import util + + +class TestUtil(unittest.TestCase): + """ + An inherited class of `unittest.TestCase`to test the module + :mod:`~pythonforandroid.util`. + """ + + @mock.patch("pythonforandroid.util.makedirs") + def test_ensure_dir(self, mock_makedirs): + """ + Basic test for method :meth:`~pythonforandroid.util.ensure_dir`. Here + we make sure that the mentioned method is called only once. + """ + util.ensure_dir("fake_directory") + mock_makedirs.assert_called_once_with("fake_directory") + + @mock.patch("shutil.rmtree") + @mock.patch("pythonforandroid.util.mkdtemp") + def test_temp_directory(self, mock_mkdtemp, mock_shutil_rmtree): + """ + Basic test for method :meth:`~pythonforandroid.util.temp_directory`. We + perform this test by `mocking` the command `mkdtemp` and + `shutil.rmtree` and we make sure that those functions are called in the + proper place. + """ + mock_mkdtemp.return_value = "/temp/any_directory" + with util.temp_directory(): + mock_mkdtemp.assert_called_once() + mock_shutil_rmtree.assert_not_called() + mock_shutil_rmtree.assert_called_once_with("/temp/any_directory") + + @mock.patch("pythonforandroid.util.chdir") + def test_current_directory(self, moch_chdir): + """ + Basic test for method :meth:`~pythonforandroid.util.current_directory`. + We `mock` chdir and we check that the command is executed once we are + inside a python's `with` statement. Then we check that `chdir has been + called with the proper arguments inside this `with` statement and also + that, once we leave the `with` statement, is called again with the + current working path. + """ + chdir_dir = "/temp/any_directory" + # test chdir to existing directory + with util.current_directory(chdir_dir): + moch_chdir.assert_called_once_with("/temp/any_directory") + moch_chdir.assert_has_calls( + [mock.call("/temp/any_directory"), mock.call(os.getcwd())] + ) + + def test_current_directory_exception(self): + """ + Another test for method + :meth:`~pythonforandroid.util.current_directory`, but here we check + that using the method with a non-existing-directory raises an `OSError` + exception. + + .. note:: test chdir to non-existing directory, should raise error, + for py3 the exception is FileNotFoundError and IOError for py2, to + avoid introduce conditions, we test with a more generic exception + """ + with self.assertRaises(OSError), util.current_directory( + "/fake/directory" + ): + pass + + @mock.patch("pythonforandroid.util.sh.which") + def test_get_virtualenv_executable(self, mock_sh_which): + """ + Test method :meth:`~pythonforandroid.util.get_virtualenv_executable`. + In here we test: + + - that all calls to `sh.which` are performed, so we expect the + first two `sh.which` calls should be None and the last one should + return the expected virtualenv (the python3 one) + - that we don't have virtualenv installed, so all calls to + `sh.which` should return None + """ + expected_venv = os.path.join( + os.path.expanduser("~"), ".local/bin/virtualenv" + ) + mock_sh_which.side_effect = [None, None, expected_venv] + self.assertEqual(util.get_virtualenv_executable(), expected_venv) + mock_sh_which.assert_has_calls( + [ + mock.call("virtualenv2"), + mock.call("virtualenv-2.7"), + mock.call("virtualenv"), + ] + ) + self.assertEqual(mock_sh_which.call_count, 3) + mock_sh_which.reset_mock() + + # Now test that we don't have virtualenv installed, so all calls to + # `sh.which` should return None + mock_sh_which.side_effect = [None, None, None] + self.assertIsNone(util.get_virtualenv_executable()) + self.assertEqual(mock_sh_which.call_count, 3) + mock_sh_which.assert_has_calls( + [ + mock.call("virtualenv2"), + mock.call("virtualenv-2.7"), + mock.call("virtualenv"), + ] + ) + + @mock.patch("pythonforandroid.util.walk") + def test_walk_valid_filens(self, mock_walk): + """ + Test method :meth:`~pythonforandroid.util.walk_valid_filens` + In here we simulate the following directory structure: + + /fake_dir + |-- README + |-- setup.py + |-- __pycache__ + |-- |__ + |__Lib + |-- abc.pyc + |-- abc.py + |__ ctypes + |-- util.pyc + |-- util.py + + Then we execute the method in order to check that we got the expected + result, which should be: + + .. code-block:: python + :emphasize-lines: 2-4 + + expected_result = { + "/fake_dir/README", + "/fake_dir/Lib/abc.pyc", + "/fake_dir/Lib/ctypes/util.pyc", + } + """ + simulated_walk_result = [ + ["/fake_dir", ["__pycache__", "Lib"], ["README", "setup.py"]], + ["/fake_dir/Lib", ["ctypes"], ["abc.pyc", "abc.py"]], + ["/fake_dir/Lib/ctypes", [], ["util.pyc", "util.py"]], + ] + mock_walk.return_value = simulated_walk_result + file_ens = util.walk_valid_filens( + "/fake_dir", ["__pycache__"], ["*.py"] + ) + self.assertIsInstance(file_ens, types.GeneratorType) + expected_result = { + "/fake_dir/README", + "/fake_dir/Lib/abc.pyc", + "/fake_dir/Lib/ctypes/util.pyc", + } + result = set(file_ens) + + self.assertEqual(result, expected_result) + + def test_util_exceptions(self): + """ + Test exceptions for a couple of methods: + + - method :meth:`~pythonforandroid.util.BuildInterruptingException` + - method :meth:`~pythonforandroid.util.handle_build_exception` + + Here we create an exception with method + :meth:`~pythonforandroid.util.BuildInterruptingException` and we run it + inside method :meth:`~pythonforandroid.util.handle_build_exception` to + make sure that it raises an `SystemExit`. + """ + exc = util.BuildInterruptingException( + "missing dependency xxx", instructions="pip install --user xxx" + ) + with self.assertRaises(SystemExit): + util.handle_build_exception(exc)