• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zopefoundation / zope.testbrowser / 16098871825

05 Mar 2025 04:01PM UTC coverage: 91.403%. Remained the same
16098871825

push

github

icemac
Back to development: 7.1

427 of 520 branches covered (82.12%)

Branch coverage included in aggregate %.

1859 of 1981 relevant lines covered (93.84%)

0.94 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.92
/src/zope/testbrowser/browser.py
1
##############################################################################
2
#
3
# Copyright (c) 2005 Zope Foundation and Contributors.
4
# All Rights Reserved.
5
#
6
# This software is subject to the provisions of the Zope Public License,
7
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
8
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
9
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
10
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
11
# FOR A PARTICULAR PURPOSE.
12
#
13
##############################################################################
14
"""Webtest-based Functional Doctest interfaces
15
"""
16

17
import http.client
1✔
18
import io
1✔
19
import re
1✔
20
import time
1✔
21
import urllib.parse
1✔
22
import urllib.request
1✔
23
import urllib.robotparser
1✔
24
from contextlib import contextmanager
1✔
25

26
import webtest
1✔
27
from bs4 import BeautifulSoup
1✔
28
from soupsieve import escape as css_escape
1✔
29
from wsgiproxy.proxies import TransparentProxy
1✔
30
from zope.cachedescriptors.property import Lazy
1✔
31
from zope.interface import implementer
1✔
32

33
import zope.testbrowser.cookies
1✔
34
from zope.testbrowser import interfaces
1✔
35

36

37
__docformat__ = "reStructuredText"
1✔
38

39
HTTPError = urllib.request.HTTPError
1✔
40
RegexType = type(re.compile(''))
1✔
41
_compress_re = re.compile(r"\s+")
1✔
42

43

44
class HostNotAllowed(Exception):
1✔
45
    pass
1✔
46

47

48
class RobotExclusionError(HTTPError):
1✔
49
    def __init__(self, *args):
1✔
50
        super().__init__(*args)
×
51

52

53
# RFC 2606
54
_allowed_2nd_level = {'example.com', 'example.net', 'example.org'}
1✔
55

56
_allowed = {'localhost', '127.0.0.1'}
1✔
57
_allowed.update(_allowed_2nd_level)
1✔
58

59
REDIRECTS = (301, 302, 303, 307)
1✔
60

61

62
class TestbrowserApp(webtest.TestApp):
1✔
63
    _last_fragment = ""
1✔
64
    restricted = False
1✔
65

66
    def _assertAllowed(self, url):
1✔
67
        parsed = urllib.parse.urlparse(url)
1✔
68
        if self.restricted:
1✔
69
            # We are in restricted mode, check host part only
70
            host = parsed.netloc.partition(':')[0]
1✔
71
            if host in _allowed:
1✔
72
                return
1✔
73
            for dom in _allowed_2nd_level:
1✔
74
                if host.endswith('.%s' % dom):
1✔
75
                    return
1✔
76

77
            raise HostNotAllowed(url)
1✔
78
        else:
79
            # Unrestricted mode: retrieve robots.txt and check against it
80
            robotsurl = urllib.parse.urlunsplit(
1✔
81
                (parsed.scheme, parsed.netloc, '/robots.txt', '', ''))
82
            rp = urllib.robotparser.RobotFileParser()
1✔
83
            rp.set_url(robotsurl)
1✔
84
            rp.read()
1✔
85
            if not rp.can_fetch("*", url):
1!
86
                msg = "request disallowed by robots.txt"
×
87
                raise RobotExclusionError(url, 403, msg, [], None)
×
88

89
    def do_request(self, req, status, expect_errors):
1✔
90
        self._assertAllowed(req.url)
1✔
91

92
        response = super().do_request(req, status,
1✔
93
                                      expect_errors)
94
        # Store _last_fragment in response to preserve fragment for history
95
        # (goBack() will not lose fragment).
96
        response._last_fragment = self._last_fragment
1✔
97
        return response
1✔
98

99
    def _remove_fragment(self, url):
1✔
100
        # HACK: we need to preserve fragment part of url, but webtest strips it
101
        # from url on every request. So we override this protected method,
102
        # assuming it is called on every request and therefore _last_fragment
103
        # will not get outdated. ``getRequestUrlWithFragment()`` will
104
        # reconstruct url with fragment for the last request.
105
        scheme, netloc, path, query, fragment = urllib.parse.urlsplit(url)
1✔
106
        self._last_fragment = fragment
1✔
107
        return super()._remove_fragment(url)
1✔
108

109
    def getRequestUrlWithFragment(self, response):
1✔
110
        url = response.request.url
1✔
111
        if not self._last_fragment:
1✔
112
            return url
1✔
113
        return "{}#{}".format(url, response._last_fragment)
1✔
114

115

116
class SetattrErrorsMixin:
1✔
117
    _enable_setattr_errors = False
1✔
118

119
    def __setattr__(self, name, value):
1✔
120
        if self._enable_setattr_errors:
1✔
121
            # cause an attribute error if the attribute doesn't already exist
122
            getattr(self, name)
1✔
123

124
        # set the value
125
        object.__setattr__(self, name, value)
1✔
126

127

128
@implementer(interfaces.IBrowser)
1✔
129
class Browser(SetattrErrorsMixin):
1✔
130
    """A web user agent."""
131

132
    _contents = None
1✔
133
    _controls = None
1✔
134
    _counter = 0
1✔
135
    _response = None
1✔
136
    _req_headers = None
1✔
137
    _req_content_type = None
1✔
138
    _req_referrer = None
1✔
139
    _history = None
1✔
140
    __html = None
1✔
141

142
    def __init__(self, url=None, wsgi_app=None):
1✔
143
        self.timer = Timer()
1✔
144
        self.raiseHttpErrors = True
1✔
145
        self.handleErrors = True
1✔
146
        self.followRedirects = True
1✔
147

148
        if wsgi_app is None:
1✔
149
            self.testapp = TestbrowserApp(TransparentProxy())
1✔
150
        else:
151
            self.testapp = TestbrowserApp(wsgi_app)
1✔
152
            self.testapp.restricted = True
1✔
153

154
        self._req_headers = {}
1✔
155
        self._history = History()
1✔
156
        self._enable_setattr_errors = True
1✔
157
        self._controls = {}
1✔
158

159
        if url is not None:
1✔
160
            self.open(url)
1✔
161

162
    @property
1✔
163
    def url(self):
1✔
164
        """See zope.testbrowser.interfaces.IBrowser"""
165
        if self._response is None:
1✔
166
            return None
1✔
167
        return self.testapp.getRequestUrlWithFragment(self._response)
1✔
168

169
    @property
1✔
170
    def isHtml(self):
1✔
171
        """See zope.testbrowser.interfaces.IBrowser"""
172
        return self._response and 'html' in self._response.content_type
1✔
173

174
    @property
1✔
175
    def lastRequestSeconds(self):
1✔
176
        """See zope.testbrowser.interfaces.IBrowser"""
177
        return self.timer.elapsedSeconds
1✔
178

179
    @property
1✔
180
    def title(self):
1✔
181
        """See zope.testbrowser.interfaces.IBrowser"""
182
        if not self.isHtml:
1✔
183
            raise BrowserStateError('not viewing HTML')
1✔
184

185
        titles = self._html.find_all('title')
1✔
186
        if not titles:
1✔
187
            return None
1✔
188
        return self.toStr(titles[0].text)
1✔
189

190
    def reload(self):
1✔
191
        """See zope.testbrowser.interfaces.IBrowser"""
