Coverage for aiocoap/message.py: 83%

337 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-30 11:17 +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 hostportjoin, hostportsplit, Sentinel, quote_nonascii 

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

24from . import interfaces 

25 

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

27 

28# FIXME there should be a proper inteface for this that does all the urllib 

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

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

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

32 

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

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

35urllib.parse.uses_relative.extend(coap_schemes) 

36urllib.parse.uses_netloc.extend(coap_schemes) 

37 

38 

39class Message: 

40 """CoAP Message with some handling metadata 

41 

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

43 can be directly manipulated. 

44 

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

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

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

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

49 library. Those are marked as "managed". 

50 

51 The attributes are: 

52 

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

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

55 Managed unless set by the application. 

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

57 :mod:`.numbers.codes`. 

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

59 

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

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

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

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

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

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

66 Non-roundtrippable. 

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

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

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

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

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

72 

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

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

75 constructor `uri` argument. 

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

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

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

79 constants on a best-effort basis. 

80 

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

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

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

84 

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

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

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

88 

89 These properties are still available but deprecated: 

90 

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

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

93 

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

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

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

97 ``remote`` in an outgoing request. 

98 

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

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

101 proxying.) 

102 

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

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

105 collide with options. 

106 

107 

108 The four messages involved in an exchange 

109 ----------------------------------------- 

110 

111 :: 

112 

113 Requester Responder 

114 

115 +-------------+ +-------------+ 

116 | request msg | ---- send request ---> | request msg | 

117 +-------------+ +-------------+ 

118 | 

119 processed into 

120 | 

121 v 

122 +-------------+ +-------------+ 

123 | response m. | <--- send response --- | response m. | 

124 +-------------+ +-------------+ 

125 

126 

127 The above shows the four message instances involved in communication 

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

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

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

131 

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

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

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

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

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

137 it is typically filled out by the network stack. 

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

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

140 not sent. 

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

142 involved. 

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

144 """ 

145 

146 request: Message | None = None 

147 

148 def __init__( 

149 self, 

150 *, 

151 mtype=None, 

152 mid=None, 

153 code=None, 

154 payload=b"", 

155 token=b"", 

156 uri=None, 

157 transport_tuning=None, 

158 _mid=None, 

159 _mtype=None, 

160 _token=b"", 

161 **kwargs, 

162 ): 

163 self.version = 1 

164 

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

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

167 # 

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

169 if mid is not None: 

170 warn( 

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

172 DeprecationWarning, 

173 stacklevel=2, 

174 ) 

175 _mid = mid 

176 if mtype is not None: 

177 warn( 

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

179 DeprecationWarning, 

180 stacklevel=2, 

181 ) 

182 _mtype = mtype 

183 if token != b"": 

184 warn( 

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

186 DeprecationWarning, 

187 stacklevel=2, 

188 ) 

189 _token = token 

190 

191 if _mtype is None: 

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

193 self.mtype = None 

194 else: 

195 self.mtype = Type(_mtype) 

196 self.mid = _mid 

197 if code is None: 

198 # as above with mtype 

199 self.code = None 

200 else: 

201 self.code = Code(code) 

202 self.token = _token 

203 self.payload = payload 

204 self.opt = Options() 

205 

206 self.remote = None 

207 self.direction: Direction = Direction.OUTGOING 

208 

209 self.transport_tuning = transport_tuning or TransportTuning() 

210 

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

212 if self.payload is None: 

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

214 

215 if uri: 

216 self.set_request_uri(uri) 

217 

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

219 setattr(self.opt, k, v) 

220 

221 def __fromto(self) -> str: 

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

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

224 if self.remote: 

225 return ( 

226 f"from {self.remote}" 

227 if self.direction is Direction.INCOMING 

228 else f"to {self.remote}" 

229 ) 

230 else: 

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

232 

233 def __repr__(self): 

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

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

236 

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

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

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

240 

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

242 

243 def _repr_html_(self): 

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

245 

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

247 tooltips and possibly fold-outs: 

248 

249 >>> from aiocoap import * 

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

251 >>> html = msg._repr_html_() 

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

253 True 

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

255 True 

256 """ 

257 import html 

258 

259 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> 

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

261 

262 def payload_html(self): 

263 """An HTML representation of the payload 

264 

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

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

267 was reflowed or absent. 

268 

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

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

