##// END OF EJS Templates
wireprototypes: clarify documentation of getbundle argument types...
Martin von Zweigbergk -
r43225:9668744c default
parent child Browse files
Show More
@@ -1,407 +1,407 b''
1 # Copyright 2018 Gregory Szorc <gregory.szorc@gmail.com>
1 # Copyright 2018 Gregory Szorc <gregory.szorc@gmail.com>
2 #
2 #
3 # This software may be used and distributed according to the terms of the
3 # This software may be used and distributed according to the terms of the
4 # GNU General Public License version 2 or any later version.
4 # GNU General Public License version 2 or any later version.
5
5
6 from __future__ import absolute_import
6 from __future__ import absolute_import
7
7
8 from .node import (
8 from .node import (
9 bin,
9 bin,
10 hex,
10 hex,
11 )
11 )
12 from .i18n import _
12 from .i18n import _
13 from .thirdparty import (
13 from .thirdparty import (
14 attr,
14 attr,
15 )
15 )
16 from . import (
16 from . import (
17 error,
17 error,
18 util,
18 util,
19 )
19 )
20 from .interfaces import (
20 from .interfaces import (
21 util as interfaceutil,
21 util as interfaceutil,
22 )
22 )
23 from .utils import (
23 from .utils import (
24 compression,
24 compression,
25 )
25 )
26
26
27 # Names of the SSH protocol implementations.
27 # Names of the SSH protocol implementations.
28 SSHV1 = 'ssh-v1'
28 SSHV1 = 'ssh-v1'
29 # These are advertised over the wire. Increment the counters at the end
29 # These are advertised over the wire. Increment the counters at the end
30 # to reflect BC breakages.
30 # to reflect BC breakages.
31 SSHV2 = 'exp-ssh-v2-0003'
31 SSHV2 = 'exp-ssh-v2-0003'
32 HTTP_WIREPROTO_V2 = 'exp-http-v2-0003'
32 HTTP_WIREPROTO_V2 = 'exp-http-v2-0003'
33
33
34 NARROWCAP = 'exp-narrow-1'
34 NARROWCAP = 'exp-narrow-1'
35 ELLIPSESCAP1 = 'exp-ellipses-1'
35 ELLIPSESCAP1 = 'exp-ellipses-1'
36 ELLIPSESCAP = 'exp-ellipses-2'
36 ELLIPSESCAP = 'exp-ellipses-2'
37 SUPPORTED_ELLIPSESCAP = (ELLIPSESCAP1, ELLIPSESCAP)
37 SUPPORTED_ELLIPSESCAP = (ELLIPSESCAP1, ELLIPSESCAP)
38
38
39 # All available wire protocol transports.
39 # All available wire protocol transports.
40 TRANSPORTS = {
40 TRANSPORTS = {
41 SSHV1: {
41 SSHV1: {
42 'transport': 'ssh',
42 'transport': 'ssh',
43 'version': 1,
43 'version': 1,
44 },
44 },
45 SSHV2: {
45 SSHV2: {
46 'transport': 'ssh',
46 'transport': 'ssh',
47 # TODO mark as version 2 once all commands are implemented.
47 # TODO mark as version 2 once all commands are implemented.
48 'version': 1,
48 'version': 1,
49 },
49 },
50 'http-v1': {
50 'http-v1': {
51 'transport': 'http',
51 'transport': 'http',
52 'version': 1,
52 'version': 1,
53 },
53 },
54 HTTP_WIREPROTO_V2: {
54 HTTP_WIREPROTO_V2: {
55 'transport': 'http',
55 'transport': 'http',
56 'version': 2,
56 'version': 2,
57 }
57 }
58 }
58 }
59
59
60 class bytesresponse(object):
60 class bytesresponse(object):
61 """A wire protocol response consisting of raw bytes."""
61 """A wire protocol response consisting of raw bytes."""
62 def __init__(self, data):
62 def __init__(self, data):
63 self.data = data
63 self.data = data
64
64
65 class ooberror(object):
65 class ooberror(object):
66 """wireproto reply: failure of a batch of operation
66 """wireproto reply: failure of a batch of operation
67
67
68 Something failed during a batch call. The error message is stored in
68 Something failed during a batch call. The error message is stored in
69 `self.message`.
69 `self.message`.
70 """
70 """
71 def __init__(self, message):
71 def __init__(self, message):
72 self.message = message
72 self.message = message
73
73
74 class pushres(object):
74 class pushres(object):
75 """wireproto reply: success with simple integer return
75 """wireproto reply: success with simple integer return
76
76
77 The call was successful and returned an integer contained in `self.res`.
77 The call was successful and returned an integer contained in `self.res`.
78 """
78 """
79 def __init__(self, res, output):
79 def __init__(self, res, output):
80 self.res = res
80 self.res = res
81 self.output = output
81 self.output = output
82
82
83 class pusherr(object):
83 class pusherr(object):
84 """wireproto reply: failure
84 """wireproto reply: failure
85
85
86 The call failed. The `self.res` attribute contains the error message.
86 The call failed. The `self.res` attribute contains the error message.
87 """
87 """
88 def __init__(self, res, output):
88 def __init__(self, res, output):
89 self.res = res
89 self.res = res
90 self.output = output
90 self.output = output
91
91
92 class streamres(object):
92 class streamres(object):
93 """wireproto reply: binary stream
93 """wireproto reply: binary stream
94
94
95 The call was successful and the result is a stream.
95 The call was successful and the result is a stream.
96
96
97 Accepts a generator containing chunks of data to be sent to the client.
97 Accepts a generator containing chunks of data to be sent to the client.
98
98
99 ``prefer_uncompressed`` indicates that the data is expected to be
99 ``prefer_uncompressed`` indicates that the data is expected to be
100 uncompressable and that the stream should therefore use the ``none``
100 uncompressable and that the stream should therefore use the ``none``
101 engine.
101 engine.
102 """
102 """
103 def __init__(self, gen=None, prefer_uncompressed=False):
103 def __init__(self, gen=None, prefer_uncompressed=False):
104 self.gen = gen
104 self.gen = gen
105 self.prefer_uncompressed = prefer_uncompressed
105 self.prefer_uncompressed = prefer_uncompressed
106
106
107 class streamreslegacy(object):
107 class streamreslegacy(object):
108 """wireproto reply: uncompressed binary stream
108 """wireproto reply: uncompressed binary stream
109
109
110 The call was successful and the result is a stream.
110 The call was successful and the result is a stream.
111
111
112 Accepts a generator containing chunks of data to be sent to the client.
112 Accepts a generator containing chunks of data to be sent to the client.
113
113
114 Like ``streamres``, but sends an uncompressed data for "version 1" clients
114 Like ``streamres``, but sends an uncompressed data for "version 1" clients
115 using the application/mercurial-0.1 media type.
115 using the application/mercurial-0.1 media type.
116 """
116 """
117 def __init__(self, gen=None):
117 def __init__(self, gen=None):
118 self.gen = gen
118 self.gen = gen
119
119
120 # list of nodes encoding / decoding
120 # list of nodes encoding / decoding
121 def decodelist(l, sep=' '):
121 def decodelist(l, sep=' '):
122 if l:
122 if l:
123 return [bin(v) for v in l.split(sep)]
123 return [bin(v) for v in l.split(sep)]
124 return []
124 return []
125
125
126 def encodelist(l, sep=' '):
126 def encodelist(l, sep=' '):
127 try:
127 try:
128 return sep.join(map(hex, l))
128 return sep.join(map(hex, l))
129 except TypeError:
129 except TypeError:
130 raise
130 raise
131
131
132 # batched call argument encoding
132 # batched call argument encoding
133
133
134 def escapebatcharg(plain):
134 def escapebatcharg(plain):
135 return (plain
135 return (plain
136 .replace(':', ':c')
136 .replace(':', ':c')
137 .replace(',', ':o')
137 .replace(',', ':o')
138 .replace(';', ':s')
138 .replace(';', ':s')
139 .replace('=', ':e'))
139 .replace('=', ':e'))
140
140
141 def unescapebatcharg(escaped):
141 def unescapebatcharg(escaped):
142 return (escaped
142 return (escaped
143 .replace(':e', '=')
143 .replace(':e', '=')
144 .replace(':s', ';')
144 .replace(':s', ';')
145 .replace(':o', ',')
145 .replace(':o', ',')
146 .replace(':c', ':'))
146 .replace(':c', ':'))
147
147
148 # mapping of options accepted by getbundle and their types
148 # mapping of options accepted by getbundle and their types
149 #
149 #
150 # Meant to be extended by extensions. It is extensions responsibility to ensure
150 # Meant to be extended by extensions. It is the extension's responsibility to
151 # such options are properly processed in exchange.getbundle.
151 # ensure such options are properly processed in exchange.getbundle.
152 #
152 #
153 # supported types are:
153 # supported types are:
154 #
154 #
155 # :nodes: list of binary nodes
155 # :nodes: list of binary nodes, transmitted as space-separated hex nodes
156 # :csv: list of comma-separated values
156 # :csv: list of values, transmitted as comma-separated values
157 # :scsv: list of comma-separated values return as set
157 # :scsv: set of values, transmitted as comma-separated values
158 # :plain: string with no transformation needed.
158 # :plain: string with no transformation needed.
159 GETBUNDLE_ARGUMENTS = {
159 GETBUNDLE_ARGUMENTS = {
160 'heads': 'nodes',
160 'heads': 'nodes',
161 'bookmarks': 'boolean',
161 'bookmarks': 'boolean',
162 'common': 'nodes',
162 'common': 'nodes',
163 'obsmarkers': 'boolean',
163 'obsmarkers': 'boolean',
164 'phases': 'boolean',
164 'phases': 'boolean',
165 'bundlecaps': 'scsv',
165 'bundlecaps': 'scsv',
166 'listkeys': 'csv',
166 'listkeys': 'csv',
167 'cg': 'boolean',
167 'cg': 'boolean',
168 'cbattempted': 'boolean',
168 'cbattempted': 'boolean',
169 'stream': 'boolean',
169 'stream': 'boolean',
170 'includepats': 'csv',
170 'includepats': 'csv',
171 'excludepats': 'csv',
171 'excludepats': 'csv',
172 }
172 }
173
173
174 class baseprotocolhandler(interfaceutil.Interface):
174 class baseprotocolhandler(interfaceutil.Interface):
175 """Abstract base class for wire protocol handlers.
175 """Abstract base class for wire protocol handlers.
176
176
177 A wire protocol handler serves as an interface between protocol command
177 A wire protocol handler serves as an interface between protocol command
178 handlers and the wire protocol transport layer. Protocol handlers provide
178 handlers and the wire protocol transport layer. Protocol handlers provide
179 methods to read command arguments, redirect stdio for the duration of
179 methods to read command arguments, redirect stdio for the duration of
180 the request, handle response types, etc.
180 the request, handle response types, etc.
181 """
181 """
182
182
183 name = interfaceutil.Attribute(
183 name = interfaceutil.Attribute(
184 """The name of the protocol implementation.
184 """The name of the protocol implementation.
185
185
186 Used for uniquely identifying the transport type.
186 Used for uniquely identifying the transport type.
187 """)
187 """)
188
188
189 def getargs(args):
189 def getargs(args):
190 """return the value for arguments in <args>
190 """return the value for arguments in <args>
191
191
192 For version 1 transports, returns a list of values in the same
192 For version 1 transports, returns a list of values in the same
193 order they appear in ``args``. For version 2 transports, returns
193 order they appear in ``args``. For version 2 transports, returns
194 a dict mapping argument name to value.
194 a dict mapping argument name to value.
195 """
195 """
196
196
197 def getprotocaps():
197 def getprotocaps():
198 """Returns the list of protocol-level capabilities of client
198 """Returns the list of protocol-level capabilities of client
199
199
200 Returns a list of capabilities as declared by the client for
200 Returns a list of capabilities as declared by the client for
201 the current request (or connection for stateful protocol handlers)."""
201 the current request (or connection for stateful protocol handlers)."""
202
202
203 def getpayload():
203 def getpayload():
204 """Provide a generator for the raw payload.
204 """Provide a generator for the raw payload.
205
205
206 The caller is responsible for ensuring that the full payload is
206 The caller is responsible for ensuring that the full payload is
207 processed.
207 processed.
208 """
208 """
209
209
210 def mayberedirectstdio():
210 def mayberedirectstdio():
211 """Context manager to possibly redirect stdio.
211 """Context manager to possibly redirect stdio.
212
212
213 The context manager yields a file-object like object that receives
213 The context manager yields a file-object like object that receives
214 stdout and stderr output when the context manager is active. Or it
214 stdout and stderr output when the context manager is active. Or it
215 yields ``None`` if no I/O redirection occurs.
215 yields ``None`` if no I/O redirection occurs.
216
216
217 The intent of this context manager is to capture stdio output
217 The intent of this context manager is to capture stdio output
218 so it may be sent in the response. Some transports support streaming
218 so it may be sent in the response. Some transports support streaming
219 stdio to the client in real time. For these transports, stdio output
219 stdio to the client in real time. For these transports, stdio output
220 won't be captured.
220 won't be captured.
221 """
221 """
222
222
223 def client():
223 def client():
224 """Returns a string representation of this client (as bytes)."""
224 """Returns a string representation of this client (as bytes)."""
225
225
226 def addcapabilities(repo, caps):
226 def addcapabilities(repo, caps):
227 """Adds advertised capabilities specific to this protocol.
227 """Adds advertised capabilities specific to this protocol.
228
228
229 Receives the list of capabilities collected so far.
229 Receives the list of capabilities collected so far.
230
230
231 Returns a list of capabilities. The passed in argument can be returned.
231 Returns a list of capabilities. The passed in argument can be returned.
232 """
232 """
233
233
234 def checkperm(perm):
234 def checkperm(perm):
235 """Validate that the client has permissions to perform a request.
235 """Validate that the client has permissions to perform a request.
236
236
237 The argument is the permission required to proceed. If the client
237 The argument is the permission required to proceed. If the client
238 doesn't have that permission, the exception should raise or abort
238 doesn't have that permission, the exception should raise or abort
239 in a protocol specific manner.
239 in a protocol specific manner.
240 """
240 """
241
241
242 class commandentry(object):
242 class commandentry(object):
243 """Represents a declared wire protocol command."""
243 """Represents a declared wire protocol command."""
244 def __init__(self, func, args='', transports=None,
244 def __init__(self, func, args='', transports=None,
245 permission='push', cachekeyfn=None, extracapabilitiesfn=None):
245 permission='push', cachekeyfn=None, extracapabilitiesfn=None):
246 self.func = func
246 self.func = func
247 self.args = args
247 self.args = args
248 self.transports = transports or set()
248 self.transports = transports or set()
249 self.permission = permission
249 self.permission = permission
250 self.cachekeyfn = cachekeyfn
250 self.cachekeyfn = cachekeyfn
251 self.extracapabilitiesfn = extracapabilitiesfn
251 self.extracapabilitiesfn = extracapabilitiesfn
252
252
253 def _merge(self, func, args):
253 def _merge(self, func, args):
254 """Merge this instance with an incoming 2-tuple.
254 """Merge this instance with an incoming 2-tuple.
255
255
256 This is called when a caller using the old 2-tuple API attempts
256 This is called when a caller using the old 2-tuple API attempts
257 to replace an instance. The incoming values are merged with
257 to replace an instance. The incoming values are merged with
258 data not captured by the 2-tuple and a new instance containing
258 data not captured by the 2-tuple and a new instance containing
259 the union of the two objects is returned.
259 the union of the two objects is returned.
260 """
260 """
261 return commandentry(func, args=args, transports=set(self.transports),
261 return commandentry(func, args=args, transports=set(self.transports),
262 permission=self.permission)
262 permission=self.permission)
263
263
264 # Old code treats instances as 2-tuples. So expose that interface.
264 # Old code treats instances as 2-tuples. So expose that interface.
265 def __iter__(self):
265 def __iter__(self):
266 yield self.func
266 yield self.func
267 yield self.args
267 yield self.args
268
268
269 def __getitem__(self, i):
269 def __getitem__(self, i):
270 if i == 0:
270 if i == 0:
271 return self.func
271 return self.func
272 elif i == 1:
272 elif i == 1:
273 return self.args
273 return self.args
274 else:
274 else:
275 raise IndexError('can only access elements 0 and 1')
275 raise IndexError('can only access elements 0 and 1')
276
276
277 class commanddict(dict):
277 class commanddict(dict):
278 """Container for registered wire protocol commands.
278 """Container for registered wire protocol commands.
279
279
280 It behaves like a dict. But __setitem__ is overwritten to allow silent
280 It behaves like a dict. But __setitem__ is overwritten to allow silent
281 coercion of values from 2-tuples for API compatibility.
281 coercion of values from 2-tuples for API compatibility.
282 """
282 """
283 def __setitem__(self, k, v):
283 def __setitem__(self, k, v):
284 if isinstance(v, commandentry):
284 if isinstance(v, commandentry):
285 pass
285 pass
286 # Cast 2-tuples to commandentry instances.
286 # Cast 2-tuples to commandentry instances.
287 elif isinstance(v, tuple):
287 elif isinstance(v, tuple):
288 if len(v) != 2:
288 if len(v) != 2:
289 raise ValueError('command tuples must have exactly 2 elements')
289 raise ValueError('command tuples must have exactly 2 elements')
290
290
291 # It is common for extensions to wrap wire protocol commands via
291 # It is common for extensions to wrap wire protocol commands via
292 # e.g. ``wireproto.commands[x] = (newfn, args)``. Because callers
292 # e.g. ``wireproto.commands[x] = (newfn, args)``. Because callers
293 # doing this aren't aware of the new API that uses objects to store
293 # doing this aren't aware of the new API that uses objects to store
294 # command entries, we automatically merge old state with new.
294 # command entries, we automatically merge old state with new.
295 if k in self:
295 if k in self:
296 v = self[k]._merge(v[0], v[1])
296 v = self[k]._merge(v[0], v[1])
297 else:
297 else:
298 # Use default values from @wireprotocommand.
298 # Use default values from @wireprotocommand.
299 v = commandentry(v[0], args=v[1],
299 v = commandentry(v[0], args=v[1],
300 transports=set(TRANSPORTS),
300 transports=set(TRANSPORTS),
301 permission='push')
301 permission='push')
302 else:
302 else:
303 raise ValueError('command entries must be commandentry instances '
303 raise ValueError('command entries must be commandentry instances '
304 'or 2-tuples')
304 'or 2-tuples')
305
305
306 return super(commanddict, self).__setitem__(k, v)
306 return super(commanddict, self).__setitem__(k, v)
307
307
308 def commandavailable(self, command, proto):
308 def commandavailable(self, command, proto):
309 """Determine if a command is available for the requested protocol."""
309 """Determine if a command is available for the requested protocol."""
310 assert proto.name in TRANSPORTS
310 assert proto.name in TRANSPORTS
311
311
312 entry = self.get(command)
312 entry = self.get(command)
313
313
314 if not entry:
314 if not entry:
315 return False
315 return False
316
316
317 if proto.name not in entry.transports:
317 if proto.name not in entry.transports:
318 return False
318 return False
319
319
320 return True
320 return True
321
321
322 def supportedcompengines(ui, role):
322 def supportedcompengines(ui, role):
323 """Obtain the list of supported compression engines for a request."""
323 """Obtain the list of supported compression engines for a request."""
324 assert role in (compression.CLIENTROLE, compression.SERVERROLE)
324 assert role in (compression.CLIENTROLE, compression.SERVERROLE)
325
325
326 compengines = compression.compengines.supportedwireengines(role)
326 compengines = compression.compengines.supportedwireengines(role)
327
327
328 # Allow config to override default list and ordering.
328 # Allow config to override default list and ordering.
329 if role == compression.SERVERROLE:
329 if role == compression.SERVERROLE:
330 configengines = ui.configlist('server', 'compressionengines')
330 configengines = ui.configlist('server', 'compressionengines')
331 config = 'server.compressionengines'
331 config = 'server.compressionengines'
332 else:
332 else:
333 # This is currently implemented mainly to facilitate testing. In most
333 # This is currently implemented mainly to facilitate testing. In most
334 # cases, the server should be in charge of choosing a compression engine
334 # cases, the server should be in charge of choosing a compression engine
335 # because a server has the most to lose from a sub-optimal choice. (e.g.
335 # because a server has the most to lose from a sub-optimal choice. (e.g.
336 # CPU DoS due to an expensive engine or a network DoS due to poor
336 # CPU DoS due to an expensive engine or a network DoS due to poor
337 # compression ratio).
337 # compression ratio).
338 configengines = ui.configlist('experimental',
338 configengines = ui.configlist('experimental',
339 'clientcompressionengines')
339 'clientcompressionengines')
340 config = 'experimental.clientcompressionengines'
340 config = 'experimental.clientcompressionengines'
341
341
342 # No explicit config. Filter out the ones that aren't supposed to be
342 # No explicit config. Filter out the ones that aren't supposed to be
343 # advertised and return default ordering.
343 # advertised and return default ordering.
344 if not configengines:
344 if not configengines:
345 attr = 'serverpriority' if role == util.SERVERROLE else 'clientpriority'
345 attr = 'serverpriority' if role == util.SERVERROLE else 'clientpriority'
346 return [e for e in compengines
346 return [e for e in compengines
347 if getattr(e.wireprotosupport(), attr) > 0]
347 if getattr(e.wireprotosupport(), attr) > 0]
348
348
349 # If compression engines are listed in the config, assume there is a good
349 # If compression engines are listed in the config, assume there is a good
350 # reason for it (like server operators wanting to achieve specific
350 # reason for it (like server operators wanting to achieve specific
351 # performance characteristics). So fail fast if the config references
351 # performance characteristics). So fail fast if the config references
352 # unusable compression engines.
352 # unusable compression engines.
353 validnames = set(e.name() for e in compengines)
353 validnames = set(e.name() for e in compengines)
354 invalidnames = set(e for e in configengines if e not in validnames)
354 invalidnames = set(e for e in configengines if e not in validnames)
355 if invalidnames:
355 if invalidnames:
356 raise error.Abort(_('invalid compression engine defined in %s: %s') %
356 raise error.Abort(_('invalid compression engine defined in %s: %s') %
357 (config, ', '.join(sorted(invalidnames))))
357 (config, ', '.join(sorted(invalidnames))))
358
358
359 compengines = [e for e in compengines if e.name() in configengines]
359 compengines = [e for e in compengines if e.name() in configengines]
360 compengines = sorted(compengines,
360 compengines = sorted(compengines,
361 key=lambda e: configengines.index(e.name()))
361 key=lambda e: configengines.index(e.name()))
362
362
363 if not compengines:
363 if not compengines:
364 raise error.Abort(_('%s config option does not specify any known '
364 raise error.Abort(_('%s config option does not specify any known '
365 'compression engines') % config,
365 'compression engines') % config,
366 hint=_('usable compression engines: %s') %
366 hint=_('usable compression engines: %s') %
367 ', '.sorted(validnames))
367 ', '.sorted(validnames))
368
368
369 return compengines
369 return compengines
370
370
371 @attr.s
371 @attr.s
372 class encodedresponse(object):
372 class encodedresponse(object):
373 """Represents response data that is already content encoded.
373 """Represents response data that is already content encoded.
374
374
375 Wire protocol version 2 only.
375 Wire protocol version 2 only.
376
376
377 Commands typically emit Python objects that are encoded and sent over the
377 Commands typically emit Python objects that are encoded and sent over the
378 wire. If commands emit an object of this type, the encoding step is bypassed
378 wire. If commands emit an object of this type, the encoding step is bypassed
379 and the content from this object is used instead.
379 and the content from this object is used instead.
380 """
380 """
381 data = attr.ib()
381 data = attr.ib()
382
382
383 @attr.s
383 @attr.s
384 class alternatelocationresponse(object):
384 class alternatelocationresponse(object):
385 """Represents a response available at an alternate location.
385 """Represents a response available at an alternate location.
386
386
387 Instances are sent in place of actual response objects when the server
387 Instances are sent in place of actual response objects when the server
388 is sending a "content redirect" response.
388 is sending a "content redirect" response.
389
389
390 Only compatible with wire protocol version 2.
390 Only compatible with wire protocol version 2.
391 """
391 """
392 url = attr.ib()
392 url = attr.ib()
393 mediatype = attr.ib()
393 mediatype = attr.ib()
394 size = attr.ib(default=None)
394 size = attr.ib(default=None)
395 fullhashes = attr.ib(default=None)
395 fullhashes = attr.ib(default=None)
396 fullhashseed = attr.ib(default=None)
396 fullhashseed = attr.ib(default=None)
397 serverdercerts = attr.ib(default=None)
397 serverdercerts = attr.ib(default=None)
398 servercadercerts = attr.ib(default=None)
398 servercadercerts = attr.ib(default=None)
399
399
400 @attr.s
400 @attr.s
401 class indefinitebytestringresponse(object):
401 class indefinitebytestringresponse(object):
402 """Represents an object to be encoded to an indefinite length bytestring.
402 """Represents an object to be encoded to an indefinite length bytestring.
403
403
404 Instances are initialized from an iterable of chunks, with each chunk being
404 Instances are initialized from an iterable of chunks, with each chunk being
405 a bytes instance.
405 a bytes instance.
406 """
406 """
407 chunks = attr.ib()
407 chunks = attr.ib()
General Comments 0
You need to be logged in to leave comments. Login now