192
        if self._response is None:
1!
193
            raise BrowserStateError("no URL has yet been .open()ed")
×
194

195
        def make_request(args):
1✔
196
            return self.testapp.request(self._response.request)
1✔
197

198
        # _req_referrer is left intact, so will be the referrer (if any) of
199
        # the request being reloaded.
200
        self._processRequest(self.url, make_request)
1✔
201

202
    def goBack(self, count=1):
1✔
203
        """See zope.testbrowser.interfaces.IBrowser"""
204
        resp = self._history.back(count, self._response)
1✔
205
        self._setResponse(resp)
1✔
206

207
    @property
1✔
208
    def contents(self):
1✔
209
        """See zope.testbrowser.interfaces.IBrowser"""
210
        if self._response is not None:
1✔
211
            return self.toStr(self._response.body)
1✔
212
        else:
213
            return None
1✔
214

215
    @property
1✔
216
    def headers(self):
1✔
217
        """See zope.testbrowser.interfaces.IBrowser"""
218
        resptxt = []
1✔
219
        resptxt.append('Status: %s' % self._response.status)
1✔
220
        for h, v in sorted(self._response.headers.items()):
1✔
221
            resptxt.append(str("{}: {}".format(h, v)))
1✔
222

223
        inp = '\n'.join(resptxt)
1✔
224
        stream = io.BytesIO(inp.encode('latin1'))
1✔
225
        return http.client.parse_headers(stream)
1✔
226

227
    @property
1✔
228
    def cookies(self):
1✔
229
        if self.url is None:
1✔
230
            raise RuntimeError("no request found")
1✔
231
        return zope.testbrowser.cookies.Cookies(self.testapp, self.url,
1✔
232
                                                self._req_headers)
233

234
    def addHeader(self, key, value):
1✔
235
        """See zope.testbrowser.interfaces.IBrowser"""
236
        if (self.url and key.lower() in ('cookie', 'cookie2') and
1✔
237
                self.cookies.header):
238
            raise ValueError('cookies are already set in `cookies` attribute')
1✔
239
        self._req_headers[key] = value
1✔
240

241
    def open(self, url, data=None, referrer=None):
1✔
242
        """See zope.testbrowser.interfaces.IBrowser"""
243
        url = self._absoluteUrl(url)
1✔
244
        if data is not None:
1✔
245
            def make_request(args):
1✔
246
                return self.testapp.post(url, data, **args)
1✔
247
        else:
248
            def make_request(args):
1✔
249
                return self.testapp.get(url, **args)
1✔
250

251
        self._req_referrer = referrer
1✔
252
        self._processRequest(url, make_request)
1✔
253

254
    def post(self, url, data, content_type=None, referrer=None):
1✔
255
        if content_type is not None:
1✔
256
            self._req_content_type = content_type
1✔
257
        self._req_referrer = referrer
1✔
258
        return self.open(url, data)
1✔
259

260
    def _clickSubmit(self, form, control=None, coord=None):
1✔
261
        # find index of given control in the form
262
        url = self._absoluteUrl(form.action)
1✔
263
        if control:
1✔
264
            def make_request(args):
1✔
265
                index = form.fields[control.name].index(control)
1✔
266
                return self._submit(
1✔
267
                    form, control.name, index, coord=coord, **args)
268
        else:
269
            def make_request(args):
1✔
270
                return self._submit(form, coord=coord, **args)
1✔
271

272
        self._req_referrer = self.url
1✔
273
        self._processRequest(url, make_request)
1✔
274

275
    def _processRequest(self, url, make_request):
1✔
276
        with self._preparedRequest(url) as reqargs:
1✔
277
            self._history.add(self._response)
1✔
278
            resp = make_request(reqargs)
1✔
279
            if self.followRedirects:
1✔
280
                remaining_redirects = 100  # infinite loops protection
1✔
281
                while resp.status_int in REDIRECTS and remaining_redirects:
1✔
282
                    remaining_redirects -= 1
1✔
283
                    self._req_referrer = url
1✔
284
                    url = urllib.parse.urljoin(url, resp.headers['location'])
1✔
285
                    with self._preparedRequest(url) as reqargs:
1✔
286
                        resp = self.testapp.get(url, **reqargs)
1✔
287
                assert remaining_redirects > 0, (
1✔
288
                    "redirects chain looks infinite")
289
            self._setResponse(resp)
1✔
290
            self._checkStatus()
1✔
291

292
    def _checkStatus(self):
1✔
293
        # if the headers don't have a status, I suppose there can't be an error
294
        if 'Status' in self.headers:
1!
295
            code, msg = self.headers['Status'].split(' ', 1)
1✔
296
            code = int(code)
1✔
297
            if self.raiseHttpErrors and code >= 400:
1!
298
                raise HTTPError(self.url, code, msg, [], None)
×
299

300
    def _submit(self, form, name=None, index=None, coord=None, **args):
1✔
301
        # A reimplementation of webtest.forms.Form.submit() to allow to insert
302
        # coords into the request
303
        fields = form.submit_fields(name, index=index)
1✔
304
        if coord is not None:
1✔
305
            fields.extend([('%s.x' % name, coord[0]),
1✔
306
                           ('%s.y' % name, coord[1])])
307

308
        url = self._absoluteUrl(form.action)
1✔
309
        if form.method.upper() != "GET":
1✔
310
            args.setdefault("content_type", form.enctype)
1✔
311
        else:
312
            parsed = urllib.parse.urlparse(url)._replace(query='', fragment='')
1✔
313
            url = urllib.parse.urlunparse(parsed)
1✔
314
        return form.response.goto(url, method=form.method,
1✔
315
                                  params=fields, **args)
316

317
    def _setResponse(self, response):
1✔
318
        self._response = response
1✔
319
        self._changed()
1✔
320

321
    def getLink(self, text=None, url=None, id=None, index=0):
1✔
322
        """See zope.testbrowser.interfaces.IBrowser"""
323
        qa = 'a' if id is None else 'a#%s' % css_escape(id)
1✔
324
        qarea = 'area' if id is None else 'area#%s' % css_escape(id)
1✔
325
        html = self._html
1✔
326
        links = html.select(qa)
1✔
327
        links.extend(html.select(qarea))
1✔
328

329
        matching = []
1✔
330
        for elem in links:
1✔
331
            matches = (isMatching(elem.text, text) and
1✔
332
                       isMatching(elem.get('href', ''), url))
333

334
            if matches:
1✔
335
                matching.append(elem)
1✔
336

337
        if index >= len(matching):
1✔
338
            raise LinkNotFoundError()
1✔
339
        elem = matching[index]
1✔
340

341
        baseurl = self._getBaseUrl()
1✔
342

343
        return Link(elem, self, baseurl)
1✔
344

345
    def follow(self, *args, **kw):
1✔
346
        """Select a link and follow it."""
347
        self.getLink(*args, **kw).click()
1✔
348

349
    def _getBaseUrl(self):
1✔
350
        # Look for <base href> tag and use it as base, if it exists
351
        url = self._response.request.url
1✔
352
        if b"<base" not in self._response.body:
1✔
353
            return url
1✔
354

355
        # we suspect there is a base tag in body, try to find href there
356
        html = self._html
1✔
357
        if not html.head:
1!
358
            return url
×
359
        base = html.head.base
1✔
360
        if not base:
1!
361
            return url
×
362
        return base['href'] or url
1✔
363

364
    def getForm(self, id=None, name=None, action=None, index=None):
1✔
365
        """See zope.testbrowser.interfaces.IBrowser"""
