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
@@ -0,0 +1,288 @@
1
+ """Zone 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.models import Zone
9
+ from mreg_cli.commands.base import BaseCommand
10
+ from mreg_cli.commands.registry import CommandRegistry
11
+ from mreg_cli.exceptions import DeleteError, InputFailure
12
+ from mreg_cli.outputmanager import OutputManager
13
+ from mreg_cli.types import Flag
14
+
15
+ command_registry = CommandRegistry()
16
+
17
+
18
+ class ZoneCommands(BaseCommand):
19
+ """Zone commands for the CLI."""
20
+
21
+ def __init__(self, cli: Any) -> None:
22
+ """Initialize the zone commands."""
23
+ super().__init__(cli, command_registry, "zone", "Manage zones.", "Manage zones")
24
+
25
+
26
+ @command_registry.register_command(
27
+ prog="create",
28
+ description="Create new zone.",
29
+ short_desc="Create new zone.",
30
+ flags=[
31
+ Flag("zone", description="Zone name.", metavar="ZONE"),
32
+ Flag("email", description="Contact email.", metavar="EMAIL"),
33
+ Flag("ns", description="Nameservers of the zone.", nargs="+", metavar="NS"),
34
+ Flag("-force", action="store_true", description="Enable force."),
35
+ ],
36
+ )
37
+ def create(args: argparse.Namespace) -> None:
38
+ """Create a new zone.
39
+
40
+ :param args: argparse.Namespace (ns, force, zone, email)
41
+ """
42
+ Zone.create_zone(args.zone, args.email, args.ns, args.force)
43
+ OutputManager().add_ok(f"Created zone {args.zone}")
44
+
45
+
46
+ @command_registry.register_command(
47
+ prog="delegation_create",
48
+ description=(
49
+ "Create new zone delegation. Requires force if ns or zone doesn't exist in MREG."
50
+ ),
51
+ short_desc="Create new zone delegation.",
52
+ flags=[
53
+ Flag("zone", description="Zone name.", metavar="ZONE"),
54
+ Flag("delegation", description="Delegation", metavar="DELEGATION"),
55
+ Flag("ns", description="Nameservers for the delegation.", nargs="+", metavar="NS"),
56
+ Flag("-comment", description="Comment with a description", metavar="COMMENT"),
57
+ Flag("-force", action="store_true", description="Enable force."),
58
+ ],
59
+ )
60
+ def delegation_create(args: argparse.Namespace) -> None:
61
+ """Create a new zone delegation.
62
+
63
+ :param args: argparse.Namespace (ns, force, zone, delegation, comment)
64
+ """
65
+ zone = Zone.get_zone_or_raise(args.zone)
66
+ zone.create_delegation(args.delegation, args.ns, args.comment, args.force)
67
+ OutputManager().add_ok(f"Created zone delegation {args.delegation}")
68
+
69
+
70
+ @command_registry.register_command(
71
+ prog="delete",
72
+ description="Delete a zone",
73
+ short_desc="Delete a zone",
74
+ flags=[
75
+ Flag("zone", description="Zone name.", metavar="ZONE"),
76
+ Flag("-force", action="store_true", description="Enable force."),
77
+ ],
78
+ )
79
+ def zone_delete(args: argparse.Namespace) -> None:
80
+ """Delete a zone.
81
+
82
+ :param args: argparse.Namespace (zone, force)
83
+ """
84
+ zone = Zone.get_zone_or_raise(args.zone)
85
+ if zone.delete_zone(args.force):
86
+ OutputManager().add_ok(f"Deleted zone {zone.name}")
87
+ else:
88
+ raise DeleteError(f"Unable to delete zone {zone.name}")
89
+
90
+
91
+ @command_registry.register_command(
92
+ prog="delegation_delete",
93
+ description="Delete a zone delegation",
94
+ short_desc="Delete a zone delegation",
95
+ flags=[
96
+ Flag("zone", description="Zone name.", metavar="ZONE"),
97
+ Flag("delegation", description="Delegation", metavar="DELEGATION"),
98
+ ],
99
+ )
100
+ def delegation_delete(args: argparse.Namespace) -> None:
101
+ """Delete a zone delegation.
102
+
103
+ :param args: argparse.Namespace (zone, delegation)
104
+ """
105
+ # NOTE: how to handle delegation that has delegation?
106
+ # i.e. uio.no -> pederhan.uio.no -> sub.pederhan.uio.no
107
+ # and we try to delete pederhan.uio.no?
108
+ zone = Zone.get_zone_or_raise(args.zone)
109
+ if zone.delete_delegation(args.delegation):
110
+ OutputManager().add_ok(f"Removed zone delegation {args.delegation}")
111
+ else:
112
+ raise DeleteError(f"Unable to delete delegation {args.delegation}")
113
+
114
+
115
+ @command_registry.register_command(
116
+ prog="info",
117
+ description="Delete a zone",
118
+ short_desc="Delete a zone",
119
+ flags=[
120
+ Flag("zone", description="Zone name.", metavar="ZONE"),
121
+ ],
122
+ )
123
+ def info(args: argparse.Namespace) -> None:
124
+ """Show SOA info for a existing zone.
125
+
126
+ :param args: argparse.Namespace (zone)
127
+ """
128
+ zone = Zone.get_zone_or_raise(args.zone)
129
+ zone.output()
130
+
131
+
132
+ @command_registry.register_command(
133
+ prog="list",
134
+ description="List zones",
135
+ short_desc="List zones",
136
+ flags=[
137
+ Flag(
138
+ "-forward",
139
+ action="store_true",
140
+ short_desc="List all forward zones",
141
+ description="List all forward zones",
142
+ ),
143
+ Flag(
144
+ "-reverse",
145
+ action="store_true",
146
+ short_desc="List all reverse zones",
147
+ description="List all reverse zones",
148
+ ),
149
+ ],
150
+ )
151
+ def zone_list(args: argparse.Namespace) -> None:
152
+ """List all zones.
153
+
154
+ :param args: argparse.Namespace (forward, reverse)
155
+ """
156
+ if not (args.forward or args.reverse):
157
+ raise InputFailure("Add either -forward or -reverse as argument")
158
+ Zone.output_zones(args.forward, args.reverse)
159
+
160
+
161
+ @command_registry.register_command(
162
+ prog="delegation_list",
163
+ description="List a zone's delegations",
164
+ short_desc="List a zone's delegations",
165
+ flags=[
166
+ Flag("zone", description="Zone name.", metavar="ZONE"),
167
+ ],
168
+ )
169
+ def zone_delegation_list(args: argparse.Namespace) -> None:
170
+ """List a zone's delegations.
171
+
172
+ :param args: argparse.Namespace (zone)
173
+ """
174
+ zone = Zone.get_zone_or_raise(args.zone)
175
+ zone.output_delegations()
176
+
177
+
178
+ @command_registry.register_command(
179
+ prog="delegation_comment_set",
180
+ description="Set a comment for zone delegation",
181
+ short_desc="Set a comment for zone delegation",
182
+ flags=[
183
+ Flag("zone", description="Zone name", metavar="ZONE"),
184
+ Flag("delegation", description="Delegation", metavar="DELEGATION"),
185
+ Flag("comment", description="Comment", metavar="COMMENT"),
186
+ ],
187
+ )
188
+ def zone_delegation_comment_set(args: argparse.Namespace) -> None:
189
+ """Set a delegation's comment.
190
+
191
+ :param args: argparse.Namespace (zone, delegation, comment)
192
+ """
193
+ zone = Zone.get_zone_or_raise(args.zone)
194
+ zone.set_delegation_comment(args.delegation, args.comment)
195
+ OutputManager().add_ok(f"Updated comment for {args.delegation}")
196
+
197
+
198
+ @command_registry.register_command(
199
+ prog="delegation_comment_remove",
200
+ description="Remove a comment for zone delegation",
201
+ short_desc="Remove a comment for zone delegation",
202
+ flags=[
203
+ Flag("zone", description="Zone name", metavar="ZONE"),
204
+ Flag("delegation", description="Delegation", metavar="DELEGATION"),
205
+ ],
206
+ )
207
+ def zone_delegation_comment_remove(args: argparse.Namespace) -> None:
208
+ """Set a delegation's comment.
209
+
210
+ :param args: argparse.Namespace (zone, delegation)
211
+ """
212
+ zone = Zone.get_zone_or_raise(args.zone)
213
+ zone.set_delegation_comment(args.delegation, "")
214
+ OutputManager().add_ok(f"Removed comment for {args.delegation}")
215
+
216
+
217
+ @command_registry.register_command(
218
+ prog="set_ns",
219
+ description="Update nameservers for an existing zone.",
220
+ short_desc="Update nameservers for an existing zone.",
221
+ flags=[
222
+ Flag("zone", description="Zone name.", metavar="ZONE"),
223
+ Flag("ns", description="Nameservers of the zone.", nargs="+", metavar="NS"),
224
+ Flag("-force", action="store_true", description="Enable force."),
225
+ ],
226
+ )
227
+ def set_ns(args: argparse.Namespace) -> None:
228
+ """Update nameservers for an existing zone.
229
+
230
+ If multiple nameservers are provided, the first one is set as the primary nameserver.
231
+
232
+ :param args: argparse.Namespace (zone, ns, force)
233
+ """
234
+ zone = Zone.get_zone_or_raise(args.zone)
235
+ zone.update_nameservers(args.ns, args.force)
236
+ OutputManager().add_ok(f"Updated nameservers for {args.zone}")
237
+
238
+
239
+ @command_registry.register_command(
240
+ prog="set_soa",
241
+ description="Updated the SOA of a zone.",
242
+ short_desc="Updated the SOA of a zone.",
243
+ flags=[
244
+ Flag("zone", description="Zone name.", metavar="ZONE"),
245
+ Flag("-ns", description="Primary nameserver (SOA MNAME).", metavar="PRIMARY-NS"),
246
+ Flag("-email", description="Zone contact email.", metavar="EMAIL"),
247
+ Flag("-serialno", description="Serial number.", flag_type=int, metavar="SERIALNO"),
248
+ Flag("-refresh", description="Refresh time.", flag_type=int, metavar="REFRESH"),
249
+ Flag("-retry", description="Retry time.", flag_type=int, metavar="RETRY"),
250
+ Flag("-expire", description="Expire time.", flag_type=int, metavar="EXPIRE"),
251
+ Flag("-soa-ttl", description="SOA Time To Live", flag_type=int, metavar="TTL"),
252
+ ],
253
+ )
254
+ def set_soa(args: argparse.Namespace) -> None:
255
+ """Update the SOA of a zone.
256
+
257
+ :param args: argparse.Namespace (zone, ns, email, serialno, retry, expire, soa_ttl)
258
+ """
259
+ zone = Zone.get_zone_or_raise(args.zone)
260
+ zone.update_soa(
261
+ primary_ns=args.ns,
262
+ email=args.email,
263
+ serialno=args.serialno,
264
+ refresh=args.refresh,
265
+ retry=args.retry,
266
+ expire=args.expire,
267
+ soa_ttl=args.soa_ttl,
268
+ )
269
+ OutputManager().add_ok(f"Updated SOA for {args.zone}")
270
+
271
+
272
+ @command_registry.register_command(
273
+ prog="set_default_ttl",
274
+ description="Set the default TTL of a zone.",
275
+ short_desc="Set the default TTL of a zone.",
276
+ flags=[
277
+ Flag("zone", description="Zone name.", metavar="ZONE"),
278
+ Flag("ttl", description="Default Time To Live.", flag_type=int, metavar="TTL"),
279
+ ],
280
+ )
281
+ def set_default_ttl(args: argparse.Namespace) -> None:
282
+ """Update the default TTL of a zone.
283
+
284
+ :param args: argparse.Namespace (zone, ttl)
285
+ """
286
+ zone = Zone.get_zone_or_raise(args.zone)
287
+ zone.set_default_ttl(args.ttl)
288
+ OutputManager().add_ok(f"Set default TTL for {args.zone}")
mreg_cli/config.py ADDED
@@ -0,0 +1,253 @@
1
+ """Logging.
2
+
3
+ This module can be used to configure basic logging to stderr, with an optional
4
+ filter level.
5
+
6
+ When prompting for user input (verbosity, debug level), log levels can be
7
+ translated according to a mapping:
8
+
9
+ .. py:data:: LOGGING_VERBOSITY
10
+
11
+ 0. :py:const:`logging.ERROR`
12
+ 1. :py:const:`logging.WARNING`
13
+ 2. :py:const:`logging.INFO`
14
+ 3. :py:const:`logging.DEBUG`
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import configparser
20
+ import logging
21
+ import os
22
+ import sys
23
+ from typing import Any, overload
24
+
25
+ from mreg_cli.types import DefaultType, LogLevel
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Config file locations
30
+ DEFAULT_CONFIG_PATH = tuple(
31
+ (
32
+ os.path.expanduser("~/.config/mreg-cli.conf"),
33
+ "/etc/mreg-cli.conf",
34
+ os.path.join(sys.prefix, "local", "share", "mreg-cli.conf"),
35
+ os.path.join(sys.prefix, "share", "mreg-cli.conf"),
36
+ # At last, look in ../data/ in case we're developing
37
+ os.path.join(os.path.dirname(__file__), "..", "data", "mreg-cli.conf"),
38
+ )
39
+ )
40
+
41
+ DEFAULT_LOG_FILE = os.path.expanduser("~/.mreg-cli.log")
42
+
43
+ # Default logging format
44
+ LOGGING_FORMAT = "%(asctime)s - %(levelname)-8s - %(name)s - %(message)s"
45
+
46
+
47
+ class MregCliConfig:
48
+ """Configuration class for the mreg-cli.
49
+
50
+ This is a singleton class that is used to store configuration information.
51
+ Configuration is loaded with the following priority:
52
+ 1. Command line options
53
+ 2. Environment variables (prefixed with 'MREG_')
54
+ 3. Configuration file
55
+ """
56
+
57
+ _instance = None
58
+ _is_logging = False
59
+ _logfile = None
60
+ _config_cmd: dict[str, str]
61
+ _config_file: dict[str, str]
62
+ _config_env: dict[str, str]
63
+
64
+ def __new__(cls) -> MregCliConfig:
65
+ """Create a new instance of the configuration class.
66
+
67
+ This ensures that only one instance of the configuration class is created.
68
+ """
69
+ if cls._instance is None:
70
+ cls._instance = super().__new__(cls)
71
+ cls._instance._config_file = {}
72
+ cls._instance._config_env = cls._load_env_config()
73
+ cls._instance._config_cmd = {}
74
+ cls._instance.get_config()
75
+
76
+ return cls._instance
77
+
78
+ @staticmethod
79
+ def _load_env_config() -> dict[str, str]:
80
+ """Load environment variables into the configuration, filtering with 'MREGCLI_' prefix."""
81
+ env_prefix = "MREGCLI_"
82
+ return {
83
+ key[len(env_prefix) :].lower(): value
84
+ for key, value in os.environ.items()
85
+ if key.startswith(env_prefix)
86
+ }
87
+
88
+ @overload
89
+ def get(self, key: str) -> str | None: ...
90
+
91
+ @overload
92
+ def get(self, key: str, default: DefaultType = ...) -> str | DefaultType: ...
93
+
94
+ def get(self, key: str, default: DefaultType | None = None) -> str | DefaultType | None:
95
+ """Get a configuration value with priority: cmdline, env, file.
96
+
97
+ :param str key: Configuration key.
98
+ :param default: Default value if key is not found.
99
+
100
+ :returns: Configuration value.
101
+ """
102
+ return self._config_cmd.get(
103
+ key, self._config_env.get(key, self._config_file.get(key, default))
104
+ )
105
+
106
+ def set_cmd_config(self, cmd_config: dict[str, Any]) -> None:
107
+ """Set command line configuration options.
108
+
109
+ :param Dict[str, Any] cmd_config: Dictionary of command line configurations.
110
+ """
111
+ self._config_cmd.update(cmd_config)
112
+
113
+ def get_config(self, reload: bool = False) -> None:
114
+ """Load the configuration file into the class.
115
+
116
+ :param bool reload: Reload the configuration from the config file.
117
+ """
118
+ if not self._config_file or reload:
119
+ configpath = self.get_config_file()
120
+ if configpath is not None:
121
+ cfgparser = configparser.ConfigParser()
122
+ cfgparser.read(configpath)
123
+ self._config_file = dict(cfgparser["mreg"].items())
124
+
125
+ def get_logging_level(self) -> str:
126
+ """Get the current logging level by name.
127
+
128
+ :returns: The logging level by name.
129
+ """
130
+ return logging.getLevelName(logging.getLogger().getEffectiveLevel())
131
+
132
+ def set_logging_level(self, level: LogLevel) -> None:
133
+ """Set the logging level.
134
+
135
+ :param str level: The logging level to set (DEBUG, INFO, WARNING, ERROR, CRITICAL)
136
+ """
137
+ logging.getLogger().setLevel(level)
138
+
139
+ def start_logging(
140
+ self, logfile: str = DEFAULT_LOG_FILE, level: str | LogLevel = "INFO"
141
+ ) -> None:
142
+ """Enable and configure logging.
143
+
144
+ :param str logfile: Path to the logfile, defaults to DEFAULT_LOG_FILE.
145
+ :param str level: Logging level, defaults to 'INFO'.
146
+ """
147
+ level = LogLevel(level)
148
+ if self._is_logging:
149
+ logging.shutdown()
150
+ self._is_logging = False
151
+ else:
152
+ logging.basicConfig(
153
+ filename=logfile,
154
+ level=logging.getLevelName(level),
155
+ format=LOGGING_FORMAT,
156
+ datefmt="%Y-%m-%d %H:%M:%S",
157
+ )
158
+
159
+ logging.getLogger().setLevel(level)
160
+ self._is_logging = True
161
+ self._logfile = logfile
162
+
163
+ def get_logging_status(self) -> bool:
164
+ """Get the logging status.
165
+
166
+ :returns: True if logging is enabled, False otherwise.
167
+ """
168
+ return self._is_logging
169
+
170
+ def print_logging_status(self) -> None:
171
+ """Print the logging status."""
172
+ if self._is_logging:
173
+ print(f"Logging enabled to {self._logfile}")
174
+ else:
175
+ print("Logging is not enabled")
176
+
177
+ def stop_logging(self) -> None:
178
+ """Stop logging."""
179
+ logging.shutdown()
180
+ self._is_logging = False
181
+ self._logfile = None
182
+
183
+ def get_config_file(self) -> str | None:
184
+ """Get the first config file found in DEFAULT_CONFIG_PATH.
185
+
186
+ :returns: path to config file, or None if no config file was found
187
+ """
188
+ for path in DEFAULT_CONFIG_PATH:
189
+ logger.debug("looking for config in %r", os.path.abspath(path))
190
+ if os.path.isfile(path):
191
+ logger.info("found config in %r", path)
192
+ return path
193
+ logger.debug("no config file found in config paths")
194
+ return None
195
+
196
+ def get_default_domain(self):
197
+ """Get the default domain from the application."""
198
+ return self.get("domain", "uio.no")
199
+
200
+ def get_default_logfile(self):
201
+ """Get the default logfile from the application."""
202
+ return self.get("logfile", DEFAULT_LOG_FILE)
203
+
204
+ def get_location_tags(self) -> list[str]:
205
+ """Get the location tags from the application."""
206
+ return self.get("location_tags", "").split(",")
207
+
208
+ def get_category_tags(self) -> list[str]:
209
+ """Get the category tags from the application."""
210
+ return self.get("category_tags", "").split(",")
211
+
212
+ # We handle url by itself because it's a required config option,
213
+ # it cannot be none once options, env, and config file are parsed.
214
+ def get_url(self) -> str:
215
+ """Get the default url from the application."""
216
+ url = self.get("url", self.get("default_url", "https://mreg.uio.no"))
217
+ if not url:
218
+ raise ValueError("No URL found in config, no defaults available!")
219
+ return url
220
+
221
+ def _calculate_column_width(self, data: dict[str, Any], min_width: int = 8) -> int:
222
+ """Calculate the maximum column width, ensuring a minimum width.
223
+
224
+ :param data: Dictionary of data for the column.
225
+ :param min_width: Minimum width of the column.
226
+ :returns: Calculated column width.
227
+ """
228
+ max_length = max((len(str(value)) for value in data.values()), default=0)
229
+ return max(max_length, min_width) + 2 # Adding 2 for padding
230
+
231
+ def print_config_table(self) -> None:
232
+ """Pretty print the configuration options in a dynamic table format."""
233
+ all_keys = set(self._config_cmd) | set(self._config_env) | set(self._config_file)
234
+ key_width = max(max(len(key) for key in all_keys), 8) + 2
235
+
236
+ # Calculate column widths
237
+ cmd_width = self._calculate_column_width(self._config_cmd)
238
+ env_width = self._calculate_column_width(self._config_env)
239
+ file_width = self._calculate_column_width(self._config_file)
240
+
241
+ # Print the table header
242
+ print("Configuration Options:")
243
+ header_format = f"{{:<{key_width}}} {{:<{cmd_width}}} {{:<{env_width}}} {{:<{file_width}}}"
244
+ print(header_format.format("Key", "Active", "Envir", "File"))
245
+ print("-" * (key_width + cmd_width + env_width + file_width))
246
+
247
+ # Print each row
248
+ row_format = f"{{:<{key_width}}} {{:<{cmd_width}}} {{:<{env_width}}} {{:<{file_width}}}"
249
+ for key in sorted(all_keys):
250
+ cmd_line_val = str(self._config_cmd.get(key, "-"))
251
+ env_var_val = str(self._config_env.get(key, "-"))
252
+ config_file_val = str(self._config_file.get(key, "-"))
253
+ print(row_format.format(key, cmd_line_val, env_var_val, config_file_val))