Rehaul completely option management

This commit is contained in:
Jean-Christophe Dubacq 2024-11-01 00:18:55 +01:00
parent 3b7a3df51c
commit d98be6618c

599
get.py
View File

@ -9,6 +9,7 @@ import pdb # used for debugging
import inspect # used for debugging
import random
import sys
import re
# Third-party libs
import requests
@ -34,8 +35,10 @@ def debug(*args):
debug.counter += 1
caller_frame = inspect.currentframe().f_back
where = str(caller_frame.f_lineno) + "@" + caller_frame.f_code.co_name
if len(args) > 0:
if len(args) > 1:
print(f"[DEBUG {debug.counter}:{where}] " + str(args[0]), args[1:])
elif len(args) > 0:
print(f"[DEBUG {debug.counter}:{where}] " + str(args[0]))
else:
print(f"[DEBUG {debug.counter}:{where}] (no reason given)")
@ -70,10 +73,6 @@ API_URL = f"{SCODOC_SERVER}/ScoDoc/api"
DEBUG = True # Not used
BLOCKING = True # Die if csv is incorrect
# TODO : refactor / put globals in a class, eg Config
depts = []
orderkey = ""
def blockordie(reason: str = "", status: int = 2):
if reason:
@ -84,73 +83,439 @@ def blockordie(reason: str = "", status: int = 2):
sys.exit(status)
class Options:
pass
class Filter:
# Filter on students to be considered
# 1 consider only technological baccalaureates, statistics are always asked
# 2 consider only women, because gender statistics are frequently asked
# 4 consider only incoming students (primo-entrants) in first year of the cohort
# 8 consider only people having a first year, not parallel entries
TECHNO = 1
WOMAN = 2
PRIMO = 4
MAIN = 8
class OptionSet:
def __init__(self, values={}):
# Initialise un dictionnaire interne pour stocker les options
if type(values) == type({}):
self._options = values
else:
self._options = {}
self._orderkey = None
self._main_filter = None
self._secondary_filter = None
self._depts = []
def __getitem__(self, key):
# Récupère la valeur correspondant à la clé
return self._options[key]
def __setitem__(self, key, value):
# Assigne la valeur à la clé donnée
self._options[key] = value
def __delitem__(self, key):
# Supprime la clé spécifiée
if key in self._options:
del self._options[key]
def __contains__(self, key):
# Permet l'utilisation de 'in' pour vérifier l'existence d'une clé
return key in self._options
def __getattr__(self, name):
try:
return self._options[name]
except KeyError:
raise AttributeError(f"'Options' object has no attribute '{name}'")
def __setattr__(self, name, value):
if name[0] == "_":
super().__setattr__(name, value)
else:
self._options[name] = value
def __delattr__(self, name):
# Appelé quand un attribut est supprimé
if name in self._options:
del self._options[name]
else:
raise AttributeError(f"'Options' object has no attribute '{name}'")
def __repr__(self):
return f"Options({self._options})"
def asDict(self):
return self._options
def asCLI(self, excludeDefault=True, onlyOrders=False, depts=True):
cli = []
if not onlyOrders:
for opt in self.__class__.choiceoptions:
if self[opt[0]] == 0 and excludeDefault:
continue
cli.append("--" + opt[1][self[opt[0]]])
for opt in self.__class__.stringoptions:
if excludeDefault and self[opt[0]] == opt[1]:
continue
cli.append("--" + opt[0])
cli.append(self[opt[0]])
for opt in self.__class__.posint_options:
if excludeDefault and self[opt[0]] == opt[1]:
continue
cli.append("--" + opt[0])
cli.append(self[opt[0]])
for opt in self.__class__.booleanoptions:
if excludeDefault and self[opt[0]] == opt[1]:
continue
if self[opt[0]]:
cli.append("--" + opt[0])
else:
cli.append("--no-" + opt[0])
if "override" in self._options:
for FIELD, FIELDdict in self._options["override"].items():
for FIELDVALUE, FIELDVALUEdict in FIELDdict.items():
for key, val in FIELDVALUEdict.items():
cli.extend(["--override", FIELD, FIELDVALUE, key, val])
if "orders" in self._options:
orders = self._options["orders"]
cli.append("--orders")
for i, column in enumerate(orders):
if i > 0:
cli.append("/")
for row in column:
cli.append(row)
cli.append(".")
if depts:
cli.extend(self._depts)
return cli
def orderkey(self, filters=False):
if filters:
d = self._depts.copy()
d.append(str(self.filter()))
d.append(str(self.filter(main=False)))
return "_".join(d)
if self._orderkey is not None:
return self._orderkey
self._orderkey = "_".join(self._depts)
return self._orderkey
def depts(self, xset=None):
if xset:
self._depts = xset
return self._depts
def filter(self, main: bool = True):
r = 0
stem = "base"
if not main:
if self._secondary_filter is not None:
return self._secondary_filter
stem = "secondary"
else:
if self._main_filter is not None:
return self._main_filter
for suffix, f in {"techno": Filter.TECHNO, "women": Filter.WOMAN}.items():
option = f"{stem}_{suffix}"
if option in self._options and self._options[option]:
r |= f
if main:
self._main_filter = r
return r
self._secondary_filter = r | self.filter()
return self._secondary_filter
stringoptions = [
[
"department_separator",
" ",
"Separator before department in semester designation/display/origin designation",
],
[
"diplome_separator",
"",
"Separator before diploma in semester designation/display/origin designation",
],
[
"modalite_separator",
" ",
"Separator before modality in semester designation/display/origin designation",
],
[
"parcours_separator",
"/",
"Separator before parcours in semester designation/display/origin designation",
],
[
"rank_separator",
"",
"Separator before rank (~year of progress) in cursus designation/display/origin designation",
],
[
"year_separator",
" ",
"Separator before year in semester designation/display/origin designation",
],
[
"nick",
"{diplome}{rank}{multidepartment}{modalite}{parcours}{year}",
"Yearly cursus designation (should be unique for each distinguisable cursus choice)",
],
[
"displayname",
"{diplome}{rank}{multidepartment}{modaliteshort}{parcours}",
"Yearly cursus origin (used only for captionning the flow)",
],
[
"extnick",
"{ext}{rank}{multidepartment}{diplomenobut}{modaliteshort}",
"Origin designation (should be unique for each distinguisable origin of students)",
],
]
choiceoptions = [["algo", ["optimize", "reuse", "restart"]]]
booleanoptions = [
["base_techno", False, "Base population includes only techno students"],
["base_women", False, "Base population includes only women students"],
[
"secondary_techno",
True,
"Secondary (focused) population includes only techno students",
],
[
"secondary_women",
False,
"Secondary (focused) population includes only women students",
],
]
posint_options = [
["spacing", 14, 0, 30, "Spacing between groups in the same column"],
["thickness", 6, 0, 30, "Width of the group bars in columns"],
["hmargin", 20, 0, 50, "Global margin around the graph"],
["fontsize_name", 10, 0, 30, "Font size of the group name"],
["fontsize_count", 14, 0, 30, "Font size of the population marks"],
["width", 1300, 800, None, "Width of the graphics (not counting captions)"],
["statwidth", 300, 0, None, "Width of the side caption"],
["height", 0, 0, None, "Height of the graphics (0 = automaticd)"],
["loops", 300, 0, 1000, "Number of loops of the optimization algorithm"],
["baseyear", 2021, 2000, None, "Base year (start of the cohort)"],
]
shortcuts = {"baseyear": ["--base", "-b"], "loops": ["-l"]}
def range_limited_int_type(arg, MIN_VAL, MAX_VAL):
"""Type function for argparse - an integer within some predefined bounds"""
try:
f = int(arg)
except ValueError:
raise argparse.ArgumentTypeError("Must be an integer point number")
if MIN_VAL is not None and f < MIN_VAL:
raise argparse.ArgumentTypeError(
"Argument must be larger or equal to " + str(MIN_VAL)
)
if MAX_VAL is not None and f > MAX_VAL:
raise argparse.ArgumentTypeError(
"Argument must be smaller or equal to " + str(MAX_VAL)
)
return f
def format_for_shell(strings):
# Regex pour détecter les caractères spéciaux
special_chars = re.compile(r"[^+/.a-zA-Z0-9_-]")
formatted_strings = []
for ss in strings:
s = str(ss)
if special_chars.search(s): # Si la chaîne contient des caractères spéciaux
formatted_s = "'" + s.replace("'", "'\"'\"'") + "'"
elif len(s) == 0:
formatted_s = "''"
else:
formatted_s = s
formatted_strings.append(formatted_s)
# Concatène les chaînes pour qu'elles soient prêtes à copier-coller dans le shell
return " ".join(formatted_strings)
#
def cli_check():
"""Read args from the command line
then read config from {orderkey}.json
"""
global orderkey # TODO: globales à supprimer
global depts
parser = argparse.ArgumentParser(
usage="""
%(prog)s [--options] DEPARTEMENTS...
parser = argparse.ArgumentParser(description="Process some departments.")
parser.add_argument("depts", nargs="*", help="List of departments")
OR
%(prog)s FILE.json
""",
description="Create a sankey diagram for the evolution of students through some departments.",
)
parser.add_argument(
"--base",
"-b",
type=int,
choices=range(2000, 2067),
default=2021,
help="base year for the cohort (integer between 2000 and 2666)",
"--orders",
nargs="+",
help="Start of orders list with subgroups separated by / ended by .",
)
parser.add_argument("depts", nargs="*", help="List of departments")
# STRING OPTIONS
for opt in OptionSet.stringoptions:
xopt = ["--" + opt[0]]
if opt[0] in OptionSet.shortcuts:
xopt.extend(OptionSet.shortcuts[opt[0]])
parser.add_argument(*xopt, type=str, default=opt[1], help=opt[2])
# POSITIVE INTEGERS OPTIONS
for opt in OptionSet.posint_options:
xopt = ["--" + opt[0]]
if opt[0] in OptionSet.shortcuts:
xopt.extend(OptionSet.shortcuts[opt[0]])
optrange = ""
if opt[3] == None and opt[2] != None:
optrange = f" larger or equal to {opt[2]}"
elif opt[2] == None and opt[3] != None:
optrange = f" smaller than {opt[3]}"
elif opt[2] != None and opt[3] != None:
optrange = f" (between {opt[2]} and {opt[3]})"
def rangefactory(y, z):
return lambda x: range_limited_int_type(x, y, z)
parser.add_argument(
*xopt,
type=rangefactory(opt[2], opt[3]),
default=opt[1],
help=opt[4] + optrange,
)
# BOOLEAN OPTIONS
for opt in OptionSet.booleanoptions:
g = parser.add_mutually_exclusive_group()
xopt = ["--" + opt[0]]
if opt[0] in OptionSet.shortcuts:
xopt.extend(OptionSet.shortcuts[opt[0]])
g.add_argument(*xopt, action="store_true", default=opt[1], help=opt[2])
xopt = ["--no-" + opt[0]]
if "no-" + opt[0] in OptionSet.shortcuts:
xopt.extend(OptionSet.shortcuts["no-" + opt[0]])
g.add_argument(
*xopt,
action="store_true",
# help=opt[2].replace("includes only", "doesn't care about"),
)
# OTHER OPTIONS
parser.add_argument(
"--override",
nargs=4,
metavar=(
"FIELD",
"FIELD_VALUE",
"REPLACEMENT_FIELD",
"REPLACEMENT_FIELD_VALUE",
),
help="Override a specific field with a fixed value in some specific semester(s) selected by FIELD=FIELD_VALUE",
)
optimize_group = parser.add_mutually_exclusive_group()
optimize_group.add_argument(
"--reuse", action="store_true", help="Reuse mode, sets value to 0"
)
optimize_group.add_argument("--reuse", "-r", action="store_true", help="Reuse mode")
optimize_group.add_argument(
"--optimize",
type=str,
nargs="?",
const="100", # Default value if --optimize is used without specifying n
help="Optimize mode, takes an optional integer (default is 100, or 300 if no optimization option specified)",
"-o",
action="store_true",
help="Use algorithm to enhance graph (using last result)",
)
optimize_group.add_argument(
"--restart",
type=str,
nargs="?",
const="300", # Default value if --restart is used without specifying n
help="Restart & Optimize mode, takes an optional integer (default is 300)",
"-R",
action="store_true",
help="Use algorithm to enhance graph (starting from random)",
)
args = parser.parse_args()
Options.restart = False
if args.reuse:
Options.optimize = 0
elif args.restart is not None:
Options.restart = True
if len(sys.argv) > 1 and sys.argv[1].endswith(".json"):
try:
Options.optimize = -int(args.restart)
except (TypeError, ValueError):
Options.optimize = -300
if args.restart:
args.depts.insert(0, args.restart)
json_file = sys.argv[1]
with open(json_file, "r") as f:
fakeclisource = json.load(f)
fakecli = [str(x) for x in fakeclisource]
except FileNotFoundError:
die(f"Error: File '{json_file}' not found.", 1)
except json.JSONDecodeError:
die(f"Error: File '{json_file}' is not valid JSON.", 1)
args = parser.parse_args(args=fakecli)
else:
try:
Options.optimize = int(args.optimize)
except (TypeError, ValueError):
Options.optimize = 300
if args.optimize:
args.depts.insert(0, args.optimize)
Options.base_year = args.base
depts = args.depts
orderkey = "_".join(depts)
if len(depts) == 0:
args = parser.parse_args()
if len(args.depts) == 0 and (
args.orders is None or args.orders[-1] == "." or "." not in args.orders
):
parser.print_help()
sys.exit(0)
return args
def options_from_args(args):
Options = OptionSet()
# Gestion de --orders pour construire le tableau 2D
orders = []
if args.depts:
depts = args.depts
else:
depts = []
if args.orders:
current_order = []
l = args.orders.copy()
idx = 0
while len(l) > idx:
item = l[idx]
idx += 1
if item == "/":
# Nouvelle ligne à chaque "--next"
orders.append(current_order)
current_order = []
elif item == ".":
# Fin de la liste d'ordres
orders.append(current_order)
break
else:
# Ajouter l'élément au sous-groupe en cours
current_order.append(item)
depts.extend(l[idx:])
Options["orders"] = orders
dargs = vars(args)
for opt in OptionSet.posint_options:
if opt[0] in dargs:
Options[opt[0]] = dargs[opt[0]]
for opt in OptionSet.stringoptions:
if opt[0] in dargs:
Options[opt[0]] = dargs[opt[0]]
for opt in OptionSet.booleanoptions:
if opt[0] in dargs:
if "no_" + opt[0] in dargs and dargs["no_" + opt[0]]:
Options[opt[0]] = not dargs["no_" + opt[0]]
else:
Options[opt[0]] = dargs[opt[0]]
if not (args.reuse or args.restart or args.optimize):
Options.algo = 0
else:
Options.algo = 0 if args.optimize else (1 if args.reuse else 2)
Options.depts(depts)
return Options
def merge_options(Options, jsondict):
if "override" in jsondict:
Options["override"] = jsondict["override"]
if "orders" in jsondict:
Options["orders"] = jsondict["orders"]
def api_url(dept: str | None = None):
@ -161,9 +526,6 @@ def api_url(dept: str | None = None):
)
cli_check()
def read_conf(key):
if os.path.exists(f"{key}.json"):
with open(f"{key}.json", "r") as f:
@ -177,44 +539,11 @@ def write_conf(key, obj):
return {}
conf = read_conf(orderkey)
Options = options_from_args(cli_check())
orderkey = Options.orderkey()
depts = Options.depts()
defaults = {
"spacing": 14,
"thickness": 6,
"fontsize_name": 10,
"fontsize_count": 14,
"width": 1300,
"height": 0,
"hmargin": 20,
"parcours_separator": "/",
"year_separator": " ",
"rank_separator": "",
"diplome_separator": "",
"reuse": "yes",
"optimize": "yes",
"main_filter": 0,
"secondary_filter": 1,
}
def conf_value(xkey: str):
"""Manage default values"""
if xkey in conf:
return conf[xkey]
if xkey in defaults:
return defaults[xkey]
if xkey[-9:] == "separator":
return " "
if xkey == "nick":
return "{diplome}{rank}{multidepartment}{modalite}{parcours}{year}"
if xkey == "displayname":
return "{diplome}{rank}{multidepartment}{modaliteshort}{parcours}"
if xkey == "extnick":
return "{ext}{rank}{multidepartment}{diplomenobut}{modaliteshort}"
if xkey == "orders":
return [[], [], [], [], []]
return {}
defaults = {}
student = {}
@ -393,7 +722,9 @@ def get_jury_from_formsem(dept: str, semid):
def get_override(sem, xkey, default=None):
overrides = conf_value("override")
if "overrides" not in Options:
return default
overrides = Options.override
for j in ["titre_num", "titre", "session_id"]:
if (
j in sem
@ -406,46 +737,44 @@ def get_override(sem, xkey, default=None):
def nick_replace(
department, diplome, rank, modalite, parcours, nick, year=Options.base_year
department, diplome, rank, modalite, parcours, nick, year=Options.baseyear
):
if type(rank) != int:
rank = 0
if len(department) > 0:
nick = nick.replace(
"{department}", conf_value("department_separator") + department
)
nick = nick.replace("{department}", Options.department_separator + department)
else:
nick = nick.replace("{department}", "")
if len(department) > 0 and len(depts) > 1:
nick = nick.replace(
"{multidepartment}", conf_value("department_separator") + department
"{multidepartment}", Options.department_separator + department
)
else:
nick = nick.replace("{multidepartment}", "")
if len(diplome) > 0:
nick = nick.replace("{diplome}", conf_value("diplome_separator") + diplome)
nick = nick.replace("{diplome}", Options.diplome_separator + diplome)
else:
nick = nick.replace("{diplome}", "")
if len(diplome) > 0 and diplome != "BUT":
nick = nick.replace("{diplomenobut}", conf_value("diplome_separator") + diplome)
nick = nick.replace("{diplomenobut}", Options.diplome_separator + diplome)
else:
nick = nick.replace("{diplomenobut}", "")
if rank > 0:
nick = nick.replace("{rank}", conf_value("rank_separator") + str(rank))
nick = nick.replace("{rank}", Options.rank_separator + str(rank))
else:
nick = nick.replace("{rank}", "")
nick = nick.replace(
"{year}", conf_value("year_separator") + str(Options.base_year + rank - 1)
"{year}", Options.year_separator + str(Options.baseyear + rank - 1)
)
if diplome != "BUT":
nick = nick.replace(
"{yearnobut}",
conf_value("year_separator") + str(Options.base_year + rank - 1),
Options.year_separator + str(Options.baseyear + rank - 1),
)
else:
nick = nick.replace("{yearnobut}", "")
if len(modalite) > 0:
nick = nick.replace("{modalite}", conf_value("modalite_separator") + modalite)
nick = nick.replace("{modalite}", Options.modalite_separator + modalite)
else:
nick = nick.replace("{modalite}", "")
if len(modalite) > 0 and modalite != "FI":
@ -453,7 +782,7 @@ def nick_replace(
else:
nick = nick.replace("{modaliteshort}", "")
if len(parcours) > 0:
nick = nick.replace("{parcours}", conf_value("parcours_separator") + parcours)
nick = nick.replace("{parcours}", Options.parcours_separator + parcours)
else:
nick = nick.replace("{parcours}", "")
extname = "Ecand "
@ -499,9 +828,9 @@ def analyse_student(semobj, etud, univ_year=None):
gg = g.split("=")
if gg[0] in groups:
modalite = gg[1]
nick = conf_value("nick")
nick = Options.nick
nick = nick_replace(department, diplome, rank, modalite, parcours, nick, year)
displayname = conf_value("displayname")
displayname = Options.displayname
displayname = nick_replace(
department, diplome, rank, modalite, parcours, displayname, year
)
@ -538,7 +867,7 @@ def analyse_depts():
year = 1
else:
year = (sem["semestre_id"] + 1) // 2
offset = sem["annee_scolaire"] - Options.base_year - year + 1
offset = sem["annee_scolaire"] - Options.baseyear - year + 1
if offset < 0 and offset > -4:
oldsems.add(str(semid))
oldsemsdept[semid] = dept
@ -992,7 +1321,7 @@ for etudid in student.keys():
elif resultyear in ("NAR", "DEM", "DEF", "ABAN"):
finaloutput = "FAIL"
failure[ddd] += 1
elif resjury["annee"]["annee_scolaire"] != Options.base_year + lastyear - 1:
elif resjury["annee"]["annee_scolaire"] != Options.baseyear + lastyear - 1:
finaloutput = "RED"
checkred = True
if checkred:
@ -1019,7 +1348,7 @@ for etudid in student.keys():
yearold = cache["sem"][etud["oldsem"]]["annee_scolaire"]
etud["nickshort"][firstyear - 1] = etud["old"]
# yy = yearold
# delta = firstyear + Options.base_year - yy - 2
# delta = firstyear + Options.baseyear - yy - 2
# for i in range(delta, firstyear - 1):
# etud["nickshort"][i] = etud["nickshort"][firstyear - 1] + "*" * (
# firstyear - 1 - i
@ -1037,9 +1366,9 @@ for etudid in student.keys():
rank = etud["rank"][startsem]
modalite = etud["modalite"][startsem]
parcours = etud["parcours"][startsem]
nick = "EXT" + conf_value("nick")
nick = "EXT" + Options.nick
nick = nick_replace(department, diplome, rank, modalite, parcours, nick)
displayname = conf_value("extnick")
displayname = Options.extnick
displayname = nick_replace(
department, diplome, rank, modalite, parcours, displayname
)
@ -1052,18 +1381,6 @@ for etudid in student.keys():
entries[ddd] += 1
class Filter:
# Filter on students to be considered
# 1 consider only technological baccalaureates, statistics are always asked
# 2 consider only women, because gender statistics are frequently asked
# 4 consider only incoming students (primo-entrants) in first year of the cohort
# 8 consider only people having a first year, not parallel entries
TECHNO = 1
WOMAN = 2
PRIMO = 4
MAIN = 8
def bags_from_students(student, filter=0):
bags = []
for etudid in student.keys():
@ -1244,7 +1561,7 @@ def ordernodes(layers, orders, edges):
for j, n in enumerate(layernodes):
node_position[n] = j
node_layer[n] = layer
debug(crossweight(node_position, node_layer, edges))
debug("Solution has weight" + str(crossweight(node_position, node_layer, edges)))
return node_position, node_layer, newls
@ -1361,10 +1678,10 @@ def nodestructure_from_bags(bags, sbags=None):
def compute_svg(height, padding, realdensity, node_structure):
unit_ratio = 96 / 72
thickness = conf_value("thickness")
fontsize_name = conf_value("fontsize_name")
fontsize_count = conf_value("fontsize_count")
width = conf_value("width")
thickness = Options.thickness
fontsize_name = Options.fontsize_name
fontsize_count = Options.fontsize_count
width = Options.width
columns = []
l = 0
for i in range(5):
@ -1525,30 +1842,32 @@ def compute_svg(height, padding, realdensity, node_structure):
def printsvg():
padding = 4
spacing = conf_value("spacing")
height = conf_value("height")
hmargin = conf_value("hmargin")
bags = bags_from_students(student, conf_value("main_filter"))
sbags = bags_from_students(student, conf_value("secondary_filter"))
spacing = Options.spacing
height = Options.height
hmargin = Options.hmargin
bags = bags_from_students(student, Options.filter())
sbags = bags_from_students(student, Options.filter(main=False))
node_structure, layers, edges = nodestructure_from_bags(bags, sbags)
filename = "best-" + orderkey
if Options.restart:
if Options.algo == 2:
try:
os.remove(filename)
except OSError:
pass
if Options.optimize >= 0:
if Options.algo < 2:
lastorders = read_conf(filename)
else:
lastorders = {}
node_position, node_layer, newls = ordernodes(layers, lastorders, edges)
if Options.optimize != 0:
orders = genetic_optimize(
node_position, node_layer, edges, loops=abs(Options.optimize)
)
if Options.algo != 1:
orders = genetic_optimize(node_position, node_layer, edges, loops=Options.loops)
else:
orders = newls
write_conf("best-" + orderkey, orders)
info(format_for_shell(OptionSet({"orders": orders}).asCLI(onlyOrders=True)))
Options.orders = orders
write_conf(Options.orderkey(filters=True), Options.asCLI())
info(format_for_shell(OptionSet({"orders": orders}).asCLI(onlyOrders=True)))
node_position, node_layer, newls = ordernodes(layers, orders, edges)
realdensity, height, layer_structure = get_layer_structure(
newls, node_structure, spacing, hmargin, height