centralcli 7.2.1__tar.gz → 7.2.4__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.
Files changed (64) hide show
  1. {centralcli-7.2.1 → centralcli-7.2.4}/PKG-INFO +1 -1
  2. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/__init__.py +10 -9
  3. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cache.py +43 -31
  4. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cleaner.py +1 -1
  5. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clicommon.py +16 -14
  6. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clikick.py +27 -12
  7. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishow.py +44 -4
  8. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clitest.py +1 -1
  9. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliupdate.py +19 -19
  10. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/models.py +1 -1
  11. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/response.py +2 -1
  12. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/strings.py +7 -0
  13. {centralcli-7.2.1 → centralcli-7.2.4}/pyproject.toml +1 -1
  14. {centralcli-7.2.1 → centralcli-7.2.4}/LICENSE +0 -0
  15. {centralcli-7.2.1 → centralcli-7.2.4}/README.md +0 -0
  16. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/boilerplate/README.md +0 -0
  17. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/boilerplate/_cnx_allcalls.py +0 -0
  18. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/boilerplate/allcalls.py +0 -0
  19. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/caas.py +0 -0
  20. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/central.py +0 -0
  21. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cli.py +0 -0
  22. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliadd.py +0 -0
  23. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliassign.py +0 -0
  24. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clibatch.py +0 -0
  25. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clicaas.py +0 -0
  26. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clicheck.py +0 -0
  27. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliclone.py +0 -0
  28. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clidel.py +0 -0
  29. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clidelfirmware.py +0 -0
  30. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliexport.py +0 -0
  31. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clioptions.py +0 -0
  32. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clirefresh.py +0 -0
  33. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clirename.py +0 -0
  34. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliset.py +0 -0
  35. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clisetfirmware.py +0 -0
  36. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowaudit.py +0 -0
  37. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowbandwidth.py +0 -0
  38. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowbranch.py +0 -0
  39. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowcloudauth.py +0 -0
  40. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowfirmware.py +0 -0
  41. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowmpsk.py +0 -0
  42. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowospf.py +0 -0
  43. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowoverlay.py +0 -0
  44. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowtshoot.py +0 -0
  45. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowwids.py +0 -0
  46. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clitshoot.py +0 -0
  47. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliunassign.py +0 -0
  48. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliupgrade.py +0 -0
  49. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/config.py +0 -0
  50. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/constants.py +0 -0
  51. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/exceptions.py +0 -0
  52. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/logger.py +0 -0
  53. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/objects.py +0 -0
  54. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/render.py +0 -0
  55. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/setup.py +0 -0
  56. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/static/favicon.ico +0 -0
  57. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/typedefs.py +0 -0
  58. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/utils.py +0 -0
  59. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/vendored/csvlexer/__init__.py +0 -0
  60. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/vendored/csvlexer/csv.py +0 -0
  61. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/vscodeargs.py +0 -0
  62. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/wh2snow.py +0 -0
  63. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/wh_proxy.py +0 -0
  64. {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/wh_proxy_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: centralcli
3
- Version: 7.2.1
3
+ Version: 7.2.4
4
4
  Summary: A CLI for interacting with Aruba Central (Cloud Management Platform). Facilitates bulk imports, exports, reporting. A handy tool if you have devices managed by Aruba Central.
5
5
  Home-page: https://github.com/Pack3tL0ss/central-api-cli
6
6
  License: MIT
@@ -194,15 +194,16 @@ if "--capture-raw" in sys.argv: # captures raw responses into a flat file for l
194
194
  central = CentralApi(config.account)
195
195
  Cache.set_config(config)
196
196
  cache = Cache(central)
197
- CacheDevice.set_db(cache.DevDB)
198
- CacheInvDevice.set_db(cache.InvDB)
199
- CacheGroup.set_db(cache.GroupDB)
200
- CacheSite.set_db(cache.SiteDB)
201
- CacheClient.set_db(cache.ClientDB, cache=cache)
202
- CacheLabel.set_db(cache.LabelDB)
203
- CachePortal.set_db(cache.PortalDB)
204
- CacheTemplate.set_db(cache.TemplateDB)
205
- CacheMpskNetwork.set_db(cache.MpskDB)
197
+ if config.valid:
198
+ CacheDevice.set_db(cache.DevDB)
199
+ CacheInvDevice.set_db(cache.InvDB)
200
+ CacheGroup.set_db(cache.GroupDB)
201
+ CacheSite.set_db(cache.SiteDB)
202
+ CacheClient.set_db(cache.ClientDB, cache=cache)
203
+ CacheLabel.set_db(cache.LabelDB)
204
+ CachePortal.set_db(cache.PortalDB)
205
+ CacheTemplate.set_db(cache.TemplateDB)
206
+ CacheMpskNetwork.set_db(cache.MpskDB)
206
207
  cli = CLICommon(config.account, cache, central, raw_out=raw_out)
207
208
 
208
209
  # allow singular form and common synonyms for the defined show commands
@@ -464,6 +464,10 @@ class CachePortal(CentralObject):
464
464
  def __rich__(self) -> str:
465
465
  return f'[bright_green]Portal Profile[/]:[bright_green]{self.name}[/]|[cyan]{self.id}[/]'
466
466
 
467
+ @property
468
+ def help_text(self):
469
+ return render.rich_capture(self.__rich__())
470
+
467
471
 
468
472
  class CacheTemplate(CentralObject):
469
473
  db: Table | None = None
@@ -560,6 +564,12 @@ class CacheClient(CentralObject):
560
564
  def __rich__(self) -> str:
561
565
  return f'[bright_green]Client[/]:[cyan]{self.name}[/]|({utils.color([self.type, self.ip, self.mac, self.connected_name], "green_yellow", sep="|")}|s[green_yellow]{self.site})[/]'
562
566
 
567
+ @property
568
+ def help_text(self) -> str:
569
+ return render.rich_capture(
570
+ f"[bright_green]{self.name}[/]|[cyan]{self.mac}[/]|[bright_green]{self.ip}[/]|[cyan]{f's:{self.site}' if self.site else f'g:{self.group}'}[/]|[dark_olive_green2]{self.connected_name}[/]"
571
+ )
572
+
563
573
 
564
574
  class CacheMpskNetwork(CentralObject):
565
575
  db: Table | None = None
@@ -1349,9 +1359,9 @@ class Cache:
1349
1359
  match = [m for m in match if m.name not in args]
1350
1360
  for m in sorted(match, key=lambda i: i.name):
1351
1361
  if m.name.startswith(incomplete):
1352
- out += [tuple([m.name, m.id])]
1362
+ out += [tuple([m.name, m.help_text])]
1353
1363
  elif m.id.startswith(incomplete):
1354
- out += [tuple([m.id, m.name])]
1364
+ out += [tuple([m.id, m.help_text])]
1355
1365
  else:
1356
1366
  out += [tuple([m.name, m.help_text])] # failsafe, shouldn't hit
1357
1367
 
@@ -1959,19 +1969,18 @@ class Cache:
1959
1969
  # remove clients that are already on the command line
1960
1970
  match = [m for m in match if m.name not in args]
1961
1971
  for c in sorted(match, key=lambda i: i.name):
1962
- if c.name.startswith(incomplete):
1963
- out += c.help_text
1972
+ if c.name.lower().startswith(incomplete.lower()):
1973
+ out += [(c.name, c.help_text)]
1964
1974
  elif c.mac.strip(":.-").lower().startswith(incomplete.strip(":.-")):
1965
- out += c.help_text
1975
+ out += [(c.mac, c.help_text)]
1966
1976
  elif c.ip.startswith(incomplete):
1967
- out += c.help_text
1977
+ out += [(c.ip, c.help_text)]
1968
1978
  else:
1969
1979
  # failsafe, shouldn't hit
1970
- out += (c.help_text[0], f'{c.help_text[1]} FailSafe Match')
1980
+ out += [(c.name, f'{c.help_text} FailSafe Match')]
1971
1981
 
1972
-
1973
- for c in out: # TODO completion behavior has changed. This works-around issue bash doesn't complete past 00: and zsh treats each octet as a dev name when : is used.
1974
- yield c[0].replace(":", "-"), c[1]
1982
+ for c in out:
1983
+ yield c[0].replace(":", "-"), c[1] # TODO completion behavior has changed. This works-around issue bash doesn't complete past 00: and zsh treats each octet as a dev name when : is used.
1975
1984
 
1976
1985
  def event_log_completion(
1977
1986
  self,
@@ -3218,27 +3227,30 @@ class Cache:
3218
3227
 
3219
3228
 
3220
3229
  # no match found initiate cache update
3221
- if retry and not match and ((dev_type and not cache_updated) or self.responses.dev is None):
3222
- dev_type_sfx = "" if not dev_type else f" [grey42 italic](Device Type: {utils.unlistify(dev_type)})[/]"
3223
- econsole.print(f"[dark_orange3]:warning:[/] [bright_red]No Match found[/] for [cyan]{query_str}[/]{dev_type_sfx}.")
3224
- if FUZZ:
3225
- if dev_type:
3226
- fuzz_match, fuzz_confidence = process.extract(query_str, [d["name"] for d in self.devices if d["type"] in dev_type], limit=1)[0]
3227
- else:
3228
- fuzz_match, fuzz_confidence = process.extract(query_str, [d["name"] for d in self.devices], limit=1)[0]
3229
- confirm_str = render.rich_capture(f"Did you mean [green3]{fuzz_match}[/]?")
3230
- if fuzz_confidence >= 70 and typer.confirm(confirm_str):
3231
- match = self.DevDB.search(self.Q.name == fuzz_match)
3232
- if not match:
3233
- kwargs = {"dev_db": True}
3234
- if include_inventory:
3235
- _word = " & Inventory "
3236
- kwargs["inv_db"] = True
3237
- else:
3238
- _word = " "
3239
- econsole.print(f":arrows_clockwise: Updating Device{_word}Cache.")
3240
- self.check_fresh(refresh=True, dev_type=dev_type, **kwargs )
3241
- cache_updated = True # Need this for scenario when dev_type is the only thing refreshed, as that does not update self.responses.dev
3230
+ if retry and not match and self.responses.dev is None:
3231
+ if dev_type and cache_updated:
3232
+ ... # self.responses.dev is not currently updated if dev_type provided, but cache update may have already occured in this session.
3233
+ else:
3234
+ dev_type_sfx = "" if not dev_type else f" [grey42 italic](Device Type: {utils.unlistify(dev_type)})[/]"
3235
+ econsole.print(f"[dark_orange3]:warning:[/] [bright_red]No Match found[/] for [cyan]{query_str}[/]{dev_type_sfx}.")
3236
+ if FUZZ:
3237
+ if dev_type:
3238
+ fuzz_match, fuzz_confidence = process.extract(query_str, [d["name"] for d in self.devices if d["type"] in dev_type], limit=1)[0]
3239
+ else:
3240
+ fuzz_match, fuzz_confidence = process.extract(query_str, [d["name"] for d in self.devices], limit=1)[0]
3241
+ confirm_str = render.rich_capture(f"Did you mean [green3]{fuzz_match}[/]?")
3242
+ if fuzz_confidence >= 70 and typer.confirm(confirm_str):
3243
+ match = self.DevDB.search(self.Q.name == fuzz_match)
3244
+ if not match:
3245
+ kwargs = {"dev_db": True}
3246
+ if include_inventory:
3247
+ _word = " & Inventory "
3248
+ kwargs["inv_db"] = True
3249
+ else:
3250
+ _word = " "
3251
+ econsole.print(f":arrows_clockwise: Updating Device{_word}Cache.")
3252
+ self.check_fresh(refresh=True, dev_type=dev_type, **kwargs )
3253
+ cache_updated = True # Need this for scenario when dev_type is the only thing refreshed, as that does not update self.responses.dev
3242
3254
 
3243
3255
  if match:
3244
3256
  match = [Model(dev) for dev in match]
@@ -1626,7 +1626,7 @@ def show_interfaces(data: List[dict] | dict, verbosity: int = 0, dev_type: DevTy
1626
1626
  key_order = verbosity_keys[verbosity]
1627
1627
  # send all key/value pairs through formatters
1628
1628
  data = [
1629
- dict(short_value(k, d.get(k),) for k in key_order) for d in data
1629
+ dict(short_value(k, d[k],) for k in key_order if k in d) for d in data
1630
1630
  ]
1631
1631
  else:
1632
1632
  key_order = [*key_order, *data[-1].keys()]
@@ -383,8 +383,7 @@ class CLICommon:
383
383
  else:
384
384
  return default
385
385
 
386
- @staticmethod
387
- def write_file(outfile: Path, outdata: str) -> None:
386
+ def write_file(self, outfile: Path, outdata: str) -> None:
388
387
  """Output data to file
389
388
 
390
389
  Args:
@@ -407,19 +406,22 @@ class CLICommon:
407
406
 
408
407
  print(f"\n[cyan]Writing output to {outfile}... ", end="")
409
408
 
410
- out_msg = None
411
- try:
412
- if isinstance(outdata, (dict, list)):
413
- outdata = json.dumps(outdata, indent=4)
414
- outfile.write_text(outdata) # typer.unstyle(outdata) also works
415
- except Exception as e:
416
- outfile.write_text(f"{outdata}")
417
- out_msg = f"Error ({e.__class__.__name__}) occurred during attempt to output to file. " \
418
- "Used simple string conversion"
409
+ if not outfile.parent.is_dir():
410
+ self.econsole.print(f"[red]Directory Not Found[/]\n[dark_orange3]:warning:[/] Unable to write output to [cyan]{outfile.name}[/].\nDirectory [cyan]{str(outfile.parent.absolute())}[/] [red]does not exist[/].")
411
+ else:
412
+ out_msg = None
413
+ try:
414
+ if isinstance(outdata, (dict, list)):
415
+ outdata = json.dumps(outdata, indent=4)
416
+ outfile.write_text(outdata) # typer.unstyle(outdata) also works
417
+ except Exception as e:
418
+ outfile.write_text(f"{outdata}")
419
+ out_msg = f"Error ({e.__class__.__name__}) occurred during attempt to output to file. " \
420
+ "Used simple string conversion"
419
421
 
420
- print("[italic green]Done")
421
- if out_msg:
422
- log.warning(out_msg, show=True)
422
+ print("[italic green]Done")
423
+ if out_msg:
424
+ log.warning(out_msg, show=True)
423
425
 
424
426
  @staticmethod
425
427
  def exit(msg: str = None, code: int = 1, emoji: bool = True) -> None:
@@ -8,7 +8,6 @@ from rich import print
8
8
 
9
9
 
10
10
  # Detect if called from pypi installed package or via cloned github repo (development)
11
- # TODO should be able to do this in __init__
12
11
  try:
13
12
  from centralcli import cli
14
13
  except (ImportError, ModuleNotFoundError) as e:
@@ -19,16 +18,18 @@ except (ImportError, ModuleNotFoundError) as e:
19
18
  else:
20
19
  print(pkg_dir.parts)
21
20
  raise e
22
- from centralcli.constants import IdenMetaVars
23
21
 
24
- iden = IdenMetaVars()
22
+ from centralcli.constants import iden_meta
23
+ from .cache import CacheClient
24
+ from .models import Clients
25
+
25
26
  app = typer.Typer()
26
27
 
27
28
  @app.command(short_help="Disconnect all WLAN clients from an AP optionally for a specific SSID",)
28
29
  def all(
29
30
  device: str = typer.Argument(
30
31
  ...,
31
- metavar=iden.dev,
32
+ metavar=iden_meta.dev,
32
33
  help="The AP to disconnect clients from",
33
34
  autocompletion=cli.cache.dev_ap_completion,
34
35
  show_default=False,
@@ -46,7 +47,7 @@ def all(
46
47
  dev = cli.cache.get_dev_identifier(device)
47
48
  _ssid_msg = "" if not ssid else f" on SSID [cyan]{ssid}[/]"
48
49
  print(f'Kick [bright_red]ALL[/] users connected to [cyan]{dev.name}[/]{_ssid_msg}')
49
- if yes or typer.confirm("\nproceed?", abort=True):
50
+ if cli.confirm(yes):
50
51
  resp = cli.central.request(
51
52
  cli.central.kick_users,
52
53
  dev.serial,
@@ -56,12 +57,10 @@ def all(
56
57
  cli.display_results(resp, tablefmt="action")
57
58
 
58
59
 
59
- # TODO rather than drop option have cache remove users with last_connected > 30 days
60
60
  @app.command(short_help="Disconnect a WLAN client",)
61
61
  def client(
62
- client: str = typer.Argument(..., metavar=iden.client, autocompletion=cli.cache.client_completion, show_default=False),
62
+ client: str = typer.Argument(..., metavar=iden_meta.client, autocompletion=cli.cache.client_completion, show_default=False),
63
63
  refresh: bool = typer.Option(False, "--refresh", "-R", help="Cache is used to determine what AP the client is connected to, which could be [red]stale[/]. This forces a cache update."),
64
- drop: bool = typer.Option(False, "--drop", "-D", help="(implies -R): Drop all users from existing cache, then refresh. By default any user that has ever connected is retained in the cache."),
65
64
  yes: bool = cli.options.yes,
66
65
  debug: bool = cli.options.debug,
67
66
  default: bool = cli.options.default,
@@ -75,15 +74,31 @@ def client(
75
74
 
76
75
  The [cyan]-R[/] flag can be used to force a cache refresh prior to performing the disconnect.
77
76
  """
78
- if refresh or drop:
79
- resp = cli.central.request(cli.cache.refresh_client_db, "wireless", truncate=drop)
77
+ if refresh:
78
+ resp = cli.central.request(cli.cache.refresh_client_db, client_type="wireless")
80
79
  if not resp:
81
80
  cli.display_results(resp, exit_on_fail=True)
82
81
 
83
- client = cli.cache.get_client_identifier(client, exit_on_fail=True)
82
+ client: CacheClient = cli.cache.get_client_identifier(client, exit_on_fail=True)
83
+ if not client.last_connected:
84
+ if refresh:
85
+ cli.exit(f"Client {client} is not connected.")
86
+ else:
87
+ client_resp = cli.central.request(cli.cache.refresh_client_db, mac=client.mac)
88
+ if not client_resp:
89
+ cli.econsole.print(f"client {client} is not online according to cache, Failure occured attempting to fetch client details from API.")
90
+ cli.display_results(client_resp, exit_on_fail=True)
91
+
92
+ _clients = [CacheClient(c) for c in Clients(client_resp.output)]
93
+ online_client = [c for c in _clients if c.last_connected is not None]
94
+ if online_client:
95
+ client = online_client[-1]
96
+ else:
97
+ client = _clients[-1]
98
+ cli.exit(f"Client {client} is not online: Failure Stage: {client_resp.output[-1].get('failure_stage', '')}, Reason: {client_resp.output[-1].get('failure_reason', '')}")
84
99
 
85
100
  print(f'Kick client [cyan]{client.name}[/], currently connected to [cyan]{client.connected_name}[/]')
86
- if yes or typer.confirm("\nProceed?", abort=True):
101
+ if cli.confirm(yes):
87
102
  resp = cli.central.request(
88
103
  cli.central.kick_users,
89
104
  client.connected_serial,
@@ -10,6 +10,8 @@ import os
10
10
  from datetime import datetime
11
11
  from typing import List, Iterable, Literal, Dict, Any, Tuple, TYPE_CHECKING
12
12
  from pathlib import Path
13
+ import getpass
14
+ from jinja2 import Template
13
15
  from rich import print
14
16
  from rich.console import Console
15
17
 
@@ -48,6 +50,7 @@ from centralcli.constants import (
48
50
  )
49
51
  from centralcli.cache import CentralObject
50
52
  from centralcli.objects import DateTime
53
+ from .strings import cron_weekly
51
54
 
52
55
  if TYPE_CHECKING:
53
56
  from .cache import CacheSite, CacheGroup, CacheLabel, CacheDevice
@@ -163,7 +166,7 @@ def _build_device_caption(resp: Response, *, inventory: bool = False, dev_type:
163
166
 
164
167
  # Put together counts caption string
165
168
  if status:
166
- _cnt_str = ", ".join([f'[{"bright_green" if status.lower() == "up" else "red"}]{t}[/]: [cyan]{status_by_type[t]["total"]}[/]' for t in status_by_type])
169
+ _cnt_str = ", ".join([f'[{"bright_green" if status.lower() == "up" else "red"}]{status.capitalize()} {t if t != "ap" else "APs"}[/]: [cyan]{status_by_type[t]["total"]}[/]' for t in status_by_type])
167
170
  elif inventory:
168
171
  _cnt_str = f"Total in inventory: [cyan]{len(resp.output)}[/], "
169
172
  _cnt_str = _cnt_str + ", ".join(
@@ -558,7 +561,7 @@ def aps(
558
561
  if up and down:
559
562
  ... # They used both flags. ignore
560
563
  elif up or down:
561
- status = "Down" if down else "Up"
564
+ status = "down" if down else "up"
562
565
 
563
566
  show_devices(
564
567
  aps, dev_type="ap", include_inventory=with_inv, verbosity=verbose, outfile=outfile, update_cache=update_cache, group=group, site=site, label=label, status=status,
@@ -2009,6 +2012,7 @@ def wlans(
2009
2012
  "calculate_client_count": True,
2010
2013
  }
2011
2014
 
2015
+ # TODO specifying WLAN name ... is ignored if verbose
2012
2016
  tablefmt = cli.get_format(do_json=do_json, do_yaml=do_yaml, do_csv=do_csv, do_table=do_table, default="rich")
2013
2017
  if group: # Specifying the group implies verbose (same # of API calls either way.)
2014
2018
  resp = central.request(central.get_full_wlan_list, group)
@@ -2032,7 +2036,7 @@ def wlans(
2032
2036
  else:
2033
2037
  resp = central.request(central.get_wlans, **params)
2034
2038
  caption = None
2035
- if resp:
2039
+ if resp and not name:
2036
2040
  caption = [f'[green]{len(resp.output)}[/] SSIDs, [green]{sum([wlan.get("client_count", 0) for wlan in resp.output])}[/] Wireless Clients.']
2037
2041
  caption += ["Summary Output, Specify the group ([cyan]--group GROUP[/])", "or use the verbose flag ([cyan]`-v`[/]) for additional details"]
2038
2042
  cli.display_results(resp, tablefmt=tablefmt, title=title, caption=caption, pager=pager, outfile=outfile, sort_by=sort_by, reverse=reverse, cleaner=cleaner.get_wlans)
@@ -2875,7 +2879,7 @@ def portals(
2875
2879
  # TODO add sort_by completion, portal completion
2876
2880
  @app.command()
2877
2881
  def guests(
2878
- portal: str = typer.Argument(..., help="portal name", show_default=False,),
2882
+ portal: str = typer.Argument(..., help="portal name", autocompletion=cli.cache.portal_completion, show_default=False,),
2879
2883
  sort_by: str = cli.options.sort_by,
2880
2884
  reverse: bool = cli.options.reverse,
2881
2885
  do_json: bool = cli.options.do_json,
@@ -3017,6 +3021,42 @@ def version(
3017
3021
  """
3018
3022
  cli.version_callback()
3019
3023
 
3024
+ @app.command(hidden=os.name != "posix")
3025
+ def cron(
3026
+ accounts: List[str] = typer.Argument(None,),
3027
+ ) -> None:
3028
+ """Show contents of cron file that can be used to automate token refresh weekly.
3029
+
3030
+ This will keep the tokens valid, even if cencli is not used.
3031
+ """
3032
+ if os.name != "posix":
3033
+ cli.econsole.print("This command is currently only supported on Linux using cron. It is possible to do the same via Windows Task Scheduler. Showing Linux cron.weekly output for reference.")
3034
+
3035
+ user = getpass.getuser()
3036
+ exec_path = sys.argv[0]
3037
+ py_path = sys.executable
3038
+
3039
+ config_data = {
3040
+ "user": user,
3041
+ "py_path": py_path,
3042
+ "exec_path": exec_path,
3043
+ "accounts": "" if not accounts else " ".join(accounts)
3044
+ }
3045
+
3046
+ template = Template(cron_weekly)
3047
+ config_out = template.render(config_data)
3048
+
3049
+ cli.econsole.rule("/etc/cron.weekly/cencli file contents")
3050
+ cli.console.print(config_out)
3051
+ cli.econsole.rule()
3052
+
3053
+ cli.econsole.print(
3054
+ "Place the above contents into a file: /etc/cron.weekly/cencli [grey42 italic](requires sudo)[/]\n"
3055
+ f"Alternatively you can pipe the output directly [cyan]cencli show cron {'' if not accounts else ' '.join(accounts)} | sudo tee /etc/cron.weekly/cencli[/]"
3056
+ "\nThen make it executable: [cyan]sudo chmod +x /etc/cron.weekly/cencli[/]"
3057
+ "\n\n[cyan]cencli refresh token[/] [dark_olive_green2 italic]command will always update the tokens for the default workspace (that's the -d flag)[/]"
3058
+ )
3059
+
3020
3060
 
3021
3061
  def _get_cencli_config() -> None:
3022
3062
  try:
@@ -167,7 +167,7 @@ def method(
167
167
  resp = _check_bool_to_str(args, kwargs, resp_str=str(e))
168
168
 
169
169
  attrs = {
170
- k: v for k, v in resp.__dict__.items() if k not in ["output", "raw"] and (log.DEBUG or not k.startswith("_"))
170
+ k: v for k, v in resp.__dict__.items() if k not in ["output", "raw", "data_key"] and (log.DEBUG or not k.startswith("_"))
171
171
  }
172
172
 
173
173
  req = (
@@ -3,29 +3,34 @@
3
3
 
4
4
  from pathlib import Path
5
5
  import sys
6
- from typing import List, Union
6
+ from typing import TYPE_CHECKING, List, Union
7
7
  # from typing import List
8
8
  import typer
9
9
  from rich import print
10
10
  from rich.console import Console
11
+ from rich.text import Text
11
12
  from jinja2 import FileSystemLoader, Environment
12
13
  import yaml
13
14
 
14
15
  # Detect if called from pypi installed package or via cloned github repo (development)
15
16
  try:
16
- from centralcli import utils, cli, caas, cleaner, BatchRequest, log
17
+ from centralcli import utils, cli, cleaner, BatchRequest, log
17
18
  except (ImportError, ModuleNotFoundError) as e:
18
19
  pkg_dir = Path(__file__).absolute().parent
19
20
  if pkg_dir.name == "centralcli":
20
21
  sys.path.insert(0, str(pkg_dir.parent))
21
- from centralcli import utils, cli, caas, cleaner, BatchRequest, log
22
+ from centralcli import utils, cli, cleaner, BatchRequest, log
22
23
  else:
23
24
  print(pkg_dir.parts)
24
25
  raise e
25
26
 
26
- from centralcli.constants import IdenMetaVars, DevTypes, GatewayRole, state_abbrev_to_pretty
27
- from centralcli import CentralObject
27
+ from .constants import IdenMetaVars, DevTypes, GatewayRole, state_abbrev_to_pretty
28
+ from . import render
28
29
  from .cache import CacheTemplate
30
+ from .caas import CaasAPI
31
+
32
+ if TYPE_CHECKING:
33
+ from .cache import CacheDevice, CacheGroup
29
34
 
30
35
 
31
36
  SPIN_TXT_AUTH = "Establishing Session with Aruba Central API Gateway..."
@@ -277,7 +282,6 @@ def generate_template(template_file: Union[Path, str], var_file: Union[Path, str
277
282
  @app.command("config")
278
283
  def config_(
279
284
  group_dev: str = cli.arguments.group_dev,
280
- # TODO collect multi-line input as option to paste in config
281
285
  cli_file: Path = typer.Argument(..., help="File containing desired config/template in CLI format.", exists=True, autocompletion=lambda incomplete: tuple(), show_default=False,),
282
286
  var_file: Path = typer.Argument(None, help="File containing variables for j2 config template.", exists=True, autocompletion=lambda incomplete: tuple(), show_default=False,),
283
287
  do_gw: bool = typer.Option(None, "--gw", help="Update group level config for gateways."),
@@ -289,18 +293,12 @@ def config_(
289
293
  ) -> None:
290
294
  """Update group or device level config (ap or gw).
291
295
  """
292
- group_dev: CentralObject = cli.cache.get_identifier(group_dev, qry_funcs=["group", "dev"], device_type=["ap", "gw"])
296
+ group_dev: CacheDevice | CacheGroup = cli.cache.get_identifier(group_dev, qry_funcs=["group", "dev"], device_type=["ap", "gw"])
293
297
  config_out = utils.generate_template(cli_file, var_file=var_file)
294
298
  cli_cmds = utils.validate_config(config_out)
295
299
 
296
- # TODO render.py module with helper function to return styled rule/line
297
- console = Console(record=True, emoji=False)
298
- console.begin_capture()
299
- console.rule("Configuration to be sent")
300
- console.print("\n".join([f"[green]{line}[/green]" for line in cli_cmds]))
301
- console.rule()
302
- console.print(f"\nUpdating {'group' if group_dev.is_group else group_dev.generic_type.upper()} [cyan]{group_dev.name}[/]")
303
- _msg = console.end_capture()
300
+ output = render.output(cli_cmds)
301
+ output = Text.from_ansi(output.tty)
304
302
 
305
303
  if group_dev.is_group:
306
304
  device = None
@@ -313,7 +311,7 @@ def config_(
313
311
  if device and device.generic_type != "gw":
314
312
  cli.exit(f"Invalid input: --gw option conflicts with {device.name} which is an {device.generic_type}")
315
313
  use_caas = True
316
- caasapi = caas.CaasAPI(central=cli.central) # XXX Burried import
314
+ caasapi = CaasAPI(central=cli.central)
317
315
  node_iden = group_dev.name if group_dev.is_group else group_dev.mac
318
316
  elif do_ap or (device and device.generic_type == "ap"):
319
317
  if device and device.generic_type != "ap":
@@ -321,7 +319,10 @@ def config_(
321
319
  use_caas = False
322
320
  node_iden = group_dev.name if group_dev.is_group else group_dev.serial
323
321
 
324
- typer.echo(_msg)
322
+ cli.console.rule("Configuration to be sent")
323
+ cli.console.print(output, emoji=False)
324
+ cli.console.rule()
325
+ cli.console.print(f"\nUpdating {'group' if group_dev.is_group else group_dev.generic_type.upper()} [cyan]{group_dev.name}[/]")
325
326
  if cli.confirm(yes):
326
327
  if use_caas:
327
328
  resp = cli.central.request(caasapi.send_commands, node_iden, cli_cmds)
@@ -329,8 +330,7 @@ def config_(
329
330
  else:
330
331
  # FIXME this is OK for group level ap config , for AP this method is not valid
331
332
  if group_dev.is_dev:
332
- print("Not Implemented yet for AP device level updates")
333
- raise typer.Exit(1)
333
+ cli.exit("Not Implemented yet for AP device level updates")
334
334
  resp = cli.central.request(cli.central.replace_ap_config, node_iden, cli_cmds)
335
335
  cli.display_results(resp, tablefmt="action")
336
336
 
@@ -411,7 +411,7 @@ class Client(BaseModel):
411
411
  @classmethod
412
412
  def pretty_dt(cls, dt: datetime) -> DateTime:
413
413
  if dt is None: # TODO all with potential for there not to be a value need this
414
- return None
414
+ return DateTime(None, "timediff") # resolves PydanticSerializationError when model_dump_json is called on client with None for last_connected (Failed)
415
415
 
416
416
  return DateTime(dt.timestamp(), "timediff")
417
417
 
@@ -383,7 +383,8 @@ class Response:
383
383
  r = r.replace("message: ", "").replace(self.output["message"], f'[red italic]{self.output["message"]}[/]')
384
384
 
385
385
  r = r.replace("failed:", "[red]failed[/]:").replace("FAILED", "[red]FAILED[/red]").replace("failed_devices", "[red]failed_devices[/]").replace("INVALID", "[red]INVALID[/]")
386
- r = r.replace("SUCCESS", "[bright_green]SUCCESS[/]").replace("Success", "[bright_green]Success[/]").replace("success", "[bright_green]success[/]").replace("succeeded_devices", "[bright_green]succeeded_devices[/]")
386
+ r = r.replace("SUCCESS", "[bright_green]SUCCESS[/]").replace("Success", "[bright_green]Success[/]").replace("Success[/]fully", "Successfully[/]")
387
+ r = r.replace("Success", "[bright_green]Success[/]").replace("success", "[bright_green]success[/]").replace("succeeded_devices", "[bright_green]succeeded_devices[/]")
387
388
  r = r.replace("invalid_device", "[red]invalid_device[/]").replace("blocked_device", "[red]blocked_device[/red]").replace("ATHENA_ERROR_DEVICE_ALREADY_EXIST", "[italic dark_orange3]Device already exists[/]")
388
389
 
389
390
  # sanitize sensitive data for demos
@@ -589,3 +589,10 @@ class ImportExamples:
589
589
  if key not in self.__dict__.keys():
590
590
  log.error(f"An attempt was made to get {key} attr from ImportExamples which is not defined.")
591
591
  return f":warning: [bright_red]Error[/] no str defined for [cyan]ImportExamples.{key}[/]"
592
+
593
+ cron_weekly = """#!/usr/bin/env bash
594
+
595
+ /bin/su -c "{{py_path}} {{exec_path}} refresh token -d {{accounts}}" {{user}} &&
596
+ logger -t centralcli "Token Refreshed via cron" ||
597
+ logger -t centralcli "Token Refresh returned error"
598
+ """
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "centralcli"
3
- version = "7.2.1"
3
+ version = "7.2.4"
4
4
  description = "A CLI for interacting with Aruba Central (Cloud Management Platform). Facilitates bulk imports, exports, reporting. A handy tool if you have devices managed by Aruba Central."
5
5
  license = "MIT"
6
6
  authors = ["Wade Wells (Pack3tL0ss) <wade@consolepi.org>"]
File without changes
File without changes
File without changes