Coverage for aiocoap / message.py: 82%
340 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 12:28 +0000
« 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
5from __future__ import annotations
7import enum
8import ipaddress
9import urllib.parse
10import struct
11import copy
12import string
13from collections import namedtuple
14from warnings import warn
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
32__all__ = ["Message", "NoResponse"]
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"]
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)
45class Message:
46 """CoAP Message with some handling metadata
48 This object's attributes provide access to the fields in a CoAP message and
49 can be directly manipulated.
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".
57 The attributes are:
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`.
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.
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.
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.
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`).
95 These properties are still available but deprecated:
97 * requested_*: Managed by the :class:`.protocol.Request` a response results
98 from, and filled with the request's URL data. Non-roundtrippable.
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.
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.)
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.
114 The four messages involved in an exchange
115 -----------------------------------------
117 ::
119 Requester Responder
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 +-------------+ +-------------+
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:
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 """
152 request: Message | None = None
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
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
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()
212 self.remote = None
213 self.direction: Direction = Direction.OUTGOING
215 self.transport_tuning = transport_tuning or TransportTuning()
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.")
221 if uri:
222 self.set_request_uri(uri)
224 for k, v in kwargs.items():
225 setattr(self.opt, k, v)
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"
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 ""
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 ""
247 return f"<aiocoap.Message: {self.code} {self.__fromto()}{options}{payload}, {token}{mtype}{mid}>"
249 def _repr_html_(self):
250 """An HTML representation for Jupyter and similar environments
252 While the precise format is not guaranteed, it will be usable through
253 tooltips and possibly fold-outs:
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
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()}"""
268 def payload_html(self):
269 """An HTML representation of the payload
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.
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
280 if not self.payload:
281 return "<p>No payload</p>"
282 else:
283 from . import defaults
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
290 (notes, mediatype, text) = pretty_print(self)
291 import pygments
292 from pygments.formatters import HtmlFormatter
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 )
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.
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)
329 if "uri" in kwargs:
330 new.set_request_uri(kwargs.pop("uri"))
332 for k, v in kwargs.items():
333 setattr(new.opt, k, v)
335 return new
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
358 def encode(self):
359 """Create binary representation of message from Message object."""
361 assert self.direction == Direction.OUTGOING
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
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).
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 """
407 options = []
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))
416 return (self.code, tuple(options))
418 #
419 # splitting and merging messages into and from message blocks
420 #
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
431 if start >= len(self.payload):
432 raise error.BadRequest("Block request out of bounds")
434 end = start + size if start + size < len(self.payload) else len(self.payload)
435 more = True if end < len(self.payload) else False
437 payload = self.payload[start:end]
438 blockopt = (number, more, size_exp)
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)
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.")
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()
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.")
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()
488 if next_block.opt.etag != self.opt.etag:
489 raise error.ResourceChanged()
491 self.payload += next_block.payload
492 self.opt.block2 = block2
493 self.token = next_block.token
494 self.mid = next_block.mid
496 def _generate_next_block2_request(self, response):
497 """Generate a sub-request for next response block.
499 This method is used by client after receiving blockwise response from
500 server with "more" flag set."""
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.
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 )
511 # has been checked in assembly, just making sure
512 assert blockopt.start == len(response.payload), (
513 "Unexpected state of preassembled message"
514 )
516 blockopt = blockopt.reduced_to(response.remote.maximum_block_size_exp)
518 return self.copy(
519 payload=b"",
520 mid=None,
521 token=None,
522 block2=blockopt,
523 block1=None,
524 observe=None,
525 )
527 def _generate_next_block1_response(self):
528 """Generate a response to acknowledge incoming request block.
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
549 #
550 # the message in the context of network and addresses
551 #
553 def get_request_uri(self, *, local_is_server=None):
554 """The absolute URI this message belongs to.
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.
562 This implements Section 6.5 of RFC7252.
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 """
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.
580 inferred_local_is_server = (
581 self.direction is Direction.INCOMING
582 ) ^ self.code.is_response()
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
596 if self.code.is_response():
597 refmsg = self.request
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
610 proxyuri = refmsg.opt.proxy_uri
611 if proxyuri is not None:
612 return proxyuri
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
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
644 if refmsg.opt.uri_host is not None or refmsg.opt.uri_port is not None:
645 host, port = hostportsplit(netloc)
647 host = refmsg.opt.uri_host or host
648 port = refmsg.opt.uri_port or port
650 # FIXME: This sounds like it should be part of
651 # hpostportjoin/-split
652 escaped_host = quote_nonascii(host)
654 # FIXME: "If host is not valid reg-name / IP-literal / IPv4address,
655 # fail"
657 netloc = hostportjoin(escaped_host, port)
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 "/"
663 fragment = None
664 params = "" # are they not there at all?
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))
672 def set_request_uri(self, uri, *, set_uri_host=True):
673 """Parse a given URI into the uri_* fields of the options.
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.
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.
687 This implements Section 6.4 of RFC7252.
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 """
695 try:
696 parsed = urllib.parse.urlparse(uri)
697 except ValueError as e:
698 raise error.MalformedUrlError from e
700 if parsed.fragment:
701 raise error.MalformedUrlError(
702 "Fragment identifiers can not be set on a request URI"
703 )
705 if not parsed.scheme:
706 raise error.IncompleteUrlError()
708 if parsed.scheme not in coap_schemes:
709 self.opt.proxy_uri = uri
710 return
712 if not parsed.hostname:
713 raise error.MalformedUrlError("CoAP URIs need a hostname")
715 if parsed.username or parsed.password:
716 raise error.MalformedUrlError("User name and password not supported.")
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
740 self.remote = UndecidedRemote(parsed.scheme, parsed.netloc)
742 try:
743 _ = parsed.port
744 except ValueError as e:
745 raise error.MalformedUrlError("Port must be numeric") from e
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 )
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
763 # Deprecated accessors to moved functionality
765 @property
766 def unresolved_remote(self):
767 return self.remote.hostinfo
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)
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
784 @requested_scheme.setter
785 def requested_scheme(self, value):
786 self.remote = UndecidedRemote(value, self.remote.hostinfo)
788 @property
789 def requested_proxy_uri(self):
790 return self.request.opt.proxy_uri
792 @property
793 def requested_hostinfo(self):
794 return self.request.opt.uri_host or self.request.unresolved_remote
796 @property
797 def requested_path(self):
798 return self.request.opt.uri_path
800 @property
801 def requested_query(self):
802 return self.request.opt.uri_query
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.
811 It describes scheme, hostname and port that were set in
812 :meth:`.set_request_uri()` or when setting a URI per Message constructor.
814 * :attr:`scheme`: The scheme string
815 * :attr:`hostinfo`: The authority component of the URI, as it would occur
816 in the URI.
818 Both in the constructor and in the repr, it also supports a single-value
819 form of a URI Origin.
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:
825 >>> UndecidedRemote("coap+tcp", "[::0001]:1234")
826 UndecidedRemote('coap+tcp://[::1]:1234')
827 >>> tuple(UndecidedRemote("coap", "localhost"))
828 ('coap', 'localhost')
829 """
831 # This is settable per instance, for other transports to pick it up.
832 maximum_block_size_exp = MAX_REGULAR_BLOCK_SIZE_EXP
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)
843 return super().__new__(cls, scheme, hostinfo)
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.")
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
856 >>> from aiocoap.message import UndecidedRemote
857 >>> UndecidedRemote.from_pathless_uri("coap://localhost")
858 UndecidedRemote('coap://localhost')
859 """
861 parsed = urllib.parse.urlparse(uri)
863 if parsed.username or parsed.password:
864 raise ValueError("User name and password not supported.")
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 )
871 return cls(parsed.scheme, parsed.netloc)
873 def __repr__(self):
874 return f"UndecidedRemote({f'{self.scheme}://{self.hostinfo}'!r})"
877_ascii_lowercase = str.maketrans(string.ascii_uppercase, string.ascii_lowercase)
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)
885class Direction(enum.Enum):
886 INCOMING = enum.auto()
887 OUTGOING = enum.auto()
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")