• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org)
2# Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
3"""
4Routines for testing WSGI applications.
5
6Most interesting is the `TestApp <class-paste.fixture.TestApp.html>`_
7for testing WSGI applications, and the `TestFileEnvironment
8<class-paste.fixture.TestFileEnvironment.html>`_ class for testing the
9effects of command-line scripts.
10"""
11
12from __future__ import print_function
13
14import sys
15import random
16import mimetypes
17import time
18import os
19import shutil
20import smtplib
21import shlex
22import re
23import six
24import subprocess
25from six.moves import cStringIO as StringIO
26from six.moves.urllib.parse import urlencode
27from six.moves.urllib import parse as urlparse
28try:
29    # Python 3
30    from http.cookies import BaseCookie
31    from urllib.parse import splittype, splithost
32except ImportError:
33    # Python 2
34    from Cookie import BaseCookie
35    from urllib import splittype, splithost
36
37from paste import wsgilib
38from paste import lint
39from paste.response import HeaderDict
40
41def tempnam_no_warning(*args):
42    """
43    An os.tempnam with the warning turned off, because sometimes
44    you just need to use this and don't care about the stupid
45    security warning.
46    """
47    return os.tempnam(*args)
48
49class NoDefault(object):
50    pass
51
52def sorted(l):
53    l = list(l)
54    l.sort()
55    return l
56
57class Dummy_smtplib(object):
58
59    existing = None
60
61    def __init__(self, server):
62        import warnings
63        warnings.warn(
64            'Dummy_smtplib is not maintained and is deprecated',
65            DeprecationWarning, 2)
66        assert not self.existing, (
67            "smtplib.SMTP() called again before Dummy_smtplib.existing.reset() "
68            "called.")
69        self.server = server
70        self.open = True
71        self.__class__.existing = self
72
73    def quit(self):
74        assert self.open, (
75            "Called %s.quit() twice" % self)
76        self.open = False
77
78    def sendmail(self, from_address, to_addresses, msg):
79        self.from_address = from_address
80        self.to_addresses = to_addresses
81        self.message = msg
82
83    def install(cls):
84        smtplib.SMTP = cls
85
86    install = classmethod(install)
87
88    def reset(self):
89        assert not self.open, (
90            "SMTP connection not quit")
91        self.__class__.existing = None
92
93class AppError(Exception):
94    pass
95
96class TestApp(object):
97
98    # for py.test
99    disabled = True
100
101    def __init__(self, app, namespace=None, relative_to=None,
102                 extra_environ=None, pre_request_hook=None,
103                 post_request_hook=None):
104        """
105        Wraps a WSGI application in a more convenient interface for
106        testing.
107
108        ``app`` may be an application, or a Paste Deploy app
109        URI, like ``'config:filename.ini#test'``.
110
111        ``namespace`` is a dictionary that will be written to (if
112        provided).  This can be used with doctest or some other
113        system, and the variable ``res`` will be assigned everytime
114        you make a request (instead of returning the request).
115
116        ``relative_to`` is a directory, and filenames used for file
117        uploads are calculated relative to this.  Also ``config:``
118        URIs that aren't absolute.
119
120        ``extra_environ`` is a dictionary of values that should go
121        into the environment for each request.  These can provide a
122        communication channel with the application.
123
124        ``pre_request_hook`` is a function to be called prior to
125        making requests (such as ``post`` or ``get``). This function
126        must take one argument (the instance of the TestApp).
127
128        ``post_request_hook`` is a function, similar to
129        ``pre_request_hook``, to be called after requests are made.
130        """
131        if isinstance(app, (six.binary_type, six.text_type)):
132            from paste.deploy import loadapp
133            # @@: Should pick up relative_to from calling module's
134            # __file__
135            app = loadapp(app, relative_to=relative_to)
136        self.app = app
137        self.namespace = namespace
138        self.relative_to = relative_to
139        if extra_environ is None:
140            extra_environ = {}
141        self.extra_environ = extra_environ
142        self.pre_request_hook = pre_request_hook
143        self.post_request_hook = post_request_hook
144        self.reset()
145
146    def reset(self):
147        """
148        Resets the state of the application; currently just clears
149        saved cookies.
150        """
151        self.cookies = {}
152
153    def _make_environ(self):
154        environ = self.extra_environ.copy()
155        environ['paste.throw_errors'] = True
156        return environ
157
158    def get(self, url, params=None, headers=None, extra_environ=None,
159            status=None, expect_errors=False):
160        """
161        Get the given url (well, actually a path like
162        ``'/page.html'``).
163
164        ``params``:
165            A query string, or a dictionary that will be encoded
166            into a query string.  You may also include a query
167            string on the ``url``.
168
169        ``headers``:
170            A dictionary of extra headers to send.
171
172        ``extra_environ``:
173            A dictionary of environmental variables that should
174            be added to the request.
175
176        ``status``:
177            The integer status code you expect (if not 200 or 3xx).
178            If you expect a 404 response, for instance, you must give
179            ``status=404`` or it will be an error.  You can also give
180            a wildcard, like ``'3*'`` or ``'*'``.
181
182        ``expect_errors``:
183            If this is not true, then if anything is written to
184            ``wsgi.errors`` it will be an error.  If it is true, then
185            non-200/3xx responses are also okay.
186
187        Returns a `response object
188        <class-paste.fixture.TestResponse.html>`_
189        """
190        if extra_environ is None:
191            extra_environ = {}
192        # Hide from py.test:
193        __tracebackhide__ = True
194        if params:
195            if not isinstance(params, (six.binary_type, six.text_type)):
196                params = urlencode(params, doseq=True)
197            if '?' in url:
198                url += '&'
199            else:
200                url += '?'
201            url += params
202        environ = self._make_environ()
203        url = str(url)
204        if '?' in url:
205            url, environ['QUERY_STRING'] = url.split('?', 1)
206        else:
207            environ['QUERY_STRING'] = ''
208        self._set_headers(headers, environ)
209        environ.update(extra_environ)
210        req = TestRequest(url, environ, expect_errors)
211        return self.do_request(req, status=status)
212
213    def _gen_request(self, method, url, params=b'', headers=None, extra_environ=None,
214             status=None, upload_files=None, expect_errors=False):
215        """
216        Do a generic request.
217        """
218        if headers is None:
219            headers = {}
220        if extra_environ is None:
221            extra_environ = {}
222        environ = self._make_environ()
223        # @@: Should this be all non-strings?
224        if isinstance(params, (list, tuple, dict)):
225            params = urlencode(params)
226        if hasattr(params, 'items'):
227            # Some other multi-dict like format
228            params = urlencode(params.items())
229            if six.PY3:
230                params = params.encode('utf8')
231        if upload_files:
232            params = urlparse.parse_qsl(params, keep_blank_values=True)
233            content_type, params = self.encode_multipart(
234                params, upload_files)
235            environ['CONTENT_TYPE'] = content_type
236        elif params:
237            environ.setdefault('CONTENT_TYPE', 'application/x-www-form-urlencoded')
238        if '?' in url:
239            url, environ['QUERY_STRING'] = url.split('?', 1)
240        else:
241            environ['QUERY_STRING'] = ''
242        environ['CONTENT_LENGTH'] = str(len(params))
243        environ['REQUEST_METHOD'] = method
244        environ['wsgi.input'] = six.BytesIO(params)
245        self._set_headers(headers, environ)
246        environ.update(extra_environ)
247        req = TestRequest(url, environ, expect_errors)
248        return self.do_request(req, status=status)
249
250    def post(self, url, params=b'', headers=None, extra_environ=None,
251             status=None, upload_files=None, expect_errors=False):
252        """
253        Do a POST request.  Very like the ``.get()`` method.
254        ``params`` are put in the body of the request.
255
256        ``upload_files`` is for file uploads.  It should be a list of
257        ``[(fieldname, filename, file_content)]``.  You can also use
258        just ``[(fieldname, filename)]`` and the file content will be
259        read from disk.
260
261        Returns a `response object
262        <class-paste.fixture.TestResponse.html>`_
263        """
264        return self._gen_request('POST', url, params=params, headers=headers,
265                                 extra_environ=extra_environ,status=status,
266                                 upload_files=upload_files,
267                                 expect_errors=expect_errors)
268
269    def put(self, url, params=b'', headers=None, extra_environ=None,
270             status=None, upload_files=None, expect_errors=False):
271        """
272        Do a PUT request.  Very like the ``.get()`` method.
273        ``params`` are put in the body of the request.
274
275        ``upload_files`` is for file uploads.  It should be a list of
276        ``[(fieldname, filename, file_content)]``.  You can also use
277        just ``[(fieldname, filename)]`` and the file content will be
278        read from disk.
279
280        Returns a `response object
281        <class-paste.fixture.TestResponse.html>`_
282        """
283        return self._gen_request('PUT', url, params=params, headers=headers,
284                                 extra_environ=extra_environ,status=status,
285                                 upload_files=upload_files,
286                                 expect_errors=expect_errors)
287
288    def delete(self, url, params=b'', headers=None, extra_environ=None,
289               status=None, expect_errors=False):
290        """
291        Do a DELETE request.  Very like the ``.get()`` method.
292        ``params`` are put in the body of the request.
293
294        Returns a `response object
295        <class-paste.fixture.TestResponse.html>`_
296        """
297        return self._gen_request('DELETE', url, params=params, headers=headers,
298                                 extra_environ=extra_environ,status=status,
299                                 upload_files=None, expect_errors=expect_errors)
300
301
302
303
304    def _set_headers(self, headers, environ):
305        """
306        Turn any headers into environ variables
307        """
308        if not headers:
309            return
310        for header, value in headers.items():
311            if header.lower() == 'content-type':
312                var = 'CONTENT_TYPE'
313            elif header.lower() == 'content-length':
314                var = 'CONTENT_LENGTH'
315            else:
316                var = 'HTTP_%s' % header.replace('-', '_').upper()
317            environ[var] = value
318
319    def encode_multipart(self, params, files):
320        """
321        Encodes a set of parameters (typically a name/value list) and
322        a set of files (a list of (name, filename, file_body)) into a
323        typical POST body, returning the (content_type, body).
324        """
325        boundary = '----------a_BoUnDaRy%s$' % random.random()
326        content_type = 'multipart/form-data; boundary=%s' % boundary
327        if six.PY3:
328            boundary = boundary.encode('ascii')
329
330        lines = []
331        for key, value in params:
332            lines.append(b'--'+boundary)
333            line = 'Content-Disposition: form-data; name="%s"' % key
334            if six.PY3:
335                line = line.encode('utf8')
336            lines.append(line)
337            lines.append(b'')
338            line = value
339            if six.PY3 and isinstance(line, six.text_type):
340                line = line.encode('utf8')
341            lines.append(line)
342        for file_info in files:
343            key, filename, value = self._get_file_info(file_info)
344            lines.append(b'--'+boundary)
345            line = ('Content-Disposition: form-data; name="%s"; filename="%s"'
346                         % (key, filename))
347            if six.PY3:
348                line = line.encode('utf8')
349            lines.append(line)
350            fcontent = mimetypes.guess_type(filename)[0]
351            line = ('Content-Type: %s'
352                    % (fcontent or 'application/octet-stream'))
353            if six.PY3:
354                line = line.encode('utf8')
355            lines.append(line)
356            lines.append(b'')
357            lines.append(value)
358        lines.append(b'--' + boundary + b'--')
359        lines.append(b'')
360        body = b'\r\n'.join(lines)
361        return content_type, body
362
363    def _get_file_info(self, file_info):
364        if len(file_info) == 2:
365            # It only has a filename
366            filename = file_info[1]
367            if self.relative_to:
368                filename = os.path.join(self.relative_to, filename)
369            f = open(filename, 'rb')
370            content = f.read()
371            f.close()
372            return (file_info[0], filename, content)
373        elif len(file_info) == 3:
374            return file_info
375        else:
376            raise ValueError(
377                "upload_files need to be a list of tuples of (fieldname, "
378                "filename, filecontent) or (fieldname, filename); "
379                "you gave: %r"
380                % repr(file_info)[:100])
381
382    def do_request(self, req, status):
383        """
384        Executes the given request (``req``), with the expected
385        ``status``.  Generally ``.get()`` and ``.post()`` are used
386        instead.
387        """
388        if self.pre_request_hook:
389            self.pre_request_hook(self)
390        __tracebackhide__ = True
391        if self.cookies:
392            c = BaseCookie()
393            for name, value in self.cookies.items():
394                c[name] = value
395            hc = '; '.join(['='.join([m.key, m.value]) for m in c.values()])
396            req.environ['HTTP_COOKIE'] = hc
397        req.environ['paste.testing'] = True
398        req.environ['paste.testing_variables'] = {}
399        app = lint.middleware(self.app)
400        old_stdout = sys.stdout
401        out = CaptureStdout(old_stdout)
402        try:
403            sys.stdout = out
404            start_time = time.time()
405            raise_on_wsgi_error = not req.expect_errors
406            raw_res = wsgilib.raw_interactive(
407                app, req.url,
408                raise_on_wsgi_error=raise_on_wsgi_error,
409                **req.environ)
410            end_time = time.time()
411        finally:
412            sys.stdout = old_stdout
413            sys.stderr.write(out.getvalue())
414        res = self._make_response(raw_res, end_time - start_time)
415        res.request = req
416        for name, value in req.environ['paste.testing_variables'].items():
417            if hasattr(res, name):
418                raise ValueError(
419                    "paste.testing_variables contains the variable %r, but "
420                    "the response object already has an attribute by that "
421                    "name" % name)
422            setattr(res, name, value)
423        if self.namespace is not None:
424            self.namespace['res'] = res
425        if not req.expect_errors:
426            self._check_status(status, res)
427            self._check_errors(res)
428        res.cookies_set = {}
429        for header in res.all_headers('set-cookie'):
430            c = BaseCookie(header)
431            for key, morsel in c.items():
432                self.cookies[key] = morsel.value
433                res.cookies_set[key] = morsel.value
434        if self.post_request_hook:
435            self.post_request_hook(self)
436        if self.namespace is None:
437            # It's annoying to return the response in doctests, as it'll
438            # be printed, so we only return it is we couldn't assign
439            # it anywhere
440            return res
441
442    def _check_status(self, status, res):
443        __tracebackhide__ = True
444        if status == '*':
445            return
446        if isinstance(status, (list, tuple)):
447            if res.status not in status:
448                raise AppError(
449                    "Bad response: %s (not one of %s for %s)\n%s"
450                    % (res.full_status, ', '.join(map(str, status)),
451                       res.request.url, res.body))
452            return
453        if status is None:
454            if res.status >= 200 and res.status < 400:
455                return
456            body = res.body
457            if six.PY3:
458                body = body.decode('utf8', 'xmlcharrefreplace')
459            raise AppError(
460                "Bad response: %s (not 200 OK or 3xx redirect for %s)\n%s"
461                % (res.full_status, res.request.url,
462                   body))
463        if status != res.status:
464            raise AppError(
465                "Bad response: %s (not %s)" % (res.full_status, status))
466
467    def _check_errors(self, res):
468        if res.errors:
469            raise AppError(
470                "Application had errors logged:\n%s" % res.errors)
471
472    def _make_response(self, resp, total_time):
473        status, headers, body, errors = resp
474        return TestResponse(self, status, headers, body, errors,
475                            total_time)
476
477class CaptureStdout(object):
478
479    def __init__(self, actual):
480        self.captured = StringIO()
481        self.actual = actual
482
483    def write(self, s):
484        self.captured.write(s)
485        self.actual.write(s)
486
487    def flush(self):
488        self.actual.flush()
489
490    def writelines(self, lines):
491        for item in lines:
492            self.write(item)
493
494    def getvalue(self):
495        return self.captured.getvalue()
496
497class TestResponse(object):
498
499    # for py.test
500    disabled = True
501
502    """
503    Instances of this class are return by `TestApp
504    <class-paste.fixture.TestApp.html>`_
505    """
506
507    def __init__(self, test_app, status, headers, body, errors,
508                 total_time):
509        self.test_app = test_app
510        self.status = int(status.split()[0])
511        self.full_status = status
512        self.headers = headers
513        self.header_dict = HeaderDict.fromlist(self.headers)
514        self.body = body
515        self.errors = errors
516        self._normal_body = None
517        self.time = total_time
518        self._forms_indexed = None
519
520    def forms__get(self):
521        """
522        Returns a dictionary of ``Form`` objects.  Indexes are both in
523        order (from zero) and by form id (if the form is given an id).
524        """
525        if self._forms_indexed is None:
526            self._parse_forms()
527        return self._forms_indexed
528
529    forms = property(forms__get,
530                     doc="""
531                     A list of <form>s found on the page (instances of
532                     `Form <class-paste.fixture.Form.html>`_)
533                     """)
534
535    def form__get(self):
536        forms = self.forms
537        if not forms:
538            raise TypeError(
539                "You used response.form, but no forms exist")
540        if 1 in forms:
541            # There is more than one form
542            raise TypeError(
543                "You used response.form, but more than one form exists")
544        return forms[0]
545
546    form = property(form__get,
547                    doc="""
548                    Returns a single `Form
549                    <class-paste.fixture.Form.html>`_ instance; it
550                    is an error if there are multiple forms on the
551                    page.
552                    """)
553
554    _tag_re = re.compile(r'<(/?)([:a-z0-9_\-]*)(.*?)>', re.S|re.I)
555
556    def _parse_forms(self):
557        forms = self._forms_indexed = {}
558        form_texts = []
559        started = None
560        for match in self._tag_re.finditer(self.body):
561            end = match.group(1) == '/'
562            tag = match.group(2).lower()
563            if tag != 'form':
564                continue
565            if end:
566                assert started, (
567                    "</form> unexpected at %s" % match.start())
568                form_texts.append(self.body[started:match.end()])
569                started = None
570            else:
571                assert not started, (
572                    "Nested form tags at %s" % match.start())
573                started = match.start()
574        assert not started, (
575            "Danging form: %r" % self.body[started:])
576        for i, text in enumerate(form_texts):
577            form = Form(self, text)
578            forms[i] = form
579            if form.id:
580                forms[form.id] = form
581
582    def header(self, name, default=NoDefault):
583        """
584        Returns the named header; an error if there is not exactly one
585        matching header (unless you give a default -- always an error
586        if there is more than one header)
587        """
588        found = None
589        for cur_name, value in self.headers:
590            if cur_name.lower() == name.lower():
591                assert not found, (
592                    "Ambiguous header: %s matches %r and %r"
593                    % (name, found, value))
594                found = value
595        if found is None:
596            if default is NoDefault:
597                raise KeyError(
598                    "No header found: %r (from %s)"
599                    % (name, ', '.join([n for n, v in self.headers])))
600            else:
601                return default
602        return found
603
604    def all_headers(self, name):
605        """
606        Gets all headers by the ``name``, returns as a list
607        """
608        found = []
609        for cur_name, value in self.headers:
610            if cur_name.lower() == name.lower():
611                found.append(value)
612        return found
613
614    def follow(self, **kw):
615        """
616        If this request is a redirect, follow that redirect.  It
617        is an error if this is not a redirect response.  Returns
618        another response object.
619        """
620        assert self.status >= 300 and self.status < 400, (
621            "You can only follow redirect responses (not %s)"
622            % self.full_status)
623        location = self.header('location')
624        type, rest = splittype(location)
625        host, path = splithost(rest)
626        # @@: We should test that it's not a remote redirect
627        return self.test_app.get(location, **kw)
628
629    def click(self, description=None, linkid=None, href=None,
630              anchor=None, index=None, verbose=False):
631        """
632        Click the link as described.  Each of ``description``,
633        ``linkid``, and ``url`` are *patterns*, meaning that they are
634        either strings (regular expressions), compiled regular
635        expressions (objects with a ``search`` method), or callables
636        returning true or false.
637
638        All the given patterns are ANDed together:
639
640        * ``description`` is a pattern that matches the contents of the
641          anchor (HTML and all -- everything between ``<a...>`` and
642          ``</a>``)
643
644        * ``linkid`` is a pattern that matches the ``id`` attribute of
645          the anchor.  It will receive the empty string if no id is
646          given.
647
648        * ``href`` is a pattern that matches the ``href`` of the anchor;
649          the literal content of that attribute, not the fully qualified
650          attribute.
651
652        * ``anchor`` is a pattern that matches the entire anchor, with
653          its contents.
654
655        If more than one link matches, then the ``index`` link is
656        followed.  If ``index`` is not given and more than one link
657        matches, or if no link matches, then ``IndexError`` will be
658        raised.
659
660        If you give ``verbose`` then messages will be printed about
661        each link, and why it does or doesn't match.  If you use
662        ``app.click(verbose=True)`` you'll see a list of all the
663        links.
664
665        You can use multiple criteria to essentially assert multiple
666        aspects about the link, e.g., where the link's destination is.
667        """
668        __tracebackhide__ = True
669        found_html, found_desc, found_attrs = self._find_element(
670            tag='a', href_attr='href',
671            href_extract=None,
672            content=description,
673            id=linkid,
674            href_pattern=href,
675            html_pattern=anchor,
676            index=index, verbose=verbose)
677        return self.goto(found_attrs['uri'])
678
679    def clickbutton(self, description=None, buttonid=None, href=None,
680                    button=None, index=None, verbose=False):
681        """
682        Like ``.click()``, except looks for link-like buttons.
683        This kind of button should look like
684        ``<button onclick="...location.href='url'...">``.
685        """
686        __tracebackhide__ = True
687        found_html, found_desc, found_attrs = self._find_element(
688            tag='button', href_attr='onclick',
689            href_extract=re.compile(r"location\.href='(.*?)'"),
690            content=description,
691            id=buttonid,
692            href_pattern=href,
693            html_pattern=button,
694            index=index, verbose=verbose)
695        return self.goto(found_attrs['uri'])
696
697    def _find_element(self, tag, href_attr, href_extract,
698                      content, id,
699                      href_pattern,
700                      html_pattern,
701                      index, verbose):
702        content_pat = _make_pattern(content)
703        id_pat = _make_pattern(id)
704        href_pat = _make_pattern(href_pattern)
705        html_pat = _make_pattern(html_pattern)
706
707        _tag_re = re.compile(r'<%s\s+(.*?)>(.*?)</%s>' % (tag, tag),
708                             re.I+re.S)
709
710        def printlog(s):
711            if verbose:
712                print(s)
713
714        found_links = []
715        total_links = 0
716        for match in _tag_re.finditer(self.body):
717            el_html = match.group(0)
718            el_attr = match.group(1)
719            el_content = match.group(2)
720            attrs = _parse_attrs(el_attr)
721            if verbose:
722                printlog('Element: %r' % el_html)
723            if not attrs.get(href_attr):
724                printlog('  Skipped: no %s attribute' % href_attr)
725                continue
726            el_href = attrs[href_attr]
727            if href_extract:
728                m = href_extract.search(el_href)
729                if not m:
730                    printlog("  Skipped: doesn't match extract pattern")
731                    continue
732                el_href = m.group(1)
733            attrs['uri'] = el_href
734            if el_href.startswith('#'):
735                printlog('  Skipped: only internal fragment href')
736                continue
737            if el_href.startswith('javascript:'):
738                printlog('  Skipped: cannot follow javascript:')
739                continue
740            total_links += 1
741            if content_pat and not content_pat(el_content):
742                printlog("  Skipped: doesn't match description")
743                continue
744            if id_pat and not id_pat(attrs.get('id', '')):
745                printlog("  Skipped: doesn't match id")
746                continue
747            if href_pat and not href_pat(el_href):
748                printlog("  Skipped: doesn't match href")
749                continue
750            if html_pat and not html_pat(el_html):
751                printlog("  Skipped: doesn't match html")
752                continue
753            printlog("  Accepted")
754            found_links.append((el_html, el_content, attrs))
755        if not found_links:
756            raise IndexError(
757                "No matching elements found (from %s possible)"
758                % total_links)
759        if index is None:
760            if len(found_links) > 1:
761                raise IndexError(
762                    "Multiple links match: %s"
763                    % ', '.join([repr(anc) for anc, d, attr in found_links]))
764            found_link = found_links[0]
765        else:
766            try:
767                found_link = found_links[index]
768            except IndexError:
769                raise IndexError(
770                    "Only %s (out of %s) links match; index %s out of range"
771                    % (len(found_links), total_links, index))
772        return found_link
773
774    def goto(self, href, method='get', **args):
775        """
776        Go to the (potentially relative) link ``href``, using the
777        given method (``'get'`` or ``'post'``) and any extra arguments
778        you want to pass to the ``app.get()`` or ``app.post()``
779        methods.
780
781        All hostnames and schemes will be ignored.
782        """
783        scheme, host, path, query, fragment = urlparse.urlsplit(href)
784        # We
785        scheme = host = fragment = ''
786        href = urlparse.urlunsplit((scheme, host, path, query, fragment))
787        href = urlparse.urljoin(self.request.full_url, href)
788        method = method.lower()
789        assert method in ('get', 'post'), (
790            'Only "get" or "post" are allowed for method (you gave %r)'
791            % method)
792        if method == 'get':
793            method = self.test_app.get
794        else:
795            method = self.test_app.post
796        return method(href, **args)
797
798    _normal_body_regex = re.compile(br'[ \n\r\t]+')
799
800    def normal_body__get(self):
801        if self._normal_body is None:
802            self._normal_body = self._normal_body_regex.sub(
803                b' ', self.body)
804        return self._normal_body
805
806    normal_body = property(normal_body__get,
807                           doc="""
808                           Return the whitespace-normalized body
809                           """)
810
811    def __contains__(self, s):
812        """
813        A response 'contains' a string if it is present in the body
814        of the response.  Whitespace is normalized when searching
815        for a string.
816        """
817        if not isinstance(s, (six.binary_type, six.text_type)):
818            s = str(s)
819        if isinstance(s, six.text_type):
820            ## FIXME: we don't know that this response uses utf8:
821            s = s.encode('utf8')
822        return (self.body.find(s) != -1
823                or self.normal_body.find(s) != -1)
824
825    def mustcontain(self, *strings, **kw):
826        """
827        Assert that the response contains all of the strings passed
828        in as arguments.
829
830        Equivalent to::
831
832            assert string in res
833        """
834        if 'no' in kw:
835            no = kw['no']
836            del kw['no']
837            if isinstance(no, (six.binary_type, six.text_type)):
838                no = [no]
839        else:
840            no = []
841        if kw:
842            raise TypeError(
843                "The only keyword argument allowed is 'no'")
844        for s in strings:
845            if not s in self:
846                print("Actual response (no %r):" % s, file=sys.stderr)
847                print(self, file=sys.stderr)
848                raise IndexError(
849                    "Body does not contain string %r" % s)
850        for no_s in no:
851            if no_s in self:
852                print("Actual response (has %r)" % no_s, file=sys.stderr)
853                print(self, file=sys.stderr)
854                raise IndexError(
855                    "Body contains string %r" % s)
856
857    def __repr__(self):
858        body = self.body
859        if six.PY3:
860            body = body.decode('utf8', 'xmlcharrefreplace')
861        body = body[:20]
862        return '<Response %s %r>' % (self.full_status, body)
863
864    def __str__(self):
865        simple_body = b'\n'.join([l for l in self.body.splitlines()
866                                 if l.strip()])
867        if six.PY3:
868            simple_body = simple_body.decode('utf8', 'xmlcharrefreplace')
869        return 'Response: %s\n%s\n%s' % (
870            self.status,
871            '\n'.join(['%s: %s' % (n, v) for n, v in self.headers]),
872            simple_body)
873
874    def showbrowser(self):
875        """
876        Show this response in a browser window (for debugging purposes,
877        when it's hard to read the HTML).
878        """
879        import webbrowser
880        fn = tempnam_no_warning(None, 'paste-fixture') + '.html'
881        f = open(fn, 'wb')
882        f.write(self.body)
883        f.close()
884        url = 'file:' + fn.replace(os.sep, '/')
885        webbrowser.open_new(url)
886
887class TestRequest(object):
888
889    # for py.test
890    disabled = True
891
892    """
893    Instances of this class are created by `TestApp
894    <class-paste.fixture.TestApp.html>`_ with the ``.get()`` and
895    ``.post()`` methods, and are consumed there by ``.do_request()``.
896
897    Instances are also available as a ``.req`` attribute on
898    `TestResponse <class-paste.fixture.TestResponse.html>`_ instances.
899
900    Useful attributes:
901
902    ``url``:
903        The url (actually usually the path) of the request, without
904        query string.
905
906    ``environ``:
907        The environment dictionary used for the request.
908
909    ``full_url``:
910        The url/path, with query string.
911    """
912
913    def __init__(self, url, environ, expect_errors=False):
914        if url.startswith('http://localhost'):
915            url = url[len('http://localhost'):]
916        self.url = url
917        self.environ = environ
918        if environ.get('QUERY_STRING'):
919            self.full_url = url + '?' + environ['QUERY_STRING']
920        else:
921            self.full_url = url
922        self.expect_errors = expect_errors
923
924
925class Form(object):
926
927    """
928    This object represents a form that has been found in a page.
929    This has a couple useful attributes:
930
931    ``text``:
932        the full HTML of the form.
933
934    ``action``:
935        the relative URI of the action.
936
937    ``method``:
938        the method (e.g., ``'GET'``).
939
940    ``id``:
941        the id, or None if not given.
942
943    ``fields``:
944        a dictionary of fields, each value is a list of fields by
945        that name.  ``<input type=\"radio\">`` and ``<select>`` are
946        both represented as single fields with multiple options.
947    """
948
949    # @@: This really should be using Mechanize/ClientForm or
950    # something...
951
952    _tag_re = re.compile(r'<(/?)([:a-z0-9_\-]*)([^>]*?)>', re.I)
953
954    def __init__(self, response, text):
955        self.response = response
956        self.text = text
957        self._parse_fields()
958        self._parse_action()
959
960    def _parse_fields(self):
961        in_select = None
962        in_textarea = None
963        fields = {}
964        for match in self._tag_re.finditer(self.text):
965            end = match.group(1) == '/'
966            tag = match.group(2).lower()
967            if tag not in ('input', 'select', 'option', 'textarea',
968                           'button'):
969                continue
970            if tag == 'select' and end:
971                assert in_select, (
972                    '%r without starting select' % match.group(0))
973                in_select = None
974                continue
975            if tag == 'textarea' and end:
976                assert in_textarea, (
977                    "</textarea> with no <textarea> at %s" % match.start())
978                in_textarea[0].value = html_unquote(self.text[in_textarea[1]:match.start()])
979                in_textarea = None
980                continue
981            if end:
982                continue
983            attrs = _parse_attrs(match.group(3))
984            if 'name' in attrs:
985                name = attrs.pop('name')
986            else:
987                name = None
988            if tag == 'option':
989                in_select.options.append((attrs.get('value'),
990                                          'selected' in attrs))
991                continue
992            if tag == 'input' and attrs.get('type') == 'radio':
993                field = fields.get(name)
994                if not field:
995                    field = Radio(self, tag, name, match.start(), **attrs)
996                    fields.setdefault(name, []).append(field)
997                else:
998                    field = field[0]
999                    assert isinstance(field, Radio)
1000                field.options.append((attrs.get('value'),
1001                                      'checked' in attrs))
1002                continue
1003            tag_type = tag
1004            if tag == 'input':
1005                tag_type = attrs.get('type', 'text').lower()
1006            FieldClass = Field.classes.get(tag_type, Field)
1007            field = FieldClass(self, tag, name, match.start(), **attrs)
1008            if tag == 'textarea':
1009                assert not in_textarea, (
1010                    "Nested textareas: %r and %r"
1011                    % (in_textarea, match.group(0)))
1012                in_textarea = field, match.end()
1013            elif tag == 'select':
1014                assert not in_select, (
1015                    "Nested selects: %r and %r"
1016                    % (in_select, match.group(0)))
1017                in_select = field
1018            fields.setdefault(name, []).append(field)
1019        self.fields = fields
1020
1021    def _parse_action(self):
1022        self.action = None
1023        for match in self._tag_re.finditer(self.text):
1024            end = match.group(1) == '/'
1025            tag = match.group(2).lower()
1026            if tag != 'form':
1027                continue
1028            if end:
1029                break
1030            attrs = _parse_attrs(match.group(3))
1031            self.action = attrs.get('action', '')
1032            self.method = attrs.get('method', 'GET')
1033            self.id = attrs.get('id')
1034            # @@: enctype?
1035        else:
1036            assert 0, "No </form> tag found"
1037        assert self.action is not None, (
1038            "No <form> tag found")
1039
1040    def __setitem__(self, name, value):
1041        """
1042        Set the value of the named field.  If there is 0 or multiple
1043        fields by that name, it is an error.
1044
1045        Setting the value of a ``<select>`` selects the given option
1046        (and confirms it is an option).  Setting radio fields does the
1047        same.  Checkboxes get boolean values.  You cannot set hidden
1048        fields or buttons.
1049
1050        Use ``.set()`` if there is any ambiguity and you must provide
1051        an index.
1052        """
1053        fields = self.fields.get(name)
1054        assert fields is not None, (
1055            "No field by the name %r found (fields: %s)"
1056            % (name, ', '.join(map(repr, self.fields.keys()))))
1057        assert len(fields) == 1, (
1058            "Multiple fields match %r: %s"
1059            % (name, ', '.join(map(repr, fields))))
1060        fields[0].value = value
1061
1062    def __getitem__(self, name):
1063        """
1064        Get the named field object (ambiguity is an error).
1065        """
1066        fields = self.fields.get(name)
1067        assert fields is not None, (
1068            "No field by the name %r found" % name)
1069        assert len(fields) == 1, (
1070            "Multiple fields match %r: %s"
1071            % (name, ', '.join(map(repr, fields))))
1072        return fields[0]
1073
1074    def set(self, name, value, index=None):
1075        """
1076        Set the given name, using ``index`` to disambiguate.
1077        """
1078        if index is None:
1079            self[name] = value
1080        else:
1081            fields = self.fields.get(name)
1082            assert fields is not None, (
1083                "No fields found matching %r" % name)
1084            field = fields[index]
1085            field.value = value
1086
1087    def get(self, name, index=None, default=NoDefault):
1088        """
1089        Get the named/indexed field object, or ``default`` if no field
1090        is found.
1091        """
1092        fields = self.fields.get(name)
1093        if fields is None and default is not NoDefault:
1094            return default
1095        if index is None:
1096            return self[name]
1097        else:
1098            fields = self.fields.get(name)
1099            assert fields is not None, (
1100                "No fields found matching %r" % name)
1101            field = fields[index]
1102            return field
1103
1104    def select(self, name, value, index=None):
1105        """
1106        Like ``.set()``, except also confirms the target is a
1107        ``<select>``.
1108        """
1109        field = self.get(name, index=index)
1110        assert isinstance(field, Select)
1111        field.value = value
1112
1113    def submit(self, name=None, index=None, **args):
1114        """
1115        Submits the form.  If ``name`` is given, then also select that
1116        button (using ``index`` to disambiguate)``.
1117
1118        Any extra keyword arguments are passed to the ``.get()`` or
1119        ``.post()`` method.
1120
1121        Returns a response object.
1122        """
1123        fields = self.submit_fields(name, index=index)
1124        return self.response.goto(self.action, method=self.method,
1125                                  params=fields, **args)
1126
1127    def submit_fields(self, name=None, index=None):
1128        """
1129        Return a list of ``[(name, value), ...]`` for the current
1130        state of the form.
1131        """
1132        submit = []
1133        if name is not None:
1134            field = self.get(name, index=index)
1135            submit.append((field.name, field.value_if_submitted()))
1136        for name, fields in self.fields.items():
1137            if name is None:
1138                continue
1139            for field in fields:
1140                value = field.value
1141                if value is None:
1142                    continue
1143                submit.append((name, value))
1144        return submit
1145
1146
1147_attr_re = re.compile(r'([^= \n\r\t]+)[ \n\r\t]*(?:=[ \n\r\t]*(?:"([^"]*)"|([^"][^ \n\r\t>]*)))?', re.S)
1148
1149def _parse_attrs(text):
1150    attrs = {}
1151    for match in _attr_re.finditer(text):
1152        attr_name = match.group(1).lower()
1153        attr_body = match.group(2) or match.group(3)
1154        attr_body = html_unquote(attr_body or '')
1155        attrs[attr_name] = attr_body
1156    return attrs
1157
1158class Field(object):
1159
1160    """
1161    Field object.
1162    """
1163
1164    # Dictionary of field types (select, radio, etc) to classes
1165    classes = {}
1166
1167    settable = True
1168
1169    def __init__(self, form, tag, name, pos,
1170                 value=None, id=None, **attrs):
1171        self.form = form
1172        self.tag = tag
1173        self.name = name
1174        self.pos = pos
1175        self._value = value
1176        self.id = id
1177        self.attrs = attrs
1178
1179    def value__set(self, value):
1180        if not self.settable:
1181            raise AttributeError(
1182                "You cannot set the value of the <%s> field %r"
1183                % (self.tag, self.name))
1184        self._value = value
1185
1186    def force_value(self, value):
1187        """
1188        Like setting a value, except forces it even for, say, hidden
1189        fields.
1190        """
1191        self._value = value
1192
1193    def value__get(self):
1194        return self._value
1195
1196    value = property(value__get, value__set)
1197
1198class Select(Field):
1199
1200    """
1201    Field representing ``<select>``
1202    """
1203
1204    def __init__(self, *args, **attrs):
1205        super(Select, self).__init__(*args, **attrs)
1206        self.options = []
1207        self.multiple = attrs.get('multiple')
1208        assert not self.multiple, (
1209            "<select multiple> not yet supported")
1210        # Undetermined yet:
1211        self.selectedIndex = None
1212
1213    def value__set(self, value):
1214        for i, (option, checked) in enumerate(self.options):
1215            if option == str(value):
1216                self.selectedIndex = i
1217                break
1218        else:
1219            raise ValueError(
1220                "Option %r not found (from %s)"
1221                % (value, ', '.join(
1222                [repr(o) for o, c in self.options])))
1223
1224    def value__get(self):
1225        if self.selectedIndex is not None:
1226            return self.options[self.selectedIndex][0]
1227        else:
1228            for option, checked in self.options:
1229                if checked:
1230                    return option
1231            else:
1232                if self.options:
1233                    return self.options[0][0]
1234                else:
1235                    return None
1236
1237    value = property(value__get, value__set)
1238
1239Field.classes['select'] = Select
1240
1241class Radio(Select):
1242
1243    """
1244    Field representing ``<input type="radio">``
1245    """
1246
1247Field.classes['radio'] = Radio
1248
1249class Checkbox(Field):
1250
1251    """
1252    Field representing ``<input type="checkbox">``
1253    """
1254
1255    def __init__(self, *args, **attrs):
1256        super(Checkbox, self).__init__(*args, **attrs)
1257        self.checked = 'checked' in attrs
1258
1259    def value__set(self, value):
1260        self.checked = not not value
1261
1262    def value__get(self):
1263        if self.checked:
1264            if self._value is None:
1265                return 'on'
1266            else:
1267                return self._value
1268        else:
1269            return None
1270
1271    value = property(value__get, value__set)
1272
1273Field.classes['checkbox'] = Checkbox
1274
1275class Text(Field):
1276    """
1277    Field representing ``<input type="text">``
1278    """
1279    def __init__(self, form, tag, name, pos,
1280                 value='', id=None, **attrs):
1281        #text fields default to empty string
1282        Field.__init__(self, form, tag, name, pos,
1283                       value=value, id=id, **attrs)
1284
1285Field.classes['text'] = Text
1286
1287class Textarea(Text):
1288    """
1289    Field representing ``<textarea>``
1290    """
1291
1292Field.classes['textarea'] = Textarea
1293
1294class Hidden(Text):
1295    """
1296    Field representing ``<input type="hidden">``
1297    """
1298
1299Field.classes['hidden'] = Hidden
1300
1301class Submit(Field):
1302    """
1303    Field representing ``<input type="submit">`` and ``<button>``
1304    """
1305
1306    settable = False
1307
1308    def value__get(self):
1309        return None
1310
1311    value = property(value__get)
1312
1313    def value_if_submitted(self):
1314        return self._value
1315
1316Field.classes['submit'] = Submit
1317
1318Field.classes['button'] = Submit
1319
1320Field.classes['image'] = Submit
1321
1322############################################################
1323## Command-line testing
1324############################################################
1325
1326
1327class TestFileEnvironment(object):
1328
1329    """
1330    This represents an environment in which files will be written, and
1331    scripts will be run.
1332    """
1333
1334    # for py.test
1335    disabled = True
1336
1337    def __init__(self, base_path, template_path=None,
1338                 script_path=None,
1339                 environ=None, cwd=None, start_clear=True,
1340                 ignore_paths=None, ignore_hidden=True):
1341        """
1342        Creates an environment.  ``base_path`` is used as the current
1343        working directory, and generally where changes are looked for.
1344
1345        ``template_path`` is the directory to look for *template*
1346        files, which are files you'll explicitly add to the
1347        environment.  This is done with ``.writefile()``.
1348
1349        ``script_path`` is the PATH for finding executables.  Usually
1350        grabbed from ``$PATH``.
1351
1352        ``environ`` is the operating system environment,
1353        ``os.environ`` if not given.
1354
1355        ``cwd`` is the working directory, ``base_path`` by default.
1356
1357        If ``start_clear`` is true (default) then the ``base_path``
1358        will be cleared (all files deleted) when an instance is
1359        created.  You can also use ``.clear()`` to clear the files.
1360
1361        ``ignore_paths`` is a set of specific filenames that should be
1362        ignored when created in the environment.  ``ignore_hidden``
1363        means, if true (default) that filenames and directories
1364        starting with ``'.'`` will be ignored.
1365        """
1366        self.base_path = base_path
1367        self.template_path = template_path
1368        if environ is None:
1369            environ = os.environ.copy()
1370        self.environ = environ
1371        if script_path is None:
1372            if sys.platform == 'win32':
1373                script_path = environ.get('PATH', '').split(';')
1374            else:
1375                script_path = environ.get('PATH', '').split(':')
1376        self.script_path = script_path
1377        if cwd is None:
1378            cwd = base_path
1379        self.cwd = cwd
1380        if start_clear:
1381            self.clear()
1382        elif not os.path.exists(base_path):
1383            os.makedirs(base_path)
1384        self.ignore_paths = ignore_paths or []
1385        self.ignore_hidden = ignore_hidden
1386
1387    def run(self, script, *args, **kw):
1388        """
1389        Run the command, with the given arguments.  The ``script``
1390        argument can have space-separated arguments, or you can use
1391        the positional arguments.
1392
1393        Keywords allowed are:
1394
1395        ``expect_error``: (default False)
1396            Don't raise an exception in case of errors
1397        ``expect_stderr``: (default ``expect_error``)
1398            Don't raise an exception if anything is printed to stderr
1399        ``stdin``: (default ``""``)
1400            Input to the script
1401        ``printresult``: (default True)
1402            Print the result after running
1403        ``cwd``: (default ``self.cwd``)
1404            The working directory to run in
1405
1406        Returns a `ProcResponse
1407        <class-paste.fixture.ProcResponse.html>`_ object.
1408        """
1409        __tracebackhide__ = True
1410        expect_error = _popget(kw, 'expect_error', False)
1411        expect_stderr = _popget(kw, 'expect_stderr', expect_error)
1412        cwd = _popget(kw, 'cwd', self.cwd)
1413        stdin = _popget(kw, 'stdin', None)
1414        printresult = _popget(kw, 'printresult', True)
1415        args = list(map(str, args))
1416        assert not kw, (
1417            "Arguments not expected: %s" % ', '.join(kw.keys()))
1418        if ' ' in script:
1419            assert not args, (
1420                "You cannot give a multi-argument script (%r) "
1421                "and arguments (%s)" % (script, args))
1422            script, args = script.split(None, 1)
1423            args = shlex.split(args)
1424        script = self._find_exe(script)
1425        all = [script] + args
1426        files_before = self._find_files()
1427        proc = subprocess.Popen(all, stdin=subprocess.PIPE,
1428                                stderr=subprocess.PIPE,
1429                                stdout=subprocess.PIPE,
1430                                cwd=cwd,
1431                                env=self.environ)
1432        stdout, stderr = proc.communicate(stdin)
1433        files_after = self._find_files()
1434        result = ProcResult(
1435            self, all, stdin, stdout, stderr,
1436            returncode=proc.returncode,
1437            files_before=files_before,
1438            files_after=files_after)
1439        if printresult:
1440            print(result)
1441            print('-'*40)
1442        if not expect_error:
1443            result.assert_no_error()
1444        if not expect_stderr:
1445            result.assert_no_stderr()
1446        return result
1447
1448    def _find_exe(self, script_name):
1449        if self.script_path is None:
1450            script_name = os.path.join(self.cwd, script_name)
1451            if not os.path.exists(script_name):
1452                raise OSError(
1453                    "Script %s does not exist" % script_name)
1454            return script_name
1455        for path in self.script_path:
1456            fn = os.path.join(path, script_name)
1457            if os.path.exists(fn):
1458                return fn
1459        raise OSError(
1460            "Script %s could not be found in %s"
1461            % (script_name, ':'.join(self.script_path)))
1462
1463    def _find_files(self):
1464        result = {}
1465        for fn in os.listdir(self.base_path):
1466            if self._ignore_file(fn):
1467                continue
1468            self._find_traverse(fn, result)
1469        return result
1470
1471    def _ignore_file(self, fn):
1472        if fn in self.ignore_paths:
1473            return True
1474        if self.ignore_hidden and os.path.basename(fn).startswith('.'):
1475            return True
1476        return False
1477
1478    def _find_traverse(self, path, result):
1479        full = os.path.join(self.base_path, path)
1480        if os.path.isdir(full):
1481            result[path] = FoundDir(self.base_path, path)
1482            for fn in os.listdir(full):
1483                fn = os.path.join(path, fn)
1484                if self._ignore_file(fn):
1485                    continue
1486                self._find_traverse(fn, result)
1487        else:
1488            result[path] = FoundFile(self.base_path, path)
1489
1490    def clear(self):
1491        """
1492        Delete all the files in the base directory.
1493        """
1494        if os.path.exists(self.base_path):
1495            shutil.rmtree(self.base_path)
1496        os.mkdir(self.base_path)
1497
1498    def writefile(self, path, content=None,
1499                  frompath=None):
1500        """
1501        Write a file to the given path.  If ``content`` is given then
1502        that text is written, otherwise the file in ``frompath`` is
1503        used.  ``frompath`` is relative to ``self.template_path``
1504        """
1505        full = os.path.join(self.base_path, path)
1506        if not os.path.exists(os.path.dirname(full)):
1507            os.makedirs(os.path.dirname(full))
1508        f = open(full, 'wb')
1509        if content is not None:
1510            f.write(content)
1511        if frompath is not None:
1512            if self.template_path:
1513                frompath = os.path.join(self.template_path, frompath)
1514            f2 = open(frompath, 'rb')
1515            f.write(f2.read())
1516            f2.close()
1517        f.close()
1518        return FoundFile(self.base_path, path)
1519
1520class ProcResult(object):
1521
1522    """
1523    Represents the results of running a command in
1524    `TestFileEnvironment
1525    <class-paste.fixture.TestFileEnvironment.html>`_.
1526
1527    Attributes to pay particular attention to:
1528
1529    ``stdout``, ``stderr``:
1530        What is produced
1531
1532    ``files_created``, ``files_deleted``, ``files_updated``:
1533        Dictionaries mapping filenames (relative to the ``base_dir``)
1534        to `FoundFile <class-paste.fixture.FoundFile.html>`_ or
1535        `FoundDir <class-paste.fixture.FoundDir.html>`_ objects.
1536    """
1537
1538    def __init__(self, test_env, args, stdin, stdout, stderr,
1539                 returncode, files_before, files_after):
1540        self.test_env = test_env
1541        self.args = args
1542        self.stdin = stdin
1543        self.stdout = stdout
1544        self.stderr = stderr
1545        self.returncode = returncode
1546        self.files_before = files_before
1547        self.files_after = files_after
1548        self.files_deleted = {}
1549        self.files_updated = {}
1550        self.files_created = files_after.copy()
1551        for path, f in files_before.items():
1552            if path not in files_after:
1553                self.files_deleted[path] = f
1554                continue
1555            del self.files_created[path]
1556            if f.mtime < files_after[path].mtime:
1557                self.files_updated[path] = files_after[path]
1558
1559    def assert_no_error(self):
1560        __tracebackhide__ = True
1561        assert self.returncode == 0, (
1562            "Script returned code: %s" % self.returncode)
1563
1564    def assert_no_stderr(self):
1565        __tracebackhide__ = True
1566        if self.stderr:
1567            print('Error output:')
1568            print(self.stderr)
1569            raise AssertionError("stderr output not expected")
1570
1571    def __str__(self):
1572        s = ['Script result: %s' % ' '.join(self.args)]
1573        if self.returncode:
1574            s.append('  return code: %s' % self.returncode)
1575        if self.stderr:
1576            s.append('-- stderr: --------------------')
1577            s.append(self.stderr)
1578        if self.stdout:
1579            s.append('-- stdout: --------------------')
1580            s.append(self.stdout)
1581        for name, files, show_size in [
1582            ('created', self.files_created, True),
1583            ('deleted', self.files_deleted, True),
1584            ('updated', self.files_updated, True)]:
1585            if files:
1586                s.append('-- %s: -------------------' % name)
1587                files = files.items()
1588                files.sort()
1589                last = ''
1590                for path, f in files:
1591                    t = '  %s' % _space_prefix(last, path, indent=4,
1592                                               include_sep=False)
1593                    last = path
1594                    if show_size and f.size != 'N/A':
1595                        t += '  (%s bytes)' % f.size
1596                    s.append(t)
1597        return '\n'.join(s)
1598
1599class FoundFile(object):
1600
1601    """
1602    Represents a single file found as the result of a command.
1603
1604    Has attributes:
1605
1606    ``path``:
1607        The path of the file, relative to the ``base_path``
1608
1609    ``full``:
1610        The full path
1611
1612    ``stat``:
1613        The results of ``os.stat``.  Also ``mtime`` and ``size``
1614        contain the ``.st_mtime`` and ``st_size`` of the stat.
1615
1616    ``bytes``:
1617        The contents of the file.
1618
1619    You may use the ``in`` operator with these objects (tested against
1620    the contents of the file), and the ``.mustcontain()`` method.
1621    """
1622
1623    file = True
1624    dir = False
1625
1626    def __init__(self, base_path, path):
1627        self.base_path = base_path
1628        self.path = path
1629        self.full = os.path.join(base_path, path)
1630        self.stat = os.stat(self.full)
1631        self.mtime = self.stat.st_mtime
1632        self.size = self.stat.st_size
1633        self._bytes = None
1634
1635    def bytes__get(self):
1636        if self._bytes is None:
1637            f = open(self.full, 'rb')
1638            self._bytes = f.read()
1639            f.close()
1640        return self._bytes
1641    bytes = property(bytes__get)
1642
1643    def __contains__(self, s):
1644        return s in self.bytes
1645
1646    def mustcontain(self, s):
1647        __tracebackhide__ = True
1648        bytes_ = self.bytes
1649        if s not in bytes_:
1650            print('Could not find %r in:' % s)
1651            print(bytes_)
1652            assert s in bytes_
1653
1654    def __repr__(self):
1655        return '<%s %s:%s>' % (
1656            self.__class__.__name__,
1657            self.base_path, self.path)
1658
1659class FoundDir(object):
1660
1661    """
1662    Represents a directory created by a command.
1663    """
1664
1665    file = False
1666    dir = True
1667
1668    def __init__(self, base_path, path):
1669        self.base_path = base_path
1670        self.path = path
1671        self.full = os.path.join(base_path, path)
1672        self.size = 'N/A'
1673        self.mtime = 'N/A'
1674
1675    def __repr__(self):
1676        return '<%s %s:%s>' % (
1677            self.__class__.__name__,
1678            self.base_path, self.path)
1679
1680def _popget(d, key, default=None):
1681    """
1682    Pop the key if found (else return default)
1683    """
1684    if key in d:
1685        return d.pop(key)
1686    return default
1687
1688def _space_prefix(pref, full, sep=None, indent=None, include_sep=True):
1689    """
1690    Anything shared by pref and full will be replaced with spaces
1691    in full, and full returned.
1692    """
1693    if sep is None:
1694        sep = os.path.sep
1695    pref = pref.split(sep)
1696    full = full.split(sep)
1697    padding = []
1698    while pref and full and pref[0] == full[0]:
1699        if indent is None:
1700            padding.append(' ' * (len(full[0]) + len(sep)))
1701        else:
1702            padding.append(' ' * indent)
1703        full.pop(0)
1704        pref.pop(0)
1705    if padding:
1706        if include_sep:
1707            return ''.join(padding) + sep + sep.join(full)
1708        else:
1709            return ''.join(padding) + sep.join(full)
1710    else:
1711        return sep.join(full)
1712
1713def _make_pattern(pat):
1714    if pat is None:
1715        return None
1716    if isinstance(pat, (six.binary_type, six.text_type)):
1717        pat = re.compile(pat)
1718    if hasattr(pat, 'search'):
1719        return pat.search
1720    if callable(pat):
1721        return pat
1722    assert 0, (
1723        "Cannot make callable pattern object out of %r" % pat)
1724
1725def setup_module(module=None):
1726    """
1727    This is used by py.test if it is in the module, so you can
1728    import this directly.
1729
1730    Use like::
1731
1732        from paste.fixture import setup_module
1733    """
1734    # Deprecated June 2008
1735    import warnings
1736    warnings.warn(
1737        'setup_module is deprecated',
1738        DeprecationWarning, 2)
1739    if module is None:
1740        # The module we were called from must be the module...
1741        module = sys._getframe().f_back.f_globals['__name__']
1742    if isinstance(module, (six.binary_type, six.text_type)):
1743        module = sys.modules[module]
1744    if hasattr(module, 'reset_state'):
1745        module.reset_state()
1746
1747def html_unquote(v):
1748    """
1749    Unquote (some) entities in HTML.  (incomplete)
1750    """
1751    for ent, repl in [('&nbsp;', ' '), ('&gt;', '>'),
1752                      ('&lt;', '<'), ('&quot;', '"'),
1753                      ('&amp;', '&')]:
1754        v = v.replace(ent, repl)
1755    return v
1756