Coverage for aiocoap / message.py: 82%

340 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-17 12:28 +0000

1# SPDX-FileCopyrightText: Christian Amsüss and the aiocoap contributors 

2# 

3# SPDX-License-Identifier: MIT 

4 

5from __future__ import annotations 

6 

7import enum 

8import ipaddress 

9import urllib.parse 

10import struct 

11import copy 

12import string 

13from collections import namedtuple 

14from warnings import warn 

15 

16from . import error, optiontypes 

17from .numbers.codes import Code, CHANGED 

18from .numbers.types import Type 

19from .numbers.constants import TransportTuning, MAX_REGULAR_BLOCK_SIZE_EXP 

20from .numbers import uri_path_abbrev 

21from .options import Options 

22from .util import ( 

23 hostportjoin, 

24 hostportsplit, 

25 Sentinel, 

26 quote_nonascii, 

27 DeprecationWarning, 

28) 

29from .util.uri import quote_factory, unreserved, sub_delims 

30from . import interfaces 

31 

32__all__ = ["Message", "NoResponse"] 

33 

34# FIXME there should be a proper interface for this that does all the urllib 

35# patching possibly required and works with pluggable transports. urls qualify 

36# if they can be parsed into the Proxy-Scheme / Uri-* structure. 

37coap_schemes = ["coap", "coaps", "coap+tcp", "coaps+tcp", "coap+ws", "coaps+ws"] 

38 

39# Monkey patch urllib to make URL joining available in CoAP 

40# This is a workaround for <http://bugs.python.org/issue23759>. 

41urllib.parse.uses_relative.extend(coap_schemes) 

42urllib.parse.uses_netloc.extend(coap_schemes) 

43 

44 

45class Message: 

46 """CoAP Message with some handling metadata 

47 

48 This object's attributes provide access to the fields in a CoAP message and 

49 can be directly manipulated. 

50 

51 * Some attributes are additional data that do not round-trip through 

52 serialization and deserialization. They are marked as "non-roundtrippable". 

53 * Some attributes that need to be filled for submission of the message can 

54 be left empty by most applications, and will be taken care of by the 

55 library. Those are marked as "managed". 

56 

57 The attributes are: 

58 

59 * :attr:`payload`: The payload (body) of the message as bytes. 

60 * :attr:`mtype`: Message type (CON, ACK etc, see :mod:`.numbers.types`). 

61 Managed unless set by the application. 

62 * :attr:`code`: The code (either request or response code), see 

63 :mod:`.numbers.codes`. 

64 * :attr:`opt`: A container for the options, see :class:`.options.Options`. 

65 

66 * :attr:`mid`: The message ID. Managed by the :class:`.Context`. 

67 * :attr:`token`: The message's token as bytes. Managed by the :class:`.Context`. 

68 * :attr:`remote`: The socket address of the other side, managed by the 

69 :class:`.protocol.Request` by resolving the ``.opt.uri_host`` or 

70 ``unresolved_remote``, or by the stack by echoing the incoming 

71 request's. Follows the :class:`.interfaces.EndpointAddress` interface. 

72 Non-roundtrippable. 

73 * :attr:`direction`: A :cls:`.Direction` that distinguishes parsed from 

74 to-be-serialized messages, and thus sets the meaning of the remote on 

75 whether it is "from" there (incoming) or "to" there (outgoing). 

76 Managed by the parsers (everything that is not a parsing result defaults 

77 to being outgoing), and thus non-roundtrippable. 

78 

79 While a message has not been transmitted, the property is managed by the 

80 :class:`.Message` itself using the :meth:`.set_request_uri()` or the 

81 constructor `uri` argument. 

82 * :attr:`transport_tuning`: Parameters used by one or more transports to 

83 guide transmission. These are purely advisory hints; unknown properties 

84 of that object are ignored, and transports consider them over built-in 

85 constants on a best-effort basis. 

86 

87 Note that many attributes are mandatory if this is not None; it is 

88 recommended that any objects passed in here are based on the 

89 :class:`aiocoap.numbers.constants.TransportTuning` class. 

90 

91 * :attr:`request`: The request to which an incoming response message 

92 belongs; only available at the client. Managed by the 

93 :class:`.interfaces.RequestProvider` (typically a :class:`.Context`). 

94 

95 These properties are still available but deprecated: 

96 

97 * requested_*: Managed by the :class:`.protocol.Request` a response results 

98 from, and filled with the request's URL data. Non-roundtrippable. 

99 

100 * unresolved_remote: ``host[:port]`` (strictly speaking; hostinfo as in a 

101 URI) formatted string. If this attribute is set, it overrides 

102 ``.RequestManageropt.uri_host`` (and ``-_port``) when it comes to filling the 

103 ``remote`` in an outgoing request. 

104 

105 Use this when you want to send a request with a host name that would not 

106 normally resolve to the destination address. (Typically, this is used for 

107 proxying.) 

108 

109 Options can be given as further keyword arguments at message construction 

110 time. This feature is experimental, as future message parameters could 

111 collide with options. 

112 

113 

114 The four messages involved in an exchange 

115 ----------------------------------------- 

116 

117 :: 

118 

119 Requester Responder 

120 

121 +-------------+ +-------------+ 

122 | request msg | ---- send request ---> | request msg | 

123 +-------------+ +-------------+ 

124 | 

125 processed into 

126 | 

127 v 

128 +-------------+ +-------------+ 

129 | response m. | <--- send response --- | response m. | 

130 +-------------+ +-------------+ 

131 

132 

133 The above shows the four message instances involved in communication 

134 between an aiocoap client and server process. Boxes represent instances of 

135 Message, and the messages on the same line represent a single CoAP as 

136 passed around on the network. Still, they differ in some aspects: 

137 

138 * The requested URI will look different between requester and responder 

139 if the requester uses a host name and does not send it in the message. 

140 * If the request was sent via multicast, the response's requested URI 

141 differs from the request URI because it has the responder's address 

142 filled in. That address is not known at the responder's side yet, as 

143 it is typically filled out by the network stack. 

144 * It is yet unclear whether the response's URI should contain an IP 

145 literal or a host name in the unicast case if the Uri-Host option was 

146 not sent. 

147 * Properties like Message ID and token will differ if a proxy was 

148 involved. 

149 * Some options or even the payload may differ if a proxy was involved. 

150 """ 

