Coverage for aiocoap/message.py: 83%
337 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-30 11:17 +0000
« 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
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 hostportjoin, hostportsplit, Sentinel, quote_nonascii
23from .util.uri import quote_factory, unreserved, sub_delims
24from . import interfaces
26__all__ = ["Message", "NoResponse"]
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"]
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)
39class Message:
40 """CoAP Message with some handling metadata
42 This object's attributes provide access to the fields in a CoAP message and
43 can be directly manipulated.
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".
51 The attributes are:
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`.
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.
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.
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.
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`).
89 These properties are still available but deprecated:
91 * requested_*: Managed by the :class:`.protocol.Request` a response results
92 from, and filled with the request's URL data. Non-roundtrippable.
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.
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.)
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.
108 The four messages involved in an exchange
109 -----------------------------------------
111 ::
113 Requester Responder
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 +-------------+ +-------------+
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:
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 """
146 request: Message | None = None
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
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
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()
206 self.remote = None
207 self.direction: Direction = Direction.OUTGOING
209 self.transport_tuning = transport_tuning or TransportTuning()
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.")
215 if uri:
216 self.set_request_uri(uri)
218 for k, v in kwargs.items():
219 setattr(self.opt, k, v)
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"
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 ""
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 ""
241 return f"<aiocoap.Message: {self.code} {self.__fromto()}{options}{payload}, {token}{mtype}{mid}>"
243 def _repr_html_(self):
244 """An HTML representation for Jupyter and similar environments
246 While the precise format is not guaranteed, it will be usable through
247 tooltips and possibly fold-outs:
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
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()}"""
262 def payload_html(self):
263 """An HTML representation of the payload
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.
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
274 if not self.payload:
275 return "<p>No payload</p>"
276 else:
277 from . import defaults
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
284 (notes, mediatype, text) = pretty_print(self)
285 import pygments
286 from pygments.formatters import HtmlFormatter
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 )
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.
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)
323 if "uri" in kwargs:
324 new.set_request_uri(kwargs.pop("uri"))
326 for k, v in kwargs.items():
327 setattr(new.opt, k, v)
329 return new
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
352 def encode(self):
353 """Create binary representation of message from Message object."""
355 assert self.direction == Direction.OUTGOING
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
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).
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 """
401 options = []
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))
410 return (self.code, tuple(options))
412 #
413 # splitting and merging messages into and from message blocks
414 #
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
425 if start >= len(self.payload):
426 raise error.BadRequest("Block request out of bounds")
428 end = start + size if start + size < len(self.payload) else len(self.payload)
429 more = True if end < len(self.payload) else False
431 payload = self.payload[start:end]
432 blockopt = (number, more, size_exp)
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)
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.")
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()
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.")
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()
482 if next_block.opt.etag != self.opt.etag:
483 raise error.ResourceChanged()
485 self.payload += next_block.payload
486 self.opt.block2 = block2
487 self.token = next_block.token
488 self.mid = next_block.mid
490 def _generate_next_block2_request(self, response):
491 """Generate a sub-request for next response block.
493 This method is used by client after receiving blockwise response from
494 server with "more" flag set."""
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.
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 )
505 # has been checked in assembly, just making sure
506 assert blockopt.start == len(response.payload), (
507 "Unexpected state of preassembled message"
508 )
510 blockopt = blockopt.reduced_to(response.remote.maximum_block_size_exp)
512 return self.copy(
513 payload=b"",
514 mid=None,
515 token=None,
516 block2=blockopt,
517 block1=None,
518 observe=None,
519 )
521 def _generate_next_block1_response(self):
522 """Generate a response to acknowledge incoming request block.
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
543 #
544 # the message in the context of network and addresses
545 #
547 def get_request_uri(self, *, local_is_server=None):
548 """The absolute URI this message belongs to.
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.
556 This implements Section 6.5 of RFC7252.
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 """
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.
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
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 path = refmsg.opt.uri_path
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
638 if refmsg.opt.uri_host is not None or refmsg.opt.uri_port is not None:
639 host, port = hostportsplit(netloc)
641 host = refmsg.opt.uri_host or host
642 port = refmsg.opt.uri_port or port
644 # FIXME: This sounds like it should be part of
645 # hpostportjoin/-split
646 escaped_host = quote_nonascii(host)
648 # FIXME: "If host is not valid reg-name / IP-literal / IPv4address,
649 # fail"
651 netloc = hostportjoin(escaped_host, port)
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 "/"
657 fragment = None
658 params = "" # are they not there at all?
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))
666 def set_request_uri(self, uri, *, set_uri_host=True):
667 """Parse a given URI into the uri_* fields of the options.
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.
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.
681 This implements Section 6.4 of RFC7252.
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 """
689 try:
690 parsed = urllib.parse.urlparse(uri)
691 except ValueError as e:
692 raise error.MalformedUrlError from e
694 if parsed.fragment:
695 raise error.MalformedUrlError(
696 "Fragment identifiers can not be set on a request URI"
697 )
699 if not parsed.scheme:
700 raise error.IncompleteUrlError()
702 if parsed.scheme not in coap_schemes:
703 self.opt.proxy_uri = uri
704 return
706 if not parsed.hostname:
707 raise error.MalformedUrlError("CoAP URIs need a hostname")
709 if parsed.username or parsed.password:
710 raise error.MalformedUrlError("User name and password not supported.")
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
734 self.remote = UndecidedRemote(parsed.scheme, parsed.netloc)
736 try:
737 _ = parsed.port
738 except ValueError as e:
739 raise error.MalformedUrlError("Port must be numeric") from e
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 )
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
757 # Deprecated accessors to moved functionality
759 @property
760 def unresolved_remote(self):
761 return self.remote.hostinfo
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)
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
778 @requested_scheme.setter
779 def requested_scheme(self, value):
780 self.remote = UndecidedRemote(value, self.remote.hostinfo)
782 @property
783 def requested_proxy_uri(self):
784 return self.request.opt.proxy_uri
786 @property
787 def requested_hostinfo(self):
788 return self.request.opt.uri_host or self.request.unresolved_remote
790 @property
791 def requested_path(self):
792 return self.request.opt.uri_path
794 @property
795 def requested_query(self):
796 return self.request.opt.uri_query
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.
805 It describes scheme, hostname and port that were set in
806 :meth:`.set_request_uri()` or when setting a URI per Message constructor.
808 * :attr:`scheme`: The scheme string
809 * :attr:`hostinfo`: The authority component of the URI, as it would occur
810 in the URI.
812 Both in the constructor and in the repr, it also supports a single-value
813 form of a URI Origin.
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:
819 >>> UndecidedRemote("coap+tcp", "[::0001]:1234")
820 UndecidedRemote('coap+tcp://[::1]:1234')
821 >>> tuple(UndecidedRemote("coap", "localhost"))
822 ('coap', 'localhost')
823 """
825 # This is settable per instance, for other transports to pick it up.
826 maximum_block_size_exp = MAX_REGULAR_BLOCK_SIZE_EXP
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)
837 return super().__new__(cls, scheme, hostinfo)
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
844 >>> from aiocoap.message import UndecidedRemote
845 >>> UndecidedRemote.from_pathless_uri("coap://localhost")
846 UndecidedRemote('coap://localhost')
847 """
849 parsed = urllib.parse.urlparse(uri)
851 if parsed.username or parsed.password:
852 raise ValueError("User name and password not supported.")
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 )
859 return cls(parsed.scheme, parsed.netloc)
861 def __repr__(self):
862 return f"UndecidedRemote({f'{self.scheme}://{self.hostinfo}'!r})"
865_ascii_lowercase = str.maketrans(string.ascii_uppercase, string.ascii_lowercase)
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)
873class Direction(enum.Enum):
874 INCOMING = enum.auto()
875 OUTGOING = enum.auto()
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")