easyclassifier 0.8.1__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.
- easyclassifier/__init__.py +23 -0
- easyclassifier/__main__.py +61 -0
- easyclassifier/dataset.py +142 -0
- easyclassifier/demo_data.py +32 -0
- easyclassifier/diagnostics.py +95 -0
- easyclassifier/distances.py +152 -0
- easyclassifier/evaluation.py +340 -0
- easyclassifier/figures.py +510 -0
- easyclassifier/help_texts.py +113 -0
- easyclassifier/importance.py +79 -0
- easyclassifier/latex_report.py +555 -0
- easyclassifier/logbook.py +31 -0
- easyclassifier/models.py +94 -0
- easyclassifier/preprocessing.py +219 -0
- easyclassifier/recommend.py +128 -0
- easyclassifier/reporting.py +70 -0
- easyclassifier/target.py +202 -0
- easyclassifier/ui.py +281 -0
- easyclassifier/wizard.py +1266 -0
- easyclassifier-0.8.1.dist-info/METADATA +267 -0
- easyclassifier-0.8.1.dist-info/RECORD +25 -0
- easyclassifier-0.8.1.dist-info/WHEEL +5 -0
- easyclassifier-0.8.1.dist-info/entry_points.txt +2 -0
- easyclassifier-0.8.1.dist-info/licenses/LICENSE +21 -0
- easyclassifier-0.8.1.dist-info/top_level.txt +1 -0
easyclassifier/target.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Choosing and checking the column to predict (the target / class column).
|
|
2
|
+
|
|
3
|
+
Everything here is plain logic with no user interaction, so it can be tested
|
|
4
|
+
directly. The wizard uses it to
|
|
5
|
+
|
|
6
|
+
* describe every column in plain words so the user can pick the right one,
|
|
7
|
+
* suggest the most likely class column,
|
|
8
|
+
* catch unsuitable choices (a single value, an ID column, a measurement
|
|
9
|
+
instead of a category, classes with too few rows), and
|
|
10
|
+
* turn a measurement into categories when the user wants that.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
# Kinds of column
|
|
22
|
+
CATEGORY = "category" # a sensible class column
|
|
23
|
+
MEASUREMENT = "measurement" # numbers with many different values
|
|
24
|
+
ID_LIKE = "id" # different in every row (an identifier)
|
|
25
|
+
CONSTANT = "constant" # a single value, nothing to predict
|
|
26
|
+
MANY_TEXT = "many_text" # text with very many different values
|
|
27
|
+
EMPTY = "empty" # no values at all
|
|
28
|
+
|
|
29
|
+
COMMON_TARGET_NAMES = [
|
|
30
|
+
"target", "label", "class", "outcome", "result", "disease",
|
|
31
|
+
"diagnosis", "purchased", "churn", "y", "category", "species",
|
|
32
|
+
"status", "response", "grade",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
MIN_ROWS_PER_CLASS = 2 # fewer than this cannot be tested at all
|
|
36
|
+
FEW_ROWS_PER_CLASS = 5 # fewer than this gives unreliable results
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _max_categories(n_rows: int) -> int:
|
|
40
|
+
"""How many different values a class column may reasonably have."""
|
|
41
|
+
return max(20, int(0.05 * n_rows))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_integer_valued(s: pd.Series) -> bool:
|
|
45
|
+
v = s.dropna().to_numpy(dtype=float)
|
|
46
|
+
return bool(len(v)) and bool(np.all(np.equal(np.mod(v, 1), 0)))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fmt(v) -> str:
|
|
50
|
+
if isinstance(v, (float, np.floating)) and float(v).is_integer():
|
|
51
|
+
return str(int(v))
|
|
52
|
+
if isinstance(v, (float, np.floating)):
|
|
53
|
+
return f"{v:.4g}"
|
|
54
|
+
return str(v)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ColumnInfo:
|
|
59
|
+
name: str
|
|
60
|
+
kind: str
|
|
61
|
+
n_unique: int
|
|
62
|
+
n_values: int
|
|
63
|
+
label: str # one-line plain-language description
|
|
64
|
+
counts: Dict[str, int] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def describe_column(s: pd.Series) -> ColumnInfo:
|
|
68
|
+
"""Classify a column and describe it in plain words."""
|
|
69
|
+
name = str(s.name)
|
|
70
|
+
vals = s.dropna()
|
|
71
|
+
n, u = len(vals), int(vals.nunique())
|
|
72
|
+
numeric = (pd.api.types.is_numeric_dtype(s)
|
|
73
|
+
and not pd.api.types.is_bool_dtype(s))
|
|
74
|
+
|
|
75
|
+
if n == 0:
|
|
76
|
+
return ColumnInfo(name, EMPTY, 0, 0, f"{name} - empty column")
|
|
77
|
+
if u == 1:
|
|
78
|
+
return ColumnInfo(name, CONSTANT, 1, n,
|
|
79
|
+
f"{name} - only one value ({_fmt(vals.iloc[0])})")
|
|
80
|
+
|
|
81
|
+
looks_like_id = "id" in name.lower().replace("_", " ").split() or \
|
|
82
|
+
name.lower().endswith("id")
|
|
83
|
+
if u == n and n >= 20:
|
|
84
|
+
consecutive = (numeric and _is_integer_valued(vals)
|
|
85
|
+
and vals.max() - vals.min() + 1 == n)
|
|
86
|
+
if not numeric or consecutive or looks_like_id:
|
|
87
|
+
return ColumnInfo(name, ID_LIKE, u, n,
|
|
88
|
+
f"{name} - different in every row "
|
|
89
|
+
"(looks like an ID)")
|
|
90
|
+
|
|
91
|
+
if numeric and u > 2:
|
|
92
|
+
integer = _is_integer_valued(vals)
|
|
93
|
+
mostly_distinct = u > 10 and u > n / 2
|
|
94
|
+
if (not integer and u > 10) or mostly_distinct \
|
|
95
|
+
or u > _max_categories(n):
|
|
96
|
+
return ColumnInfo(
|
|
97
|
+
name, MEASUREMENT, u, n,
|
|
98
|
+
f"{name} - numbers from {_fmt(vals.min())} to "
|
|
99
|
+
f"{_fmt(vals.max())} ({u} different)")
|
|
100
|
+
|
|
101
|
+
if not numeric and u > _max_categories(n):
|
|
102
|
+
return ColumnInfo(name, MANY_TEXT, u, n,
|
|
103
|
+
f"{name} - {u} different text values")
|
|
104
|
+
|
|
105
|
+
counts = vals.value_counts()
|
|
106
|
+
shown = ", ".join(_fmt(v) for v in counts.index[:3])
|
|
107
|
+
more = ", ..." if u > 3 else ""
|
|
108
|
+
return ColumnInfo(name, CATEGORY, u, n,
|
|
109
|
+
f"{name} - {u} categories ({shown}{more})",
|
|
110
|
+
counts={_fmt(k): int(v) for k, v in counts.items()})
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def suggest_target(df: pd.DataFrame) -> Optional[str]:
|
|
114
|
+
"""Most likely class column, or None if nothing looks suitable."""
|
|
115
|
+
infos = {c: describe_column(df[c]) for c in df.columns}
|
|
116
|
+
lower = {str(c).lower(): c for c in df.columns}
|
|
117
|
+
for name in COMMON_TARGET_NAMES:
|
|
118
|
+
c = lower.get(name)
|
|
119
|
+
if c is not None and infos[c].kind == CATEGORY:
|
|
120
|
+
return c
|
|
121
|
+
# Otherwise the last category-like column (class columns are usually
|
|
122
|
+
# last), preferring few categories.
|
|
123
|
+
cands = [c for c in df.columns if infos[c].kind == CATEGORY]
|
|
124
|
+
if not cands:
|
|
125
|
+
return None
|
|
126
|
+
return min(reversed(cands), key=lambda c: infos[c].n_unique)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --------------------------------------------------------------------------- #
|
|
130
|
+
# Class-size checks
|
|
131
|
+
# --------------------------------------------------------------------------- #
|
|
132
|
+
|
|
133
|
+
def tiny_and_small_classes(s: pd.Series) -> Tuple[List[str], List[str]]:
|
|
134
|
+
"""Classes with fewer than 2 rows (unusable) and fewer than 5 (risky)."""
|
|
135
|
+
counts = s.dropna().astype(str).value_counts()
|
|
136
|
+
tiny = [k for k, v in counts.items() if v < MIN_ROWS_PER_CLASS]
|
|
137
|
+
small = [k for k, v in counts.items()
|
|
138
|
+
if MIN_ROWS_PER_CLASS <= v < FEW_ROWS_PER_CLASS]
|
|
139
|
+
return tiny, small
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --------------------------------------------------------------------------- #
|
|
143
|
+
# Turning a measurement into categories
|
|
144
|
+
# --------------------------------------------------------------------------- #
|
|
145
|
+
|
|
146
|
+
GROUP_NAMES = {
|
|
147
|
+
2: ["Low", "High"],
|
|
148
|
+
3: ["Low", "Medium", "High"],
|
|
149
|
+
4: ["Very low", "Low", "High", "Very high"],
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def group_equal(s: pd.Series, k: int) -> Tuple[pd.Series, List[float], str]:
|
|
154
|
+
"""Split numbers into ``k`` groups of (roughly) equal size.
|
|
155
|
+
|
|
156
|
+
Returns the new labels, the cut-points, and a description. If many rows
|
|
157
|
+
share the same value, fewer groups may result.
|
|
158
|
+
"""
|
|
159
|
+
vals = pd.to_numeric(s, errors="coerce")
|
|
160
|
+
uniq = np.sort(vals.dropna().unique())
|
|
161
|
+
cuts = []
|
|
162
|
+
for q in vals.quantile([i / k for i in range(1, k)]).tolist():
|
|
163
|
+
# A value >= cut goes to the higher group. A cut at (or below) the
|
|
164
|
+
# smallest value would leave the lowest group empty, so move it to
|
|
165
|
+
# the next value up - e.g. many zeros give "0" vs "above 0".
|
|
166
|
+
above = uniq[uniq > q] if q <= uniq[0] else uniq[uniq >= q]
|
|
167
|
+
if len(above):
|
|
168
|
+
cuts.append(float(above[0]))
|
|
169
|
+
cuts = sorted(set(cuts))
|
|
170
|
+
if not cuts:
|
|
171
|
+
raise ValueError("all values are the same; cannot form groups")
|
|
172
|
+
return _cut(vals, cuts)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def group_at(s: pd.Series, threshold: float) -> Tuple[pd.Series, List[float], str]:
|
|
176
|
+
"""Two groups: below the threshold, and at or above it."""
|
|
177
|
+
vals = pd.to_numeric(s, errors="coerce")
|
|
178
|
+
return _cut(vals, [float(threshold)])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _cut(vals: pd.Series, cuts: List[float]) -> Tuple[pd.Series, List[float], str]:
|
|
182
|
+
cuts = sorted(set(float(c) for c in cuts))
|
|
183
|
+
k = len(cuts) + 1
|
|
184
|
+
names = GROUP_NAMES.get(k, [f"Group {i + 1}" for i in range(k)])
|
|
185
|
+
labels = []
|
|
186
|
+
for i in range(k):
|
|
187
|
+
if i == 0:
|
|
188
|
+
rng = f"< {_fmt(cuts[0])}"
|
|
189
|
+
elif i == k - 1:
|
|
190
|
+
rng = f">= {_fmt(cuts[-1])}"
|
|
191
|
+
else:
|
|
192
|
+
rng = f"{_fmt(cuts[i - 1])} to < {_fmt(cuts[i])}"
|
|
193
|
+
labels.append(f"{names[i]} ({rng})")
|
|
194
|
+
codes = np.searchsorted(np.asarray(cuts), vals.to_numpy(dtype=float),
|
|
195
|
+
side="right")
|
|
196
|
+
# An ordered category keeps Low < Medium < High in tables and figures.
|
|
197
|
+
out = pd.Series(pd.Categorical(
|
|
198
|
+
[labels[c] if not np.isnan(v) else np.nan
|
|
199
|
+
for c, v in zip(codes, vals.to_numpy(dtype=float))],
|
|
200
|
+
categories=labels, ordered=True), index=vals.index, name=vals.name)
|
|
201
|
+
desc = "; ".join(labels)
|
|
202
|
+
return out, cuts, desc
|
easyclassifier/ui.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Terminal user-interface helpers for EasyClassifier.
|
|
2
|
+
|
|
3
|
+
All user interaction goes through these functions so the rest of the code
|
|
4
|
+
never touches ``input``/``print`` directly. This keeps the wizard logic clean
|
|
5
|
+
and makes the interface easy to swap out later (e.g. for a GUI).
|
|
6
|
+
|
|
7
|
+
The helpers are intentionally plain-text and dependency-free so they work in
|
|
8
|
+
any terminal.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import textwrap
|
|
14
|
+
from typing import List, Optional, Sequence, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# --------------------------------------------------------------------------- #
|
|
18
|
+
# Basic output
|
|
19
|
+
# --------------------------------------------------------------------------- #
|
|
20
|
+
|
|
21
|
+
WIDTH = 66
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _line(char: str = "-") -> str:
|
|
25
|
+
return char * WIDTH
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def rule(char: str = "-") -> None:
|
|
29
|
+
print(_line(char))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def banner(title: str, subtitle: str = "") -> None:
|
|
33
|
+
"""Print a boxed banner."""
|
|
34
|
+
print()
|
|
35
|
+
rule("=")
|
|
36
|
+
print(title.center(WIDTH))
|
|
37
|
+
if subtitle:
|
|
38
|
+
print(subtitle.center(WIDTH))
|
|
39
|
+
rule("=")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def header(text: str) -> None:
|
|
43
|
+
"""Section header."""
|
|
44
|
+
print()
|
|
45
|
+
rule()
|
|
46
|
+
print(text)
|
|
47
|
+
rule()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def info(text: str) -> None:
|
|
51
|
+
for line in textwrap.wrap(text, WIDTH) or [""]:
|
|
52
|
+
print(line)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def note(text: str) -> None:
|
|
56
|
+
print(f" * {text}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def success(text: str) -> None:
|
|
60
|
+
print(f"[OK] {text}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def warn(text: str) -> None:
|
|
64
|
+
print(f"[!] {text}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def error(text: str) -> None:
|
|
68
|
+
print(f"[X] {text}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def blank() -> None:
|
|
72
|
+
print()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def pause(msg: str = "Press ENTER to continue...") -> None:
|
|
76
|
+
try:
|
|
77
|
+
input("\n" + msg)
|
|
78
|
+
except EOFError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --------------------------------------------------------------------------- #
|
|
83
|
+
# Input primitives
|
|
84
|
+
# --------------------------------------------------------------------------- #
|
|
85
|
+
|
|
86
|
+
def _read(prompt: str) -> str:
|
|
87
|
+
try:
|
|
88
|
+
return input(prompt)
|
|
89
|
+
except EOFError:
|
|
90
|
+
# Non-interactive / piped input exhausted.
|
|
91
|
+
print()
|
|
92
|
+
raise KeyboardInterrupt
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def ask_text(prompt: str, default: Optional[str] = None,
|
|
96
|
+
allow_empty: bool = False) -> str:
|
|
97
|
+
"""Free-text input with an optional default."""
|
|
98
|
+
suffix = f" [{default}]" if default is not None else ""
|
|
99
|
+
while True:
|
|
100
|
+
value = _read(f"{prompt}{suffix}\n>> ").strip()
|
|
101
|
+
if not value:
|
|
102
|
+
if default is not None:
|
|
103
|
+
return default
|
|
104
|
+
if allow_empty:
|
|
105
|
+
return ""
|
|
106
|
+
error("Please enter a value.")
|
|
107
|
+
continue
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ask_yes_no(prompt: str, default: Optional[bool] = None) -> bool:
|
|
112
|
+
"""Yes/No question. Returns True for yes."""
|
|
113
|
+
if default is True:
|
|
114
|
+
hint = " [Y/n]"
|
|
115
|
+
elif default is False:
|
|
116
|
+
hint = " [y/N]"
|
|
117
|
+
else:
|
|
118
|
+
hint = " [y/n]"
|
|
119
|
+
while True:
|
|
120
|
+
value = _read(f"{prompt}{hint}\n>> ").strip().lower()
|
|
121
|
+
if not value and default is not None:
|
|
122
|
+
return default
|
|
123
|
+
if value in ("y", "yes", "1"):
|
|
124
|
+
return True
|
|
125
|
+
if value in ("n", "no", "2"):
|
|
126
|
+
return False
|
|
127
|
+
error("Please answer y or n.")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def menu(prompt: str, options: Sequence[str],
|
|
131
|
+
default: Optional[int] = None,
|
|
132
|
+
allow_help: bool = True,
|
|
133
|
+
help_keys: Optional[Sequence[str]] = None) -> int:
|
|
134
|
+
"""Numbered single-choice menu.
|
|
135
|
+
|
|
136
|
+
Returns the zero-based index of the chosen option.
|
|
137
|
+
Options are displayed starting at 1. If ``help_keys`` is provided the user
|
|
138
|
+
may type ``?N`` to see help for option N (handled by the caller via the
|
|
139
|
+
returned sentinel).
|
|
140
|
+
"""
|
|
141
|
+
print()
|
|
142
|
+
print(prompt)
|
|
143
|
+
for i, opt in enumerate(options, start=1):
|
|
144
|
+
print(f" {i}. {opt}")
|
|
145
|
+
hint = ""
|
|
146
|
+
if default is not None:
|
|
147
|
+
hint = f" (default {default + 1})"
|
|
148
|
+
if allow_help:
|
|
149
|
+
hint += " | type ?N for help on option N"
|
|
150
|
+
while True:
|
|
151
|
+
raw = _read(f"Choose 1-{len(options)}{hint}\n>> ").strip()
|
|
152
|
+
if not raw and default is not None:
|
|
153
|
+
return default
|
|
154
|
+
if allow_help and raw.startswith("?"):
|
|
155
|
+
num = raw[1:].strip()
|
|
156
|
+
if num.isdigit() and 1 <= int(num) <= len(options):
|
|
157
|
+
return -int(num) # sentinel: caller shows help
|
|
158
|
+
error("Type ? followed by a valid option number, e.g. ?2")
|
|
159
|
+
continue
|
|
160
|
+
if raw.isdigit() and 1 <= int(raw) <= len(options):
|
|
161
|
+
return int(raw) - 1
|
|
162
|
+
error(f"Please enter a number between 1 and {len(options)}.")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def multi_select(prompt: str, options: Sequence[str],
|
|
166
|
+
default_all: bool = True,
|
|
167
|
+
default: Optional[List[int]] = None) -> List[int]:
|
|
168
|
+
"""Multiple-choice checkbox menu.
|
|
169
|
+
|
|
170
|
+
User enters comma-separated numbers, 'all', or 'none'. ENTER gives
|
|
171
|
+
``default`` if provided, otherwise all (``default_all``) or none.
|
|
172
|
+
Returns a list of zero-based indices.
|
|
173
|
+
"""
|
|
174
|
+
if default is None:
|
|
175
|
+
default = list(range(len(options))) if default_all else []
|
|
176
|
+
print()
|
|
177
|
+
print(prompt)
|
|
178
|
+
for i, opt in enumerate(options, start=1):
|
|
179
|
+
mark = "x" if i - 1 in default else " "
|
|
180
|
+
print(f" [{mark}] {i}. {opt}")
|
|
181
|
+
if len(default) == len(options):
|
|
182
|
+
default_hint = "ENTER = all"
|
|
183
|
+
elif not default:
|
|
184
|
+
default_hint = "ENTER = none"
|
|
185
|
+
else:
|
|
186
|
+
default_hint = "ENTER = " + ",".join(str(i + 1) for i in default)
|
|
187
|
+
while True:
|
|
188
|
+
raw = _read(
|
|
189
|
+
f"Enter numbers separated by commas, or 'all' / 'none' "
|
|
190
|
+
f"[{default_hint}]\n>> "
|
|
191
|
+
).strip().lower()
|
|
192
|
+
if not raw:
|
|
193
|
+
return list(default)
|
|
194
|
+
if raw == "all":
|
|
195
|
+
return list(range(len(options)))
|
|
196
|
+
if raw == "none":
|
|
197
|
+
return []
|
|
198
|
+
try:
|
|
199
|
+
picks = sorted({
|
|
200
|
+
int(p) - 1 for p in raw.replace(" ", "").split(",") if p != ""
|
|
201
|
+
})
|
|
202
|
+
except ValueError:
|
|
203
|
+
error("Please enter numbers separated by commas.")
|
|
204
|
+
continue
|
|
205
|
+
if all(0 <= p < len(options) for p in picks) and picks:
|
|
206
|
+
return picks
|
|
207
|
+
error(f"Please enter numbers between 1 and {len(options)}.")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def choose_from_list(prompt: str, items: Sequence[str],
|
|
211
|
+
extra_options: Sequence[str] = ()) -> Tuple[int, bool]:
|
|
212
|
+
"""Present a list of items plus optional extra menu entries.
|
|
213
|
+
|
|
214
|
+
Returns ``(index, is_extra)``. If ``is_extra`` is True the index refers to
|
|
215
|
+
``extra_options``; otherwise it refers to ``items``.
|
|
216
|
+
"""
|
|
217
|
+
combined = list(items) + list(extra_options)
|
|
218
|
+
idx = menu(prompt, combined, allow_help=False)
|
|
219
|
+
if idx >= len(items):
|
|
220
|
+
return idx - len(items), True
|
|
221
|
+
return idx, False
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def pick_from_long_list(prompt: str, labels: Sequence[str],
|
|
225
|
+
names: Sequence[str],
|
|
226
|
+
default: Optional[int] = None,
|
|
227
|
+
marks: Optional[dict] = None,
|
|
228
|
+
page_size: int = 20) -> int:
|
|
229
|
+
"""Choose one item from a possibly very long list.
|
|
230
|
+
|
|
231
|
+
Numbers stay the same while paging or searching, so the user can always
|
|
232
|
+
type the number they saw. Typing text shows only the items whose name
|
|
233
|
+
contains that text. Returns the zero-based index into ``labels``.
|
|
234
|
+
"""
|
|
235
|
+
marks = marks or {}
|
|
236
|
+
shown = list(range(len(labels)))
|
|
237
|
+
page = 0
|
|
238
|
+
while True:
|
|
239
|
+
pages = max(1, (len(shown) + page_size - 1) // page_size)
|
|
240
|
+
page = min(page, pages - 1)
|
|
241
|
+
print()
|
|
242
|
+
print(prompt)
|
|
243
|
+
visible = shown[page * page_size:(page + 1) * page_size]
|
|
244
|
+
for i in visible:
|
|
245
|
+
print(f" {i + 1:>3}. {labels[i]}{marks.get(i, '')}")
|
|
246
|
+
if default is not None and default not in visible:
|
|
247
|
+
print(f" Suggested: {default + 1}. {labels[default]}")
|
|
248
|
+
hints = []
|
|
249
|
+
if default is not None:
|
|
250
|
+
hints.append(f"ENTER = {default + 1}, the suggested one")
|
|
251
|
+
if pages > 1:
|
|
252
|
+
hints.append(f"page {page + 1} of {pages}: 'n' next, 'p' back")
|
|
253
|
+
if len(labels) > page_size or len(shown) < len(labels):
|
|
254
|
+
hints.append("type part of a name to search"
|
|
255
|
+
+ (", 'all' to list everything"
|
|
256
|
+
if len(shown) < len(labels) else ""))
|
|
257
|
+
if hints:
|
|
258
|
+
print(" (" + "; ".join(hints) + ")")
|
|
259
|
+
raw = _read("Choose a number\n>> ").strip()
|
|
260
|
+
low = raw.lower()
|
|
261
|
+
if not raw:
|
|
262
|
+
if default is not None:
|
|
263
|
+
return default
|
|
264
|
+
error("Please type a number.")
|
|
265
|
+
elif raw.isdigit():
|
|
266
|
+
n = int(raw)
|
|
267
|
+
if 1 <= n <= len(labels):
|
|
268
|
+
return n - 1
|
|
269
|
+
error(f"Please enter a number between 1 and {len(labels)}.")
|
|
270
|
+
elif low in ("n", "next"):
|
|
271
|
+
page = page + 1 if page + 1 < pages else page
|
|
272
|
+
elif low in ("p", "back", "prev"):
|
|
273
|
+
page = max(0, page - 1)
|
|
274
|
+
elif low == "all":
|
|
275
|
+
shown, page = list(range(len(labels))), 0
|
|
276
|
+
else:
|
|
277
|
+
found = [i for i, nm in enumerate(names) if low in str(nm).lower()]
|
|
278
|
+
if found:
|
|
279
|
+
shown, page = found, 0
|
|
280
|
+
else:
|
|
281
|
+
error(f"No column name contains '{raw}'.")
|