From 62fd4d76ab9f6138d14d8fda7ef0a02d7a1bca65 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Fri, 19 Apr 2024 18:09:36 +0100 Subject: [PATCH 1/6] Utilities for reading exported spreadsheets --- pyproject.toml | 1 + spond/exports.py | 138 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 spond/exports.py diff --git a/pyproject.toml b/pyproject.toml index 85b20a6..2499497 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ repository = 'https://github.com/Olen/Spond' [tool.poetry.dependencies] python = "^3.8" aiohttp = "^3.8.5" +openpyxl = "^3.1" [tool.poetry.group.dev.dependencies] black = "^23.7.0" diff --git a/spond/exports.py b/spond/exports.py new file mode 100644 index 0000000..944339b --- /dev/null +++ b/spond/exports.py @@ -0,0 +1,138 @@ +from pathlib import Path +import openpyxl as op +from typing import NamedTuple, Optional, Any, Iterable, Iterator +from collections import deque + + +class User(NamedTuple): + """A user's basic information.""" + + name: str + email: Optional[str] + phone: Optional[str] + + +def _str_or_none(v: Optional[Any]) -> Optional[str]: + if v is None or v == "": + return None + return str(v) + + +def read_poll(fpath: Path) -> Iterator[tuple[User, Optional[set[str]]]]: + """Read an exported poll result excel sheet. + + Parameters + ---------- + fpath + Path to .xlsx + + Yields + ------ + Iterator + Users and which options they voted for. + Empty set if they did not vote; + None if they voted blank. + """ + wb: op.Workbook = op.load_workbook(fpath) + sheet = wb[wb.sheetnames[0]] + block_n = 0 + rows_iter = sheet.iter_rows() + for row in rows_iter: + val = row[0].value + if _str_or_none(val) is None: + block_n += 1 + if block_n >= 3: + break + + # blank, name, email, phone + values = [h.value for h in next(rows_iter)[:-3]] + + for row in rows_iter: + this_row = [c.value for c in row] + phone = _str_or_none(this_row.pop()) + email = _str_or_none(this_row.pop()) + name = str(this_row.pop()) + user = User(name, email, phone) + + # voted blank + if this_row.pop(): + yield (user, None) + continue + + responses = set() + + for val, response in zip(values, this_row): + if response: + responses.add(val) + + yield (User(name, email, phone), responses) + + +class UserExt(NamedTuple): + """A user, extended information, and their group memberships""" + + user: User + info: dict[str, Any] + groups: set[str] + + +def _sliding_window(seq: Iterable, n: int) -> Iterator[tuple]: + it = iter(seq) + d = deque(maxlen=n) + for _, val in zip(range(n), it): + d.append(val) + + if len(d) < n: + return + + yield tuple(d) + + for val in it: + d.append(val) + yield tuple(d) + + +def read_members(fpath: Path) -> Iterator[UserExt]: + """Read an exported membership list spreadsheet. + + Parameters + ---------- + fpath : Path + Path to .xlsx file. + + Yields + ------ + Iterator + User information + + Raises + ------ + RuntimeError + Could not find expected basic information columns + """ + wb: op.Workbook = op.load_workbook(fpath) + sheet = wb[wb.sheetnames[0]] + rows_iter = sheet.iter_rows(values_only=True) + header_row = next(rows_iter) + search_titles = ("Name", "Email", "Cell") + for user_idx, titles_tup in enumerate( + _sliding_window(header_row, len(search_titles)) + ): + if titles_tup != search_titles: + continue + groups = [str(h) for h in header_row[:user_idx]] + info_keys = [str(h) for h in header_row[user_idx + 3 :]] + break + else: + raise RuntimeError("Name, Email, Cell columns not found") + + for row in rows_iter: + cell_iter = iter(row) + grps = {name for name, cell in zip(groups, cell_iter) if _str_or_none(cell)} + u = User( + str(next(cell_iter)), + _str_or_none(next(cell_iter)), + _str_or_none(next(cell_iter)), + ) + info = dict(zip(info_keys, cell_iter)) + yield UserExt(u, info, grps) From 18d13062bd699e057be89bcd07f22c92c1037934 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Fri, 19 Apr 2024 18:14:16 +0100 Subject: [PATCH 2/6] Add module docstring --- spond/exports.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spond/exports.py b/spond/exports.py index 944339b..068d523 100644 --- a/spond/exports.py +++ b/spond/exports.py @@ -1,3 +1,5 @@ +"""Utilities for reading downloaded spreadsheet exports.""" + from pathlib import Path import openpyxl as op from typing import NamedTuple, Optional, Any, Iterable, Iterator From f2876ccf06d2b25b291d1a43d8f24e4f33707446 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Sat, 20 Apr 2024 10:48:05 +0100 Subject: [PATCH 3/6] Reverse set()/None for poll non-responders --- spond/exports.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spond/exports.py b/spond/exports.py index 068d523..08d37b7 100644 --- a/spond/exports.py +++ b/spond/exports.py @@ -32,8 +32,8 @@ def read_poll(fpath: Path) -> Iterator[tuple[User, Optional[set[str]]]]: ------ Iterator Users and which options they voted for. - Empty set if they did not vote; - None if they voted blank. + Empty set if they voted blank; + None if they did not vote. """ wb: op.Workbook = op.load_workbook(fpath) sheet = wb[wb.sheetnames[0]] @@ -56,18 +56,18 @@ def read_poll(fpath: Path) -> Iterator[tuple[User, Optional[set[str]]]]: name = str(this_row.pop()) user = User(name, email, phone) + responses = set() + # voted blank if this_row.pop(): - yield (user, None) + yield (user, responses) continue - responses = set() - for val, response in zip(values, this_row): if response: responses.add(val) - yield (User(name, email, phone), responses) + yield (User(name, email, phone), responses or None) class UserExt(NamedTuple): From be963df709b631781428be36bc6543e959b14b86 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Sat, 20 Apr 2024 12:07:20 +0100 Subject: [PATCH 4/6] minor: update comment --- spond/exports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spond/exports.py b/spond/exports.py index 08d37b7..91b9a14 100644 --- a/spond/exports.py +++ b/spond/exports.py @@ -46,7 +46,7 @@ def read_poll(fpath: Path) -> Iterator[tuple[User, Optional[set[str]]]]: if block_n >= 3: break - # blank, name, email, phone + # name, email, phone values = [h.value for h in next(rows_iter)[:-3]] for row in rows_iter: From 6432c94ca86ebb2e98492ef89584a109b0944caa Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Sat, 20 Apr 2024 12:09:46 +0100 Subject: [PATCH 5/6] minor: more terse sliding window impl --- spond/exports.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/spond/exports.py b/spond/exports.py index 91b9a14..6a225a8 100644 --- a/spond/exports.py +++ b/spond/exports.py @@ -79,19 +79,11 @@ class UserExt(NamedTuple): def _sliding_window(seq: Iterable, n: int) -> Iterator[tuple]: - it = iter(seq) d = deque(maxlen=n) - for _, val in zip(range(n), it): + for val in seq: d.append(val) - - if len(d) < n: - return - - yield tuple(d) - - for val in it: - d.append(val) - yield tuple(d) + if len(d) >= n: + yield tuple(d) def read_members(fpath: Path) -> Iterator[UserExt]: From d9706b17a35c253e9d8d07502859de34429eefd9 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Sat, 20 Apr 2024 12:12:05 +0100 Subject: [PATCH 6/6] minor: slightly better docstrings --- spond/exports.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spond/exports.py b/spond/exports.py index 6a225a8..4d1665d 100644 --- a/spond/exports.py +++ b/spond/exports.py @@ -30,7 +30,7 @@ def read_poll(fpath: Path) -> Iterator[tuple[User, Optional[set[str]]]]: Yields ------ - Iterator + tuple[User, set[str] | None] Users and which options they voted for. Empty set if they voted blank; None if they did not vote. @@ -91,13 +91,13 @@ def read_members(fpath: Path) -> Iterator[UserExt]: Parameters ---------- - fpath : Path + fpath Path to .xlsx file. Yields ------ - Iterator - User information + UserExt + Extended user information Raises ------