Managing Sisense Plugin States via the REST API Overview Manually enabling and disabling Sisense plugins through the admin interface can become time consuming, particularly when keeping multiple Sisense server instances in a consistent state, switching active plugin lists quickly for testing, restoring a previous known working plugin list after an upgrade or support session, or iterating on a plugin during development where having only one plugin active significantly reduces build time for any plugin changes to become active. This article describes a Python command line (CLI) tool that manages Sisense plugin states via the Sisense REST API, using a simple configuration file as the config. The tool supports enabling and disabling plugins, isolating (enabling only one and disabling all others) a single plugin for faster development builds, saving and restoring named snapshots of plugin state, and previewing changes before applying them. This tool does not install or uninstall plugins, and does not update plugins or upgrade/downgrade or modify the code within the plugins. The script, configuration file, and dependencies are included in this article. Sisense instances often have many plugins active, with the enabled list changing over time. Common scenarios include: Plugins being left enabled or disabled after support sessions or testing, or enabling or disabling all plugins The need to quickly reach a server plugin state with no plugins enabled, to isolate whether a plugin is causing a potential issue Maintaining a consistent plugin list across multiple Sisense environments or after upgrades Needing to roll back to a previous plugins active state after a change is potentially creating issues (This tool does not rollback plugin versions, only active plugin lists) Performing these operations manually through the Sisense UI admin panel is practical for occasional changes, but becomes inefficient when the changes are frequent, when many plugins are involved, or when the same configuration needs to be applied reliably and repeatably. Description The plugin manager script reads an enable_plugins list from a configuration file and syncs the Sisense instance to match via the REST API. The script: Enables plugins listed in enable_plugins in the configuration file Optionally disables plugins listed in disable_plugins Leaves all other plugins in their current state by default Saves a timestamped snapshot of the current plugin state before applying any changes, providing an automatic rollback point Supports restoring any saved snapshot to return to a previous state A strict mode is also available, which disables any currently enabled plugin that is not listed in enable_plugins , making the instance exactly match the configuration file. The script also supports previewing what would change without making any API calls, and can save named snapshots for later reference or comparison. Plugin name matching Plugin names in the configuration file accept either the short form ( SwitchDimension ) or the full form ( plugin-SwitchDimension ). Matching is case-insensitive. This applies both to the configuration file and to command line arguments. Prerequisites Python 3.7 or later Network access to the Sisense instance (The tool runs remotely and does not need to be installed directly on the server, or SSH access) A Sisense API token, which can be generated from Admin > REST API > v1.0 > GET /authentication/tokens/api Setup Install the required Python packages: pip install requests pyyaml Create a config.yaml and fill in the Sisense instance URL and API token: sisense:
url: https://your-sisense-instance.com
token: YOUR_API_TOKEN_HERE
Add the plugins to enable to the enable_plugins list. See the Configuration reference section for all available settings. The script is then ready to run. Configuration reference sisense:
url: https://your-sisense-instance.com
token: YOUR_API_TOKEN_HERE # Admin → REST API → v1.0 → GET /authentication/tokens/api
bulk: true # set false to update plugins one at a time instead of a single batch request
# Plugins to enable.
# Accepts short names (SwitchDimension) or full names (plugin-SwitchDimension).
# Matching is case-insensitive.
#
# Default behavior: enable listed plugins; leave every other plugin unchanged.
# With --strict: also disable any currently-enabled plugin NOT in this list,
# making the instance exactly match this config.
enable_plugins:
- examplePlugin
# Plugins to explicitly disable (optional).
# Only needed in the default sync (no --strict), where unlisted plugins are
# left alone. Listing a plugin here ensures it stays off and documents the
# intentional exclusion.
# disable_plugins:
# - anotherExamplePlugin
enable_plugins : the list of plugins to enable. By default, plugins not on this list are left in their current state. With --strict , any currently enabled plugin not on this list will be disabled. disable_plugins : optional. Plugins to explicitly disable in the default sync without --strict . This is useful for documenting intentional exclusions when strict mode is not being used. bulk : controls whether plugin state changes are sent as a single batch API request or one at a time. Set to false for individual requests, which is slower but can be useful for diagnosing issues with specific plugins. Usage Run the script with no arguments to enable plugins in enable_plugins , disable plugins in disable_plugins , and leave everything else unchanged: python plugin_manager.py
Before making any changes, the script saves a timestamped snapshot of the current plugin state. To restore the most recent snapshot: python plugin_manager.py --restore-snapshot
To preview what would change without making any API calls: python plugin_manager.py --dry-run
Common Use Cases Maintaining a known good plugin baseline Organizations that need to ensure consistent plugin configuration across upgrades, support sessions, or environment changes can maintain an enable_plugins list in the configuration file. Running the script applies the defined configuration to the instance. To also disable any currently enabled plugin not in the list: python plugin_manager.py --strict
Testing on a stock Sisense instance Plugin interference is a common source of issues that are hard to reproduce. To quickly disable all plugins and test against a clean instance, clear enable_plugins in the configuration file and run with --strict . The current state is saved automatically before any changes are made, and can be restored when testing is complete: # with enable_plugins cleared in config.yaml
python plugin_manager.py --strict
# restore when done
python plugin_manager.py --restore-snapshot
Developing and testing a single plugin When many plugins are installed, Sisense rebuilds all of them whenever any plugin changes. Using --enable-one disables all currently enabled plugins and enables only the specified plugin, which can significantly reduce rebuild time on instances with many plugins: python plugin_manager.py --enable-one MyPlugin
Snapshots The script saves snapshots as YAML files to a snapshots/ folder alongside the script. Snapshots capture the full enabled and disabled plugin state at a point in time. Snapshots are saved automatically before any run that changes state. Named snapshots can also be saved and restored explicitly: # save the current state with a specific name
python plugin_manager.py --save-snapshot before-upgrade
# list all saved snapshots
python plugin_manager.py --list-snapshots
# restore a specific snapshot
python plugin_manager.py --restore-snapshot before-upgrade
Including --log-version when saving a snapshot records the version of each plugin at that point in time, which can be useful for tracking plugin versions across upgrades. Full option reference usage: plugin_manager.py [-h] [--config FILE] [--dry-run] [--no-bulk]
[--skip-snapshot] [--log-version] [--strict]
[--enable-only | --disable-only]
[--enable-one PLUGIN | --save-snapshot [NAME] |
--list-snapshots | --restore-snapshot [NAME]]
Manage Sisense plugin states via the REST API.
options:
-h, --help show this help message and exit
--config FILE Path to config.yaml (default: config.yaml next to this
script)
--dry-run Preview what would change; make no API calls and save
no snapshot
--no-bulk Send one API call per plugin instead of a single batch
PATCH. Slower but easier to diagnose partial failures.
Overrides sisense.bulk in config.
--skip-snapshot Do not automatically save a snapshot before this run
--log-version Record each plugin's version field (from the API
response) in any snapshot saved during this run
(manual --save-snapshot or auto saved snapshot)
--strict Also disable every currently enabled plugin that is
not listed in enable_plugins. Without this flag, only
plugins in disable_plugins are disabled; everything
else is left alone. Always active for --restore-
snapshot.
--enable-only Run only the enable step; skip all disables
--disable-only Run only the disable step; skip all enables
--enable-one PLUGIN Enable exactly one plugin by name and disable all
currently enabled others. Accepts the short or full
plugin name.
--save-snapshot [NAME]
Save the current plugin state as a named snapshot.
Omit NAME for an auto generated timestamp filename.
Combine with --log-version to also record versions for
each plugin.
--list-snapshots List all saved snapshots with creation date and
enabled plugin count
--restore-snapshot [NAME]
Restore plugin state to exactly match a saved
snapshot. Omit NAME to use the most recently created
snapshot. Always strict (disables plugins not in
snapshot) unless --enable-only.
Plugin names accept the short form (SwitchDimension) or the full
form (plugin-SwitchDimension) in both CLI args and config.yaml.
Default behavior: enable plugins in enable_plugins, disable plugins
in disable_plugins, leave everything else unchanged.
Use --strict to also disable any enabled plugin not in enable_plugins.
A snapshot is saved automatically before each run that
modifies state. Use --skip-snapshot to suppress this.
Use Case Managing plugins via the REST API and a configuration file offers several advantages over manual administration: Applies plugin active list consistently and repeatably without manual steps Provides automatic rollback through snapshots saved before each change Supports quickly switching between plugin states for testing or development Reduces the time required to restore a known working plugin list after an issue is resolved Gives server administrators a record of plugin state changes over time (done via the tool and saved via snapshots) Summary With this tool in place, administrators and developers can manage Sisense plugin states quickly and reliably from the command line. The configuration file serves as a single source of truth for the intended plugin set, and the snapshot system ensures that any change can be reversed. Whether maintaining a consistent production environment, isolating issues related to plugins, or iterating on plugin development, the tool provides a practical and repeatable approach to plugin state management. The code is not compressed or obfuscated and can be modified as needed or used as a reference for similar tools. Full Python Code: """
Sisense Plugin Manager: manage Sisense plugin states via the REST API.
Overview
Manages Sisense plugin states via the REST API. Treats plugin configuration
as code: define which plugins should be enabled in config.yaml, run the script,
and the instance matches, quickly, from anywhere with network access.
Before applying any changes, the current plugin state is automatically captured
in a timestamped snapshot. If a change needs to be reverted, a single
--restore-snapshot command rolls the instance back to exactly how it was.
By default only the plugins explicitly listed in config.yaml are enabled or disabled,
everything else is left as is. Use --strict to also disable any enabled plugin
not in the list, making the instance enabled plugin list exactly match the config.
Common use cases
Maintain a known good plugin baseline
Keep enable_plugins in config.yaml up to date. Run the script after
upgrades, support sessions, or any time the instance may have drifted
from the expected state.
Test against a stock (no plugins) Sisense instance
Eliminate plugins as a potential cause of a bug or issue. Clear
enable_plugins in config.yaml, run --strict to disable everything, test,
then restore when done, the snapshot is saved automatically before changes:
# (clear enable_plugins in config.yaml)
python plugin_manager.py --strict
# ... reproduce the issue on a vanilla instance ...
python plugin_manager.py --restore-snapshot # restores most recent
Develop and iterate on a single plugin quickly
With many plugins installed Sisense rebuilds all of them on each change.
--enable-one isolates a single plugin and disables the rest, cutting
rebuild time to a fraction on instances with many plugins:
python plugin_manager.py --enable-one MyPlugin
Roll back any change instantly
Every run saves a timestamped snapshot of the state it found before
making changes. Use --list-snapshots to find an earlier state and
--restore-snapshot to return to it.
Usage
# Enable listed plugins, disable listed disable_plugins, leave rest unchanged
python plugin_manager.py
# Strict: also disables any enabled plugin not in enable_plugins
python plugin_manager.py --strict
# Preview what would change (no API calls, no auto snapshot)
python plugin_manager.py --dry-run
python plugin_manager.py --strict --dry-run
# Run only one half of the sync
python plugin_manager.py --enable-only # enable listed; skip all disables
python plugin_manager.py --disable-only # disable listed; skip all enables
# Enable exactly one plugin and disable every other currently enabled plugin
python plugin_manager.py --enable-one SwitchDimension
# Snapshots
python plugin_manager.py --save-snapshot [NAME] # name is optional
python plugin_manager.py --save-snapshot [NAME] --log-version # also record per-plugin versions
python plugin_manager.py --list-snapshots
python plugin_manager.py --restore-snapshot [NAME] # omit NAME → most recent
# Other flags (combinable with any of the above)
--no-bulk one API call per plugin instead of a single batch request
--skip-snapshot do not save an auto-snapshot before this run
--log-version record each plugin's version in any snapshot saved this run
--config FILE use a config file other than the default config.yaml
Plugin names
All plugin names accept either the short form (SwitchDimension) or the full
form (plugin-SwitchDimension), both in CLI arguments and in config.yaml.
Matching is case insensitive.
API token
Admin → REST API → v1.0 → GET /authentication/tokens/api
Setup
pip install requests pyyaml # Python 3.7+ required
cp config.yaml.example config.yaml
# Edit config.yaml: set sisense.url, sisense.token, and enable_plugins list
"""
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import requests
import yaml
# Constants
SCRIPT_DIR = Path(__file__).parent
DEFAULT_CONFIG_PATH = SCRIPT_DIR / "config.yaml"
SNAPSHOTS_DIR = SCRIPT_DIR / "snapshots"
PLUGIN_PAGE_SIZE = 20 # plugins returned per page by GET /api/v1/plugins
# Config helpers
def load_config(config_path: Path) -> dict:
"""Load and return the YAML config file, printing an error and exiting if missing."""
if not config_path.exists():
print(f"Config file not found: {config_path}")
print("Copy config.yaml.example to config.yaml and fill in your instance URL and API token.")
sys.exit(1)
with config_path.open() as file_handle:
return yaml.safe_load(file_handle)
def make_auth_headers(api_token: str) -> dict:
"""Return the Authorization + Content-Type headers required by the Sisense REST API."""
return {
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json",
}
# Plugin name normalization
def normalize_plugin_name(raw_name: str) -> str:
"""
Return a canonical form: lowercase with any leading 'plugin-' prefix stripped.
Both 'plugin-SwitchDimension' and 'SwitchDimension' normalize to 'switchdimension',
so callers do not need to be consistent about the prefix.
"""
lowered = raw_name.lower()
return lowered[len("plugin-"):] if lowered.startswith("plugin-") else lowered
def build_name_lookup(plugin_names: list) -> set:
"""
Build a set that matches a plugin regardless of whether the caller used the
'plugin-' prefix. Each name contributes two entries: the bare normalized
form and the 'plugin-<normalized>' form.
Use with plugin_matches_lookup() to test whether an API plugin object is
covered by a configured list.
"""
lookup: set = set()
for name in plugin_names:
normalized = normalize_plugin_name(name)
lookup.add(normalized)
lookup.add("plugin-" + normalized)
return lookup
def plugin_matches_lookup(plugin: dict, name_lookup: set) -> bool:
"""
Return True if either the plugin's folderName or its API name appears in
name_lookup (case insensitive, prefix agnostic).
"""
plugin_identifiers = {plugin["folderName"].lower(), plugin["name"].lower()}
return bool(plugin_identifiers & name_lookup)
# Plugin list API
def fetch_all_plugins(base_url: str, auth_headers: dict) -> list:
"""
Retrieve the full plugin list from the Sisense instance via paginated GET requests.
Prints progress to stdout and exits on HTTP error.
"""
print("Fetching plugin list...")
try:
all_plugins = _paginate_plugins(base_url, auth_headers)
except requests.HTTPError as http_error:
print(f"Failed to fetch plugins: {http_error}")
sys.exit(1)
print(f" {len(all_plugins)} plugins total\n")
return all_plugins
def _paginate_plugins(base_url: str, auth_headers: dict) -> list:
"""Internal: collect every page from GET /api/v1/plugins."""
endpoint = f"{base_url}/api/v1/plugins"
all_plugins: list = []
offset = 0
while True:
response = requests.get(
endpoint,
headers=auth_headers,
params={"limit": PLUGIN_PAGE_SIZE, "skip": offset},
timeout=30,
)
response.raise_for_status()
payload = response.json()
page = payload.get("plugins", [])
all_plugins.extend(page)
offset += len(page)
total_count = payload.get("count", 0)
print(f" Fetched {offset} / {total_count} plugins...")
if offset >= total_count or not page:
break
return all_plugins
# Plugin versions
def extract_plugin_versions(plugins: list) -> dict:
"""
Extract the version field from each plugin object in an API response.
Returns a dict mapping folderName → version string for every plugin that
carries a version field. Plugins with no version field are
omitted so the caller can treat an absent key as 'version unknown'.
"""
versions: dict = {}
for plugin in plugins:
version_value = plugin.get("version") or plugin.get("pluginVersion")
if version_value:
versions[plugin["folderName"]] = str(version_value)
return versions
# Applying changes
def _patch_plugins(base_url: str, auth_headers: dict, updates: list) -> None:
"""Send a PATCH /api/v1/plugins request. Raises requests.HTTPError on failure."""
response = requests.patch(
f"{base_url}/api/v1/plugins",
headers=auth_headers,
json=updates,
timeout=60,
)
response.raise_for_status()
def apply_plugin_updates(
base_url: str,
auth_headers: dict,
updates: list,
use_bulk: bool,
) -> tuple:
"""
Apply a list of enable/disable changes and print the outcome per plugin.
use_bulk=True → single PATCH with all updates at once (faster, one round-trip).
use_bulk=False → one PATCH per plugin (slower, easier to diagnose partial failures).
Returns (success_count, error_count).
"""
if use_bulk:
print(f"Sending bulk request ({len(updates)} plugin(s))...")
try:
_patch_plugins(base_url, auth_headers, updates)
except requests.HTTPError as http_error:
print(f" [ERROR] Bulk request failed: {http_error}")
sys.exit(1)
for update in updates:
status_label = "ENABLED" if update["isEnabled"] else "DISABLED"
print(f" [{status_label}] {update['folderName']}")
return len(updates), 0
success_count = error_count = 0
for update in updates:
folder_name = update["folderName"]
status_label = "ENABLED" if update["isEnabled"] else "DISABLED"
try:
_patch_plugins(base_url, auth_headers, [update])
print(f" [{status_label}] {folder_name}")
success_count += 1
except requests.HTTPError as http_error:
print(f" [ERROR] {folder_name}: {http_error}")
error_count += 1
return success_count, error_count
# Snapshot I/O
def save_snapshot(
snapshot_name: str,
enabled_folders: list,
*,
disabled_folders: Optional[list] = None,
plugin_versions: Optional[dict] = None,
) -> Path:
"""
Write a snapshot YAML file to snapshots/<snapshot_name>.yaml.
Fields written (in this order):
enable_plugins: sorted list of currently enabled folderNames
disabled_plugins: sorted list of currently-disabled folderNames (when provided)
plugin_versions: {folderName: version} for each plugin (when provided)
created: ISO-8601 UTC timestamp
Key ordering is preserved (sort_keys=False) so the file reads naturally.
"""
SNAPSHOTS_DIR.mkdir(exist_ok=True)
# created goes last so the plugin lists appear first in the file
snapshot_data: dict = {"enable_plugins": sorted(enabled_folders)}
if disabled_folders is not None:
snapshot_data["disabled_plugins"] = sorted(disabled_folders)
if plugin_versions:
snapshot_data["plugin_versions"] = plugin_versions
snapshot_data["created"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
snapshot_path = SNAPSHOTS_DIR / f"{snapshot_name}.yaml"
with snapshot_path.open("w") as file_handle:
yaml.dump(snapshot_data, file_handle, sort_keys=False, default_flow_style=False, allow_unicode=True)
return snapshot_path
def _snapshot_enable_list(snapshot: dict) -> list:
"""
Return the enabled plugin list from a snapshot, handling both the current
'enable_plugins' key and the legacy 'plugins' key used by older snapshots.
"""
return snapshot.get("enable_plugins") or snapshot.get("plugins", [])
def load_snapshot(snapshot_name: str) -> dict:
"""Load a snapshot by stem name, exiting with an error if the file is missing."""
snapshot_path = SNAPSHOTS_DIR / f"{snapshot_name}.yaml"
if not snapshot_path.exists():
print(f"Snapshot not found: {snapshot_path}")
sys.exit(1)
with snapshot_path.open() as file_handle:
return yaml.safe_load(file_handle)
def list_snapshot_files() -> list:
"""Return all snapshot YAML files in the snapshots directory, sorted by filename."""
if not SNAPSHOTS_DIR.exists():
return []
return sorted(SNAPSHOTS_DIR.glob("*.yaml"))
def latest_snapshot_name() -> str:
"""
Return the stem of the snapshot with the most recent 'created' timestamp.
Exits if no snapshots exist.
"""
snapshot_files = list_snapshot_files()
if not snapshot_files:
print("No snapshots found.")
sys.exit(1)
def created_timestamp(path: Path) -> str:
with path.open() as file_handle:
return yaml.safe_load(file_handle).get("created", "")
return max(snapshot_files, key=created_timestamp).stem
def auto_save_snapshot(plugins: list, include_version: bool) -> None:
"""
Save a snapshot from an already fetched plugin list, before applying changes.
Accepts the plugin list fetched at the start of the run so no second API
call is needed. Called before any changes are applied, giving every
run that changes state a rollback point. Failures are caught and reported as
a warning so a snapshot error never blocks the intended change.
"""
snapshot_name = datetime.now().strftime("auto_%Y%m%d_%H%M%S")
print(f"\nSaving snapshot '{snapshot_name}' before applying changes...")
try:
enabled_folders = [p["folderName"] for p in plugins if p.get("isEnabled")]
disabled_folders = [p["folderName"] for p in plugins if not p.get("isEnabled")]
plugin_versions = extract_plugin_versions(plugins) if include_version else None
snapshot_path = save_snapshot(
snapshot_name,
enabled_folders,
disabled_folders=disabled_folders or None,
plugin_versions=plugin_versions,
)
print(f" Saved to {snapshot_path} ({len(enabled_folders)} enabled / {len(disabled_folders)} disabled)")
except Exception as exc:
print(f" Warning: auto snapshot failed: {exc}")
# Sync computation
def compute_plugin_updates(
all_plugins: list,
enable_names: list,
disable_names: list,
*,
strict: bool,
enable_only: bool,
disable_only: bool,
) -> tuple:
"""
Determine which plugins need their state changed.
Default behavior (strict=False):
- Enable: plugins in enable_names that are currently disabled.
- Disable: plugins in disable_names that are currently enabled.
- Leave alone: everything else (not mentioned in either list).
With strict=True:
- Enable: same as default.
- Disable: plugins in disable_names, PLUS any currently enabled plugin
that does not appear in enable_names. This makes the instance
exactly match enable_names.
Direction overrides (applied after the above):
enable_only=True → clears to_disable entirely.
disable_only=True → clears to_enable entirely.
Returns:
to_enable: sorted folderNames to enable
to_disable: sorted folderNames to disable
unmatched_names: entries from enable_names that matched no instance plugin
"""
enable_lookup = build_name_lookup(enable_names)
disable_lookup = build_name_lookup(disable_names)
# Plugins from the instance that match the enable list
matched_enable_plugins = [p for p in all_plugins if plugin_matches_lookup(p, enable_lookup)]
enable_folder_set = {plugin["folderName"] for plugin in matched_enable_plugins}
# Plugins that should be enabled but currently are not
to_enable: list = [] if disable_only else sorted(
plugin["folderName"]
for plugin in matched_enable_plugins
if not plugin.get("isEnabled")
)
# Plugins that should be disabled but currently are enabled
if enable_only:
to_disable: list = []
elif strict:
# Strict: disable every enabled plugin not in the enable list
to_disable = sorted(
plugin["folderName"]
for plugin in all_plugins
if plugin.get("isEnabled") and plugin["folderName"] not in enable_folder_set
)
else:
# Soft: only disable what is explicitly listed in disable_plugins
to_disable = sorted(
plugin["folderName"]
for plugin in all_plugins
if plugin.get("isEnabled") and plugin_matches_lookup(plugin, disable_lookup)
)
# Entries from enable_names that found no matching plugin in the instance
matched_normalized_names = (
{normalize_plugin_name(plugin["folderName"]) for plugin in matched_enable_plugins} |
{normalize_plugin_name(plugin["name"]) for plugin in matched_enable_plugins}
)
unmatched_names = sorted(
name for name in enable_names
if normalize_plugin_name(name) not in matched_normalized_names
)
return to_enable, to_disable, unmatched_names
def _build_update_payload(to_enable: list, to_disable: list) -> list:
"""Combine enable and disable lists into the PATCH payload format."""
return (
[{"folderName": folder_name, "isEnabled": True} for folder_name in to_enable] +
[{"folderName": folder_name, "isEnabled": False} for folder_name in to_disable]
)
def _execute_sync(
base_url: str,
auth_headers: dict,
to_enable: list,
to_disable: list,
use_bulk: bool,
) -> int:
"""
Build the PATCH payload from to_enable / to_disable and apply it.
Prints 'Nothing to change.' if both lists are empty.
Returns the error count (0 on full success).
"""
updates = _build_update_payload(to_enable, to_disable)
if not updates:
print("Nothing to change.")
return 0
_, error_count = apply_plugin_updates(base_url, auth_headers, updates, use_bulk)
return error_count
# Output helpers
def _print_update_plan(
to_enable: list,
to_disable: list,
already_correct_count: int,
untouched_count: int = 0,
) -> None:
"""Print a summary of pending changes before they are applied."""
print(f" To enable: {len(to_enable)}")
print(f" To disable: {len(to_disable)}")
if already_correct_count > 0:
print(f" Already set: {already_correct_count}")
if untouched_count > 0:
print(f" Untouched: {untouched_count} (not listed in config; left as-is)")
print()
def _print_dry_run_preview(to_enable: list, to_disable: list) -> None:
"""Print the would-be changes when --dry-run is active."""
if not to_enable and not to_disable:
print("Nothing to change.")
if to_enable:
print("Would enable:")
for folder_name in to_enable:
print(f" + {folder_name}")
if to_disable:
print("Would disable:")
for folder_name in to_disable:
print(f" - {folder_name}")
print("\n[DRY RUN] No changes made.")
def _report_unmatched_names(unmatched_names: list) -> None:
"""Warn about configured plugin names that matched nothing in the instance."""
if unmatched_names:
print(f"\nNot found in instance ({len(unmatched_names)}):")
for name in unmatched_names:
print(f" - {name}")
# Commands
def cmd_sync_to_config(
base_url: str,
auth_headers: dict,
enable_names: list,
disable_names: list,
*,
dry_run: bool,
use_bulk: bool,
strict: bool,
enable_only: bool,
disable_only: bool,
skip_snapshot: bool,
include_version: bool,
) -> None:
"""
Default command: sync the instance to match config.yaml.
Enables plugins in enable_plugins; in strict mode, also disables any
currently enabled plugin not in enable_plugins. Without strict, only
plugins explicitly in disable_plugins are disabled.
"""
all_plugins = fetch_all_plugins(base_url, auth_headers)
to_enable, to_disable, unmatched_names = compute_plugin_updates(
all_plugins, enable_names, disable_names,
strict=strict, enable_only=enable_only, disable_only=disable_only,
)
enable_lookup = build_name_lookup(enable_names)
disable_lookup = build_name_lookup(disable_names)
matched_plugins = [p for p in all_plugins if plugin_matches_lookup(p, enable_lookup)]
already_correctly_enabled = sum(1 for p in matched_plugins if p.get("isEnabled"))
# Untouched: plugins in neither list, only relevant without --strict, where
# we deliberately leave them alone
untouched_count = 0 if strict or enable_only else sum(
1 for p in all_plugins
if not plugin_matches_lookup(p, enable_lookup)
and not plugin_matches_lookup(p, disable_lookup)
)
# Table of plugins matched by the enable list
if matched_plugins:
print(f"Matched {len(matched_plugins)} plugin(s) from enable_plugins:\n")
print(f" {'Folder name':<45} {'API name':<35} {'Enabled'}")
print(f" {'-'*45} {'-'*35} {'-'*7}")
for plugin in sorted(matched_plugins, key=lambda p: p["folderName"]):
print(f" {plugin['folderName']:<45} {plugin['name']:<35} {plugin['isEnabled']}")
print()
_print_update_plan(to_enable, to_disable, already_correctly_enabled, untouched_count)
if dry_run:
_print_dry_run_preview(to_enable, to_disable)
_report_unmatched_names(unmatched_names)
return
if not skip_snapshot and (to_enable or to_disable):
auto_save_snapshot(all_plugins, include_version)
error_count = _execute_sync(base_url, auth_headers, to_enable, to_disable, use_bulk)
print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {error_count}")
_report_unmatched_names(unmatched_names)
def cmd_enable_single_plugin(
base_url: str,
auth_headers: dict,
plugin_name: str,
*,
dry_run: bool,
use_bulk: bool,
skip_snapshot: bool,
include_version: bool,
) -> None:
"""
Enable exactly one plugin and disable every other currently enabled plugin.
The target plugin is identified by name (short or full form, case insensitive).
This command does not consult enable_plugins or disable_plugins in config.
"""
target_name_lookup = build_name_lookup([plugin_name])
all_plugins = fetch_all_plugins(base_url, auth_headers)
target_plugin = next(
(plugin for plugin in all_plugins if plugin_matches_lookup(plugin, target_name_lookup)),
None,
)
if target_plugin is None:
print(f"Plugin not found: {plugin_name!r}")
print("Tip: check spelling against the Sisense admin panel, or run --save-snapshot to capture current names.")
sys.exit(1)
target_folder = target_plugin["folderName"]
target_currently_on = target_plugin.get("isEnabled", False)
to_enable = [] if target_currently_on else [target_folder]
to_disable = sorted(
plugin["folderName"]
for plugin in all_plugins
if plugin.get("isEnabled") and plugin["folderName"] != target_folder
)
print(f"Target plugin: {target_folder}")
print(f" Currently: {'enabled' if target_currently_on else 'disabled'}\n")
_print_update_plan(
to_enable, to_disable,
already_correct_count=1 if target_currently_on else 0,
)
if dry_run:
_print_dry_run_preview(to_enable, to_disable)
return
if not skip_snapshot and (to_enable or to_disable):
auto_save_snapshot(all_plugins, include_version)
error_count = _execute_sync(base_url, auth_headers, to_enable, to_disable, use_bulk)
print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {error_count}")
def cmd_save_named_snapshot(
base_url: str,
auth_headers: dict,
snapshot_name: str,
include_version: bool,
) -> None:
"""Save the current plugin state as a named snapshot file."""
all_plugins = fetch_all_plugins(base_url, auth_headers)
enabled_folders = [plugin["folderName"] for plugin in all_plugins if plugin.get("isEnabled")]
disabled_folders = [plugin["folderName"] for plugin in all_plugins if not plugin.get("isEnabled")]
plugin_versions = extract_plugin_versions(all_plugins) if include_version else None
snapshot_path = save_snapshot(
snapshot_name,
enabled_folders,
disabled_folders=disabled_folders or None,
plugin_versions=plugin_versions,
)
print(f"\nSnapshot '{snapshot_name}' saved to {snapshot_path}")
print(f" {len(enabled_folders)} enabled / {len(disabled_folders)} disabled plugin(s) captured.")
if plugin_versions is not None:
print(f" Versions recorded for {len(plugin_versions)} plugin(s).")
def cmd_list_snapshots() -> None:
"""Print a formatted table of all saved snapshots."""
snapshot_files = list_snapshot_files()
if not snapshot_files:
print("No snapshots found.")
return
print(f"Snapshots ({len(snapshot_files)}):\n")
print(f" {'Name':<32} {'Created':<22} {'Enabled':<9} {'Versions'}")
print(f" {'-'*32} {'-'*22} {'-'*9} {'-'*8}")
for snapshot_path in snapshot_files:
with snapshot_path.open() as file_handle:
snapshot_data = yaml.safe_load(file_handle)
enabled_count = len(_snapshot_enable_list(snapshot_data))
versions_logged = "yes" if snapshot_data.get("plugin_versions") else "no"
print(
f" {snapshot_path.stem:<32} "
f"{snapshot_data.get('created', 'unknown'):<22} "
f"{enabled_count:<9} "
f"{versions_logged}"
)
def cmd_restore_from_snapshot(
base_url: str,
auth_headers: dict,
snapshot_name: str,
*,
dry_run: bool,
use_bulk: bool,
enable_only: bool,
disable_only: bool,
skip_snapshot: bool,
include_version: bool,
) -> None:
"""
Restore plugin state to exactly match a saved snapshot.
Restore is always strict: plugins in the snapshot are enabled and all other
currently enabled plugins are disabled, matching the state at snapshot time.
Use --enable-only to skip the disable step (only enable what the snapshot lists).
"""
snapshot = load_snapshot(snapshot_name)
snapshot_enable_list = _snapshot_enable_list(snapshot)
print(f"Snapshot '{snapshot_name}' ({snapshot.get('created', 'unknown')})")
if snapshot.get("disabled_plugins"):
print(f" {len(snapshot_enable_list)} enabled / {len(snapshot['disabled_plugins'])} disabled at snapshot time")
else:
print(f" {len(snapshot_enable_list)} plugin(s) enabled in snapshot")
if snapshot.get("plugin_versions"):
print(f" Plugin versions recorded: {len(snapshot['plugin_versions'])}")
print()
all_plugins = fetch_all_plugins(base_url, auth_headers)
# Restore is inherently strict: disable anything not in the snapshot
# unless the caller asked only to enable (enable_only=True).
to_enable, to_disable, unmatched_names = compute_plugin_updates(
all_plugins,
enable_names=snapshot_enable_list,
disable_names=[],
strict=not enable_only,
enable_only=enable_only,
disable_only=disable_only,
)
already_correct_count = max(
0, len(snapshot_enable_list) - len(to_enable) - len(unmatched_names)
)
_print_update_plan(to_enable, to_disable, already_correct_count)
if dry_run:
_print_dry_run_preview(to_enable, to_disable)
_report_unmatched_names(unmatched_names)
return
if not skip_snapshot and (to_enable or to_disable):
auto_save_snapshot(all_plugins, include_version)
error_count = _execute_sync(base_url, auth_headers, to_enable, to_disable, use_bulk)
print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {error_count}")
_report_unmatched_names(unmatched_names)
# Main
def main() -> None:
import argparse
parser = argparse.ArgumentParser(
prog="plugin_manager.py",
description="Manage Sisense plugin states via the REST API.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Plugin names accept the short form (SwitchDimension) or the full\n"
"form (plugin-SwitchDimension) in both CLI args and config.yaml.\n"
"\n"
"Default behavior: enable plugins in enable_plugins, disable plugins\n"
"in disable_plugins, leave everything else unchanged.\n"
"Use --strict to also disable any enabled plugin not in enable_plugins.\n"
"\n"
"A snapshot is saved automatically before each run that\n"
"modifies state. Use --skip-snapshot to suppress this."
),
)
# Universal flags
parser.add_argument(
"--config", default=str(DEFAULT_CONFIG_PATH), metavar="FILE",
help="Path to config.yaml (default: config.yaml next to this script)",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Preview what would change; make no API calls and save no snapshot",
)
parser.add_argument(
"--no-bulk", action="store_true",
help=(
"Send one API call per plugin instead of a single batch PATCH. "
"Slower but easier to diagnose partial failures. "
"Overrides sisense.bulk in config."
),
)
parser.add_argument(
"--skip-snapshot", action="store_true",
help="Do not automatically save a snapshot before this run",
)
parser.add_argument(
"--log-version", action="store_true",
help=(
"Record each plugin's version field (from the API response) in any "
"snapshot saved during this run (manual --save-snapshot or auto saved snapshot)"
),
)
parser.add_argument(
"--strict", action="store_true",
help=(
"Also disable every currently enabled plugin that is not listed in "
"enable_plugins. Without this flag, only plugins in disable_plugins are "
"disabled; everything else is left alone. "
"Always active for --restore-snapshot."
),
)
# Direction flags (mutually exclusive)
direction_group = parser.add_mutually_exclusive_group()
direction_group.add_argument(
"--enable-only", action="store_true",
help="Run only the enable step; skip all disables",
)
direction_group.add_argument(
"--disable-only", action="store_true",
help="Run only the disable step; skip all enables",
)
# Mode (mutually exclusive)
mode_group = parser.add_mutually_exclusive_group()
mode_group.add_argument(
"--enable-one", metavar="PLUGIN",
help=(
"Enable exactly one plugin by name and disable all currently enabled "
"others. Accepts the short or full plugin name."
),
)
mode_group.add_argument(
"--save-snapshot", metavar="NAME", nargs="?", const="__auto__",
help=(
"Save the current plugin state as a named snapshot. "
"Omit NAME for an auto generated timestamp filename. "
"Combine with --log-version to also record versions for each plugin."
),
)
mode_group.add_argument(
"--list-snapshots", action="store_true",
help="List all saved snapshots with creation date and enabled plugin count",
)
mode_group.add_argument(
"--restore-snapshot", metavar="NAME", nargs="?", const="__latest__",
help=(
"Restore plugin state to exactly match a saved snapshot. "
"Omit NAME to use the most recently created snapshot. "
"Always strict (disables plugins not in snapshot) unless --enable-only."
),
)
args = parser.parse_args()
# --list-snapshots reads local files only; no config or API connection needed
if args.list_snapshots:
cmd_list_snapshots()
return
# Load config and connect
config = load_config(Path(args.config))
sisense_config = config.get("sisense", {})
base_url = sisense_config.get("url", "").rstrip("/")
api_token = sisense_config.get("token", "")
if not base_url or not api_token:
print("config.yaml must include sisense.url and sisense.token.")
sys.exit(1)
auth_headers = make_auth_headers(api_token)
use_bulk = sisense_config.get("bulk", True) and not args.no_bulk
# Support both 'enable_plugins' (current key) and 'plugins' (legacy key)
enable_names = config.get("enable_plugins") or config.get("plugins", [])
disable_names = config.get("disable_plugins", [])
# Dispatch
if args.enable_one:
cmd_enable_single_plugin(
base_url, auth_headers, args.enable_one,
dry_run=args.dry_run,
use_bulk=use_bulk,
skip_snapshot=args.skip_snapshot,
include_version=args.log_version,
)
elif args.save_snapshot is not None:
snapshot_name = (
datetime.now().strftime("snapshot_%Y%m%d_%H%M%S")
if args.save_snapshot == "__auto__"
else args.save_snapshot
)
cmd_save_named_snapshot(
base_url, auth_headers, snapshot_name, args.log_version,
)
elif args.restore_snapshot is not None:
snapshot_name = (
latest_snapshot_name()
if args.restore_snapshot == "__latest__"
else args.restore_snapshot
)
cmd_restore_from_snapshot(
base_url, auth_headers, snapshot_name,
dry_run=args.dry_run,
use_bulk=use_bulk,
enable_only=args.enable_only,
disable_only=args.disable_only,
skip_snapshot=args.skip_snapshot,
include_version=args.log_version,
)
else:
# Default: sync instance state to config
if not enable_names and not disable_names:
print("config.yaml must include at least one entry in 'enable_plugins'.")
sys.exit(1)
cmd_sync_to_config(
base_url, auth_headers, enable_names, disable_names,
dry_run=args.dry_run,
use_bulk=use_bulk,
strict=args.strict,
enable_only=args.enable_only,
disable_only=args.disable_only,
skip_snapshot=args.skip_snapshot,
include_version=args.log_version,
)
if __name__ == "__main__":
main()