DPDK patches and discussions
 help / color / mirror / Atom feed
From: jspewock@iol.unh.edu
To: thomas@monjalon.net, yoan.picchi@foss.arm.com,
	paul.szczepanek@arm.com, Honnappa.Nagarahalli@arm.com,
	probb@iol.unh.edu, wathsala.vithanage@arm.com,
	Luca.Vizzarro@arm.com, juraj.linkes@pantheon.tech,
	npratte@iol.unh.edu, alex.chapman@arm.com
Cc: dev@dpdk.org, Jeremy Spewock <jspewock@iol.unh.edu>
Subject: [PATCH v3 1/1] dts: add text parser for testpmd verbose output
Date: Thu,  8 Aug 2024 16:36:12 -0400	[thread overview]
Message-ID: <20240808203612.329540-2-jspewock@iol.unh.edu> (raw)
In-Reply-To: <20240808203612.329540-1-jspewock@iol.unh.edu>

From: Jeremy Spewock <jspewock@iol.unh.edu>

Multiple test suites from the old DTS framework rely on being able to
consume and interpret the verbose output of testpmd. The new framework
doesn't have an elegant way for handling the verbose output, but test
suites are starting to be written that rely on it. This patch creates a
TextParser class that can be used to extract the verbose information
from any testpmd output and also adjusts the `stop` method of the shell
to return all output that it collected.

Signed-off-by: Jeremy Spewock <jspewock@iol.unh.edu>
---
 dts/framework/parser.py                       |  30 ++
 dts/framework/remote_session/testpmd_shell.py | 405 +++++++++++++++++-
 dts/framework/utils.py                        |   1 +
 3 files changed, 434 insertions(+), 2 deletions(-)

diff --git a/dts/framework/parser.py b/dts/framework/parser.py
index 741dfff821..0b39025a48 100644
--- a/dts/framework/parser.py
+++ b/dts/framework/parser.py
@@ -160,6 +160,36 @@ def _find(text: str) -> Any:
 
         return ParserFn(TextParser_fn=_find)
 
