0001
0002
0003
0004import threading
0005import os
0006import socket
0007import logging
0008import atexit
0009import signal
0010import time
0011import posixpath
0012from paste import fileapp
0013from wphp import fcgi_app
0014
0015here = os.path.dirname(__file__)
0016default_php_ini = os.path.join(here, 'default-php.ini')
0017
0018class PHPApp(object):
0019
0020 def __init__(self, base_dir,
0021 php_script='php-cgi',
0022 php_ini=default_php_ini,
0023 php_options=None,
0024 fcgi_port=None,
0025 search_fcgi_port_starting=10000,
0026 logger='wphp'):
0027 """
0028 Create a WSGI wrapper around a PHP application.
0029
0030 `base_dir` is the root of the PHP application. This
0031 contains .php files, and potentially other static files. (@@:
0032 Currently there is no way to indicate files that should not be
0033 served, like ``.inc`` files or certain directories)
0034
0035 `php_script` is the path to the ``php-cgi`` script you want
0036 to use. By default it just looks on the ``$PATH`` for that
0037 file.
0038
0039 `php_ini` is the path to the ``php.ini`` file you want to use.
0040 An example (taken from the default PHP file) is in
0041 ``wphp/default-php.ini``. This allows you to customize the
0042 language environment that the PHP files run in.
0043
0044 `php_options` is a dictionary of config-name: value, of
0045 specific overrides for PHP options. For instance,
0046 ``{'magic_quotes_gpc': 'Off'}`` will turn off magic quotes.
0047
0048 PHP is started as a long-running FastCGI process. PHP (from
0049 what I can tell) only supports listening over IP sockets, so
0050 we must get a port for it. You may provide a specific port
0051 (with `fcgi_port`) or give a starting port number (default
0052 10000), and the first free port will be used.
0053 """
0054 self.base_dir = base_dir
0055 self.fcgi_port = fcgi_port
0056 self.php_script = php_script
0057 self.php_ini = php_ini
0058 if php_options is None:
0059 php_options = {}
0060 self.php_options = php_options
0061 self.search_fcgi_port_starting = search_fcgi_port_starting
0062 if isinstance(logger, basestring):
0063 logger = logging.getLogger(logger)
0064 self.logger = logger
0065
0066 self.lock = threading.Lock()
0067 self.child_pid = None
0068 self.fcgi_app = None
0069
0070 def __call__(self, environ, start_response):
0071 if self.child_pid is None:
0072 if environ['wsgi.multiprocess']:
0073 environ['wsgi.errors'].write(
0074 "wphp doesn't support multiprocess apps very well yet")
0075 self.create_child()
0076 path_info = environ.get('PATH_INFO', '').lstrip('/')
0077 script_filename, path_info = self.find_script(self.base_dir, path_info)
0078 if script_filename is None:
0079 exc = httpexceptions.HTTPNotFound()
0080 return exc(environ, start_response)
0081 script_name = posixpath.join(environ.get('SCRIPT_NAME', ''), script_filename)
0082 script_filename = posixpath.join(self.base_dir, script_filename)
0083 environ['SCRIPT_NAME'] = script_name
0084 environ['SCRIPT_FILENAME'] = os.path.join(self.base_dir, script_filename)
0085 environ['PATH_INFO'] = path_info
0086 ext = posixpath.splitext(script_filename)[1]
0087 if ext != '.php':
0088 app = fileapp.FileApp(script_filename)
0089 return app(environ, start_response)
0090 if (environ['REQUEST_METHOD'] == 'POST'
0091 and not environ.get('CONTENT_TYPE')):
0092 environ['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'
0093 return self.fcgi_app(environ, start_response)
0094
0095 def find_script(self, base, path):
0096 """
0097 Given a path, finds the file the path points to, and the extra
0098 portion of the path (PATH_INFO).
0099 """
0100 path_info = ''
0101 while 1:
0102 full_path = os.path.join(base, path)
0103 if not os.path.exists(full_path):
0104 if not path:
0105 return None, None
0106 path_info = '/' + os.path.basename(path) + path_info
0107 path = os.path.dirname(path)
0108 else:
0109 return path, path_info
0110
0111 def create_child(self):
0112 """
0113 Creates the PHP subprocess, with some locking and whatnot, and
0114 creates the WSGI application wrapper around that.
0115 """
0116 self.lock.acquire()
0117 try:
0118 if self.child_pid:
0119 return
0120 if self.logger:
0121 self.logger.info('Spawning PHP process')
0122 if self.fcgi_port is None:
0123 self.fcgi_port = self.find_port()
0124 self.spawn_php(self.fcgi_port)
0125 self.fcgi_app = fcgi_app.FCGIApp(
0126 connect=('127.0.0.1', self.fcgi_port),
0127 filterEnviron=False)
0128 finally:
0129 self.lock.release()
0130
0131 def spawn_php(self, port):
0132 """
0133 Creates a PHP process that listens for FastCGI requests on the
0134 given port.
0135 """
0136 cmd = [self.php_script,
0137 '-b',
0138 '127.0.0.1:%s' % self.fcgi_port]
0139 if self.php_ini:
0140 cmd.extend([
0141 '-c', self.php_ini])
0142 for name, value in self.php_options.items():
0143 cmd.extend([
0144 '-d', '%s=%s' % (name, value)])
0145 pid = os.fork()
0146 if pid:
0147 self.child_pid = pid
0148 if self.logger:
0149 self.logger.info(
0150 'PHP process spawned in PID %s, port %s'
0151 % (pid, self.fcgi_port))
0152 atexit.register(self.close)
0153
0154
0155 time.sleep(0.1)
0156
0157
0158 return
0159 os.execvpe(
0160 self.php_script,
0161 cmd,
0162 os.environ)
0163
0164 def find_port(self):
0165 """
0166 Finds a free port.
0167 """
0168 host = '127.0.0.1'
0169 port = self.search_fcgi_port_starting
0170 while 1:
0171 s = socket.socket(
0172 socket.AF_INET, socket.SOCK_STREAM)
0173 try:
0174 s.bind((host, port))
0175 except socket.error, e:
0176 port += 1
0177 else:
0178 s.close()
0179 return port
0180
0181 def close(self):
0182 """
0183 Kills the PHP subprocess. Registered with atexit, so the
0184 subprocess is killed when this process dies.
0185 """
0186
0187
0188 if self.child_pid:
0189 if self.logger:
0190 self.logger.info(
0191 "Killing PHP subprocess %s"
0192 % self.child_pid)
0193 os.kill(self.child_pid, signal.SIGKILL)
0194
0195def make_app(global_conf, **kw):
0196 """
0197 Create a PHP application (with Paste Deploy).
0198 """
0199 if 'fcgi_port' in kw:
0200 kw['fcgi_port'] = int(kw['fcgi_port'])
0201 if 'search_fcgi_port_starting':
0202 kw['search_fcgi_port_starting'] = int(kw['search_fcgi_port_starting'])
0203 kw.setdefault('php_options', {})
0204 for name, value in kw.items():
0205 if name.startswith('option '):
0206 name = name[len('option '):].strip()
0207 kw['php_options'][name] = value
0208 del kw[name]
0209 if 'base_dir' not in kw:
0210 raise ValueError(
0211 "base_dir option is required")
0212 return PHPApp(**kw)