The Sisense Community is a place to solve problems, learn, and collaborate.
Read the stories of how people are using Sisense, and get the latest product news and Community updates.
Specific guides written by Sisense experts and trusted partners.
Find inspiration for your visualizations, or show off your best work and tell us how you did it!
Bulk Updating BloX Widget Fonts with PySisense Overview BloX widgets do not inherit the font configured in a Sisense instance's Look and Feel settings. Changing the tenant level font has no effect on existing BloX widgets, and updating each one by hand through the BloX template editor becomes impractical once a dashboard has more than a few of these widgets, or the same change needs to be applied across many dashboards. This article describes blox_font_updater.py , a Python command-line tool that finds BloX widgets across a Sisense tenant and updates their font using the pySisense library. The tool changes only the font of BloX widgets. It does not modify any other widget type, and it does not change the tenant level Look and Feel font; it edits each BloX widget's own style or font configuration directly. Full coverage is not guaranteed on every widget: BloX widgets are built from open ended custom HTML and JSON, so a small number may still need a manual adjustment after a run. The full source is available on GitHub in this repository . Two ways to change a BloX widget's font A BloX widget's font can be set in two different places, and the tool can use either one, or both: CSS rule : adds a CSS rule to the front of the widget's existing custom style. A wildcard selector targets nearly every element in the widget, including elements such as input fields that do not otherwise inherit a font from their surroundings. Font configuration : sets Sisense's own font setting for the widget directly. This is closer to how a BloX widget's font is normally configured, but depends on the widget's own template reading that setting, so it can miss some elements. Both mechanisms are applied by default. A widget that is already correct for one mechanism is left unchanged for that mechanism even when both are in use, so a run only touches whatever is actually out of date. About PySisense PySisense is a Python SDK for the Sisense REST API, installed via pip. It provides wrappers around common Sisense operations, including dashboard and widget access, and handles authentication, session management, and request errors internally. This tool uses pySisense's SisenseClient and Dashboard classes to discover dashboards and widgets, and to read and write BloX widget definitions. Prerequisites Python 3.13 or later. pySisense and PyYAML, installed with pip install -r requirements.txt . A Sisense API token, ideally belonging to a Sisense administrator. An administrator token lets the tool discover and update BloX widgets across the whole tenant, rather than only dashboards owned by or shared with one user. Setup Copy the example configuration and fill in the Sisense domain and API token: cp config.example.yaml config.yaml The tool is ready to run once domain and token are filled in. The remaining settings control the font change itself and are covered in the next section. Configuration reference # Copy this file to config.yaml and fill in real values:
#
# cp config.example.yaml config.yaml
#
# config.yaml contains a real credential once filled in - never send it to
# anyone else or commit it to source control. When sharing this tool with
# someone else, share this file (config.example.yaml), not a filled-in
# config.yaml.
# The Sisense server's address. A "http://" or "https://" prefix is fine
# here if included - it's ignored either way, since is_ssl below is what
# actually decides whether HTTPS or HTTP is used.
domain: "your-tenant.sisense.com"
# Leave this as true unless the Sisense server uses plain HTTP.
is_ssl: true
# A Sisense API token. Recommended: a Sisense administrator's token - see
# is_user_admin below, and the README's "Getting a Sisense API token" and
# "Permissions" sections.
token: "YOUR_SISENSE_API_TOKEN"
# The kind of widget this tool looks for. Leave this as BloX.
widget_type: BloX
# Recommended: true, with an administrator token (see token above) - finds
# widgets across every dashboard in the tenant, not just ones owned by or
# shared with one particular user, and an administrator is also the most
# likely to already have the access needed to update most of them.
# Set to false to only look at dashboards the API user can already see.
is_user_admin: true
# Even with is_user_admin above, Sisense can still deny a widget update with
# "access denied" if the API user has no owner or edit share on that specific
# dashboard - admin access relaxes what can be read, not what can be written.
# Set this to true to have the tool work around that automatically: for a
# widget that fails this way, it temporarily makes the API user the
# dashboard's owner, applies the change, then restores the original owner and
# shares. Leave this as false unless that "access denied" failure is actually
# being seen - see the README's "Permissions" section for the tradeoffs.
use_temporary_ownership: false
# The settings below control what the tool does by default when run
# without the matching command-line option. Anything here can still be
# overridden per run - for example, --font "Georgia, serif" always wins over
# default_font_family below, no matter what it's set to.
# The CSS selector used when default_font_mode is css or both (see README.md).
default_selector: ".blox-slides *"
# The font used for the change - a full CSS font stack (e.g. with fallback
# fonts). Used as-is for default_font_mode: css. For default_font_mode:
# config, only the first name is used (e.g. "Arial" here) - that's the
# typical, well-supported value for Sisense's font config field, even though
# it has also been observed to accept a full stack like this one directly.
default_font_family: "Arial, Helvetica, sans-serif"
# One of: css, config, both - see "Two ways to change the font" in
# blox_font_updater.py, or the README, for what each one does. "both" is
# the default here for the widest out-of-the-box coverage.
default_font_mode: both
# Maximum number of widgets a single run will touch. 0 (or a negative
# number) means no limit.
default_max_widgets: 10 domain , is_ssl , token : connection details for the Sisense instance. A http:// or https:// prefix on domain is ignored either way; is_ssl decides the scheme. widget_type : left as BloX . is_user_admin : true to discover widgets across the whole tenant with an administrator token, false to only look at dashboards the token's own user can already see. default_selector : the CSS selector used when default_font_mode is css or both . default_font_family : the font applied. A full CSS font stack is used as is for the CSS rule; only the first name is used for the font configuration field. default_font_mode : css , config , or both , as described above. default_max_widgets : how many widgets a single run touches. 0 or a negative number removes the limit. use_temporary_ownership : false by default. Set to true to have the tool temporarily take ownership of a dashboard when a widget update is denied with "access denied," even with is_user_admin set. Each of these can be overridden for a single run with a matching command-line option ( --selector , --font , --font-mode , --max-widgets ) without changing config.yaml . Usage Run with no options to preview which widgets would change, without writing anything: python blox_font_updater.py Apply the change to a single widget first, then confirm it in Sisense: python blox_font_updater.py --apply --max-widgets 1 If the result is not correct, every change made by the tool can be reverted: python blox_font_updater.py --undo Once confirmed, apply the change across the tenant: python blox_font_updater.py --apply --all Common use cases Rolling out a font change across a tenant Set default_selector , default_font_family , and default_font_mode in config.yaml to the desired values, then run the sequence in Usage: preview, apply to one widget, confirm, apply to all. Using a different font for a single run, without changing config.yaml python blox_font_updater.py --apply --font "Georgia, serif" --font-mode config Reducing log output on a large run python blox_font_updater.py --apply --all --quiet Apply Change to One Specific Widget ID Only python blox_font_updater.py --apply --dashboard-id <dashboardId> --widget-id <widgetId> Logs and backups Every run writes to logs/blox_font_update.log . Before any widget is changed, its complete prior definition is recorded in logs/blox_font_backup.json . --undo reads that file and restores every change that has not already been undone, most recent first. Both paths can be changed with --log-file and --backup-file . Full option reference usage: blox_font_updater.py [-h] [--config PATH] [--selector CSS_SELECTOR]
[--font FONT_FAMILY]
[--font-mode {css,config,both}] [--max-widgets N]
[--all] [--dashboard-id ID] [--widget-id ID]
[--apply] [--force] [--undo] [--backup-file PATH]
[--log-file PATH] [--quiet] [--verbose]
Change the font used by BloX widgets on a Sisense instance.
options:
-h, --help show this help message and exit
--config PATH Path to config.yaml.
--selector CSS_SELECTOR
Override default_selector from config.yaml, for this
run only. Normally set in config.yaml instead. Used if
neither is set: '.blox-slides *'.
--font FONT_FAMILY Override default_font_family from config.yaml, for
this run only. Normally set in config.yaml instead.
Used if neither is set: 'Arial, Helvetica, sans-
serif'.
--font-mode {css,config,both}
Override default_font_mode from config.yaml, for this
run only - normally set in config.yaml instead. Used
if neither is set: 'both'. 'css' adds a CSS rule that
directly targets nearly every element, including
custom HTML and input fields. 'config' sets Sisense's
simpler built-in font setting instead, which some
widgets' own elements (including input fields) may not
pick up. Neither is guaranteed to cover everything -
BloX templates vary - so 'both' applies both changes
for the best chance of full coverage.
--max-widgets N Override default_max_widgets from config.yaml, for
this run only. Normally set in config.yaml instead.
Used if neither is set: 10. 0 or a negative number
means no limit - same as --all.
--all Process every matching widget, with no limit.
Overrides --max-widgets.
--dashboard-id ID Target one already-known widget directly by dashboard
and widget id, instead of scanning the tenant. Must be
used together with --widget-id. --max-widgets/--all
are ignored in this mode.
--widget-id ID Used together with --dashboard-id. See --dashboard-id.
--apply Actually write changes to Sisense. Without this flag,
the tool only previews what it would do.
--force Re-apply even to widgets that already look up to date
for the selected --font-mode.
--undo Revert every not-yet-undone change recorded in
--backup-file, then exit.
--backup-file PATH Where change backups are stored.
--log-file PATH Where the run log is written.
--quiet Only log the scan summary and any warnings/errors -
skip the per-widget lines. Useful for large runs.
--verbose Show full style strings in the log instead of a
truncated preview.
Use case Using blox_font_updater.py instead of manual BloX template edits offers several advantages: Applies a font change consistently across every BloX widget in a tenant, without opening each widget individually. Provides automatic rollback through the backup file, without a separate export or snapshot step. Supports two independent font change mechanisms, so a widget that does not respond to one still receives the font through the other. Reduces the time needed to test a change safely, through a preview mode and a single widget run before a full rollout. Summary blox_font_updater.py gives Sisense administrators a repeatable way to change the font of BloX widgets across a tenant from the command line, with a preview step, a backup of every change, and full reversal through --undo . config.yaml serves as the source of truth for the selector, font, and mode used, and can be adjusted per environment without changing the script itself. The full source is available on GitHub and can be modified as needed. """
bloxFontUpdater - bulk-update the font used by Sisense BloX widgets.
BloX widgets don't inherit Sisense's tenant-level "Look and Feel" font, so
this tool sets one directly, across every BloX widget it finds, by adding a
CSS rule, by setting Sisense's own font config field, or both (see
resolveSetting/applyChanges below and "Two ways to change the font" in
README.md). Preview-only by default; every change is backed up and
reversible with --undo.
Full documentation, including configuration and the two font-change
mechanisms: README.md
Command-line options: --help
python blox_font_updater.py # preview only, changes nothing
python blox_font_updater.py --apply --max-widgets 1
python blox_font_updater.py --undo
"""
import argparse
import base64
import copy
import json
import logging
import os
import sys
from datetime import datetime, timezone
_installHint = (
"Missing dependency: {module}.\n\n"
"Install the required packages first, for example:\n"
" pip install -r requirements.txt\n"
"or, with uv:\n"
" uv sync\n"
)
try:
import yaml
except ImportError:
sys.exit(_installHint.format(module="PyYAML"))
try:
from pysisense import SisenseClient, Dashboard
except ImportError:
sys.exit(_installHint.format(module="pysisense"))
# ---------------------------------------------------------------------------
# Fallback values, used only for a setting that is given on neither the
# command line nor in config.yaml. Most users should set default_selector,
# default_font_family, default_font_mode, and default_max_widgets in
# config.yaml instead of changing these - see config.example.yaml.
# ---------------------------------------------------------------------------
fallbackSelector = ".blox-slides *"
fallbackFontFamily = "Arial, Helvetica, sans-serif"
# "both" gives the widest out-of-the-box coverage, so it's the fallback -
# see "Two ways to change the font" in README.md.
fallbackFontMode = "both"
# Safety cap on how many widgets a single run will touch. Set
# default_max_widgets to 0 in config.yaml (or pass --all) to remove the cap.
fallbackMaxWidgets = 10
# The only valid values for config.yaml's default_font_mode (or --font-mode).
fontModeChoices = ("css", "config", "both")
scriptDir = os.path.dirname(os.path.abspath(__file__))
defaultConfigPath = os.path.join(scriptDir, "config.yaml")
defaultLogFile = os.path.join(scriptDir, "logs", "blox_font_update.log")
defaultBackupFile = os.path.join(scriptDir, "logs", "blox_font_backup.json")
# Length a style preview is truncated to in normal (non --verbose) logging,
# so a widget with a very long style string doesn't flood the terminal.
stylePreviewLength = 100
# Fields Sisense manages internally and does not expect to receive back when
# saving a widget - present on data read from the API, but absent from a
# widget definition when it is written.
serverManagedWidgetFields = (
"oid",
"_id",
"owner",
"userId",
"created",
"lastUpdated",
"instanceType",
"dashboardid",
)
def setupLogger(logFile):
"""Log to both the console and logFile, so a run can be reviewed afterwards."""
# dirname is "" for a bare filename (e.g. --log-file run.log, meaning
# "current directory") - makedirs("") raises, so only call it when
# there's an actual directory to create.
logDir = os.path.dirname(logFile)
if logDir:
os.makedirs(logDir, exist_ok=True)
logger = logging.getLogger("bloxFontUpdater")
logger.setLevel(logging.INFO)
logger.handlers.clear() # avoid duplicate output if setupLogger is ever called twice
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
fileHandler = logging.FileHandler(logFile)
fileHandler.setFormatter(formatter)
logger.addHandler(fileHandler)
consoleHandler = logging.StreamHandler()
consoleHandler.setFormatter(formatter)
logger.addHandler(consoleHandler)
return logger
def previewStyle(styleText, verbose):
"""Shorten a style string for logging, unless --verbose was requested."""
if verbose or len(styleText) <= stylePreviewLength:
return styleText
return styleText[:stylePreviewLength] + f"... ({len(styleText)} chars total, use --verbose to see all)"
def buildCssRule(selector, fontFamily):
"""Build the single CSS rule that gets added to the front of a widget's style."""
return f"{selector}{{font-family: {fontFamily} !important}}"
def extractPrimaryFontName(fontFamily):
"""
Turn a CSS font-family value into just its first font name, e.g. "Arial,
Helvetica, sans-serif" -> "Arial", or '"Open Sans", sans-serif' -> "Open
Sans". Used for Sisense's font config field (style.currentConfig.
fontFamily), which is normally set to a single font name like this -
that field has also been observed to accept a full comma-separated stack,
but a single name is the more typical, well-supported value for it.
"""
firstFont = fontFamily.split(",")[0].strip()
return firstFont.strip("'\"")
def mergeStyle(existingStyle, newRule):
"""
Add newRule to the front of a widget's existing style, without altering
the existing content in any way.
A BloX widget's style string may be missing entirely, empty, a single
rule, several rules concatenated together, or CSS left over from manual
editing that doesn't fully make sense on its own. In every case, newRule
is placed at the front and whatever was already there is kept exactly as
it was - this tool never tries to interpret or "clean up" existing CSS.
"""
existingStyle = existingStyle or ""
if not existingStyle.strip():
return newRule
return f"{newRule} {existingStyle}"
def ruleAlreadyApplied(existingStyle, newRule):
"""True if newRule is already the first thing in the style string."""
return (existingStyle or "").strip().startswith(newRule)
def loadBackup(path):
"""Read the JSON list of previously-applied changes, or [] if none exist yet."""
if not os.path.exists(path):
return []
with open(path, "r") as backupFile:
try:
return json.load(backupFile)
except json.JSONDecodeError:
return []
def saveBackup(path, records):
"""Persist the backup list. Called immediately after every change, so an
interrupted run never loses track of widgets it already modified."""
# See setupLogger for why this only calls makedirs when there's an
# actual directory component.
backupDir = os.path.dirname(path)
if backupDir:
os.makedirs(backupDir, exist_ok=True)
with open(path, "w") as backupFile:
json.dump(records, backupFile, indent=2)
def getWidgetEndpoint(dashboardId, widgetId, isAdmin):
"""Endpoint used to read a single widget's full definition."""
endpoint = f"/api/v1/dashboards/{dashboardId}/widgets/{widgetId}"
if isAdmin:
endpoint += "?adminAccess=true"
return endpoint
def putWidgetEndpoint(dashboardId, widgetId):
"""Endpoint used to save a widget - the same one the Sisense web UI itself
uses when a widget is edited and saved."""
return f"/api/dashboards/{dashboardId}/widgets/{widgetId}"
def buildPutPayload(widget):
"""Strip the server-managed fields Sisense does not expect back when a
widget is saved (see serverManagedWidgetFields)."""
return {key: value for key, value in widget.items() if key not in serverManagedWidgetFields}
def describeErrorResponse(response):
"""
Best-effort description of a failed request, for logging.
`requests.Response` objects are falsy for any non-2xx status - `if
response` is True only when the request actually succeeded, not merely
when a response was received. Checking `response is not None` here is
what actually distinguishes "no response at all" (e.g. a network error)
from "the server responded with an error", so the real error detail
(e.g. "Widget not found") gets logged instead of a misleading "No
response received." Also falls back gracefully if the error body isn't
valid JSON.
"""
if response is None:
return "No response received."
try:
return response.json()
except ValueError:
return response.text or f"HTTP {response.status_code}"
def decodeJwtUserId(token):
"""
Read the "user" claim out of a Sisense API token, without verifying its
signature - used only to learn the API user's own id for
use_temporary_ownership, not for any security decision.
Sisense API tokens are JWTs: three dot-separated, base64url-encoded
segments (header, payload, signature). The payload segment decodes to a
JSON object containing, among other things, "user" (the token owner's own
id). Returns None if token isn't a decodable JWT of this shape.
"""
try:
payloadSegment = token.split(".")[1]
padded = payloadSegment + "=" * (-len(payloadSegment) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded))
except (IndexError, ValueError, TypeError):
return None
return payload.get("user")
def getDashboardOwnerId(apiClient, dashboardId, logger):
"""Look up the current owner of a dashboard, for use_temporary_ownership -
the same admin endpoint pysisense's own get_all_dashboards() reads from,
scoped to one dashboard."""
response = apiClient.get(f"/api/v1/dashboards/admin?dashboardType=owner&id={dashboardId}&asObject=false")
if not response or not response.ok:
logger.error(f"Could not look up the current owner of dashboard {dashboardId}: {describeErrorResponse(response)}")
return None
matches = response.json()
if not matches:
logger.error(f"Could not look up the current owner of dashboard {dashboardId}: dashboard not found.")
return None
return matches[0].get("owner")
def getDashboardShares(apiClient, dashboardId, logger):
"""Read a dashboard's current share list, so use_temporary_ownership can
restore it exactly after a temporary ownership change."""
response = apiClient.get(f"/api/shares/dashboard/{dashboardId}?adminAccess=true")
if not response or not response.ok:
logger.error(f"Could not read the current shares for dashboard {dashboardId}: {describeErrorResponse(response)}")
return None
return response.json().get("sharesTo", [])
def changeDashboardOwner(apiClient, dashboardId, newOwnerId, useAdminAccess, logger):
"""
Change who owns a dashboard - the same endpoint pysisense's own
add_widget_script() helper uses to temporarily take ownership of a
dashboard it doesn't otherwise have edit rights on. useAdminAccess should
be True when taking ownership (the API user typically isn't already a
party to the dashboard) and False when handing it back (the API user is
the current owner at that point, so no admin override is needed).
"""
endpoint = f"/api/v1/dashboards/{dashboardId}/change_owner"
if useAdminAccess:
endpoint += "?adminAccess=true"
response = apiClient.post(endpoint, data={"ownerId": newOwnerId, "originalOwnerRule": "edit"})
if not response or not response.ok:
logger.error(f"Could not change the owner of dashboard {dashboardId} to {newOwnerId}: {describeErrorResponse(response)}")
return False
return True
def restoreDashboardShares(apiClient, dashboardId, shares, logger):
"""Write a previously-captured share list (from getDashboardShares) back
onto a dashboard, after a temporary ownership change."""
payload = {
"sharesTo": [
{
"shareId": share["shareId"],
"type": share["type"],
"rule": share.get("rule", "edit"),
"subscribe": share.get("subscribe", False),
}
for share in shares
]
}
response = apiClient.post(f"/api/shares/dashboard/{dashboardId}", data=payload)
if not response or not response.ok:
logger.error(f"Could not restore the original shares for dashboard {dashboardId}: {describeErrorResponse(response)}")
return False
return True
def withTemporaryOwnership(apiClient, dashboardId, apiUserId, logger, action):
"""
Make apiUserId the temporary owner of dashboardId, run action(), then
restore the original owner and shares - even if action() raises, or the
restore step itself fails partway.
This is the use_temporary_ownership fallback (see config.example.yaml and
README.md "Permissions"): Sisense can deny a widget write with 403 even to
an admin token, when the token's user has no owner/edit share on that
specific dashboard. Mirrors the ownership-swap-and-restore pattern
pysisense's own add_widget_script() uses for widget scripts, applied here
to widget field writes instead. Unlike that pysisense helper, restoration
here always runs - including when action() raises or the write itself
fails - so a dashboard is never left owned by the tool's API user because
of a mid-run error.
Returns (result, attempted): result is action()'s return value, or None
if the swap could not be attempted at all (owner/shares lookup failed, or
the ownership change itself failed - nothing was touched in that case).
attempted is True whenever action() actually ran.
"""
originalOwnerId = getDashboardOwnerId(apiClient, dashboardId, logger)
if not originalOwnerId:
return None, False
if originalOwnerId == apiUserId:
# Already the owner - a plain write should not have failed with 403
# for ownership reasons, so there's nothing useful to swap here.
return None, False
originalShares = getDashboardShares(apiClient, dashboardId, logger)
if originalShares is None:
return None, False
if not changeDashboardOwner(apiClient, dashboardId, apiUserId, True, logger):
return None, False
logger.warning(f"Temporarily changed the owner of dashboard {dashboardId} to the API user, to apply the font change.")
try:
result = action()
finally:
sharesRestored = restoreDashboardShares(apiClient, dashboardId, originalShares, logger)
ownerRestored = changeDashboardOwner(apiClient, dashboardId, originalOwnerId, False, logger)
if sharesRestored and ownerRestored:
logger.warning(f"Restored the original owner and shares for dashboard {dashboardId}.")
else:
logger.error(
f"Dashboard {dashboardId} may still be owned by the API user - restoring shares "
f"{'succeeded' if sharesRestored else 'FAILED'} and restoring ownership "
f"{'succeeded' if ownerRestored else 'FAILED'}. Check this dashboard's owner and "
"shares in Sisense directly."
)
return result, True
def putWidgetWithOwnershipFallback(apiClient, dashboardId, widgetId, payload, useTemporaryOwnership, apiUserId, logger):
"""
Save a widget, retrying once through withTemporaryOwnership if the first
attempt is denied with 403 and use_temporary_ownership is enabled.
Returns (response, usedTemporaryOwnership) - response is whichever
attempt actually produced one (None if the fallback couldn't even be
attempted).
"""
response = apiClient.put(putWidgetEndpoint(dashboardId, widgetId), data=payload)
deniedByPermissions = response is not None and response.status_code == 403
if not (deniedByPermissions and useTemporaryOwnership and apiUserId):
return response, False
logger.warning(
f"Widget {widgetId} on dashboard {dashboardId} was denied (403) - retrying with "
"temporary dashboard ownership since use_temporary_ownership is enabled."
)
fallbackResponse, attempted = withTemporaryOwnership(
apiClient,
dashboardId,
apiUserId,
logger,
lambda: apiClient.put(putWidgetEndpoint(dashboardId, widgetId), data=payload),
)
if not attempted:
# Swap couldn't even be attempted - report the original 403, not "no response".
return response, False
return fallbackResponse, True
def fetchWidget(apiClient, dashboardId, widgetId, isAdmin, logger):
"""Fetch a widget's full current definition - the source of truth this
tool reads from, diffs against, and eventually writes back to."""
response = apiClient.get(getWidgetEndpoint(dashboardId, widgetId, isAdmin))
if not response or not response.ok:
logger.error(f"Could not fetch widget {widgetId} on dashboard {dashboardId} - skipping it. {describeErrorResponse(response)}")
return None
return response.json()
def findTargetWidgets(apiClient, dashboards, widgetType, isAdmin, maxWidgets, logger):
"""
Scan dashboards for widgets matching widgetType, returning up to
maxWidgets matches as a list of dicts with dashboard/widget IDs and
titles. Pass maxWidgets=None to scan every dashboard with no cap.
Stops scanning as soon as maxWidgets is reached, so a small run does not
pay the cost of scanning every dashboard in the tenant.
"""
found = []
for dashboard in dashboards:
if maxWidgets is not None and len(found) >= maxWidgets:
break
if isAdmin:
# Admin export: works for dashboards not shared with this API user.
response = apiClient.get(
f"/api/v1/dashboards/export?dashboardIds={dashboard['oid']}&adminAccess=true"
)
if not response or not response.ok:
logger.warning(
f"Could not export dashboard {dashboard['oid']} ({dashboard.get('title', '')}) - "
f"skipping. {describeErrorResponse(response)}"
)
continue
exportedDashboards = response.json()
exported = exportedDashboards[0] if exportedDashboards else {}
candidateWidgets = exported.get("widgets") or []
else:
# Only dashboards this API user can already see.
response = apiClient.get(f"/api/v1/dashboards/{dashboard['oid']}/widgets")
if not response or not response.ok:
logger.warning(
f"Could not fetch widgets for dashboard {dashboard['oid']} ({dashboard.get('title', '')}) - "
f"skipping. {describeErrorResponse(response)}"
)
continue
candidateWidgets = response.json() or []
for widget in candidateWidgets:
if widget.get("type") != widgetType:
continue
found.append(
{
"dashboardId": dashboard["oid"],
"dashboardTitle": dashboard.get("title", ""),
"widgetId": widget["oid"],
"widgetTitle": widget.get("title", ""),
}
)
if maxWidgets is not None and len(found) >= maxWidgets:
break
return found
def undoChanges(apiClient, backupFile, logger, useTemporaryOwnership=False, apiUserId=None):
"""Restore every not-yet-undone widget in backupFile to its recorded state."""
records = loadBackup(backupFile)
pending = [record for record in records if not record.get("undone")]
if not pending:
logger.info("Nothing to undo - no pending changes found in the backup file.")
return
logger.info(f"Reverting {len(pending)} previously applied change(s)...")
# Revert most-recent-first, like a normal undo stack.
for record in reversed(records):
if record.get("undone"):
continue
# A hand-edited or corrupted backup file shouldn't be able to abort
# the whole run - skip just this record and keep going.
missingKeys = [key for key in ("dashboardId", "widgetId", "originalWidget") if key not in record]
if missingKeys:
logger.error(f"Skipping a backup record missing {', '.join(missingKeys)} - it cannot be undone.")
continue
dashboardId = record["dashboardId"]
widgetId = record["widgetId"]
originalWidget = record["originalWidget"]
response, _ = putWidgetWithOwnershipFallback(
apiClient,
dashboardId,
widgetId,
buildPutPayload(originalWidget),
useTemporaryOwnership,
apiUserId,
logger,
)
if response and response.ok:
record["undone"] = True
record["undoneAt"] = datetime.now(timezone.utc).isoformat()
logger.info(f"Reverted widget {widgetId} ({record.get('widgetTitle', '')}) on dashboard {dashboardId}.")
else:
logger.error(f"Failed to revert widget {widgetId}: {describeErrorResponse(response)}")
# Save after every widget, not just at the end, so a partial undo run isn't lost.
saveBackup(backupFile, records)
logger.info("Undo complete.")
def applyChanges(
apiClient,
candidates,
selector,
fontFamily,
fontMode,
apply,
force,
backupFile,
isAdmin,
quiet,
verbose,
logger,
useTemporaryOwnership=False,
apiUserId=None,
):
"""
Apply the configured font change(s) to each candidate widget.
fontMode controls which of the two font-change mechanisms are used (see
"Two ways to change the font" in README.md):
- "css": add newRule to the front of style.currentCard.style
- "config": set style.currentConfig.fontFamily to a single font name
- "both": do both
A widget is skipped only if every mechanism in use is already up to date
for it; otherwise, only the mechanism(s) that actually need a change are
applied (so, in "both" mode, a widget that already has the right CSS rule
but the wrong font config only gets its font config updated).
"""
updateCss = fontMode in ("css", "both")
updateConfig = fontMode in ("config", "both")
newRule = buildCssRule(selector, fontFamily) if updateCss else None
primaryFontName = extractPrimaryFontName(fontFamily) if updateConfig else None
if updateCss:
logger.info(f"CSS rule to apply: {newRule}")
if updateConfig:
logger.info(f"Font config value to apply (style.currentConfig.fontFamily): {primaryFontName!r}")
backupRecords = loadBackup(backupFile)
appliedCount = 0
skippedCount = 0
for candidate in candidates:
# Re-fetch fresh rather than reuse the discovery payload, which has a
# different shape (bulk export vs. single-widget document) and may
# be stale.
widget = fetchWidget(apiClient, candidate["dashboardId"], candidate["widgetId"], isAdmin, logger)
if widget is None:
continue
# Read state for both mechanisms up front, even if only one is in
# use, so the checks below don't need to branch on fontMode again.
styleBlock = widget.get("style", {}) or {}
currentCard = styleBlock.get("currentCard", {}) or {}
currentConfig = styleBlock.get("currentConfig", {}) or {}
existingStyle = currentCard.get("style", "")
existingFontFamily = currentConfig.get("fontFamily", "")
# A mechanism needs updating only if it's in use and not already
# correct (or --force is set). This is what lets "both" mode touch
# only the mechanism that's actually out of date on a given widget.
needsCssChange = updateCss and (force or not ruleAlreadyApplied(existingStyle, newRule))
needsConfigChange = updateConfig and (force or existingFontFamily != primaryFontName)
if not needsCssChange and not needsConfigChange:
if not quiet:
logger.info(
f"Skipping widget {candidate['widgetId']} ({candidate['widgetTitle']!r}) - "
"already up to date. Use --force to re-apply anyway."
)
skippedCount += 1
continue
updatedStyle = mergeStyle(existingStyle, newRule) if needsCssChange else existingStyle
# Dry run: log the preview, touch nothing.
if not apply:
if not quiet:
previewLines = [
f"[DRY RUN] Would update widget {candidate['widgetId']} "
f"({candidate['widgetTitle']!r} on dashboard {candidate['dashboardTitle']!r}):"
]
if needsCssChange:
previewLines.append(f" CSS style - old: {previewStyle(existingStyle, verbose)!r}")
previewLines.append(f" CSS style - new: {previewStyle(updatedStyle, verbose)!r}")
if needsConfigChange:
previewLines.append(f" Font config - old: {existingFontFamily!r}")
previewLines.append(f" Font config - new: {primaryFontName!r}")
logger.info("\n".join(previewLines))
continue
# Back up before mutating, and save immediately so --undo survives an
# interrupted run. deepcopy is required, not optional: `widget` is
# mutated in place below, and saveBackup() re-serializes this whole
# list on every call - a bare reference here would let a later
# widget's save silently overwrite this "before" snapshot with
# whatever `widget` looks like by then.
backupRecords.append(
{
"timestamp": datetime.now(timezone.utc).isoformat(),
"dashboardId": candidate["dashboardId"],
"widgetId": candidate["widgetId"],
"widgetTitle": candidate["widgetTitle"],
"fontModeUsed": fontMode,
"selectorUsed": selector if needsCssChange else None,
"fontUsed": fontFamily,
"originalWidget": copy.deepcopy(widget),
"undone": False,
}
)
saveBackup(backupFile, backupRecords)
# Mutate only what's out of date - a widget already correct for one
# mechanism keeps that field untouched, even in "both" mode.
if needsCssChange:
widget.setdefault("style", {}).setdefault("currentCard", {})["style"] = updatedStyle
if needsConfigChange:
widget.setdefault("style", {}).setdefault("currentConfig", {})["fontFamily"] = primaryFontName
response, usedTemporaryOwnership = putWidgetWithOwnershipFallback(
apiClient,
candidate["dashboardId"],
candidate["widgetId"],
buildPutPayload(widget),
useTemporaryOwnership,
apiUserId,
logger,
)
if response and response.ok:
appliedCount += 1
if usedTemporaryOwnership:
backupRecords[-1]["usedTemporaryOwnership"] = True
saveBackup(backupFile, backupRecords)
if not quiet:
# Report exactly which mechanism(s) were touched for this
# widget, since in "both" mode it may only have been one.
changedParts = [
label
for label, changed in (("CSS style", needsCssChange), ("font config", needsConfigChange))
if changed
]
viaOwnershipSwap = " (via temporary dashboard ownership)" if usedTemporaryOwnership else ""
logger.info(
f"Updated widget {candidate['widgetId']} ({candidate['widgetTitle']!r}) "
f"on dashboard {candidate['dashboardId']} - changed: {', '.join(changedParts)}{viaOwnershipSwap}."
)
else:
logger.error(f"Failed to update widget {candidate['widgetId']}: {describeErrorResponse(response)}")
backupRecords[-1]["updateFailed"] = True
saveBackup(backupFile, backupRecords)
return appliedCount, skippedCount
def parseArgs():
"""Define and parse the tool's command-line flags."""
parser = argparse.ArgumentParser(
description="Change the font used by BloX widgets on a Sisense instance.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--config", dest="configPath", default=defaultConfigPath, metavar="PATH", help="Path to config.yaml.")
# Font settings default to None, not a real value, so main() can tell
# whether each was actually passed - see resolveSetting().
parser.add_argument(
"--selector",
dest="selector",
default=None,
metavar="CSS_SELECTOR",
help="Override default_selector from config.yaml, for this run only. Normally set "
f"in config.yaml instead. Used if neither is set: {fallbackSelector!r}.",
)
parser.add_argument(
"--font",
dest="fontFamily",
default=None,
metavar="FONT_FAMILY",
help="Override default_font_family from config.yaml, for this run only. Normally set "
f"in config.yaml instead. Used if neither is set: {fallbackFontFamily!r}.",
)
parser.add_argument(
"--font-mode",
dest="fontMode",
choices=fontModeChoices,
default=None,
help=(
"Override default_font_mode from config.yaml, for this run only - normally set "
f"in config.yaml instead. Used if neither is set: {fallbackFontMode!r}. "
"'css' adds a CSS rule that directly targets nearly every element, including custom "
"HTML and input fields. 'config' sets Sisense's simpler built-in font setting instead, "
"which some widgets' own elements (including input fields) may not pick up. Neither is "
"guaranteed to cover everything - BloX templates vary - so 'both' applies both changes "
"for the best chance of full coverage."
),
)
parser.add_argument(
"--max-widgets",
dest="maxWidgets",
type=int,
default=None,
metavar="N",
help="Override default_max_widgets from config.yaml, for this run only. Normally set "
f"in config.yaml instead. Used if neither is set: {fallbackMaxWidgets}. "
"0 or a negative number means no limit - same as --all.",
)
parser.add_argument(
"--all",
dest="allWidgets",
action="store_true",
help="Process every matching widget, with no limit. Overrides --max-widgets.",
)
parser.add_argument(
"--dashboard-id",
dest="dashboardId",
default=None,
metavar="ID",
help="Target one already-known widget directly by dashboard and widget id, instead of "
"scanning the tenant. Must be used together with --widget-id. --max-widgets/--all are "
"ignored in this mode.",
)
parser.add_argument(
"--widget-id",
dest="widgetId",
default=None,
metavar="ID",
help="Used together with --dashboard-id. See --dashboard-id.",
)
# Run control - what this particular invocation actually does.
parser.add_argument(
"--apply",
dest="apply",
action="store_true",
help="Actually write changes to Sisense. Without this flag, the tool only previews what it would do.",
)
parser.add_argument(
"--force",
dest="force",
action="store_true",
help="Re-apply even to widgets that already look up to date for the selected --font-mode.",
)
parser.add_argument(
"--undo",
dest="undo",
action="store_true",
help="Revert every not-yet-undone change recorded in --backup-file, then exit.",
)
# Where state is read from / written to.
parser.add_argument("--backup-file", dest="backupFile", default=defaultBackupFile, metavar="PATH", help="Where change backups are stored.")
parser.add_argument("--log-file", dest="logFile", default=defaultLogFile, metavar="PATH", help="Where the run log is written.")
# Output verbosity.
parser.add_argument(
"--quiet",
dest="quiet",
action="store_true",
help="Only log the scan summary and any warnings/errors - skip the per-widget lines. "
"Useful for large runs.",
)
parser.add_argument(
"--verbose",
dest="verbose",
action="store_true",
help="Show full style strings in the log instead of a truncated preview.",
)
return parser.parse_args()
def loadConfig(configPath):
"""Read and sanity-check config.yaml, exiting with a plain-language
explanation (not a Python traceback) for the mistakes a new user is most
likely to make: no config file yet, broken YAML, or placeholder values
left unfilled."""
if not os.path.exists(configPath):
sys.exit(
f"Could not find {configPath}.\n\n"
"On first use, copy the example config and fill in the required values:\n\n"
" cp config.example.yaml config.yaml\n\n"
"Then edit config.yaml with the Sisense domain and API token to use - see README.md."
)
if not os.path.isfile(configPath):
sys.exit(f"{configPath} is not a file (is it a directory?). Point --config at a config.yaml file.")
with open(configPath, "r") as configFile:
try:
config = yaml.safe_load(configFile) or {}
except yaml.YAMLError as error:
sys.exit(f"{configPath} is not valid YAML: {error}\n\nCheck for typos, especially around quotes and indentation.")
if not isinstance(config, dict):
sys.exit(f"{configPath} must contain key: value settings, not a {type(config).__name__}. See config.example.yaml.")
placeholderValues = {"", "your-tenant.sisense.com", "YOUR_SISENSE_API_TOKEN"}
missingKeys = [key for key in ("domain", "token") if str(config.get(key, "")).strip() in placeholderValues]
if missingKeys:
sys.exit(
f"{configPath} still has placeholder value(s) for: {', '.join(missingKeys)}.\n\n"
"Edit config.yaml with the real Sisense domain and API token to use - see README.md."
)
return config
def resolveSetting(cliValue, config, configKey, fallback):
"""
Decide the effective value for a setting that can be given in three
places, checked in this order: a command-line flag, a value in
config.yaml, or this tool's own built-in fallback.
"""
if cliValue is not None:
return cliValue
configValue = config.get(configKey)
if configValue is not None:
return configValue
return fallback
def requireNonEmptyString(value, settingDescription):
"""Exit with a clear error if value isn't a usable non-empty string -
catches mistakes like an empty --selector/--font flag or a
default_font_family: "" in config.yaml, either of which would otherwise
silently produce a broken CSS rule or font name later on."""
if not isinstance(value, str) or not value.strip():
sys.exit(f"Invalid {settingDescription}: {value!r} - must be a non-empty string.")
def parseWholeNumber(value):
"""
Interpret value as a whole number, or return None if it isn't one.
Used for default_max_widgets, which - unlike --max-widgets - isn't
type-checked by argparse and can be any YAML value. Deliberately
stricter than Python's own int(): int(10.5) truncates to 10 and
int(True) succeeds as 1, both of which would silently accept a config
mistake instead of rejecting it.
"""
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value) if value.is_integer() else None
if isinstance(value, str):
try:
return int(value.strip())
except ValueError:
return None
return None
def main():
"""
Entry point: parse arguments, load configuration, then either undo a
previous run or scan for candidate widgets and apply font changes to
them. This function only wires the pieces together - all the actual
logic lives in the functions it calls.
"""
# Logging starts as early as possible so even a bad --log-file value or a
# config problem below gets written to the console consistently.
args = parseArgs()
logger = setupLogger(args.logFile)
# config.yaml supplies the Sisense connection details (read directly by
# SisenseClient) plus the default_* settings resolved below.
config = loadConfig(args.configPath)
isAdmin = bool(config.get("is_user_admin"))
widgetType = config.get("widget_type", "BloX")
# use_temporary_ownership (see config.example.yaml and README.md
# "Permissions") needs the API user's own id, read directly out of the
# token rather than via a separate lookup call.
useTemporaryOwnership = bool(config.get("use_temporary_ownership"))
apiUserId = decodeJwtUserId(config.get("token", "")) if useTemporaryOwnership else None
if useTemporaryOwnership and not apiUserId:
logger.warning(
"use_temporary_ownership is enabled in config.yaml, but the API user's id could not be "
"read from token - this fallback will be skipped for the rest of this run."
)
apiClient = SisenseClient(config_file=args.configPath, debug=False)
# --undo works entirely from the backup file and skips everything below.
if args.undo:
undoChanges(apiClient, args.backupFile, logger, useTemporaryOwnership, apiUserId)
return
# Priority: CLI flag, then config.yaml, then built-in fallback - see
# resolveSetting().
selector = resolveSetting(args.selector, config, "default_selector", fallbackSelector)
requireNonEmptyString(selector, "selector (--selector / default_selector)")
fontFamily = resolveSetting(args.fontFamily, config, "default_font_family", fallbackFontFamily)
requireNonEmptyString(fontFamily, "font (--font / default_font_family)")
fontMode = resolveSetting(args.fontMode, config, "default_font_mode", fallbackFontMode)
if fontMode not in fontModeChoices:
# Only reachable via a typo in config.yaml's default_font_mode -
# argparse itself already restricts --font-mode to fontModeChoices.
sys.exit(
f"Invalid default_font_mode in config.yaml: {fontMode!r}. Must be one of: "
f"{', '.join(fontModeChoices)}."
)
# default_max_widgets isn't type-checked by argparse the way --max-widgets
# is (it can only come from config.yaml here), so it needs its own
# validation before being used as a number.
rawMaxWidgets = resolveSetting(args.maxWidgets, config, "default_max_widgets", fallbackMaxWidgets)
maxWidgetsSetting = parseWholeNumber(rawMaxWidgets)
if maxWidgetsSetting is None:
sys.exit(f"Invalid default_max_widgets in config.yaml: {rawMaxWidgets!r} - must be a whole number.")
# --all, or a zero/negative limit from either source, both mean "no cap".
maxWidgets = None if (args.allWidgets or maxWidgetsSetting <= 0) else maxWidgetsSetting
if bool(args.dashboardId) != bool(args.widgetId):
sys.exit("--dashboard-id and --widget-id must be used together.")
if args.dashboardId and args.widgetId:
# Skip the tenant scan entirely and go straight to one already-known
# widget - useful for retrying, or verifying, a specific widget
# without depending on dashboard discovery order (which can surface
# unrelated widgets, or the same widget twice, on a tenant whose
# dashboards have data quirks of their own).
widget = fetchWidget(apiClient, args.dashboardId, args.widgetId, isAdmin, logger)
if widget is None:
sys.exit(f"Could not fetch widget {args.widgetId} on dashboard {args.dashboardId} - see the log above.")
if widget.get("type") != widgetType:
sys.exit(
f"Widget {args.widgetId} is a {widget.get('type')!r} widget, not {widgetType!r} - refusing to "
"change its font. Pass the id of a BloX widget instead."
)
candidates = [
{
"dashboardId": args.dashboardId,
"dashboardTitle": "",
"widgetId": args.widgetId,
"widgetTitle": widget.get("title", ""),
}
]
logger.info(f"Targeting widget {args.widgetId} ({widget.get('title', '')!r}) directly - skipping the tenant scan.")
else:
dashboardHelper = Dashboard(api_client=apiClient)
dashboards = dashboardHelper.get_all_dashboards()
# On failure (unreachable domain, bad token, server error), pysisense
# returns {"error": "..."} instead of a list - catch that here so it
# surfaces as a clear message instead of a confusing crash further down.
if not isinstance(dashboards, list):
detail = dashboards.get("error", dashboards) if isinstance(dashboards, dict) else dashboards
sys.exit(
f"Could not retrieve dashboards from Sisense: {detail}\n\n"
"Check that domain and token in config.yaml are correct, and that the server is reachable."
)
scopeDescription = "every" if maxWidgets is None else f"up to {maxWidgets}"
logger.info(f"Scanning {len(dashboards)} dashboard(s) for {scopeDescription} '{widgetType}' widget(s)...")
candidates = findTargetWidgets(apiClient, dashboards, widgetType, isAdmin, maxWidgets, logger)
logger.info(f"Found {len(candidates)} candidate widget(s).")
# With args.apply False, this only previews - see applyChanges().
appliedCount, skippedCount = applyChanges(
apiClient,
candidates,
selector,
fontFamily,
fontMode,
args.apply,
args.force,
args.backupFile,
isAdmin,
args.quiet,
args.verbose,
logger,
useTemporaryOwnership,
apiUserId,
)
logger.info(f"Done. Applied: {appliedCount}, skipped (already applied): {skippedCount}, candidates seen: {len(candidates)}.")
# Surface the exact undo command after a real run, so it doesn't have to
# be looked up in --help right when it might be needed.
if not args.apply:
logger.info("This was a dry run - nothing was written. Re-run with --apply to write changes.")
elif appliedCount:
logger.info(f"To revert everything just written, run: python {os.path.basename(__file__)} --undo --backup-file {args.backupFile}")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit("\nStopped.")
If you manage Sisense, you've had some version of this conversation: someone asks for a report that actually looks like a finished document, not a dashboard screenshot with the edges cut off. There are more ways to solve this than most admins realize, and each one solves a different piece of the problem. A quick rundown: Native PDF export - zero setup, but the layout is whatever the dashboard happens to produce. No control over page breaks, no way to add narrative text, and pivot tables cut off at the edge of the screen. Sisense Report Manager - solid scheduling and delivery, but it's still exporting the dashboard as-is. No design layer, no multi-dashboard composition. Jasper Reports - genuinely pixel-perfect for table and pivot widgets specifically, if you have developer resource to build and maintain JRXML templates. Doesn't touch charts or KPI widgets. SAP Crystal Reports via ODBC - full design control, but it pulls your data out of Sisense entirely to do it, which is a real consideration if data residency matters to you. Where this leaves most teams: something that handles scheduling, or something that handles pixel-perfect tabular data, but not both, and rarely anything that lets you design a report as its own document rather than a dashboard export. That's the gap QBeeQ Report Studio is built for: a canvas inside Sisense where you design the report as a report - pulling from multiple dashboards if you need to, with full control over layout, brand, and every widget on the page. Report Studio Deliver , our scheduled distribution layer, is coming in beta this month. For the full breakdown with a side-by-side comparison table, here's the complete write-up .
Filter by:
No upcoming events