366
        zeroOrOne([id, name, action], '"id", "name", and "action"')
1✔
367
        matching_forms = []
1✔
368
        allforms = self._getAllResponseForms()
1✔
369
        for form in allforms:
1✔
370
            if ((id is not None and form.id == id) or
1✔
371
                (name is not None and form.html.form.get('name') == name) or
372
                (action is not None and re.search(action, form.action)) or
373
                    id == name == action is None):
374
                matching_forms.append(form)
1✔
375

376
        if index is None and not any([id, name, action]):
1✔
377
            if len(matching_forms) == 1:
1✔
378
                index = 0
1✔
379
            else:
380
                raise ValueError(
1✔
381
                    'if no other arguments are given, index is required.')
382

383
        form = disambiguate(matching_forms, '', index)
1✔
384
        return Form(self, form)
1✔
385

386
    def getControl(self, label=None, name=None, index=None):
1✔
387
        """See zope.testbrowser.interfaces.IBrowser"""
388
        intermediate, msg, available = self._getAllControls(
1✔
389
            label, name, self._getAllResponseForms(),
390
            include_subcontrols=True)
391
        control = disambiguate(intermediate, msg, index,
1✔
392
                               controlFormTupleRepr,
393
                               available)
394
        return control
1✔
395

396
    def _getAllResponseForms(self):
1✔
397
        """ Return set of response forms in the order they appear in
398
        ``self._response.form``."""
399
        respforms = self._response.forms
1✔
400
        idxkeys = [k for k in respforms.keys() if isinstance(k, int)]
1✔
401
        return [respforms[k] for k in sorted(idxkeys)]
1✔
402

403
    def _getAllControls(self, label, name, forms, include_subcontrols=False):
1✔
404
        onlyOne([label, name], '"label" and "name"')
1✔
405

406
        # might be an iterator, and we need to iterate twice
407
        forms = list(forms)
1✔
408

409
        available = None
1✔
410
        if label is not None:
1✔
411
            res = self._findByLabel(label, forms, include_subcontrols)
1✔
412
            msg = 'label %r' % label
1✔
413
        elif name is not None:
1!
414
            include_subcontrols = False
1✔
415
            res = self._findByName(name, forms)
1✔
416
            msg = 'name %r' % name
1✔
417
        if not res:
1✔
418
            available = list(self._findAllControls(forms, include_subcontrols))
1✔
419
        return res, msg, available
1✔
420

421
    def _findByLabel(self, label, forms, include_subcontrols=False):
1✔
422
        # forms are iterable of mech_forms
423
        matches = re.compile(r'(^|\b|\W)%s(\b|\W|$)'
1✔
424
                             % re.escape(normalizeWhitespace(label))).search
425
        found = []
1✔
426
        for wtcontrol in self._findAllControls(forms, include_subcontrols):
1✔
427
            control = getattr(wtcontrol, 'control', wtcontrol)
1✔
428
            if control.type == 'hidden':
1✔
429
                continue
1✔
430
            for label in wtcontrol.labels:
1✔
431
                if matches(label):
1✔
432
                    found.append(wtcontrol)
1✔
433
                    break
1✔
434
        return found
1✔
435

436
    def _indexControls(self, form):
1✔
437
        # Unfortunately, webtest will remove all 'name' attributes from
438
        # form.html after parsing. But we need them (at least to locate labels
439
        # for radio buttons). So we are forced to reparse part of html, to
440
        # extract elements.
441
        html = BeautifulSoup(form.text, 'html.parser')
1✔
442
        tags = ('input', 'select', 'textarea', 'button')
1✔
443
        return html.find_all(tags)
1✔
444

445
    def _findByName(self, name, forms):
1✔
446
        return [c for c in self._findAllControls(forms) if c.name == name]
1✔
447

448
    def _findAllControls(self, forms, include_subcontrols=False):
1✔
449
        res = []
1✔
450
        for f in forms:
1✔
451
            if f not in self._controls:
1✔
452
                fc = []
1✔
453
                allelems = self._indexControls(f)
1✔
454
                already_processed = set()
1✔
455
                for cname, wtcontrol in f.field_order:
1✔
456
                    # we need to group checkboxes by name, but leave
457
                    # the other controls in the original order,
458
                    # even if the name repeats
459
                    if isinstance(wtcontrol, webtest.forms.Checkbox):
1✔
460
                        if cname in already_processed:
1✔
461
                            continue
1✔
462
                        already_processed.add(cname)
1✔
463
                        wtcontrols = f.fields[cname]
1✔
464
                    else:
465
                        wtcontrols = [wtcontrol]
1✔
466
                    for c in controlFactory(cname, wtcontrols, allelems, self):
1✔
467
                        fc.append((c, False))
1✔
468

469
                        for subcontrol in c.controls:
1✔
470
                            fc.append((subcontrol, True))
1✔
471

472
                self._controls[f] = fc
1✔
473

474
            controls = [c for c, subcontrol in self._controls[f]
1✔
475
                        if not subcontrol or include_subcontrols]
476
            res.extend(controls)
1✔
477

478
        return res
1✔
479

480
    def _changed(self):
1✔
481
        self._counter += 1
1✔
482
        self._contents = None
1✔
483
        self._controls = {}
1✔
484
        self.__html = None
1✔
485

486
    @contextmanager
1✔
487
    def _preparedRequest(self, url):
1✔
488
        self.timer.start()
1✔
489

490
        headers = {}
1✔
491
        if self._req_referrer is not None:
1✔
492
            headers['Referer'] = self._req_referrer
1✔
493

494
        if self._req_content_type:
1✔
495
            headers['Content-Type'] = self._req_content_type
1✔
496

497
        headers['Connection'] = 'close'
1✔
498
        headers['Host'] = urllib.parse.urlparse(url).netloc
1✔
499
        headers['User-Agent'] = 'Python-urllib/2.4'
1✔
500

501
        headers.update(self._req_headers)
1✔
502

503
        extra_environ = {}
1✔
504
        if self.handleErrors:
1✔
505
            extra_environ['paste.throw_errors'] = None
1✔
506
            headers['X-zope-handle-errors'] = 'True'
1✔
507
        else:
508
            extra_environ['wsgi.handleErrors'] = False
1✔
509
            extra_environ['paste.throw_errors'] = True
1✔
510
            extra_environ['x-wsgiorg.throw_errors'] = True
1✔
511
            headers.pop('X-zope-handle-errors', None)
1✔
512

513
        kwargs = {'headers': sorted(headers.items()),
1✔
514
                  'extra_environ': extra_environ,
515
                  'expect_errors': True}
516

517
        yield kwargs
1✔
518

519
        self._req_content_type = None
1✔
520
        self.timer.stop()
1✔
521

522
    def _absoluteUrl(self, url):
1✔
523
        absolute = url.startswith('http://') or url.startswith('https://')
1✔
524
        if absolute:
1✔
525
            return str(url)
1✔
526

527
        if self._response is None:
1✔
528
            raise BrowserStateError(
1✔
529
                "can't fetch relative reference: not viewing any document")
530

531
        return str(urllib.parse.urljoin(self._getBaseUrl(), url))
1✔
532

533
    def toStr(self, s):
1✔
534
        """Convert possibly unicode object to native string using response
535
        charset"""
536
        if not self._response.charset:
1!
537
            return s
×
538
        if s is None:
1✔
539
            return None
1✔
540
        # Might be an iterable, especially the 'class' attribute.
541
        if isinstance(s, (list, tuple)):
1✔
542
            subs = [self.toStr(sub) for sub in s]
1✔
543
            if isinstance(s, tuple):
