diff --git a/src/humulus/app.py b/src/humulus/app.py index 5bf78c5..3feb6b3 100644 --- a/src/humulus/app.py +++ b/src/humulus/app.py @@ -33,6 +33,9 @@ def create_app(test_config=None): from . import couch couch.init_app(app) + from . import styles + styles.init_app(app) + # Register blueprint for index page from . import home app.register_blueprint(home.bp) diff --git a/src/humulus/styles.py b/src/humulus/styles.py new file mode 100644 index 0000000..41d573e --- /dev/null +++ b/src/humulus/styles.py @@ -0,0 +1,116 @@ +"""This module has functions for working with BJCP styles.""" + +# Copyright 2019 Mike Shoup +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import xml.etree.ElementTree as ET + +import click +import requests +from flask import current_app +from flask.cli import with_appcontext + +from humulus.couch import get_db, put_doc + +def sub_to_doc(sub): + """Coverts sub (XML) to a dictionary document. + + The returned dictionary can be placed right into CouchDB if you want. + """ + doc = { + '_id': 'style_{}'.format(sub.attrib['id']), + '$type': 'style', + 'id': sub.attrib['id'], + 'name': sub.find('name').text, + 'aroma': sub.find('aroma').text, + 'appearance': sub.find('appearance').text, + 'flavor': sub.find('flavor').text, + 'mouthfeel': sub.find('mouthfeel').text, + 'impression': sub.find('impression').text, + 'ibu': {}, + 'og': {}, + 'fg': {}, + 'srm': {}, + 'abv': {} + } + if sub.find('comments') is not None: + doc['comments'] = sub.find('comments').text + if sub.find('history') is not None: + doc['history'] = sub.find('history').text + if sub.find('ingredients') is not None: + doc['ingredients'] = sub.find('ingredients').text + if sub.find('comparison') is not None: + doc['comparison'] = sub.find('comparison').text + if sub.find('examples') is not None: + doc['examples'] = sub.find('examples').text + if sub.find('tags') is not None: + doc['tags'] = sub.find('tags').text.split(', ') + + doc['ibu']['low'] = (sub.find('./stats/ibu/low').text + if sub.find('./stats/ibu/low') is not None else '0') + doc['ibu']['high'] = (sub.find('./stats/ibu/high').text + if sub.find('./stats/ibu/high') is not None else '100') + doc['og']['low'] = (sub.find('./stats/og/low').text + if sub.find('./stats/og/low') is not None else '1.0') + doc['og']['high'] = (sub.find('./stats/og/high').text + if sub.find('./stats/og/high') is not None else '1.2') + doc['fg']['low'] = (sub.find('./stats/fg/low').text + if sub.find('./stats/fg/low') is not None else '1.0') + doc['fg']['high'] = (sub.find('./stats/fg/high').text + if sub.find('./stats/fg/high') is not None else '1.2') + doc['srm']['low'] = (sub.find('./stats/srm/low').text + if sub.find('./stats/srm/low') is not None else '0') + doc['srm']['high'] = (sub.find('./stats/srm/high').text + if sub.find('./stats/srm/high') is not None else '100') + doc['abv']['low'] = (sub.find('./stats/abv/low').text + if sub.find('./stats/abv/low') is not None else '0') + doc['abv']['high'] = (sub.find('./stats/abv/high').text + if sub.find('./stats/abv/high') is not None else '100') + return doc + + +def import_styles(url): + """Parses BJCP styles in XML format from `url`. + + `url` defaults to the official BJCP XML styleguide. + + Each subcategory is converted to JSON and then put to a couchdb. The _id of + the subsequent document will be `style_`, i.e., style_1A. + If the style already exists in the database, it will be skipped. + """ + db = get_db() + root = ET.fromstring(requests.get(url).text) + subs = root.findall('./class[@type="beer"]/category/subcategory') + for sub in subs: + doc = sub_to_doc(sub) + if doc['_id'] not in db: + put_doc(doc) + + +@click.command('import-styles') +@with_appcontext +def import_command(): + """CLI command to import BJCP styles.""" + url = current_app.config.get( + 'BJCP_STYLES_URL', + ('https://raw.githubusercontent.com/meanphil' + '/bjcp-guidelines-2015/master/styleguide.xml') + ) + import_styles(url) + click.echo("Imported BJCP styles.") + + +def init_app(app): + """Register the CLI command with the app.""" + app.cli.add_command(import_command) diff --git a/tests/test_styles.py b/tests/test_styles.py new file mode 100644 index 0000000..14f2e7f --- /dev/null +++ b/tests/test_styles.py @@ -0,0 +1,182 @@ +# Copyright 2019 Mike Shoup +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import xml.etree.ElementTree as ET + +from humulus.styles import import_styles, sub_to_doc + +COMPLETE_STYLE = ''' + Test Style + Smelly + Good looking + Good tasting + Good feeling + Refreshing + Comments + Old + Grains, Hops, and Water + Comparison + Examples + one, two + + + 1 + 2 + + + 1.010 + 1.020 + + + 1.000 + 1.010 + + + 1 + 2 + + + 1 + 2 + + + +''' + +INCOMPLETE_STYLE = ''' + Test Style + Smelly + Good looking + Good tasting + Good feeling + Refreshing + +''' + +TEST_XML = ''' + + + + + Test Style + Smelly + Good looking + Good tasting + Good feeling + Refreshing + + + + +''' + + +def test_sub_to_doc(): + assert sub_to_doc(ET.fromstring(COMPLETE_STYLE)) == { + '_id': 'style_1A', + '$type': 'style', + 'id': '1A', + 'name': 'Test Style', + 'aroma': 'Smelly', + 'appearance': 'Good looking', + 'flavor': 'Good tasting', + 'mouthfeel': 'Good feeling', + 'impression': 'Refreshing', + 'comments': 'Comments', + 'history': 'Old', + 'ingredients': 'Grains, Hops, and Water', + 'comparison': 'Comparison', + 'examples': 'Examples', + 'tags': ['one', 'two'], + 'ibu': {'low': '1', 'high': '2'}, + 'og': {'low': '1.010', 'high': '1.020'}, + 'fg': {'low': '1.000', 'high': '1.010'}, + 'srm': {'low': '1', 'high': '2'}, + 'abv': {'low': '1', 'high': '2'} + } + + assert sub_to_doc(ET.fromstring(INCOMPLETE_STYLE)) == { + '_id': 'style_2B', + '$type': 'style', + 'id': '2B', + 'name': 'Test Style', + 'aroma': 'Smelly', + 'appearance': 'Good looking', + 'flavor': 'Good tasting', + 'mouthfeel': 'Good feeling', + 'impression': 'Refreshing', + 'ibu': {'low': '0', 'high': '100'}, + 'og': {'low': '1.0', 'high': '1.2'}, + 'fg': {'low': '1.0', 'high': '1.2'}, + 'srm': {'low': '0', 'high': '100'}, + 'abv': {'low': '0', 'high': '100'} + } + + +def test_import_styles(monkeypatch): + class PutRecorder: + doc = None + + class MockDB: + db = {} + + def fake_get_db(): + return MockDB.db + + def fake_put_doc(doc): + PutRecorder.doc = doc + + def fake_requests_get(url): + class TestXML: + text = TEST_XML + return TestXML + + monkeypatch.setattr('requests.get', fake_requests_get) + monkeypatch.setattr('humulus.styles.get_db', fake_get_db) + monkeypatch.setattr('humulus.styles.put_doc', fake_put_doc) + + import_styles(None) + assert PutRecorder.doc == {'$type': 'style', + '_id': 'style_1A', + 'abv': {'high': '100', 'low': '0'}, + 'appearance': 'Good looking', + 'aroma': 'Smelly', + 'fg': {'high': '1.2', 'low': '1.0'}, + 'flavor': 'Good tasting', + 'ibu': {'high': '100', 'low': '0'}, + 'id': '1A', + 'impression': 'Refreshing', + 'mouthfeel': 'Good feeling', + 'name': 'Test Style', + 'og': {'high': '1.2', 'low': '1.0'}, + 'srm': {'high': '100', 'low': '0'} + } + + MockDB.db = {'style_1A': ''} + PutRecorder.doc = None + import_styles(None) + assert PutRecorder.doc is None + + +def test_import_command(runner, monkeypatch): + class Recorder: + called = False + + def fake_import_styles(url): + Recorder.called = True + + monkeypatch.setattr('humulus.styles.import_styles', fake_import_styles) + result = runner.invoke(args=['import-styles']) + assert Recorder.called + assert 'Imported BJCP styles.' in result.output