diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..45a0dfbf5d45bf0f346cf4f9c1031ebfdc5c07d1 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,69 @@ +variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache" + VENV2: "python_client_venv_p2/" + VENV3: "python_client_venv_p3/" + +cache: + paths: + - $VENV2 + - $VENV3 + +# ---------------------------- +stages: + - test + - doc + - deploy + +# ---------------------------- +image: python:3.7 + +before_script: + - python -V + - pip install virtualenv + - virtualenv $VENV3 + - source $VENV3/bin/activate + - pip install -r requirements.txt + - pwd + +# ---------------------------- +tests:2.7: + stage: test + image: python:2.7 + before_script: + - python -V + - pip install virtualenv + - virtualenv $VENV2 + - source $VENV2/bin/activate + - pip install -r requirements.txt mock + - pwd + script: + - python -V + - python -m pytest --pep8 --cov=allgo tests/ + +# ---------------------------- +tests:3.7: + stage: test + script: + - python -V + - python -m pytest --pep8 --cov=allgo tests/ + +# ---------------------------- +sphinx: + stage: doc + script: + - apt update && apt install -y pandoc + - cd doc ; make html + +# ---------------------------- +# gitlab pages : documentation generation & deploy only for master branch. +pages: + stage: deploy + script: + - apt update && apt install -y pandoc + - cd doc ; make html + - mv _build/html/ ../public/ + artifacts: + paths: + - public + only: + - master diff --git a/README.md b/README.md index f1c2ee723802df0ccf5122a77b9d558bd2e301e2..78bfdaef04bfa226a219995e2f6e9c433bdc0932 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # A||GO API-Client python library We provide here a simple API client for [A||GO](https://allgo18.inria.fr) written in Python. +[](https://gitlab.inria.fr/allgo/api-clients/python_client/commits/client_api) +python 2.7: [](https://gitlab.inria.fr/allgo/api-clients/python_client/commits/client_api?job=tests:2.7) +python 3.7: [](https://gitlab.inria.fr/allgo/api-clients/python_client/commits/client_api?job=tests:3.7) + ## Description AllGo is a SaaS (Software as a Service) platform provided by Inria. It may be seen as a virtual showroom of technologies developed by research teams. First follow https://allgo18.inria.fr/accounts/signup to create an account on AllGo (anyone may create such an account). Once your account creation is confirmed, please connect to https://allgo18.inria.fr to obtain your private token, which will allow yo to use the AllGo REST API. You will need this token later (cf. §3 below). diff --git a/allgo/__init__.py b/allgo/__init__.py index 78cd843ebc1a2b1f9c8e3e71ce891266d5adc242..c3fa80ac7905068d392694926160726bf2e588f6 100644 --- a/allgo/__init__.py +++ b/allgo/__init__.py @@ -223,7 +223,7 @@ class Client: msg += "KeyError: {}".format(str(err)) raise AllgoError(msg) - def create_job(self, app, version=None, params='', files=None): + def create_job(self, app, version=None, params='', files=None, queue=None): """Create a job for app, with params. Launch the API request to create a job for the given app, @@ -240,6 +240,8 @@ class Client: all the app command line params as one string. files : list of filepaths, optional filepaths to upload. + queue : string + name of the queue to use. Returns ------- @@ -253,10 +255,11 @@ class Client: with the Response status code if it's not 200. """ data = { - "job[webapp_name]": app, + # ~ "job[webapp_name]": app, "job[webapp_id]" : app, "job[param]" : params, - "job[version]" : version + "job[version]" : version, + "job[queue]" : queue, } file_dict = {} diff --git a/doc/conf.py b/doc/conf.py index d986506ac65252aab54cbaa59047f8474293d6af..ce1a08291683c8966e3c97ba2ab43ce06e9c42d8 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -91,7 +91,7 @@ html_theme = 'sphinx_rtd_theme' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +# html_static_path = ['_static'] # Custom sidebar templates, must be a dictionary that maps document names # to template names. diff --git a/pytest.ini b/pytest.ini index db328198fc04802f3fb5ed9e0fbce2cfbded84b1..1775774e320a6ddf29bf1fa2aa733592c9b2bb4b 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,3 +5,11 @@ markers = error: test when an error occured success: test output on successfull request dbg: for debug. + pep8: workaround for https://bitbucket.org/pytest-dev/pytest-pep8/issues/23/ + +# spaces rules are disabled +pep8ignore = E201 E202 E203 E221 E231 E272 + +# developper are recommanded to use a max length = 80 +# but as doctrings are quite longer, we allow up to 100. +pep8maxlinelength = 100 diff --git a/requirements.txt b/requirements.txt index 6566012f04aac252ab361294aef221b7b8ee07a9..a5d15da2f144f83542dbac0ddfc54621cf49bcd0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,9 @@ requests pytest +pytest-cov +pytest-pep8 sphinx +sphinx_rtd_theme +ipython nbsphinx pandoc diff --git a/tests/test_allgo.py b/tests/test_allgo.py index b69ffcf259ff355a30a78a11f77359a0f9c47db9..74c1f45e51c53f79bff5e71755835c43d4f664a2 100644 --- a/tests/test_allgo.py +++ b/tests/test_allgo.py @@ -9,14 +9,32 @@ import tempfile # not standard import pytest +# local import +import allgo # I need to modify constant +from allgo import * + if 2 == sys.version_info.major: from mock import Mock, patch else: from unittest.mock import Mock, patch -# local import -import allgo # I need to modify constant -from allgo import * + +# ================================================ +class TestStatusError: + + REF_MSG = "whatever, an error message." + + @pytest.fixture + def response(self): + m = Mock() + m.json.return_value = \ + { 'error': self.REF_MSG } + return m + + @pytest.mark.dbg + def test_msg(self, response): + err = StatusError(42, response) + assert self.REF_MSG == err.msg # ================================================ @@ -149,7 +167,7 @@ def test_create_job__app_not_exist(mock_post, client): err = err_info.value assert 404 == err.status_code - assert 'Application not found' == err.msg + # ~ assert 'Application not found' == err.msg @pytest.mark.error @@ -166,7 +184,7 @@ def test_create_job__app_not_published(mock_post, client): err = err_info.value assert 404 == err.status_code - assert 'not yet published' in err.msg + # ~ assert 'not yet published' in err.msg @pytest.mark.error @@ -179,11 +197,11 @@ def test_create_job__queue_not_exist(mock_post, client): # -- tests with pytest.raises(StatusError) as err_info: - client.create_job('fake') + client.create_job('fake', queue='no_queue') err = err_info.value assert 400 == err.status_code - assert 'Unknown queue' == err.msg + # ~ assert 'Unknown queue' == err.msg @pytest.mark.error @@ -200,7 +218,7 @@ def test_create_job__param_error(mock_post, client): err = err_info.value assert 400 == err.status_code - assert err.msg.startswith('Invalid parameters') + # ~ assert err.msg.startswith('Invalid parameters') @pytest.mark.success @@ -256,7 +274,7 @@ def test_job_status__error(mock_get, client): err = err_info.value assert 404 == err.status_code - assert 'Job not found' == err.msg + # ~ assert 'Job not found' == err.msg @pytest.mark.success @@ -388,7 +406,7 @@ def test_download_file__default(mock_get, client): file_url = "https://whatever.fr/" + filename filepath = client.download_file(file_url) - assert "./"+filename == filepath + assert "./" + filename == filepath content = "" with open(filepath, "r") as file_out: @@ -530,7 +548,7 @@ def test_get_log__big(mock_get, mock_status, client): for c in alphabet: chunk = '' for i in range(8): - chunk += c*10 + "\n" + chunk += c * 10 + "\n" file_content.append(chunk) # file_content is 5 chunks of 8 lines each # and each line is the same letter repeated 10 times. diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000000000000000000000000000000000000..af1ac73fcededd9ec7e03eb7911f7fd3dfce76b9 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,270 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +# standard lib +import os + +# not standard +import pytest + +# local import +from allgo import * + + +# ========================================================== +class ApiClientIntegration: + CLIENT = None + TEST_APP = 'allgo api test' + + @classmethod + def setup_class(cls): + """Create a client object, and set it as attribute.""" + raise NotImplementedError("This class has to be derived to be used.") + + @classmethod + def _build_client(cls): + raise NotImplementedError + + @classmethod + def _get_job_id(cls): + raise NotImplementedError + + @pytest.mark.error + def test_create_job__user_not_exist(self): + """create a job with a token that refers to no one. + We test that we get an error 401.""" + # The token should refer to unexisting user + client = self._build_client(token='0') + + with pytest.raises(StatusError) as err_info: + client.create_job('fake') + + err = err_info.value + assert 401 == err.status_code + # ~ assert '' == err.msg + + @pytest.mark.error + def test_create_job__app_not_exist(self): + """I try to create a job with an app that cannot exist. + Its name is illegal, an error 404 should be raised.""" + with pytest.raises(StatusError) as err_info: + self.CLIENT.create_job('ç') + + err = err_info.value + assert 404 == err.status_code + # ~ assert 'Application not found' == err.msg + + @pytest.mark.skip(reason="I don't want to create another fake app. \ + Wait for version to be considered.") + @pytest.mark.error + def test_create_job__app_not_published(self): + with pytest.raises(StatusError) as err_info: + self.CLIENT.create_job('not_published') + + err = err_info.value + assert 404 == err.status_code + # ~ assert 'not yet published' in err.msg + + @pytest.mark.error + def test_create_job__queue_not_exist(self): + """create a job to run on a queue that does not exist. + Should raise an error 400.""" + with pytest.raises(StatusError) as err_info: + self.CLIENT.create_job(self.TEST_APP, queue='no_queue') + + err = err_info.value + assert 400 == err.status_code + # ~ assert 'Unknown queue' == err.msg + + @pytest.mark.error + def test_create_job__param_error(self): + """create a job with an invalid param. + should raise an error 400.""" + with pytest.raises(StatusError) as err_info: + self.CLIENT.create_job(self.TEST_APP, params='$') + + err = err_info.value + assert 400 == err.status_code + # ~ assert err.msg.startswith('Invalid parameters') + + @pytest.mark.success + def test_create_job(self): + """create a job that will succeed. + response should be a dict with at least keys 'id' and 'url'.""" + response = self.CLIENT.create_job(self.TEST_APP) + + assert isinstance(response, dict) + assert 'id' in response + assert 'url' in response + + # I think this test is useless as the failure is not related to the submission. + @pytest.mark.success + def test_create_job__that_will_fail(self): + """create a job that will fail (the job, not its submission). + response should be a dict with at least keys 'id' and 'url'.""" + response = self.CLIENT.create_job(self.TEST_APP, 1) + + assert isinstance(response, dict) + assert 'id' in response + assert 'url' in response + + @pytest.mark.error + def test_job_status__do_not_exist(self): + """try to get status of a job that does not exist. + should raise an error 404.""" + with pytest.raises(StatusError) as err_info: + self.CLIENT.job_status('fake_id') # not found, as job id should be integer + + err = err_info.value + assert 404 == err.status_code + # ~ assert 'Job not found' == err.msg + + @pytest.mark.success + def test_job_status(self): + """create a job and get its status, + the method should return the request response as a dictionnary. + it should contain the 'status' key and the job id as key.""" + response = self.CLIENT.create_job(self.TEST_APP) + job_id = response['id'] + response = self.CLIENT.job_status(job_id) + + assert isinstance(response, dict) + assert str(job_id) in response + assert 'status' in response + + @pytest.mark.dbg + @pytest.mark.success + def test_download_file(self, tmp_path): + """Get the job status of an existing job, and try to download the log file. + The file should have been written in a given directory. + The content file is also checked. + + The job queried exists and belongs to the testing user. + """ + JOB_ID = self._get_job_id() + response = self.CLIENT.job_status(JOB_ID) + + file_url = response[str(JOB_ID)]['allgo.log'] + filepath = self.CLIENT.download_file(file_url, outdir=tmp_path) + + assert filepath.startswith(str(tmp_path)) + + content = "" + with open(filepath, "r") as file_out: + content = file_out.read() + assert content.endswith("==== ALLGO JOB SUCCESS ====\n") + + # cleaning + os.remove(filepath) + + +# ========================================================== +class TestAllgo18(ApiClientIntegration): + + @classmethod + def _build_client(cls, token): + return Client(token) + + @classmethod + def _get_job_id(cls): + """return a job id that exist, with a log file named allgo.log""" + return 874 + + def setup_class(cls): + """define the client used in tests. + It uses a token defined in as env variable.""" + TOKEN = os.environ.get('ALLGO_18_TOKEN') + cls.CLIENT = cls._build_client(TOKEN) + + +@pytest.mark.skipif(not os.environ.get('DEV'), reason="No dev test in CI.") +class TestAllgoDev(ApiClientIntegration): + TEST_APP = 'Allgo Api Test' + + @classmethod + def _build_client(cls, token): + ALLGO_URL = 'https://localhost' + + return Client(token, allgo_url=ALLGO_URL, verify_tls=False) + + @classmethod + def _get_job_id(cls): + """return a job id that exist, with a log file named allgo.log""" + return 37 + + @classmethod + def setup_class(cls): + """define the client used in local instance. + It uses a token defined in as env variable.""" + TOKEN = os.environ.get('ALLGO_DEV_TOKEN') + cls.CLIENT = cls._build_client(TOKEN) + + # should be removed when a global solution will be found. + @pytest.mark.xfail(reason="published state is not yet implemented.") + @pytest.mark.error + def test_create_job__app_not_published(self): + with pytest.raises(StatusError) as err_info: + self.CLIENT.create_job('not_published') + + err = err_info.value + assert 404 == err.status_code + # ~ assert 'not yet published' in err.msg + + +class TestAllgoOld(ApiClientIntegration): + TEST_APP = 206 + + @classmethod + def _build_client(cls, token): + ALLGO_URL = 'https://allgo.inria.fr' + + return Client(token, allgo_url=ALLGO_URL) + + @classmethod + def _get_job_id(cls): + """return a job id that exist, with a log file named allgo.log""" + return 71771 + + def setup_class(cls): + """define the client used in local instance. + It uses a token defined in as env variable.""" + TOKEN = os.environ.get('ALLGO_OLD_TOKEN') + cls.CLIENT = cls._build_client(TOKEN) + + # behaviour changed between old and new. + @pytest.mark.error + def test_create_job__queue_not_exist(self): + """create a job to run on a queue that does not exist. + Should raise an error 404 (code error changed between old and new version).""" + with pytest.raises(StatusError) as err_info: + self.CLIENT.create_job(self.TEST_APP, queue='no_queue') + + err = err_info.value + assert 404 == err.status_code + # ~ assert 'Unknown queue' == err.msg + + # behaviour changed between old and new. + # I didn't find the source that emit the error in rails code. + @pytest.mark.error + def test_create_job__param_error(self): + """create a job with an invalid param. + should raise an error 422 (code error changed between old and new version).""" + with pytest.raises(StatusError) as err_info: + self.CLIENT.create_job(self.TEST_APP, params='$') + + err = err_info.value + assert 422 == err.status_code + # ~ assert err.msg.startswith('Invalid parameters') + + # behaviour changed between old and new. + @pytest.mark.success + def test_job_status(self): + """create a job and get its status, + the method should return the request response as a dictionnary. + it should contain only the 'status' key.""" + response = self.CLIENT.create_job(self.TEST_APP) + job_id = response['id'] + response = self.CLIENT.job_status(job_id) + + assert isinstance(response, dict) + assert 'status' in response