From e156bbfeca4885ba8810bd870decc4049b1bece4 Mon Sep 17 00:00:00 2001 From: Alex Forbes Date: Wed, 20 Jan 2016 15:28:18 +0000 Subject: [PATCH 01/14] Add changlog for 0.1.0 --- debian/changelog | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/debian/changelog b/debian/changelog index 2c90008..6f8bfea 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,67 @@ +orlo (0.1.0) stable; urgency=medium + + * Move orlo to /api/ in nginx + * Add ability to filter on rollback + * Add DB created to vagrant file + * Add more get_releases package filters and "latest" option + * Abstract filtering logic + + * Show easy to understand json message when filtering on invalid field + * Fix last/latest parameter name + * Use limit(1) instead of first for latest query + * Move helper functions from views.py to util.py + * Rename views.py to route_api.py + * Move filter function to util + * Start adding stats endpoints and functions + * Add index to stime field on Release + * Add __version__ attribute to package + * Add _version.py to gitignore + * Move cli interface into python page + * Add queries, for use by the stats and info routes + * Implement more /info endpoints, separate tests from stats + * Refactor non-endpoint tests to use internal methods rather than http + * Remove data.py + * Make the release counting function (now count_releases) more generic + * Add rollback filter to count_packages + * Comments, plus bump version + * Install requirements in vagrant file + * Fix platform name in log message + * Implement /stats endpoints + * Add /stats/package and consolidate the dictionary creation + * Handle poorly formatted time gracefully in stats endpoints + * Bump version + * Reverse Release-Package relationship + * Fix missing arguments passed to count_releases and implement /stats + * Fix to package_versions + * Rename /info urls + * Documentation updates + * Update Travis config to use Postgres + * Remove password from test postgres DB + * Fix postgres command + * Fix database setup + * Fix quotes in travis DB string + * Fix package_versions under postgres + * Set Flask packages to >= $version in requirements.txt + * Change /info/user endpoint + * Remove print statement, fix minor documentation bugs in /info + * Move platform in /info routes to query parameter + * Stream output of GET /releases to reduce memory usage + * Move /releases streaming json generator to util.py + * Add example curl to documentation for /import + * Abstract release logic away from the get_releases route + * Add status to get_releases parameters + * Remove "latest" filter option in favour of "desc" and "limit" + * Rename test_import + * Add offset to get_releases + * Ensure limit and offset are ints + * Implement time-based stats for charts + * Move orlo.conf to orlo/orlo.ini + * Rename package from python-orlo to orlo + * Deb packaging fixes + * Add tests for stop package + * Bump version to 0.1.0 + + -- Alex Forbes Tue, 19 Jan 2016 16:07:52 +0000 + python-orlo (0.0.4) stable; urgency=medium * Update debian description From 17db8bf65229a8982427585e11c5e71c23c8c2df Mon Sep 17 00:00:00 2001 From: Alex Forbes Date: Wed, 20 Jan 2016 15:54:12 +0000 Subject: [PATCH 02/14] Cast package_rollback as a boolean --- orlo/route_api.py | 7 ++++++- orlo/util.py | 8 ++++++++ setup.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/orlo/route_api.py b/orlo/route_api.py index fe2942f..c685c3a 100644 --- a/orlo/route_api.py +++ b/orlo/route_api.py @@ -5,7 +5,8 @@ import datetime from orlo.orm import db, Release, Package, PackageResult, ReleaseNote, Platform from orlo.util import validate_request_json, create_release, validate_release_input, \ - validate_package_input, fetch_release, create_package, fetch_package, stream_json_list + validate_package_input, fetch_release, create_package, fetch_package, stream_json_list, \ + str_to_bool @app.route('/ping', methods=['GET']) @@ -271,12 +272,16 @@ def get_releases(release_id=None): """ + booleans = ('package_rollback', ) + if release_id: # Simple query = db.session.query(Release).filter(Release.id == release_id) else: # Bit more complex # Flatten args, as the ImmutableDict puts some values in a list when expanded args = {} for k, v in request.args.items(): + if k in booleans: + args[k] = request.args.get(k, type=bool) if type(v) is list: args[k] = v[0] else: diff --git a/orlo/util.py b/orlo/util.py index be892aa..81c2b46 100644 --- a/orlo/util.py +++ b/orlo/util.py @@ -183,3 +183,11 @@ def is_int(value): return True except ValueError: return False + + +def str_to_bool(value): + if value in ('T', 't', '1', 'true', 'True'): + return True + if value in ('F', 'f', '0', 'false', 'True'): + return False + raise ValueError("Value {} can not be cast as boolean".format(value)) diff --git a/setup.py b/setup.py index 6ef5264..7693996 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ import multiprocessing # nopep8 -VERSION = '0.1.0' +VERSION = '0.1.0-2' version_file = open('./orlo/_version.py', 'w') version_file.write("__version__ = '{}'".format(VERSION)) version_file.close() From 980ec6375803517428ae5b0723580f55f9ab0e59 Mon Sep 17 00:00:00 2001 From: Alex Forbes Date: Wed, 27 Jan 2016 13:23:48 +0000 Subject: [PATCH 03/14] Add log files for gunicorn --- systemd/orlo.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/systemd/orlo.service b/systemd/orlo.service index 91fa7e1..73ee3bd 100644 --- a/systemd/orlo.service +++ b/systemd/orlo.service @@ -9,7 +9,7 @@ ConditionPathExists=/usr/share/python/orlo/bin/gunicorn Type=simple User=orlo Group=orlo -ExecStart=/usr/share/python/orlo/bin/gunicorn -w 4 -b 127.0.0.1:8080 orlo:app +ExecStart=/usr/share/python/orlo/bin/gunicorn -w 4 -b 127.0.0.1:8080 orlo:app --access-logfile /var/log/orlo/gunicorn-access.log --log-level debug --error-logfile /var/log/orlo/gunicorn-error.log --log-file /var/log/orlo/gunicorn.log [Install] WantedBy=multi-user.target From bc3deda14b5d0ded95e6dd840e54368efa134e1e Mon Sep 17 00:00:00 2001 From: Alex Forbes Date: Wed, 27 Jan 2016 16:41:45 +0000 Subject: [PATCH 04/14] Separate debug mode and debug logging --- orlo/__init__.py | 10 +++++++++- orlo/config.py | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/orlo/__init__.py b/orlo/__init__.py index 5846fd9..a5943cd 100644 --- a/orlo/__init__.py +++ b/orlo/__init__.py @@ -1,4 +1,5 @@ from flask import Flask +import logging from logging.handlers import RotatingFileHandler from orlo.config import config @@ -15,8 +16,15 @@ if config.getboolean('db', 'echo_queries'): app.config['SQLALCHEMY_ECHO'] = True -if config.getboolean('logging', 'debug'): +# Debug mode ignores all custom logging and should only be used in +# local testing... +if config.getboolean('main', 'debug_mode'): app.debug = True + +# ...as opposed to loglevel debug, which can be used anywhere +if config.getboolean('logging', 'debug'): + app.logger.setLevel(logging.DEBUG) + app.logger.debug('Debug enabled') if not config.getboolean('main', 'strict_slashes'): diff --git a/orlo/config.py b/orlo/config.py index 59ed67c..922cb10 100644 --- a/orlo/config.py +++ b/orlo/config.py @@ -5,6 +5,7 @@ config = ConfigParser.ConfigParser() config.add_section('main') +config.set('main', 'debug_mode', 'false') config.set('main', 'propagate_exceptions', 'true') config.set('main', 'time_format', '%Y-%m-%dT%H:%M:%SZ') config.set('main', 'time_zone', 'UTC') From ab92b54929c2fb348c9545ccfa49030b393c1a1e Mon Sep 17 00:00:00 2001 From: Alex Forbes Date: Wed, 27 Jan 2016 16:44:18 +0000 Subject: [PATCH 05/14] Return 400 when getting /releases with no filter Returns a lot of data, usually not what one wants --- orlo/exceptions.py | 2 +- orlo/route_api.py | 4 ++ tests/test_contract.py | 91 ++++++++++++++++++++++-------------------- 3 files changed, 53 insertions(+), 44 deletions(-) diff --git a/orlo/exceptions.py b/orlo/exceptions.py index 73dd6c3..3602a2c 100644 --- a/orlo/exceptions.py +++ b/orlo/exceptions.py @@ -5,6 +5,7 @@ class OrloError(Exception): status_code = 500 + def __init__(self, message, status_code=None, payload=None): Exception.__init__(self) self.message = message @@ -28,4 +29,3 @@ class DatabaseError(OrloError): class OrloWorkflowError(OrloError): status_code = 400 - diff --git a/orlo/route_api.py b/orlo/route_api.py index c685c3a..d9621b8 100644 --- a/orlo/route_api.py +++ b/orlo/route_api.py @@ -276,6 +276,10 @@ def get_releases(release_id=None): if release_id: # Simple query = db.session.query(Release).filter(Release.id == release_id) + elif len(request.args.keys()) == 0: + raise InvalidUsage("Please specify a filter. See " + "http://orlo.readthedocs.org/en/latest/rest.html#get--releases for " + "more info") else: # Bit more complex # Flatten args, as the ImmutableDict puts some values in a list when expanded args = {} diff --git a/tests/test_contract.py b/tests/test_contract.py index a12dd6a..c1ef2fb 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -31,15 +31,15 @@ def _create_release(self, """ response = self.client.post( - '/releases', - data=json.dumps({ - 'note': 'test note lorem ipsum', - 'platforms': platforms, - 'references': references, - 'team': team, - 'user': user, - }), - content_type='application/json', + '/releases', + data=json.dumps({ + 'note': 'test note lorem ipsum', + 'platforms': platforms, + 'references': references, + 'team': team, + 'user': user, + }), + content_type='application/json', ) self.assert200(response) return response.json['id'] @@ -71,9 +71,9 @@ def _create_package(self, release_id, doc['rollback'] = rollback response = self.client.post( - '/releases/{}/packages'.format(release_id), - data=json.dumps(doc), - content_type='application/json', + '/releases/{}/packages'.format(release_id), + data=json.dumps(doc), + content_type='application/json', ) self.assert200(response) return response.json['id'] @@ -87,8 +87,8 @@ def _start_package(self, release_id, package_id): """ response = self.client.post( - '/releases/{}/packages/{}/start'.format(release_id, package_id), - content_type='application/json', + '/releases/{}/packages/{}/start'.format(release_id, package_id), + content_type='application/json', ) self.assertEqual(response.status_code, 204) return response @@ -103,12 +103,12 @@ def _stop_package(self, release_id, package_id, """ response = self.client.post( - '/releases/{}/packages/{}/stop'.format(release_id, package_id), - data=json.dumps({ - 'success': str(success), - 'foo': 'bar', - }), - content_type='application/json', + '/releases/{}/packages/{}/stop'.format(release_id, package_id), + data=json.dumps({ + 'success': str(success), + 'foo': 'bar', + }), + content_type='application/json', ) self.assertEqual(response.status_code, 204) return response @@ -121,8 +121,8 @@ def _stop_release(self, release_id): :return: """ response = self.client.post( - '/releases/{}/stop'.format(release_id), - content_type='application/json', + '/releases/{}/stop'.format(release_id), + content_type='application/json', ) self.assertEqual(response.status_code, 204) @@ -149,9 +149,9 @@ def _post_releases_notes(self, release_id, text): doc = {'text': text} response = self.client.post( - '/releases/{}/notes'.format(release_id, text), - data=json.dumps(doc), - content_type='application/json', + '/releases/{}/notes'.format(release_id, text), + data=json.dumps(doc), + content_type='application/json', ) self.assertEqual(response.status_code, 204) return response @@ -185,13 +185,13 @@ def test_add_results(self): package_id = self._create_package(release_id) results_response = self.client.post( - '/releases/{}/packages/{}/results'.format( - release_id, package_id), - data=json.dumps({ - 'success': 'true', - 'foo': 'bar', - }), - content_type='application/json', + '/releases/{}/packages/{}/results'.format( + release_id, package_id), + data=json.dumps({ + 'success': 'true', + 'foo': 'bar', + }), + content_type='application/json', ) self.assertEqual(results_response.status_code, 204) @@ -238,12 +238,12 @@ def test_create_release_minimal(self): Create a release, omitting all optional parameters """ response = self.client.post('/releases', - data=json.dumps({ - 'platforms': ['test_platform'], - 'user': 'testuser', - }), - content_type='application/json', - ) + data=json.dumps({ + 'platforms': ['test_platform'], + 'user': 'testuser', + }), + content_type='application/json', + ) self.assert200(response) def test_diffurl_present(self): @@ -327,7 +327,7 @@ def _get_releases(self, release_id=None, filters=None, expected_status=200): path = '/releases' results_response = self.client.get( - path, content_type='application/json', + path, content_type='application/json', ) try: @@ -376,7 +376,7 @@ def test_get_release_filter_package(self): package_id = self._create_package(release_id, name='specific-package') results = self._get_releases(filters=[ 'package_name=specific-package' - ]) + ]) for r in results['releases']: for p in r['packages']: @@ -475,7 +475,7 @@ def test_get_release_filter_ftime_before(self): Filter on releases that finished before a particular time """ r_yesterday, r_tomorrow = self._get_releases_time_filter( - 'ftime_before', finished=True) + 'ftime_before', finished=True) self.assertEqual(3, len(r_tomorrow['releases'])) self.assertEqual(0, len(r_yesterday['releases'])) @@ -485,7 +485,7 @@ def test_get_release_filter_ftime_after(self): Filter on releases that finished after a particular time """ r_yesterday, r_tomorrow = self._get_releases_time_filter( - 'ftime_after', finished=True) + 'ftime_after', finished=True) self.assertEqual(0, len(r_tomorrow['releases'])) self.assertEqual(3, len(r_yesterday['releases'])) @@ -777,10 +777,15 @@ def test_get_release_with_status_successful(self): def test_get_release_with_bad_status(self): """ - Tests get /releases?status=garbage give a helpful mesage + Tests get /releases?status=garbage give a helpful message """ self._create_finished_release() result = self._get_releases(filters=['status=garbage_boz'], expected_status=400) self.assertIn('message', result) + def test_get_releases_with_no_filters(self): + """ + Test get /releases without filters returns 400 + """ + self._get_releases(expected_status=400) From 731cf9d3e749d366b6c227d29661f7f4bf33345d Mon Sep 17 00:00:00 2001 From: Alex Forbes Date: Wed, 27 Jan 2016 16:47:01 +0000 Subject: [PATCH 06/14] Remove skip from documentation, it was renamed to offset --- orlo/route_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/orlo/route_api.py b/orlo/route_api.py index d9621b8..6d65c7d 100644 --- a/orlo/route_api.py +++ b/orlo/route_api.py @@ -240,7 +240,6 @@ def get_releases(release_id=None): desc to true will reverse this and sort by stime descending :query int limit: Limit the results by int :query int offset: Offset the results by int - :query int skip: Skip this number of releases :query string package_name: Filter releases by package name :query string user: Filter releases by user the that performed the release :query string platform: Filter releases by platform From df4b543bd779e2bfe8ca99ee90d21c434d186cf8 Mon Sep 17 00:00:00 2001 From: Alex Forbes Date: Wed, 27 Jan 2016 18:16:11 +0000 Subject: [PATCH 07/14] Fix tests, require filter on GET /releases --- orlo/queries.py | 12 ++++++++---- orlo/route_api.py | 4 +--- orlo/util.py | 23 +++++++++++------------ tests/test_contract.py | 12 +++++++----- tests/test_route_import.py | 2 +- tests/test_util.py | 21 +++++++++++++++++++++ 6 files changed, 49 insertions(+), 25 deletions(-) create mode 100644 tests/test_util.py diff --git a/orlo/queries.py b/orlo/queries.py index 36dba1a..44963fc 100644 --- a/orlo/queries.py +++ b/orlo/queries.py @@ -5,7 +5,6 @@ from orlo import app from orlo.orm import db, Release, Platform, Package, release_platform from orlo.exceptions import OrloError, InvalidUsage -from orlo.util import is_int from collections import OrderedDict __author__ = 'alforbes' @@ -81,7 +80,8 @@ def apply_filters(query, args): if field == 'latest': # this is not a comparison continue - # special logic for these ones, as they are package attributes + # special logic for these ones, as they are release attributes that + # are JIT calculated from package attributes if field == 'status': query = _filter_release_status(query, value) continue @@ -189,11 +189,15 @@ def releases(**kwargs): query = query.order_by(stime_field()) if limit: - if not is_int(limit): + try: + limit = int(limit) + except ValueError: raise InvalidUsage("limit must be a valid integer value") query = query.limit(limit) if offset: - if not is_int(offset): + try: + offset = int(offset) + except ValueError: raise InvalidUsage("offset must be a valid integer value") query = query.offset(offset) diff --git a/orlo/route_api.py b/orlo/route_api.py index 6d65c7d..cba74e8 100644 --- a/orlo/route_api.py +++ b/orlo/route_api.py @@ -284,9 +284,7 @@ def get_releases(release_id=None): args = {} for k, v in request.args.items(): if k in booleans: - args[k] = request.args.get(k, type=bool) - if type(v) is list: - args[k] = v[0] + args[k] = str_to_bool(request.args.get(k)) else: args[k] = v query = queries.releases(**args) diff --git a/orlo/util.py b/orlo/util.py index 81c2b46..f0d4278 100644 --- a/orlo/util.py +++ b/orlo/util.py @@ -6,6 +6,7 @@ from orlo.orm import db, Release, Package, Platform from orlo.exceptions import InvalidUsage from sqlalchemy.orm import exc +from six import string_types __author__ = 'alforbes' @@ -177,17 +178,15 @@ def stream_json_list(heading, iterator): yield json.dumps(prev_release.to_dict()) + ']}' -def is_int(value): - try: - int(value) - return True - except ValueError: - return False - - def str_to_bool(value): - if value in ('T', 't', '1', 'true', 'True'): - return True - if value in ('F', 'f', '0', 'false', 'True'): - return False + if isinstance(value, string_types): + try: + value = int(value) + except ValueError: + if value.lower() in ('t', 'true'): + return True + elif value.lower() in ('f', 'false'): + return False + if isinstance(value, int): + return True if value > 0 else False raise ValueError("Value {} can not be cast as boolean".format(value)) diff --git a/tests/test_contract.py b/tests/test_contract.py index c1ef2fb..9d9d31e 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -362,7 +362,9 @@ def test_get_releases(self): """ for _ in range(0, 3): self._create_finished_release() - results = self._get_releases() + results = self._get_releases( + filters=['limit=10'] + ) self.assertEqual(len(results['releases']), 3) def test_get_release_filter_package(self): @@ -555,9 +557,6 @@ def test_get_release_filter_rollback(self): first_results = self._get_releases(filters=['package_rollback=True']) second_results = self._get_releases(filters=['package_rollback=False']) - self.assertEqual(len(first_results['releases']), 3) - self.assertEqual(len(second_results['releases']), 2) - for r in first_results['releases']: for p in r['packages']: self.assertIs(p['rollback'], True) @@ -565,6 +564,9 @@ def test_get_release_filter_rollback(self): for p in r['packages']: self.assertIs(p['rollback'], False) + self.assertEqual(len(first_results['releases']), 3) + self.assertEqual(len(second_results['releases']), 2) + def test_get_release_limit_one(self): """ Should return only one release @@ -728,7 +730,7 @@ def test_get_release_filter_rollback_and_status(self): second_results = self._get_releases(filters=['package_rollback=False', 'package_status=NOT_STARTED']) # should be zero - third_results = self._get_releases(filters=['package_rollback=FALSE', + third_results = self._get_releases(filters=['package_rollback=False', 'package_status=SUCCESSFUL']) self.assertEqual(len(first_results['releases']), 3) diff --git a/tests/test_route_import.py b/tests/test_route_import.py index 6332a28..a75c9e6 100644 --- a/tests/test_route_import.py +++ b/tests/test_route_import.py @@ -63,7 +63,7 @@ def test_import_get_releases(self): Crude. If only this test fails, consider adding a more specific test for the cause of the failure. """ - response = self.client.get('/releases') + response = self.client.get('/releases?limit=1') self.assert200(response) def test_import_param_platforms(self): diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..c90af6d --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,21 @@ +from __future__ import print_function +from unittest import TestCase +import orlo.util + +__author__ = 'alforbes' + + +class UtilTest(TestCase): + def test_str_to_bool_true(self): + """ + Test some values for True + """ + for v in ['true', 'TrUE', '1', 't', '99', 1, 99]: + self.assertIs(orlo.util.str_to_bool(v), True) + + def test_str_to_bool_false(self): + """ + Test some values for True + """ + for v in ['false', 'FaLsE', 'f', '0', '-99', 0, -99]: + self.assertIs(orlo.util.str_to_bool(v), False) From e486a10cc8fe7c41b395acb3ab8210833f81cbcd Mon Sep 17 00:00:00 2001 From: Alex Forbes Date: Fri, 29 Jan 2016 11:30:49 +0000 Subject: [PATCH 08/14] Bump vagrant box cpus to 2 --- Vagrantfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Vagrantfile b/Vagrantfile index a7f3b7c..f4f6433 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -43,13 +43,14 @@ Vagrant.configure(2) do |config| # backing providers for Vagrant. These expose provider-specific options. # Example for VirtualBox: # - # config.vm.provider "virtualbox" do |vb| + config.vm.provider "virtualbox" do |vb| # # Display the VirtualBox GUI when booting the machine # vb.gui = true # # # Customize the amount of memory on the VM: # vb.memory = "1024" - # end + vb.cpus = "2" + end # # View the documentation for the provider you are using for more # information on available options. From 4d1c071833091b6ac6000d0c858e87183fd5880c Mon Sep 17 00:00:00 2001 From: Alex Forbes Date: Wed, 27 Jan 2016 12:41:53 +0000 Subject: [PATCH 09/14] Implement stats by date/time Move stats functions and tests to separate files --- orlo/cli.py | 6 +- orlo/queries.py | 125 ++---------------------------- orlo/route_api.py | 6 +- orlo/route_stats.py | 6 +- orlo/stats.py | 175 ++++++++++++++++++++++++++++++++++++++++++ setup.py | 2 +- tests/test_queries.py | 103 +------------------------ tests/test_stats.py | 111 +++++++++++++++++++++++++++ 8 files changed, 305 insertions(+), 229 deletions(-) create mode 100644 orlo/stats.py create mode 100644 tests/test_stats.py diff --git a/orlo/cli.py b/orlo/cli.py index ebe532e..cfcf600 100644 --- a/orlo/cli.py +++ b/orlo/cli.py @@ -19,8 +19,10 @@ def parse_args(): p_database = argparse.ArgumentParser(add_help=False) p_server = argparse.ArgumentParser(add_help=False) - p_server.add_argument('--host', '-H', dest='host', default='127.0.0.1', help="Address to listen on") - p_server.add_argument('--port', '-P', dest='port', type=int, default=5000, help="Port to listen on") + p_server.add_argument('--host', '-H', dest='host', default='127.0.0.1', + help="Address to listen on") + p_server.add_argument('--port', '-P', dest='port', type=int, default=5000, + help="Port to listen on") subparsers = parser.add_subparsers(dest='action') sp_config = subparsers.add_parser( diff --git a/orlo/queries.py b/orlo/queries.py index 44963fc..3b87e26 100644 --- a/orlo/queries.py +++ b/orlo/queries.py @@ -14,7 +14,7 @@ """ -def _filter_release_status(query, status): +def filter_release_status(query, status): """ Filter the given query by the given release status @@ -42,7 +42,7 @@ def _filter_release_status(query, status): return query -def _filter_release_rollback(query, rollback): +def filter_release_rollback(query, rollback): """ Filter the given query by whether the releases are rollbacks or not @@ -83,10 +83,10 @@ def apply_filters(query, args): # special logic for these ones, as they are release attributes that # are JIT calculated from package attributes if field == 'status': - query = _filter_release_status(query, value) + query = filter_release_status(query, value) continue if field == 'rollback': - query = _filter_release_rollback(query, value) + query = filter_release_rollback(query, value) continue if field.startswith('package_'): @@ -424,10 +424,10 @@ def count_releases(user=None, package=None, team=None, platform=None, status=Non query = query.filter(Release.stime <= ftime) if rollback is not None: - query = _filter_release_rollback(query, rollback) + query = filter_release_rollback(query, rollback) if status: - query = _filter_release_status(query, status) + query = filter_release_status(query, status) return query @@ -504,116 +504,3 @@ def platform_list(): return query -def stats_release_time(unit, summarize_by_unit=False, **kwargs): - """ - Return stats by time from the given arguments - - Functions in this file usually return a query object, but here we are - returning the result, as there are several queries in play. - - :param summarize_by_unit: Passed to add_release_by_time_to_dict() - :param unit: Passed to add_release_by_time_to_dict() - """ - - root_query = db.session.query(Release.id, Release.stime).join(Package) - root_query = apply_filters(root_query, kwargs) - - # Build queries for the individual stats - q_normal_successful = _filter_release_status( - _filter_release_rollback(root_query, rollback=False), 'SUCCESSFUL' - ) - q_normal_failed = _filter_release_status( - _filter_release_rollback(root_query, rollback=False), 'FAILED' - ) - q_rollback_successful = _filter_release_status( - _filter_release_rollback(root_query, rollback=True), 'SUCCESSFUL' - ) - q_rollback_failed = _filter_release_status( - _filter_release_rollback(root_query, rollback=True), 'FAILED' - ) - - output_dict = OrderedDict() - - add_releases_by_time_to_dict( - q_normal_successful, output_dict, ('normal', 'successful'), unit, summarize_by_unit) - add_releases_by_time_to_dict( - q_normal_failed, output_dict, ('normal', 'failed'), unit, summarize_by_unit) - add_releases_by_time_to_dict( - q_rollback_successful, output_dict, ('rollback', 'successful'), unit, - summarize_by_unit) - add_releases_by_time_to_dict( - q_rollback_failed, output_dict, ('rollback', 'failed'), unit, summarize_by_unit) - - return output_dict - - -def add_releases_by_time_to_dict(query, releases_dict, t_category, unit='month', - summarize_by_unit=False): - """ - Take a query and add each of its releases to a dictionary, broken down by time - - :param dict releases_dict: Dict to add to - :param tuple t_category: tuple of headings, i.e. (, ) - :param query query: Query object to retrieve releases from - :param string unit: Can be 'iso', 'hour', 'day', 'week', 'month', 'year', - :param boolean summarize_by_unit: Only break down releases by the given unit, i.e. only one - layer deep - :return: - """ - - for release in query: - if summarize_by_unit: - tree_args = [str(getattr(release.stime, unit))] - else: - if unit == 'year': - tree_args = [str(release.stime.year)] - elif unit == 'month': - tree_args = [str(release.stime.year), str(release.stime.month)] - elif unit == 'week': - # First two args of isocalendar(), year and week - tree_args = [str(i) for i in release.stime.isocalendar()][0:2] - elif unit == 'iso': - tree_args = [str(i) for i in release.stime.isocalendar()] - elif unit == 'day': - tree_args = [str(release.stime.year), str(release.stime.month), - str(release.stime.day)] - elif unit == 'hour': - tree_args = [str(release.stime.year), str(release.stime.month), - str(release.stime.day), str(release.stime.hour)] - else: - raise InvalidUsage( - 'Invalid unit "{}" specified for release breakdown'.format( - unit)) - # Append categories - print(tree_args) - tree_args += t_category - append_tree_recursive(releases_dict, tree_args[0], tree_args) - - -def append_tree_recursive(tree, parent, nodes): - """ - Recursively place the nodes under each other - - :param dict tree: The dictionary we are operating on - :param parent: The parent for this node - :param nodes: The list of nodes - :return: - """ - print('Called recursive function with args:\n{}, {}, {}'.format( - str(tree), str(parent), str(nodes))) - try: - # Get the child, one after the parent - child = nodes[nodes.index(parent) + 1] - except IndexError: - # Must be at end - if parent in tree: - tree[parent] += 1 - else: - tree[parent] = 1 - return tree - - # Otherwise recurse again - if parent not in tree: - tree[parent] = {} - # Child becomes the parent - append_tree_recursive(tree[parent], child, nodes) diff --git a/orlo/route_api.py b/orlo/route_api.py index cba74e8..e056167 100644 --- a/orlo/route_api.py +++ b/orlo/route_api.py @@ -271,7 +271,7 @@ def get_releases(release_id=None): """ - booleans = ('package_rollback', ) + booleans = ('rollback', 'package_rollback', ) if release_id: # Simple query = db.session.query(Release).filter(Release.id == release_id) @@ -282,11 +282,11 @@ def get_releases(release_id=None): else: # Bit more complex # Flatten args, as the ImmutableDict puts some values in a list when expanded args = {} - for k, v in request.args.items(): + for k in request.args.keys(): if k in booleans: args[k] = str_to_bool(request.args.get(k)) else: - args[k] = v + args[k] = request.args.get(k) query = queries.releases(**args) return Response(stream_json_list('releases', query), content_type='application/json') diff --git a/orlo/route_stats.py b/orlo/route_stats.py index 329869c..8e04308 100644 --- a/orlo/route_stats.py +++ b/orlo/route_stats.py @@ -1,6 +1,8 @@ from __future__ import print_function import arrow from flask import request, jsonify + +import stats from orlo import app from orlo.exceptions import InvalidUsage import orlo.queries as queries @@ -127,7 +129,7 @@ def build_all_stats_dict(stime=None, ftime=None): @app.route('/stats') -def stats(): +def stats_(): """ Return dictionary of global stats @@ -323,7 +325,7 @@ def stats_by_date(): summarize_by_unit = True # Returns releases and their time by rollback and status - release_stats = queries.stats_release_time(unit, summarize_by_unit, **filters) + release_stats = stats.releases_by_time(unit, summarize_by_unit, **filters) return jsonify(release_stats) diff --git a/orlo/stats.py b/orlo/stats.py new file mode 100644 index 0000000..b5aed7e --- /dev/null +++ b/orlo/stats.py @@ -0,0 +1,175 @@ +from __future__ import print_function +from orlo.queries import apply_filters, filter_release_rollback, filter_release_status +from orlo import app +from orlo.orm import db, Release, Platform, Package, release_platform +from orlo.exceptions import OrloError, InvalidUsage +from collections import OrderedDict + +__author__ = 'alforbes' + +""" +Functions related to building statistics +""" + + +def releases_by_time(unit, summarize_by_unit=False, **kwargs): + """ + Return stats by time from the given arguments + + Functions in this file usually return a query object, but here we are + returning the result, as there are several queries in play. + + :param summarize_by_unit: Passed to add_release_by_time_to_dict() + :param unit: Passed to add_release_by_time_to_dict() + """ + + root_query = db.session.query(Release.id, Release.stime).join(Package) + root_query = apply_filters(root_query, kwargs) + + # Build queries for the individual stats + q_normal_successful = filter_release_status( + filter_release_rollback(root_query, rollback=False), 'SUCCESSFUL' + ) + q_normal_failed = filter_release_status( + filter_release_rollback(root_query, rollback=False), 'FAILED' + ) + q_rollback_successful = filter_release_status( + filter_release_rollback(root_query, rollback=True), 'SUCCESSFUL' + ) + q_rollback_failed = filter_release_status( + filter_release_rollback(root_query, rollback=True), 'FAILED' + ) + + output_dict = OrderedDict() + + add_releases_by_time_to_dict( + q_normal_successful, output_dict, ('normal', 'successful'), unit, summarize_by_unit) + add_releases_by_time_to_dict( + q_normal_failed, output_dict, ('normal', 'failed'), unit, summarize_by_unit) + add_releases_by_time_to_dict( + q_rollback_successful, output_dict, ('rollback', 'successful'), unit, + summarize_by_unit) + add_releases_by_time_to_dict( + q_rollback_failed, output_dict, ('rollback', 'failed'), unit, summarize_by_unit) + + return output_dict + + +def package_by_time(unit, summarize_by_unit=False, **kwargs): + """ + Count packages from the filters given + + Functions in this file usually return a query object, but here we are + returning the result, as there are several queries in play. + + :param summarize_by_unit: Passed to add_release_by_time_to_dict() + :param unit: Passed to add_release_by_time_to_dict() + """ + + root_query = db.session.query(Package.id, Package.stime).join(Release) + root_query = apply_filters(root_query, kwargs) + + # Build queries for the individual stats + q_normal_successful = filter_release_status( + filter_release_rollback(root_query, rollback=False), 'SUCCESSFUL' + ) + q_normal_failed = filter_release_status( + filter_release_rollback(root_query, rollback=False), 'FAILED' + ) + q_rollback_successful = filter_release_status( + filter_release_rollback(root_query, rollback=True), 'SUCCESSFUL' + ) + q_rollback_failed = filter_release_status( + filter_release_rollback(root_query, rollback=True), 'FAILED' + ) + + output_dict = OrderedDict() + + add_releases_by_time_to_dict( + q_normal_successful, output_dict, ('normal', 'successful'), unit, summarize_by_unit) + add_releases_by_time_to_dict( + q_normal_failed, output_dict, ('normal', 'failed'), unit, summarize_by_unit) + add_releases_by_time_to_dict( + q_rollback_successful, output_dict, ('rollback', 'successful'), unit, + summarize_by_unit) + add_releases_by_time_to_dict( + q_rollback_failed, output_dict, ('rollback', 'failed'), unit, summarize_by_unit) + + return output_dict + + +# TODO add stats_user_time and stats_team_time +# or generalise a stats_time function + + +def add_releases_by_time_to_dict(query, releases_dict, t_category, unit='month', + summarize_by_unit=False): + """ + Take a query and add each of its releases to a dictionary, broken down by time + + :param dict releases_dict: Dict to add to + :param tuple t_category: tuple of headings, i.e. (, ) + :param query query: Query object to retrieve releases from + :param string unit: Can be 'iso', 'hour', 'day', 'week', 'month', 'year', + :param boolean summarize_by_unit: Only break down releases by the given unit, i.e. only one + layer deep. For example, if "year" is the unit, we group all releases under the year + and do not add month etc underneath. + :return: + + **Note**: this can also be use for packages + """ + + for release in query: + if summarize_by_unit: + tree_args = [str(getattr(release.stime, unit))] + else: + if unit == 'year': + tree_args = [str(release.stime.year)] + elif unit == 'month': + tree_args = [str(release.stime.year), str(release.stime.month)] + elif unit == 'week': + # First two args of isocalendar(), year and week + tree_args = [str(i) for i in release.stime.isocalendar()][0:2] + elif unit == 'iso': + tree_args = [str(i) for i in release.stime.isocalendar()] + elif unit == 'day': + tree_args = [str(release.stime.year), str(release.stime.month), + str(release.stime.day)] + elif unit == 'hour': + tree_args = [str(release.stime.year), str(release.stime.month), + str(release.stime.day), str(release.stime.hour)] + else: + raise InvalidUsage( + 'Invalid unit "{}" specified for release breakdown'.format(unit)) + # Append categories + tree_args += t_category + append_tree_recursive(releases_dict, tree_args[0], tree_args) + + +def append_tree_recursive(tree, parent, nodes): + """ + Recursively place the nodes under each other + + :param dict tree: The dictionary we are operating on + :param parent: The parent for this node + :param nodes: The list of nodes + :return: + """ + app.logger.debug('Called recursive function with args:\n{}, {}, {}'.format( + str(tree), str(parent), str(nodes))) + try: + # Get the child, one after the parent + child = nodes[nodes.index(parent) + 1] + except IndexError: + # Must be at end + if parent in tree: + tree[parent] += 1 + else: + tree[parent] = 1 + return tree + + # Otherwise recurse again + if parent not in tree: + tree[parent] = {} + # Child becomes the parent + append_tree_recursive(tree[parent], child, nodes) diff --git a/setup.py b/setup.py index 7693996..5c93b2a 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ import multiprocessing # nopep8 -VERSION = '0.1.0-2' +VERSION = '0.1.0-4' version_file = open('./orlo/_version.py', 'w') version_file.write("__version__ = '{}'".format(VERSION)) version_file.close() diff --git a/tests/test_queries.py b/tests/test_queries.py index f3de4eb..db03c92 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -4,6 +4,7 @@ from tests.test_orm import OrloDbTest import orlo.queries import orlo.exceptions +import orlo.stats from time import sleep __author__ = 'alforbes' @@ -677,105 +678,3 @@ def test_releases_with_bad_offset(self): with self.assertRaises(orlo.exceptions.InvalidUsage): orlo.queries.releases(**args) - -class ReleaseTimeTest(OrloQueryTest): - """ - Test queries.stats_release_time - """ - ARGS = { - 'stime_gt': arrow.utcnow().replace(hours=-1), - 'stime_lt': arrow.utcnow().replace(hours=+1) - } - - def setUp(self): - super(OrloQueryTest, self).setUp() - - for r in range(0, 7): - self._create_finished_release() - - def test_append_tree_recursive(self): - """ - Test that append_tree_recursive returns a properly structured dictionary - """ - tree = {} - nodes = ['apple', 'orange'] - orlo.queries.append_tree_recursive(tree, nodes[0], nodes) - self.assertEqual(tree, {'apple': {'orange': 1}}) - - def test_append_tree_recursive_adds(self): - """ - Test that append_tree_recursive correctly adds one when called on the same path - """ - tree = {} - nodes = ['apple', 'orange'] - orlo.queries.append_tree_recursive(tree, nodes[0], nodes) - orlo.queries.append_tree_recursive(tree, nodes[0], nodes) - self.assertEqual(tree, {'apple': {'orange': 2}}) - - def test_release_time_month(self): - """ - Test queries.add_releases_by_time_to_dict by month - """ - result = orlo.queries.stats_release_time('month', **self.ARGS) - year = str(arrow.utcnow().year) - month = str(arrow.utcnow().month) - self.assertEqual(7, result[year][month]['normal']['successful']) - - def test_release_time_week(self): - """ - Test queries.add_releases_by_time_to_dict by week - """ - result = orlo.queries.stats_release_time('week', **self.ARGS) - year, week, day = arrow.utcnow().isocalendar() - self.assertEqual(7, result[str(year)][str(week)]['normal']['successful']) - - def test_release_time_year(self): - """ - Test queries.add_releases_by_time_to_dict by year - """ - result = orlo.queries.stats_release_time('year', **self.ARGS) - year = str(arrow.utcnow().year) - self.assertEqual(7, result[str(year)]['normal']['successful']) - - def test_release_time_day(self): - """ - Test queries.add_releases_by_time_to_dict by day - """ - result = orlo.queries.stats_release_time('day', **self.ARGS) - year = str(arrow.utcnow().year) - month = str(arrow.utcnow().month) - day = str(arrow.utcnow().day) - self.assertEqual( - 7, - result[year][month][day]['normal']['successful'], - ) - - def test_release_time_hour(self): - """ - Test queries.add_releases_by_time_to_dict by hour - """ - result = orlo.queries.stats_release_time('hour', **self.ARGS) - year = str(arrow.utcnow().year) - month = str(arrow.utcnow().month) - day = str(arrow.utcnow().day) - hour = str(arrow.utcnow().hour) - self.assertEqual( - 7, - result[year][month][day][hour]['normal']['successful'], - ) - - def test_release_time_with_only_this_unit(self): - """ - Test queries.add_releases_by_time_to_dict with only_this_unit - - Should break down by only the unit given - """ - result = orlo.queries.stats_release_time('hour', summarize_by_unit=True, **self.ARGS) - hour = str(arrow.utcnow().hour) - self.assertEqual( - 7, - result[hour]['normal']['successful'], - ) - - def test_release_time_with_unit_day(self): - pass \ No newline at end of file diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..41fdfab --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,111 @@ +from __future__ import print_function, unicode_literals +import arrow +import orlo.queries +import orlo.exceptions +import orlo.stats +from tests.test_orm import OrloDbTest + +__author__ = 'alforbes' + + +class ReleaseTimeTest(OrloDbTest): + """ + Test stats.release_time + """ + ARGS = { + 'stime_gt': arrow.utcnow().replace(hours=-1), + 'stime_lt': arrow.utcnow().replace(hours=+1) + } + + def setUp(self): + super(OrloDbTest, self).setUp() + + for r in range(0, 7): + self._create_finished_release() + + def test_append_tree_recursive(self): + """ + Test that append_tree_recursive returns a properly structured dictionary + """ + tree = {} + nodes = ['apple', 'orange'] + orlo.stats.append_tree_recursive(tree, nodes[0], nodes) + self.assertEqual(tree, {'apple': {'orange': 1}}) + + def test_append_tree_recursive_adds(self): + """ + Test that append_tree_recursive correctly adds one when called on the same path + """ + tree = {} + nodes = ['apple', 'orange'] + orlo.stats.append_tree_recursive(tree, nodes[0], nodes) + orlo.stats.append_tree_recursive(tree, nodes[0], nodes) + self.assertEqual(tree, {'apple': {'orange': 2}}) + + def test_release_time_month(self): + """ + Test queries.add_releases_by_time_to_dict by month + """ + result = orlo.stats.releases_by_time('month', **self.ARGS) + year = str(arrow.utcnow().year) + month = str(arrow.utcnow().month) + self.assertEqual(7, result[year][month]['normal']['successful']) + + def test_release_time_week(self): + """ + Test queries.add_releases_by_time_to_dict by week + """ + result = orlo.stats.releases_by_time('week', **self.ARGS) + year, week, day = arrow.utcnow().isocalendar() + self.assertEqual(7, result[str(year)][str(week)]['normal']['successful']) + + def test_release_time_year(self): + """ + Test queries.add_releases_by_time_to_dict by year + """ + result = orlo.stats.releases_by_time('year', **self.ARGS) + year = str(arrow.utcnow().year) + self.assertEqual(7, result[str(year)]['normal']['successful']) + + def test_release_time_day(self): + """ + Test queries.add_releases_by_time_to_dict by day + """ + result = orlo.stats.releases_by_time('day', **self.ARGS) + year = str(arrow.utcnow().year) + month = str(arrow.utcnow().month) + day = str(arrow.utcnow().day) + self.assertEqual( + 7, + result[year][month][day]['normal']['successful'], + ) + + def test_release_time_hour(self): + """ + Test queries.add_releases_by_time_to_dict by hour + """ + result = orlo.stats.releases_by_time('hour', **self.ARGS) + year = str(arrow.utcnow().year) + month = str(arrow.utcnow().month) + day = str(arrow.utcnow().day) + hour = str(arrow.utcnow().hour) + self.assertEqual( + 7, + result[year][month][day][hour]['normal']['successful'], + ) + + def test_release_time_with_only_this_unit(self): + """ + Test queries.add_releases_by_time_to_dict with only_this_unit + + Should break down by only the unit given + """ + result = orlo.stats.releases_by_time('hour', summarize_by_unit=True, **self.ARGS) + hour = str(arrow.utcnow().hour) + self.assertEqual( + 7, + result[hour]['normal']['successful'], + ) + + def test_release_time_with_unit_day(self): + pass From 48a6807833963e0410aff13f50f9279f3c6c73bb Mon Sep 17 00:00:00 2001 From: Alex Forbes Date: Fri, 29 Jan 2016 18:36:06 +0000 Subject: [PATCH 10/14] Fix query in release stats to group by release Previously, every package counted as a release. Also add more tests for route_stats, including tests against generated data. --- orlo/route_stats.py | 19 ++- orlo/stats.py | 124 ++++++++---------- orlo/util.py | 2 +- setup.py | 2 +- tests/test_route_stats.py | 59 ++++++++- tests/test_stats.py | 110 ++++++++++++++-- tests/test_stats_empirical.py | 238 ++++++++++++++++++++++++++++++++++ 7 files changed, 465 insertions(+), 89 deletions(-) create mode 100644 tests/test_stats_empirical.py diff --git a/orlo/route_stats.py b/orlo/route_stats.py index 8e04308..8d40230 100644 --- a/orlo/route_stats.py +++ b/orlo/route_stats.py @@ -145,7 +145,7 @@ def stats_(): stime = arrow.get(s_stime) if s_ftime: ftime = arrow.get(s_ftime) - except RuntimeError: # super-class to arrows ParserError, which is not importable + except RuntimeError: # super-class to arrow's ParserError, which is not importable raise InvalidUsage("A badly formatted datetime string was given") app.logger.debug("Building all_stats dict") @@ -303,11 +303,12 @@ def stats_package(package=None): return jsonify(package_stats) -@app.route('/stats/by_date') -def stats_by_date(): +@app.route('/stats/by_date/') +def stats_by_date(subject='release'): """ - Return release release_stats by date + Return stats by date + :param subject: Release or Package (default: release) :query string unit: Unit to group by, i.e. year, month, week, day, hour :query boolean summarize_by_unit: Don't build hierarchy, just summarize by the unit :return: @@ -320,12 +321,18 @@ def stats_by_date(): filters = dict((k, v) for k, v in request.args.items()) unit = filters.pop('unit', 'month') - summarize_by_unit = False if filters.pop('summarize_by_unit', False): summarize_by_unit = True + else: + summarize_by_unit = False # Returns releases and their time by rollback and status - release_stats = stats.releases_by_time(unit, summarize_by_unit, **filters) + if subject == 'release': + release_stats = stats.releases_by_time(unit, summarize_by_unit, **filters) + elif subject == 'package': + release_stats = stats.packages_by_time(unit, summarize_by_unit, **filters) + else: + raise InvalidUsage("subject must release or package, not '{}'".format()) return jsonify(release_stats) diff --git a/orlo/stats.py b/orlo/stats.py index b5aed7e..43c0450 100644 --- a/orlo/stats.py +++ b/orlo/stats.py @@ -16,96 +16,80 @@ def releases_by_time(unit, summarize_by_unit=False, **kwargs): """ Return stats by time from the given arguments - Functions in this file usually return a query object, but here we are - returning the result, as there are several queries in play. - :param summarize_by_unit: Passed to add_release_by_time_to_dict() :param unit: Passed to add_release_by_time_to_dict() """ - root_query = db.session.query(Release.id, Release.stime).join(Package) - root_query = apply_filters(root_query, kwargs) + query = db.session.query(Release.id, Release.stime).join(Package).group_by(Release) + query = apply_filters(query, kwargs) - # Build queries for the individual stats - q_normal_successful = filter_release_status( - filter_release_rollback(root_query, rollback=False), 'SUCCESSFUL' - ) - q_normal_failed = filter_release_status( - filter_release_rollback(root_query, rollback=False), 'FAILED' - ) - q_rollback_successful = filter_release_status( - filter_release_rollback(root_query, rollback=True), 'SUCCESSFUL' - ) - q_rollback_failed = filter_release_status( - filter_release_rollback(root_query, rollback=True), 'FAILED' - ) + return get_dict_of_objects_by_time(query, unit, summarize_by_unit) - output_dict = OrderedDict() - add_releases_by_time_to_dict( - q_normal_successful, output_dict, ('normal', 'successful'), unit, summarize_by_unit) - add_releases_by_time_to_dict( - q_normal_failed, output_dict, ('normal', 'failed'), unit, summarize_by_unit) - add_releases_by_time_to_dict( - q_rollback_successful, output_dict, ('rollback', 'successful'), unit, - summarize_by_unit) - add_releases_by_time_to_dict( - q_rollback_failed, output_dict, ('rollback', 'failed'), unit, summarize_by_unit) +def packages_by_time(unit, summarize_by_unit=False, **kwargs): + """ + Count packages by time from the filters given - return output_dict + :param summarize_by_unit: Passed to add_release_by_time_to_dict() + :param unit: Passed to add_release_by_time_to_dict() + """ + query = db.session.query(Package.id, Package.name, Package.stime).join(Release) + query = apply_filters(query, kwargs) -def package_by_time(unit, summarize_by_unit=False, **kwargs): - """ - Count packages from the filters given + return get_dict_of_objects_by_time(query, unit, summarize_by_unit) - Functions in this file usually return a query object, but here we are - returning the result, as there are several queries in play. - :param summarize_by_unit: Passed to add_release_by_time_to_dict() - :param unit: Passed to add_release_by_time_to_dict() +# TODO add stats_user_time and stats_team_time +# or generalise a stats_time function + + +def get_dict_of_objects_by_time(query, unit, summarize_by_unit=False): """ + Build a dictionary which summarises the objects in the query given - root_query = db.session.query(Package.id, Package.stime).join(Release) - root_query = apply_filters(root_query, kwargs) + :param query: + :param unit: + :param summarize_by_unit: + :return: + """ # Build queries for the individual stats q_normal_successful = filter_release_status( - filter_release_rollback(root_query, rollback=False), 'SUCCESSFUL' + filter_release_rollback(query, rollback=False), 'SUCCESSFUL' ) q_normal_failed = filter_release_status( - filter_release_rollback(root_query, rollback=False), 'FAILED' + filter_release_rollback(query, rollback=False), 'FAILED' ) q_rollback_successful = filter_release_status( - filter_release_rollback(root_query, rollback=True), 'SUCCESSFUL' + filter_release_rollback(query, rollback=True), 'SUCCESSFUL' ) q_rollback_failed = filter_release_status( - filter_release_rollback(root_query, rollback=True), 'FAILED' + filter_release_rollback(query, rollback=True), 'FAILED' ) output_dict = OrderedDict() - add_releases_by_time_to_dict( + add_objects_by_time_to_dict( q_normal_successful, output_dict, ('normal', 'successful'), unit, summarize_by_unit) - add_releases_by_time_to_dict( + add_objects_by_time_to_dict( q_normal_failed, output_dict, ('normal', 'failed'), unit, summarize_by_unit) - add_releases_by_time_to_dict( + add_objects_by_time_to_dict( q_rollback_successful, output_dict, ('rollback', 'successful'), unit, summarize_by_unit) - add_releases_by_time_to_dict( + add_objects_by_time_to_dict( q_rollback_failed, output_dict, ('rollback', 'failed'), unit, summarize_by_unit) return output_dict -# TODO add stats_user_time and stats_team_time -# or generalise a stats_time function - - -def add_releases_by_time_to_dict(query, releases_dict, t_category, unit='month', - summarize_by_unit=False): +def add_objects_by_time_to_dict(query, releases_dict, t_category, unit='month', + summarize_by_unit=False): """ - Take a query and add each of its releases to a dictionary, broken down by time + Take a query and add each of its objects to a dictionary, broken down by time + + If the query given has a 'name' column, that will be included in the dictionary path + above the categories (t_category). :param dict releases_dict: Dict to add to :param tuple t_category: tuple of headings, i.e. (, ) @@ -118,48 +102,54 @@ def add_releases_by_time_to_dict(query, releases_dict, t_category, unit='month', **Note**: this can also be use for packages """ - - for release in query: + app.logger.debug("Entered add_objects_by_time_to_dict") + for object_ in query: if summarize_by_unit: - tree_args = [str(getattr(release.stime, unit))] + tree_args = [str(getattr(object_.stime, unit))] else: if unit == 'year': - tree_args = [str(release.stime.year)] + tree_args = [str(object_.stime.year)] elif unit == 'month': - tree_args = [str(release.stime.year), str(release.stime.month)] + tree_args = [str(object_.stime.year), str(object_.stime.month)] elif unit == 'week': # First two args of isocalendar(), year and week - tree_args = [str(i) for i in release.stime.isocalendar()][0:2] + tree_args = [str(i) for i in object_.stime.isocalendar()][0:2] elif unit == 'iso': - tree_args = [str(i) for i in release.stime.isocalendar()] + tree_args = [str(i) for i in object_.stime.isocalendar()] elif unit == 'day': - tree_args = [str(release.stime.year), str(release.stime.month), - str(release.stime.day)] + tree_args = [str(object_.stime.year), str(object_.stime.month), + str(object_.stime.day)] elif unit == 'hour': - tree_args = [str(release.stime.year), str(release.stime.month), - str(release.stime.day), str(release.stime.hour)] + tree_args = [str(object_.stime.year), str(object_.stime.month), + str(object_.stime.day), str(object_.stime.hour)] else: raise InvalidUsage( 'Invalid unit "{}" specified for release breakdown'.format(unit)) + if hasattr(object_, 'name'): + # + tree_args.append(object_.name) # Append categories tree_args += t_category append_tree_recursive(releases_dict, tree_args[0], tree_args) -def append_tree_recursive(tree, parent, nodes): +def append_tree_recursive(tree, parent, nodes, node_index=0): """ Recursively place the nodes under each other :param dict tree: The dictionary we are operating on :param parent: The parent for this node :param nodes: The list of nodes + :param node_index: The position in the list list we are up to :return: """ app.logger.debug('Called recursive function with args:\n{}, {}, {}'.format( str(tree), str(parent), str(nodes))) + + child_index = node_index + 1 try: # Get the child, one after the parent - child = nodes[nodes.index(parent) + 1] + child = nodes[child_index] except IndexError: # Must be at end if parent in tree: @@ -172,4 +162,4 @@ def append_tree_recursive(tree, parent, nodes): if parent not in tree: tree[parent] = {} # Child becomes the parent - append_tree_recursive(tree[parent], child, nodes) + append_tree_recursive(tree[parent], child, nodes, node_index=child_index) diff --git a/orlo/util.py b/orlo/util.py index f0d4278..7d478c9 100644 --- a/orlo/util.py +++ b/orlo/util.py @@ -22,7 +22,7 @@ def append_or_create_platforms(request_platforms): try: query = db.session.query(Platform).filter(Platform.name == p) platform = query.one() - app.logger.debug("Found platform {}".format(platform.name)) + # app.logger.debug("Found platform {}".format(platform.name)) except exc.NoResultFound: app.logger.info("Creating platform {}".format(p)) platform = Platform(p) diff --git a/setup.py b/setup.py index 5c93b2a..17366b1 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ import multiprocessing # nopep8 -VERSION = '0.1.0-4' +VERSION = '0.1.0-7' version_file = open('./orlo/_version.py', 'w') version_file.write("__version__ = '{}'".format(VERSION)) version_file.close() diff --git a/tests/test_route_stats.py b/tests/test_route_stats.py index 1ee54ec..23c2642 100644 --- a/tests/test_route_stats.py +++ b/tests/test_route_stats.py @@ -129,8 +129,11 @@ def test_stats_package_returns_dict_with_package(self): self.assertIsInstance(response.json, dict) -class TimeBasedStatsTest(StatsTest): - ENDPOINT = '/stats/by_date' +class StatsByDateReleaseTest(StatsTest): + """ + Testing the "by_date" urls + """ + ENDPOINT = '/stats/by_date/release' def test_result_includes_normals(self): unittest.skip("Not suitable test for this endpoint") @@ -176,3 +179,55 @@ def test_stats_by_date_with_summarize_by_unit_day(self): """ response = self.client.get(self.ENDPOINT + '?unit=day&summarize_by_unit=1') self.assert200(response) + + def test_stats_by_date_with_platform_filter(self): + """ + Test /stats/by_date with a platform filter + """ + year = str(arrow.utcnow().year) + response = self.client.get(self.ENDPOINT + '?platform=test_platform') + self.assert200(response) + self.assertIn(year, response.json) + + def test_stats_by_date_with_platform_filter_negative(self): + """ + Test /stats/by_date with a bad platform filter returns nothing + """ + response = self.client.get(self.ENDPOINT + '?platform=bad_platform_foo') + self.assert200(response) + self.assertEqual({}, response.json) + + +class StatsByDatePackageTest(OrloDbTest): + """ + Testing the "by_date" urls + """ + ENDPOINT = '/stats/by_date/package' + + def setUp(self): + super(OrloDbTest, self).setUp() + for r in range(0, 3): + self._create_finished_release() + + def test_endpoint_200(self): + """ + Test self.ENDPOINT returns 200 + """ + response = self.client.get(self.ENDPOINT) + self.assert200(response) + + def test_endpoint_returns_dict(self): + """ + Test self.ENDPOINT returns a dictionary + """ + response = self.client.get(self.ENDPOINT) + self.assertIsInstance(response.json, dict) + + def test_package_name_in_dict(self): + """ + Test the package name is in the returned json + """ + response = self.client.get(self.ENDPOINT) + year = str(arrow.utcnow().year) + month = str(arrow.utcnow().month) + self.assertIn('test-package', response.json[year][month]) diff --git a/tests/test_stats.py b/tests/test_stats.py index 41fdfab..cf03a8e 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -8,9 +8,9 @@ __author__ = 'alforbes' -class ReleaseTimeTest(OrloDbTest): +class OrloStatsTest(OrloDbTest): """ - Test stats.release_time + Parent class for the stats tests """ ARGS = { 'stime_gt': arrow.utcnow().replace(hours=-1), @@ -23,28 +23,40 @@ def setUp(self): for r in range(0, 7): self._create_finished_release() + +class GeneralTest(OrloStatsTest): + """ + Testing the shared stats functions + """ + def test_append_tree_recursive(self): """ Test that append_tree_recursive returns a properly structured dictionary """ tree = {} - nodes = ['apple', 'orange'] + nodes = ['parent', 'child'] orlo.stats.append_tree_recursive(tree, nodes[0], nodes) - self.assertEqual(tree, {'apple': {'orange': 1}}) + self.assertEqual(tree, {'parent': {'child': 1}}) def test_append_tree_recursive_adds(self): """ Test that append_tree_recursive correctly adds one when called on the same path """ tree = {} - nodes = ['apple', 'orange'] + nodes = ['parent', 'child'] orlo.stats.append_tree_recursive(tree, nodes[0], nodes) orlo.stats.append_tree_recursive(tree, nodes[0], nodes) - self.assertEqual(tree, {'apple': {'orange': 2}}) + self.assertEqual(tree, {'parent': {'child': 2}}) + + +class ReleaseTimeTest(OrloStatsTest): + """ + Test stats.releases_by_time + """ def test_release_time_month(self): """ - Test queries.add_releases_by_time_to_dict by month + Test stats.add_objects_by_time_to_dict by month """ result = orlo.stats.releases_by_time('month', **self.ARGS) year = str(arrow.utcnow().year) @@ -53,7 +65,7 @@ def test_release_time_month(self): def test_release_time_week(self): """ - Test queries.add_releases_by_time_to_dict by week + Test stats.add_objects_by_time_to_dict by week """ result = orlo.stats.releases_by_time('week', **self.ARGS) year, week, day = arrow.utcnow().isocalendar() @@ -61,7 +73,7 @@ def test_release_time_week(self): def test_release_time_year(self): """ - Test queries.add_releases_by_time_to_dict by year + Test stats.add_objects_by_time_to_dict by year """ result = orlo.stats.releases_by_time('year', **self.ARGS) year = str(arrow.utcnow().year) @@ -69,7 +81,7 @@ def test_release_time_year(self): def test_release_time_day(self): """ - Test queries.add_releases_by_time_to_dict by day + Test stats.add_objects_by_time_to_dict by day """ result = orlo.stats.releases_by_time('day', **self.ARGS) year = str(arrow.utcnow().year) @@ -82,7 +94,7 @@ def test_release_time_day(self): def test_release_time_hour(self): """ - Test queries.add_releases_by_time_to_dict by hour + Test stats.add_objects_by_time_to_dict by hour """ result = orlo.stats.releases_by_time('hour', **self.ARGS) year = str(arrow.utcnow().year) @@ -96,7 +108,7 @@ def test_release_time_hour(self): def test_release_time_with_only_this_unit(self): """ - Test queries.add_releases_by_time_to_dict with only_this_unit + Test stats.add_objects_by_time_to_dict with only_this_unit Should break down by only the unit given """ @@ -109,3 +121,77 @@ def test_release_time_with_only_this_unit(self): def test_release_time_with_unit_day(self): pass + + +class PackageTimeTest(OrloStatsTest): + """ + Test stats.packages_by_time + """ + def test_package_time_month(self): + """ + Test stats.add_objects_by_time_to_dict by month + """ + result = orlo.stats.packages_by_time('month', **self.ARGS) + year = str(arrow.utcnow().year) + month = str(arrow.utcnow().month) + self.assertEqual(7, result[year][month]['test-package']['normal']['successful']) + print(result) + + def test_package_time_week(self): + """ + Test stats.add_objects_by_time_to_dict by week + """ + result = orlo.stats.packages_by_time('week', **self.ARGS) + year, week, day = arrow.utcnow().isocalendar() + self.assertEqual(7, result[str(year)][str(week)]['test-package']['normal']['successful']) + + def test_package_time_year(self): + """ + Test stats.add_objects_by_time_to_dict by year + """ + result = orlo.stats.packages_by_time('year', **self.ARGS) + year = str(arrow.utcnow().year) + self.assertEqual(7, result[str(year)]['test-package']['normal']['successful']) + + def test_package_time_day(self): + """ + Test stats.add_objects_by_time_to_dict by day + """ + result = orlo.stats.packages_by_time('day', **self.ARGS) + year = str(arrow.utcnow().year) + month = str(arrow.utcnow().month) + day = str(arrow.utcnow().day) + self.assertEqual( + 7, + result[year][month][day]['test-package']['normal']['successful'], + ) + + def test_package_time_hour(self): + """ + Test stats.add_objects_by_time_to_dict by hour + """ + result = orlo.stats.packages_by_time('hour', **self.ARGS) + year = str(arrow.utcnow().year) + month = str(arrow.utcnow().month) + day = str(arrow.utcnow().day) + hour = str(arrow.utcnow().hour) + self.assertEqual( + 7, + result[year][month][day][hour]['test-package']['normal']['successful'], + ) + + def test_package_time_with_only_this_unit(self): + """ + Test stats.add_objects_by_time_to_dict with only_this_unit + + Should break down by only the unit given + """ + result = orlo.stats.packages_by_time('hour', summarize_by_unit=True, **self.ARGS) + hour = str(arrow.utcnow().hour) + self.assertEqual( + 7, + result[hour]['test-package']['normal']['successful'], + ) + + def test_package_time_with_unit_day(self): + pass diff --git a/tests/test_stats_empirical.py b/tests/test_stats_empirical.py new file mode 100644 index 0000000..f6cfc01 --- /dev/null +++ b/tests/test_stats_empirical.py @@ -0,0 +1,238 @@ +from __future__ import print_function +from datetime import date, timedelta, datetime + +from orlo.util import append_or_create_platforms + +from tests.test_orm import OrloDbTest +from orlo.orm import db, Release, Package + +__author__ = 'alforbes' + +""" +This tests the stats functions by creating a known dataset, +making the results predictable +""" + + +def date_range(start, end): + # Courtesy of http://stackoverflow.com/questions/1060279 + for n in range(int((end - start).days)): + yield start + timedelta(n) + + +class StatsEmpiricalTest(OrloDbTest): + """ + Test release stats by creating a known data set + + Changing the dates or number of releases may impact tests + """ + + # Create releases for two days which span a year + start_date = datetime(2015, 12, 31) + end_date = datetime(2016, 1, 2) + + # Which means results for 2015-12-31 and 2016-01-01 should match below: + normal_successful_per_day = 2 + normal_failed_per_day = 1 + rollback_successful_per_day = 1 + rollback_failed_per_day = 1 + + package_list = ['p1', 'p2'] + + time_cursor = start_date + + def setUp(self): + self.releases = 0 + super(OrloDbTest, self).setUp() + + for day in date_range(self.start_date, self.end_date): + if day.weekday() > 5: + # weekend! don't release + continue + + # starting at 9am... + self.time_cursor = day + timedelta(hours=9) + + # do $per_day releases... + for i in range(0, self.normal_successful_per_day): + self.create_release(True, True) + for i in range(0, self.normal_failed_per_day): + self.create_release(True, False) + for i in range(0, self.rollback_successful_per_day): + self.create_release(False, True) + for i in range(0, self.rollback_failed_per_day): + self.create_release(False, False) + + db.session.commit() + print("Total releases created: {}".format(self.releases)) + + def create_release(self, normal, successful, user='test_user', team='test_team', + platform='test_platform'): + release = Release( + platforms=append_or_create_platforms([platform]), + user=user, + team=team, + # references=list_to_string(['test_reference']) + ) + release.stime = self.time_cursor + db.session.add(release) + + for package in self.package_list: + package = Package( + release_id=release.id, + name=package, + version='0.0.0' + ) + # which take 10 mins each... + package.stime = self.time_cursor + package.ftime = self.time_cursor = self.time_cursor + timedelta(minutes=10) + if normal: + package.rollback = False + else: + package.rollback = True + if successful: + package.status = 'SUCCESSFUL' + else: + package.status = 'FAILED' + db.session.add(package) + + release.ftime = self.time_cursor + release.duration = release.ftime - release.stime + self.releases += 1 + + def test_stats(self): + """ + Test our data against /stats + """ + response = self.client.get('/stats').json + total = response['global']['releases']['total'] + self.assertEqual(total['successful'] + total['failed'], self.releases) + + def test_stats_user(self): + """ + Test /stats/user with data + """ + # Add a release with a different user + self.create_release(False, False, user='bad_user') + + response = self.client.get('/stats/user/test_user').json + total = response['test_user']['releases']['total'] + self.assertEqual(total['successful'] + total['failed'], self.releases - 1) + + def test_stats_team(self): + """ + Test /stats/team with data + """ + # Add a release with a different team + self.create_release(False, False, team='bad_team') + + response = self.client.get('/stats/team/test_team').json + total = response['test_team']['releases']['total'] + self.assertEqual(total['successful'] + total['failed'], self.releases - 1) + + def test_stats_platform(self): + """ + Test /stats/platform with data + """ + # Add a release with a different team + self.create_release(False, False, platform='bad_platform') + + response = self.client.get('/stats/platform/test_platform').json + total = response['test_platform']['releases']['total'] + self.assertEqual(total['successful'] + total['failed'], self.releases - 1) + + def test_stats_package(self): + """ + Test /stats/package with data + """ + # All packages are in every release here, could be better tested + response = self.client.get('/stats/package/p1').json + total = response['p1']['releases']['total'] + self.assertEqual(total['successful'] + total['failed'], self.releases) + + def test_stats_by_date_release(self): + """ + Tests /stats/by_date/release + """ + s_year = self.start_date.year + s_month = self.start_date.month + response = self.client.get('/stats/by_date/release') + + self.assertEqual( + response.json[str(s_year)][str(s_month)]['normal']['successful'], + self.normal_successful_per_day, + ) + self.assertEqual( + response.json[str(s_year)][str(s_month)]['normal']['successful'], + self.normal_successful_per_day, + ) + self.assertEqual( + response.json[str(s_year + 1)]['1']['rollback']['failed'], + self.rollback_failed_per_day + ) + + def test_stats_by_date_package(self): + """ + Tests /stats/by_date/package + """ + s_year = self.start_date.year + s_month = self.start_date.month + response = self.client.get('/stats/by_date/package') + + # Assumes p1 is in every release + self.assertEqual( + response.json[str(s_year)][str(s_month)]['p1']['normal']['successful'], + self.normal_successful_per_day, + ) + self.assertEqual( + response.json[str(s_year)][str(s_month)]['p2']['normal']['successful'], + self.normal_successful_per_day, + ) + self.assertEqual( + response.json[str(s_year + 1)]['1']['p1']['rollback']['failed'], + self.rollback_failed_per_day + ) + + def test_stats_by_date_release_with_unit_day(self): + """ + Tests /stats/by_date/release + """ + s_year = self.start_date.year + s_month = self.start_date.month + response = self.client.get('/stats/by_date/release?unit=day') + + print(response.data) + self.assertEqual( + response.json[str(s_year)][str(s_month)]['31']['normal']['successful'], + self.normal_successful_per_day, + ) + self.assertEqual( + response.json[str(s_year)][str(s_month)]['31']['normal']['successful'], + self.normal_successful_per_day, + ) + self.assertEqual( + response.json[str(s_year + 1)]['1']['1']['rollback']['failed'], + self.rollback_failed_per_day + ) + + def test_stats_by_date_package_with_unit_day(self): + """ + Tests /stats/by_date/package + """ + s_year = self.start_date.year + s_month = self.start_date.month + response = self.client.get('/stats/by_date/package?unit=day') + + # Assumes p1 is in every release + self.assertEqual( + response.json[str(s_year)][str(s_month)]['31']['p1']['normal']['successful'], + self.normal_successful_per_day, + ) + self.assertEqual( + response.json[str(s_year)][str(s_month)]['31']['p2']['normal']['successful'], + self.normal_successful_per_day, + ) + self.assertEqual( + response.json[str(s_year + 1)]['1']['1']['p1']['rollback']['failed'], + self.rollback_failed_per_day + ) From 5b907c1bcf1ef3da0cbbc0f4aa615603280be1e6 Mon Sep 17 00:00:00 2001 From: Alex Forbes Date: Mon, 1 Feb 2016 14:47:46 +0000 Subject: [PATCH 11/14] Add boolean True to acceptable success values --- orlo/route_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orlo/route_api.py b/orlo/route_api.py index e056167..18c2e3a 100644 --- a/orlo/route_api.py +++ b/orlo/route_api.py @@ -195,7 +195,7 @@ def post_packages_stop(release_id, package_id): :param string release_id: Release UUID """ validate_request_json(request) - success = request.json.get('success') in ['True', 'true', '1'] + success = request.json.get('success') in [True, 'True', 'true', '1'] package = fetch_package(release_id, package_id) app.logger.info("Package stop, release {}, package {}, success {}".format( From e2d5280816974b26bfbd7d8e8d04bd2d9f3e4fd0 Mon Sep 17 00:00:00 2001 From: Alex Forbes Date: Mon, 1 Feb 2016 18:14:17 +0000 Subject: [PATCH 12/14] Bump version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 17366b1..b8ca83c 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ import multiprocessing # nopep8 -VERSION = '0.1.0-7' +VERSION = '0.1.1' version_file = open('./orlo/_version.py', 'w') version_file.write("__version__ = '{}'".format(VERSION)) version_file.close() From 3a515546268dce4329bbe3b33b86e6da0703d027 Mon Sep 17 00:00:00 2001 From: Alex Forbes Date: Mon, 1 Feb 2016 19:08:09 +0000 Subject: [PATCH 13/14] Install dh-virtualenv from ppa in Vagrantfile --- Vagrantfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Vagrantfile b/Vagrantfile index f4f6433..97c9f19 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -78,6 +78,9 @@ Vagrant.configure(2) do |config| # Build tools sudo apt-get -y install build-essential git-buildpackage debhelper python-dev dh-systemd + wget -P /tmp/ \ + 'https://launchpad.net/ubuntu/+archive/primary/+files/dh-virtualenv_0.11-1_all.deb' + dpkg -i /tmp/dh-virtualenv_0.11-1_all.deb sudo pip install --upgrade pip sudo pip install sphinx sphinxcontrib-httpdomain From d7ee06a30096b746cd2b34c7484a7ff597618b3c Mon Sep 17 00:00:00 2001 From: Alex Forbes Date: Mon, 1 Feb 2016 19:20:14 +0000 Subject: [PATCH 14/14] Add debian changelog for 0.1.1 Also bump setup.py build due to failed pypi upload --- debian/changelog | 27 +++++++++++++++++++++++++++ setup.py | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/debian/changelog b/debian/changelog index 6f8bfea..13cbd14 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,30 @@ +orlo (0.1.1) stable; urgency=medium + + * Cast package_rollback as a boolean + * Add log files for gunicorn + * Separate debug mode and debug logging + * Return 400 when getting /releases with no filter + * Remove skip from documentation, it was renamed to offset + * Fix tests, require filter on GET /releases + * Bump vagrant box cpus to 2 + * Implement stats by date/time + * Fix query in release stats to group by release + * Add boolean True to acceptable success values + * Bump version + * Cast package_rollback as a boolean + * Add log files for gunicorn + * Separate debug mode and debug logging + * Return 400 when getting /releases with no filter + * Remove skip from documentation, it was renamed to offset + * Fix tests, require filter on GET /releases + * Bump vagrant box cpus to 2 + * Implement stats by date/time + * Fix query in release stats to group by release + * Add boolean True to acceptable success values + * Bump version + + -- Alex Forbes Mon, 01 Feb 2016 18:19:03 +0000 + orlo (0.1.0) stable; urgency=medium * Move orlo to /api/ in nginx diff --git a/setup.py b/setup.py index b8ca83c..c931526 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ import multiprocessing # nopep8 -VERSION = '0.1.1' +VERSION = '0.1.1-1' version_file = open('./orlo/_version.py', 'w') version_file.write("__version__ = '{}'".format(VERSION)) version_file.close()