Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
57d5a09
Intial janky solution
Oddant1 Dec 12, 2024
05ad0a2
Get an open socket
Oddant1 Dec 13, 2024
0d8278c
Unneeded )
Oddant1 Dec 13, 2024
3ba5ceb
Serve files witout service worker
Oddant1 Dec 17, 2024
1525f52
lint
Oddant1 Dec 18, 2024
5521b1b
Point at vendored now
Oddant1 Dec 20, 2024
6b6cba5
add port argument
Oddant1 Dec 31, 2024
601b815
Typo in help
Oddant1 Dec 31, 2024
68efe4c
Make sure the file requested is actually in our result
Oddant1 Dec 31, 2024
fb5684c
open up port range
Oddant1 Jan 14, 2025
4130d42
mess with params and text
Oddant1 Jan 14, 2025
d38ae8e
change overloaded name
Oddant1 Jan 14, 2025
b65d109
double to single quote
Oddant1 Jan 14, 2025
94924ec
Simplify get
Oddant1 Jan 15, 2025
b5b32dc
Load and extract result first thing
Oddant1 Jan 15, 2025
bb0575f
Rework imports
Oddant1 Jan 15, 2025
ed674a6
Generate session id
Oddant1 Jan 15, 2025
5bbc253
visualization -> result
Oddant1 Jan 16, 2025
86a25d0
reorder and add comments
Oddant1 Jan 16, 2025
9a40358
Merge branch 'dev' into vendor-q2view
Oddant1 Jan 16, 2025
5a32171
lint
Oddant1 Jan 16, 2025
837a215
Create the server socket ourselves so no race condition in getting fr…
Oddant1 Mar 20, 2025
54cb98f
Add submodule for vendored_view
Oddant1 Mar 20, 2025
945ccac
Vendor path and build command changes
Oddant1 Mar 27, 2025
c3d2b6b
refactor how ports are handled
Oddant1 Mar 27, 2025
df73f3f
Update vendored view
Oddant1 Apr 1, 2025
881fa93
Build vendored view under q2cli/assets/view
Oddant1 Apr 2, 2025
3cdfb65
Move q2view submodule
Oddant1 Apr 4, 2025
cd59868
Keep the vendored name
Oddant1 Apr 4, 2025
2bf9bdd
Maybe do the make directives like this?
Oddant1 Apr 4, 2025
4536740
I used git mv on the submodule and git was STILL dumb about it
Oddant1 Apr 4, 2025
73941e1
Trailing newline in .gitignore
Oddant1 Apr 4, 2025
0ce547b
Point the submodule at my fork
Oddant1 Apr 4, 2025
2408556
AAHHHHHHHHHHHHHHHHHHHAAAAAAAAAAAAAAAHHHHHHHHHHHHH
Oddant1 Apr 4, 2025
64aea3f
Fun with .gitmodules file
Oddant1 Apr 4, 2025
0fd6790
Try recreating the submodule and using an upstream branch
Oddant1 Apr 4, 2025
0da88b5
Distributions has been updated to checkout submodules... hopefully
Oddant1 Apr 4, 2025
b2555dd
It is now just q2view
Oddant1 Apr 4, 2025
0977cf2
Try https URL explicitly why not
Oddant1 Apr 4, 2025
dc3a644
remove comment from Makefile
Oddant1 Apr 14, 2025
98266df
No branch
Oddant1 Apr 15, 2025
e0d7697
Print the url of the view, and add a verbose flag
Oddant1 Apr 15, 2025
b971d45
Redirect stderr better
Oddant1 Apr 17, 2025
dbb5751
Don't leak open handles
Oddant1 Apr 17, 2025
1da1930
Get the vendored view path from the package
Oddant1 Apr 18, 2025
47cc828
Need to get abspath of result in case they gave a relpath
Oddant1 Apr 18, 2025
eb480a4
Make sure the submodule it set up in the Makefile
Oddant1 Apr 18, 2025
3037176
removing built assets to clean directive
Oddant1 Apr 18, 2025
4e239e9
Add TODO in case we start packaging our stuff differently
Oddant1 Apr 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,5 @@ runinfo
# Version file from versioningit
_version.py

