mreg-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. mreg_cli/__about__.py +7 -0
  2. mreg_cli/__init__.py +5 -0
  3. mreg_cli/__main__.py +8 -0
  4. mreg_cli/_version.py +16 -0
  5. mreg_cli/api/__init__.py +7 -0
  6. mreg_cli/api/abstracts.py +486 -0
  7. mreg_cli/api/endpoints.py +130 -0
  8. mreg_cli/api/fields.py +104 -0
  9. mreg_cli/api/history.py +152 -0
  10. mreg_cli/api/models.py +3416 -0
  11. mreg_cli/cli.py +382 -0
  12. mreg_cli/commands/__init__.py +1 -0
  13. mreg_cli/commands/base.py +62 -0
  14. mreg_cli/commands/dhcp.py +136 -0
  15. mreg_cli/commands/group.py +326 -0
  16. mreg_cli/commands/help.py +77 -0
  17. mreg_cli/commands/host.py +54 -0
  18. mreg_cli/commands/host_submodules/__init__.py +28 -0
  19. mreg_cli/commands/host_submodules/a_aaaa.py +452 -0
  20. mreg_cli/commands/host_submodules/bacnet.py +126 -0
  21. mreg_cli/commands/host_submodules/cname.py +166 -0
  22. mreg_cli/commands/host_submodules/core.py +507 -0
  23. mreg_cli/commands/host_submodules/rr.py +973 -0
  24. mreg_cli/commands/label.py +146 -0
  25. mreg_cli/commands/logging.py +112 -0
  26. mreg_cli/commands/network.py +516 -0
  27. mreg_cli/commands/permission.py +202 -0
  28. mreg_cli/commands/policy.py +519 -0
  29. mreg_cli/commands/recording.py +59 -0
  30. mreg_cli/commands/registry.py +56 -0
  31. mreg_cli/commands/root.py +58 -0
  32. mreg_cli/commands/zone.py +288 -0
  33. mreg_cli/config.py +253 -0
  34. mreg_cli/errorbuilder.py +193 -0
  35. mreg_cli/exceptions.py +237 -0
  36. mreg_cli/help_formatter.py +38 -0
  37. mreg_cli/main.py +238 -0
  38. mreg_cli/outputmanager.py +466 -0
  39. mreg_cli/py.typed +0 -0
  40. mreg_cli/tags.txt +55 -0
  41. mreg_cli/tokenfile.py +89 -0
  42. mreg_cli/types.py +160 -0
  43. mreg_cli/utilities/__init__.py +5 -0
  44. mreg_cli/utilities/api.py +595 -0
  45. mreg_cli/utilities/shared.py +65 -0
  46. mreg_cli/utilities/validators.py +19 -0
  47. mreg_cli-1.0.0.dist-info/AUTHORS +12 -0
  48. mreg_cli-1.0.0.dist-info/LICENSE +674 -0
  49. mreg_cli-1.0.0.dist-info/METADATA +1079 -0
  50. mreg_cli-1.0.0.dist-info/RECORD +53 -0
  51. mreg_cli-1.0.0.dist-info/WHEEL +5 -0
  52. mreg_cli-1.0.0.dist-info/entry_points.txt +2 -0
  53. mreg_cli-1.0.0.dist-info/top_level.txt +1 -0