1!
544
                return tuple(subs)
×
545
            return subs
1✔
546
        if isinstance(s, bytes):
1✔
547
            return s.decode(self._response.charset)
1✔
548
        return s
1✔
549

550
    @property
1✔
551
    def _html(self):
1✔
552
        if self.__html is None:
1✔
553
            self.__html = self._response.html
1✔
554
        return self.__html
1✔
555

556

557
def controlFactory(name, wtcontrols, elemindex, browser):
1✔
558
    assert len(wtcontrols) > 0
1✔
559

560
    first_wtc = wtcontrols[0]
1✔
561
    checkbox = isinstance(first_wtc, webtest.forms.Checkbox)
1✔
562

563
    # Create control list
564
    if checkbox:
1✔
565
        ctrlelems = [(wtc, elemindex[wtc.pos]) for wtc in wtcontrols]
1✔
566
        controls = [CheckboxListControl(name, ctrlelems, browser)]
1✔
567

568
    else:
569
        controls = []
1✔
570
        for wtc in wtcontrols:
1✔
571
            controls.append(simpleControlFactory(
1✔
572
                wtc, wtc.form, elemindex, browser))
573

574
    return controls
1✔
575

576

577
def simpleControlFactory(wtcontrol, form, elemindex, browser):
1✔
578
    if isinstance(wtcontrol, webtest.forms.Radio):
1✔
579
        elems = [e for e in elemindex
1✔
580
                 if e.attrs.get('name') == wtcontrol.name]
581
        return RadioListControl(wtcontrol, form, elems, browser)
1✔
582

583
    elem = elemindex[wtcontrol.pos]
1✔
584
    if isinstance(wtcontrol, (webtest.forms.Select,
1✔
585
                              webtest.forms.MultipleSelect)):
586
        return ListControl(wtcontrol, form, elem, browser)
1✔
587

588
    elif isinstance(wtcontrol, webtest.forms.Submit):
1✔
589
        if wtcontrol.attrs.get('type', 'submit') == 'image':
1✔
590
            return ImageControl(wtcontrol, form, elem, browser)
1✔
591
        else:
592
            return SubmitControl(wtcontrol, form, elem, browser)
1✔
593
    else:
594
        return Control(wtcontrol, form, elem, browser)
1✔
595

596

597
@implementer(interfaces.ILink)
1✔
598
class Link(SetattrErrorsMixin):
1✔
599

600
    def __init__(self, link, browser, baseurl=""):
1✔
601
        self._link = link
1✔
602
        self.browser = browser
1✔
603
        self._baseurl = baseurl
1✔
604
        self._browser_counter = self.browser._counter
1✔
605
        self._enable_setattr_errors = True
1✔
606

607
    def click(self):
1✔
608
        if self._browser_counter != self.browser._counter:
1✔
609
            raise interfaces.ExpiredError
1✔
610
        self.browser.open(self.url, referrer=self.browser.url)
1✔
611

612
    @property
1✔
613
    def url(self):
1✔
614
        relurl = self._link['href']
1✔
615
        return self.browser._absoluteUrl(relurl)
1✔
616

617
    @property
1✔
618
    def text(self):
1✔
619
        txt = normalizeWhitespace(self._link.text)
1✔
620
        return self.browser.toStr(txt)
1✔
621

622
    @property
1✔
623
    def tag(self):
1✔
624
        return str(self._link.name)
1✔
625

626
    @property
1✔
627
    def attrs(self):
1✔
628
        toStr = self.browser.toStr
1✔
629
        return {toStr(k): toStr(v) for k, v in self._link.attrs.items()}
1✔
630

631
    def __repr__(self):
1✔
632
        return "<{} text='{}' url='{}'>".format(
1✔
633
            self.__class__.__name__, normalizeWhitespace(self.text), self.url)
634

635

636
def controlFormTupleRepr(wtcontrol):
1✔
637
    return wtcontrol.mechRepr()
1✔
638

639

640
@implementer(interfaces.IControl)
1✔
641
class Control(SetattrErrorsMixin):
1✔
642

643
    _enable_setattr_errors = False
1✔
644

645
    def __init__(self, control, form, elem, browser):
1✔
646
        self._control = control
1✔
647
        self._form = form
1✔
648
        self._elem = elem
1✔
649
        self.browser = browser
1✔
650
        self._browser_counter = self.browser._counter
1✔
651

652
        # disable addition of further attributes
653
        self._enable_setattr_errors = True
1✔
654

655
    @property
1✔
656
    def disabled(self):
1✔
657
        return 'disabled' in self._control.attrs
1✔
658

659
    @property
1✔
660
    def readonly(self):
1✔
661
        return 'readonly' in self._control.attrs
1✔
662

663
    @property
1✔
664
    def type(self):
1✔
665
        typeattr = self._control.attrs.get('type', None)
1✔
666
        if typeattr is None:
1✔
667
            # try to figure out type by tag
668
            if self._control.tag == 'textarea':
1✔
669
                return 'textarea'
1✔
670
            else:
671
                # By default, inputs are of 'text' type
672
                return 'text'
1✔
673
        return self.browser.toStr(typeattr)
1✔
674

675
    @property
1✔
676
    def name(self):
1✔
677
        if self._control.name is None:
1✔
678
            return None
1✔
679
        return self.browser.toStr(self._control.name)
1✔
680

681
    @property
1✔
682
    def multiple(self):
1✔
683
        return 'multiple' in self._control.attrs
1✔
684

685
    @property
1✔
686
    def value(self):
1✔
687
        if self.type == 'file':
1✔
688
            if not self._control.value:
1!
689
                return None
1✔
690

691
        if self.type == 'image':
1✔
692
            if not self._control.value:
1!
693
                return ''
1✔
694

695
        if isinstance(self._control, webtest.forms.Submit):
1✔
696
            return self.browser.toStr(self._control.value_if_submitted())
1✔
697

698
        val = self._control.value
1✔
699
        if val is None:
1!
700
            return None
×
701

702
        return self.browser.toStr(val)
1✔
703

704
    @value.setter
1✔
705
    def value(self, value):
1✔
706
        if self._browser_counter != self.browser._counter:
1!
707
            raise interfaces.ExpiredError
×
708
        if self.readonly:
1✔
709
            raise AttributeError("Trying to set value of readonly control")
1✔
710
        if self.type == 'file':
1✔
711
            self.add_file(value, content_type=None, filename=None)
1✔
712
        else:
713
            self._control.value = value
1✔
714

715
    def add_file(self, file, content_type, filename):
1✔
716
        if self.type != 'file':
1!
717
            raise TypeError("Can't call add_file on %s controls"
×
718
                            % self.type)
719

720
        if hasattr(file, 'read'):
1✔
721
            contents = file.read()
1✔
722
        else:
723
            contents = file
1✔
724

725
        self._form[self.name] = webtest.forms.Upload(filename or '', contents,
1✔
726
                                                     content_type)
727

728
    def clear(self):
1✔
729
        if self._browser_counter != self.browser._counter:
×
730
            raise zope.testbrowser.interfaces.ExpiredError
×
731
        self.value = None
×
732

733
    def __repr__(self):
1✔
734
        return "<{} name='{}' type='{}'>".format(
1✔
735
            self.__class__.__name__, self.name, self.type)
736

737
    @Lazy
1✔
738
    def labels(self):
1✔
739
        return [self.browser.toStr(label)
1✔
740
                for label in getControlLabels(self._elem, self._form.html)]
741

742
    @property
1✔
743
    def controls(self):
1✔
744
        return []