151 

152 request: Message | None = None 

153 

154 def __init__( 

155 self, 

156 *, 

157 mtype=None, 

158 mid=None, 

159 code=None, 

160 payload=b"", 

161 token=b"", 

162 uri=None, 

163 transport_tuning=None, 

164 _mid=None, 

165 _mtype=None, 

166 _token=b"", 

167 **kwargs, 

168 ): 

169 self.version = 1 

170 

171 # Moving those to underscore arguments: They're widespread in internal 

172 # code, but no application has any business tampering with them. 

173 # 

174 # We trust that internal code doesn't try to set both. 

175 if mid is not None: 

176 warn( 

177 "Initializing messages with an MID is deprecated. (No replacement: This needs to be managed by the library.)", 

178 DeprecationWarning, 

179 stacklevel=2, 

180 ) 

181 _mid = mid 

182 if mtype is not None: 

183 warn( 

184 "Initializing messages with an mtype is deprecated. Instead, set transport_tuning=aiocoap.Reliable or aiocoap.Unreliable.", 

185 DeprecationWarning, 

186 stacklevel=2, 

187 ) 

188 _mtype = mtype 

189 if token != b"": 

190 warn( 

191 "Initializing messages with a token is deprecated. (No replacement: This needs to be managed by the library.)", 

192 DeprecationWarning, 

193 stacklevel=2, 

194 ) 

195 _token = token 

196 

197 if _mtype is None: 

198 # leave it unspecified for convenience, sending functions will know what to do 

199 self.mtype = None 

200 else: 

201 self.mtype = Type(_mtype) 

202 self.mid = _mid 

203 if code is None: 

204 # as above with mtype 

205 self.code = None 

206 else: 

207 self.code = Code(code) 

208 self.token = _token 

209 self.payload = payload 

210 self.opt = Options() 

211 

212 self.remote = None 

213 self.direction: Direction = Direction.OUTGOING 

214 

215 self.transport_tuning = transport_tuning or TransportTuning() 

216 

217 # deprecation error, should go away roughly after 0.2 release 

218 if self.payload is None: 

219 raise TypeError("Payload must not be None. Use empty string instead.") 

220 

221 if uri: 

222 self.set_request_uri(uri) 

223 

224 for k, v in kwargs.items(): 

225 setattr(self.opt, k, v) 

226 

227 def __fromto(self) -> str: 