mreg_cli/cli.py ADDED
@@ -0,0 +1,382 @@
1
+ """Command line interface for mreg.
2
+
3
+ This file contains the main CLI class and the top level parser.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import html
10
+ import logging
11
+ import os
12
+ import shlex
13
+ import sys
14
+ from collections.abc import Generator
15
+ from typing import TYPE_CHECKING, Any, Protocol
16
+
17
+ from prompt_toolkit import HTML, document, print_formatted_text
18
+ from prompt_toolkit.completion import CompleteEvent, Completer, Completion
19
+
20
+ # Import all the commands
21
+ from mreg_cli.commands.dhcp import DHCPCommands
22
+ from mreg_cli.commands.group import GroupCommands
23
+ from mreg_cli.commands.help import HelpCommands
24
+ from mreg_cli.commands.host import HostCommands
25
+ from mreg_cli.commands.label import LabelCommands
26
+ from mreg_cli.commands.logging import LoggingCommmands
27
+ from mreg_cli.commands.network import NetworkCommands
28
+ from mreg_cli.commands.permission import PermissionCommands
29
+ from mreg_cli.commands.policy import PolicyCommands
30
+ from mreg_cli.commands.recording import RecordingCommmands
31
+ from mreg_cli.commands.root import RootCommmands
32
+ from mreg_cli.commands.zone import ZoneCommands
33
+
34
+ # Import other mreg_cli modules
35
+ from mreg_cli.exceptions import CliError, CliExit, CliWarning
36
+ from mreg_cli.help_formatter import CustomHelpFormatter
37
+ from mreg_cli.outputmanager import OutputManager
38
+ from mreg_cli.types import CommandFunc, Flag
39
+ from mreg_cli.utilities.api import create_and_set_corrolation_id
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ if TYPE_CHECKING:
44
+ # Can't use _SubParsersAction as generic in Python <3.9
45
+ SubparserType = argparse._SubParsersAction[argparse.ArgumentParser] # type: ignore # private attribute
46
+
47
+ # Subclasses of BaseCommand change the constructor signature from
48
+ # 6 arguments to a single argument `cli`. That means we cannot
49
+ # declare the list of commands as a list of `type[BaseCommand]`
50
+ # and then instantiate them. Instead, we need to declare this interface
51
+ # which lets the type checker understand what kind of classes we are
52
+ # trying to instantiate.
53
+ class BaseCommandSubclass(Protocol): # noqa: D101 (undocumented-public-class)
54
+ def __init__(self, cli: "Command") -> None: ... # noqa: D107 (undocumented-public-init)
55
+ def register_all_commands(self) -> None: ... # noqa: D102 (undocumented-public-method)
56
+
57
+
58
+ def _create_command_group(parent: argparse.ArgumentParser) -> SubparserType:
59
+ """Create a sub parser for a command."""
60
+ parent_name = parent.prog.strip()
61
+
62
+ if parent_name:
63
+ title = "subcommands"
64
+ else:
65
+ title = "commands"
66
+
67
+ metavar = "<command>"
68
+ description = "Run '{}' for more details".format(
69
+ " ".join(word for word in (parent_name, metavar, "-h") if word)
70
+ )
71
+
72
+ return parent.add_subparsers(
73
+ title=title,
74
+ description=description,
75
+ metavar=metavar,
76
+ )
77
+
78
+
79
+ class Command(Completer):
80
+ """Command class for the CLI.
81
+
82
+ Wrapper around argparse.ArgumentParser and prompt_toolkit.
83
+ """
84
+
85
+ # Used to detect an error when running commands from a source file.
86
+ last_errno: str | int | None = 0
87
+
88
+ def __init__(self, parser: argparse.ArgumentParser, flags: list[Flag], short_desc: str):
89
+ """Initialize a Command object."""
90
+ self.parser = parser
91
+ # sub is an object used for creating sub parser for this command. A
92
+ # command/ArgParser can only have one of this object.
93
+ self.sub: SubparserType | None = None
94
+
95
+ self.short_desc = short_desc
96
+ self.children: dict[str, Command] = {}
97
+ self.flags: dict[str, Flag] = {}
98
+ for flag in flags:
99
+ if flag.name.startswith("-"):
100
+ self.flags[flag.name.lstrip("-")] = flag
101
+
102
+ def add_command(
103
+ self,
104
+ prog: str,
105
+ description: str,
106
+ short_desc: str = "",
107
+ epilog: str | None = None,
108
+ callback: CommandFunc | None = None,
109
+ flags: list[Flag] | None = None,
110
+ ):
111
+ """Add a command to the current parser.
112
+
113
+ :param flags: a list of Flag objects. NB: must be handled as read-only,
114
+ since the default value is [].
115
+ :return: the Command object of the new command.
116
+ """
117
+ if flags is None:
118
+ flags = []
119
+ if self.sub is None:
120
+ self.sub = _create_command_group(self.parser)
121
+ parser = self.sub.add_parser(prog, description=description, epilog=epilog, help=short_desc)
122
+ parser.formatter_class = CustomHelpFormatter
123
+ for f in flags:
124
+ # Need to create a dict with the parameters so only used
125
+ # parameters are sent, or else exceptions are raised. Ex: if
126
+ # required is passed with an argument which doesn't accept the
127
+ # required option.
128
+ args: dict[str, Any] = {
129
+ "help": f.description,
130
+ }
131
+ if f.type:
132
+ args["type"] = f.type
133
+ if f.nargs:
134
+ args["nargs"] = f.nargs
135
+ if f.default:
136
+ args["default"] = f.default
137
+ if f.choices:
138
+ args["choices"] = f.choices
139
+ if f.required:
140
+ args["required"] = f.required
141
+ if f.metavar:
142
+ args["metavar"] = f.metavar
143
+ if f.action:
144
+ args["action"] = f.action
145
+ parser.add_argument(f.name, **args)
146
+ parser.set_defaults(func=callback)
147
+
148
+ new_cmd = Command(parser, flags, short_desc)
149
+ self.children[prog] = new_cmd
150
+ return new_cmd
151
+
152
+ def parse(self, command: str) -> None:
153
+ """Parse and execute a command."""
154
+ logger.debug(f"Parsing command: {command}")
155
+ args = shlex.split(command, comments=True)
156
+
157
+ try:
158
+ parsed_args = self.parser.parse_args(args)
159
+ # If the command has a callback function, call it.
160
+ if hasattr(parsed_args, "func") and parsed_args.func:
161
+ parsed_args.func(parsed_args)
162
+
163
+ except SystemExit as e:
164
+ # This is a super-hacky workaround to implement a REPL app using
165
+ # argparse; Argparse calls sys.exit when it detects an error or
166
+ # after it prints a help msg.
167
+ self.last_errno = e.code
168
+
169
+ except (CliWarning, CliError) as exc:
170
+ exc.print_self()
171
+
172
+ except CliExit:
173
+ # If we have active recordings going on, save them before exiting
174
+ OutputManager().recording_stop()
175
+ sys.exit(0)
176
+
177
+ else:
178
+ # If no exception occurred make sure errno isn't set to an error
179
+ # code.
180
+ self.last_errno = 0
181
+
182
+ # We ignore ARG0002 (unused-argument) because the method signature is
183
+ # required by the Completer class.
184
+ def get_completions(
185
+ self,
186
+ document: document.Document,
187
+ complete_event: CompleteEvent, # noqa: ARG002
188
+ ) -> Generator[Completion | Any, Any, None]:
189
+ """Prepare completions for the current command.
190
+
191
+ :param document: The current document.
192
+ :param complete_event: The current complete event.
193
+
194
+ :yields: Completions for the current command.
195
+ """
196
+ cur = document.get_word_before_cursor()
197
+ words = document.text.strip().split(" ")
198
+ yield from self.complete(cur, words)
199
+
200
+ def complete(self, cur: str, words: list[str]) -> Generator[Completion | Any, Any, None]:
201
+ """Generate completions during typing.
202
+
203
+ :param cur: The current word.
204
+ :param words: The current line split into words.
205
+ """
206
+ # if line is empty suggest all sub commands
207
+ if not words:
208
+ for name in self.children:
209
+ yield Completion(name, display_meta=self.children[name].short_desc)
210
+ return
211
+
212
+ # only suggest sub commands if there's only one word on the line.
213
+ # this behavior might cause a sub command to be suggested when typing
214
+ # an unflagged argument, but it avoids suggestion of sub commands
215
+ # purely based on current word, which would cause suggestions of sub
216
+ # commands at any time when typing a matching word.
217
+ # NOTE: must not return at end of block or else there'll be a bug when
218
+ # the first (and only) word is a flag.
219
+ if len(words) < 2:
220
+ for name in self.children:
221
+ if name.startswith(cur) and cur:
222
+ yield Completion(
223
+ name,
224
+ display_meta=self.children[name].short_desc,
225
+ start_position=-len(cur),
226
+ )
227
+
228
+ # if the line starts with one of the sub commands, pass it along
229
+ if words[0] in self.children:
230
+ yield from self.children[words[0]].complete(cur, words[1:])
231
+ return
232
+
233
+ # If none of the above then check if some of the flags match
234
+
235
+ # If current word is empty then no flag is suggested
236
+ if not cur:
237
+ return
238
+ # If the current word is - then it is the beginning of a flag
239
+ if cur == "-":
240
+ cur = ""
241
+ # If current word doesn't start with - then it isn't a flag being typed
242
+ elif ("-" + cur) not in words:
243
+ return
244
+
245
+ # complete flags which aren't already used
246
+ for name in self.flags:
247
+ if ("-" + name) not in words:
248
+ if name.startswith(cur):
249
+ yield Completion(
250
+ name,
251
+ display_meta=self.flags[name].short_desc,
252
+ start_position=-len(cur),
253
+ )
254
+
255
+ def process_command_line(self, line: str) -> None:
256
+ """Process a line containing a command."""
257
+ # OutputManager is a singleton class so we
258
+ # need to clear it before each command.
259
+ output = OutputManager()
260
+ output.clear()
261
+ # Set the command that generated the output
262
+ # Also remove filters and other noise.
263
+ try:
264
+ cmd = output.from_command(line)
265
+ except (CliWarning, CliError) as exc:
266
+ exc.print_self()
267
+ return
268
+ # Create and set the corrolation id, using the cleaned command
269
+ # as the suffix. This is used to track the command in the logs
270
+ # on the server side.
271
+ create_and_set_corrolation_id(cmd)
272
+ # Run the command
273
+ cli.parse(cmd)
274
+ # Render the output
275
+ output.render()
276
+
277
+
278
+ # Top parser is the root of all the command parsers
279
+ _top_parser = argparse.ArgumentParser(formatter_class=CustomHelpFormatter)
280
+ cli = Command(_top_parser, list(), "")
281
+ commands: list[type[BaseCommandSubclass]] = [
282
+ DHCPCommands,
283
+ GroupCommands,
284
+ HelpCommands,
285
+ HostCommands,
286
+ NetworkCommands,
287
+ PermissionCommands,
288
+ PolicyCommands,
289
+ ZoneCommands,
290
+ LabelCommands,
291
+ RecordingCommmands,
292
+ LoggingCommmands,
293
+ RootCommmands,
294
+ ]
295
+ for command in commands:
296
+ command(cli).register_all_commands()
297
+
298
+
299
+ def source(files: list[str], ignore_errors: bool, verbose: bool) -> Generator[str, None, None]:
300
+ """Read commands from one or more source files and yield them.
301
+
302
+ :param files: List of file paths to read commands from.
303
+ :param ignore_errors: If True, continue on errors.
304
+ :param verbose: If True, print commands before execution.
305
+
306
+ :yields: Command lines from the files.
307
+ """
308
+ for filename in files:
309
+ if filename.startswith("~"):
310
+ filename = os.path.expanduser(filename)
311
+ try:
312
+ with open(filename) as f:
313
+ logger.info("Reading commands from %s", filename)
314
+ for i, line in enumerate(f):
315
+ # Shell commands can be called from scripts. They start with '!'
316
+ if line.startswith("!"):
317
+ os.system(line[1:])
318
+ continue
319
+
320
+ if verbose:
321
+ print_formatted_text(HTML(f"<i>> {html.escape(line.strip())}</i>"))
322
+
323
+ yield line
324
+
325
+ if cli.last_errno != 0:
326
+ col = "ansired"
327
+ print_formatted_text(
328
+ HTML(f"<{col}><i>{filename}</i>: Error on line {i + 1}</{col}>")
329
+ )
330
+ OutputManager().add_error(f"{filename}: Error on line {i + 1}")
331
+ if not ignore_errors:
332
+ return
333
+ except FileNotFoundError:
334
+ print_formatted_text(f"No such file: '{filename}'")
335
+ logger.error(f"File not found during source: '{filename}'")
336
+ except PermissionError:
337
+ print_formatted_text(f"Permission denied: '{filename}'")
338
+ logger.error(f"Permission denied during source: '{filename}'")
339
+
340
+
341
+ # Always need the source command. This is located here due to the deep interaction with
342
+ # the cli variable itself and the command processing. Moving this out to another file
343
+ # would lead to circular imports in all its joy.
344
+ def _source(args: argparse.Namespace):
345
+ """Source command for the CLI.
346
+
347
+ :param args: The arguments passed to the command.
348
+ """
349
+ for command in source(args.files, args.ignore_errors, args.verbose):
350
+ # Process each command here as needed, similar to the main loop
351
+ print(f"Processing command: {command}")
352
+ cli.process_command_line(command)
353
+
354
+
355
+ cli.add_command(
356
+ prog="source",
357
+ description="Read and run commands from the given source files.",
358
+ short_desc="Run commands from file(s)",
359
+ callback=_source,
360
+ flags=[
361
+ Flag(
362
+ "files",
363
+ description=(
364
+ "Source files to read commands from. Commands are "
365
+ "separated with new lines and comments are started "
366
+ 'with "#"'
367
+ ),
368
+ short_desc="File names",
369
+ nargs="+",
370
+ metavar="SOURCE",
371
+ ),
372
+ Flag(
373
+ "-ignore-errors",
374
+ description=(
375
+ "Continue command execution on error. Default is to stop execution on error."
376
+ ),
377
+ short_desc="Stop on error.",
378
+ action="store_true",
379
+ ),
380
+ Flag("-verbose", description="Verbose output.", action="store_true"),
381
+ ],
382
+ )
@@ -0,0 +1 @@
1
+ """Command modules for mreg_cli."""
@@ -0,0 +1,62 @@
1
+ """Base command class for mreg_cli.
2
+
3
+ This module provides the :py:class:`BaseCommands` class, which is used as the
4
+ base class for all CLI command classes.
5
+
6
+ Note: Logging in this file is hard as logging is not configured when this is loaded.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from mreg_cli.commands.registry import CommandRegistry
14
+ from mreg_cli.types import CommandFunc, Flag
15
+
16
+
17
+ class BaseCommand:
18
+ """Base class for CLI commands."""
19
+
20
+ def __init__(
21
+ self,
22
+ cli: Any,
23
+ command_registry: CommandRegistry,
24
+ command_name: str,
25
+ description: str,
26
+ short_desc: str | None,
27
+ callback: CommandFunc | None = None,
28
+ ) -> None:
29
+ """Initialize the command class."""
30
+ self.base_cli = cli
31
+ self.command_registry = command_registry
32
+ if not command_registry.root:
33
+ self.scope = cli.add_command(
34
+ prog=command_name,
35
+ description=description,
36
+ short_desc=short_desc or description,
37
+ callback=callback,
38
+ )
39
+ else:
40
+ self.scope = cli
41
+
42
+ def register_all_commands(self) -> None:
43
+ """Register all commands currently in the registry."""
44
+ for prog, description, short_desc, callback, flags in self.command_registry.get_commands():
45
+ self.add_command(prog, description, short_desc, callback, flags)
46
+
47
+ def add_command(
48
+ self,
49
+ prog: str,
50
+ description: str,
51
+ short_desc: str,
52
+ callback: CommandFunc,
53
+ flags: list[Flag] | None = None,
54
+ ) -> None:
55
+ """Add a command to the registry."""
56
+ self.scope.add_command(
57
+ prog=prog,
58
+ description=description,
59
+ short_desc=short_desc,
60
+ callback=callback,
61
+ flags=flags,
62
+ )
@@ -0,0 +1,136 @@
1
+ """DHCP commands for mreg_cli."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from typing import Any
7
+
8
+ from mreg_cli.api.fields import IPAddressField
9
+ from mreg_cli.api.models import Host, IPAddress
10
+ from mreg_cli.commands.base import BaseCommand
11
+ from mreg_cli.commands.registry import CommandRegistry
12
+ from mreg_cli.exceptions import EntityOwnershipMismatch, InputFailure
13
+ from mreg_cli.outputmanager import OutputManager
14
+ from mreg_cli.types import Flag
15
+
16
+ command_registry = CommandRegistry()
17
+
18
+
19
+ class DHCPCommands(BaseCommand):
20
+ """DHCP commands for the CLI."""
21
+
22
+ def __init__(self, cli: Any) -> None:
23
+ """Initialize the DHCP commands."""
24
+ super().__init__(cli, command_registry, "dhcp", "Manage DHCP associations.", "Manage DHCP")
25
+
26
+
27
+ def ipaddress_from_ip_arg(arg: str) -> IPAddress | None:
28
+ """Get an IPAddress object from an IP address argument.
29
+
30
+ :param arg: IP address argument.
31
+
32
+ :returns: IPAddress object if IP is valid and exists, None if IP is invalid.
33
+
34
+ :raises InputFailure: If the IP address is valid but does not exist.
35
+ :raises EntityOwnershipMismatch: If the IP address is in use by multiple hosts.
36
+ """
37
+ if not IPAddressField.is_valid(arg):
38
+ return None
39
+ try:
40
+ addr = IPAddressField.from_string(arg)
41
+ except InputFailure:
42
+ return None
43
+ ipobjs = IPAddress.get_by_ip(addr.address)
44
+ if not ipobjs:
45
+ raise InputFailure(f"IP address {arg} does not exist.")
46
+ elif len(ipobjs) > 1:
47
+ raise EntityOwnershipMismatch(f"IP {arg} is in use by {len(ipobjs)} hosts.")
48
+ return ipobjs[0]
49
+
50
+
51
+ @command_registry.register_command(
52
+ prog="assoc",
53
+ description=(
54
+ "Associate MAC address with a host. If the host has multiple A/AAAA "
55
+ "records an IP must be given instead of name."
56
+ ),
57
+ short_desc="Add MAC address to host.",
58
+ flags=[
59
+ Flag("name", "Name or IP of target host.", metavar="NAME/IP"),
60
+ Flag("mac", "Mac address.", metavar="MACADDRESS"),
61
+ Flag("-force", action="store_true", description="Enable force."),
62
+ ],
63
+ )
64
+ def assoc(args: argparse.Namespace) -> None:
65
+ """Associate MAC address with host.
66
+
67
+ If the host has multiple A/AAAA records an IP must be given instead of a name.
68
+
69
+ :param args: argparse.Namespace (name, mac, force)
70
+ """
71
+ name: str = args.name
72
+ mac: str = args.mac
73
+ force: bool = args.force
74
+
75
+ in_use = IPAddress.get_by_mac(mac)
76
+ if in_use:
77
+ raise InputFailure(f"MAC {mac} is already in use by {in_use.ip()}.")
78
+
79
+ ipaddress = ipaddress_from_ip_arg(name)
80
+ if not ipaddress:
81
+ host = Host.get_by_any_means_or_raise(name)
82
+ ipaddress = host.get_associatable_ip()
83
+
84
+ ipaddress.associate_mac(mac, force=force)
85
+ OutputManager().add_ok(
86
+ f"Associated mac address {mac} with ip {ipaddress.ip()}"
87
+ )
88
+
89
+
90
+ @command_registry.register_command(
91
+ prog="disassoc",
92
+ description=(
93
+ "Disassociate MAC address with a host or ip. If the host has multiple "
94
+ "A/AAAA records an IP must be given instead of a name."
95
+ ),
96
+ short_desc="Disassociate MAC address.",
97
+ flags=[
98
+ Flag("name", description="Name or IP of host.", metavar="NAME/IP"),
99
+ ],
100
+ )
101
+ def disassoc(args: argparse.Namespace) -> None:
102
+ """Disassociate MAC address with host/ip.
103
+
104
+ If the host has multiple A/AAAA records an IP must be given instead of a name.
105
+
106
+ :param args: argparse.Namespace (name)
107
+ """
108
+ name: str = args.name
109
+
110
+ ipaddress = ipaddress_from_ip_arg(name)
111
+ if not ipaddress:
112
+ host = Host.get_by_any_means_or_raise(name)
113
+ try:
114
+ ipaddress = host.has_ip_with_mac(name)
115
+ except ValueError:
116
+ pass
117
+
118
+ if not ipaddress:
119
+ ips_with_mac = host.ips_with_macaddresses()
120
+
121
+ if not ips_with_mac:
122
+ raise InputFailure(
123
+ f"Host {host} does not have any IP addresses with MAC addresses."
124
+ ) from None
125
+
126
+ if len(ips_with_mac) > 1:
127
+ raise InputFailure(
128
+ f"Host {host} has multiple IP addresses with MAC addresses."
129
+ ) from None
130
+
131
+ ipaddress = ips_with_mac[0]
132
+
133
+ ipaddress.disassociate_mac()
134
+ OutputManager().add_ok(
135
+ f"Disassociated mac address {ipaddress.macaddress} from ip {ipaddress.ip()}"
136
+ )