OLD | NEW |
(Empty) | |
| 1 from cgi import escape |
| 2 import gzip as gzip_module |
| 3 import re |
| 4 import time |
| 5 import types |
| 6 import uuid |
| 7 from cStringIO import StringIO |
| 8 |
| 9 |
| 10 def resolve_content(response): |
| 11 rv = "".join(item for item in response.iter_content()) |
| 12 if type(rv) == unicode: |
| 13 rv = rv.encode(response.encoding) |
| 14 return rv |
| 15 |
| 16 |
| 17 class Pipeline(object): |
| 18 pipes = {} |
| 19 |
| 20 def __init__(self, pipe_string): |
| 21 self.pipe_functions = self.parse(pipe_string) |
| 22 |
| 23 def parse(self, pipe_string): |
| 24 functions = [] |
| 25 for item in PipeTokenizer().tokenize(pipe_string): |
| 26 if not item: |
| 27 break |
| 28 if item[0] == "function": |
| 29 functions.append((self.pipes[item[1]], [])) |
| 30 elif item[0] == "argument": |
| 31 functions[-1][1].append(item[1]) |
| 32 return functions |
| 33 |
| 34 def __call__(self, request, response): |
| 35 for func, args in self.pipe_functions: |
| 36 response = func(request, response, *args) |
| 37 return response |
| 38 |
| 39 |
| 40 class PipeTokenizer(object): |
| 41 def __init__(self): |
| 42 #This whole class can likely be replaced by some regexps |
| 43 self.state = None |
| 44 |
| 45 def tokenize(self, string): |
| 46 self.string = string |
| 47 self.state = self.func_name_state |
| 48 self._index = 0 |
| 49 while self.state: |
| 50 yield self.state() |
| 51 yield None |
| 52 |
| 53 def get_char(self): |
| 54 if self._index >= len(self.string): |
| 55 return None |
| 56 rv = self.string[self._index] |
| 57 self._index += 1 |
| 58 return rv |
| 59 |
| 60 def func_name_state(self): |
| 61 rv = "" |
| 62 while True: |
| 63 char = self.get_char() |
| 64 if char is None: |
| 65 self.state = None |
| 66 if rv: |
| 67 return ("function", rv) |
| 68 else: |
| 69 return None |
| 70 elif char == "(": |
| 71 self.state = self.argument_state |
| 72 return ("function", rv) |
| 73 elif char == "|": |
| 74 if rv: |
| 75 return ("function", rv) |
| 76 else: |
| 77 rv += char |
| 78 |
| 79 def argument_state(self): |
| 80 rv = "" |
| 81 while True: |
| 82 char = self.get_char() |
| 83 if char is None: |
| 84 self.state = None |
| 85 return ("argument", rv) |
| 86 elif char == "\\": |
| 87 rv += self.get_escape() |
| 88 if rv is None: |
| 89 #This should perhaps be an error instead |
| 90 return ("argument", rv) |
| 91 elif char == ",": |
| 92 return ("argument", rv) |
| 93 elif char == ")": |
| 94 self.state = self.func_name_state |
| 95 return ("argument", rv) |
| 96 else: |
| 97 rv += char |
| 98 |
| 99 def get_escape(self): |
| 100 char = self.get_char() |
| 101 escapes = {"n": "\n", |
| 102 "r": "\r", |
| 103 "t": "\t"} |
| 104 return escapes.get(char, char) |
| 105 |
| 106 |
| 107 class pipe(object): |
| 108 def __init__(self, *arg_converters): |
| 109 self.arg_converters = arg_converters |
| 110 self.max_args = len(self.arg_converters) |
| 111 self.min_args = 0 |
| 112 opt_seen = False |
| 113 for item in self.arg_converters: |
| 114 if not opt_seen: |
| 115 if isinstance(item, opt): |
| 116 opt_seen = True |
| 117 else: |
| 118 self.min_args += 1 |
| 119 else: |
| 120 if not isinstance(item, opt): |
| 121 raise ValueError("Non-optional argument cannot follow option
al argument") |
| 122 |
| 123 def __call__(self, f): |
| 124 def inner(request, response, *args): |
| 125 if not (self.min_args <= len(args) <= self.max_args): |
| 126 raise ValueError("Expected between %d and %d args, got %d" % |
| 127 (self.min_args, self.max_args, len(args))) |
| 128 arg_values = tuple(f(x) for f, x in zip(self.arg_converters, args)) |
| 129 return f(request, response, *arg_values) |
| 130 Pipeline.pipes[f.__name__] = inner |
| 131 #We actually want the undecorated function in the main namespace |
| 132 return f |
| 133 |
| 134 |
| 135 class opt(object): |
| 136 def __init__(self, f): |
| 137 self.f = f |
| 138 |
| 139 def __call__(self, arg): |
| 140 return self.f(arg) |
| 141 |
| 142 |
| 143 def nullable(func): |
| 144 def inner(arg): |
| 145 if arg.lower() == "null": |
| 146 return None |
| 147 else: |
| 148 return func(arg) |
| 149 return inner |
| 150 |
| 151 |
| 152 def boolean(arg): |
| 153 if arg.lower() in ("true", "1"): |
| 154 return True |
| 155 elif arg.lower() in ("false", "0"): |
| 156 return False |
| 157 raise ValueError |
| 158 |
| 159 |
| 160 @pipe(int) |
| 161 def status(request, response, code): |
| 162 """Alter the status code. |
| 163 |
| 164 :param code: Status code to use for the response.""" |
| 165 response.status = code |
| 166 return response |
| 167 |
| 168 |
| 169 @pipe(str, str, opt(boolean)) |
| 170 def header(request, response, name, value, append=False): |
| 171 """Set a HTTP header. |
| 172 |
| 173 Replaces any existing HTTP header of the same name unless |
| 174 append is set, in which case the header is appended without |
| 175 replacement. |
| 176 |
| 177 :param name: Name of the header to set. |
| 178 :param value: Value to use for the header. |
| 179 :param append: True if existing headers should not be replaced |
| 180 """ |
| 181 if not append: |
| 182 response.headers.set(name, value) |
| 183 else: |
| 184 response.headers.append(name, value) |
| 185 return response |
| 186 |
| 187 |
| 188 @pipe(str) |
| 189 def trickle(request, response, delays): |
| 190 """Send the response in parts, with time delays. |
| 191 |
| 192 :param delays: A string of delays and amounts, in bytes, of the |
| 193 response to send. Each component is separated by |
| 194 a colon. Amounts in bytes are plain integers, whilst |
| 195 delays are floats prefixed with a single d e.g. |
| 196 d1:100:d2 |
| 197 Would cause a 1 second delay, would then send 100 bytes |
| 198 of the file, and then cause a 2 second delay, before sending |
| 199 the remainder of the file. |
| 200 |
| 201 If the last token is of the form rN, instead of sending the |
| 202 remainder of the file, the previous N instructions will be |
| 203 repeated until the whole file has been sent e.g. |
| 204 d1:100:d2:r2 |
| 205 Causes a delay of 1s, then 100 bytes to be sent, then a 2s de
lay |
| 206 and then a further 100 bytes followed by a two second delay |
| 207 until the response has been fully sent. |
| 208 """ |
| 209 def parse_delays(): |
| 210 parts = delays.split(":") |
| 211 rv = [] |
| 212 for item in parts: |
| 213 if item.startswith("d"): |
| 214 item_type = "delay" |
| 215 item = item[1:] |
| 216 value = float(item) |
| 217 elif item.startswith("r"): |
| 218 item_type = "repeat" |
| 219 value = int(item[1:]) |
| 220 if not value % 2 == 0: |
| 221 raise ValueError |
| 222 else: |
| 223 item_type = "bytes" |
| 224 value = int(item) |
| 225 if len(rv) and rv[-1][0] == item_type: |
| 226 rv[-1][1] += value |
| 227 else: |
| 228 rv.append((item_type, value)) |
| 229 return rv |
| 230 |
| 231 delays = parse_delays() |
| 232 if not delays: |
| 233 return response |
| 234 content = resolve_content(response) |
| 235 modified_content = [] |
| 236 offset = [0] |
| 237 |
| 238 def sleep(seconds): |
| 239 def inner(): |
| 240 time.sleep(seconds) |
| 241 return "" |
| 242 return inner |
| 243 |
| 244 def add_content(delays, repeat=False): |
| 245 for i, (item_type, value) in enumerate(delays): |
| 246 if item_type == "bytes": |
| 247 modified_content.append(content[offset[0]:offset[0] + value]) |
| 248 offset[0] += value |
| 249 elif item_type == "delay": |
| 250 modified_content.append(sleep(value)) |
| 251 elif item_type == "repeat": |
| 252 assert i == len(delays) - 1 |
| 253 while offset[0] < len(content): |
| 254 add_content(delays[-(value + 1):-1], True) |
| 255 |
| 256 if not repeat and offset[0] < len(content): |
| 257 modified_content.append(content[offset[0]:]) |
| 258 |
| 259 add_content(delays) |
| 260 |
| 261 response.content = modified_content |
| 262 return response |
| 263 |
| 264 |
| 265 @pipe(nullable(int), opt(nullable(int))) |
| 266 def slice(request, response, start, end=None): |
| 267 """Send a byte range of the response body |
| 268 |
| 269 :param start: The starting offset. Follows python semantics including |
| 270 negative numbers. |
| 271 |
| 272 :param end: The ending offset, again with python semantics and None |
| 273 (spelled "null" in a query string) to indicate the end of |
| 274 the file. |
| 275 """ |
| 276 content = resolve_content(response) |
| 277 response.content = content[start:end] |
| 278 return response |
| 279 |
| 280 |
| 281 class ReplacementTokenizer(object): |
| 282 def ident(scanner, token): |
| 283 return ("ident", token) |
| 284 |
| 285 def index(scanner, token): |
| 286 token = token[1:-1] |
| 287 try: |
| 288 token = int(token) |
| 289 except ValueError: |
| 290 token = unicode(token, "utf8") |
| 291 return ("index", token) |
| 292 |
| 293 def var(scanner, token): |
| 294 token = token[:-1] |
| 295 return ("var", token) |
| 296 |
| 297 def tokenize(self, string): |
| 298 return self.scanner.scan(string)[0] |
| 299 |
| 300 scanner = re.Scanner([(r"\$\w+:", var), |
| 301 (r"\$?\w+(?:\(\))?", ident), |
| 302 (r"\[[^\]]*\]", index)]) |
| 303 |
| 304 |
| 305 class FirstWrapper(object): |
| 306 def __init__(self, params): |
| 307 self.params = params |
| 308 |
| 309 def __getitem__(self, key): |
| 310 try: |
| 311 return self.params.first(key) |
| 312 except KeyError: |
| 313 return "" |
| 314 |
| 315 |
| 316 @pipe() |
| 317 def sub(request, response): |
| 318 """Substitute environment information about the server and request into the
script. |
| 319 |
| 320 The format is a very limited template language. Substitutions are |
| 321 enclosed by {{ and }}. There are several avaliable substitutions: |
| 322 |
| 323 host |
| 324 A simple string value and represents the primary host from which the |
| 325 tests are being run. |
| 326 domains |
| 327 A dictionary of available domains indexed by subdomain name. |
| 328 ports |
| 329 A dictionary of lists of ports indexed by protocol. |
| 330 location |
| 331 A dictionary of parts of the request URL. Valid keys are |
| 332 'server, 'scheme', 'host', 'hostname', 'port', 'path' and 'query'. |
| 333 'server' is scheme://host:port, 'host' is hostname:port, and query |
| 334 includes the leading '?', but other delimiters are omitted. |
| 335 headers |
| 336 A dictionary of HTTP headers in the request. |
| 337 GET |
| 338 A dictionary of query parameters supplied with the request. |
| 339 uuid() |
| 340 A pesudo-random UUID suitable for usage with stash |
| 341 |
| 342 So for example in a setup running on localhost with a www |
| 343 subdomain and a http server on ports 80 and 81:: |
| 344 |
| 345 {{host}} => localhost |
| 346 {{domains[www]}} => www.localhost |
| 347 {{ports[http][1]}} => 81 |
| 348 |
| 349 |
| 350 It is also possible to assign a value to a variable name, which must start w
ith |
| 351 the $ character, using the ":" syntax e.g. |
| 352 |
| 353 {{$id:uuid()} |
| 354 |
| 355 Later substitutions in the same file may then refer to the variable |
| 356 by name e.g. |
| 357 |
| 358 {{$id}} |
| 359 """ |
| 360 content = resolve_content(response) |
| 361 |
| 362 new_content = template(request, content) |
| 363 |
| 364 response.content = new_content |
| 365 return response |
| 366 |
| 367 def template(request, content): |
| 368 #TODO: There basically isn't any error handling here |
| 369 tokenizer = ReplacementTokenizer() |
| 370 |
| 371 variables = {} |
| 372 |
| 373 def config_replacement(match): |
| 374 content, = match.groups() |
| 375 |
| 376 tokens = tokenizer.tokenize(content) |
| 377 |
| 378 if tokens[0][0] == "var": |
| 379 variable = tokens[0][1] |
| 380 tokens = tokens[1:] |
| 381 else: |
| 382 variable = None |
| 383 |
| 384 assert tokens[0][0] == "ident" and all(item[0] == "index" for item in to
kens[1:]), tokens |
| 385 |
| 386 field = tokens[0][1] |
| 387 |
| 388 if field in variables: |
| 389 value = variables[field] |
| 390 elif field == "headers": |
| 391 value = request.headers |
| 392 elif field == "GET": |
| 393 value = FirstWrapper(request.GET) |
| 394 elif field in request.server.config: |
| 395 value = request.server.config[tokens[0][1]] |
| 396 elif field == "location": |
| 397 value = {"server": "%s://%s:%s" % (request.url_parts.scheme, |
| 398 request.url_parts.hostname, |
| 399 request.url_parts.port), |
| 400 "scheme": request.url_parts.scheme, |
| 401 "host": "%s:%s" % (request.url_parts.hostname, |
| 402 request.url_parts.port), |
| 403 "hostname": request.url_parts.hostname, |
| 404 "port": request.url_parts.port, |
| 405 "path": request.url_parts.path, |
| 406 "query": "?%s" % request.url_parts.query} |
| 407 elif field == "uuid()": |
| 408 value = str(uuid.uuid4()) |
| 409 else: |
| 410 raise Exception("Undefined template variable %s" % field) |
| 411 |
| 412 for item in tokens[1:]: |
| 413 value = value[item[1]] |
| 414 |
| 415 assert isinstance(value, (int,) + types.StringTypes), tokens |
| 416 |
| 417 if variable is not None: |
| 418 variables[variable] = value |
| 419 |
| 420 #Should possibly support escaping for other contexts e.g. script |
| 421 #TODO: read the encoding of the response |
| 422 return escape(unicode(value)).encode("utf-8") |
| 423 |
| 424 template_regexp = re.compile(r"{{([^}]*)}}") |
| 425 new_content, count = template_regexp.subn(config_replacement, content) |
| 426 |
| 427 return new_content |
| 428 |
| 429 @pipe() |
| 430 def gzip(request, response): |
| 431 """This pipe gzip-encodes response data. |
| 432 |
| 433 It sets (or overwrites) these HTTP headers: |
| 434 Content-Encoding is set to gzip |
| 435 Content-Length is set to the length of the compressed content |
| 436 """ |
| 437 content = resolve_content(response) |
| 438 response.headers.set("Content-Encoding", "gzip") |
| 439 |
| 440 out = StringIO() |
| 441 with gzip_module.GzipFile(fileobj=out, mode="w") as f: |
| 442 f.write(content) |
| 443 response.content = out.getvalue() |
| 444 |
| 445 response.headers.set("Content-Length", len(response.content)) |
| 446 |
| 447 return response |
OLD | NEW |