Compare commits
2 commits
2464eba49d
...
05372eef68
Author | SHA1 | Date | |
---|---|---|---|
05372eef68 | |||
44cb69583f |
2 changed files with 236 additions and 17 deletions
|
@ -2,11 +2,13 @@ import argparse
|
||||||
import typing as t
|
import typing as t
|
||||||
import random
|
import random
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from collections import defaultdict
|
||||||
import logging
|
import logging
|
||||||
import jinja2 as j2
|
import jinja2 as j2
|
||||||
|
|
||||||
from .config import Task, Category, Config
|
from .config import Task, Category, Config
|
||||||
from .partition import TaskId, partition
|
from .partition import TaskId, partition
|
||||||
|
from . import intermed_file
|
||||||
from . import util
|
from . import util
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -178,6 +180,32 @@ def export_latex(config: Config, groupes: list[list[str]]) -> str:
|
||||||
return template.render(**env)
|
return template.render(**env)
|
||||||
|
|
||||||
|
|
||||||
|
def log_assignment_toughness(config: Config):
|
||||||
|
"""Prints each group's assigned tasks toughness"""
|
||||||
|
|
||||||
|
grp_tough: dict[int, int] = defaultdict(int)
|
||||||
|
|
||||||
|
def explore_tasks(task: Task | Category):
|
||||||
|
if isinstance(task, Task):
|
||||||
|
assert task.assigned is not None
|
||||||
|
for grp in task.assigned:
|
||||||
|
grp_tough[grp] += task.tough
|
||||||
|
else:
|
||||||
|
for child in task.tasks:
|
||||||
|
explore_tasks(child)
|
||||||
|
|
||||||
|
explore_tasks(config.taches)
|
||||||
|
|
||||||
|
grp_ids = list(grp_tough.keys())
|
||||||
|
grp_ids.sort()
|
||||||
|
tough_lines = []
|
||||||
|
for grp_id in grp_ids:
|
||||||
|
tough_lines.append(f"{grp_id+1:2d} : {grp_tough[grp_id]:3d}")
|
||||||
|
out_str = "Répartition des pénibilités :\n" + "\n".join(tough_lines)
|
||||||
|
|
||||||
|
logger.info(out_str)
|
||||||
|
|
||||||
|
|
||||||
def repartition(config: Config) -> list[Group]:
|
def repartition(config: Config) -> list[Group]:
|
||||||
"""Crée des groupes et assigne des tâches"""
|
"""Crée des groupes et assigne des tâches"""
|
||||||
groupes: list[Group] = constituer_groupes(config.choristes)
|
groupes: list[Group] = constituer_groupes(config.choristes)
|
||||||
|
@ -201,6 +229,16 @@ def main() -> None:
|
||||||
"--to-short-md",
|
"--to-short-md",
|
||||||
help="Exporter vers un fichier Markdown (pour vérification uniquement)",
|
help="Exporter vers un fichier Markdown (pour vérification uniquement)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--to-intermed",
|
||||||
|
type=Path,
|
||||||
|
help="Exporter vers un fichier éditable manuellement, et finaliser plus tard",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--use-intermed",
|
||||||
|
type=Path,
|
||||||
|
help="Importer un fichier éditable manuellement précédemment généré",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-g",
|
"-g",
|
||||||
"--debug",
|
"--debug",
|
||||||
|
@ -218,24 +256,38 @@ def main() -> None:
|
||||||
if args.bare_tasks:
|
if args.bare_tasks:
|
||||||
util.write_to_file(args.bare_tasks, export_bare_tasks_md(config))
|
util.write_to_file(args.bare_tasks, export_bare_tasks_md(config))
|
||||||
|
|
||||||
retry: int = 0
|
groupes: list[Group]
|
||||||
MAX_RETRY: int = 4
|
if args.use_intermed:
|
||||||
while retry < MAX_RETRY:
|
intermed = intermed_file.IntermedFile.from_file(
|
||||||
try:
|
config.choristes, config.taches, args.use_intermed
|
||||||
groupes = repartition(config)
|
)
|
||||||
break
|
intermed.sanity_check()
|
||||||
except AssignError as exn:
|
groupes = intermed.to_assignment(config)
|
||||||
retry += 1
|
else:
|
||||||
logger.warning(
|
retry: int = 0
|
||||||
"[essai %d/%d] Échec de répartition des tâches : %s",
|
MAX_RETRY: int = 4
|
||||||
retry,
|
while retry < MAX_RETRY:
|
||||||
MAX_RETRY,
|
try:
|
||||||
exn,
|
groupes = repartition(config)
|
||||||
)
|
break
|
||||||
if retry == MAX_RETRY:
|
except AssignError as exn:
|
||||||
logger.critical("Échec de répartition des tâches.")
|
retry += 1
|
||||||
raise exn from exn
|
logger.warning(
|
||||||
|
"[essai %d/%d] Échec de répartition des tâches : %s",
|
||||||
|
retry,
|
||||||
|
MAX_RETRY,
|
||||||
|
exn,
|
||||||
|
)
|
||||||
|
if retry == MAX_RETRY:
|
||||||
|
logger.critical("Échec de répartition des tâches.")
|
||||||
|
raise exn from exn
|
||||||
|
|
||||||
|
log_assignment_toughness(config)
|
||||||
|
|
||||||
|
if args.to_intermed:
|
||||||
|
intermed = intermed_file.IntermedFile.from_assignment(config, groupes)
|
||||||
|
intermed.sanity_check()
|
||||||
|
intermed.write(args.to_intermed)
|
||||||
if args.to_tex:
|
if args.to_tex:
|
||||||
util.write_to_file(args.to_tex, export_latex(config, groupes))
|
util.write_to_file(args.to_tex, export_latex(config, groupes))
|
||||||
if args.to_short_md:
|
if args.to_short_md:
|
||||||
|
|
167
repartir_taches/intermed_file.py
Normal file
167
repartir_taches/intermed_file.py
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
""" Fichier intermédiaire aisément éditable à la main """
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import typing as t
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import dataclasses
|
||||||
|
import ruamel
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
Assignment: t.TypeAlias = dict[str, list[int]]
|
||||||
|
|
||||||
|
|
||||||
|
class BadIntermedFile(Exception):
|
||||||
|
"""Raised when an intermediary file doesn't contain what is expected"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _IntermedFileData:
|
||||||
|
assignation: Assignment
|
||||||
|
nb_groupes: int
|
||||||
|
groupes: list[list[str]]
|
||||||
|
|
||||||
|
|
||||||
|
class IntermedFile:
|
||||||
|
"""Fichier intermédiaire aisément éditable à la main"""
|
||||||
|
|
||||||
|
choristes: list[str]
|
||||||
|
tasks: config.Category
|
||||||
|
data: _IntermedFileData
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
choristes: list[str],
|
||||||
|
tasks: config.Category,
|
||||||
|
assignment: dict[str, list[int]],
|
||||||
|
nb_groups: int,
|
||||||
|
groups: list[list[str]],
|
||||||
|
):
|
||||||
|
self.choristes = choristes
|
||||||
|
self.tasks = tasks
|
||||||
|
self.data = _IntermedFileData(
|
||||||
|
assignation=assignment,
|
||||||
|
nb_groupes=nb_groups,
|
||||||
|
groupes=groups,
|
||||||
|
)
|
||||||
|
|
||||||
|
def sanity_check(self):
|
||||||
|
"""Check that the data is consistent -- does not check that the assignment is
|
||||||
|
fair"""
|
||||||
|
|
||||||
|
def check_taches(task: config.Category | config.Task):
|
||||||
|
if isinstance(task, config.Task):
|
||||||
|
if task.qualified_name not in self.data.assignation:
|
||||||
|
raise BadIntermedFile(
|
||||||
|
f"Aucun groupe assigné pour {task.qualified_name}"
|
||||||
|
)
|
||||||
|
nb_assigned = len(self.data.assignation[task.qualified_name])
|
||||||
|
if nb_assigned != task.nb_groups:
|
||||||
|
raise BadIntermedFile(
|
||||||
|
f"{nb_assigned} groupes assignés pour {task.qualified_name}, "
|
||||||
|
f"il en faut {task.nb_groups}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for child in task.tasks:
|
||||||
|
check_taches(child)
|
||||||
|
|
||||||
|
if len(self.data.groupes) != self.data.nb_groupes:
|
||||||
|
raise BadIntermedFile("Nombre de groupes incohérent")
|
||||||
|
|
||||||
|
choristes_seen: dict[str, int] = {}
|
||||||
|
for grp_id, group in enumerate(self.data.groupes):
|
||||||
|
for choriste in group:
|
||||||
|
if choriste in choristes_seen:
|
||||||
|
raise BadIntermedFile(
|
||||||
|
f"{choriste} est dans plusieurs groupes : "
|
||||||
|
f"{choristes_seen[choriste]} et {grp_id}."
|
||||||
|
)
|
||||||
|
if choriste not in self.choristes:
|
||||||
|
raise BadIntermedFile(
|
||||||
|
f"{choriste} est dans le groupe {grp_id}, mais ne fait pas "
|
||||||
|
"partie de la liste des choristes"
|
||||||
|
)
|
||||||
|
choristes_seen[choriste] = grp_id
|
||||||
|
missing_choristes = set(self.choristes) - set(choristes_seen.keys())
|
||||||
|
if missing_choristes:
|
||||||
|
raise BadIntermedFile(
|
||||||
|
"Ces choristes ne sont dans aucun groupe : "
|
||||||
|
+ ", ".join(missing_choristes)
|
||||||
|
)
|
||||||
|
|
||||||
|
check_taches(self.tasks)
|
||||||
|
|
||||||
|
def write(self, to_path: Path):
|
||||||
|
"""Write the intermediary file to this path"""
|
||||||
|
yaml = ruamel.yaml.YAML()
|
||||||
|
yaml.dump(dataclasses.asdict(self.data), to_path)
|
||||||
|
|
||||||
|
def to_assignment(self, conf: config.Config) -> list[list[str]]:
|
||||||
|
"""Use this intermediary file to make an assignment. Assigns groups to tasks
|
||||||
|
in the config, and returns the groups"""
|
||||||
|
|
||||||
|
def assign_tasks(task: config.Category | config.Task):
|
||||||
|
if isinstance(task, config.Task):
|
||||||
|
task.assigned = self.data.assignation[task.qualified_name]
|
||||||
|
task.assigned.sort()
|
||||||
|
else:
|
||||||
|
for child in task.tasks:
|
||||||
|
assign_tasks(child)
|
||||||
|
|
||||||
|
assign_tasks(conf.taches)
|
||||||
|
return self.data.groupes
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_assignment(
|
||||||
|
cls, conf: config.Config, groups: list[list[str]]
|
||||||
|
) -> "IntermedFile":
|
||||||
|
def make_assignment(task: config.Category | config.Task, out: Assignment):
|
||||||
|
if isinstance(task, config.Task):
|
||||||
|
assert task.qualified_name not in out
|
||||||
|
assert task.assigned is not None
|
||||||
|
# Internal groups are 0-indexed
|
||||||
|
out[task.qualified_name] = list(map(lambda x: x + 1, task.assigned))
|
||||||
|
else:
|
||||||
|
for child in task.tasks:
|
||||||
|
make_assignment(child, out)
|
||||||
|
|
||||||
|
assignment: Assignment = {}
|
||||||
|
make_assignment(conf.taches, assignment)
|
||||||
|
return cls(
|
||||||
|
choristes=conf.choristes,
|
||||||
|
tasks=conf.taches,
|
||||||
|
assignment=assignment,
|
||||||
|
nb_groups=len(groups),
|
||||||
|
groups=groups,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(
|
||||||
|
cls, choristes: list[str], tasks: config.Category, file: Path
|
||||||
|
) -> "IntermedFile":
|
||||||
|
yaml = ruamel.yaml.YAML()
|
||||||
|
with file.open("r") as handle:
|
||||||
|
raw_data = yaml.load(handle)
|
||||||
|
for expected_field in dataclasses.fields(_IntermedFileData):
|
||||||
|
if expected_field.name not in raw_data:
|
||||||
|
raise BadIntermedFile(
|
||||||
|
f"Le champ '{expected_field.name}' est absent du fichier !"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = _IntermedFileData(**raw_data)
|
||||||
|
except TypeError as exn:
|
||||||
|
raise BadIntermedFile(
|
||||||
|
"Impossible d'interpréter ce fichier comme un fichier intermédiaire."
|
||||||
|
) from exn
|
||||||
|
|
||||||
|
# Internal groups are 0-indexed
|
||||||
|
for grp in data.assignation.values():
|
||||||
|
for pos in range(len(grp)):
|
||||||
|
grp[pos] -= 1
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
choristes=choristes,
|
||||||
|
tasks=tasks,
|
||||||
|
assignment=data.assignation,
|
||||||
|
nb_groups=data.nb_groupes,
|
||||||
|
groups=data.groupes,
|
||||||
|
)
|
Loading…
Reference in a new issue