Skip to content

Commit d6d2337

Browse files
authored
adapt qiime tools view to launch q2view locally (#351)
This enables viewing of provenance, metadata, and citations locally (as opposed to only via https://view.qiime2.org) and is a first step toward additional ways to view and interact with data provenance graphs through other interfaces.
1 parent bd99eec commit d6d2337

File tree

5 files changed

+173
-60
lines changed

5 files changed

+173
-60
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,5 @@ runinfo
7676
# Version file from versioningit
7777
_version.py
7878

79+
# Built vendored view
80+
q2cli/assets/view

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "q2view"]
2+
path = q2view
3+
url = https://github.com/qiime2/q2view.git

Makefile

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: all lint test install dev clean distclean
1+
.PHONY: all lint test init-view-submodule vendor-view install dev clean distclean
22

33
PYTHON ?= python
44
PREFIX ?= $(CONDA_PREFIX)
@@ -12,20 +12,28 @@ lint:
1212
test: all
1313
QIIMETEST= pytest
1414

15+
vendor-view: all
16+
git submodule init && \
17+
git submodule update && \
18+
cd q2view && \
19+
npm install --no-save && \
20+
npm run vendor --VENDOR_DIR=../q2cli/assets/view
21+
1522
# install pytest-xdist plugin for the `-n auto` argument.
1623
mystery-stew: all
1724
MYSTERY_STEW= pytest -k mystery_stew -n auto
1825

19-
install: all
26+
install: vendor-view all
2027
$(PYTHON) -m pip install -v . && \
2128
mkdir -p $(PREFIX)/etc/conda/activate.d && \
2229
cp hooks/50_activate_q2cli_tab_completion.sh $(PREFIX)/etc/conda/activate.d/
2330

24-
dev: all
31+
dev: vendor-view all
2532
pip install -e . && \
2633
mkdir -p $(PREFIX)/etc/conda/activate.d && \
2734
cp hooks/50_activate_q2cli_tab_completion.sh $(PREFIX)/etc/conda/activate.d/
2835

2936
clean: distclean
37+
rm -rf ./q2cli/assets
3038

3139
distclean: ;

q2cli/builtin/tools.py

Lines changed: 156 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -511,72 +511,171 @@ def _merge_metadata(paths):
511511
return metadata
512512

513513

514-
@tools.command(short_help='View a QIIME 2 Visualization.',
515-
help="Displays a QIIME 2 Visualization until the command "
516-
"exits. To open a QIIME 2 Visualization so it can be "
517-
"used after the command exits, use 'qiime tools extract'.",
514+
@tools.command(short_help='View a QIIME 2 Result.',
515+
help="Displays a QIIME 2 Result until the command exits. To "
516+
"open a QIIME 2 Visualization so it can be used after the "
517+
"command exits, use 'qiime tools extract'.",
518518
cls=ToolCommand)
519-
@click.argument('visualization-path', metavar='VISUALIZATION',
519+
@click.argument('result-path', metavar='RESULT',
520520
type=click.Path(file_okay=True, dir_okay=False, readable=True))
521-
@click.option('--index-extension', required=False, default='html',
522-
help='The extension of the index file that should be opened. '
523-
'[default: html]')
524-
def view(visualization_path, index_extension):
521+
@click.option('--port', required=False, type=click.IntRange(1024, 65535),
522+
default=None, help='The port to serve the webapp on.')
523+
@click.option('--verbose', is_flag=True,
524+
help='Display all GET requests in the terminal.')
525+
def view(result_path, port, verbose):
526+
# Get the abspath to the result
527+
result_path = os.path.abspath(result_path)
528+
525529
# Guard headless envs from having to import anything large
526530
import sys
527-
from qiime2 import Visualization
528-
from q2cli.util import _load_input
529-
from q2cli.core.config import CONFIG
530-
if not os.getenv("DISPLAY") and sys.platform != "darwin":
531+
532+
if not os.getenv('DISPLAY') and sys.platform != 'darwin':
531533
raise click.UsageError(
532-
'Visualization viewing is currently not supported in headless '
533-
'environments. You can view Visualizations (and Artifacts) at '
534-
'https://view.qiime2.org, or move the Visualization to an '
535-
'environment with a display and view it with `qiime tools view`.')
534+
'Result viewing is currently not supported in headless '
535+
'environments. You can view Results at https://view.qiime2.org, '
536+
'or move the Result to an environment with a display and view it '
537+
'with `qiime tools view`.')
536538

537-
if index_extension.startswith('.'):
538-
index_extension = index_extension[1:]
539+
import signal
540+
import random
541+
import tempfile
542+
import threading
543+
import http.server
544+
import contextlib
539545

540-
_, visualization = _load_input(visualization_path, view=True)[0]
541-
if not isinstance(visualization, Visualization):
542-
raise click.BadParameter(
543-
'%s is not a QIIME 2 Visualization. Only QIIME 2 Visualizations '
544-
'can be viewed.' % visualization_path)
546+
from qiime2.sdk import Result
545547

546-
index_paths = visualization.get_index_paths(relative=False)
548+
from q2cli.core.config import CONFIG
547549

548-
if index_extension not in index_paths:
549-
raise click.BadParameter(
550-
'No index %s file is present in the archive. Available index '
551-
'extensions are: %s' % (index_extension,
552-
', '.join(index_paths.keys())))
553-
else:
554-
index_path = index_paths[index_extension]
555-
launch_status = click.launch(index_path)
556-
if launch_status != 0:
557-
click.echo(CONFIG.cfg_style('error', 'Viewing visualization '
558-
'failed while attempting to open '
559-
f'{index_path}'), err=True)
560-
else:
561-
while True:
562-
click.echo(
563-
"Press the 'q' key, Control-C, or Control-D to quit. This "
564-
"view may no longer be accessible or work correctly after "
565-
"quitting.", nl=False)
566-
# There is currently a bug in click.getchar where translation
567-
# of Control-C and Control-D into KeyboardInterrupt and
568-
# EOFError (respectively) does not work on Python 3. The code
569-
# here should continue to work as expected when the bug is
570-
# fixed in Click.
571-
#
572-
# https://github.com/pallets/click/issues/583
573-
try:
574-
char = click.getchar()
575-
click.echo()
576-
if char in {'q', '\x03', '\x04'}:
577-
break
578-
except (KeyboardInterrupt, EOFError):
579-
break
550+
# Load and extract result
551+
result = Result.load(result_path)
552+
553+
extracted_path = os.path.join(tempfile.gettempdir(), str(result.uuid))
554+
if not os.path.exists(extracted_path):
555+
result.extract(result_path, tempfile.gettempdir())
556+
557+
# This ought to look like a session id generated by normal view
558+
CHAR_SET = 'abcdefghijklmnopqrstuvwxyz0123456789'
559+
SESSION_LEN = 11
560+
session = ''.join(random.choice(CHAR_SET) for i in range(SESSION_LEN))
561+
562+
# Start server
563+
class Handler(http.server.SimpleHTTPRequestHandler):
564+
def do_GET(self):
565+
# Redirect the output from these requests to devnull if not verbose
566+
with open(os.devnull, 'w') as devnull:
567+
with contextlib.redirect_stderr(
568+
sys.stderr if verbose else devnull):
569+
# Determine if this is a request for the file we are
570+
# supposed to be viewing
571+
if self.path == result_path:
572+
if not os.path.exists(self.path):
573+
self.send_error(404)
574+
else:
575+
self.send_response(200)
576+
with open(self.path, 'rb') as file:
577+
self.wfile.write(file.read())
578+
# Determine if this is a request for a file within the
579+
# visualization
580+
elif self.path.startswith(f'/_/{session}/{result.uuid}/'):
581+
file_path = self.path.split(str(result.uuid))[1]
582+
file_path = extracted_path + file_path
583+
file_path = os.path.abspath(file_path)
584+
585+
if not os.path.exists(file_path) or \
586+
not file_path.startswith(extracted_path):
587+
self.send_error(404)
588+
else:
589+
self.send_response(200)
590+
self.send_header('Access-Control-Allow-Origin',
591+
'*')
592+
self.end_headers()
593+
594+
with open(file_path, 'rb') as file:
595+
self.wfile.write(file.read())
596+
# Otherwise default to super class. This will respond
597+
# appropriately to requests for assets that are part of the
598+
# vendored view app and will reject any requests for files
599+
# outside the served directory
600+
else:
601+
super().do_GET()
602+
603+
# Get the path to the packaged vendored view
604+
# TODO: This won't work if we start packaging QIIME 2 as a wheel, we will
605+
# have to reimplement this used importlib.resources and it may be mildly
606+
# annoying to make things work properly. It's hard to tell right now since
607+
# we do not use a wheel.
608+
# https://docs.python.org/3/library/importlib.resources.html
609+
import importlib
610+
MODULE_INIT = importlib.import_module('q2cli').__file__
611+
MODULE_BASE_DIR = os.path.abspath(os.path.dirname(MODULE_INIT))
612+
VENDOR_PATH = os.path.join(MODULE_BASE_DIR, 'assets', 'view')
613+
614+
# Set up the server socket
615+
import socket
616+
617+
server_socket = socket.socket()
618+
# If port is None then slap a 0 into here to get a free port
619+
server_socket.bind(('localhost', 0 if port is None else port))
620+
# Get the port off the opened socket, if a port was passed, this will set
621+
# the port to itself no harm done. If no port was passed, this will get the
622+
# open port that .bind found.
623+
port = server_socket.getsockname()[1]
624+
625+
# Start up the server
626+
server = http.server.HTTPServer(
627+
('localhost', port), lambda *_: Handler(*_, directory=VENDOR_PATH),
628+
bind_and_activate=False)
629+
server.socket = server_socket
630+
631+
# Don't listen until the server is already going
632+
server_socket.listen(0)
633+
click.echo(f'Agent started on port: {port}')
634+
635+
# Stop server on termination of main thread
636+
def stop():
637+
server.shutdown()
638+
sys.exit(0)
639+
640+
signal.signal(signal.SIGTERM, stop)
641+
642+
# Start the server in a new thread
643+
thread = threading.Thread(target=server.serve_forever)
644+
thread.daemon = True
645+
thread.start()
646+
647+
# Open page on server
648+
url = f'http://localhost:{port}?file={result_path}&session={session}'
649+
launch_status = click.launch(url)
650+
click.echo('Your view should open in your default browser shortly. You '
651+
f'may open it manually at the URL: {url}')
652+
653+
# Yell if there was an error
654+
if launch_status != 0:
655+
click.echo(
656+
CONFIG.cfg_style('error', 'Viewing result failed while attempting '
657+
f'to open {result_path}'),
658+
err=True)
659+
660+
# Wait for shut down request
661+
while True:
662+
click.echo("Press the 'q' key, Control-C, or Control-D to quit. This "
663+
"view may no longer be accessible or work correctly after "
664+
"quitting.")
665+
# There is currently a bug in click.getchar where translation
666+
# of Control-C and Control-D into KeyboardInterrupt and
667+
# EOFError (respectively) does not work on Python 3. The code
668+
# here should continue to work as expected when the bug is
669+
# fixed in Click.
670+
#
671+
# https://github.com/pallets/click/issues/583
672+
try:
673+
char = click.getchar()
674+
click.echo()
675+
if char in {'q', '\x03', '\x04'}:
676+
break
677+
except (KeyboardInterrupt, EOFError):
678+
break
580679

581680

582681
@tools.command(short_help="Extract a QIIME 2 Artifact or Visualization "

q2view

Submodule q2view added at 918723e

0 commit comments

Comments
 (0)