1✔
745

746
    def mechRepr(self):
1✔
747
        # emulate mechanize control representation
748
        toStr = self.browser.toStr
1✔
749
        ctrl = self._control
1✔
750
        if isinstance(ctrl, (webtest.forms.Text, webtest.forms.Email)):
1✔
751
            tp = ctrl.attrs.get('type')
1✔
752
            infos = []
1✔
753
            if 'readonly' in ctrl.attrs or tp == 'hidden':
1✔
754
                infos.append('readonly')
1✔
755
            if 'disabled' in ctrl.attrs:
1!
756
                infos.append('disabled')
×
757

758
            classnames = {'password': "PasswordControl",
1✔
759
                          'hidden': "HiddenControl",
760
                          'email': "EMailControl",
761
                          }
762
            clname = classnames.get(tp, "TextControl")
1✔
763
            return "<{}({}={}){}>".format(
1✔
764
                clname, toStr(ctrl.name), toStr(ctrl.value),
765
                ' (%s)' % (', '.join(infos)) if infos else '')
766

767
        if isinstance(ctrl, (webtest.forms.File, webtest.forms.Field)):
1✔
768
            return repr(ctrl) + "<-- unknown"
1✔
769
        raise NotImplementedError(str((self, ctrl)))
770

771

772
@implementer(interfaces.ISubmitControl)
1✔
773
class SubmitControl(Control):
1✔
774

775
    def click(self):
1✔
776
        if self._browser_counter != self.browser._counter:
1✔
777
            raise interfaces.ExpiredError
1✔
778
        self.browser._clickSubmit(self._form, self._control)
1✔
779

780
    @Lazy
1✔
781
    def labels(self):
1✔
782
        labels = super().labels
1✔
783
        labels.append(self._control.value_if_submitted())
1✔
784
        if self._elem.text:
1✔
785
            labels.append(normalizeWhitespace(self._elem.text))
1✔
786
        return [label for label in labels if label]
1✔
787

788
    def mechRepr(self):
1✔
789
        name = self.name if self.name is not None else "<None>"
1✔
790
        value = self.value if self.value is not None else "<None>"
1✔
791
        extra = ' (disabled)' if self.disabled else ''
1✔
792
        # Mechanize explicitly told us submit controls were readonly, as
793
        # if they could be any other way.... *sigh*  Let's take this
794
        # opportunity and strip that off.
795
        return "<SubmitControl({}={}){}>".format(name, value, extra)
1✔
796

797

798
@implementer(interfaces.IListControl)
1✔
799
class ListControl(Control):
1✔
800

801
    def __init__(self, control, form, elem, browser):
1✔
802
        super().__init__(control, form, elem, browser)
1✔
803
        # HACK: set default value of a list control and then forget about
804
        # initial default values. Otherwise webtest will not allow to set None
805
        # as a value of select and radio controls.
806
        v = control.value
1✔
807
        if v:
1✔
808
            control.value = v
1✔
809
            # Uncheck all the options   Carefully: WebTest used to have
810
            # 2-tuples here before commit 1031d82e, and 3-tuples since then.
811
            control.options = [option[:1] + (False,) + option[2:]
1✔
812
                               for option in control.options]
813

814
    @property
1✔
815
    def type(self):
1✔
816
        return 'select'
1✔
817

818
    @property
1✔
819
    def value(self):
1✔
820
        val = self._control.value
1✔
821
        if val is None:
1✔
822
            return []
1✔
823

824
        if self.multiple and isinstance(val, (list, tuple)):
1✔
825
            return [self.browser.toStr(v) for v in val]
1✔
826
        else:
827
            return [self.browser.toStr(val)]
1✔
828

829
    @value.setter
1✔
830
    def value(self, value):
1✔
831
        if not value:
1✔
832
            self._set_falsy_value(value)
1✔
833
        else:
834
            if not self.multiple and isinstance(value, (list, tuple)):
1✔
835
                value = value[0]
1✔
836
            self._control.value = value
1✔
837

838
    @property
1✔
839
    def _selectedIndex(self):
1✔
840
        return self._control.selectedIndex
1✔
841

842
    @_selectedIndex.setter
1✔
843
    def _selectedIndex(self, index):
1✔
844
        self._control.force_value(webtest.forms.NoValue)
1✔
845
        self._control.selectedIndex = index
1✔
846

847
    def _set_falsy_value(self, value):
1✔
848
        self._control.force_value(value)
1✔
849

850
    @property
1✔
851
    def displayValue(self):
1✔
852
        """See zope.testbrowser.interfaces.IListControl"""
853
        # not implemented for anything other than select;
854
        cvalue = self._control.value
1✔
855
        if cvalue is None:
1✔
856
            return []
1✔
857

858
        if not isinstance(cvalue, list):
1✔
859
            cvalue = [cvalue]
1✔
860

861
        alltitles = []
1✔
862
        for key, titles in self._getOptions():
1✔
863
            if key in cvalue:
1✔
864
                alltitles.append(titles[0])
1✔
865
        return alltitles
1✔
866

867
    @displayValue.setter
1✔
868
    def displayValue(self, value):
1✔
869
        if self._browser_counter != self.browser._counter:
1!
870
            raise interfaces.ExpiredError
×
871

872
        if isinstance(value, str):
1✔
873
            value = [value]
1✔
874
        if not self.multiple and len(value) > 1:
1✔
875
            raise ItemCountError(
1✔
876
                "single selection list, must set sequence of length 0 or 1")
877
        values = []
1✔
878
        found = set()
1✔
879
        for key, titles in self._getOptions():
1✔
880
            matches = {v for t in titles for v in value if v in t}
1✔
881
            if matches:
1✔
882
                values.append(key)
1✔
883
                found.update(matches)
1✔
884
        for v in value:
1✔
885
            if v not in found:
1✔
886
                raise ItemNotFoundError(v)
1✔
887
        self.value = values
1✔
888

889
    @property
1✔
890
    def displayOptions(self):
1✔
891
        """See zope.testbrowser.interfaces.IListControl"""
892
        return [titles[0] for key, titles in self._getOptions()]
1✔
893

894
    @property
1✔
895
    def options(self):
1✔
896
        """See zope.testbrowser.interfaces.IListControl"""
897
        return [key for key, title in self._getOptions()]
1✔
898

899
    def getControl(self, label=None, value=None, index=None):
1✔
900
        if self._browser_counter != self.browser._counter:
1!
901
            raise interfaces.ExpiredError
×
902

903
        return getControl(self.controls, label, value, index)
1✔
904

905
    @property
1✔
906
    def controls(self):
1✔
907
        if self._browser_counter != self.browser._counter:
1!
908
            raise interfaces.ExpiredError
×
909
        ctrls = []
1✔
910
        for idx, elem in enumerate(self._elem.select('option')):
1✔
911
            ctrls.append(ItemControl(self, elem, self._form, self.browser,
1✔
912
                                     idx))
913

914
        return ctrls
1✔
915

916
    def _getOptions(self):
1✔
917
        return [(c.optionValue, c.labels) for c in self.controls]
1✔
918

919
    def mechRepr(self):
1✔
920
        # TODO: figure out what is replacement for "[*, ambiguous])"
921
        return "<SelectControl(%s=[*, ambiguous])>" % self.name
1✔
922

923

924
class RadioListControl(ListControl):
1✔
925

926
    _elems = None
1✔
927

928
    def __init__(self, control, form, elems, browser):
1✔
929
        super().__init__(
1✔
930
            control, form, elems[0], browser)
931
        self._elems = elems
1✔
932

933
    @property
