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.
- ddgbasic-1.0.0.dist-info/METADATA +473 -0
- ddgbasic-1.0.0.dist-info/RECORD +11 -0
- ddgbasic-1.0.0.dist-info/WHEEL +5 -0
- ddgbasic-1.0.0.dist-info/entry_points.txt +2 -0
- ddgbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
- ddgbasic-1.0.0.dist-info/top_level.txt +5 -0
- ddgbasic.py +14883 -0
- ddgformgen.py +295 -0
- ddgide.py +1277 -0
- ddgreport.py +175 -0
- ddgscreen.py +2205 -0
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
|