diff --git a/repartir_taches/__init__.py b/repartir_taches/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repartir_taches.py b/repartir_taches/config.py similarity index 52% rename from repartir_taches.py rename to repartir_taches/config.py index ca0f348..bcb5f57 100644 --- a/repartir_taches.py +++ b/repartir_taches/config.py @@ -1,10 +1,9 @@ -import yaml -import argparse -import csv -from collections import defaultdict -import typing as t from dataclasses import dataclass -import random +from collections import defaultdict +import csv +import typing as t +from ruamel import yaml +from .util import levenshtein_distance, UnionFind @dataclass @@ -27,55 +26,6 @@ class Category(t.NamedTuple): intro: str -def levenshtein_distance(s1, s2): - """Shamelessly stolen from https://stackoverflow.com/a/32558749""" - if len(s1) > len(s2): - s1, s2 = s2, s1 - - distances = range(len(s1) + 1) - for i2, c2 in enumerate(s2): - distances_ = [i2 + 1] - for i1, c1 in enumerate(s1): - if c1 == c2: - distances_.append(distances[i1]) - else: - distances_.append( - 1 + min((distances[i1], distances[i1 + 1], distances_[-1])) - ) - distances = distances_ - return distances[-1] - - -class UnionFind: - parent_of: list[int] - _group_size: list[int] - - def __init__(self, elt_count: int): - self.parent_of = list(range(elt_count)) - self._group_size = [1] * elt_count - - def root(self, elt: int) -> int: - if self.parent_of[elt] == elt: - return elt - self.parent_of[elt] = self.root(self.parent_of[elt]) - return self.parent_of[elt] - - def union(self, elt1: int, elt2: int) -> None: - elt1 = self.root(elt1) - elt2 = self.root(elt2) - if elt1 == elt2: - return - if self._group_size[elt1] > self._group_size[elt2]: - self.union(elt2, elt1) - else: - self._group_size[elt2] += self._group_size[elt1] - self._group_size[elt1] = 0 - self.parent_of[self.root(elt1)] = self.root(elt2) - - def group_size(self, elt: int) -> int: - return self._group_size[self.root(elt)] - - class Config: tasks_path: str people_path: str @@ -199,89 +149,3 @@ class Config: req_letters += 1 self.choristes.sort() - - -def constituer_groupes(choristes: list[str]) -> list[list[str]]: - """Répartir aléatoirement les gens en groupes""" - TAILLE_GROUPE: int = 4 - nb_choristes = len(choristes) - - groupes: list[list[str]] = [] - - random.shuffle(choristes) - pos = 0 - for _ in range(nb_choristes // TAILLE_GROUPE): - groupes.append(choristes[pos : pos + TAILLE_GROUPE]) - pos += TAILLE_GROUPE - - reste = choristes[pos:] - - if len(reste) == TAILLE_GROUPE - 1: - groupes.append(reste) - else: - for gid, pers in enumerate(reste): - groupes[gid].append(pers) - - for groupe in groupes: - groupe.sort() - random.shuffle(groupes) - - return groupes - - -def assigner_taches(task: Category | Task, group_count: int, cur_group: int = 0) -> int: - """Assigne les tâches aux groupes (round-robin)""" - if isinstance(task, Task): - task.assigned = list( - map( - lambda x: x % group_count, - range(cur_group, cur_group + task.nb_groups), - ) - ) - return (cur_group + task.nb_groups) % group_count - for subtask in task.tasks: - cur_group = assigner_taches(subtask, group_count, cur_group) - return cur_group - - -def export_short(config: Config, groupes: list[list[str]]) -> str: - """Exporte la liste des tâches au format court (pour vérification)""" - - def export_taskcat(grp: Task | Category) -> str: - if isinstance(grp, Task): - return f'* {grp.qualified_name}: {", ".join(map(lambda x: str(x+1), grp.assigned))}' - out = "\n" + "#" * (2 + grp.depth) + f" {grp.name}" - if grp.time: - out += f" ({grp.time})" - out += "\n\n" - if grp.intro: - out += grp.intro + "\n\n" - out += "\n".join(map(export_taskcat, grp.tasks)) - return out - - out = "## Groupes\n\n" - for g_id, group in enumerate(groupes): - out += f"* Groupe {g_id+1} : " + ", ".join(group) + "\n" - - out += "\n## Tâches\n" - - out += "\n".join(map(export_taskcat, config.taches.tasks)) - - return out - - -def export_latex(config: Config, groupes: list[list[str]]) -> str: - """Exporter la liste des tâches en LaTeX (à insérer dans un template)""" - - -def main(): - parser = argparse.ArgumentParser("Répartition des tâches") - parser.add_argument("taches", help="Fichier yaml contenant les tâches") - parser.add_argument("choristes", help="Fichier CSV contenant les choristes") - args = parser.parse_args() - - config = Config(args.taches, args.choristes) - - -if __name__ == "__main__": - main() diff --git a/repartir_taches/entrypoint.py b/repartir_taches/entrypoint.py new file mode 100644 index 0000000..5958c84 --- /dev/null +++ b/repartir_taches/entrypoint.py @@ -0,0 +1,88 @@ +import argparse +import typing as t +import random + +from .config import Task, Category, Config + + +def constituer_groupes(choristes: list[str]) -> list[list[str]]: + """Répartir aléatoirement les gens en groupes""" + TAILLE_GROUPE: int = 4 + nb_choristes = len(choristes) + + groupes: list[list[str]] = [] + + random.shuffle(choristes) + pos = 0 + for _ in range(nb_choristes // TAILLE_GROUPE): + groupes.append(choristes[pos : pos + TAILLE_GROUPE]) + pos += TAILLE_GROUPE + + reste = choristes[pos:] + + if len(reste) == TAILLE_GROUPE - 1: + groupes.append(reste) + else: + for gid, pers in enumerate(reste): + groupes[gid].append(pers) + + for groupe in groupes: + groupe.sort() + random.shuffle(groupes) + + return groupes + + +def assigner_taches(task: Category | Task, group_count: int, cur_group: int = 0) -> int: + """Assigne les tâches aux groupes (round-robin)""" + if isinstance(task, Task): + task.assigned = list( + map( + lambda x: x % group_count, + range(cur_group, cur_group + task.nb_groups), + ) + ) + return (cur_group + task.nb_groups) % group_count + for subtask in task.tasks: + cur_group = assigner_taches(subtask, group_count, cur_group) + return cur_group + + +def export_short(config: Config, groupes: list[list[str]]) -> str: + """Exporte la liste des tâches au format court (pour vérification)""" + + def export_taskcat(grp: Task | Category) -> str: + if isinstance(grp, Task): + assert grp.assigned is not None + return f'* {grp.qualified_name}: {", ".join(map(lambda x: str(x+1), grp.assigned))}' + out = "\n" + "#" * (2 + grp.depth) + f" {grp.name}" + if grp.time: + out += f" ({grp.time})" + out += "\n\n" + if grp.intro: + out += grp.intro + "\n\n" + out += "\n".join(map(export_taskcat, grp.tasks)) + return out + + out = "## Groupes\n\n" + for g_id, group in enumerate(groupes): + out += f"* Groupe {g_id+1} : " + ", ".join(group) + "\n" + + out += "\n## Tâches\n" + + out += "\n".join(map(export_taskcat, config.taches.tasks)) + + return out + + +def export_latex(config: Config, groupes: list[list[str]]) -> str: + """Exporter la liste des tâches en LaTeX (à insérer dans un template)""" + + +def main(): + parser = argparse.ArgumentParser("Répartition des tâches") + parser.add_argument("taches", help="Fichier yaml contenant les tâches") + parser.add_argument("choristes", help="Fichier CSV contenant les choristes") + args = parser.parse_args() + + config = Config(args.taches, args.choristes) diff --git a/repartir_taches/py.typed b/repartir_taches/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/repartir_taches/util.py b/repartir_taches/util.py new file mode 100644 index 0000000..cb8d996 --- /dev/null +++ b/repartir_taches/util.py @@ -0,0 +1,59 @@ +""" Utility functions and classes """ + + +def levenshtein_distance(s1, s2): + """Compute the Levenshtein distance (edit distance) between two strings + + Shamelessly stolen from https://stackoverflow.com/a/32558749""" + if len(s1) > len(s2): + s1, s2 = s2, s1 + + distances = range(len(s1) + 1) + for i2, c2 in enumerate(s2): + distances_ = [i2 + 1] + for i1, c1 in enumerate(s1): + if c1 == c2: + distances_.append(distances[i1]) + else: + distances_.append( + 1 + min((distances[i1], distances[i1 + 1], distances_[-1])) + ) + distances = distances_ + return distances[-1] + + +class UnionFind: + """A union-find implementation""" + + parent_of: list[int] + _group_size: list[int] + + def __init__(self, elt_count: int): + self.parent_of = list(range(elt_count)) + self._group_size = [1] * elt_count + + def root(self, elt: int) -> int: + """Find the element representing :elt: (root of component) + + Compresses paths along the way""" + if self.parent_of[elt] == elt: + return elt + self.parent_of[elt] = self.root(self.parent_of[elt]) + return self.parent_of[elt] + + def union(self, elt1: int, elt2: int) -> None: + """Unites two components""" + elt1 = self.root(elt1) + elt2 = self.root(elt2) + if elt1 == elt2: + return + if self._group_size[elt1] > self._group_size[elt2]: + self.union(elt2, elt1) + else: + self._group_size[elt2] += self._group_size[elt1] + self._group_size[elt1] = 0 + self.parent_of[self.root(elt1)] = self.root(elt2) + + def group_size(self, elt: int) -> int: + """Get the number of elements in the component of :elt:""" + return self._group_size[self.root(elt)] diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..f203fe4 --- /dev/null +++ b/setup.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 + +from setuptools import setup, find_packages +import sys + + +def parse_requirements(): + reqs = [] + with open("requirements.txt", "r") as handle: + for line in handle: + reqs.append(line) + return reqs + + +setup( + name="repartir_taches", + version="0.0.1", + description="Réparttion des tâches pour le WE chorale", + url="https://git.tobast.fr/RainbowSwingers/WE-repartir-taches", + packages=find_packages(), + include_package_data=True, + package_data={"staticdeps": ["py.typed"]}, + install_requires=parse_requirements(), + entry_points={ + "console_scripts": [ + ("repartir_taches = repartir_taches.entrypoint:main"), + ] + }, +)