271 """ 

272 import html 

273 

274 if not self.payload: 

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

276 else: 

277 from . import defaults 

278 

279 if defaults.prettyprint_missing_modules(): 

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

281 else: 

282 from .util.prettyprint import pretty_print, lexer_for_mime 

283 

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

285 import pygments 

286 from pygments.formatters import HtmlFormatter 

287 

288 try: 

289 lexer = lexer_for_mime(mediatype) 

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

291 except pygments.util.ClassNotFound: 

292 text = html.escape(text) 

293 return ( 

294 "<div>" 

295 + "".join( 

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

297 for n in notes 

298 ) 

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

300 + "</div>" 

301 ) 

302 

303 def copy(self, **kwargs): 

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

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

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

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

308 

309 new = type(self)( 

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

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

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

313 # around in a class-like fashion 

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

315 ) 

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

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

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

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

320 new.direction = self.direction 

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

322 

323 if "uri" in kwargs: 

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

325 

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

327 setattr(new.opt, k, v) 

328 

329 return new 

330 

331 @classmethod 

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

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

334 try: 

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

336 except struct.error: 

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

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

339 if version != 1: 

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

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

342 token_length = vttkl & 0x0F 

343 msg = Message(code=code) 

344 msg.mid = mid 

345 msg.mtype = Type(mtype) 

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

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

348 msg.remote = remote 

349 msg.direction = Direction.INCOMING 

350 return msg 

351 

352 def encode(self): 

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

354 

355 assert self.direction == Direction.OUTGOING 

356 

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

358 raise TypeError( 

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

360 ) 

361 rawdata = bytes( 

362 [ 

363 (self.version << 6) 

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

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

366 ] 

367 ) 

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

369 rawdata += self.token 

370 rawdata += self.opt.encode() 

371 if len(self.payload) > 0: 

372 rawdata += bytes([0xFF]) 

373 rawdata += self.payload 

374 return rawdata 

375 

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

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

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

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

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

381 application using this method). 

382 

383 >>> from aiocoap.numbers import GET 

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

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

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

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

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

389 >>> m2.opt.size1 = 20 

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

391 True 

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

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

394 False 

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

396 >>> ignore = [OptionNumber.ETAG] 

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

398 True 

399 """ 

400 

401 options = [] 

402 

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

404 if option.number in ignore_options or ( 

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

406 ): 

407 continue 

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

409 

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

411 

412 # 

413 # splitting and merging messages into and from message blocks 

414 # 

415 

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

417 """Extract block from current message.""" 

418 if size_exp == 7: 

419 start = number * 1024 

420 size = 1024 * (max_bert_size // 1024) 

421 else: 

422 size = 2 ** (size_exp + 4) 

423 start = number * size 

424 

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

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

427 

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

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

430 

431 payload = self.payload[start:end] 

432 blockopt = (number, more, size_exp) 

433 

434 if self.code.is_request(): 

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

436 else: 

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

438 

439 def _append_request_block(self, next_block): 

440 """Modify message by appending another block""" 

441 if not self.code.is_request(): 

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

443 

444 block1 = next_block.opt.block1 

445 if block1.more: 

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

447 pass 

448 elif ( 

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

450 ): 

451 pass 

452 else: 

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

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

455 self.payload += next_block.payload 

456 self.opt.block1 = block1 

457 self.token = next_block.token 

458 self.mid = next_block.mid 

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

460 self.opt.block2 = next_block.opt.block2 

461 else: 

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

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

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

465 # specification even condones such behavior. 

466 raise ValueError() 

467 

468 def _append_response_block(self, next_block): 

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

470 Used when assembling incoming blockwise responses.""" 

471 if not self.code.is_response(): 

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

473 

474 block2 = next_block.opt.block2 

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

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

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

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

479 # sequentially clocks out data 

480 raise error.NotImplemented() 

481 

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

483 raise error.ResourceChanged() 

484 

485 self.payload += next_block.payload 

486 self.opt.block2 = block2 

487 self.token = next_block.token 

488 self.mid = next_block.mid 

489 

490 def _generate_next_block2_request(self, response): 

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

492 

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

494 server with "more" flag set.""" 

495 

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

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

498 # the last received block. 

499 

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

501 blockopt = optiontypes.BlockOption.BlockwiseTuple( 

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

503 ) 

504 

505 # has been checked in assembly, just making sure 

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

507 "Unexpected state of preassembled message" 

508 ) 

509 

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

511 

512 return self.copy( 

513 payload=b"", 

514 mid=None, 

515 token=None, 

516 block2=blockopt, 

517 block1=None, 

518 observe=None, 

519 ) 

520 

521 def _generate_next_block1_response(self): 

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

523 

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

525 client with "more" flag set.""" 

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

527 response.remote = self.remote 

528 if ( 

529 self.opt.block1.block_number == 0 

530 and self.opt.block1.size_exponent 

531 > self.transport_tuning.DEFAULT_BLOCK_SIZE_EXP 

532 ): 

533 new_size_exponent = self.transport_tuning.DEFAULT_BLOCK_SIZE_EXP 

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

535 else: 

536 response.opt.block1 = ( 

537 self.opt.block1.block_number, 

538 True, 

539 self.opt.block1.size_exponent, 

540 ) 

541 return response 

542 

543 # 

544 # the message in the context of network and addresses 

545 # 

546 

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

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

549 

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

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

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

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

554 host replaced by the responder's endpoint details. 

555 

556 This implements Section 6.5 of RFC7252. 

557 

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

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

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

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

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

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

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

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

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

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

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

569 """ 

570 

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

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

573 

574 if hasattr(self, "_original_request_uri"): 

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

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

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

578 return self._original_request_uri 

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 path = refmsg.opt.uri_path 

629 

630 if multicast_netloc_override is not None: 

631 netloc = multicast_netloc_override 

632 else: 

633 if local_is_server: 

