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.
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)