2 import sys
, os
, re
, datetime
, compress
, cStringIO
, traceback
, cgitb
, Queue
3 from multiprocessing
import Pool
, Pipe
, Lock
, Process
, cpu_count
5 # from fcgi import WSGIServer
6 from fcgi
import FastCGIServer
7 from fcgi
.fcgiutil
import flatten
, GeneratorException
8 from template
import Template
, TemplateExecutionError
, ellipsis
, pad
11 Settings
= settings
.Settings
12 __all__
= ["LimoServer",]
15 def __init__(self
, s
):
17 for t
in s
.split(';'):
23 if self
.get(k
,None).value
is None:
26 log("Duplicate cookie value: %s %s %s" % (k
,v
,self
[k
]))
29 raise Exception("len(t) did not == 2? %d %s from t: %s from s: %s" % (len(p
), p
, t
, s
))
31 return ';'.join(['%s=%s' % (k
,v
) for k
,v
in self
.items()])
33 # wrapped version of the dict accessors
34 def get(self
,k
,default
=None):
36 return self
.__getitem
__(k
)
38 return Cookie(name
=k
,value
=default
)
39 def __getitem__(self
,k
):
40 return dict.__getitem
__(self
,k
)
41 def __setitem__(self
,k
,v
):
42 if type(v
) in (str,unicode):
43 dict.__setitem
__(self
,k
,Cookie(name
=k
,value
=v
))
44 elif v
.__class
__.__name
__ is 'Cookie':
45 dict.__setitem
__(self
,k
,v
)
47 raise TypeError("Expected string or Cookie, got %s" % v
.__class
__.__name
__)
48 def __delitem__(self
,k
):
49 dict.__delitem
__(self
,k
)
52 def __init__(self
,name
,value
,expires
=None,path
=None,domain
=None):
55 self
.expires
= expires
60 cookie
= "%s=%s" % (self
.name
,self
.value
)
61 expires
= "; Expires=%s" % self
.expires
.strftime("%a, %d %b %Y %H:%M:%S GMT")\
62 if type(self
.expires
) is datetime
.datetime
else ""
63 path
= "; Path=%s" % self
.path
if self
.path
is not None else ""
64 domain
= "; Domain=%s" % self
.domain
if self
.domain
is not None else ""
65 max_age
= "; Max-Age=%d" % self
.max_age
if self
.max_age
> -1 else ""
75 self
.status
= '200 OK'
76 self
.cookies
= Cookies('')
78 self
.headers
['Content-Type'] = 'text/html'
81 ret
= [('Status',self
.status
),]
82 for k
,v
in self
.headers
.items():
84 for k
,v
in self
.cookies
.items():
85 ret
.append(('Set-Cookie',str(v
)))
88 def headerstring(self
):
89 return '\r\n'.join(["%s: %s" %(k
,v
) for k
,v
in self
.getheaders()])+'\r\n\r\n'
92 return self
.headerstring()
95 def __init__(self
, environ
, stdin
=None):
96 for k
,v
in environ
.items():
98 # add POST_DATA if we find an input stream to read
99 self
["POST_DATA"] = ''.join(v
.readlines())
100 if not k
.startswith('wsgi.'):
102 if stdin
is not None:
103 self
["POST_DATA"] = ''.join(stdin
.readlines())
104 # add QUERY_STRING and REQUEST_URI if missing
105 if self
.get("QUERY_STRING",None) is None:
106 self
["QUERY_STRING"] = ""
107 if self
.get('REQUEST_URI',None) is None:
108 q
= ('?'+self
["QUERY_STRING"]) if len(self
["QUERY_STRING"]) > 0 else ""
109 self
['REQUEST_URI'] = self
['SCRIPT_NAME']+q
110 environ
['REQUEST_URI'] = self
['REQUEST_URI'] # write this back, in case we need it later during an error redirect out of here
111 # normalize script_name
112 i
= self
['REQUEST_URI'].find('?')
114 self
['SCRIPT_NAME'] = self
['REQUEST_URI'][:i
]
116 self
['SCRIPT_NAME'] = self
['REQUEST_URI']
117 # IIS puts all sorts of crap into the environment, which we should remove
119 for k
,v
in self
.items():
120 if not (k
.startswith('HTTP')\
121 or k
in ('SERVER_SOFTWARE','SCRIPT_NAME','LOCAL_ADDR','REQUEST_METHOD','POST_DATA',\
122 'SERVER_PROTOCOL','QUERY_STRING','CONTENT_LENGTH','SERVER_NAME','REMOTE_ADDR','SERVER_ADDR',\
123 'PATH_TRANSLATED','SERVER_PORT','PATH_INFO','GATEWAY_INTERFACE','REMOTE_HOST','REQUEST_URI')):
125 self
._discarded
.append(k
)
126 self
.cookies
= Cookies(self
.get('HTTP_COOKIE', ''))
127 # log the request headers
128 if Settings
.logRequestHeaders
:
129 log("REQUEST: %s" % str(self
))
131 return "self = %s; cookies = %s; discarded = %s;" % ('{'+', '.join(["'%s': '%s'" % (k
,v
) for k
,v
in self
.items()\
132 if not k
.startswith('wsgi')])+'}', str(self
.cookies
), str(self
._discarded
))
135 _freeList
= Queue
.Queue()
140 p
= LimoProcess
._freeList
.get()
143 log("Exception in LimoProcess.get: %s" % str(e
))
147 #return "[%.2f%%]" % ((1.0 - (LimoProcess._free/LimoProcess._total))*100)
148 return "[%d/%d]" % (LimoProcess
._freeList
.qsize(), len(LimoProcess
._all
))
149 def __init__(self
, target
,index
=-1,skipModels
=False):
150 """ 'target' is a function like: def request(pipe, index, skip=False), that reads requests from the pipe and sends responses back over it.
151 To work on win32, the target function cannot be a member of anything (static or otherwise).
152 If skipModels is True, then the Model objects won't be initialized for that instance (since presumably they already were imported by some previous launch in the very recent future)
156 self
.is_started
= False
157 self
.skip_models
= skipModels
159 self
.index
= len(LimoProcess
._all
)
164 LimoProcess
._freeList
.put(self
)
167 LimoProcess
._all
.append(self
)
168 self
.parent_conn
, child_conn
= Pipe(True) # duplex pipes to talk to the children
169 self
.p
= Process(target
=self
.target
, args
=(child_conn
,self
.index
, self
.skip_models
))
173 self
.send("INIT") # this will block until the process is awake enough to recieve it
174 ack
= self
.recv() # and then again until it is awake enough to respond
175 assert ack
== "READY", "Expected READY, got: %s" % str(ack
) # and lets just make sure nothing got garbled
176 self
.is_started
= True
179 self
.stop(nicely
=False)
181 def stop(self
,nicely
=True):
182 # right away make sure people dont try to use us for something while we are going down
183 LimoProcess
._all
.remove(self
)
184 self
.is_started
= False
185 # ask the process nicely to die
189 log("Waiting for response")
191 assert ack
== "CLOSE", "Expected CLOSE, got: %s" % str(ack
)
192 log("Handler acknowledged stop request.")
193 # then just pull the plug
195 self
.is_started
= False
199 # masquerade as a Pipe
201 return self
.parent_conn
.send(data
)
203 return self
.parent_conn
.recv()
207 class LimoServer(Loggable
):
210 self
.last_restart
= datetime
.datetime
.now()
212 def stop(self
,message
=None):
213 self
.log("SERVER GOT DEATH REQUEST: %s" % message
)
217 log("[%s] %s" % (self
.socket
, msg
))
219 def start(self
,count
):
220 assert count
> 0, "Must have at least one handler starting"
222 # start a ghetto process pool
223 # these will be our back end request handlers
225 for i
in range(count
):
226 self
.procs
.append(LimoProcess(fcgi_handler
,i
,skipModels
))
229 self
.log("Process %d started." % i
)
230 # TODO: Start On Demand, up to a maximum
233 now
= datetime
.datetime
.now()
234 diff
= ms_elapsed(self
.last_restart
)
236 log("Detected two quick restarts (%.0fms), interpreting as an exit request." % diff
)
243 def run(self
, socket
=Settings
.tempDir
+"/limo.sock", count
=None):
245 self
.socket
= str(socket
)
249 self
.log("Starting %d handler processes" % count
)
251 self
.log("All handlers started.")
252 # then start the front facing fastcgi server
253 self
.request_count
= 0
254 # ws = WSGIServer(self.wsgi_request, bindAddress = socket, multithreaded=True)
255 self
.log("Binding address: %s" % (str(socket
)))
256 ws
= FastCGIServer(bindAddress
= socket
)
257 restart
= ws
.run(self
.fcgi_request
,log_traffic
=Settings
.logAllRequestTraffic
)
259 self
.log("Server restarting...")
262 self
.run(socket
=socket
,count
=0)
266 self
.log("Server exiting...")
268 # the new fastcgi server eschews wsgi for more stream-ability
269 def fcgi_request(self
, request
):
270 start_wait
= datetime
.datetime
.now()
271 run_pipe
= LimoProcess
.get()
272 wait_time
= ms_elapsed(start_wait
)
273 saw_header_end
= False
276 start
= datetime
.datetime
.now()
277 request
= Request(request
.params
, request
.stdin
)
278 run_pipe
.send(request
)
282 data
= run_pipe
.recv()
283 if type(data
) in (str, unicode):
285 if status
is None and data
.startswith('Status: '):
286 status
= data
[8:data
.find('\r',8)]
287 if not saw_header_end
:
288 n
= data
.find('\r\n\r\n')
290 headers
+= data
[:n
+2] # grab the last chunk of headers plus one crlf
291 saw_header_end
= True
295 elif data
.__class
__.__name
__ is 'EOFToken':
297 elif data
.__class
__.__name
__ is 'ErrorToken':
300 self
.log("Handler returned ErrorToken: %s" % (data
.msg
))
302 raise ValueError("Don't know what to do with response: %s %s" % (str(type(data
)), str(data
)))
303 load
= LimoProcess
.load()+"+%d"%run
_pipe
.index
304 render_time
= ms_elapsed(start
)
307 if not Settings
.disableAllLogging
:
309 wait_time
= "+%.0f" % (wait_time
)
313 bytes
= "%.2fKb" % (bytes
/ 1024.0)
315 bytes
= "%sb" % bytes
316 log("%s %s %s %s %s %s %s %s %s" % (
317 (str(start
)[5:-3] if Settings
.logRequestStartTime
else ""),\
318 (pad(str(load
),9) if Settings
.logRequestLoad
else ""),\
319 pad(request
['REQUEST_METHOD'],4),\
320 pad(ellipsis(request
['REQUEST_URI'],40,17), 40),\
322 pad("(%s)"%bytes
,10),\
323 pad("(%.0f%sms)"%(render_time
, wait_time
),11),\
324 ("[sha:%s]"%ellipsis
(request
.cookies
.get('sha','None').value
,7,0)[:-3] if Settings
.logSessionToken
else ""),\
325 ("\n[headers:\n%s]" % headers
) if Settings
.logResponseHeaders
else "",\
328 class EOFToken(object):
329 """ This is a token passed from LimoProcess handlers to the dispatcher, to signal they are done sending data. """
332 class ErrorToken(object):
333 """ This token is used by a handler to send data to the dispatcher that should be sent directly to the web server's stderr.
334 If the fatal flag is True, the handler is considered dead, and is never .free()'d again.
336 def __init__(self
, msg
, fatal
=False):
337 assert type(msg
) in (str, unicode)
341 def get_redirect_response(e
,defaultLocation
="/"):
342 response
= Response()
343 response
.data
= e
.status
344 response
.status
= e
.status
345 have_location
= False
346 for item
in e
.headers
:
347 response
.headers
[item
[0]] = item
[1]
348 if item
[0] == "Location":
350 if not have_location
:
351 response
.headers
["Location"] = defaultLocation
352 # log("REDIRECT: %s Location: %s" % (e.status, response.headers["Location"]))
355 def get_status_response(e
):
356 response
= Response()
357 response
.status
= e
.status
358 for item
in e
.headers
:
359 response
.headers
[item
[0]] = item
[1]
362 def get_error_response():
363 (a
,b
,c
) = sys
.exc_info()
364 if ( a
== GeneratorException
):
365 x
,y
,z
= (type(b
.error
), b
.error
, b
.tb
)
368 log("Handler saw an uncaught exception: %s: %s\n%s" % (a
,b
,'\n'.join(traceback
.format_tb(c
))))
369 response
= Response()
370 response
.status
= "200 OK"
371 response
.headers
["Content-Type"] = "text/html"
372 response
.data
= cgitb
.html((a
,b
,c
))
376 # copies of fcgi_handler are the actual handler 'threads'
377 # each one waits for a request to land in its pipe, then processes it
378 # and writes the result back out the pipe
379 def fcgi_handler(pipe
, index
=0, skipModels
=False):
380 from limoutil
import log
, profile
381 from cache
import Cache
386 # block until we get a new request in the pipe
387 request
= pipe
.recv()
388 if request
== "INIT": # initialize the handler
389 Settings
= reload(settings
).Settings
390 log("handler: Initializing modules...")
391 modules
.init(skipModels
=skipModels
)
392 log("handler: Building sitemap...")
393 sitemap
= LimoModel().getSiteMap()
394 log("handler: Done initializing and ready to process requests.")
396 elif request
== "STOP": # stop the handler gracefully
397 log("Stopping on request")
399 else: # begin a normal request phase
401 Settings
= reload(settings
).Settings
402 response
= Response()
403 session
= Session(request
, response
)
404 messages
= session
.getMessages() # TODO: pass session messages along so they get on the page somehow
406 session
.clearMessages()
407 log("Got messages from previous page: %s" % messages
)
408 regions
= {'entire': '', 'left': '', 'right': '', 'main': '', 'head': '', 'header': '', 'footer': ''}
409 url
= request
["REQUEST_URI"].split("?")[0].split("/")
410 # optionally, redirect to standardize URLs
411 if Settings
.strictScriptNameRedirects
:
412 s
= request
["SCRIPT_NAME"]
413 if len(s
) > 0 and s
[-1] != "/" and s
[-3] != "." and s
[-4] != ".":
415 q
= request
["QUERY_STRING"]
417 raise HTTPRedirect(fixed
, status
="301 Moved Permanently")
419 for i
in range(len(url
)):
420 step
= '/'.join(url
[:i
+1]) if i
> 0 else '/'
422 regions
.update(sitemap
[step
])
425 if Settings
.logRequestRegions
:
426 log("REGIONS: %s" % str(regions
))
427 # define some one-off helper functions
428 # get a list of real blocks, given a comma-sep list of block names
429 def text_to_blocks(text
, request
, response
, session
):
431 return [modules
.blocks
[block
](request
, response
, session
)
432 for block
in reversed(text
.split(",")) if block
!= '']
434 raise KeyError("%s not in %s" % (block
, modules
.blocks
.keys()))
435 # set up the blocks prior to rendering, call init() and action()
436 # redirect if an action added a message to the session
437 def init_blocks(b
, request
, response
, session
):
438 ret
= text_to_blocks(regions
.get(b
,''), request
, response
, session
)
442 # log("Calling %s.action()..." % str(type(block).__name__))
444 if len(session
.getMessages()) > 0:
445 log("Got messages: %s" % session
.getMessages())
446 raise HTTPRedirect(request
["SCRIPT_NAME"])
447 except HTTPStatusError
, e
:
448 log("Passing along status error: %s %s" % (str(type(e
)), str(e
)))
451 # session.addMessage("Action Failure: %s" % str(e))
453 log("Action Failure: %s %s %s" % (str(e
[0]), str(e
[1]), traceback
.format_tb(e
[2])))
456 # given a list of blocks, b, call render() on each item and return the result
457 def render_blocks(b
):
459 for block
in regions
[b
]:
460 ret
= block
.render(ret
)
462 # this generator, on each call, will return one piece of the final result
463 def result_generator(request
, response
, session
):
464 if Settings
.developmentMode
:
465 modules
.init(skipModels
=False) # will only re-init changed modules
466 if len(regions
.get('entire','')) > 0:
467 regions
['entire'] = init_blocks('entire', request
, response
, session
)
468 yield render_blocks('entire')
470 for region
in regions
.keys():
471 if region
== 'entire':
473 regions
[region
] = init_blocks(region
, request
, response
, session
)
475 for region
in regions
.keys():
476 if region
== 'entire':
478 elif region
== 'head':
479 page
[region
] = render_blocks(region
)
481 page
[region
] = Template
.render("region", {'name':region
,'output':render_blocks(region
)})
482 page
['page_class'] = 'logged-in' if session
.user
is not None else 'not-logged-in'
483 yield Template
.render("page", page
)
484 # the final output will come out at the end of a series of generator-filters
486 # define the initial input generator as our result generator from above
487 # this is the basic request generator that will pump LIMO's regions and return raw text or html
488 gen
= result_generator(request
, response
, session
)
489 # for now, compression is configured based on user agent
490 # so, first get the configured compression type, like 'gzip','deflate'
491 compression
= Settings
.getCompression(request
.get('HTTP_USER_AGENT',''))
492 # then if the user-agent really says it supports it, lets add a compressor filter
493 if request
.get('HTTP_ACCEPT_ENCODING','').find(compression
) > -1:
494 if Settings
.logCompression
:
495 log("server: Using compression: %s" % compression
);
496 if compression
== "deflate":
497 gen
= compress
.generate_deflate(gen
, cache_file
=request
.get('LIMO_CACHE_FILE',None))
499 gen
= compress
.generate_gzip(gen
, cache_file
=request
.get('LIMO_CACHE_FILE',None))
500 response
.headers
['Vary'] = 'Accept-Encoding'
501 response
.headers
['Content-Encoding'] = compression
502 elif compression
!= None:
503 # log("server: Compression enabled, but not supported by client: %s" % request.get('HTTP_ACCEPT_ENCODING'))
505 # if we dont want to immediately write pieces of the page to the network as they are available
506 # then use a quick buffering filter
507 if Settings
.bufferOutput
:
509 buf
= cStringIO
.StringIO()
514 # yield None # TODO: remove this
515 # by yielding none here we keep loop overhead the same (for comparison sake), but dont produce any incremental output
516 # this allows this setting to test the difference between Transfer-Encoding: chunked responses and not
520 # log("Buffered output collapsed %d writes" % count)
521 # then wrap the result generator in a buffering filter
523 # now that all the output layers are in place
526 # execute the whole generator tree and write the results out
527 for text
in flatten(gen
):
528 if text
is not None and len(text
) > 0:
531 pipe
.send(response
.headerstring() + text
)
536 # write an EOF to mark the end
537 pipe
.send(EOFToken())
538 except GeneratorException
, e
: # generator exceptions bundle the real errors, so lets unpack and catch the real thing
539 if e
.error
.__class
__.__name
__ == 'HTTPRedirect':
540 pipe
.send(get_redirect_response(e
.error
, request
['REQUEST_URI']).headerstring())
541 pipe
.send(EOFToken())
543 elif e
.error
.__class
__.__name
__ == 'HTTPStatusError':
544 pipe
.send(get_status_response(e
.error
).headerstring())
545 pipe
.send(EOFToken())
548 response
= get_error_response()
549 pipe
.send(response
.headerstring())
550 pipe
.send(response
.data
)
551 pipe
.send(EOFToken())
553 except HTTPRedirect
, e
:
554 pipe
.send(get_redirect_response(e
, request
['REQUEST_URI']).headerstring())
555 pipe
.send(EOFToken())
557 except HTTPStatusError
, e
:
558 pipe
.send(get_status_response(e
).headerstring())
559 pipe
.send(EOFToken())
561 except KeyboardInterrupt: # the main process catches the sigint and tells us what to do about it
564 response
= get_error_response()
565 pipe
.send(response
.headerstring())
566 pipe
.send(response
.data
)
567 pipe
.send(EOFToken())
569 finally: # do our best to clean up and finalize all the request objects
571 # make sure all the .css and .js files required in this request are written out to the disk
572 # (as the html we just sent out referenced them, so the browser is likely to request them soon)
573 Cache
.CSS
.saveContent()
574 Cache
.JS
.saveContent()
578 # and that the requirements are cleared before processing the next request
584 # write the session back to the database
587 log("Error saving session: %s" % str(e
))
589 if Settings
.profileServer
and Settings
.profileLogQueries
:
590 for (sql
, ms
) in Query
.getQueryLog():
591 log("QUERY: [%.2f] %s" % (float(ms
), sql
))
592 Query
.clearQueryLog()
593 except EOFError: # the pipe died (maybe the parent process suddenly died)
596 response
= get_error_response()
597 pipe
.send(response
.headerstring())
598 pipe
.send(response
.data
)
599 pipe
.send(EOFToken())
600 pipe
.send(ErrorToken("Handler exiting due to error: %s %s" % (a
, b
), fatal
=True))
605 log("Handler exiting")
607 log("Handler closed")