228 """Text 'from (remote)', 'to (remote)', 'incoming' or 'outgoing' 

229 depending on direction and presence of a remote""" 

230 if self.remote: 

231 return ( 

232 f"from {self.remote}" 

233 if self.direction is Direction.INCOMING 

234 else f"to {self.remote}" 

235 ) 

236 else: 

237 return "incoming" if self.direction is Direction.INCOMING else "outgoing" 

238 

239 def __repr__(self): 

240 options = f", {len(self.opt._options)} option(s)" if self.opt._options else "" 

241 payload = f", {len(self.payload)} byte(s) payload" if self.payload else "" 

242 

243 token = f"token {self.token.hex()}" if self.token else "empty token" 

244 mtype = f", {self.mtype}" if self.mtype is not None else "" 

245 mid = f", MID {self.mid:#04x}" if self.mid is not None else "" 

246 

247 return f"<aiocoap.Message: {self.code} {self.__fromto()}{options}{payload}, {token}{mtype}{mid}>" 

248 

249 def _repr_html_(self): 

250 """An HTML representation for Jupyter and similar environments 

251 

252 While the precise format is not guaranteed, it will be usable through 

253 tooltips and possibly fold-outs: 

254 

255 >>> from aiocoap import * 

256 >>> msg = Message(code=GET, uri="coap://localhost/other/separate") 

257 >>> html = msg._repr_html_() 

258 >>> 'Message with code <abbr title="Request Code 0.01">GET</abbr>' in html 

259 True 

260 >>> '3 options</summary>' in html 

261 True 

262 """ 

263 import html 

264 

265 return f"""<details style="padding-left:1em"><summary style="margin-left:-1em;display:list-item;">Message with code {self.code._repr_html_() if self.code is not None else "None"}, {html.escape(str(self.__fromto()))}</summary> 

266 {self.opt._repr_html_()}{self.payload_html()}""" 

267 

268 def payload_html(self): 

269 """An HTML representation of the payload 

270 

271 The precise format is not guaranteed, but generally it may involve 

272 pretty-printing, syntax highlighting, and visible notes that content 

273 was reflowed or absent. 

274 

275 The result may vary depending on the available modules (falling back go 

276 plain HTML-escaped ``repr()`` of the payload). 

277 """ 

278 import html 

279 

280 if not self.payload: 

281 return "<p>No payload</p>" 

282 else: 

283 from . import defaults 

284 

285 if defaults.prettyprint_missing_modules(): 

286 return f"<code>{html.escape(repr(self.payload))}</code>" 

287 else: 

288 from .util.prettyprint import pretty_print, lexer_for_mime 

289 

290 (notes, mediatype, text) = pretty_print(self) 

291 import pygments 

292 from pygments.formatters import HtmlFormatter 

293 

294 try: 

295 lexer = lexer_for_mime(mediatype) 

296 text = pygments.highlight(text, lexer, HtmlFormatter()) 

297 except pygments.util.ClassNotFound: 

298 text = html.escape(text) 

299 return ( 

300 "<div>" 

301 + "".join( 

302 f'<p style="color:gray;font-size:small;">{html.escape(n)}</p>' 

303 for n in notes 

304 ) 

305 + f"<pre>{text}</pre>" 

306 + "</div>" 

307 ) 

308 

309 def copy(self, **kwargs): 

310 """Create a copy of the Message. kwargs are treated like the named 

311 arguments in the constructor, and update the copy.""" 

312 # This is part of moving messages in an "immutable" direction; not 

313 # necessarily hard immutable. Let's see where this goes. 

314 

315 new = type(self)( 

316 code=kwargs.pop("code", self.code), 

317 payload=kwargs.pop("payload", self.payload), 

318 # Assuming these are not readily mutated, but rather passed 

319 # around in a class-like fashion 

320 transport_tuning=kwargs.pop("transport_tuning", self.transport_tuning), 

321 ) 

322 new.mtype = Type(kwargs.pop("mtype")) if "mtype" in kwargs else self.mtype 

323 new.mid = kwargs.pop("mid", self.mid) 

324 new.token = kwargs.pop("token", self.token) 

325 new.remote = kwargs.pop("remote", self.remote) 

326 new.direction = self.direction 

327 new.opt = copy.deepcopy(self.opt) 

328 

329 if "uri" in kwargs: 

330 new.set_request_uri(kwargs.pop("uri")) 

331 

