mercurial/hgweb/request.py
changeset 43077 687b865b95ad
parent 43076 2372284d9457
child 43106 d783f945a701
equal deleted inserted replaced
43076:2372284d9457 43077:687b865b95ad
    67         Raises KeyError if key not defined or it has multiple values set.
    67         Raises KeyError if key not defined or it has multiple values set.
    68         """
    68         """
    69         vals = self._items[key]
    69         vals = self._items[key]
    70 
    70 
    71         if len(vals) > 1:
    71         if len(vals) > 1:
    72             raise KeyError('multiple values for %r' % key)
    72             raise KeyError(b'multiple values for %r' % key)
    73 
    73 
    74         return vals[0]
    74         return vals[0]
    75 
    75 
    76     def asdictoflists(self):
    76     def asdictoflists(self):
    77         return {k: list(v) for k, v in self._items.iteritems()}
    77         return {k: list(v) for k, v in self._items.iteritems()}
   170 
   170 
   171     # Some hosting solutions are emulating hgwebdir, and dispatching directly
   171     # Some hosting solutions are emulating hgwebdir, and dispatching directly
   172     # to an hgweb instance using this environment variable.  This was always
   172     # to an hgweb instance using this environment variable.  This was always
   173     # checked prior to d7fd203e36cc; keep doing so to avoid breaking them.
   173     # checked prior to d7fd203e36cc; keep doing so to avoid breaking them.
   174     if not reponame:
   174     if not reponame:
   175         reponame = env.get('REPO_NAME')
   175         reponame = env.get(b'REPO_NAME')
   176 
   176 
   177     if altbaseurl:
   177     if altbaseurl:
   178         altbaseurl = util.url(altbaseurl)
   178         altbaseurl = util.url(altbaseurl)
   179 
   179 
   180     # https://www.python.org/dev/peps/pep-0333/#environ-variables defines
   180     # https://www.python.org/dev/peps/pep-0333/#environ-variables defines
   181     # the environment variables.
   181     # the environment variables.
   182     # https://www.python.org/dev/peps/pep-0333/#url-reconstruction defines
   182     # https://www.python.org/dev/peps/pep-0333/#url-reconstruction defines
   183     # how URLs are reconstructed.
   183     # how URLs are reconstructed.
   184     fullurl = env['wsgi.url_scheme'] + '://'
   184     fullurl = env[b'wsgi.url_scheme'] + b'://'
   185 
   185 
   186     if altbaseurl and altbaseurl.scheme:
   186     if altbaseurl and altbaseurl.scheme:
   187         advertisedfullurl = altbaseurl.scheme + '://'
   187         advertisedfullurl = altbaseurl.scheme + b'://'
   188     else:
   188     else:
   189         advertisedfullurl = fullurl
   189         advertisedfullurl = fullurl
   190 
   190 
   191     def addport(s, port):
   191     def addport(s, port):
   192         if s.startswith('https://'):
   192         if s.startswith(b'https://'):
   193             if port != '443':
   193             if port != b'443':
   194                 s += ':' + port
   194                 s += b':' + port
   195         else:
   195         else:
   196             if port != '80':
   196             if port != b'80':
   197                 s += ':' + port
   197                 s += b':' + port
   198 
   198 
   199         return s
   199         return s
   200 
   200 
   201     if env.get('HTTP_HOST'):
   201     if env.get(b'HTTP_HOST'):
   202         fullurl += env['HTTP_HOST']
   202         fullurl += env[b'HTTP_HOST']
   203     else:
   203     else:
   204         fullurl += env['SERVER_NAME']
   204         fullurl += env[b'SERVER_NAME']
   205         fullurl = addport(fullurl, env['SERVER_PORT'])
   205         fullurl = addport(fullurl, env[b'SERVER_PORT'])
   206 
   206 
   207     if altbaseurl and altbaseurl.host:
   207     if altbaseurl and altbaseurl.host:
   208         advertisedfullurl += altbaseurl.host
   208         advertisedfullurl += altbaseurl.host
   209 
   209 
   210         if altbaseurl.port:
   210         if altbaseurl.port:
   211             port = altbaseurl.port
   211             port = altbaseurl.port
   212         elif altbaseurl.scheme == 'http' and not altbaseurl.port:
   212         elif altbaseurl.scheme == b'http' and not altbaseurl.port:
   213             port = '80'
   213             port = b'80'
   214         elif altbaseurl.scheme == 'https' and not altbaseurl.port:
   214         elif altbaseurl.scheme == b'https' and not altbaseurl.port:
   215             port = '443'
   215             port = b'443'
   216         else:
   216         else:
   217             port = env['SERVER_PORT']
   217             port = env[b'SERVER_PORT']
   218 
   218 
   219         advertisedfullurl = addport(advertisedfullurl, port)
   219         advertisedfullurl = addport(advertisedfullurl, port)
   220     else:
   220     else:
   221         advertisedfullurl += env['SERVER_NAME']
   221         advertisedfullurl += env[b'SERVER_NAME']
   222         advertisedfullurl = addport(advertisedfullurl, env['SERVER_PORT'])
   222         advertisedfullurl = addport(advertisedfullurl, env[b'SERVER_PORT'])
   223 
   223 
   224     baseurl = fullurl
   224     baseurl = fullurl
   225     advertisedbaseurl = advertisedfullurl
   225     advertisedbaseurl = advertisedfullurl
   226 
   226 
   227     fullurl += util.urlreq.quote(env.get('SCRIPT_NAME', ''))
   227     fullurl += util.urlreq.quote(env.get(b'SCRIPT_NAME', b''))
   228     fullurl += util.urlreq.quote(env.get('PATH_INFO', ''))
   228     fullurl += util.urlreq.quote(env.get(b'PATH_INFO', b''))
   229 
   229 
   230     if altbaseurl:
   230     if altbaseurl:
   231         path = altbaseurl.path or ''
   231         path = altbaseurl.path or b''
   232         if path and not path.startswith('/'):
   232         if path and not path.startswith(b'/'):
   233             path = '/' + path
   233             path = b'/' + path
   234         advertisedfullurl += util.urlreq.quote(path)
   234         advertisedfullurl += util.urlreq.quote(path)
   235     else:
   235     else:
   236         advertisedfullurl += util.urlreq.quote(env.get('SCRIPT_NAME', ''))
   236         advertisedfullurl += util.urlreq.quote(env.get(b'SCRIPT_NAME', b''))
   237 
   237 
   238     advertisedfullurl += util.urlreq.quote(env.get('PATH_INFO', ''))
   238     advertisedfullurl += util.urlreq.quote(env.get(b'PATH_INFO', b''))
   239 
   239 
   240     if env.get('QUERY_STRING'):
   240     if env.get(b'QUERY_STRING'):
   241         fullurl += '?' + env['QUERY_STRING']
   241         fullurl += b'?' + env[b'QUERY_STRING']
   242         advertisedfullurl += '?' + env['QUERY_STRING']
   242         advertisedfullurl += b'?' + env[b'QUERY_STRING']
   243 
   243 
   244     # If ``reponame`` is defined, that must be a prefix on PATH_INFO
   244     # If ``reponame`` is defined, that must be a prefix on PATH_INFO
   245     # that represents the repository being dispatched to. When computing
   245     # that represents the repository being dispatched to. When computing
   246     # the dispatch info, we ignore these leading path components.
   246     # the dispatch info, we ignore these leading path components.
   247 
   247 
   248     if altbaseurl:
   248     if altbaseurl:
   249         apppath = altbaseurl.path or ''
   249         apppath = altbaseurl.path or b''
   250         if apppath and not apppath.startswith('/'):
   250         if apppath and not apppath.startswith(b'/'):
   251             apppath = '/' + apppath
   251             apppath = b'/' + apppath
   252     else:
   252     else:
   253         apppath = env.get('SCRIPT_NAME', '')
   253         apppath = env.get(b'SCRIPT_NAME', b'')
   254 
   254 
   255     if reponame:
   255     if reponame:
   256         repoprefix = '/' + reponame.strip('/')
   256         repoprefix = b'/' + reponame.strip(b'/')
   257 
   257 
   258         if not env.get('PATH_INFO'):
   258         if not env.get(b'PATH_INFO'):
   259             raise error.ProgrammingError('reponame requires PATH_INFO')
   259             raise error.ProgrammingError(b'reponame requires PATH_INFO')
   260 
   260 
   261         if not env['PATH_INFO'].startswith(repoprefix):
   261         if not env[b'PATH_INFO'].startswith(repoprefix):
   262             raise error.ProgrammingError(
   262             raise error.ProgrammingError(
   263                 'PATH_INFO does not begin with repo '
   263                 b'PATH_INFO does not begin with repo '
   264                 'name: %s (%s)' % (env['PATH_INFO'], reponame)
   264                 b'name: %s (%s)' % (env[b'PATH_INFO'], reponame)
   265             )
   265             )
   266 
   266 
   267         dispatchpath = env['PATH_INFO'][len(repoprefix) :]
   267         dispatchpath = env[b'PATH_INFO'][len(repoprefix) :]
   268 
   268 
   269         if dispatchpath and not dispatchpath.startswith('/'):
   269         if dispatchpath and not dispatchpath.startswith(b'/'):
   270             raise error.ProgrammingError(
   270             raise error.ProgrammingError(
   271                 'reponame prefix of PATH_INFO does '
   271                 b'reponame prefix of PATH_INFO does '
   272                 'not end at path delimiter: %s (%s)'
   272                 b'not end at path delimiter: %s (%s)'
   273                 % (env['PATH_INFO'], reponame)
   273                 % (env[b'PATH_INFO'], reponame)
   274             )
   274             )
   275 
   275 
   276         apppath = apppath.rstrip('/') + repoprefix
   276         apppath = apppath.rstrip(b'/') + repoprefix
   277         dispatchparts = dispatchpath.strip('/').split('/')
   277         dispatchparts = dispatchpath.strip(b'/').split(b'/')
   278         dispatchpath = '/'.join(dispatchparts)
   278         dispatchpath = b'/'.join(dispatchparts)
   279 
   279 
   280     elif 'PATH_INFO' in env:
   280     elif b'PATH_INFO' in env:
   281         if env['PATH_INFO'].strip('/'):
   281         if env[b'PATH_INFO'].strip(b'/'):
   282             dispatchparts = env['PATH_INFO'].strip('/').split('/')
   282             dispatchparts = env[b'PATH_INFO'].strip(b'/').split(b'/')
   283             dispatchpath = '/'.join(dispatchparts)
   283             dispatchpath = b'/'.join(dispatchparts)
   284         else:
   284         else:
   285             dispatchparts = []
   285             dispatchparts = []
   286             dispatchpath = ''
   286             dispatchpath = b''
   287     else:
   287     else:
   288         dispatchparts = []
   288         dispatchparts = []
   289         dispatchpath = None
   289         dispatchpath = None
   290 
   290 
   291     querystring = env.get('QUERY_STRING', '')
   291     querystring = env.get(b'QUERY_STRING', b'')
   292 
   292 
   293     # We store as a list so we have ordering information. We also store as
   293     # We store as a list so we have ordering information. We also store as
   294     # a dict to facilitate fast lookup.
   294     # a dict to facilitate fast lookup.
   295     qsparams = multidict()
   295     qsparams = multidict()
   296     for k, v in util.urlreq.parseqsl(querystring, keep_blank_values=True):
   296     for k, v in util.urlreq.parseqsl(querystring, keep_blank_values=True):
   299     # HTTP_* keys contain HTTP request headers. The Headers structure should
   299     # HTTP_* keys contain HTTP request headers. The Headers structure should
   300     # perform case normalization for us. We just rewrite underscore to dash
   300     # perform case normalization for us. We just rewrite underscore to dash
   301     # so keys match what likely went over the wire.
   301     # so keys match what likely went over the wire.
   302     headers = []
   302     headers = []
   303     for k, v in env.iteritems():
   303     for k, v in env.iteritems():
   304         if k.startswith('HTTP_'):
   304         if k.startswith(b'HTTP_'):
   305             headers.append((k[len('HTTP_') :].replace('_', '-'), v))
   305             headers.append((k[len(b'HTTP_') :].replace(b'_', b'-'), v))
   306 
   306 
   307     from . import wsgiheaders  # avoid cycle
   307     from . import wsgiheaders  # avoid cycle
   308 
   308 
   309     headers = wsgiheaders.Headers(headers)
   309     headers = wsgiheaders.Headers(headers)
   310 
   310 
   311     # This is kind of a lie because the HTTP header wasn't explicitly
   311     # This is kind of a lie because the HTTP header wasn't explicitly
   312     # sent. But for all intents and purposes it should be OK to lie about
   312     # sent. But for all intents and purposes it should be OK to lie about
   313     # this, since a consumer will either either value to determine how many
   313     # this, since a consumer will either either value to determine how many
   314     # bytes are available to read.
   314     # bytes are available to read.
   315     if 'CONTENT_LENGTH' in env and 'HTTP_CONTENT_LENGTH' not in env:
   315     if b'CONTENT_LENGTH' in env and b'HTTP_CONTENT_LENGTH' not in env:
   316         headers['Content-Length'] = env['CONTENT_LENGTH']
   316         headers[b'Content-Length'] = env[b'CONTENT_LENGTH']
   317 
   317 
   318     if 'CONTENT_TYPE' in env and 'HTTP_CONTENT_TYPE' not in env:
   318     if b'CONTENT_TYPE' in env and b'HTTP_CONTENT_TYPE' not in env:
   319         headers['Content-Type'] = env['CONTENT_TYPE']
   319         headers[b'Content-Type'] = env[b'CONTENT_TYPE']
   320 
   320 
   321     if bodyfh is None:
   321     if bodyfh is None:
   322         bodyfh = env['wsgi.input']
   322         bodyfh = env[b'wsgi.input']
   323         if 'Content-Length' in headers:
   323         if b'Content-Length' in headers:
   324             bodyfh = util.cappedreader(
   324             bodyfh = util.cappedreader(
   325                 bodyfh, int(headers['Content-Length'] or '0')
   325                 bodyfh, int(headers[b'Content-Length'] or b'0')
   326             )
   326             )
   327 
   327 
   328     return parsedrequest(
   328     return parsedrequest(
   329         method=env['REQUEST_METHOD'],
   329         method=env[b'REQUEST_METHOD'],
   330         url=fullurl,
   330         url=fullurl,
   331         baseurl=baseurl,
   331         baseurl=baseurl,
   332         advertisedurl=advertisedfullurl,
   332         advertisedurl=advertisedfullurl,
   333         advertisedbaseurl=advertisedbaseurl,
   333         advertisedbaseurl=advertisedbaseurl,
   334         urlscheme=env['wsgi.url_scheme'],
   334         urlscheme=env[b'wsgi.url_scheme'],
   335         remoteuser=env.get('REMOTE_USER'),
   335         remoteuser=env.get(b'REMOTE_USER'),
   336         remotehost=env.get('REMOTE_HOST'),
   336         remotehost=env.get(b'REMOTE_HOST'),
   337         apppath=apppath,
   337         apppath=apppath,
   338         dispatchparts=dispatchparts,
   338         dispatchparts=dispatchparts,
   339         dispatchpath=dispatchpath,
   339         dispatchpath=dispatchpath,
   340         reponame=reponame,
   340         reponame=reponame,
   341         querystring=querystring,
   341         querystring=querystring,
   419         if (
   419         if (
   420             self._bodybytes is not None
   420             self._bodybytes is not None
   421             or self._bodygen is not None
   421             or self._bodygen is not None
   422             or self._bodywillwrite
   422             or self._bodywillwrite
   423         ):
   423         ):
   424             raise error.ProgrammingError('cannot define body multiple times')
   424             raise error.ProgrammingError(b'cannot define body multiple times')
   425 
   425 
   426     def setbodybytes(self, b):
   426     def setbodybytes(self, b):
   427         """Define the response body as static bytes.
   427         """Define the response body as static bytes.
   428 
   428 
   429         The empty string signals that there is no response body.
   429         The empty string signals that there is no response body.
   430         """
   430         """
   431         self._verifybody()
   431         self._verifybody()
   432         self._bodybytes = b
   432         self._bodybytes = b
   433         self.headers['Content-Length'] = '%d' % len(b)
   433         self.headers[b'Content-Length'] = b'%d' % len(b)
   434 
   434 
   435     def setbodygen(self, gen):
   435     def setbodygen(self, gen):
   436         """Define the response body as a generator of bytes."""
   436         """Define the response body as a generator of bytes."""
   437         self._verifybody()
   437         self._verifybody()
   438         self._bodygen = gen
   438         self._bodygen = gen
   461         ``setbodybytes()`` or ``setbodygen()`` must be called.
   461         ``setbodybytes()`` or ``setbodygen()`` must be called.
   462 
   462 
   463         Calling this method multiple times is not allowed.
   463         Calling this method multiple times is not allowed.
   464         """
   464         """
   465         if self._started:
   465         if self._started:
   466             raise error.ProgrammingError('sendresponse() called multiple times')
   466             raise error.ProgrammingError(
       
   467                 b'sendresponse() called multiple times'
       
   468             )
   467 
   469 
   468         self._started = True
   470         self._started = True
   469 
   471 
   470         if not self.status:
   472         if not self.status:
   471             raise error.ProgrammingError('status line not defined')
   473             raise error.ProgrammingError(b'status line not defined')
   472 
   474 
   473         if (
   475         if (
   474             self._bodybytes is None
   476             self._bodybytes is None
   475             and self._bodygen is None
   477             and self._bodygen is None
   476             and not self._bodywillwrite
   478             and not self._bodywillwrite
   477         ):
   479         ):
   478             raise error.ProgrammingError('response body not defined')
   480             raise error.ProgrammingError(b'response body not defined')
   479 
   481 
   480         # RFC 7232 Section 4.1 states that a 304 MUST generate one of
   482         # RFC 7232 Section 4.1 states that a 304 MUST generate one of
   481         # {Cache-Control, Content-Location, Date, ETag, Expires, Vary}
   483         # {Cache-Control, Content-Location, Date, ETag, Expires, Vary}
   482         # and SHOULD NOT generate other headers unless they could be used
   484         # and SHOULD NOT generate other headers unless they could be used
   483         # to guide cache updates. Furthermore, RFC 7230 Section 3.3.2
   485         # to guide cache updates. Furthermore, RFC 7230 Section 3.3.2
   484         # states that no response body can be issued. Content-Length can
   486         # states that no response body can be issued. Content-Length can
   485         # be sent. But if it is present, it should be the size of the response
   487         # be sent. But if it is present, it should be the size of the response
   486         # that wasn't transferred.
   488         # that wasn't transferred.
   487         if self.status.startswith('304 '):
   489         if self.status.startswith(b'304 '):
   488             # setbodybytes('') will set C-L to 0. This doesn't conform with the
   490             # setbodybytes('') will set C-L to 0. This doesn't conform with the
   489             # spec. So remove it.
   491             # spec. So remove it.
   490             if self.headers.get('Content-Length') == '0':
   492             if self.headers.get(b'Content-Length') == b'0':
   491                 del self.headers['Content-Length']
   493                 del self.headers[b'Content-Length']
   492 
   494 
   493             # Strictly speaking, this is too strict. But until it causes
   495             # Strictly speaking, this is too strict. But until it causes
   494             # problems, let's be strict.
   496             # problems, let's be strict.
   495             badheaders = {
   497             badheaders = {
   496                 k
   498                 k
   497                 for k in self.headers.keys()
   499                 for k in self.headers.keys()
   498                 if k.lower()
   500                 if k.lower()
   499                 not in (
   501                 not in (
   500                     'date',
   502                     b'date',
   501                     'etag',
   503                     b'etag',
   502                     'expires',
   504                     b'expires',
   503                     'cache-control',
   505                     b'cache-control',
   504                     'content-location',
   506                     b'content-location',
   505                     'content-security-policy',
   507                     b'content-security-policy',
   506                     'vary',
   508                     b'vary',
   507                 )
   509                 )
   508             }
   510             }
   509             if badheaders:
   511             if badheaders:
   510                 raise error.ProgrammingError(
   512                 raise error.ProgrammingError(
   511                     'illegal header on 304 response: %s'
   513                     b'illegal header on 304 response: %s'
   512                     % ', '.join(sorted(badheaders))
   514                     % b', '.join(sorted(badheaders))
   513                 )
   515                 )
   514 
   516 
   515             if self._bodygen is not None or self._bodywillwrite:
   517             if self._bodygen is not None or self._bodywillwrite:
   516                 raise error.ProgrammingError(
   518                 raise error.ProgrammingError(
   517                     "must use setbodybytes('') with " "304 responses"
   519                     b"must use setbodybytes('') with " b"304 responses"
   518                 )
   520                 )
   519 
   521 
   520         # Various HTTP clients (notably httplib) won't read the HTTP response
   522         # Various HTTP clients (notably httplib) won't read the HTTP response
   521         # until the HTTP request has been sent in full. If servers (us) send a
   523         # until the HTTP request has been sent in full. If servers (us) send a
   522         # response before the HTTP request has been fully sent, the connection
   524         # response before the HTTP request has been fully sent, the connection
   528         close = False
   530         close = False
   529 
   531 
   530         # If the client sent Expect: 100-continue, we assume it is smart enough
   532         # If the client sent Expect: 100-continue, we assume it is smart enough
   531         # to deal with the server sending a response before reading the request.
   533         # to deal with the server sending a response before reading the request.
   532         # (httplib doesn't do this.)
   534         # (httplib doesn't do this.)
   533         if self._req.headers.get('Expect', '').lower() == '100-continue':
   535         if self._req.headers.get(b'Expect', b'').lower() == b'100-continue':
   534             pass
   536             pass
   535         # Only tend to request methods that have bodies. Strictly speaking,
   537         # Only tend to request methods that have bodies. Strictly speaking,
   536         # we should sniff for a body. But this is fine for our existing
   538         # we should sniff for a body. But this is fine for our existing
   537         # WSGI applications.
   539         # WSGI applications.
   538         elif self._req.method not in ('POST', 'PUT'):
   540         elif self._req.method not in (b'POST', b'PUT'):
   539             pass
   541             pass
   540         else:
   542         else:
   541             # If we don't know how much data to read, there's no guarantee
   543             # If we don't know how much data to read, there's no guarantee
   542             # that we can drain the request responsibly. The WSGI
   544             # that we can drain the request responsibly. The WSGI
   543             # specification only says that servers *should* ensure the
   545             # specification only says that servers *should* ensure the
   553                 # drain, it is safe to read from that stream. We'll either do
   555                 # drain, it is safe to read from that stream. We'll either do
   554                 # a drain or no-op if we're already at EOF.
   556                 # a drain or no-op if we're already at EOF.
   555                 drain = True
   557                 drain = True
   556 
   558 
   557         if close:
   559         if close:
   558             self.headers['Connection'] = 'Close'
   560             self.headers[b'Connection'] = b'Close'
   559 
   561 
   560         if drain:
   562         if drain:
   561             assert isinstance(self._req.bodyfh, util.cappedreader)
   563             assert isinstance(self._req.bodyfh, util.cappedreader)
   562             while True:
   564             while True:
   563                 chunk = self._req.bodyfh.read(32768)
   565                 chunk = self._req.bodyfh.read(32768)
   582 
   584 
   583                 yield chunk
   585                 yield chunk
   584         elif self._bodywillwrite:
   586         elif self._bodywillwrite:
   585             self._bodywritefn = write
   587             self._bodywritefn = write
   586         else:
   588         else:
   587             error.ProgrammingError('do not know how to send body')
   589             error.ProgrammingError(b'do not know how to send body')
   588 
   590 
   589     def getbodyfile(self):
   591     def getbodyfile(self):
   590         """Obtain a file object like object representing the response body.
   592         """Obtain a file object like object representing the response body.
   591 
   593 
   592         For this to work, you must call ``setbodywillwrite()`` and then
   594         For this to work, you must call ``setbodywillwrite()`` and then
   595         generator yields not items. The easiest way to consume it is with
   597         generator yields not items. The easiest way to consume it is with
   596         ``list(res.sendresponse())``, which should resolve to an empty list -
   598         ``list(res.sendresponse())``, which should resolve to an empty list -
   597         ``[]``.
   599         ``[]``.
   598         """
   600         """
   599         if not self._bodywillwrite:
   601         if not self._bodywillwrite:
   600             raise error.ProgrammingError('must call setbodywillwrite() first')
   602             raise error.ProgrammingError(b'must call setbodywillwrite() first')
   601 
   603 
   602         if not self._started:
   604         if not self._started:
   603             raise error.ProgrammingError(
   605             raise error.ProgrammingError(
   604                 'must call sendresponse() first; did '
   606                 b'must call sendresponse() first; did '
   605                 'you remember to consume it since it '
   607                 b'you remember to consume it since it '
   606                 'is a generator?'
   608                 b'is a generator?'
   607             )
   609             )
   608 
   610 
   609         assert self._bodywritefn
   611         assert self._bodywritefn
   610         return offsettrackingwriter(self._bodywritefn)
   612         return offsettrackingwriter(self._bodywritefn)
   611 
   613