# Built vendored view
q2cli/assets/view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "q2view"]
path = q2view
url = https://github.com/qiime2/q2view.git
14 changes: 11 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: all lint test install dev clean distclean
.PHONY: all lint test init-view-submodule vendor-view install dev clean distclean

PYTHON ?= python
PREFIX ?= $(CONDA_PREFIX)
Expand All @@ -12,20 +12,28 @@ lint:
test: all
QIIMETEST= pytest

vendor-view: all
git submodule init && \
git submodule update && \
cd q2view && \
npm install --no-save && \
npm run vendor --VENDOR_DIR=../q2cli/assets/view

# install pytest-xdist plugin for the `-n auto` argument.
mystery-stew: all
MYSTERY_STEW= pytest -k mystery_stew -n auto

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

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

clean: distclean
rm -rf ./q2cli/assets

distclean: ;
213 changes: 156 additions & 57 deletions q2cli/builtin/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,72 +511,171 @@ def _merge_metadata(paths):
return metadata


@tools.command(short_help='View a QIIME 2 Visualization.',
help="Displays a QIIME 2 Visualization until the command "
"exits. To open a QIIME 2 Visualization so it can be "
"used after the command exits, use 'qiime tools extract'.",
@tools.command(short_help='View a QIIME 2 Result.',
help="Displays a QIIME 2 Result until the command exits. To "
"open a QIIME 2 Visualization so it can be used after the "
"command exits, use 'qiime tools extract'.",
cls=ToolCommand)
@click.argument('visualization-path', metavar='VISUALIZATION',
@click.argument('result-path', metavar='RESULT',
type=click.Path(file_okay=True, dir_okay=False, readable=True))
@click.option('--index-extension', required=False, default='html',
help='The extension of the index file that should be opened. '
'[default: html]')
def view(visualization_path, index_extension):
@click.option('--port', required=False, type=click.IntRange(1024, 65535),
default=None, help='The port to serve the webapp on.')
@click.option('--verbose', is_flag=True,
help='Display all GET requests in the terminal.')
def view(result_path, port, verbose):
# Get the abspath to the result
result_path = os.path.abspath(result_path)

# Guard headless envs from having to import anything large
import sys
from qiime2 import Visualization
from q2cli.util import _load_input
from q2cli.core.config import CONFIG
if not os.getenv("DISPLAY") and sys.platform != "darwin":

if not os.getenv('DISPLAY') and sys.platform != 'darwin':
raise click.UsageError(
'Visualization viewing is currently not supported in headless '
'environments. You can view Visualizations (and Artifacts) at '
'https://view.qiime2.org, or move the Visualization to an '
'environment with a display and view it with `qiime tools view`.')
'Result viewing is currently not supported in headless '
'environments. You can view Results at https://view.qiime2.org, '
'or move the Result to an environment with a display and view it '
'with `qiime tools view`.')

if index_extension.startswith('.'):
index_extension = index_extension[1:]
import signal
import random
import tempfile
import threading
import http.server
import contextlib

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

index_paths = visualization.get_index_paths(relative=False)
from q2cli.core.config import CONFIG

if index_extension not in index_paths:
raise click.BadParameter(
'No index %s file is present in the archive. Available index '
'extensions are: %s' % (index_extension,
', '.join(index_paths.keys())))
else:
index_path = index_paths[index_extension]
launch_status = click.launch(index_path)
if launch_status != 0:
click.echo(CONFIG.cfg_style('error', 'Viewing visualization '
'failed while attempting to open '
f'{index_path}'), err=True)
else:
while True:
click.echo(
"Press the 'q' key, Control-C, or Control-D to quit. This "
"view may no longer be accessible or work correctly after "
"quitting.", nl=False)
# There is currently a bug in click.getchar where translation
# of Control-C and Control-D into KeyboardInterrupt and
# EOFError (respectively) does not work on Python 3. The code
# here should continue to work as expected when the bug is
# fixed in Click.
#
# https://github.com/pallets/click/issues/583
try:
char = click.getchar()
click.echo()
if char in {'q', '\x03', '\x04'}:
break
except (KeyboardInterrupt, EOFError):
break
# Load and extract result
result = Result.load(result_path)

extracted_path = os.path.join(tempfile.gettempdir(), str(result.uuid))
if not os.path.exists(extracted_path):
result.extract(result_path, tempfile.gettempdir())

# This ought to look like a session id generated by normal view
CHAR_SET = 'abcdefghijklmnopqrstuvwxyz0123456789'
SESSION_LEN = 11
session = ''.join(random.choice(CHAR_SET) for i in range(SESSION_LEN))

# Start server
class Handler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
# Redirect the output from these requests to devnull if not verbose
with open(os.devnull, 'w') as devnull:
with contextlib.redirect_stderr(
sys.stderr if verbose else devnull):
# Determine if this is a request for the file we are
# supposed to be viewing
if self.path == result_path:
if not os.path.exists(self.path):
self.send_error(404)
else:
self.send_response(200)
with open(self.path, 'rb') as file:
self.wfile.write(file.read())
# Determine if this is a request for a file within the
# visualization
elif self.path.startswith(f'/_/{session}/{result.uuid}/'):
file_path = self.path.split(str(result.uuid))[1]
file_path = extracted_path + file_path
file_path = os.path.abspath(file_path)

if not os.path.exists(file_path) or \
not file_path.startswith(extracted_path):
self.send_error(404)
else:
self.send_response(200)
self.send_header('Access-Control-Allow-Origin',
'*')
self.end_headers()

with open(file_path, 'rb') as file:
self.wfile.write(file.read())
# Otherwise default to super class. This will respond
# appropriately to requests for assets that are part of the
# vendored view app and will reject any requests for files
# outside the served directory
else:
super().do_GET()

# Get the path to the packaged vendored view
# TODO: This won't work if we start packaging QIIME 2 as a wheel, we will
# have to reimplement this used importlib.resources and it may be mildly
# annoying to make things work properly. It's hard to tell right now since
# we do not use a wheel.
# https://docs.python.org/3/library/importlib.resources.html
import importlib
MODULE_INIT = importlib.import_module('q2cli').__file__
MODULE_BASE_DIR = os.path.abspath(os.path.dirname(MODULE_INIT))
VENDOR_PATH = os.path.join(MODULE_BASE_DIR, 'assets', 'view')

# Set up the server socket
import socket

server_socket = socket.socket()
# If port is None then slap a 0 into here to get a free port
server_socket.bind(('localhost', 0 if port is None else port))
# Get the port off the opened socket, if a port was passed, this will set
# the port to itself no harm done. If no port was passed, this will get the
# open port that .bind found.
port = server_socket.getsockname()[1]

# Start up the server
server = http.server.HTTPServer(
('localhost', port), lambda *_: Handler(*_, directory=VENDOR_PATH),
bind_and_activate=False)
server.socket = server_socket

# Don't listen until the server is already going
server_socket.listen(0)
click.echo(f'Agent started on port: {port}')

# Stop server on termination of main thread
def stop():
server.shutdown()
sys.exit(0)

signal.signal(signal.SIGTERM, stop)

# Start the server in a new thread
thread = threading.Thread(target=server.serve_forever)
thread.daemon = True
thread.start()

# Open page on server
url = f'http://localhost:{port}?file={result_path}&session={session}'
launch_status = click.launch(url)
click.echo('Your view should open in your default browser shortly. You '
f'may open it manually at the URL: {url}')

# Yell if there was an error
if launch_status != 0:
click.echo(
CONFIG.cfg_style('error', 'Viewing result failed while attempting '
f'to open {result_path}'),
err=True)

# Wait for shut down request
while True:
click.echo("Press the 'q' key, Control-C, or Control-D to quit. This "
"view may no longer be accessible or work correctly after "
"quitting.")
# There is currently a bug in click.getchar where translation
# of Control-C and Control-D into KeyboardInterrupt and
# EOFError (respectively) does not work on Python 3. The code
# here should continue to work as expected when the bug is
# fixed in Click.
#
# https://github.com/pallets/click/issues/583
try:
char = click.getchar()
click.echo()
if char in {'q', '\x03', '\x04'}:
break
except (KeyboardInterrupt, EOFError):
break


@tools.command(short_help="Extract a QIIME 2 Artifact or Visualization "
Expand Down
1 change: 1 addition & 0 deletions q2view
Submodule q2view added at 918723
Loading