+    @staticmethod
+    def find_all(
+        pattern: str | re.Pattern[str],
+        flags: re.RegexFlag = re.RegexFlag(0),
+    ) -> ParserFn:
+        """Makes a parser function that finds all of the regular expression matches in the text.
+
+        If there are no matches found in the text than None will be returned, otherwise a list
+        containing all matches will be returned. Patterns that contain multiple groups will pack
+        the matches for each group into a tuple.
+
+        Args:
+            pattern: The regular expression pattern.
+            flags: The regular expression flags. Ignored if the given pattern is already compiled.
+
+        Returns:
+            A :class:`ParserFn` that can be used as metadata for a dataclass field.
+        """
+        if isinstance(pattern, str):
+            pattern = re.compile(pattern, flags)
+
+        def _find_all(text: str) -> list[str] | None:
+            m = pattern.findall(text)
+            if len(m) == 0:
+                return None
+
+            return m
+
+        return ParserFn(TextParser_fn=_find_all)
+
     @staticmethod
     def find_int(
         pattern: str | re.Pattern[str],
diff --git a/dts/framework/remote_session/testpmd_shell.py b/dts/framework/remote_session/testpmd_shell.py
index 43e9f56517..7d0b5a374c 100644
--- a/dts/framework/remote_session/testpmd_shell.py
+++ b/dts/framework/remote_session/testpmd_shell.py
@@ -31,7 +31,7 @@
 from framework.settings import SETTINGS
 from framework.testbed_model.cpu import LogicalCoreCount, LogicalCoreList
 from framework.testbed_model.sut_node import SutNode
-from framework.utils import StrEnum
+from framework.utils import REGEX_FOR_MAC_ADDRESS, StrEnum
 
 
 class TestPmdDevice:
@@ -577,6 +577,377 @@ class TestPmdPortStats(TextParser):
     tx_bps: int = field(metadata=TextParser.find_int(r"Tx-bps:\s+(\d+)"))
 
 
+class OLFlag(Flag):
+    """Flag representing the Packet Offload Features Flags in DPDK.
+
+    Values in this class are taken from the definitions in the RTE MBUF core library in DPDK.
+    """
+
+    # RX flags
+    #:
+    RTE_MBUF_F_RX_RSS_HASH = auto()
+
+    #:
+    RTE_MBUF_F_RX_L4_CKSUM_GOOD = auto()
+    #:
+    RTE_MBUF_F_RX_L4_CKSUM_BAD = auto()
+    #:
+    RTE_MBUF_F_RX_L4_CKSUM_UNKNOWN = auto()
+    #:
+    RTE_MBUF_F_RX_L4_CKSUM_NONE = auto()
+
+    #:
+    RTE_MBUF_F_RX_IP_CKSUM_GOOD = auto()
+    #:
+    RTE_MBUF_F_RX_IP_CKSUM_BAD = auto()
+    #:
+    RTE_MBUF_F_RX_IP_CKSUM_UNKNOWN = auto()
+    #:
+    RTE_MBUF_F_RX_IP_CKSUM_NONE = auto()
+
+    #:
+    RTE_MBUF_F_RX_OUTER_L4_CKSUM_GOOD = auto()
+    #:
+    RTE_MBUF_F_RX_OUTER_L4_CKSUM_BAD = auto()
+    #:
+    RTE_MBUF_F_RX_OUTER_L4_CKSUM_UNKNOWN = auto()
+    #:
+    RTE_MBUF_F_RX_OUTER_L4_CKSUM_INVALID = auto()
+
+    #:
+    RTE_MBUF_F_RX_VLAN = auto()
+    #:
+    RTE_MBUF_F_RX_FDIR = auto()
+    #:
+    RTE_MBUF_F_RX_OUTER_IP_CKSUM_BAD = auto()
+    #:
+    RTE_MBUF_F_RX_VLAN_STRIPPED = auto()
+    #: RX IEEE1588 L2 Ethernet PT Packet.
+    RTE_MBUF_F_RX_IEEE1588_PTP = auto()
+    #: RX IEEE1588 L2/L4 timestamped packet.
+    RTE_MBUF_F_RX_IEEE1588_TMST = auto()
+    #: FD id reported if FDIR match.
+    RTE_MBUF_F_RX_FDIR_ID = auto()
+    #: Flexible bytes reported if FDIR match.
+    RTE_MBUF_F_RX_FDIR_FLX = auto()
+    #:
+    RTE_MBUF_F_RX_QINQ_STRIPPED = auto()
+    #:
+    RTE_MBUF_F_RX_LRO = auto()
+    #:
+    RTE_MBUF_F_RX_SEC_OFFLOAD_FAILED = auto()
+    #:
+    RTE_MBUF_F_RX_QINQ = auto()
+
+    # TX flags
+    #:
+    RTE_MBUF_F_TX_OUTER_UDP_CKSUM = auto()
+    #:
+    RTE_MBUF_F_TX_UDP_SEG = auto()
+    #:
+    RTE_MBUF_F_TX_SEC_OFFLOAD = auto()
+    #:
+    RTE_MBUF_F_TX_MACSEC = auto()
+
+    #:
+    RTE_MBUF_F_TX_TUNNEL_VXLAN = auto()
+    #:
+    RTE_MBUF_F_TX_TUNNEL_GRE = auto()
+    #:
+    RTE_MBUF_F_TX_TUNNEL_IPIP = auto()
+    #:
+    RTE_MBUF_F_TX_TUNNEL_GENEVE = auto()
+    #:
+    RTE_MBUF_F_TX_TUNNEL_MPLSINUDP = auto()
+    #:
+    RTE_MBUF_F_TX_TUNNEL_VXLAN_GPE = auto()
+    #:
+    RTE_MBUF_F_TX_TUNNEL_GTP = auto()
+    #:
+    RTE_MBUF_F_TX_TUNNEL_ESP = auto()
+    #:
+    RTE_MBUF_F_TX_TUNNEL_IP = auto()
+    #:
+    RTE_MBUF_F_TX_TUNNEL_UDP = auto()
+
+    #:
+    RTE_MBUF_F_TX_QINQ = auto()
+    #:
+    RTE_MBUF_F_TX_TCP_SEG = auto()
+    #: TX IEEE1588 packet to timestamp.
+    RTE_MBUF_F_TX_IEEE1588_TMST = auto()
+
+    #:
+    RTE_MBUF_F_TX_L4_NO_CKSUM = auto()
+    #:
+    RTE_MBUF_F_TX_TCP_CKSUM = auto()
+    #:
+    RTE_MBUF_F_TX_SCTP_CKSUM = auto()
+    #:
+    RTE_MBUF_F_TX_UDP_CKSUM = auto()
+    #:
+    RTE_MBUF_F_TX_L4_MASK = auto()
+    #:
+    RTE_MBUF_F_TX_IP_CKSUM = auto()
+    #:
+    RTE_MBUF_F_TX_OUTER_IP_CKSUM = auto()
+
+    #:
+    RTE_MBUF_F_TX_IPV4 = auto()
+    #:
+    RTE_MBUF_F_TX_IPV6 = auto()
+    #:
+    RTE_MBUF_F_TX_VLAN = auto()
+    #:
+    RTE_MBUF_F_TX_OUTER_IPV4 = auto()
+    #:
+    RTE_MBUF_F_TX_OUTER_IPV6 = auto()
+
+    @classmethod
+    def from_str_list(cls, arr: list[str]) -> Self:
+        """Makes an instance from a list containing the flag members.
+
+        Args:
+            arr: A list of strings containing ol_flag values.
+
+        Returns:
+            A new instance of the flag.
+        """
+        flag = cls(0)
+        for name in arr:
+            if name in cls.__members__:
+                flag |= cls[name]
+        return flag
+
+    @classmethod
+    def make_parser(cls) -> ParserFn:
+        """Makes a parser function.
+
+        Returns:
+            ParserFn: A dictionary for the `dataclasses.field` metadata argument containing a
+                parser function that makes an instance of this flag from text.
+        """
+        return TextParser.wrap(
+            TextParser.wrap(TextParser.find(r"ol_flags: ([^\n]+)"), str.split),
+            cls.from_str_list,
+        )
+
+
+class RtePTypes(Flag):
+    """Flag representing possible packet types in DPDK verbose output."""
+
+    # L2
+    #:
+    L2_ETHER = auto()
+    #:
+    L2_ETHER_TIMESYNC = auto()
+    #:
+    L2_ETHER_ARP = auto()
+    #:
+    L2_ETHER_LLDP = auto()
+    #:
+    L2_ETHER_NSH = auto()
+    #:
+    L2_ETHER_VLAN = auto()
+    #:
+    L2_ETHER_QINQ = auto()
+    #:
+    L2_ETHER_PPPOE = auto()
+    #:
+    L2_ETHER_FCOE = auto()
+    #:
+    L2_ETHER_MPLS = auto()
+    #:
+    L2_UNKNOWN = auto()
+
+    # L3
+    #:
+    L3_IPV4 = auto()
+    #:
+    L3_IPV4_EXT = auto()
+    #:
+    L3_IPV6 = auto()
+    #:
+    L3_IPV4_EXT_UNKNOWN = auto()
+    #:
+    L3_IPV6_EXT = auto()
+    #:
+    L3_IPV6_EXT_UNKNOWN = auto()
+    #:
+    L3_UNKNOWN = auto()
+
+    # L4
+    #:
+    L4_TCP = auto()
+    #:
+    L4_UDP = auto()
+    #:
+    L4_FRAG = auto()
+    #:
+    L4_SCTP = auto()
+    #:
+    L4_ICMP = auto()
+    #:
+    L4_NONFRAG = auto()
+    #:
+    L4_IGMP = auto()
+    #:
+    L4_UNKNOWN = auto()
+
+    # Tunnel
+    #:
+    TUNNEL_IP = auto()
+    #:
+    TUNNEL_GRE = auto()
+    #:
+    TUNNEL_VXLAN = auto()
+    #:
+    TUNNEL_NVGRE = auto()
+    #:
+    TUNNEL_GENEVE = auto()
+    #:
+    TUNNEL_GRENAT = auto()
+    #:
+    TUNNEL_GTPC = auto()
+    #:
+    TUNNEL_GTPU = auto()
+    #:
+    TUNNEL_ESP = auto()
+    #:
+    TUNNEL_L2TP = auto()
+    #:
+    TUNNEL_VXLAN_GPE = auto()
+    #:
+    TUNNEL_MPLS_IN_UDP = auto()
+    #:
+    TUNNEL_MPLS_IN_GRE = auto()
+    #:
+    TUNNEL_UNKNOWN = auto()
+
+    # Inner L2
+    #:
+    INNER_L2_ETHER = auto()
+    #:
+    INNER_L2_ETHER_VLAN = auto()
+    #:
+    INNER_L2_ETHER_QINQ = auto()
+    #:
+    INNER_L2_UNKNOWN = auto()
+
+    # Inner L3
+    #:
+    INNER_L3_IPV4 = auto()
+    #:
+    INNER_L3_IPV4_EXT = auto()
+    #:
+    INNER_L3_IPV6 = auto()
+    #:
+    INNER_L3_IPV4_EXT_UNKNOWN = auto()
+    #:
+    INNER_L3_IPV6_EXT = auto()
+    #:
+    INNER_L3_IPV6_EXT_UNKNOWN = auto()
+    #:
+    INNER_L3_UNKNOWN = auto()
+
+    # Inner L4
+    #:
+    INNER_L4_TCP = auto()
+    #:
+    INNER_L4_UDP = auto()
+    #:
+    INNER_L4_FRAG = auto()
+    #:
+    INNER_L4_SCTP = auto()
+    #:
+    INNER_L4_ICMP = auto()
+    #:
+    INNER_L4_NONFRAG = auto()
+    #:
+    INNER_L4_UNKNOWN = auto()
+
+    @classmethod
+    def from_str_list(cls, arr: list[str]) -> Self:
+        """Makes an instance from a list containing the flag members.
+
+        Args:
+            arr: A list of strings containing ol_flag values.
+
+        Returns:
+            A new instance of the flag.
+        """
+        flag = cls(0)
+        for name in arr:
+            if name in cls.__members__:
+                flag |= cls[name]
+        return flag
+
+    @classmethod
+    def make_parser(cls, hw: bool) -> ParserFn:
+        """Makes a parser function.
+
+        Args:
+            hw: Whether to make a parser for hardware ptypes or software ptypes. If :data:`True`
+                hardware ptypes will be collected, otherwise software pytpes will.
+
+        Returns:
+            ParserFn: A dictionary for the `dataclasses.field` metadata argument containing a
+                parser function that makes an instance of this flag from text.
+        """
+        return TextParser.wrap(
+            TextParser.wrap(TextParser.find(f"{'hw' if hw else 'sw'} ptype: ([^-]+)"), str.split),
+            cls.from_str_list,
+        )
+
+
+@dataclass
+class TestPmdVerbosePacket(TextParser):
+    """Packet information provided by verbose output in Testpmd.
+
+    This dataclass expects that packet information be prepended with the starting line of packet
+    bursts. Specifically, the line that reads "port X/queue Y: sent/received Z packets".
+    """
+
+    #: ID of the port that handled the packet.
+    port_id: int = field(metadata=TextParser.find_int(r"port (\d+)/queue \d+"))
+    #: ID of the queue that handled the packet.
+    queue_id: int = field(metadata=TextParser.find_int(r"port \d+/queue (\d+)"))
+    #: Whether the packet was received or sent by the queue/port.
+    was_received: bool = field(metadata=TextParser.find(r"received \d+ packets"))
+    #:
+    src_mac: str = field(metadata=TextParser.find(f"src=({REGEX_FOR_MAC_ADDRESS})"))
+    #:
+    dst_mac: str = field(metadata=TextParser.find(f"dst=({REGEX_FOR_MAC_ADDRESS})"))
+    #: Memory pool the packet was handled on.
+    pool: str = field(metadata=TextParser.find(r"pool=(\S+)"))
+    #: Packet type in hex.
+    p_type: int = field(metadata=TextParser.find_int(r"type=(0x[a-fA-F\d]+)"))
+    #:
+    length: int = field(metadata=TextParser.find_int(r"length=(\d+)"))
+    #: Number of segments in the packet.
+    nb_segs: int = field(metadata=TextParser.find_int(r"nb_segs=(\d+)"))
+    #: Hardware packet type.
+    hw_ptype: RtePTypes = field(metadata=RtePTypes.make_parser(hw=True))
+    #: Software packet type.
+    sw_ptype: RtePTypes = field(metadata=RtePTypes.make_parser(hw=False))
+    #:
+    l2_len: int = field(metadata=TextParser.find_int(r"l2_len=(\d+)"))
+    #:
+    ol_flags: OLFlag = field(metadata=OLFlag.make_parser())
+    #: RSS has of the packet in hex.
+    rss_hash: int | None = field(
+        default=None, metadata=TextParser.find_int(r"RSS hash=(0x[a-fA-F\d]+)")
+    )
+    #: RSS queue that handled the packet in hex.
+    rss_queue: int | None = field(
+        default=None, metadata=TextParser.find_int(r"RSS queue=(0x[a-fA-F\d]+)")
+    )
+    #:
+    l3_len: int | None = field(default=None, metadata=TextParser.find_int(r"l3_len=(\d+)"))
+    #:
+    l4_len: int | None = field(default=None, metadata=TextParser.find_int(r"l4_len=(\d+)"))
+
+
 class TestPmdShell(DPDKShell):
     """Testpmd interactive shell.
 
@@ -645,7 +1016,7 @@ def start(self, verify: bool = True) -> None:
                         "Not all ports came up after starting packet forwarding in testpmd."
                     )
 
-    def stop(self, verify: bool = True) -> None:
+    def stop(self, verify: bool = True) -> str:
         """Stop packet forwarding.
 
         Args:
@@ -656,6 +1027,9 @@ def stop(self, verify: bool = True) -> None:
         Raises:
             InteractiveCommandExecutionError: If `verify` is :data:`True` and the command to stop
                 forwarding results in an error.
+
+        Returns:
+            Output gathered from sending the stop command.
         """
         stop_cmd_output = self.send_command("stop")
         if verify:
@@ -665,6 +1039,7 @@ def stop(self, verify: bool = True) -> None:
             ):
                 self._logger.debug(f"Failed to stop packet forwarding: \n{stop_cmd_output}")
                 raise InteractiveCommandExecutionError("Testpmd failed to stop packet forwarding.")
+        return stop_cmd_output
 
     def get_devices(self) -> list[TestPmdDevice]:
         """Get a list of device names that are known to testpmd.
@@ -806,6 +1181,32 @@ def show_port_stats(self, port_id: int) -> TestPmdPortStats:
 
         return TestPmdPortStats.parse(output)
 
+    @staticmethod
+    def extract_verbose_output(output: str) -> list[TestPmdVerbosePacket]:
+        """Extract the verbose information present in given testpmd output.
+
+        This method extracts sections of verbose output that begin with the line
+        "port X/queue Y: sent/received Z packets" and end with the ol_flags of a packet.
+
+        Args:
+            output: Testpmd output that contains verbose information
+
+        Returns:
+            List of parsed packet information gathered from verbose information in `output`.
+        """
+        out: list[TestPmdVerbosePacket] = []
+        prev_header: str = ""
+        iter = re.finditer(
+            r"(?P<HEADER>(?:port \d+/queue \d+: received \d packets)?)\s*"
+            r"(?P<PACKET>src=[\w\s=:-]+?ol_flags: [\w ]+)",
+            output,
+        )
+        for match in iter:
+            if match.group("HEADER"):
+                prev_header = match.group("HEADER")
+            out.append(TestPmdVerbosePacket.parse(f"{prev_header}\n{match.group('PACKET')}"))
+        return out
+
     def _close(self) -> None:
         """Overrides :meth:`~.interactive_shell.close`."""
         self.stop()
diff --git a/dts/framework/utils.py b/dts/framework/utils.py
index 6b5d5a805f..9c64cf497f 100644
--- a/dts/framework/utils.py
+++ b/dts/framework/utils.py
@@ -27,6 +27,7 @@
 from .exception import ConfigurationError
 
 REGEX_FOR_PCI_ADDRESS: str = "/[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}.[0-9]{1}/"
+REGEX_FOR_MAC_ADDRESS: str = r"(?:[\da-fA-F]{2}:){5}[\da-fA-F]{2}"
 
 
 def expand_range(range_str: str) -> list[int]:
-- 
2.45.2


  reply	other threads:[~2024-08-08 20:36 UTC|newest]

Thread overview: 39+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2024-07-29 20:39 [PATCH v1 0/1] dts: testpmd verbose parser jspewock
2024-07-29 20:39 ` [PATCH v1 1/1] dts: add text parser for testpmd verbose output jspewock
2024-07-30 13:34 ` [PATCH v2 0/1] dts: testpmd verbose parser jspewock
2024-07-30 13:34   ` [PATCH v2 1/1] dts: add text parser for testpmd verbose output jspewock
2024-07-30 15:41     ` Nicholas Pratte
2024-07-30 21:30       ` Jeremy Spewock
2024-08-02 14:54         ` Nicholas Pratte
2024-08-02 17:38           ` Jeremy Spewock
2024-08-05 13:20             ` Nicholas Pratte
2024-07-30 21:33     ` Jeremy Spewock
2024-08-01  8:43       ` Luca Vizzarro
2024-08-02 13:40         ` Jeremy Spewock
2024-08-01  8:41     ` Luca Vizzarro
2024-08-02 13:35       ` Jeremy Spewock
2024-08-08 20:36 ` [PATCH v3 0/1] dts: testpmd verbose parser jspewock
2024-08-08 20:36   ` jspewock [this message]
2024-08-08 21:49     ` [PATCH v3 1/1] dts: add text parser for testpmd verbose output Jeremy Spewock
2024-08-12 17:32       ` Nicholas Pratte
2024-09-09 11:44     ` Juraj Linkeš
2024-09-17 13:40       ` Jeremy Spewock
2024-09-18  8:09         ` Juraj Linkeš
2024-09-18 16:34 ` [PATCH v4 0/1] dts: testpmd verbose parser jspewock
2024-09-18 16:34   ` [PATCH v4 1/1] dts: add text parser for testpmd verbose output jspewock
2024-09-18 17:05 ` [PATCH v5 0/1] dts: testpmd verbose parser jspewock
2024-09-18 17:05   ` [PATCH v5 1/1] dts: add text parser for testpmd verbose output jspewock
2024-09-19  9:02     ` Juraj Linkeš
2024-09-20 15:53       ` Jeremy Spewock
2024-09-23 13:30         ` Juraj Linkeš
2024-09-19 12:35     ` Juraj Linkeš
2024-09-20 15:55       ` Jeremy Spewock
2024-09-25 15:46 ` [PATCH v6 0/1] dts: testpmd verbose parser jspewock
2024-09-25 15:46   ` [PATCH v6 1/1] dts: add text parser for testpmd verbose output jspewock
2024-09-26  8:25     ` Juraj Linkeš
2024-09-26 14:43       ` Jeremy Spewock
2024-09-26 15:47 ` [PATCH v7 0/1] dts: testpmd verbose parser jspewock
2024-09-26 15:47   ` [PATCH v7 1/1] dts: add text parser for testpmd verbose output jspewock
2024-09-27  9:32     ` Juraj Linkeš
2024-09-27 11:48     ` Luca Vizzarro
2024-09-30 13:41     ` Juraj Linkeš

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20240808203612.329540-2-jspewock@iol.unh.edu \
    --to=jspewock@iol.unh.edu \
    --cc=Honnappa.Nagarahalli@arm.com \
    --cc=Luca.Vizzarro@arm.com \
    --cc=alex.chapman@arm.com \
    --cc=dev@dpdk.org \
    --cc=juraj.linkes@pantheon.tech \
    --cc=npratte@iol.unh.edu \
    --cc=paul.szczepanek@arm.com \
    --cc=probb@iol.unh.edu \
    --cc=thomas@monjalon.net \
    --cc=wathsala.vithanage@arm.com \
    --cc=yoan.picchi@foss.arm.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for NNTP newsgroup(s).