Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make web UI dependencies optional #119

Merged
merged 15 commits into from
Sep 12, 2023
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install .[test]
pip install .[test,ui]
python -c "import nltk; nltk.download('punkt'); nltk.download('stopwords')"
python -m adeft.download
- name: Tests
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,4 @@ doc/_build/
benchmarks/data/**
benchmarks/results/**
.DS_Store
test.db
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ RUN python -m pip install --upgrade pip

COPY . /app
WORKDIR /app
RUN python -m pip install .
RUN python -m pip install .[ui]
RUN python -m gilda.resources
ENTRYPOINT gilda --port 8001 --host "0.0.0.0"
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ results = gilda.annotate('Calcium is released from the ER.')
```

### Use as a web service
The REST service accepts POST requests with a JSON header on the /ground

The REST service accepts POST requests with a JSON header on the `/ground`
endpoint. There is a public REST service running at http://grounding.indra.bio
but the service can also be run locally as

Expand Down Expand Up @@ -106,7 +107,7 @@ is an issue, the following options are recommended.
other processes send requests to.

2. Create a custom Grounder instance that only loads a subset of terms
approrpiate for a narrow use case.
appropriate for a narrow use case.

3. Gilda also offers an optional sqlite back-end which significantly decreases
memory usage and results in minor drop in the number of strings grounder per
Expand Down
164 changes: 44 additions & 120 deletions gilda/app/app.py
Original file line number Diff line number Diff line change
@@ -1,127 +1,12 @@
from textwrap import dedent
from typing import Optional

from flask import Blueprint, Flask, abort, jsonify, \
render_template, request, current_app
from flask_bootstrap import Bootstrap
from flask import Flask, abort, jsonify, redirect, request
from flask_restx import Api, Resource, fields
from flask_wtf import FlaskForm
from werkzeug.local import LocalProxy
from wtforms import StringField, SubmitField, TextAreaField, \
SelectMultipleField
from wtforms.validators import DataRequired

from gilda import __version__ as version
from gilda.resources import popular_organisms, organism_labels
from gilda.grounder import GrounderInput, Grounder

ui_blueprint = Blueprint("ui", __name__, url_prefix="/")

# The way that local proxies work is that when the app gets
# instantiated, you can stick objects into the `app.config`
# dictionary, then the local proxy lets you access them through
# a fake "current_app" object.
grounder = LocalProxy(lambda: current_app.config["grounder"])

ORGANISMS_FIELD = SelectMultipleField(
'Species priority (optional)',
choices=[(org, organism_labels[org]) for org in popular_organisms],
id='organism-select',
description=dedent("""\
Optionally select one or more taxonomy
species IDs to define a species priority list. Click
<a type="button" href="#" data-toggle="modal" data-target="#species-modal">
here <i class="far fa-question-circle">
</i></a> for more details.
"""),
)

class GroundForm(FlaskForm):
text = StringField(
'Text',
validators=[DataRequired()],
description=dedent("""\
Input the entity text (e.g., <code>k-ras</code>) to ground."""
#Click <a type="button" href="#" data-toggle="modal" data-target="#text-modal">
#here <i class="far fa-question-circle">
#</i></a> for more information
),
)
context = TextAreaField(
'Context (optional)',
description=dedent("""\
Optionally provide additional text context to help disambiguation. Click
<a type="button" href="#" data-toggle="modal" data-target="#context-modal">
here <i class="far fa-question-circle">
</i></a> for more details.
""")
)
organisms = ORGANISMS_FIELD
submit = SubmitField('Submit')

def get_matches(self):
return grounder.ground(self.text.data, context=self.context.data,
organisms=self.organisms.data)


class NERForm(FlaskForm):
text = TextAreaField(
'Text',
validators=[DataRequired()],
description=dedent("""\
Text from which to identify and ground named entities.
""")
)
organisms = ORGANISMS_FIELD
submit = SubmitField('Submit')

def get_annotations(self):
from gilda.ner import annotate

return annotate(self.text.data, grounder=grounder,
organisms=self.organisms.data)


@ui_blueprint.route('/', methods=['GET', 'POST'])
def home():
text = request.args.get('text')
if text is not None:
context = request.args.get('context')
organisms = request.args.getlist('organisms')
matches = grounder.ground(text, context=context, organisms=organisms)
return render_template('matches.html', matches=matches, version=version,
text=text, context=context)

form = GroundForm()
if form.validate_on_submit():
matches = form.get_matches()
return render_template(
'matches.html',
matches=matches,
version=version,
text=form.text.data,
context=form.context.data,
# Add a new form that doesn't auto-populate
form=GroundForm(formdata=None),
)
return render_template('home.html', form=form, version=version)


@ui_blueprint.route('/ner', methods=['GET', 'POST'])
def view_ner():
form = NERForm()
if form.validate_on_submit():
annotations = form.get_annotations()
return render_template(
'ner_matches.html',
annotations=annotations,
version=version,
text=form.text.data,
# Add a new form that doesn't auto-populate
form=NERForm(formdata=None),
)
return render_template('ner_home.html', form=form, version=version)

from gilda.app.proxies import grounder

# NOTE: the Flask REST-X API has to be declared here, below the home endpoint
# otherwise it reserves the / base path.
Expand Down Expand Up @@ -363,14 +248,53 @@ def get(self):
return jsonify(grounder.get_models())


def get_app(terms: Optional[GrounderInput] = None) -> Flask:
def get_app(terms: Optional[GrounderInput] = None, *, ui: bool = True) -> Flask:
app = Flask(__name__)
app.config['RESTX_MASK_SWAGGER'] = False
app.config['WTF_CSRF_ENABLED'] = False
app.config['SWAGGER_UI_DOC_EXPANSION'] = 'list'
app.config["grounder"] = Grounder(terms=terms)
Bootstrap(app)
app.register_blueprint(ui_blueprint, url_prefix="/")

if not ui:
_mount_home_redirect(app)
else:
try:
import importlib.metadata as importlib_metadata
except ImportError:
import importlib_metadata

try:
from flask_bootstrap import Bootstrap

bootstrap_version = importlib_metadata.version("flask_bootstrap")
if "3.3.7.1" != bootstrap_version:
raise ImportError(
dedent(
"""\
The wrong flask-bootstrap is installed, therefore the UI
can not be enabled. Please run the following commands in
the shell:

pip uninstall flask-bootstrap bootstrap-flask
pip install flask-bootstrap
"""
)
)

from gilda.app.ui import ui_blueprint
except ImportError:
_mount_home_redirect(app)
else:
Bootstrap(app)
app.register_blueprint(ui_blueprint, url_prefix="/")

# has to be put after defining the UI blueprint otherwise it reserves "/"
api.init_app(app)
return app


def _mount_home_redirect(app):
@app.route("/")
def home_redirect():
"""Redirect the home url to the API documentation."""
return redirect("/apidocs")
13 changes: 13 additions & 0 deletions gilda/app/proxies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from werkzeug.local import LocalProxy

from flask import current_app

__all__ = [
"grounder",
]

# The way that local proxies work is that when the app gets
# instantiated, you can stick objects into the `app.config`
# dictionary, then the local proxy lets you access them through
# a fake "current_app" object.
grounder = LocalProxy(lambda: current_app.config["grounder"])
121 changes: 121 additions & 0 deletions gilda/app/ui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from textwrap import dedent

from flask import Blueprint, render_template, request
from flask_wtf import FlaskForm
from wtforms import SelectMultipleField, StringField, SubmitField, TextAreaField
from wtforms.validators import DataRequired

from gilda.app.proxies import grounder
from gilda import __version__ as version
from gilda.resources import organism_labels, popular_organisms

__all__ = [
"ui_blueprint",
]

ORGANISMS_FIELD = SelectMultipleField(
"Species priority (optional)",
choices=[(org, organism_labels[org]) for org in popular_organisms],
id="organism-select",
description=dedent(
"""\
Optionally select one or more taxonomy
species IDs to define a species priority list. Click
<a type="button" href="#" data-toggle="modal" data-target="#species-modal">
here <i class="far fa-question-circle">
</i></a> for more details.
"""
),
)


class GroundForm(FlaskForm):
text = StringField(
"Text",
validators=[DataRequired()],
description="Input the entity text (e.g., <code>k-ras</code>) to ground.",
)
context = TextAreaField(
"Context (optional)",
description=dedent(
"""\
Optionally provide additional text context to help disambiguation. Click
<a type="button" href="#" data-toggle="modal" data-target="#context-modal">
here <i class="far fa-question-circle">
</i></a> for more details.
"""
),
)
organisms = ORGANISMS_FIELD
submit = SubmitField("Submit")

def get_matches(self):
return grounder.ground(
self.text.data, context=self.context.data, organisms=self.organisms.data
)


class NERForm(FlaskForm):
text = TextAreaField(
"Text",
validators=[DataRequired()],
description=dedent(
"""\
Text from which to identify and ground named entities.
"""
),
)
organisms = ORGANISMS_FIELD
submit = SubmitField("Submit")

def get_annotations(self):
from gilda.ner import annotate

return annotate(
self.text.data, grounder=grounder, organisms=self.organisms.data
)


ui_blueprint = Blueprint("ui", __name__, url_prefix="/")


@ui_blueprint.route("/", methods=["GET", "POST"])
def home():
text = request.args.get("text")
if text is not None:
context = request.args.get("context")
organisms = request.args.getlist("organisms")
matches = grounder.ground(text, context=context, organisms=organisms)
return render_template(
"matches.html", matches=matches, version=version, text=text, context=context
)

form = GroundForm()
if form.validate_on_submit():
matches = form.get_matches()
return render_template(
"matches.html",
matches=matches,
version=version,
text=form.text.data,
context=form.context.data,
# Add a new form that doesn't auto-populate
form=GroundForm(formdata=None),
)
return render_template("home.html", form=form, version=version)


@ui_blueprint.route("/ner", methods=["GET", "POST"])
def view_ner():
form = NERForm()
if form.validate_on_submit():
annotations = form.get_annotations()
return render_template(
"ner_matches.html",
annotations=annotations,
version=version,
text=form.text.data,
# Add a new form that doesn't auto-populate
form=NERForm(formdata=None),
)
return render_template("ner_home.html", form=form, version=version)
Loading
Loading