@@ -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 "
0 commit comments