1✔
934
    def type(self):
1✔
935
        return 'radio'
1✔
936

937
    def __repr__(self):
1✔
938
        # Return backwards compatible representation
939
        return "<ListControl name='%s' type='radio'>" % self.name
1✔
940

941
    @property
1✔
942
    def controls(self):
1✔
943
        if self._browser_counter != self.browser._counter:
1!
944
            raise interfaces.ExpiredError
×
945
        for idx, opt in enumerate(self._elems):
1✔
946
            yield RadioItemControl(self, opt, self._form, self.browser, idx)
1✔
947

948
    @Lazy
1✔
949
    def labels(self):
1✔
950
        # Parent radio button control has no labels. Children are labeled.
951
        return []
1✔
952

953
    def _set_falsy_value(self, value):
1✔
954
        # HACK: Force unsetting selected value, by avoiding validity check.
955
        # Note, that force_value will not work for webtest.forms.Radio
956
        # controls.
957
        self._control.selectedIndex = None
1✔
958

959

960
@implementer(interfaces.IListControl)
1✔
961
class CheckboxListControl(SetattrErrorsMixin):
1✔
962
    def __init__(self, name, ctrlelems, browser):
1✔
963
        self.name = name
1✔
964
        self.browser = browser
1✔
965
        self._browser_counter = self.browser._counter
1✔
966
        self._ctrlelems = ctrlelems
1✔
967
        self._enable_setattr_errors = True
1✔
968

969
    @property
1✔
970
    def options(self):
1✔
971
        opts = [self._trValue(c.optionValue) for c in self.controls]
1✔
972
        return opts
1✔
973

974
    @property
1✔
975
    def displayOptions(self):
1✔
976
        return [c.labels[0] for c in self.controls]
1✔
977

978
    @property
1✔
979
    def value(self):
1✔
980
        ctrls = self.controls
1✔
981
        val = [self._trValue(c.optionValue) for c in ctrls if c.selected]
1✔
982

983
        if len(self._ctrlelems) == 1 and val == [True]:
1✔
984
            return True
1✔
985
        return val
1✔
986

987
    @value.setter
1✔
988
    def value(self, value):
1✔
989
        ctrls = self.controls
1✔
990
        if isinstance(value, (list, tuple)):
1✔
991
            for c in ctrls:
1✔
992
                c.selected = c.optionValue in value
1✔
993
        else:
994
            ctrls[0].selected = value
1✔
995

996
    @property
1✔
997
    def displayValue(self):
1✔
998
        return [c.labels[0] for c in self.controls if c.selected]
1✔
999

1000
    @displayValue.setter
1✔
1001
    def displayValue(self, value):
1✔
1002
        found = set()
1✔
1003
        for c in self.controls:
1✔
1004
            matches = {v for v in value if v in c.labels}
1✔
1005
            c.selected = bool(matches)
1✔
1006
            found.update(matches)
1✔
1007
        for v in value:
1✔
1008
            if v not in found:
1✔
1009
                raise ItemNotFoundError(v)
1✔
1010

1011
    @property
1✔
1012
    def multiple(self):
1✔
1013
        return True
1✔
1014

1015
    @property
1✔
1016
    def disabled(self):
1✔
1017
        return all('disabled' in e.attrs for c, e in self._ctrlelems)
1✔
1018

1019
    @property
1✔
1020
    def type(self):
1✔
1021
        return 'checkbox'
1✔
1022

1023
    def getControl(self, label=None, value=None, index=None):
1✔
1024
        if self._browser_counter != self.browser._counter:
1!
1025
            raise interfaces.ExpiredError
×
1026

1027
        return getControl(self.controls, label, value, index)
1✔
1028

1029
    @property
1✔
1030
    def controls(self):
1✔
1031
        return [CheckboxItemControl(self, c, e, c.form, self.browser, i)
1✔
1032
                for i, (c, e) in enumerate(self._ctrlelems)]
1033

1034
    def clear(self):
1✔
1035
        if self._browser_counter != self.browser._counter:
×
1036
            raise zope.testbrowser.interfaces.ExpiredError
×
1037
        self.value = []
×
1038

1039
    def mechRepr(self):
1✔
1040
        return "<SelectControl(%s=[*, ambiguous])>" % self.browser.toStr(
1✔
1041
            self.name)
1042

1043
    @Lazy
1✔
1044
    def labels(self):
1✔
1045
        return []
1✔
1046

1047
    def __repr__(self):
1✔
1048
        # Return backwards compatible representation
1049
        return "<ListControl name='%s' type='checkbox'>" % self.name
1✔
1050

1051
    def _trValue(self, chbval):
1✔
1052
        return True if chbval == 'on' else chbval
1✔
1053

1054

1055
@implementer(interfaces.IImageSubmitControl)
1✔
1056
class ImageControl(Control):
1✔
1057

1058
    def click(self, coord=(1, 1)):
1✔
1059
        if self._browser_counter != self.browser._counter:
1✔
1060
            raise interfaces.ExpiredError
1✔
1061
        self.browser._clickSubmit(self._form, self._control, coord)
1✔
1062

1063
    def mechRepr(self):
1✔
1064
        return "ImageControl???"  # TODO
1✔
1065

1066

1067
@implementer(interfaces.IItemControl)
1✔
1068
class ItemControl(SetattrErrorsMixin):
1✔
1069

1070
    def __init__(self, parent, elem, form, browser, index):
1✔
1071
        self._parent = parent
1✔
1072
        self._elem = elem
1✔
1073
        self._index = index
1✔
1074
        self._form = form
1✔
1075
        self.browser = browser
1✔
1076
        self._browser_counter = self.browser._counter
1✔
1077
        self._enable_setattr_errors = True
1✔
1078

1079
    @property
1✔
1080
    def control(self):
1✔
1081
        if self._browser_counter != self.browser._counter:
1!
1082
            raise interfaces.ExpiredError
×
1083
        return self._parent
1✔
1084

1085
    @property
1✔
1086
    def _value(self):
1✔
1087
        return self._elem.attrs.get('value', self._elem.text)
1✔
1088

1089
    @property
1✔
1090
    def disabled(self):
1✔
1091
        return 'disabled' in self._elem.attrs
1✔
1092

1093
    @property
1✔
1094
    def selected(self):
1✔
1095
        """See zope.testbrowser.interfaces.IControl"""
1096
        if self._parent.multiple:
1✔
1097
            return self._value in self._parent.value
1✔
1098
        else:
1099
            return self._parent._selectedIndex == self._index
1✔
1100

1101
    @selected.setter
1✔
1102
    def selected(self, value):
1✔
1103
        if self._browser_counter != self.browser._counter:
1!
1104
            raise interfaces.ExpiredError
×
1105
        if self._parent.multiple:
1✔
1106
            values = list(self._parent.value)
1✔
1107
            if value:
1!
1108
                values.append(self._value)
1✔
1109
            else:
1110
                values = [v for v in values if v != self._value]
×
1111
            self._parent.value = values
1✔
1112
        else:
1113
            if value:
1✔
1114
                self._parent._selectedIndex = self._index
1✔
1115
            else:
1116
                self._parent.value = None
1✔
1117

1118
    @property
1✔
1119
    def optionValue(self):
1✔
1120
        return self.browser.toStr(self._value)
1✔
1121

1122
    @property
1✔
1123
    def value(self):
1✔
1124
        # internal alias for convenience implementing getControl()
1125
        return self.optionValue
1✔
1126

1127
    def click(self):
1✔
1128
        if self._browser_counter != self.browser._counter:
1!
1129
            raise interfaces.ExpiredError
×
1130
        self.selected = not self.selected
1✔
1131

1132
    def __repr__(self):
1✔
1133
        return (
1✔
1134
            "<ItemControl name='%s' type='select' optionValue=%r selected=%r>"
1135
        ) % (self._parent.name, self.optionValue, self.selected)
1136

1137
    @Lazy
1✔
1138
    def labels(self):
1✔
1139
        labels = [self._elem.attrs.get('label'), self._elem.text]
1✔
1140
        return [self.browser.toStr(normalizeWhitespace(lbl))
1✔
1141
                for lbl in labels if lbl]
1142

1143
    def mechRepr(self):
1✔
1144
        toStr = self.browser.toStr
1✔
1145
        contents = toStr(normalizeWhitespace(self._elem.text))
1✔
1146
        id = toStr(self._elem.attrs.get('id'))
1✔
1147
        label = toStr(self._elem.attrs.get('label', contents))
1✔
1148
        value = toStr(self._value)
1✔
1149
        name = toStr(self._elem.attrs.get('name', value))  # XXX wha????
1✔
1150
        return (
1✔
1151
            "<Item name='%s' id=%s contents='%s' value='%s' label='%s'>"
1152
        ) % (name, id, contents, value, label)
1153

1154

1155
class RadioItemControl(ItemControl):
1✔
1156
    @property
1✔
1157
    def optionValue(self):
1✔
1158
        return self.browser.toStr(self._elem.attrs.get('value'))
1✔
1159

1160
    @Lazy
1✔
1161
    def labels(self):
1✔
1162
        return [self.browser.toStr(label)
1✔
1163
                for label in getControlLabels(self._elem, self._form.html)]
1164

1165
    def __repr__(self):
1✔
1166
        return (
1✔
1167
            "<ItemControl name='%s' type='radio' optionValue=%r selected=%r>"
1168
        ) % (self._parent.name, self.optionValue, self.selected)
1169

1170
    def click(self):
1✔
1171
        # Radio buttons cannot be unselected by clicking on them, see
1172
        # https://github.com/zopefoundation/zope.testbrowser/issues/68
1173
        if not self.selected:
1✔
1174
            super().click()
1✔
1175

1176
    def mechRepr(self):
1✔
1177
        toStr = self.browser.toStr
1✔
1178
        id = toStr(self._elem.attrs.get('id'))
1✔
1179
        value = toStr(self._elem.attrs.get('value'))
1✔
1180
        name = toStr(self._elem.attrs.get('name'))
1✔
1181

1182
        props = []
1✔
1183
        if self._elem.parent.name == 'label':
1✔
1184
            props.append((
1✔
1185
                '__label', {'__text': toStr(self._elem.parent.text)}))
1186
        if self.selected:
1✔
1187
            props.append(('checked', 'checked'))
1✔
1188
        props.append(('type', 'radio'))
1✔
1189
        props.append(('name', name))
1✔
1190
        props.append(('value', value))
1✔
1191
        props.append(('id', id))
1✔
1192

1193
        propstr = ' '.join('{}={!r}'.format(pk, pv) for pk, pv in props)
1✔
1194
        return "<Item name='{}' id='{}' {}>".format(value, id, propstr)
1✔
1195

1196

1197
class CheckboxItemControl(ItemControl):
1✔
1198
    _control = None
1✔
1199

1200
    def __init__(self, parent, wtcontrol, elem, form, browser, index):
1✔
1201
        super().__init__(parent, elem, form, browser,
1✔
1202
                         index)
1203
        self._control = wtcontrol
1✔
1204

1205
    @property
1✔
1206
    def selected(self):
1✔
1207
        """See zope.testbrowser.interfaces.IControl"""
1208
        return self._control.checked
1✔
1209

1210
    @selected.setter
1✔
1211
    def selected(self, value):
1✔
1212
        if self._browser_counter != self.browser._counter:
1!
1213
            raise interfaces.ExpiredError
×
1214
        self._control.checked = value
1✔
1215

1216
    @property
1✔
1217
    def optionValue(self):
1✔
1218
        return self.browser.toStr(self._control._value or 'on')
1✔
1219

1220
    @Lazy
1✔
1221
    def labels(self):
1✔
1222
        return [self.browser.toStr(label)
1✔
1223
                for label in getControlLabels(self._elem, self._form.html)]
1224

1225
    def __repr__(self):
1✔
1226
        return (
1✔
1227
            "<ItemControl name='%s' type='checkbox' "
1228
            "optionValue=%r selected=%r>"
1229
        ) % (self._control.name, self.optionValue, self.selected)
1230

1231
    def mechRepr(self):
1✔
1232
        id = self.browser.toStr(self._elem.attrs.get('id'))
1✔
1233
        value = self.browser.toStr(self._elem.attrs.get('value'))
1✔
1234
        name = self.browser.toStr(self._elem.attrs.get('name'))
1✔
1235

1236
        props = []
1✔
1237
        if self._elem.parent.name == 'label':
1✔
1238
            props.append(('__label', {'__text': self.browser.toStr(
1✔
1239
                self._elem.parent.text)}))
1240
        if self.selected:
1✔
1241
            props.append(('checked', 'checked'))
1✔
1242
        props.append(('name', name))
1✔
1243
        props.append(('type', 'checkbox'))
1✔
1244
        props.append(('id', id))
1✔
1245
        props.append(('value', value))
1✔
1246

1247
        propstr = ' '.join('{}={!r}'.format(pk, pv) for pk, pv in props)
1✔
1248
        return "<Item name='{}' id='{}' {}>".format(value, id, propstr)
1✔
1249

1250

1251
@implementer(interfaces.IForm)
1✔
1252
class Form(SetattrErrorsMixin):
1✔
1253
    """HTML Form"""
1254

1255
    def __init__(self, browser, form):
1✔
1256
        """Initialize the Form
1257

1258
        browser - a Browser instance
1259
        form - a webtest.Form instance
1260
        """
1261
        self.browser = browser
1✔
1262
        self._form = form
1✔
1263
        self._browser_counter = self.browser._counter
1✔
1264
        self._enable_setattr_errors = True
1✔
1265

1266
    @property
1✔
1267
    def action(self):
1✔
1268
        return self.browser._absoluteUrl(self._form.action)
1✔
1269

1270
    @property
1✔
1271
    def method(self):
1✔
1272
        return str(self._form.method)
1✔
1273

1274
    @property
1✔
1275
    def enctype(self):
1✔
1276
        return str(self._form.enctype)
1✔
1277

1278
    @property
1✔
1279
    def name(self):
1✔
1280
        return str(self._form.html.form.get('name'))
1✔
1281

1282
    @property
1✔
1283
    def id(self):
1✔
1284
        """See zope.testbrowser.interfaces.IForm"""
1285
        return str(self._form.id)
1✔
1286

1287
    def submit(self, label=None, name=None, index=None, coord=None):
1✔
1288
        """See zope.testbrowser.interfaces.IForm"""
1289
        if self._browser_counter != self.browser._counter:
1!
1290
            raise interfaces.ExpiredError
×
1291

1292
        form = self._form
1✔
1293
        if label is not None or name is not None:
1✔
1294
            controls, msg, available = self.browser._getAllControls(
1✔
1295
                label, name, [form])
1296
            controls = [c for c in controls
1✔
1297
                        if isinstance(c, (ImageControl, SubmitControl))]
1298
            control = disambiguate(
1✔
1299
                controls, msg, index, controlFormTupleRepr, available)
1300
            self.browser._clickSubmit(form, control._control, coord)
