diff --git a/Makefile.local b/Makefile.local index f555e5f9..9d2441cd 100644 --- a/Makefile.local +++ b/Makefile.local @@ -1,8 +1,8 @@ -SWH_WEB_UI=./bin/swh-web-ui +SWH_WEB_UI=./bin/swh-web-ui-dev FLAG=-v run-test: - $(SWH_WEB_UI) $(FLAG) --config ./resources/test/web-ui.ini + $(SWH_WEB_UI) $(FLAG) --config ./resources/test/webapp.ini run: # works with the default ~/.config/swh/web-ui.ini file $(SWH_WEB_UI) $(FLAG) diff --git a/PKG-INFO b/PKG-INFO index d89a2337..66753955 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,10 +1,10 @@ Metadata-Version: 1.0 Name: swh.web.ui -Version: 0.0.8 +Version: 0.0.9 Summary: Software Heritage Web UI Home-page: https://forge.softwareheritage.org/diffusion/DWUI/ Author: Software Heritage developers Author-email: swh-devel@inria.fr License: UNKNOWN Description: UNKNOWN Platform: UNKNOWN diff --git a/README b/README index 11e51015..1fb81ab9 100644 --- a/README +++ b/README @@ -1,24 +1,43 @@ swh-web-ui ========== SWH's web application +Dev mode +-------- + # Configuration file sample ~/.config/swh/webapp.ini [main] + # the dedicated storage arguments, comma separated list of values + storage_args = http://localhost:5000/ + + # either remote_storage or local_storage + storage_class = remote_storage + # where to log information log_dir = /tmp/swh/web-ui/log # for dev only debug = true # current server (0.0.0.0 for world opening) host = 127.0.0.1 # its port port = 6543 - - # the backend this server communicates to - api_backend = http://127.0.0.1:5000 + +# Run + + ./bin/swh-web-ui-dev -v -c ~/.config/swh/webapp.ini + + +Server mode +----------- + +uwsgi is used. +cf. https://forge.softwareheritage.org/diffusion/SPPROF/browse/master/manifests/swh/deploy/webapp.pp + +for more information. diff --git a/bin/swh-web-ui b/bin/swh-web-ui-dev similarity index 69% rename from bin/swh-web-ui rename to bin/swh-web-ui-dev index 1547264d..5622bf5c 100755 --- a/bin/swh-web-ui +++ b/bin/swh-web-ui-dev @@ -1,42 +1,35 @@ #!/usr/bin/env python3 # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information import argparse -import logging -import os -from swh.web.ui import controller, main +from swh.web.ui import main # Default configuration file DEFAULT_CONF_FILE = '~/.config/swh/webapp.ini' -# Default configuration in swh.web.ui.main - def parse_args(): """Parse the configuration for the cli. """ cli = argparse.ArgumentParser(description="SWH's web ui.") cli.add_argument('--verbose', '-v', action='store_true', help='Verbosity level in log file.') cli.add_argument('--config', '-c', help='configuration file path') args = cli.parse_args() return args if __name__ == '__main__': args = parse_args() - conf = main.read_config(args.config or DEFAULT_CONF_FILE) - - logging.basicConfig(filename=os.path.join(conf['log_dir'], 'web-ui.log'), - level=logging.DEBUG if args.verbose else logging.INFO) + config_path = args.config or DEFAULT_CONF_FILE - controller.run(conf) + main.run_debug_from(config_path, args.verbose) diff --git a/resources/test/web-ui.ini b/resources/test/webapp.ini similarity index 100% rename from resources/test/web-ui.ini rename to resources/test/webapp.ini diff --git a/setup.py b/setup.py index f7698da2..ccd8ad98 100755 --- a/setup.py +++ b/setup.py @@ -1,30 +1,30 @@ #!/usr/bin/env python3 from setuptools import setup def parse_requirements(): requirements = [] with open('requirements.txt') as f: for line in f.readlines(): line = line.strip() if not line or line.startswith('#'): continue requirements.append(line) return requirements setup( name='swh.web.ui', description='Software Heritage Web UI', author='Software Heritage developers', author_email='swh-devel@inria.fr', url='https://forge.softwareheritage.org/diffusion/DWUI/', packages=['swh.web.ui', 'swh.web.ui.tests'], - scripts=['bin/swh-web-ui'], + scripts=[], install_requires=parse_requirements(), setup_requires=['vcversioner'], vcversioner={}, include_package_data=True, ) diff --git a/swh.web.ui.egg-info/PKG-INFO b/swh.web.ui.egg-info/PKG-INFO index d89a2337..66753955 100644 --- a/swh.web.ui.egg-info/PKG-INFO +++ b/swh.web.ui.egg-info/PKG-INFO @@ -1,10 +1,10 @@ Metadata-Version: 1.0 Name: swh.web.ui -Version: 0.0.8 +Version: 0.0.9 Summary: Software Heritage Web UI Home-page: https://forge.softwareheritage.org/diffusion/DWUI/ Author: Software Heritage developers Author-email: swh-devel@inria.fr License: UNKNOWN Description: UNKNOWN Platform: UNKNOWN diff --git a/swh.web.ui.egg-info/SOURCES.txt b/swh.web.ui.egg-info/SOURCES.txt index 09a7bbdc..84ec533d 100644 --- a/swh.web.ui.egg-info/SOURCES.txt +++ b/swh.web.ui.egg-info/SOURCES.txt @@ -1,37 +1,40 @@ .gitignore AUTHORS LICENSE MANIFEST.in Makefile Makefile.local README README-uri-scheme.md requirements.txt setup.py version.txt -bin/swh-web-ui +bin/swh-web-ui-dev debian/changelog debian/compat debian/control debian/copyright debian/rules debian/source/format -resources/test/web-ui.ini +resources/test/webapp.ini swh.web.ui.egg-info/PKG-INFO swh.web.ui.egg-info/SOURCES.txt swh.web.ui.egg-info/dependency_links.txt swh.web.ui.egg-info/requires.txt swh.web.ui.egg-info/top_level.txt swh/web/ui/controller.py +swh/web/ui/converters.py swh/web/ui/decorators.py swh/web/ui/main.py swh/web/ui/query.py swh/web/ui/service.py swh/web/ui/static/style.css swh/web/ui/templates/content.html swh/web/ui/templates/directory.html swh/web/ui/templates/layout.html swh/web/ui/templates/revision.html swh/web/ui/templates/search.html swh/web/ui/tests/test_controller.py -swh/web/ui/tests/test_query.py \ No newline at end of file +swh/web/ui/tests/test_converters.py +swh/web/ui/tests/test_query.py +swh/web/ui/tests/test_service.py \ No newline at end of file diff --git a/swh/web/ui/controller.py b/swh/web/ui/controller.py index 8ffa879f..3f02cebe 100644 --- a/swh/web/ui/controller.py +++ b/swh/web/ui/controller.py @@ -1,333 +1,339 @@ # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information import logging +import json from flask import redirect, render_template, url_for, jsonify, request from flask import make_response from swh.core.hashutil import ALGORITHMS from swh.web.ui.main import app -from swh.web.ui import service, query +from swh.web.ui import service from swh.web.ui.decorators import jsonp hash_filter_keys = ALGORITHMS @app.route('/') def main(): """Main application view. At the moment, redirect to the content search view. """ return redirect(url_for('info')) @app.route('/info') def info(): """A simple api to define what the server is all about. """ logging.info('Dev SWH UI') return 'Dev SWH UI' @app.route('/search') def search(): """Search for hashes in swh-storage. """ q = request.args.get('q', '') env = {'q': q, 'message': '', 'found': None} try: if q: env['found'] = service.lookup_hash(q) except ValueError: env['message'] = 'Error: invalid query string' return render_template('search.html', **env) @app.route('/browse/revision/') def revision(sha1_git): """Show commit information. Args: sha1_git: the revision's sha1 Returns: Revision information """ return render_template('revision.html', sha1_git=sha1_git) @app.route('/browse/directory/') def directory(sha1_git): """Show directory information. Args: sha1_git: the directory's sha1 Returns: Directory information """ return render_template('directory.html', sha1_git=sha1_git) @app.route('/browse/directory//') def directory_at_path(sha1_git, p): """Show directory information for the sha1_git at path. Args: sha1_git: the directory's sha1 path: file or directory pointed to Returns: Directory information at sha1_git + path """ return render_template('directory.html', sha1_git=sha1_git, path=p) +def _origin_seen(hash, data): + """Given an origin, compute a message string with the right information. + + Args: + origin: a dictionary with keys: + - origin: a dictionary with type and url keys + - occurrence: a dictionary with a validity range + + Returns: + message as a string + + """ + if data is None: + return 'Content with hash %s is unknown as of now.' % hash + + origin_type = data['origin_type'] + origin_url = data['origin_url'] + revision = data['revision'] + branch = data['branch'] + path = data['path'] + + return """The content with hash %s has been seen on origin with type '%s' +at url '%s'. The revision was identified at '%s' on branch '%s'. +The file's path referenced was '%s'.""" % (hash, + origin_type, + origin_url, + revision, + branch, + path) + + @app.route('/browse/content/:') def content(hash, sha): """Show content information. Args: hash: hash according to HASH_ALGO, where HASH_ALGO is one of: sha1, sha1_git, sha256. This means that several different URLs (at least one per HASH_ALGO) will point to the same content sha: the sha with 'hash' format Returns: The content's information at sha1_git """ - # Checks user input if hash not in hash_filter_keys: - return make_response( - 'Bad request, sha must be one of sha1, sha1_git, sha256', - 400) - - h = query.categorize_hash(sha) - if h == {}: - return make_response( - 'Bad request, %s is not of type %s' % (sha, hash), - 400) - - if hash == 'sha256' and not h.get(hash): - return make_response( - 'Bad request, %s is not of type sha256' % (sha,), - 400) - - if hash != 'sha256' and not h.get('sha1') and not h.get('sha1_git'): - return make_response( - 'Bad request, %s is not of type sha1 or sha1_git' % (sha,), - 400) - - message = service.lookup_hash_origin(h) + message = 'The checksum must be one of sha1, sha1_git, sha256' + else: + q = "%s:%s" % (hash, sha) + found = service.lookup_hash(q) + if not found: + message = "Hash %s was not found." % hash + else: + origin = service.lookup_hash_origin(q) + message = _origin_seen(hash, origin) return render_template('content.html', hash=hash, sha=sha, message=message) @app.route('/browse/release/') def release(sha1_git): """Show release's information. Args: sha1_git: sha1_git for this particular release Returns: Release's information """ return 'Release information at %s' % sha1_git @app.route('/browse/person/') def person(id): """Show Person's information at id. Args: id: person's unique identifier Returns: Person's information """ return 'Person information at %s' % id @app.route('/browse/origin/') def origin(id): """Show origin's information at id. Args: id: origin's unique identifier Returns: Origin's information """ return 'Origin information at %s' % id @app.route('/browse/project/') def project(id): """Show project's information at id. Args: id: project's unique identifier Returns: Project's information """ return 'Project information at %s' % id @app.route('/browse/organization/') def organization(id): """Show organization's information at id. Args: id: organization's unique identifier Returns: Organization's information """ return 'Organization information at %s' % id @app.route('/browse/directory//' '+|/' '|/') def directory_at_origin(timestamp, origin_type, origin_url, branch, path): """Show directory information at timestamp, origin-type, origin-url, branch and path. Those parameters are separated by the `|` terminator. Args: timestamp: the timestamp to look for. can be latest or some iso8601 date format. (TODO: decide the time matching policy.) origin_type: origin's type origin_url: origin's url (can contain `/`) branch: branch name which can contain `/` path: path to directory or file Returns: Directory information at the given parameters. """ return 'Directory at (%s, %s, %s, %s, %s)' % (timestamp, origin_type, origin_url, branch, path) @app.route('/browse/revision//' '+|/') def revision_at_origin_and_branch(timestamp, origin_type, origin_url, branch): """Show revision information at timestamp, origin, and branch. Those parameters are separated by the `|` terminator. Args: timestamp: the timestamp to look for. can be latest or some iso8601 date format. (TODO: decide the time matching policy.) origin_type: origin's type origin_url: origin's url (can contain `/`) branch: branch name which can contain / Returns: Revision information at the given parameters. """ return 'Revision at (ts=%s, type=%s, url=%s, branch=%s)' % (timestamp, origin_type, origin_url, branch) @app.route('/browse/revision//' '+|') def revision_at_origin(timestamp, origin_type, origin_url): """Show revision information at timestamp, origin, and branch. Those parameters are separated by the `|` terminator. Args: timestamp: the timestamp to look for. can be latest or iso8601 date format. (TODO: decide the time matching policy.) origin_type: origin's type origin_url: origin's url (can contain `/`) Returns: Revision information at the given parameters. """ return 'Revision at (timestamp=%s, type=%s, url=%s)' % (timestamp, origin_type, origin_url) @app.route('/api/1/stat/counters') @jsonp def api_stats(): """Return statistics as a JSON object""" return jsonify(service.stat_counters()) +@app.errorhandler(ValueError) +def value_error_as_bad_request(error): + """Compute a bad request and add body as payload. + + """ + response = make_response( + 'Bad request', + 400) + response.headers['Content-type'] = 'application/json' + response.data = json.dumps({"error": str(error)}) + return response + + @app.route('/api/1/search//') @jsonp def api_search(q): """Return search results as a JSON object""" - return jsonify({'query': q, - 'found': service.lookup_hash(q)}) - - -def run(conf): - """Run the api's server. - - Args: - conf is a dictionary of keywords: - - 'db_url' the db url's access (through psycopg2 format) - - 'content_storage_dir' revisions/directories/contents storage on disk - - 'host' to override the default 127.0.0.1 to open or not the server - to the world - - 'port' to override the default of 5000 (from the underlying layer: - flask) - - 'debug' activate the verbose logs - - 'secret_key' the flask secret key + return jsonify({'found': service.lookup_hash(q)}) - Returns: - Never - - Raises: - ? - - """ - print("""SWH Web UI available at http://%s:%s/ -debug: %s""" % (conf['host'], conf.get('port', None), conf['debug'])) - app.secret_key = conf['secret_key'] - app.config.update({'conf': conf}) - - app.run(host=conf['host'], - port=conf.get('port', None), - debug=conf['debug']) +@app.route('/api/1/browse//') +@jsonp +def api_browse(q): + """Return search results as a JSON object""" + return jsonify({'origin': service.lookup_hash_origin(q)}) diff --git a/swh/web/ui/converters.py b/swh/web/ui/converters.py new file mode 100644 index 00000000..abf1d964 --- /dev/null +++ b/swh/web/ui/converters.py @@ -0,0 +1,22 @@ +# Copyright (C) 2015 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from swh.core import hashutil + + +def from_origin(origin): + """Convert from an swh origin to an origin dictionary. + + """ + new_origin = {} + for key, value in origin.items(): + if key == 'revision': + new_origin[key] = hashutil.hash_to_hex(value) + elif key == 'path': + new_origin[key] = value.decode('utf-8') + else: + new_origin[key] = value + + return new_origin diff --git a/swh/web/ui/main.py b/swh/web/ui/main.py index 2420c146..43f5abad 100644 --- a/swh/web/ui/main.py +++ b/swh/web/ui/main.py @@ -1,73 +1,111 @@ # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information import logging import os from flask import Flask from swh.core import config + DEFAULT_CONFIG = { 'storage_args': ('list[str]', ['http://localhost:5000/']), 'storage_class': ('str', 'remote_storage'), 'log_dir': ('string', '/tmp/swh/log'), 'debug': ('bool', None), 'host': ('string', '127.0.0.1'), 'port': ('int', 6543), 'secret_key': ('string', 'development key'), } # api's definition app = Flask(__name__) def read_config(config_file): """Read the configuration file `config_file`, update the app with parameters (secret_key, conf) and return the parsed configuration as a dict""" conf = config.read(config_file, DEFAULT_CONFIG) config.prepare_folders(conf, 'log_dir') if conf['storage_class'] == 'remote_storage': from swh.storage.api.client import RemoteStorage as Storage else: from swh.storage import Storage conf['storage'] = Storage(*conf['storage_args']) return conf def load_controllers(): """Load the controllers for the application""" from swh.web.ui import controller # flake8: noqa +def storage(): + """Return the current application's storage. + + """ + return app.config['conf']['storage'] + + def run_from_webserver(environ, start_response): """Run the WSGI app from the webserver, loading the configuration.""" load_controllers() config_path = '/etc/softwareheritage/webapp/webapp.ini' conf = read_config(config_path) app.secret_key = conf['secret_key'] app.config['conf'] = conf logging.basicConfig(filename=os.path.join(conf['log_dir'], 'web-ui.log'), level=logging.INFO) return app(environ, start_response) -def storage(): - """Return the current application's storage. +def run_debug_from(config_path, verbose=False): + """Run the api's server in dev mode. + + Args: + conf is a dictionary of keywords: + - 'db_url' the db url's access (through psycopg2 format) + - 'content_storage_dir' revisions/directories/contents storage on disk + - 'host' to override the default 127.0.0.1 to open or not the server + to the world + - 'port' to override the default of 5000 (from the underlying layer: + flask) + - 'debug' activate the verbose logs + - 'secret_key' the flask secret key + + Returns: + Never """ - return app.config['conf']['storage'] + load_controllers() + + conf = read_config(config_path) + + app.secret_key = conf['secret_key'] + app.config['conf'] = conf + + host = conf.get('host', '127.0.0.1') + port = conf.get('port') + debug = conf.get('debug') + + log_file = os.path.join(conf['log_dir'], 'web-ui.log') + logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO, + handlers=[logging.FileHandler(log_file), + logging.StreamHandler()]) + + app.run(host=host, port=port, debug=debug) diff --git a/swh/web/ui/service.py b/swh/web/ui/service.py index 9ae49c75..0a4bccab 100644 --- a/swh/web/ui/service.py +++ b/swh/web/ui/service.py @@ -1,77 +1,43 @@ # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information -from swh.web.ui import main -from swh.web.ui import query +from swh.web.ui import converters, main, query def lookup_hash(q): """Checks if the storage contains a given content checksum Args: query string Returns: True or False, according to whether the checksum is present or not """ (algo, hash) = query.parse_hash(q) return main.storage().content_exist({algo: hash}) -def _origin_seen(hash, data): - """Given an origin, compute a message string with the right information. +def lookup_hash_origin(q): + """Return information about the checksum contained in the query q. - Args: - origin: a dictionary with keys: - - origin: a dictionary with type and url keys - - occurrence: a dictionary with a validity range - - Returns: - message as a string - - """ - if data is None: - return 'Content with hash %s is unknown as of now.' % hash - - origin_type = data['origin_type'] - origin_url = data['origin_url'] - revision = data['revision'] - branch = data['branch'] - path = data['path'] - - return """The content with hash %s has been seen on origin with type '%s' -at url '%s'. The revision was identified at '%s' on branch '%s'. -The file's path referenced was '%s'.""" % (hash, - origin_type, - origin_url, - revision, - branch, - path) - - -def lookup_hash_origin(hash): - """Given a hash, return the origin of such content if any is found. - - Args: - hash: key/value dictionary + Args: query string Returns: - The origin for such hash if it's found. + True or False, according to whether the checksum is present or not - Raises: - OSError (no route to host), etc... Network issues in general """ - data = main.storage().content_find_occurrence(hash) - return _origin_seen(hash, data) + algo, h = query.parse_hash(q) + origin = main.storage().content_find_occurrence({algo: h}) + return converters.from_origin(origin) def stat_counters(): """Return the stat counters for Software Heritage Returns: A dict mapping textual labels to integer values. """ return main.storage().stat_counters() diff --git a/swh/web/ui/tests/test_converters.py b/swh/web/ui/tests/test_converters.py new file mode 100644 index 00000000..91773567 --- /dev/null +++ b/swh/web/ui/tests/test_converters.py @@ -0,0 +1,38 @@ +# Copyright (C) 2015 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import unittest + +from nose.tools import istest + +from swh.web.ui import converters + + +class ConvertersTestCase(unittest.TestCase): + + @istest + def from_origin(self): + # given + origin_input = { + 'origin_type': 'ftp', + 'origin_url': 'rsync://ftp.gnu.org/gnu/octave', + 'branch': 'octave-3.4.0.tar.gz', + 'revision': b'\xb0L\xaf\x10\xe9SQ`\xd9\x0e\x87KE\xaaBm\xe7b\xf1\x9f', # noqa + 'path': b'octave-3.4.0/doc/interpreter/octave.html/doc_002dS_005fISREG.html' # noqa + } + + expected_origin = { + 'origin_type': 'ftp', + 'origin_url': 'rsync://ftp.gnu.org/gnu/octave', + 'branch': 'octave-3.4.0.tar.gz', + 'revision': 'b04caf10e9535160d90e874b45aa426de762f19f', + 'path': 'octave-3.4.0/doc/interpreter/octave.html/doc_002dS_005fISREG.html' # noqa + } + + # when + actual_origin = converters.from_origin(origin_input) + + # then + self.assertEqual(actual_origin, expected_origin) diff --git a/swh/web/ui/tests/test_service.py b/swh/web/ui/tests/test_service.py new file mode 100644 index 00000000..3f0d4eb2 --- /dev/null +++ b/swh/web/ui/tests/test_service.py @@ -0,0 +1,90 @@ +# Copyright (C) 2015 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import unittest + +from nose.tools import istest + +from unittest.mock import patch + +from swh.web.ui import service + + +class MockStorage(): + def stat_counters(self): + return { + "content": 1770830, + "directory": 211683, + "directory_entry_dir": 209167, + "directory_entry_file": 1807094, + "directory_entry_rev": 0, + "entity": 0, + "entity_history": 0, + "occurrence": 0, + "occurrence_history": 19600, + "origin": 1096, + "person": 0, + "release": 8584, + "revision": 7792, + "revision_history": 0, + "skipped_content": 0 + } + + def content_find_occurrence(self, h): + return { + 'origin_type': 'sftp', + 'origin_url': 'sftp://ftp.gnu.org/gnu/octave', + 'branch': 'octavio-3.4.0.tar.gz', + 'revision': b'\xb0L\xaf\x10\xe9SQ`\xd9\x0e\x87KE\xaaBm\xe7b\xf1\x9f', # noqa + 'path': b'octavio-3.4.0/doc/interpreter/octave.html/doc_002dS_005fISREG.html' # noqa + } + + def content_exist(self, h): + return False + + +class ServiceTestCase(unittest.TestCase): + + @istest + def lookup_hash(self): + with patch('swh.web.ui.main.storage', + return_value=MockStorage()): + actual_lookup = service.lookup_hash( + 'sha1:123caf10e9535160d90e874b45aa426de762f19f') + + self.assertFalse(actual_lookup) + + @istest + def lookup_hash_origin(self): + # given + expected_origin = { + 'origin_type': 'sftp', + 'origin_url': 'sftp://ftp.gnu.org/gnu/octave', + 'branch': 'octavio-3.4.0.tar.gz', + 'revision': 'b04caf10e9535160d90e874b45aa426de762f19f', + 'path': 'octavio-3.4.0/doc/interpreter/octave.html/doc' + '_002dS_005fISREG.html' + } + + # when + with patch('swh.web.ui.main.storage', + return_value=MockStorage()): + actual_origin = service.lookup_hash_origin( + 'sha1_git:456caf10e9535160d90e874b45aa426de762f19f') + + # then + self.assertEqual(actual_origin, expected_origin) + + @istest + def stat_counters(self): + # given + expected_stats = MockStorage().stat_counters() + + # when + with patch('swh.web.ui.main.storage', return_value=MockStorage()): + actual_stats = service.stat_counters() + + # then + self.assertEqual(actual_stats, expected_stats) diff --git a/version.txt b/version.txt index 33f71f9b..d36b6836 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v0.0.8-0-g69d124a \ No newline at end of file +v0.0.9-0-gb1aba5b \ No newline at end of file