/
view.py
223 lines (173 loc) · 7.12 KB
/
view.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
"""
Visualizes a completed pathogen build in Auspice, the Nextstrain visualization app.
The data directory should contain sets of Auspice JSON¹ files like
<name>.json
or
<name>_tree.json
<name>_meta.json
¹ <https://nextstrain.github.io/auspice/introduction/how-to-run#input-file-formats>
"""
import re
import netifaces as net
from pathlib import Path
from typing import Iterable
from .. import runner
from ..argparse import add_extended_help_flags
from ..runner import docker, native
from ..util import colored, remove_suffix, warn
from ..volume import store_volume
def register_parser(subparser):
"""
%(prog)s [options] <directory>
%(prog)s --help
"""
parser = subparser.add_parser("view", help = "View pathogen build", add_help = False)
# Support --help and --help-all
add_extended_help_flags(parser)
parser.add_argument(
"--allow-remote-access",
help = "Allow other computers on the network to access the website",
action = "store_true")
parser.add_argument(
"--port",
help = "Listen on the given port instead of the default port %(default)s",
metavar = "<number>",
type = int,
default = 4000)
# Positional parameters
parser.add_argument(
"directory",
help = "Path to directory containing JSONs for Auspice",
metavar = "<directory>",
action = store_volume("auspice/data"))
# Register runners; only Docker is supported for now.
runner.register_runners(
parser,
exec = ["auspice", "view", "--verbose", "--datasetDir=."],
runners = [docker, native])
return parser
def run(opts):
# Ensure our data path is a directory that exists
data_dir = opts.auspice_data.src
if not data_dir.is_dir():
warn("Error: Data path \"%s\" does not exist or is not a directory." % data_dir)
if not data_dir.is_absolute():
warn()
warn("Perhaps your current working directory is different than you expect?")
return 1
# Find the available dataset paths
datasets = dataset_paths(data_dir)
# Setup the published port. Default to localhost for security reasons
# unless explicitly told otherwise.
#
# The environment variables HOST and PORT are respected by auspice's
# cli/view.js. HOST requires a new enough version of Auspice; 1.35.7 and
# earlier always listen on 0.0.0.0 or ::.
host = "0.0.0.0" if opts.allow_remote_access else "127.0.0.1"
port = opts.port
env = {
'HOST': host,
'PORT': str(port)
}
# These are docker-specific details which will only be used when the
# docker runner (--docker flag) is in use.
opts.docker_args = [
*opts.docker_args,
# auspice's cli (probably thanks to Express or Node?) when run in the
# circumstances of the container seems to ignore signals (like SIGINT,
# ^C, or SIGTERM), so run it under an init process that does respect
# signals.
"--init",
# Inside the container, always bind to all interfaces. This is
# required for Docker to forward a port from the container's host into
# the container because of how it does port publishing. Note that
# container ports aren't automatically published outside the container,
# so this still doesn't allow arbitrary access from the outside world.
# The published port on the container's host is still bound to
# 127.0.0.1 by default.
"--env=HOST=0.0.0.0",
# Publish the port
"--publish=%s:%d:%d" % (host, port, port),
]
# Find the best remote address if we're allowing remote access. While we
# listen on all interfaces (0.0.0.0), only the local host can connect to
# that successfully. Remote hosts need a real IP on the network, which we
# do our best to discover. If something goes wrong, ignore it and leave
# the host IP as-is (0.0.0.0); it'll at least work for local access.
if opts.allow_remote_access:
try:
remote_address = best_remote_address()
except:
pass
else:
host = remote_address
# Show a helpful message about where to connect
print_url(host, port, datasets)
return runner.run(opts, working_volume = opts.auspice_data, extra_env = env)
def dataset_paths(data_dir: Path) -> Iterable[str]:
"""
Returns a :py:class:`set` of Auspice (not filesystem) paths for datasets in
*data_dir*.
"""
# v2: All *.json files which don't end with a known sidecar or v1 suffix.
sidecar_suffixes = {"meta", "tree", "root-sequence", "seq", "sequences", "tip-frequencies", "entropy"}
def sidecar_file(path):
return any(path.name.endswith("_%s.json" % suffix) for suffix in sidecar_suffixes)
datasets_v2 = set(
path.stem.replace("_", "/")
for path in data_dir.glob("*.json")
if not sidecar_file(path))
# v1: All *_tree.json files with corresponding *_meta.json files.
def meta_exists(path):
return path.with_name(remove_suffix("_tree.json", path.name) + "_meta.json").exists()
datasets_v1 = set(
re.sub(r"_tree$", "", path.stem).replace("_", "/")
for path in data_dir.glob("*_tree.json")
if meta_exists(path))
return datasets_v2 | datasets_v1
def print_url(host, port, datasets):
"""
Prints a list of available dataset URLs, if any. Otherwise, prints a
generic URL.
"""
def url(path = None):
return colored(
"blue",
"http://{host}:{port}/{path}".format(
host = host,
port = port,
path = path if path is not None else ""))
horizontal_rule = colored("green", "—" * 78)
print()
print(horizontal_rule)
if len(datasets):
print(" The following datasets should be available in a moment:")
for path in sorted(datasets, key = str.casefold):
print(" • %s" % url(path))
else:
print(" Open <%s> in your browser." % url())
print()
print(" ", colored("yellow", "Warning: No datasets detected."))
print(horizontal_rule)
print()
def best_remote_address():
"""
Returns the "best" non-localback IP address for the local host, if
possible. The "best" IP address is that bound to either the default
gateway interface, if any, else the arbitrary first interface found.
IPv4 is preferred, but IPv6 will be used if no IPv4 interfaces/addresses
are available.
"""
default_gateway = net.gateways().get("default", {})
default_interface = default_gateway.get(net.AF_INET, (None, None))[1] \
or default_gateway.get(net.AF_INET6, (None, None))[1] \
or net.interfaces()[0]
interface_addresses = net.ifaddresses(default_interface).get(net.AF_INET) \
or net.ifaddresses(default_interface).get(net.AF_INET6) \
or []
addresses = [
address["addr"]
for address in interface_addresses
if address.get("addr")
]
return addresses[0] if addresses else None