1✔
1301
        else:  # JavaScript sort of submit
1302
            if index is not None or coord is not None:
1!
1303
                raise ValueError(
×
1304
                    'May not use index or coord without a control')
1305
            self.browser._clickSubmit(form)
1✔
1306

1307
    def getControl(self, label=None, name=None, index=None):
1✔
1308
        """See zope.testbrowser.interfaces.IBrowser"""
1309
        if self._browser_counter != self.browser._counter:
1!
1310
            raise interfaces.ExpiredError
×
1311
        intermediate, msg, available = self.browser._getAllControls(
1✔
1312
            label, name, [self._form], include_subcontrols=True)
1313
        return disambiguate(intermediate, msg, index,
1✔
1314
                            controlFormTupleRepr, available)
1315

1316
    @property
1✔
1317
    def controls(self):
1✔
1318
        return list(self.browser._findAllControls(
1✔
1319
            [self._form], include_subcontrols=True))
1320

1321

1322
def disambiguate(intermediate, msg, index, choice_repr=None, available=None):
1✔
1323
    if intermediate:
1✔
1324
        if index is None:
1✔
1325
            if len(intermediate) > 1:
1✔
1326
                if choice_repr:
1!
1327
                    msg += ' matches:' + ''.join([
1✔
1328
                        '\n  %s' % choice_repr(choice)
1329
                        for choice in intermediate])
1330
                raise AmbiguityError(msg)
1✔
1331
            else:
1332
                return intermediate[0]
1✔
1333
        else:
1334
            try:
1✔
1335
                return intermediate[index]
1✔
1336
            except IndexError:
1✔
1337
                msg = (
1✔
1338
                    '%s\nIndex %d out of range, available choices are 0...%d'
1339
                ) % (msg, index, len(intermediate) - 1)
1340
                if choice_repr:
1!
1341
                    msg += ''.join(['\n  %d: %s' % (n, choice_repr(choice))
1✔
1342
                                    for n, choice in enumerate(intermediate)])
1343
    else:
1344
        if available:
1✔
1345
            msg += '\navailable items:' + ''.join([
1✔
1346
                '\n  %s' % choice_repr(choice)
1347
                for choice in available])
1348
        elif available is not None:  # empty list
1✔
1349
            msg += '\n(there are no form items in the HTML)'
1✔
1350
    raise LookupError(msg)
1✔
1351

1352

1353
def onlyOne(items, description):
1✔
1354
    total = sum([bool(i) for i in items])
1✔
1355
    if total == 0 or total > 1:
1✔
1356
        raise ValueError(
1✔
1357
            "Supply one and only one of %s as arguments" % description)
1358

1359

1360
def zeroOrOne(items, description):
1✔
1361
    if sum([bool(i) for i in items]) > 1:
1!
1362
        raise ValueError(
×
1363
            "Supply no more than one of %s as arguments" % description)
1364

1365

1366
def getControl(controls, label=None, value=None, index=None):
1✔
1367
    onlyOne([label, value], '"label" and "value"')
1✔
1368

1369
    if label is not None:
1✔
1370
        options = [c for c in controls
1✔
1371
                   if any(isMatching(control_label, label)
1372
                          for control_label in c.labels)]
1373
        msg = 'label %r' % label
1✔
1374
    elif value is not None:
1!
1375
        options = [c for c in controls if isMatching(c.value, value)]
1✔
1376
        msg = 'value %r' % value
1✔
1377

1378
    res = disambiguate(options, msg, index, controlFormTupleRepr,
1✔
1379
                       available=controls)
1380
    return res
1✔
1381

1382

1383
def getControlLabels(celem, html):
1✔
1384
    labels = []
1✔
1385

1386
    # In case celem is contained in label element, use its text as a label
1387
    if celem.parent.name == 'label':
1✔
1388
        labels.append(normalizeWhitespace(celem.parent.text))
1✔
1389

1390
    # find all labels, connected by 'for' attribute
1391
    controlid = celem.attrs.get('id')
1✔
1392
    if controlid:
1✔
1393
        forlbls = html.select('label[for="%s"]' % controlid)
1✔
1394
        labels.extend([normalizeWhitespace(label.text) for label in forlbls])
1✔
1395

1396
    return [label for label in labels if label is not None]
1✔
1397

1398

1399
def normalizeWhitespace(string):
1✔
1400
    return ' '.join(string.split())
1✔
1401

1402

1403
def isMatching(string, expr):
1✔
1404
    """Determine whether ``expr`` matches to ``string``
1405

1406
    ``expr`` can be None, plain text or regular expression.
1407

1408
      * If ``expr`` is ``None``, ``string`` is considered matching
1409
      * If ``expr`` is plain text, its equality to ``string`` will be checked
1410
      * If ``expr`` is regexp, regexp matching agains ``string`` will
1411
        be performed
1412
    """
1413
    if expr is None:
1✔
1414
        return True
1✔
1415

1416
    if isinstance(expr, RegexType):
1!
1417
        return expr.match(normalizeWhitespace(string))
×
1418
    else:
1419
        return normalizeWhitespace(expr) in normalizeWhitespace(string)
1✔
1420

1421

1422
class Timer:
1✔
1423
    start_time = 0
1✔
1424
    end_time = 0
1✔
1425

1426
    def _getTime(self):
1✔
1427
        return time.perf_counter()
1✔
1428

1429
    def start(self):
1✔
1430
        """Begin a timing period"""
1431
        self.start_time = self._getTime()
1✔
1432
        self.end_time = None
1✔
1433

1434
    def stop(self):
1✔
1435
        """End a timing period"""
1436
        self.end_time = self._getTime()
1✔
1437

1438
    @property
1✔
1439
    def elapsedSeconds(self):
1✔
1440
        """Elapsed time from calling `start` to calling `stop` or present time
1441

1442
        If `stop` has been called, the timing period stopped then, otherwise
1443
        the end is the current time.
1444
        """
1445
        if self.end_time is None:
1!
1446
            end_time = self._getTime()
×
1447
        else:
1448
            end_time = self.end_time
1✔
1449
        return end_time - self.start_time
1✔
1450

1451
    def __enter__(self):
1✔
1452
        self.start()
×
1453

1454
    def __exit__(self, exc_type, exc_value, traceback):
1✔
1455
        self.stop()
×
1456

1457

1458
class History:
1✔
1459
    """
1460

1461
    Though this will become public, the implied interface is not yet stable.
1462

1463
    """
1464

1465
    def __init__(self):
1✔
1466
        self._history = []  # LIFO
1✔
1467

1468
    def add(self, response):
1✔
1469
        self._history.append(response)
1✔
1470

1471
    def back(self, n, _response):
1✔
1472
        response = _response
1✔
1473
        while n > 0 or response is None:
1✔
1474
            try:
1✔
1475
                response = self._history.pop()
1✔
1476
            except IndexError:
×
1477
                raise BrowserStateError("already at start of history")
×
1478
            n -= 1
1✔
1479
        return response
1✔
1480

1481
    def clear(self):
1✔
1482
        del self._history[:]
×
1483

1484

1485
class AmbiguityError(ValueError):
1✔
1486
    pass
1✔
1487

1488

1489
class BrowserStateError(Exception):
1✔
1490
    pass
1✔
1491

1492

1493
class LinkNotFoundError(IndexError):
1✔
1494
    pass
1✔
1495

1496

1497
class ItemCountError(ValueError):
1✔
1498
    pass
1✔
1499

1500

1501
class ItemNotFoundError(ValueError):
1✔
1502
    pass
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc