py-tbparse 0.1.1a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py_tbparse-0.1.1a0.dist-info/METADATA +168 -0
- py_tbparse-0.1.1a0.dist-info/RECORD +26 -0
- py_tbparse-0.1.1a0.dist-info/WHEEL +5 -0
- py_tbparse-0.1.1a0.dist-info/entry_points.txt +3 -0
- py_tbparse-0.1.1a0.dist-info/licenses/LICENSE +25 -0
- py_tbparse-0.1.1a0.dist-info/top_level.txt +1 -0
- twbparser_py/__init__.py +54 -0
- twbparser_py/_clean.py +87 -0
- twbparser_py/_tables.py +97 -0
- twbparser_py/_xml.py +172 -0
- twbparser_py/batch.py +62 -0
- twbparser_py/calculated_fields.py +113 -0
- twbparser_py/cli.py +207 -0
- twbparser_py/dashboards.py +101 -0
- twbparser_py/datasources.py +257 -0
- twbparser_py/diff.py +86 -0
- twbparser_py/fields.py +148 -0
- twbparser_py/graph.py +81 -0
- twbparser_py/joins.py +99 -0
- twbparser_py/parser.py +234 -0
- twbparser_py/published.py +52 -0
- twbparser_py/py.typed +0 -0
- twbparser_py/relationships.py +175 -0
- twbparser_py/sql.py +76 -0
- twbparser_py/validators.py +99 -0
- twbparser_py/webgui.py +398 -0
twbparser_py/batch.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Batch/folder analysis: run a table across every workbook in a directory.
|
|
2
|
+
|
|
3
|
+
Not part of the R package, which operates on one workbook at a time --
|
|
4
|
+
this is a natural Python-side extension (glob + pandas.concat).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import glob
|
|
10
|
+
import os
|
|
11
|
+
import warnings
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from ._tables import TABLE_SPECS
|
|
16
|
+
from .parser import TwbParser
|
|
17
|
+
|
|
18
|
+
DEFAULT_PATTERNS = ("*.twb", "*.twbx")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def scan_folder(
|
|
22
|
+
directory: str,
|
|
23
|
+
table: str = "overview",
|
|
24
|
+
patterns=DEFAULT_PATTERNS,
|
|
25
|
+
**kwargs,
|
|
26
|
+
) -> pd.DataFrame:
|
|
27
|
+
"""Run `table` (see `_tables.TABLE_SPECS`) over every `.twb`/`.twbx`
|
|
28
|
+
file in `directory`, concatenating the results with a `workbook`
|
|
29
|
+
column identifying the source file.
|
|
30
|
+
|
|
31
|
+
A workbook that fails to load or fails to extract `table` is skipped
|
|
32
|
+
with a warning rather than aborting the whole batch -- one corrupt
|
|
33
|
+
file in a folder of fifty shouldn't block the other forty-nine.
|
|
34
|
+
"""
|
|
35
|
+
if table not in TABLE_SPECS:
|
|
36
|
+
raise ValueError(f"unknown table '{table}'")
|
|
37
|
+
getter = TABLE_SPECS[table]
|
|
38
|
+
|
|
39
|
+
paths: list[str] = []
|
|
40
|
+
for pattern in patterns:
|
|
41
|
+
paths.extend(glob.glob(os.path.join(directory, pattern)))
|
|
42
|
+
paths = sorted(set(paths))
|
|
43
|
+
|
|
44
|
+
if not paths:
|
|
45
|
+
return pd.DataFrame(columns=["workbook"])
|
|
46
|
+
|
|
47
|
+
frames = []
|
|
48
|
+
for path in paths:
|
|
49
|
+
try:
|
|
50
|
+
parser = TwbParser(path)
|
|
51
|
+
df = getter(parser, **kwargs)
|
|
52
|
+
except Exception as e: # noqa: BLE001 - genuinely want to skip any failure
|
|
53
|
+
warnings.warn(f"skipping {path}: {e}")
|
|
54
|
+
continue
|
|
55
|
+
df = df.copy()
|
|
56
|
+
df.insert(0, "workbook", os.path.basename(path))
|
|
57
|
+
frames.append(df)
|
|
58
|
+
|
|
59
|
+
if not frames:
|
|
60
|
+
return pd.DataFrame(columns=["workbook"])
|
|
61
|
+
|
|
62
|
+
return pd.concat(frames, ignore_index=True)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Calculated-field and raw-field (non-calc, non-parameter) extraction.
|
|
2
|
+
|
|
3
|
+
Ports of R/calculated_fields.R (`extract_calculated_fields`,
|
|
4
|
+
`extract_raw_fields`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from ._clean import attr_safe_get, clean_table, strip_brackets
|
|
14
|
+
|
|
15
|
+
_DATASOURCE_XPATH = (
|
|
16
|
+
"/workbook/datasources/datasource[@name and not(ancestor::view)]"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_TABLE_CALC_RE = re.compile(
|
|
20
|
+
r"\b(WINDOW_|LOOKUP\(|INDEX\(|RUNNING_|RANK\(|PREVIOUS_VALUE\()"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_CALC_COLUMNS = [
|
|
24
|
+
"datasource", "name", "tableau_internal_name", "datatype", "role",
|
|
25
|
+
"formula", "calc_class", "is_table_calc", "table", "table_clean",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
_RAW_COLUMNS = [
|
|
29
|
+
"datasource", "name", "tableau_internal_name", "datatype", "role",
|
|
30
|
+
"is_hidden", "is_parameter", "table", "table_clean",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def extract_calculated_fields(xml_doc, include_parameters: bool = False) -> pd.DataFrame:
|
|
35
|
+
"""Port of `extract_calculated_fields()`."""
|
|
36
|
+
ds_nodes = xml_doc.xpath(_DATASOURCE_XPATH)
|
|
37
|
+
if not ds_nodes:
|
|
38
|
+
return pd.DataFrame(columns=_CALC_COLUMNS)
|
|
39
|
+
|
|
40
|
+
rows = []
|
|
41
|
+
for ds in ds_nodes:
|
|
42
|
+
ds_name = ds.get("name")
|
|
43
|
+
if not include_parameters and ds_name == "Parameters":
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
cols = ds.xpath(".//column[@name and not(@param-domain-type)][.//calculation]")
|
|
47
|
+
for col in cols:
|
|
48
|
+
ca = dict(col.attrib)
|
|
49
|
+
calc = col.find(".//calculation")
|
|
50
|
+
fa = dict(calc.attrib) if calc is not None else {}
|
|
51
|
+
|
|
52
|
+
internal = attr_safe_get(ca, "name")
|
|
53
|
+
caption = attr_safe_get(ca, "caption")
|
|
54
|
+
raw_tbl = attr_safe_get(ca, "table")
|
|
55
|
+
|
|
56
|
+
formula = attr_safe_get(fa, "formula")
|
|
57
|
+
calc_class = attr_safe_get(fa, "class")
|
|
58
|
+
|
|
59
|
+
is_tbl = bool(formula) and bool(_TABLE_CALC_RE.search(formula))
|
|
60
|
+
|
|
61
|
+
rows.append(
|
|
62
|
+
{
|
|
63
|
+
"datasource": ds_name,
|
|
64
|
+
"name": caption if caption else strip_brackets(internal),
|
|
65
|
+
"tableau_internal_name": internal,
|
|
66
|
+
"datatype": attr_safe_get(ca, "datatype"),
|
|
67
|
+
"role": attr_safe_get(ca, "role"),
|
|
68
|
+
"formula": formula,
|
|
69
|
+
"calc_class": calc_class,
|
|
70
|
+
"is_table_calc": is_tbl,
|
|
71
|
+
"table": raw_tbl,
|
|
72
|
+
"table_clean": clean_table(raw_tbl),
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if not rows:
|
|
77
|
+
return pd.DataFrame(columns=_CALC_COLUMNS)
|
|
78
|
+
return pd.DataFrame(rows, columns=_CALC_COLUMNS).drop_duplicates()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def extract_raw_fields(xml_doc) -> pd.DataFrame:
|
|
82
|
+
"""Port of `extract_raw_fields()`."""
|
|
83
|
+
ds_nodes = xml_doc.xpath(_DATASOURCE_XPATH)
|
|
84
|
+
if not ds_nodes:
|
|
85
|
+
return pd.DataFrame(columns=_RAW_COLUMNS)
|
|
86
|
+
|
|
87
|
+
rows = []
|
|
88
|
+
for ds in ds_nodes:
|
|
89
|
+
ds_name = ds.get("name")
|
|
90
|
+
nodes = ds.xpath(".//column[@name and not(.//calculation) and not(@param-domain-type)]")
|
|
91
|
+
for node in nodes:
|
|
92
|
+
a = dict(node.attrib)
|
|
93
|
+
internal = attr_safe_get(a, "name")
|
|
94
|
+
caption = attr_safe_get(a, "caption")
|
|
95
|
+
raw_tbl = attr_safe_get(a, "table")
|
|
96
|
+
|
|
97
|
+
rows.append(
|
|
98
|
+
{
|
|
99
|
+
"datasource": ds_name,
|
|
100
|
+
"name": caption if caption else strip_brackets(internal),
|
|
101
|
+
"tableau_internal_name": internal,
|
|
102
|
+
"datatype": attr_safe_get(a, "datatype"),
|
|
103
|
+
"role": attr_safe_get(a, "role"),
|
|
104
|
+
"is_hidden": attr_safe_get(a, "hidden", "false") in ("true", "1"),
|
|
105
|
+
"is_parameter": False,
|
|
106
|
+
"table": raw_tbl,
|
|
107
|
+
"table_clean": clean_table(raw_tbl),
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if not rows:
|
|
112
|
+
return pd.DataFrame(columns=_RAW_COLUMNS)
|
|
113
|
+
return pd.DataFrame(rows, columns=_RAW_COLUMNS).drop_duplicates()
|
twbparser_py/cli.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Command-line interface: `twbparser WORKBOOK [TABLE] [options]`.
|
|
2
|
+
|
|
3
|
+
Two reserved subcommands, dispatched on the first argument before the
|
|
4
|
+
normal single-workbook parser runs: `twbparser diff A.twb B.twb [TABLE]`
|
|
5
|
+
and `twbparser batch DIR [TABLE]`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from ._tables import TABLE_NAMES, TABLE_SPECS
|
|
18
|
+
from .batch import scan_folder
|
|
19
|
+
from .diff import diff_workbooks
|
|
20
|
+
from .parser import TwbParser
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _df_text(df: pd.DataFrame, fmt: str) -> str:
|
|
24
|
+
if fmt == "csv":
|
|
25
|
+
return df.to_csv(index=False)
|
|
26
|
+
if fmt == "json":
|
|
27
|
+
return df.to_json(orient="records", indent=2)
|
|
28
|
+
if df.empty:
|
|
29
|
+
return "(empty)"
|
|
30
|
+
with pd.option_context(
|
|
31
|
+
"display.max_rows", None, "display.max_columns", None, "display.width", 200
|
|
32
|
+
):
|
|
33
|
+
return df.to_string(index=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _write(text: str, output: str | None) -> None:
|
|
37
|
+
if output:
|
|
38
|
+
Path(output).write_text(text)
|
|
39
|
+
else:
|
|
40
|
+
print(text)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_arg_parser() -> argparse.ArgumentParser:
|
|
44
|
+
ap = argparse.ArgumentParser(
|
|
45
|
+
prog="twbparser",
|
|
46
|
+
description="Parse a Tableau .twb/.twbx workbook and print one of its tables. "
|
|
47
|
+
"See also: 'twbparser diff A.twb B.twb [TABLE]' and 'twbparser batch DIR [TABLE]'.",
|
|
48
|
+
)
|
|
49
|
+
ap.add_argument("workbook", help="path to a .twb or .twbx file")
|
|
50
|
+
ap.add_argument(
|
|
51
|
+
"table",
|
|
52
|
+
nargs="?",
|
|
53
|
+
default="overview",
|
|
54
|
+
choices=[*TABLE_NAMES, "validate", "tables", "graph"],
|
|
55
|
+
help="which table to print (default: overview); 'tables' lists options; "
|
|
56
|
+
"'validate' checks relationships; 'graph' prints a Graphviz DOT digraph "
|
|
57
|
+
"of joins/relationships",
|
|
58
|
+
)
|
|
59
|
+
ap.add_argument(
|
|
60
|
+
"--format", "-f", choices=["table", "csv", "json"], default="table",
|
|
61
|
+
help="output format (default: table)",
|
|
62
|
+
)
|
|
63
|
+
ap.add_argument("--output", "-o", help="write to this file instead of stdout")
|
|
64
|
+
ap.add_argument(
|
|
65
|
+
"--dashboard", help="restrict 'dashboard-sheets' to one dashboard by name"
|
|
66
|
+
)
|
|
67
|
+
ap.add_argument(
|
|
68
|
+
"--include-parameters", action="store_true",
|
|
69
|
+
help="include the 'Parameters' datasource in 'calculated-fields'",
|
|
70
|
+
)
|
|
71
|
+
ap.add_argument(
|
|
72
|
+
"--include-inferred", action="store_true",
|
|
73
|
+
help="for 'graph': also include dashed edges from inferred_relationships",
|
|
74
|
+
)
|
|
75
|
+
return ap
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_diff_arg_parser() -> argparse.ArgumentParser:
|
|
79
|
+
ap = argparse.ArgumentParser(
|
|
80
|
+
prog="twbparser diff",
|
|
81
|
+
description="Diff one table between two workbooks (row-level added/removed).",
|
|
82
|
+
)
|
|
83
|
+
ap.add_argument("workbook_a", help="the 'before' .twb/.twbx file")
|
|
84
|
+
ap.add_argument("workbook_b", help="the 'after' .twb/.twbx file")
|
|
85
|
+
ap.add_argument("table", nargs="?", default="datasources", choices=TABLE_NAMES)
|
|
86
|
+
ap.add_argument("--format", "-f", choices=["table", "csv", "json"], default="table")
|
|
87
|
+
ap.add_argument("--output", "-o", help="write to this file instead of stdout")
|
|
88
|
+
ap.add_argument("--dashboard", help="restrict 'dashboard-sheets' to one dashboard by name")
|
|
89
|
+
ap.add_argument(
|
|
90
|
+
"--include-parameters", action="store_true",
|
|
91
|
+
help="include the 'Parameters' datasource in 'calculated-fields'",
|
|
92
|
+
)
|
|
93
|
+
return ap
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _run_diff(argv: list[str]) -> int:
|
|
97
|
+
args = build_diff_arg_parser().parse_args(argv)
|
|
98
|
+
try:
|
|
99
|
+
a = TwbParser(args.workbook_a)
|
|
100
|
+
b = TwbParser(args.workbook_b)
|
|
101
|
+
except (FileNotFoundError, ValueError) as e:
|
|
102
|
+
print(f"error: {e}", file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
df = diff_workbooks(
|
|
106
|
+
a, b, table=args.table, dashboard=args.dashboard, include_parameters=args.include_parameters
|
|
107
|
+
)
|
|
108
|
+
_write(_df_text(df, args.format), args.output)
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_batch_arg_parser() -> argparse.ArgumentParser:
|
|
113
|
+
ap = argparse.ArgumentParser(
|
|
114
|
+
prog="twbparser batch",
|
|
115
|
+
description="Run one table across every .twb/.twbx file in a directory.",
|
|
116
|
+
)
|
|
117
|
+
ap.add_argument("directory", help="folder to scan for .twb/.twbx files")
|
|
118
|
+
ap.add_argument("table", nargs="?", default="overview", choices=TABLE_NAMES)
|
|
119
|
+
ap.add_argument("--format", "-f", choices=["table", "csv", "json"], default="table")
|
|
120
|
+
ap.add_argument("--output", "-o", help="write to this file instead of stdout")
|
|
121
|
+
ap.add_argument("--dashboard", help="restrict 'dashboard-sheets' to one dashboard by name")
|
|
122
|
+
ap.add_argument(
|
|
123
|
+
"--include-parameters", action="store_true",
|
|
124
|
+
help="include the 'Parameters' datasource in 'calculated-fields'",
|
|
125
|
+
)
|
|
126
|
+
return ap
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _run_batch(argv: list[str]) -> int:
|
|
130
|
+
args = build_batch_arg_parser().parse_args(argv)
|
|
131
|
+
if not Path(args.directory).is_dir():
|
|
132
|
+
print(f"error: not a directory: {args.directory}", file=sys.stderr)
|
|
133
|
+
return 1
|
|
134
|
+
|
|
135
|
+
df = scan_folder(
|
|
136
|
+
args.directory,
|
|
137
|
+
table=args.table,
|
|
138
|
+
dashboard=args.dashboard,
|
|
139
|
+
include_parameters=args.include_parameters,
|
|
140
|
+
)
|
|
141
|
+
_write(_df_text(df, args.format), args.output)
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _is_reserved_subcommand(argv: list[str], name: str) -> bool:
|
|
146
|
+
"""True if `argv` invokes the `name` subcommand -- but don't let that
|
|
147
|
+
shadow an actual workbook that happens to be named exactly "diff" or
|
|
148
|
+
"batch" (no extension) sitting in the current directory."""
|
|
149
|
+
return argv[:1] == [name] and not Path(name).exists()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main(argv: list[str] | None = None) -> int:
|
|
153
|
+
raw_argv = sys.argv[1:] if argv is None else list(argv)
|
|
154
|
+
if _is_reserved_subcommand(raw_argv, "diff"):
|
|
155
|
+
return _run_diff(raw_argv[1:])
|
|
156
|
+
if _is_reserved_subcommand(raw_argv, "batch"):
|
|
157
|
+
return _run_batch(raw_argv[1:])
|
|
158
|
+
|
|
159
|
+
args = build_arg_parser().parse_args(argv)
|
|
160
|
+
|
|
161
|
+
if args.table == "tables":
|
|
162
|
+
for name in TABLE_NAMES:
|
|
163
|
+
print(name)
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
parser = TwbParser(args.workbook)
|
|
168
|
+
except (FileNotFoundError, ValueError) as e:
|
|
169
|
+
print(f"error: {e}", file=sys.stderr)
|
|
170
|
+
return 1
|
|
171
|
+
|
|
172
|
+
if args.table == "graph":
|
|
173
|
+
dot = parser.get_relationship_graph_dot(include_inferred=args.include_inferred)
|
|
174
|
+
_write(dot, args.output)
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
if args.table == "validate":
|
|
178
|
+
result = parser.validate()
|
|
179
|
+
if args.format == "json":
|
|
180
|
+
payload = {
|
|
181
|
+
"ok": result["ok"],
|
|
182
|
+
"issues": {
|
|
183
|
+
name: df.to_dict(orient="records")
|
|
184
|
+
for name, df in result["issues"].items()
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
_write(json.dumps(payload, indent=2, default=str), args.output)
|
|
188
|
+
else:
|
|
189
|
+
lines = [f"ok: {result['ok']}"]
|
|
190
|
+
for name, df in result["issues"].items():
|
|
191
|
+
lines.append(f"\n{name}:")
|
|
192
|
+
lines.append(df.to_string(index=False))
|
|
193
|
+
_write("\n".join(lines), args.output)
|
|
194
|
+
return 0 if result["ok"] else 2
|
|
195
|
+
|
|
196
|
+
getter = TABLE_SPECS[args.table]
|
|
197
|
+
df = getter(
|
|
198
|
+
parser,
|
|
199
|
+
dashboard=args.dashboard,
|
|
200
|
+
include_parameters=args.include_parameters,
|
|
201
|
+
)
|
|
202
|
+
_write(_df_text(df, args.format), args.output)
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Dashboard listing and worksheet-placement extraction.
|
|
2
|
+
|
|
3
|
+
Port of R/dashboard_details.R (`twb_dashboard_sheets`). `list_dashboards`
|
|
4
|
+
is a deliberately simplified name-only lister for this v1 subset, not a
|
|
5
|
+
full port of R's `.ins_dashboards` (which also reports per-dashboard
|
|
6
|
+
worksheet/zone counts) -- see AGENTS.md's v2 scope.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
_DASHBOARD_COLUMNS = ["name"]
|
|
16
|
+
_SHEETS_COLUMNS = ["dashboard", "sheet", "zone_id", "x", "y", "w", "h"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _int_attr(node, name) -> Optional[int]:
|
|
20
|
+
"""Port of `.int_attr`.
|
|
21
|
+
|
|
22
|
+
R's `as.integer(xml_attr(...))` parses the string as a double first and
|
|
23
|
+
truncates, so a fractional coordinate like "682.666" (Tableau does emit
|
|
24
|
+
these for some floating-layout zones) becomes 682 rather than failing.
|
|
25
|
+
"""
|
|
26
|
+
val = node.get(name)
|
|
27
|
+
if val is None:
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
return int(val)
|
|
31
|
+
except ValueError:
|
|
32
|
+
try:
|
|
33
|
+
return int(float(val))
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _xpath_string_literal(s: str) -> str:
|
|
39
|
+
"""Build an XPath 1.0 string literal for `s`, safe for names containing
|
|
40
|
+
a `'`, a `"`, or both.
|
|
41
|
+
|
|
42
|
+
XPath 1.0 has no in-literal escape character, so a value containing
|
|
43
|
+
both quote characters can't be wrapped in either on its own -- it has
|
|
44
|
+
to be assembled with `concat()`, splitting on `'` and substituting a
|
|
45
|
+
double-quoted `'` for each occurrence.
|
|
46
|
+
"""
|
|
47
|
+
if "'" not in s:
|
|
48
|
+
return f"'{s}'"
|
|
49
|
+
if '"' not in s:
|
|
50
|
+
return f'"{s}"'
|
|
51
|
+
parts = s.split("'")
|
|
52
|
+
pieces = []
|
|
53
|
+
for i, part in enumerate(parts):
|
|
54
|
+
if part:
|
|
55
|
+
pieces.append(f"'{part}'")
|
|
56
|
+
if i < len(parts) - 1:
|
|
57
|
+
pieces.append("\"'\"")
|
|
58
|
+
return f"concat({','.join(pieces)})"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _dashboard_xpath(dashboard: Optional[str]) -> str:
|
|
62
|
+
if dashboard is None:
|
|
63
|
+
return ".//dashboard"
|
|
64
|
+
return f".//dashboard[@name={_xpath_string_literal(dashboard)}]"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def list_dashboards(xml_doc) -> pd.DataFrame:
|
|
68
|
+
"""List dashboard names in the workbook."""
|
|
69
|
+
nodes = xml_doc.xpath(".//dashboard")
|
|
70
|
+
names = [n.get("name") for n in nodes if n.get("name")]
|
|
71
|
+
return pd.DataFrame({"name": sorted(set(names))}, columns=_DASHBOARD_COLUMNS)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def dashboard_sheets(xml_doc, dashboard: Optional[str] = None) -> pd.DataFrame:
|
|
75
|
+
"""Port of `twb_dashboard_sheets()` / `.ins_dashboard_sheets`."""
|
|
76
|
+
d_nodes = xml_doc.xpath(_dashboard_xpath(dashboard))
|
|
77
|
+
if not d_nodes:
|
|
78
|
+
return pd.DataFrame(columns=_SHEETS_COLUMNS)
|
|
79
|
+
|
|
80
|
+
rows = []
|
|
81
|
+
for d in d_nodes:
|
|
82
|
+
d_name = d.get("name")
|
|
83
|
+
ws_zones = d.xpath(".//zone[@worksheet]")
|
|
84
|
+
for z in ws_zones:
|
|
85
|
+
rows.append(
|
|
86
|
+
{
|
|
87
|
+
"dashboard": d_name,
|
|
88
|
+
"sheet": z.get("worksheet"),
|
|
89
|
+
"zone_id": z.get("id"),
|
|
90
|
+
"x": _int_attr(z, "x"),
|
|
91
|
+
"y": _int_attr(z, "y"),
|
|
92
|
+
"w": _int_attr(z, "w"),
|
|
93
|
+
"h": _int_attr(z, "h"),
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if not rows:
|
|
98
|
+
return pd.DataFrame(columns=_SHEETS_COLUMNS)
|
|
99
|
+
|
|
100
|
+
df = pd.DataFrame(rows, columns=_SHEETS_COLUMNS)
|
|
101
|
+
return df.sort_values(["dashboard", "sheet"]).reset_index(drop=True)
|