ddgbasic 1.0.0__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.
ddgreport.py ADDED
@@ -0,0 +1,175 @@
1
+ """
2
+ Report layout for DDGBasic's REPORT statement: a title line with date and page
3
+ number, column headings, group headers and subtotal footers (nested for
4
+ several GROUP BY columns), grand totals, and optional pagination.
5
+
6
+ build_report() is pure: it takes the query's columns and rows and returns the
7
+ report as a list of text lines (a form feed, '\f', starts each new page).
8
+ """
9
+
10
+ def _is_number(value):
11
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
12
+
13
+
14
+ def _plain(value):
15
+ """A value as PRINT shows it, without the leading space."""
16
+ if value is None:
17
+ return ''
18
+ if _is_number(value):
19
+ f = float(value)
20
+ return str(int(f)) if f == int(f) and abs(f) < 1e15 else repr(f)
21
+ return str(value)
22
+
23
+
24
+ def build_report(columns, rows, title='', groups=(), totals=(), formats=None,
25
+ page_length=0, date='', format_value=None, max_width=40):
26
+ """Returns the report as a list of lines.
27
+
28
+ columns the query's column names
29
+ rows the query's rows (sequences, in column order)
30
+ groups column names to group by, outermost first (the rows must be sorted by them)
31
+ totals column names to sum in group footers and the grand total
32
+ formats {column name: FORMAT$ mask}
33
+ page_length lines per page, or 0 for one long page
34
+ format_value format_value(mask, value) -> text, used for FORMAT masks
35
+ """
36
+ formats = {k.lower(): v for k, v in (formats or {}).items()}
37
+ lower = [c.lower() for c in columns]
38
+
39
+ def index_of(name):
40
+ try:
41
+ return lower.index(name.lower())
42
+ except ValueError:
43
+ raise ValueError(f"REPORT: there is no column '{name}' in the query.")
44
+
45
+ group_idx = [index_of(g) for g in groups]
46
+ total_idx = [index_of(t) for t in totals]
47
+ shown = [i for i in range(len(columns)) if i not in group_idx]
48
+ if not shown:
49
+ raise ValueError("REPORT: every column is a GROUP BY column; nothing is left to show.")
50
+
51
+ def text(i, value):
52
+ mask = formats.get(lower[i])
53
+ if mask and value not in (None, '') and format_value:
54
+ return format_value(mask, value).strip()
55
+ return _plain(value)
56
+
57
+ numeric = {i: any(_is_number(r[i]) for r in rows) for i in shown}
58
+ cells = [[text(i, r[i]) for i in shown] for r in rows]
59
+
60
+ # Grand and group totals, worked out first so the column widths can fit them
61
+ def total_of(subset, i):
62
+ values = [r[i] for r in subset if _is_number(r[i])]
63
+ return sum(values) if values else 0
64
+
65
+ def make_breaks():
66
+ """Group boundaries: a list of (level, start, end) for every group at every level."""
67
+ spans = []
68
+ for level in range(len(group_idx)):
69
+ keys = [tuple(r[g] for g in group_idx[:level + 1]) for r in rows]
70
+ start = 0
71
+ for k in range(1, len(rows) + 1):
72
+ if k == len(rows) or keys[k] != keys[start]:
73
+ spans.append((level, start, k))
74
+ start = k
75
+ return spans
76
+
77
+ spans = make_breaks()
78
+ total_texts = {i: [text(i, total_of(rows[s:e], i)) for _, s, e in spans] + [text(i, total_of(rows, i))]
79
+ for i in total_idx}
80
+
81
+ widths = []
82
+ for n, i in enumerate(shown):
83
+ w = max([len(str(columns[i]))] + [len(c[n]) for c in cells])
84
+ if i in total_idx:
85
+ w = max([w] + [len(t) for t in total_texts[i]])
86
+ widths.append(min(max_width, w))
87
+
88
+ # Footer labels ("East (2)", "Total (4)") go in the columns before the first total;
89
+ # widen the last of those columns so the longest label fits
90
+ first_total = min((shown.index(i) for i in total_idx), default=0)
91
+ if first_total > 0:
92
+ labels = [len(' ' * level + f"{text(group_idx[level], rows[s][group_idx[level]])} ({e - s})")
93
+ for level, s, e in spans] + [len(f"Total ({len(rows)})")]
94
+ room = sum(widths[:first_total]) + first_total - 1
95
+ if max(labels) > room:
96
+ widths[first_total - 1] += max(labels) - room
97
+
98
+ def fit(n, value):
99
+ i = shown[n]
100
+ value = value[:widths[n]]
101
+ return value.rjust(widths[n]) if numeric[i] or i in total_idx else value.ljust(widths[n])
102
+
103
+ def line_of(values):
104
+ return ' '.join(fit(n, v) for n, v in enumerate(values)).rstrip()
105
+
106
+ heading = ' '.join(str(columns[i])[:widths[n]].rjust(widths[n]) if (numeric[i] or i in total_idx)
107
+ else str(columns[i])[:widths[n]].ljust(widths[n])
108
+ for n, i in enumerate(shown)).rstrip()
109
+ rule = ' '.join('-' * w for w in widths)
110
+ report_width = max(len(rule), 40)
111
+
112
+ def totals_line(label, subset, mark):
113
+ """A footer: the label in the columns before the first total, then the totals."""
114
+ values = [''] * len(shown)
115
+ for i in total_idx:
116
+ values[shown.index(i)] = text(i, total_of(subset, i))
117
+ first = min((shown.index(i) for i in total_idx), default=len(shown))
118
+ room = sum(widths[:first]) + max(0, first - 1)
119
+ under = ' '.join((mark * widths[n]) if shown[n] in total_idx else ' ' * widths[n]
120
+ for n in range(len(shown))).rstrip()
121
+ out = [under] if total_idx else []
122
+ body = line_of(values)
123
+ if room >= len(label):
124
+ out.append((label.ljust(room) + body[room:]).rstrip())
125
+ else:
126
+ out.append(label)
127
+ if total_idx:
128
+ out.append(body)
129
+ return out
130
+
131
+ # Lay out the body: rows, with group headers before and footers after each group
132
+ body = []
133
+ starts = {}
134
+ ends = {}
135
+ for level, s, e in spans:
136
+ starts.setdefault(s, []).append((level, s, e))
137
+ ends.setdefault(e, []).append((level, s, e))
138
+ for k, cell_row in enumerate(cells):
139
+ for level, s, e in sorted(starts.get(k, []), key=lambda x: x[0]):
140
+ g = group_idx[level]
141
+ body.append(' ' * level + f"{columns[g]}: {text(g, rows[s][g])}")
142
+ body.append(line_of(cell_row))
143
+ for level, s, e in sorted(ends.get(k + 1, []), key=lambda x: -x[0]):
144
+ g = group_idx[level]
145
+ label = ' ' * level + f"{text(g, rows[s][g])} ({e - s})"
146
+ body += totals_line(label, rows[s:e], '-')
147
+ body.append('')
148
+ if total_idx or group_idx:
149
+ body += totals_line(f"Total ({len(rows)})", rows, '=')
150
+ else:
151
+ body.append('')
152
+ body.append(f"({len(rows)} rows)")
153
+ if not rows:
154
+ body = ['(no rows)']
155
+
156
+ # Pages: a title line (with date and page number) and the headings at the top of each
157
+ def page_top(number):
158
+ right = f"{date} Page {number}".strip() if page_length else date
159
+ top = (title + ' ' * max(1, report_width - len(title) - len(right)) + right).rstrip() \
160
+ if (title or right) else ''
161
+ lines = [top, ''] if top else []
162
+ return lines + [heading, rule]
163
+
164
+ if not page_length:
165
+ return page_top(1) + body
166
+ lines, page = [], 1
167
+ space = max(1, page_length - len(page_top(1)))
168
+ for n in range(0, max(1, len(body)), space):
169
+ chunk = body[n:n + space]
170
+ top = page_top(page)
171
+ if page > 1:
172
+ top[0] = '\f' + top[0]
173
+ lines += top + chunk
174
+ page += 1
175
+ return lines