scripts: runners: rework RunnerCaps implementation

This provides miscellaneous quality of life improvements:

- We couldn't use dataclasses when this class was originally written.
  We can now, so move to dataclass to avoid having to write
  __repr__().

- Add missing validation for the advertised commands

- Add missing documentation for the 'file' capability

Signed-off-by: Martí Bolívar <mbolivar@amperecomputing.com>
diff --git a/scripts/west_commands/runners/core.py b/scripts/west_commands/runners/core.py
index 68ad0ee..c1f9eb7 100644
--- a/scripts/west_commands/runners/core.py
+++ b/scripts/west_commands/runners/core.py
@@ -22,6 +22,7 @@
 import signal
 import subprocess
 import re
+from dataclasses import dataclass, field
 from functools import partial
 from enum import Enum
 from inspect import isabstract
@@ -199,6 +200,9 @@
         super().__init__(errno.ENOENT, os.strerror(errno.ENOENT), program)
 
 
+_RUNNERCAPS_COMMANDS = {'flash', 'debug', 'debugserver', 'attach'}
+
+@dataclass
 class RunnerCaps:
     '''This class represents a runner class's capabilities.
 
@@ -235,34 +239,23 @@
     - tool_opt: whether the runner supports a --tool-opt (-O) option, which
       can be given multiple times and is passed on to the underlying tool
       that the runner wraps.
+
+    - file: whether the runner supports a --file option, which specifies
+      exactly the file that should be used to flash, overriding any default
+      discovered in the build directory.
     '''
 
-    def __init__(self,
-                 commands: Set[str] = {'flash', 'debug',
-                                       'debugserver', 'attach'},
-                 dev_id: bool = False,
-                 flash_addr: bool = False,
-                 erase: bool = False,
-                 reset: bool = False,
-                 tool_opt: bool = False,
-                 file: bool = False):
-        self.commands = commands
-        self.dev_id = dev_id
-        self.flash_addr = bool(flash_addr)
-        self.erase = bool(erase)
-        self.reset = bool(reset)
-        self.tool_opt = bool(tool_opt)
-        self.file = bool(file)
+    commands: Set[str] = field(default_factory=lambda: set(_RUNNERCAPS_COMMANDS))
+    dev_id: bool = False
+    flash_addr: bool = False
+    erase: bool = False
+    reset: bool = False
+    tool_opt: bool = False
+    file: bool = False
 
-    def __str__(self):
-        return (f'RunnerCaps(commands={self.commands}, '
-                f'dev_id={self.dev_id}, '
-                f'flash_addr={self.flash_addr}, '
-                f'erase={self.erase}, '
-                f'reset={self.reset}, '
-                f'tool_opt={self.tool_opt}, '
-                f'file={self.file}'
-                ')')
+    def __post_init__(self):
+        if not self.commands.issubset(_RUNNERCAPS_COMMANDS):
+            raise ValueError(f'{self.commands=} contains invalid command')
 
 
 def _missing_cap(cls: Type['ZephyrBinaryRunner'], option: str) -> NoReturn: