comparison jb2_webserver.py @ 0:53c2be00bb6f draft default tip

planemo upload for repository https://github.com/galaxyproject/tools-iuc/tree/master/tools/jbrowse2 commit 0a86c88a95b0d1cc49d84544136de6556b95320f
author bgruening
date Wed, 05 Jun 2024 08:15:49 +0000
parents
children
comparison
equal deleted inserted replaced
-1:000000000000 0:53c2be00bb6f
1 #!/usr/bin/env python3
2 # spec: simplest python web server with range support and multithreading that takes root path,
3 # port and bind address as command line arguments; by default uses the current dir as webroot,
4 # port 8000 and bind address of 0.0.0.0
5 # borrowed from https://github.com/danvk/RangeHTTPServer
6 # and reborrowed from https://gist.github.com/glowinthedark/b99900abe935e4ab4857314d647a9068
7 #
8 # The Apache 2.0 license copy in this repository is distributed with this code in accordance with that licence.
9 # https://www.apache.org/licenses/LICENSE-2.0.txt
10 # This part is not MIT licenced like the other components.
11
12 # APPENDIX: How to apply the Apache License to your work.
13
14 # To apply the Apache License to your work, attach the following
15 # boilerplate notice, with the fields enclosed by brackets "[]"
16 # replaced with your own identifying information. (Don't include
17 # the brackets!) The text should be enclosed in the appropriate
18 # comment syntax for the file format. We also recommend that a
19 # file or class name and description of purpose be included on the
20 # same "printed page" as the copyright notice for easier
21 # identification within third-party archives.
22
23 # Licensed under the Apache License, Version 2.0 (the "License");
24 # you may not use this file except in compliance with the License.
25 # You may obtain a copy of the License at
26
27 # http://www.apache.org/licenses/LICENSE-2.0
28
29 # Unless required by applicable law or agreed to in writing, software
30 # distributed under the License is distributed on an "AS IS" BASIS,
31 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
32 # See the License for the specific language governing permissions and
33 # limitations under the License.
34
35
36 import argparse
37 import functools
38 import os
39 import re
40 import socketserver
41 import webbrowser
42 from http.server import SimpleHTTPRequestHandler
43
44
45 DEFAULT_PORT = 8081
46
47
48 def copy_byte_range(infile, outfile, start=None, stop=None, bufsize=16 * 1024):
49 """Like shutil.copyfileobj, but only copy a range of the streams.
50
51 Both start and stop are inclusive.
52 """
53 if start is not None:
54 infile.seek(start)
55 while 1:
56 to_read = min(bufsize, stop + 1 - infile.tell() if stop else bufsize)
57 buf = infile.read(to_read)
58 if not buf:
59 break
60 outfile.write(buf)
61
62
63 BYTE_RANGE_RE = re.compile(r"bytes=(\d+)-(\d+)?$")
64
65
66 def parse_byte_range(byte_range):
67 """Returns the two numbers in 'bytes=123-456' or throws ValueError.
68
69 The last number or both numbers may be None.
70 """
71 if byte_range.strip() == "":
72 return None, None
73
74 m = BYTE_RANGE_RE.match(byte_range)
75 if not m:
76 raise ValueError("Invalid byte range %s" % byte_range)
77
78 first, last = [x and int(x) for x in m.groups()]
79 if last and last < first:
80 raise ValueError("Invalid byte range %s" % byte_range)
81 return first, last
82
83
84 class RangeRequestHandler(SimpleHTTPRequestHandler):
85 """Adds support for HTTP 'Range' requests to SimpleHTTPRequestHandler
86
87 The approach is to:
88 - Override send_head to look for 'Range' and respond appropriately.
89 - Override copyfile to only transmit a range when requested.
90 """
91
92 def handle(self):
93 try:
94 SimpleHTTPRequestHandler.handle(self)
95 except Exception:
96 # ignored, thrown whenever the client aborts streaming (broken pipe)
97 pass
98
99 def send_head(self):
100 if "Range" not in self.headers:
101 self.range = None
102 return SimpleHTTPRequestHandler.send_head(self)
103 try:
104 self.range = parse_byte_range(self.headers["Range"])
105 except ValueError:
106 self.send_error(400, "Invalid byte range")
107 return None
108 first, last = self.range
109
110 # Mirroring SimpleHTTPServer.py here
111 path = self.translate_path(self.path)
112 f = None
113 ctype = self.guess_type(path)
114 try:
115 f = open(path, "rb")
116 except IOError:
117 self.send_error(404, "File not found")
118 return None
119
120 fs = os.fstat(f.fileno())
121 file_len = fs[6]
122 if first >= file_len:
123 self.send_error(416, "Requested Range Not Satisfiable")
124 return None
125
126 self.send_response(206)
127 self.send_header("Content-type", ctype)
128
129 if last is None or last >= file_len:
130 last = file_len - 1
131 response_length = last - first + 1
132
133 self.send_header("Content-Range", "bytes %s-%s/%s" % (first, last, file_len))
134 self.send_header("Content-Length", str(response_length))
135 self.send_header("Last-Modified", self.date_time_string(fs.st_mtime))
136 self.end_headers()
137 return f
138
139 def end_headers(self):
140 self.send_header("Accept-Ranges", "bytes")
141 return SimpleHTTPRequestHandler.end_headers(self)
142
143 def copyfile(self, source, outputfile):
144 if not self.range:
145 return SimpleHTTPRequestHandler.copyfile(self, source, outputfile)
146
147 # SimpleHTTPRequestHandler uses shutil.copyfileobj, which doesn't let
148 # you stop the copying before the end of the file.
149 start, stop = self.range # set in send_head()
150 copy_byte_range(source, outputfile, start, stop)
151
152
153 class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
154 allow_reuse_address = True
155
156
157 if __name__ == "__main__":
158 parser = argparse.ArgumentParser(
159 description="Tiny Python Web Server supporting range requests, for local viewing of unzipped Galaxy JBrowse2 configurations"
160 )
161 parser.add_argument(
162 "--root",
163 default=os.getcwd(),
164 help="Root path to serve files from (default: current working directory)",
165 )
166 parser.add_argument(
167 "--port",
168 type=int,
169 default=DEFAULT_PORT,
170 help=f"Port to listen on (default: {DEFAULT_PORT})",
171 )
172 parser.add_argument(
173 "--bind",
174 default="127.0.0.1",
175 help="IP address to bind to (default: 127.0.0.1 - use 0.0.0.0 to allow access on your network)",
176 )
177 args = parser.parse_args()
178
179 handler = functools.partial(RangeRequestHandler, directory=args.root)
180
181 webbrowser.open(f"http://{args.bind}:{args.port}")
182
183 with ThreadedTCPServer((args.bind, args.port), handler) as httpd:
184 print(
185 f"Serving HTTP on {args.bind} port {args.port} (http://{args.bind}:{args.port}/)"
186 )
187 httpd.serve_forever()