332 for k, v in kwargs.items(): 

333 setattr(new.opt, k, v) 

334 

335 return new 

336 

337 @classmethod 

338 def decode(cls, rawdata, remote=None): 

339 """Create Message object from binary representation of message.""" 

340 try: 

341 (vttkl, code, mid) = struct.unpack("!BBH", rawdata[:4]) 

342 except struct.error: 

343 raise error.UnparsableMessage("Incoming message too short for CoAP") 

344 version = (vttkl & 0xC0) >> 6 

345 if version != 1: 

346 raise error.UnparsableMessage("Fatal Error: Protocol Version must be 1") 

347 mtype = (vttkl & 0x30) >> 4 

348 token_length = vttkl & 0x0F 

349 msg = Message(code=code) 

350 msg.mid = mid 

351 msg.mtype = Type(mtype) 

352 msg.token = rawdata[4 : 4 + token_length] 

353 msg.payload = msg.opt.decode(rawdata[4 + token_length :]) 

354 msg.remote = remote 

355 msg.direction = Direction.INCOMING 

356 return msg 

357 

358 def encode(self): 

359 """Create binary representation of message from Message object.""" 

360 

361 assert self.direction == Direction.OUTGOING 

362 

363 if self.code is None or self.mtype is None or self.mid is None: 

364 raise TypeError( 

365 "Fatal Error: Code, Message Type and Message ID must not be None." 

366 ) 

367 rawdata = bytes( 

368 [ 

369 (self.version << 6) 

370 + ((self.mtype & 0x03) << 4) 

371 + (len(self.token) & 0x0F) 

372 ] 

373 ) 

374 rawdata += struct.pack("!BH", self.code, self.mid) 

375 rawdata += self.token 

376 rawdata += self.opt.encode() 

377 if len(self.payload) > 0: 

378 rawdata += bytes([0xFF]) 

379 rawdata += self.payload 

380 return rawdata 

381 

382 def get_cache_key(self, ignore_options=()): 

383 """Generate a hashable and comparable object (currently a tuple) from 

384 the message's code and all option values that are part of the cache key 

385 and not in the optional list of ignore_options (which is the list of 

386 option numbers that are not technically NoCacheKey but handled by the 

387 application using this method). 

388 

389 >>> from aiocoap.numbers import GET 

390 >>> m1 = Message(code=GET) 

391 >>> m2 = Message(code=GET) 

392 >>> m1.opt.uri_path = ('s', '1') 

393 >>> m2.opt.uri_path = ('s', '1') 

394 >>> m1.opt.size1 = 10 # the only no-cache-key option in the base spec 

395 >>> m2.opt.size1 = 20 

396 >>> m1.get_cache_key() == m2.get_cache_key() 

397 True 

398 >>> m2.opt.etag = b'000' 

399 >>> m1.get_cache_key() == m2.get_cache_key() 

400 False 

401 >>> from aiocoap.numbers.optionnumbers import OptionNumber 

402 >>> ignore = [OptionNumber.ETAG] 

403 >>> m1.get_cache_key(ignore) == m2.get_cache_key(ignore) 

404 True 

405 """ 

406 

407 options = [] 

408 

409 for option in self.opt.option_list(): 

410 if option.number in ignore_options or ( 

411 option.number.is_safetoforward() and option.number.is_nocachekey() 

412 ): 

413 continue 

414 options.append((option.number, option.value)) 

415 

416 return (self.code, tuple(options)) 

417 

418 # 

419 # splitting and merging messages into and from message blocks 

420 # 

421 

422 def _extract_block(self, number, size_exp, max_bert_size): 

423 """Extract block from current message.""" 

424 if size_exp == 7: 

425 start = number * 1024 

