-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdata_io.py
More file actions
207 lines (172 loc) · 7.42 KB
/
Copy pathdata_io.py
File metadata and controls
207 lines (172 loc) · 7.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
"""CSV parsing/validation and safe file I/O for students & teachers data.
Internal on-disk format (read/written by calls2.py and mainfile.py) is
unchanged: students are `id,subject,subject,...` and teachers are
`id,(subject,size),(subject,size),...`. This module translates between
that format and plain, spreadsheet-friendly CSV, and adds validation,
atomic writes, and timestamped backups around it.
"""
import csv
import io
import os
import shutil
import time
MAX_BACKUPS = 5
# -------------------- Safe file I/O --------------------
def atomic_write(path, text):
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = f"{path}.tmp{os.getpid()}"
with open(tmp, "w", encoding="utf-8", newline="") as f:
f.write(text)
os.replace(tmp, path)
def _backups_dir(path):
return os.path.join(os.path.dirname(path), "backups")
def backup_file(path):
"""Snapshot the current file (if any) before it gets overwritten."""
if not os.path.exists(path):
return None
backups_dir = _backups_dir(path)
os.makedirs(backups_dir, exist_ok=True)
base = os.path.basename(path)
# Microsecond precision baked into the name (not just a same-second
# collision suffix) so plain lexical sort always equals chronological
# order for both pruning and listing -- second resolution alone
# collides on rapid successive calls (e.g. scripted imports).
ns = time.time_ns()
stamp = time.strftime("%Y%m%d_%H%M%S", time.localtime(ns / 1e9))
micros = (ns // 1000) % 1_000_000
dest = os.path.join(backups_dir, f"{base}.{stamp}.{micros:06d}.bak")
n = 1
while os.path.exists(dest): # vanishingly unlikely, guard anyway
n += 1
dest = os.path.join(backups_dir, f"{base}.{stamp}.{micros:06d}-{n}.bak")
shutil.copyfile(path, dest)
_prune_backups(backups_dir, base)
return dest
def _prune_backups(backups_dir, base):
matches = sorted(f for f in os.listdir(backups_dir) if f.startswith(base + "."))
excess = len(matches) - MAX_BACKUPS
for f in matches[:max(0, excess)]:
os.remove(os.path.join(backups_dir, f))
def list_backups(path):
"""Newest first: list of (backup_path, display_timestamp)."""
backups_dir = _backups_dir(path)
base = os.path.basename(path)
if not os.path.isdir(backups_dir):
return []
# Filenames carry a microsecond-precision timestamp (see backup_file),
# so lexical order is chronological order -- no need for mtime, which
# can have coarser or unreliable resolution depending on filesystem.
matches = sorted((f for f in os.listdir(backups_dir) if f.startswith(base + ".")), reverse=True)
out = []
for f in matches:
stamp = f[len(base) + 1:-len(".bak")]
out.append((os.path.join(backups_dir, f), stamp))
return out
def restore_backup(path, backup_path):
"""Restore a backup, snapshotting the current state first so this is reversible too."""
backups_dir = _backups_dir(path)
if os.path.dirname(os.path.abspath(backup_path)) != os.path.abspath(backups_dir):
raise ValueError("invalid backup path")
backup_file(path)
shutil.copyfile(backup_path, path)
# -------------------- Students CSV <-> internal format --------------------
def _rows_from_csv_text(text):
reader = csv.reader(io.StringIO(text))
return [[c.strip() for c in row] for row in reader if any(c.strip() for c in row)]
def parse_students_csv(text):
"""Returns (students, errors). students is a list of [id, subject, subject, ...]
matching the internal storage format used by write_students()."""
rows = _rows_from_csv_text(text)
if rows and rows[0][0].lower() in ("student_id", "id"):
rows = rows[1:]
errors = []
students = []
seen_ids = set()
for line_no, row in enumerate(rows, start=1):
if not row or not row[0]:
errors.append(f"Row {line_no}: missing student ID")
continue
sid = row[0]
if sid in seen_ids:
errors.append(f"Row {line_no}: duplicate student ID '{sid}'")
continue
subjects = [c for c in row[1:] if c]
if not subjects:
errors.append(f"Row {line_no}: student '{sid}' has no subjects listed")
continue
seen_ids.add(sid)
students.append([sid] + subjects)
return students, errors
def students_to_csv(students):
width = max((len(s) - 1 for s in students), default=7)
out = io.StringIO()
writer = csv.writer(out)
writer.writerow(["student_id"] + [f"subject_{i + 1}" for i in range(width)])
for s in students:
writer.writerow(s)
return out.getvalue()
# -------------------- Teachers CSV <-> internal format --------------------
def parse_teachers_csv(text):
"""Long-format CSV: teacher_id,subject,class_size (one row per subject).
Returns (teachers, errors); teachers matches the internal
[id, [[subject, size], ...]] format used by write_teachers()."""
rows = _rows_from_csv_text(text)
if rows and rows[0][0].lower() in ("teacher_id", "id"):
rows = rows[1:]
errors = []
order = []
by_id = {}
for line_no, row in enumerate(rows, start=1):
if len(row) < 3:
errors.append(f"Row {line_no}: expected teacher_id,subject,class_size, got {len(row)} column(s)")
continue
tid, subject, size = row[0], row[1], row[2]
if not tid:
errors.append(f"Row {line_no}: missing teacher ID")
continue
if not subject:
errors.append(f"Row {line_no}: missing subject for teacher '{tid}'")
continue
try:
size_int = int(size)
if size_int <= 0:
raise ValueError
except ValueError:
errors.append(f"Row {line_no}: class size '{size}' for teacher '{tid}' / {subject} "
f"must be a positive whole number")
continue
if tid not in by_id:
by_id[tid] = []
order.append(tid)
if any(s == subject for s, _ in by_id[tid]):
errors.append(f"Row {line_no}: teacher '{tid}' already has subject '{subject}' listed")
continue
by_id[tid].append([subject, str(size_int)])
teachers = [[tid, by_id[tid]] for tid in order]
return teachers, errors
def teachers_to_csv(teachers):
out = io.StringIO()
writer = csv.writer(out)
writer.writerow(["teacher_id", "subject", "class_size"])
for tid, pairs in teachers:
for sub, size in pairs:
writer.writerow([tid, sub, size])
return out.getvalue()
# -------------------- Friendly schedule export --------------------
def schedule_to_csv(output_rows, num_periods=7):
"""output_rows: list of (student_id, teacher_id, class_name, period) as
read from output.txt. Produces one row per student with a column per
period, e.g. 'Math (Teacher 3)', for easy reading in a spreadsheet."""
by_student = {}
for sid, tid, cls, period in output_rows:
by_student.setdefault(sid, {})[int(period)] = f"{cls} (Teacher {tid})"
def sort_key(sid):
digits = "".join(c for c in sid if c.isdigit())
return (int(digits) if digits else 0, sid)
out = io.StringIO()
writer = csv.writer(out)
writer.writerow(["student_id"] + [f"period_{i + 1}" for i in range(num_periods)])
for sid in sorted(by_student, key=sort_key):
periods = by_student[sid]
writer.writerow([sid] + [periods.get(i, "") for i in range(num_periods)])
return out.getvalue()