634 netloc = refmsg.remote.hostinfo_local 

635 else: 

636 netloc = refmsg.remote.hostinfo 

637 

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

639 host, port = hostportsplit(netloc) 

640 

641 host = refmsg.opt.uri_host or host 

642 port = refmsg.opt.uri_port or port 

643 

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

645 # hpostportjoin/-split 

646 escaped_host = quote_nonascii(host) 

647 

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

649 # fail" 

650 

651 netloc = hostportjoin(escaped_host, port) 

652 

653 # FIXME this should follow coap section 6.5 more closely 

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

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

656 

657 fragment = None 

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

659 

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

661 # which of them it was 

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

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

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

665 

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

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

668 

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

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

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

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

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

674 how to handle the information correctly. 

675 

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

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

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

679 breaks virtual hosts but makes message sizes smaller. 

680 

681 This implements Section 6.4 of RFC7252. 

682 

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

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

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

686 violated. 

687 """ 

688 

689 try: 

690 parsed = urllib.parse.urlparse(uri) 

691 except ValueError as e: 

692 raise error.MalformedUrlError from e 

693 

694 if parsed.fragment: 

695 raise error.MalformedUrlError( 

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

697 ) 

698 

699 if not parsed.scheme: 

700 raise error.IncompleteUrlError() 

701 

702 if parsed.scheme not in coap_schemes: 

703 self.opt.proxy_uri = uri 

704 return 

705 

706 if not parsed.hostname: 

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

708 

709 if parsed.username or parsed.password: 

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

711 

712 try: 

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

714 # FIXME: This tolerates incomplete % sequences. 

715 self.opt.uri_path = [ 

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

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

718 ] 

719 else: 

720 self.opt.uri_path = [] 

721 if parsed.query: 

722 # FIXME: This tolerates incomplete % sequences. 

723 self.opt.uri_query = [ 

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

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

726 ] 

727 else: 

728 self.opt.uri_query = [] 

729 except UnicodeError as e: 

730 raise error.MalformedUrlError( 

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

732 ) from e 

733 

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

735 

736 try: 

737 _ = parsed.port 

738 except ValueError as e: 

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

740 

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

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

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

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

745 ) 

746 

747 if set_uri_host and not is_ip_literal: 

748 try: 

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

750 parsed.hostname, errors="strict" 

751 ).translate(_ascii_lowercase) 

752 except UnicodeError as e: 

753 raise error.MalformedUrlError( 

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

755 ) from e 

756 

757 # Deprecated accessors to moved functionality 

758 

759 @property 

760 def unresolved_remote(self): 

761 return self.remote.hostinfo 

762 

763 @unresolved_remote.setter 

764 def unresolved_remote(self, value): 

765 # should get a big fat deprecation warning 

766 if value is None: 

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

768 else: 

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

770 

771 @property 

772 def requested_scheme(self): 

773 if self.code.is_request(): 

774 return self.remote.scheme 

775 else: 

776 return self.request.requested_scheme 

777 

778 @requested_scheme.setter 

779 def requested_scheme(self, value): 

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

781 

782 @property 

783 def requested_proxy_uri(self): 

784 return self.request.opt.proxy_uri 

785 

786 @property 

787 def requested_hostinfo(self): 

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

789 

790 @property 

791 def requested_path(self): 

792 return self.request.opt.uri_path 

793 

794 @property 

795 def requested_query(self): 

796 return self.request.opt.uri_query 

797 

798 

799class UndecidedRemote( 

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

801): 

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

803 transport. 

804 

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

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

807 

808 * :attr:`scheme`: The scheme string 

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

810 in the URI. 

811 

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

813 form of a URI Origin. 

814 

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

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

817 in the hostinfo are normalized: 

818 

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

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

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

822 ('coap', 'localhost') 

823 """ 

824 

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

826 maximum_block_size_exp = MAX_REGULAR_BLOCK_SIZE_EXP 

827 

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

829 if hostinfo is None: 

830 return cls.from_pathless_uri(scheme) 

831 if "[" in hostinfo: 

832 (host, port) = hostportsplit(hostinfo) 

833 ip = ipaddress.ip_address(host) 

834 host = str(ip) 

835 hostinfo = hostportjoin(host, port) 

836 

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

838 

839 @classmethod 

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

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

842 fragment or other components not expressed in an UndecidedRemote 

843 

844 >>> from aiocoap.message import UndecidedRemote 

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

846 UndecidedRemote('coap://localhost') 

847 """ 

848 

849 parsed = urllib.parse.urlparse(uri) 

850 

851 if parsed.username or parsed.password: 

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

853 

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

855 raise ValueError( 

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

857 ) 

858 

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

860 

861 def __repr__(self): 

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

863 

864 

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

866 

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

868_quote_for_query = quote_factory( 

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

870) 

871 

872 

873class Direction(enum.Enum): 

874 INCOMING = enum.auto() 

875 OUTGOING = enum.auto() 

876 

877 

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

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

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

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

882#: request. 

883#: 

884#: Depercated; set the no_response option on a regular response instead (see 

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

886NoResponse = Sentinel("NoResponse")