426 size = 1024 * (max_bert_size // 1024) 

427 else: 

428 size = 2 ** (size_exp + 4) 

429 start = number * size 

430 

431 if start >= len(self.payload): 

432 raise error.BadRequest("Block request out of bounds") 

433 

434 end = start + size if start + size < len(self.payload) else len(self.payload) 

435 more = True if end < len(self.payload) else False 

436 

437 payload = self.payload[start:end] 

438 blockopt = (number, more, size_exp) 

439 

440 if self.code.is_request(): 

441 return self.copy(payload=payload, mid=None, block1=blockopt) 

442 else: 

443 return self.copy(payload=payload, mid=None, block2=blockopt) 

444 

445 def _append_request_block(self, next_block): 

446 """Modify message by appending another block""" 

447 if not self.code.is_request(): 

448 raise ValueError("_append_request_block only works on requests.") 

449 

450 block1 = next_block.opt.block1 

451 if block1.more: 

452 if len(next_block.payload) == block1.size: 

453 pass 

454 elif ( 

455 block1.size_exponent == 7 and len(next_block.payload) % block1.size == 0 

456 ): 

457 pass 

458 else: 

459 raise error.BadRequest("Payload size does not match Block1") 

460 if block1.start == len(self.payload): 

461 self.payload += next_block.payload 

462 self.opt.block1 = block1 

463 self.token = next_block.token 

464 self.mid = next_block.mid 

465 if not block1.more and next_block.opt.block2 is not None: 

466 self.opt.block2 = next_block.opt.block2 

467 else: 

468 # possible extension point: allow messages with "gaps"; then 

469 # ValueError would only be raised when trying to overwrite an 

470 # existing part; it is doubtful though that the blockwise 

471 # specification even condones such behavior. 

472 raise ValueError() 

473 

474 def _append_response_block(self, next_block): 

475 """Append next block to current response message. 

476 Used when assembling incoming blockwise responses.""" 

477 if not self.code.is_response(): 

478 raise ValueError("_append_response_block only works on responses.") 

479 

480 block2 = next_block.opt.block2 

481 if not block2.is_valid_for_payload_size(len(next_block.payload)): 

482 raise error.UnexpectedBlock2("Payload size does not match Block2") 

483 if block2.start != len(self.payload): 

484 # Does not need to be implemented as long as the requesting code 

485 # sequentially clocks out data 

486 raise error.NotImplemented() 

487 

488 if next_block.opt.etag != self.opt.etag: 

489 raise error.ResourceChanged() 

490 

491 self.payload += next_block.payload 

492 self.opt.block2 = block2 

493 self.token = next_block.token 

494 self.mid = next_block.mid 

495 

496 def _generate_next_block2_request(self, response): 

497 """Generate a sub-request for next response block. 

498 

499 This method is used by client after receiving blockwise response from 

500 server with "more" flag set.""" 

501 

502 # Note: response here is the assembled response, but (due to 

503 # _append_response_block's workings) it carries the Block2 option of 

504 # the last received block. 

505 

506 next_after_received = len(response.payload) // response.opt.block2.size 

507 blockopt = optiontypes.BlockOption.BlockwiseTuple( 

508 next_after_received, False, response.opt.block2.size_exponent 

509 ) 

510 

511 # has been checked in assembly, just making sure 

512 assert blockopt.start == len(response.payload), ( 

513 "Unexpected state of preassembled message" 

514 ) 

515 

516 blockopt = blockopt.reduced_to(response.remote.maximum_block_size_exp) 

517 

518 return self.copy( 

519 payload=b"", 

520 mid=None, 

521 token=None, 

522 block2=blockopt, 

523 block1=None, 

524 observe=None, 

525 ) 

526 

527 def _generate_next_block1_response(self): 

528 """Generate a response to acknowledge incoming request block. 

529 

530 This method is used by server after receiving blockwise request from 

531 client with "more" flag set.""" 

532 response = Message(code=CHANGED, token=self.token) 

533 response.remote = self.remote 

534 if ( 

535 self.opt.block1.block_number == 0 

536 and self.opt.block1.size_exponent 

537 > self.transport_tuning.DEFAULT_BLOCK_SIZE_EXP 

538 ): 

539 new_size_exponent = self.transport_tuning.DEFAULT_BLOCK_SIZE_EXP 

540 response.opt.block1 = (0, True, new_size_exponent) 

541 else: 

542 response.opt.block1 = ( 

543 self.opt.block1.block_number, 

544 True, 

545 self.opt.block1.size_exponent, 

546 ) 

547 return response 

548 

549 # 

550 # the message in the context of network and addresses 

551 # 

552 

553 def get_request_uri(self, *, local_is_server=None): 

554 """The absolute URI this message belongs to. 

555 

556 For requests, this is composed from the options (falling back to the 

557 remote). For responses, this is largely taken from the original request 

558 message (so far, that could have been trackecd by the requesting 

559 application as well), but -- in case of a multicast request -- with the 

560 host replaced by the responder's endpoint details. 

561 

562 This implements Section 6.5 of RFC7252. 

563 

564 By default, these values are only valid on the client. To determine a 

565 message's request URI on the server, set the local_is_server argument 

566 to True. Note that determining the request URI on the server is brittle 

567 when behind a reverse proxy, may not be possible on all platforms, and 

568 can only be applied to a request message in a renderer (for the 

569 response message created by the renderer will only be populated when it 

570 gets transmitted; simple manual copying of the request's remote to the 

571 response will not magically make this work, for in the very case where 

572 the request and response's URIs differ, that would not catch the 

573 difference and still report the multicast address, while the actual 

574 sending address will only be populated by the operating system later). 

575 """ 

576 

577 # maybe this function does not belong exactly *here*, but it belongs to 

578 # the results of .request(message), which is currently a message itself. 

579 

580 inferred_local_is_server = ( 

581 self.direction is Direction.INCOMING 

582 ) ^ self.code.is_response() 

583 

584 if local_is_server is not None: 

585 warn( 

586 "Argument local_is_server is not needed any more and is deprecated", 

587 PendingDeprecationWarning, 

588 stacklevel=2, 

589 ) 

590 assert local_is_server == inferred_local_is_server, ( 

591 "local_is_server value mismatches message direction" 

592 ) 

593 else: 

594 local_is_server = inferred_local_is_server 

595 

596 if self.code.is_response(): 

597 refmsg = self.request 

598 

599 if refmsg.remote.is_multicast: 

600 if local_is_server: 

601 multicast_netloc_override = self.remote.hostinfo_local 

602 else: 

603 multicast_netloc_override = self.remote.hostinfo 

604 else: 

605 multicast_netloc_override = None 

606 else: 

607 refmsg = self 

608 multicast_netloc_override = None 

609 

610 proxyuri = refmsg.opt.proxy_uri 

611 if proxyuri is not None: 

612 return proxyuri 

613 

614 scheme = refmsg.opt.proxy_scheme or refmsg.remote.scheme 

615 query = refmsg.opt.uri_query or () 

616 if refmsg.opt.uri_path_abbrev is not None: 

617 if refmsg.opt.uri_path: 

618 raise ValueError( 

619 "Conflicting information about the path (Uri-Path and Uri-Path-Abbrev)" 

620 ) 

621 try: 

622 path = uri_path_abbrev._map[refmsg.opt.uri_path_abbrev] 

623 except KeyError: 

624 raise ValueError( 

625 f"Path could not be determined: Unknown Uri-Path-Abbrev value {refmsg.opt.uri_path!r}" 

626 ) from None 

627 else: 

628 if hasattr(self, "_original_request_path"): 

629 # During server-side processing, a message's options may be altered 

630 # to the point where its options don't accurately reflect its URI 

631 # any more. In that case, this is stored. 

632 path = self._original_request_path 

633 else: 

634 path = refmsg.opt.uri_path 

635 

636 if multicast_netloc_override is not None: 

637 netloc = multicast_netloc_override 

638 else: 

639 if local_is_server: 

640 netloc = refmsg.remote.hostinfo_local 

641 else: 

642 netloc = refmsg.remote.hostinfo 

643 

644 if refmsg.opt.uri_host is not None or refmsg.opt.uri_port is not None: 

645 host, port = hostportsplit(netloc) 

646 

647 host = refmsg.opt.uri_host or host 

648 port = refmsg.opt.uri_port or port 

649 

650 # FIXME: This sounds like it should be part of 

651 # hpostportjoin/-split 

652 escaped_host = quote_nonascii(host) 

653 

654 # FIXME: "If host is not valid reg-name / IP-literal / IPv4address, 

655 # fail" 

656 

657 netloc = hostportjoin(escaped_host, port) 

658 

659 # FIXME this should follow coap section 6.5 more closely 

660 query = "&".join(_quote_for_query(q) for q in query) 

661 path = "".join("/" + _quote_for_path(p) for p in path) or "/" 

662 

663 fragment = None 

664 params = "" # are they not there at all? 

665 

666 # Eases debugging, for when they raise from urunparse you won't know 

667 # which of them it was 

668 assert scheme is not None, "Remote has no scheme set" 

669 assert netloc is not None, "Remote has no netloc set" 

670 return urllib.parse.urlunparse((scheme, netloc, path, params, query, fragment)) 

671 

672 def set_request_uri(self, uri, *, set_uri_host=True): 

673 """Parse a given URI into the uri_* fields of the options. 

674 

675 The remote does not get set automatically; instead, the remote data is 

676 stored in the uri_host and uri_port options. That is because name resolution 

677 is coupled with network specifics the protocol will know better by the 

678 time the message is sent. Whatever sends the message, be it the 

679 protocol itself, a proxy wrapper or an alternative transport, will know 

680 how to handle the information correctly. 

681 

682 When ``set_uri_host=False`` is passed, the host/port is stored in the 

683 ``unresolved_remote`` message property instead of the uri_host option; 

684 as a result, the unresolved host name is not sent on the wire, which 

685 breaks virtual hosts but makes message sizes smaller. 

686 

687 This implements Section 6.4 of RFC7252. 

688 

689 This raises IncompleteUrlError if URI references are passed in (instead 

690 of a full URI), and MalformedUrlError if the URI specification or the 

691 library's expectations of URI shapes (eg. 'coap+tcp:no-slashes') are 

692 violated. 

693 """ 

694 

695 try: 

696 parsed = urllib.parse.urlparse(uri) 

697 except ValueError as e: 

698 raise error.MalformedUrlError from e 

699 

700 if parsed.fragment: 

701 raise error.MalformedUrlError( 

702 "Fragment identifiers can not be set on a request URI" 

703 ) 

704 

705 if not parsed.scheme: 

706 raise error.IncompleteUrlError() 

707 

708 if parsed.scheme not in coap_schemes: 

709 self.opt.proxy_uri = uri 

710 return 

711 

712 if not parsed.hostname: 

713 raise error.MalformedUrlError("CoAP URIs need a hostname") 

714 

715 if parsed.username or parsed.password: 

716 raise error.MalformedUrlError("User name and password not supported.") 

717 

718 try: 

719 if parsed.path not in ("", "/"): 

720 # FIXME: This tolerates incomplete % sequences. 

721 self.opt.uri_path = [ 

722 urllib.parse.unquote(x, errors="strict") 

723 for x in parsed.path.split("/")[1:] 

724 ] 

725 else: 

726 self.opt.uri_path = [] 

727 if parsed.query: 

728 # FIXME: This tolerates incomplete % sequences. 

729 self.opt.uri_query = [ 

730 urllib.parse.unquote(x, errors="strict") 

731 for x in parsed.query.split("&") 

732 ] 

733 else: 

734 self.opt.uri_query = [] 

735 except UnicodeError as e: 

736 raise error.MalformedUrlError( 

737 "Percent encoded strings in CoAP URIs need to be UTF-8 encoded" 

738 ) from e 

739 

740 self.remote = UndecidedRemote(parsed.scheme, parsed.netloc) 

741 

742 try: 

743 _ = parsed.port 

744 except ValueError as e: 

745 raise error.MalformedUrlError("Port must be numeric") from e 

746 

747 is_ip_literal = parsed.netloc.startswith("[") or ( 

748 parsed.hostname.count(".") == 3 

749 and all(c in "0123456789." for c in parsed.hostname) 

750 and all(int(x) <= 255 for x in parsed.hostname.split(".")) 

751 ) 

752 

753 if set_uri_host and not is_ip_literal: 

754 try: 

755 self.opt.uri_host = urllib.parse.unquote( 

756 parsed.hostname, errors="strict" 

757 ).translate(_ascii_lowercase) 

758 except UnicodeError as e: 

759 raise error.MalformedUrlError( 

760 "Percent encoded strings in CoAP URI hosts need to be UTF-8 encoded" 

761 ) from e 

762 

763 # Deprecated accessors to moved functionality 

764 

765 @property 

766 def unresolved_remote(self): 

767 return self.remote.hostinfo 

768 

769 @unresolved_remote.setter 

770 def unresolved_remote(self, value): 

771 # should get a big fat deprecation warning 

772 if value is None: 

773 self.remote = UndecidedRemote("coap", None) 

774 else: 

775 self.remote = UndecidedRemote("coap", value) 

776 

777 @property 

778 def requested_scheme(self): 

779 if self.code.is_request(): 

780 return self.remote.scheme 

781 else: 

782 return self.request.requested_scheme 

783 

784 @requested_scheme.setter 

785 def requested_scheme(self, value): 

786 self.remote = UndecidedRemote(value, self.remote.hostinfo) 

787 

788 @property 

789 def requested_proxy_uri(self): 

790 return self.request.opt.proxy_uri 

791 

792 @property 

793 def requested_hostinfo(self): 

794 return self.request.opt.uri_host or self.request.unresolved_remote 

795 

796 @property 

797 def requested_path(self): 

798 return self.request.opt.uri_path 

799 

800 @property 

801 def requested_query(self): 

802 return self.request.opt.uri_query 

803 

804 

805class UndecidedRemote( 

806 namedtuple("_UndecidedRemote", ("scheme", "hostinfo")), interfaces.EndpointAddress 

807): 

808 """Remote that is set on messages that have not been sent through any any 

809 transport. 

810 

811 It describes scheme, hostname and port that were set in 

812 :meth:`.set_request_uri()` or when setting a URI per Message constructor. 

813 

814 * :attr:`scheme`: The scheme string 

815 * :attr:`hostinfo`: The authority component of the URI, as it would occur 

816 in the URI. 

817 

818 Both in the constructor and in the repr, it also supports a single-value 

819 form of a URI Origin. 

820 

821 In order to produce URIs identical to those received in responses, and 

822 because the underlying types should really be binary anyway, IP addresses 

823 in the hostinfo are normalized: 

824 

825 >>> UndecidedRemote("coap+tcp", "[::0001]:1234") 

826 UndecidedRemote('coap+tcp://[::1]:1234') 

827 >>> tuple(UndecidedRemote("coap", "localhost")) 

828 ('coap', 'localhost') 

829 """ 

830 

831 # This is settable per instance, for other transports to pick it up. 

832 maximum_block_size_exp = MAX_REGULAR_BLOCK_SIZE_EXP 

833 

834 def __new__(cls, scheme: str, hostinfo: str | None): 

835 if hostinfo is None: 

836 return cls.from_pathless_uri(scheme) 

837 if "[" in hostinfo: 

838 (host, port) = hostportsplit(hostinfo) 

839 ip = ipaddress.ip_address(host) 

840 host = str(ip) 

841 hostinfo = hostportjoin(host, port) 

842 

843 return super().__new__(cls, scheme, hostinfo) 

844 

845 @property 

846 def hostinfo_local(self): 

847 # Could also raise AnonymousHost, but there might also be a sensible 

848 # value to come, so we better point out the issue. 

849 raise RuntimeError(f"Access to {self}.hostinfo_local is premature.") 

850 

851 @classmethod 

852 def from_pathless_uri(cls, uri: str) -> UndecidedRemote: 

853 """Create an UndecidedRemote for a given URI that has no query, path, 

854 fragment or other components not expressed in an UndecidedRemote 

855 

856 >>> from aiocoap.message import UndecidedRemote 

857 >>> UndecidedRemote.from_pathless_uri("coap://localhost") 

858 UndecidedRemote('coap://localhost') 

859 """ 

860 

861 parsed = urllib.parse.urlparse(uri) 

862 

863 if parsed.username or parsed.password: 

864 raise ValueError("User name and password not supported.") 

865 

866 if parsed.path not in ("", "/") or parsed.query or parsed.fragment: 

867 raise ValueError( 

868 "Paths and query and fragment can not be set on an UndecidedRemote" 

869 ) 

870 

871 return cls(parsed.scheme, parsed.netloc) 

872 

873 def __repr__(self): 

874 return f"UndecidedRemote({f'{self.scheme}://{self.hostinfo}'!r})" 

875 

876 

877_ascii_lowercase = str.maketrans(string.ascii_uppercase, string.ascii_lowercase) 

878 

879_quote_for_path = quote_factory(unreserved + sub_delims + ":@") 

880_quote_for_query = quote_factory( 

881 unreserved + "".join(c for c in sub_delims if c != "&") + ":@/?" 

882) 

883 

884 

885class Direction(enum.Enum): 

886 INCOMING = enum.auto() 

887 OUTGOING = enum.auto() 

888 

889 

890#: Result that can be returned from a render method instead of a Message when 

891#: due to defaults (eg. multicast link-format queries) or explicit 

892#: configuration (eg. the No-Response option), no response should be sent at 

893#: all. Note that per RFC7967 section 2, an ACK is still sent to a CON 

894#: request. 

895#: 

896#: Deprecated; set the no_response option on a regular response instead (see 

897#: :meth:`.interfaces.Resource.render` for details). 

898NoResponse = Sentinel("NoResponse")