isemass 0.1.0__tar.gz

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.
isemass-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: isemass
3
+ Version: 0.1.0
4
+ Summary: CLI framework for mass Cisco ISE related operations.
5
+ License-Expression: Apache-2.0
6
+ Requires-Dist: click>=8.1
7
+ Requires-Dist: platformdirs>=4.2
8
+ Requires-Dist: requests>=2.32
9
+ Requires-Dist: rich>=13.7
10
+ Requires-Dist: urllib3>=2.0
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+
14
+ # isemass
15
+
16
+ `isemass` is a Python CLI for mass operations related to Cisco ISE.
17
+
18
+ The `coa` command performs Cisco ISE API Change-of-Authorization requests from MAC addresses
19
+ found in an input text file. The `swauth` command is still a placeholder for future switch SSH
20
+ reauthentication work.
21
+
22
+ ## Development
23
+
24
+ ```bash
25
+ uv sync
26
+ uv run isemass --help
27
+ uv run pytest
28
+ uv run ruff check .
29
+ ```
30
+
31
+ ## Commands
32
+
33
+ ```bash
34
+ isemass init
35
+ isemass coa --input-file macs.txt --host ise-mnt.example.com --node ise-psn01
36
+ isemass coa --input-file macs.txt --host ise-mnt.example.com --node ise-psn01 --yes
37
+ isemass swauth
38
+ ```
39
+
40
+ `isemass init` creates `settings.toml` in the operating system's standard user config directory
41
+ for the `isemass` app.
42
+
43
+ `isemass coa` accepts colon, dash, and Cisco dotted MAC formats, normalizes them to uppercase
44
+ colon format, removes duplicates, prompts for the API password, previews the target MAC list, and
45
+ asks for confirmation before sending requests. Use `--insecure` or `insecure = true` only when you
46
+ need to skip HTTPS certificate validation.
47
+
48
+ ## Input Config
49
+
50
+ Priority of configuration input is this order:
51
+ 1. CLI arguments
52
+ 2. settings.toml values
53
+ 3. back-end defaults (in defaults.py)
@@ -0,0 +1,40 @@
1
+ # isemass
2
+
3
+ `isemass` is a Python CLI for mass operations related to Cisco ISE.
4
+
5
+ The `coa` command performs Cisco ISE API Change-of-Authorization requests from MAC addresses
6
+ found in an input text file. The `swauth` command is still a placeholder for future switch SSH
7
+ reauthentication work.
8
+
9
+ ## Development
10
+
11
+ ```bash
12
+ uv sync
13
+ uv run isemass --help
14
+ uv run pytest
15
+ uv run ruff check .
16
+ ```
17
+
18
+ ## Commands
19
+
20
+ ```bash
21
+ isemass init
22
+ isemass coa --input-file macs.txt --host ise-mnt.example.com --node ise-psn01
23
+ isemass coa --input-file macs.txt --host ise-mnt.example.com --node ise-psn01 --yes
24
+ isemass swauth
25
+ ```
26
+
27
+ `isemass init` creates `settings.toml` in the operating system's standard user config directory
28
+ for the `isemass` app.
29
+
30
+ `isemass coa` accepts colon, dash, and Cisco dotted MAC formats, normalizes them to uppercase
31
+ colon format, removes duplicates, prompts for the API password, previews the target MAC list, and
32
+ asks for confirmation before sending requests. Use `--insecure` or `insecure = true` only when you
33
+ need to skip HTTPS certificate validation.
34
+
35
+ ## Input Config
36
+
37
+ Priority of configuration input is this order:
38
+ 1. CLI arguments
39
+ 2. settings.toml values
40
+ 3. back-end defaults (in defaults.py)
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.11.16,<0.12"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "isemass"
7
+ version = "0.1.0"
8
+ description = "CLI framework for mass Cisco ISE related operations."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "Apache-2.0"
12
+ dependencies = [
13
+ "click>=8.1",
14
+ "platformdirs>=4.2",
15
+ "requests>=2.32",
16
+ "rich>=13.7",
17
+ "urllib3>=2.0",
18
+ ]
19
+
20
+ [project.scripts]
21
+ isemass = "isemass.cli:cli"
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "pytest>=8.0",
26
+ "ruff>=0.8",
27
+ ]
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/isemass"]
31
+
32
+ [tool.pytest.ini_options]
33
+ addopts = "-q"
34
+ pythonpath = ["src"]
35
+
36
+ [tool.ruff]
37
+ line-length = 100
38
+ target-version = "py312"
@@ -0,0 +1,4 @@
1
+ """isemass CLI package."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,231 @@
1
+ """Click command line interface for isemass."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import click
9
+ from rich.table import Table
10
+
11
+ from isemass import __version__
12
+ from isemass import coa as coa_ops
13
+ from isemass.config import SettingsError, get_settings_path, load_settings, write_default_settings
14
+ from isemass.console import console
15
+
16
+
17
+ def _load_settings_for_cli() -> dict[str, dict[str, Any]]:
18
+ try:
19
+ return load_settings()
20
+ except SettingsError as exc:
21
+ raise click.ClickException(str(exc)) from exc
22
+
23
+
24
+ def _section(settings: dict[str, dict[str, Any]], name: str) -> dict[str, Any]:
25
+ values = settings.get(name, {})
26
+ if not isinstance(values, dict):
27
+ raise click.ClickException(f"Invalid settings: [{name}] must be a table.")
28
+ return values
29
+
30
+
31
+ def _resolve_option(cli_value: Any, config_value: Any) -> Any:
32
+ return cli_value if cli_value is not None else config_value
33
+
34
+
35
+ def _missing_required_options(options: dict[str, Any]) -> list[str]:
36
+ return [option_name for option_name, value in options.items() if value in (None, "")]
37
+
38
+
39
+ def _coerce_input_file(value: Any) -> Path:
40
+ path = Path(value).expanduser()
41
+ if not path.is_file():
42
+ raise click.ClickException(f"Input file does not exist: {path}")
43
+ return path
44
+
45
+
46
+ def _coerce_positive_int(value: Any, *, field_name: str) -> int:
47
+ try:
48
+ resolved = int(value)
49
+ except (TypeError, ValueError) as exc:
50
+ raise click.ClickException(f"{field_name} must be a positive integer.") from exc
51
+
52
+ if resolved < 1:
53
+ raise click.ClickException(f"{field_name} must be a positive integer.")
54
+
55
+ return resolved
56
+
57
+
58
+ def _coerce_bool(value: Any, *, field_name: str) -> bool:
59
+ if isinstance(value, bool):
60
+ return value
61
+
62
+ raise click.ClickException(f"{field_name} must be a boolean true or false.")
63
+
64
+
65
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
66
+ @click.version_option(version=__version__, prog_name="isemass")
67
+ def cli() -> None:
68
+ """Mass Cisco ISE related operations."""
69
+
70
+
71
+ @cli.command()
72
+ @click.option(
73
+ "--force",
74
+ is_flag=True,
75
+ help="Overwrite an existing settings.toml file.",
76
+ )
77
+ def init(force: bool) -> None:
78
+ """Create the optional settings.toml file."""
79
+ path, wrote_file = write_default_settings(force=force)
80
+
81
+ if not wrote_file:
82
+ raise click.ClickException(f"Settings file already exists: {path}. Use --force to overwrite.")
83
+
84
+ console.print(f"[green]Created settings file:[/green] '{path}'")
85
+
86
+
87
+ @cli.command()
88
+ @click.option(
89
+ "-i",
90
+ "--input-file",
91
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
92
+ help="Input text file containing MAC addresses.",
93
+ )
94
+ @click.option(
95
+ "-u",
96
+ "--username",
97
+ help="API username. Prompts if omitted and not configured.",
98
+ )
99
+ @click.option(
100
+ "-w",
101
+ "--max-workers",
102
+ type=int,
103
+ help="Max workers for multitasking at once.",
104
+ )
105
+ @click.option(
106
+ "--host",
107
+ help="FQDN or IP for the API request, typically the MnT node.",
108
+ )
109
+ @click.option(
110
+ "-n",
111
+ "--node",
112
+ help="Short ISE node name that processes the CoA request.",
113
+ )
114
+ @click.option(
115
+ "-k",
116
+ "--insecure",
117
+ is_flag=True,
118
+ default=None,
119
+ help="Skip HTTPS certificate validation.",
120
+ )
121
+ @click.option(
122
+ "-y",
123
+ "--yes",
124
+ is_flag=True,
125
+ help="Skip confirmation before submitting CoA requests.",
126
+ )
127
+ def coa(
128
+ input_file: Path | None,
129
+ username: str | None,
130
+ max_workers: int | None,
131
+ host: str | None,
132
+ node: str | None,
133
+ insecure: bool | None,
134
+ yes: bool,
135
+ ) -> None:
136
+ """ISE Mass CoA through API."""
137
+ settings = _load_settings_for_cli()
138
+ coa_settings = _section(settings, "coa")
139
+
140
+ required_values = {
141
+ "--input-file": _resolve_option(input_file, coa_settings.get("input_file")),
142
+ "--host": _resolve_option(host, coa_settings.get("host")),
143
+ "--node": _resolve_option(node, coa_settings.get("node")),
144
+ }
145
+ missing = _missing_required_options(required_values)
146
+ if missing:
147
+ missing_options = ", ".join(missing)
148
+ raise click.UsageError(
149
+ f"Missing required option(s): {missing_options}. "
150
+ "Provide them on the CLI or in [coa]."
151
+ )
152
+
153
+ resolved_input_file = _coerce_input_file(required_values["--input-file"])
154
+ resolved_host = str(required_values["--host"])
155
+ resolved_node = str(required_values["--node"])
156
+ resolved_username = _resolve_option(username, coa_settings.get("username"))
157
+ if not resolved_username:
158
+ resolved_username = click.prompt("API username", type=str)
159
+
160
+ resolved_max_workers = _coerce_positive_int(
161
+ _resolve_option(max_workers, coa_settings.get("max_workers")),
162
+ field_name="max_workers",
163
+ )
164
+ resolved_insecure = _coerce_bool(
165
+ _resolve_option(insecure, coa_settings.get("insecure")),
166
+ field_name="insecure",
167
+ )
168
+
169
+ password = click.prompt(f"API password for {resolved_username}", hide_input=True, type=str)
170
+ macs = coa_ops.extract_macs_from_file(resolved_input_file)
171
+ if not macs:
172
+ raise click.ClickException(f"No MAC addresses found in {resolved_input_file}.")
173
+
174
+ _print_mac_preview(macs)
175
+ if not yes and not click.confirm("Continue with CoA operation?", default=False):
176
+ raise click.ClickException("Operation not approved. Aborting.")
177
+
178
+ console.print(f"Starting CoA requests for {len(macs)} MAC address(es)...", highlight=False)
179
+ for result in coa_ops.run_coa_requests(
180
+ macs=macs,
181
+ host=resolved_host,
182
+ node=resolved_node,
183
+ username=str(resolved_username),
184
+ password=password,
185
+ max_workers=resolved_max_workers,
186
+ insecure=resolved_insecure,
187
+ ):
188
+ _print_coa_result(result)
189
+
190
+
191
+ @cli.command()
192
+ def swauth() -> None:
193
+ """Mass session reauthentication through switch SSH."""
194
+ settings = _load_settings_for_cli()
195
+ swauth_settings = _section(settings, "swauth")
196
+ verbose = _coerce_bool(swauth_settings.get("verbose", False), field_name="verbose")
197
+
198
+ console.print("[yellow]Switch reauthentication is not implemented yet.[/yellow]")
199
+ console.print(f"Verbose: {verbose}")
200
+ console.print(f"Settings path: {get_settings_path()}")
201
+
202
+
203
+ def _print_mac_preview(macs: list[str]) -> None:
204
+ table = Table(title=f"Unique MAC addresses ({len(macs)})")
205
+ table.add_column("#", justify="right")
206
+ table.add_column("MAC address", style="cyan")
207
+
208
+ for index, mac in enumerate(macs, start=1):
209
+ table.add_row(str(index), mac)
210
+
211
+ console.print(table)
212
+
213
+
214
+ def _print_coa_result(result: coa_ops.CoaResult) -> None:
215
+ styles = {
216
+ coa_ops.CoaStatus.SUCCEEDED: "green",
217
+ coa_ops.CoaStatus.COA_FAILED: "yellow",
218
+ coa_ops.CoaStatus.REQUEST_FAILED: "red",
219
+ }
220
+ labels = {
221
+ coa_ops.CoaStatus.SUCCEEDED: "SUCCEEDED",
222
+ coa_ops.CoaStatus.COA_FAILED: "FAILED/UNDETERMINED",
223
+ coa_ops.CoaStatus.REQUEST_FAILED: "REQUEST FAILED",
224
+ }
225
+ style = styles[result.status]
226
+ label = labels[result.status]
227
+
228
+ console.print(
229
+ f"{result.mac} | {result.seconds:>5.2f}s | [{style}]{label}[/{style}] | {result.detail}",
230
+ highlight=False,
231
+ )
@@ -0,0 +1,225 @@
1
+ """Cisco ISE CoA API helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+ from pathlib import Path
9
+ from time import perf_counter
10
+ import re
11
+ from typing import Any, Callable, Iterable
12
+ from xml.etree import ElementTree
13
+
14
+ import requests
15
+ import urllib3
16
+
17
+ HEADERS = {
18
+ "Accept": "application/xml",
19
+ }
20
+ REQUEST_TIMEOUT_SECONDS = 30
21
+
22
+ # REGEX for all formats of MAC address.
23
+ # "?:" removes groups so re.findall returns complete MAC address strings.
24
+ RE_MAC_COLON = r"(?:[0-9A-Fa-f]{2}:){5}(?:[0-9A-Fa-f]{2})"
25
+ RE_MAC_DASH = r"(?:[0-9A-Fa-f]{2}-){5}(?:[0-9A-Fa-f]{2})"
26
+ RE_MAC_CISCO = r"(?:[0-9A-Fa-f]{4}\.){2}(?:[0-9A-Fa-f]{4})"
27
+ RE_MAC_ALL = RE_MAC_COLON + r"|" + RE_MAC_DASH + r"|" + RE_MAC_CISCO
28
+ RE_MAC_PATTERN = re.compile(RE_MAC_ALL)
29
+
30
+
31
+ class CoaStatus(StrEnum):
32
+ """Normalized CoA request outcome."""
33
+
34
+ REQUEST_FAILED = "request_failed"
35
+ COA_FAILED = "coa_failed"
36
+ SUCCEEDED = "succeeded"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class CoaResult:
41
+ """Result for one MAC address CoA request."""
42
+
43
+ mac: str
44
+ status: CoaStatus
45
+ seconds: float
46
+ detail: str
47
+
48
+
49
+ RequestFunc = Callable[..., Any]
50
+
51
+
52
+ def normalize_mac(mac: str) -> str:
53
+ """Normalize a supported MAC address string to uppercase colon format."""
54
+ compact = mac.replace(":", "").replace("-", "").replace(".", "").upper()
55
+ return ":".join(compact[index : index + 2] for index in range(0, len(compact), 2))
56
+
57
+
58
+ def extract_macs(text: str) -> list[str]:
59
+ """Extract unique MAC addresses from text while preserving first-seen order."""
60
+ unique_macs: list[str] = []
61
+ seen: set[str] = set()
62
+
63
+ for match in RE_MAC_PATTERN.findall(text):
64
+ normalized = normalize_mac(match)
65
+ if normalized in seen:
66
+ continue
67
+ seen.add(normalized)
68
+ unique_macs.append(normalized)
69
+
70
+ return unique_macs
71
+
72
+
73
+ def extract_macs_from_file(path: Path) -> list[str]:
74
+ """Read text from path and extract unique MAC addresses."""
75
+ return extract_macs(path.read_text(encoding="utf-8"))
76
+
77
+
78
+ def build_coa_url(*, host: str, node: str, mac: str) -> str:
79
+ """Build the Cisco ISE CoA Reauth API URL."""
80
+ return f"https://{host}/admin/API/mnt/CoA/Reauth/{node}/{mac}/0"
81
+
82
+
83
+ def perform_coa_request(
84
+ *,
85
+ mac: str,
86
+ host: str,
87
+ node: str,
88
+ username: str,
89
+ password: str,
90
+ verify_tls: bool,
91
+ timeout: float = REQUEST_TIMEOUT_SECONDS,
92
+ request_func: RequestFunc = requests.get,
93
+ ) -> CoaResult:
94
+ """Perform one Cisco ISE CoA request and classify the result."""
95
+ url = build_coa_url(host=host, node=node, mac=mac)
96
+ start = perf_counter()
97
+
98
+ try:
99
+ response = request_func(
100
+ url,
101
+ headers=HEADERS,
102
+ verify=verify_tls,
103
+ auth=(username, password),
104
+ timeout=timeout,
105
+ )
106
+ except requests.RequestException as exc:
107
+ return CoaResult(
108
+ mac=mac,
109
+ status=CoaStatus.REQUEST_FAILED,
110
+ seconds=_elapsed_seconds(start),
111
+ detail=f"API request failed: {exc}",
112
+ )
113
+
114
+ # TEMP TESTING. MIGHT BE USED FOR VERBOSITY LATER
115
+ # print(response.status_code, response.headers, response.text)
116
+
117
+ seconds = _elapsed_seconds(start)
118
+ status_code = getattr(response, "status_code", None)
119
+ if status_code is None or not 200 <= status_code < 300:
120
+ reason = getattr(response, "reason", "")
121
+ reason_text = f" {reason}" if reason else ""
122
+ return CoaResult(
123
+ mac=mac,
124
+ status=CoaStatus.REQUEST_FAILED,
125
+ seconds=seconds,
126
+ detail=f"HTTP {status_code}{reason_text}",
127
+ )
128
+
129
+ result_value = _extract_remote_coa_result(getattr(response, "text", ""))
130
+ if result_value is None:
131
+ return CoaResult(
132
+ mac=mac,
133
+ status=CoaStatus.COA_FAILED,
134
+ seconds=seconds,
135
+ detail="ISE response did not include remoteCoA.results",
136
+ )
137
+
138
+ if result_value.casefold() == "true":
139
+ return CoaResult(
140
+ mac=mac,
141
+ status=CoaStatus.SUCCEEDED,
142
+ seconds=seconds,
143
+ detail="ISE remoteCoA.results=true",
144
+ )
145
+
146
+ return CoaResult(
147
+ mac=mac,
148
+ status=CoaStatus.COA_FAILED,
149
+ seconds=seconds,
150
+ detail=f"ISE remoteCoA.results={result_value}",
151
+ )
152
+
153
+
154
+ def run_coa_requests(
155
+ *,
156
+ macs: Iterable[str],
157
+ host: str,
158
+ node: str,
159
+ username: str,
160
+ password: str,
161
+ max_workers: int,
162
+ insecure: bool,
163
+ ) -> Iterable[CoaResult]:
164
+ """Run CoA requests concurrently and yield results as they complete."""
165
+ verify_tls = not insecure
166
+ if insecure:
167
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
168
+
169
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
170
+ future_to_mac = {
171
+ executor.submit(
172
+ perform_coa_request,
173
+ mac=mac,
174
+ host=host,
175
+ node=node,
176
+ username=username,
177
+ password=password,
178
+ verify_tls=verify_tls,
179
+ ): mac
180
+ for mac in macs
181
+ }
182
+
183
+ for future in as_completed(future_to_mac):
184
+ mac = future_to_mac[future]
185
+ try:
186
+ yield future.result()
187
+ except Exception as exc:
188
+ yield CoaResult(
189
+ mac=mac,
190
+ status=CoaStatus.REQUEST_FAILED,
191
+ seconds=0.0,
192
+ detail=f"Unexpected worker error: {exc}",
193
+ )
194
+
195
+
196
+ def _elapsed_seconds(start: float) -> float:
197
+ return round(perf_counter() - start, 2)
198
+
199
+
200
+ def _extract_remote_coa_result(xml_text: str) -> str | None:
201
+ try:
202
+ root = ElementTree.fromstring(xml_text)
203
+ except ElementTree.ParseError:
204
+ return None
205
+
206
+ remote_coa = _find_first_element(root, "remoteCoA")
207
+ if remote_coa is None:
208
+ return None
209
+
210
+ results = _find_first_element(remote_coa, "results")
211
+ if results is None or results.text is None:
212
+ return None
213
+
214
+ return results.text.strip()
215
+
216
+
217
+ def _find_first_element(root: ElementTree.Element, local_name: str) -> ElementTree.Element | None:
218
+ for element in root.iter():
219
+ if _local_name(element.tag) == local_name:
220
+ return element
221
+ return None
222
+
223
+
224
+ def _local_name(tag: str) -> str:
225
+ return tag.rsplit("}", maxsplit=1)[-1]
@@ -0,0 +1,81 @@
1
+ """Optional settings.toml support for isemass."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import tomllib
7
+ from importlib.resources import files
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from platformdirs import user_config_dir
12
+
13
+ from isemass.defaults import DEFAULT_SETTINGS
14
+
15
+ APP_NAME = "isemass"
16
+ SETTINGS_FILENAME = "settings.toml"
17
+ TEMPLATE_PACKAGE = "isemass.templates"
18
+
19
+
20
+ class SettingsError(Exception):
21
+ """Raised when settings.toml cannot be loaded safely."""
22
+
23
+
24
+ def get_config_dir() -> Path:
25
+ """Return the platform-specific user config directory."""
26
+ return Path(user_config_dir(APP_NAME))
27
+
28
+
29
+ def get_settings_path() -> Path:
30
+ """Return the platform-specific settings.toml path."""
31
+ return get_config_dir() / SETTINGS_FILENAME
32
+
33
+
34
+ def default_settings() -> dict[str, dict[str, Any]]:
35
+ """Return a copy of the built-in settings defaults."""
36
+ return copy.deepcopy(DEFAULT_SETTINGS)
37
+
38
+
39
+ def load_settings() -> dict[str, dict[str, Any]]:
40
+ """Load settings.toml and merge it over built-in defaults."""
41
+ path = get_settings_path()
42
+ settings = default_settings()
43
+
44
+ if not path.exists():
45
+ return settings
46
+
47
+ try:
48
+ parsed = tomllib.loads(path.read_text(encoding="utf-8"))
49
+ except tomllib.TOMLDecodeError as exc:
50
+ raise SettingsError(f"Invalid TOML in {path}: {exc}") from exc
51
+ except OSError as exc:
52
+ raise SettingsError(f"Unable to read settings file {path}: {exc}") from exc
53
+
54
+ for section_name, section_values in parsed.items():
55
+ if not isinstance(section_values, dict):
56
+ raise SettingsError(
57
+ f"Invalid settings in {path}: top-level key '{section_name}' must be a table."
58
+ )
59
+ settings.setdefault(section_name, {}).update(section_values)
60
+
61
+ return settings
62
+
63
+
64
+ def load_settings_template() -> str:
65
+ """Return the packaged default settings.toml template."""
66
+ return files(TEMPLATE_PACKAGE).joinpath(SETTINGS_FILENAME).read_text(encoding="utf-8")
67
+
68
+
69
+ def write_default_settings(*, force: bool = False) -> tuple[Path, bool]:
70
+ """Write the default settings template.
71
+
72
+ Returns the settings path and a boolean indicating whether a file was written.
73
+ """
74
+ path = get_settings_path()
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+
77
+ if path.exists() and not force:
78
+ return path, False
79
+
80
+ path.write_text(load_settings_template(), encoding="utf-8")
81
+ return path, True
@@ -0,0 +1,6 @@
1
+ """Shared Rich console."""
2
+
3
+ from rich.console import Console
4
+
5
+ console = Console()
6
+
@@ -0,0 +1,16 @@
1
+ """Built-in settings defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ DEFAULT_SETTINGS: dict[str, dict[str, Any]] = {
8
+ "coa": {
9
+ "verbose": False,
10
+ "max_workers": 20,
11
+ "insecure": False,
12
+ },
13
+ "swauth": {
14
+ "verbose": False,
15
+ },
16
+ }
@@ -0,0 +1,2 @@
1
+ """Packaged templates for isemass."""
2
+
@@ -0,0 +1,25 @@
1
+ [coa]
2
+ # Turns on extra verbose logging for CoA operations.
3
+ verbose = false
4
+
5
+ # Optional API username. Passwords/tokens are intentionally not stored here.
6
+ # username = "bob-example"
7
+
8
+ # Input text file containing MAC addresses. CLI equivalent: -i/--input-file.
9
+ # input_file = "macs.txt"
10
+
11
+ # API host to connect to, typically the MnT node FQDN or IP.
12
+ # host = "ise-mnt.example.com"
13
+
14
+ # Short ISE node name that processes the CoA request, typically a PSN.
15
+ # node = "ise-psn01"
16
+
17
+ # Maximum number of concurrent workers for CoA tasks.
18
+ max_workers = 20
19
+
20
+ # Skip HTTPS certificate validation when set to true.
21
+ insecure = false
22
+
23
+ [swauth]
24
+ # Turns on extra verbose logging for switch reauthentication operations.
25
+ verbose = false