python: Added a simple function for doing directory-local "imports".
[ashd.git] / python3 / ashd / wsgidir.py
1 """WSGI handler for serving chained WSGI modules from physical files
2
3 The WSGI handler in this module ensures that the SCRIPT_FILENAME
4 variable is properly set in every request and points out a file that
5 exists and is readable. It then dispatches the request in one of two
6 ways: If the header X-Ash-Python-Handler is set in the request, its
7 value is used as the name of a handler object to dispatch the request
8 to; otherwise, the file extension of the SCRIPT_FILENAME is used to
9 determine the handler object.
10
11 The name of a handler object is specified as a string, which is split
12 along its last constituent dot. The part left of the dot is the name
13 of a module, which is imported; and the part right of the dot is the
14 name of an object in that module, which should be a callable adhering
15 to the WSGI specification. Alternatively, the module part may be
16 omitted (such that the name is a string with no dots), in which case
17 the handler object is looked up from this module.
18
19 By default, this module will handle files with the extensions `.wsgi'
20 or `.wsgi3' using the `chain' handler, which chainloads such files and
21 runs them as independent WSGI applications. See its documentation for
22 details.
23
24 This module itself contains both an `application' and a `wmain'
25 object. If this module is used by ashd-wsgi(1) or scgi-wsgi(1) so that
26 its wmain function is called, arguments can be specified to it to
27 install handlers for other file extensions. Such arguments take the
28 form `.EXT=HANDLER', where EXT is the file extension to be handled,
29 and HANDLER is a handler name, as described above. For example, the
30 argument `.fpy=my.module.foohandler' can be given to pass requests for
31 `.fpy' files to the function `foohandler' in the module `my.module'
32 (which must, of course, be importable). When writing such handler
33 functions, you may want to use the getmod() function in this module.
34 """
35
36 import sys, os, threading, types, logging, importlib, getopt
37 from . import wsgiutil
38
39 __all__ = ["application", "wmain", "getmod", "cachedmod", "chain"]
40
41 log = logging.getLogger("wsgidir")
42
43 class cachedmod(object):
44     """Cache entry for modules loaded by getmod()
45
46     Instances of this class are returned by the getmod()
47     function. They contain three data attributes:
48      * mod - The loaded module
49      * lock - A threading.Lock object, which can be used for
50        manipulating this instance in a thread-safe manner
51      * mtime - The time the file was last modified
52
53     Additional data attributes can be arbitrarily added for recording
54     any meta-data about the module.
55     """
56     def __init__(self, mod = None, mtime = -1):
57         self.lock = threading.Lock()
58         self.mod = mod
59         self.mtime = mtime
60
61 modcache = {}
62 cachelock = threading.Lock()
63
64 def mangle(path):
65     ret = ""
66     for c in path:
67         if c.isalnum():
68             ret += c
69         else:
70             ret += "_"
71     return ret
72
73 def getmod(path):
74     """Load the given file as a module, caching it appropriately
75
76     The given file is loaded and compiled into a Python module. The
77     compiled module is cached and returned upon subsequent requests
78     for the same file, unless the file has changed (as determined by
79     its mtime), in which case the cached module is discarded and the
80     new file contents are reloaded in its place.
81
82     The return value is an instance of the cachedmod class, which can
83     be used for locking purposes and for storing arbitrary meta-data
84     about the module. See its documentation for details.
85     """
86     sb = os.stat(path)
87     with cachelock:
88         if path in modcache:
89             entry = modcache[path]
90         else:
91             entry = [threading.Lock(), None]
92             modcache[path] = entry
93     with entry[0]:
94         if entry[1] is None or sb.st_mtime > entry[1].mtime:
95             with open(path, "rb") as f:
96                 text = f.read()
97             code = compile(text, path, "exec")
98             mod = types.ModuleType(mangle(path))
99             mod.__file__ = path
100             exec(code, mod.__dict__)
101             entry[1] = cachedmod(mod, sb.st_mtime)
102         return entry[1]
103
104 def importlocal(filename):
105     import inspect
106     cf = inspect.currentframe()
107     if cf is None: raise ImportError("could not get current frame")
108     if cf.f_back is None: raise ImportError("could not get caller frame")
109     cfile = cf.f_back.f_code.co_filename
110     if not os.path.exists(cfile):
111         raise ImportError("caller is not in a proper file")
112     path = os.path.realpath(os.path.join(os.path.dirname(cfile), filename))
113     if '.' not in os.path.basename(path):
114         for ext in [".pyl", ".py"]:
115             if os.path.exists(path + ext):
116                 path += ext
117                 break
118         else:
119             raise ImportError("could not resolve file: " + filename)
120     else:
121         if not os.path.exists(cfile):
122             raise ImportError("no such file: " + filename)
123     return getmod(path).mod
124
125 class handler(object):
126     def __init__(self):
127         self.lock = threading.Lock()
128         self.handlers = {}
129         self.exts = {}
130         self.addext("wsgi", "chain")
131         self.addext("wsgi3", "chain")
132
133     def resolve(self, name):
134         with self.lock:
135             if name in self.handlers:
136                 return self.handlers[name]
137             p = name.rfind('.')
138             if p < 0:
139                 return globals()[name]
140             mname = name[:p]
141             hname = name[p + 1:]
142             mod = importlib.import_module(mname)
143             ret = getattr(mod, hname)
144             self.handlers[name] = ret
145             return ret
146         
147     def addext(self, ext, handler):
148         self.exts[ext] = self.resolve(handler)
149
150     def handle(self, env, startreq):
151         if not "SCRIPT_FILENAME" in env:
152             log.error("wsgidir called without SCRIPT_FILENAME set")
153             return wsgiutil.simpleerror(env, startreq, 500, "Internal Error", "The server is erroneously configured.")
154         path = env["SCRIPT_FILENAME"]
155         if not os.access(path, os.R_OK):
156             log.error("%s: not readable" % path)
157             return wsgiutil.simpleerror(env, startreq, 500, "Internal Error", "The server is erroneously configured.")
158         if "HTTP_X_ASH_PYTHON_HANDLER" in env:
159             try:
160                 handler = self.resolve(env["HTTP_X_ASH_PYTHON_HANDLER"])
161             except Exception:
162                 log.error("could not load handler %s" % env["HTTP_X_ASH_PYTHON_HANDLER"], exc_info=sys.exc_info())
163                 return wsgiutil.simpleerror(env, startreq, 500, "Internal Error", "The server is erroneously configured.")
164         else:
165             base = os.path.basename(path)
166             p = base.rfind('.')
167             if p < 0:
168                 log.error("wsgidir called with neither X-Ash-Python-Handler nor a file extension: %s" % path)
169                 return wsgiutil.simpleerror(env, startreq, 500, "Internal Error", "The server is erroneously configured.")
170             ext = base[p + 1:]
171             if not ext in self.exts:
172                 log.error("unregistered file extension: %s" % ext)
173                 return wsgiutil.simpleerror(env, startreq, 500, "Internal Error", "The server is erroneously configured.")
174             handler = self.exts[ext]
175         return handler(env, startreq)
176
177 def wmain(*argv):
178     """Main function for ashd(7)-compatible WSGI handlers
179
180     Returns the `application' function. If any arguments are given,
181     they are parsed according to the module documentation.
182     """
183     hnd = handler()
184     ret = hnd.handle
185
186     opts, args = getopt.getopt(argv, "-V")
187     for o, a in opts:
188         if o == "-V":
189             import wsgiref.validate
190             ret = wsgiref.validate.validator(ret)
191
192     for arg in args:
193         if arg[0] == '.':
194             p = arg.index('=')
195             hnd.addext(arg[1:p], arg[p + 1:])
196     return ret
197
198 def chain(env, startreq):
199     """Chain-loading WSGI handler
200     
201     This handler loads requested files, compiles them and loads them
202     into their own modules. The compiled modules are cached and reused
203     until the file is modified, in which case the previous module is
204     discarded and the new file contents are loaded into a new module
205     in its place. When chaining such modules, an object named `wmain'
206     is first looked for and called with no arguments if found. The
207     object it returns is then used as the WSGI application object for
208     that module, which is reused until the module is reloaded. If
209     `wmain' is not found, an object named `application' is looked for
210     instead. If found, it is used directly as the WSGI application
211     object.
212     """
213     path = env["SCRIPT_FILENAME"]
214     try:
215         mod = getmod(path)
216     except Exception:
217         log.error("Exception occurred when loading %s" % path, exc_info=sys.exc_info())
218         return wsgiutil.simpleerror(env, startreq, 500, "Internal Error", "Could not load WSGI handler.")
219     entry = None
220     if mod is not None:
221         with mod.lock:
222             if hasattr(mod, "entry"):
223                 entry = mod.entry
224             else:
225                 if hasattr(mod.mod, "wmain"):
226                     entry = mod.mod.wmain()
227                 elif hasattr(mod.mod, "application"):
228                     entry = mod.mod.application
229                 mod.entry = entry
230     if entry is not None:
231         return entry(env, startreq)
232     return wsgiutil.simpleerror(env, startreq, 500, "Internal Error", "Invalid WSGI handler.")
233
234 application = handler().handle