fhe-cli 1.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fhe-cli might be problematic. Click here for more details.
- fhe_cli-1.3.1.dist-info/METADATA +280 -0
- fhe_cli-1.3.1.dist-info/RECORD +37 -0
- fhe_cli-1.3.1.dist-info/WHEEL +5 -0
- fhe_cli-1.3.1.dist-info/entry_points.txt +2 -0
- fhe_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
- fhe_cli-1.3.1.dist-info/top_level.txt +1 -0
- fhecli/__init__.py +5 -0
- fhecli/__main__.py +6 -0
- fhecli/cli.py +220 -0
- fhecli/data/info.toml +77 -0
- fhecli/data/originals/ex10_ckks_bonus.py +84 -0
- fhecli/data/originals/ex1_cesar.py +92 -0
- fhecli/data/originals/ex2_xor_malleability.py +102 -0
- fhecli/data/originals/ex3_fernet_tamper.py +92 -0
- fhecli/data/originals/ex4_modpow.py +73 -0
- fhecli/data/originals/ex5_modinv.py +77 -0
- fhecli/data/originals/ex6_paillier_keygen.py +85 -0
- fhecli/data/originals/ex7_paillier_enc_dec.py +118 -0
- fhecli/data/originals/ex8_paillier_homomorphic.py +144 -0
- fhecli/data/originals/ex9_scoring_paillier.py +143 -0
- fhecli/data/solutions/ex10_ckks_bonus.sol +8 -0
- fhecli/data/solutions/ex1_cesar.sol +6 -0
- fhecli/data/solutions/ex2_xor_malleability.sol +7 -0
- fhecli/data/solutions/ex3_fernet_tamper.sol +7 -0
- fhecli/data/solutions/ex4_modpow.sol +5 -0
- fhecli/data/solutions/ex5_modinv.sol +6 -0
- fhecli/data/solutions/ex6_paillier_keygen.sol +8 -0
- fhecli/data/solutions/ex7_paillier_enc_dec.sol +11 -0
- fhecli/data/solutions/ex8_paillier_homomorphic.sol +12 -0
- fhecli/data/solutions/ex9_scoring_paillier.sol +15 -0
- fhecli/info.py +46 -0
- fhecli/resources.py +28 -0
- fhecli/runner.py +72 -0
- fhecli/solutions.py +30 -0
- fhecli/ui.py +70 -0
- fhecli/watch.py +116 -0
- fhecli/workspace.py +58 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ex7 : Chiffrer et déchiffrer avec Paillier
|
|
3
|
+
============================================
|
|
4
|
+
|
|
5
|
+
Les clés sont prêtes (ex6, fourni ci-dessous). On chiffre maintenant un entier
|
|
6
|
+
m, puis on le retrouve par déchiffrement.
|
|
7
|
+
|
|
8
|
+
Chiffrement, avec un aléa r tel que pgcd(r, n) == 1 :
|
|
9
|
+
c = (1 + m*n) * (r ** n) modulo n2
|
|
10
|
+
(la variante g = n + 1 permet d'écrire g**m = 1 + m*n modulo n2, bien plus
|
|
11
|
+
rapide à calculer). L'aléa r rend deux chiffrés du même message différents.
|
|
12
|
+
|
|
13
|
+
Déchiffrement, avec la fonction L(u) = (u - 1) // n (fournie) :
|
|
14
|
+
u = (c ** lam) modulo n2
|
|
15
|
+
m = (L(u) * mu) modulo n
|
|
16
|
+
|
|
17
|
+
Représentation symétrique : pour pouvoir manipuler des nombres négatifs (un
|
|
18
|
+
score peut être négatif), on convient que les valeurs supérieures à n/2
|
|
19
|
+
représentent des négatifs. Donc après déchiffrement, si m > n // 2, faites
|
|
20
|
+
m -= n.
|
|
21
|
+
|
|
22
|
+
À implémenter :
|
|
23
|
+
- encrypt(pub, m, r) -> int
|
|
24
|
+
- decrypt(pub, priv, c) -> int (avec la correction symétrique ci-dessus)
|
|
25
|
+
|
|
26
|
+
Vous pouvez utiliser le pow(base, exp, mod) intégré ici.
|
|
27
|
+
|
|
28
|
+
Testez avec : fhecli run ex7_paillier_enc_dec
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# I AM NOT DONE
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _extended_gcd(a: int, b: int) -> tuple[int, int, int]:
|
|
35
|
+
if b == 0:
|
|
36
|
+
return a, 1, 0
|
|
37
|
+
g, x1, y1 = _extended_gcd(b, a % b)
|
|
38
|
+
return g, y1, x1 - (a // b) * y1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _mod_inverse(a: int, mod: int) -> int:
|
|
42
|
+
g, x, _ = _extended_gcd(a, mod)
|
|
43
|
+
if g != 1:
|
|
44
|
+
raise ValueError(f"{a} n'a pas d'inverse modulo {mod}")
|
|
45
|
+
return x % mod
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def generate_keypair(p: int, q: int) -> tuple[dict, dict]:
|
|
49
|
+
"""Fourni (ex6)."""
|
|
50
|
+
n = p * q
|
|
51
|
+
g = n + 1
|
|
52
|
+
lam = (p - 1) * (q - 1)
|
|
53
|
+
mu = _mod_inverse(lam, n)
|
|
54
|
+
return {"n": n, "g": g, "n2": n * n}, {"lam": lam, "mu": mu}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _l(u: int, n: int) -> int:
|
|
58
|
+
"""Fonction L(u) = (u - 1) // n, fournie telle quelle."""
|
|
59
|
+
return (u - 1) // n
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def encrypt(pub: dict, m: int, r: int) -> int:
|
|
63
|
+
"""Chiffre l'entier m avec l'aléa r (pgcd(r, n) == 1)."""
|
|
64
|
+
# TODO: implémentez la formule de chiffrement
|
|
65
|
+
raise NotImplementedError
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def decrypt(pub: dict, priv: dict, c: int) -> int:
|
|
69
|
+
"""Déchiffre c. Gère la représentation symétrique pour les négatifs."""
|
|
70
|
+
# TODO: implémentez la formule de déchiffrement, puis la correction m > n//2
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Tests : ne modifiez pas cette section, elle valide votre implémentation.
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
import math
|
|
79
|
+
import random
|
|
80
|
+
|
|
81
|
+
P, Q = 1009, 1013
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _random_r(n: int, rng: random.Random) -> int:
|
|
85
|
+
r = rng.randrange(2, n)
|
|
86
|
+
while math.gcd(r, n) != 1:
|
|
87
|
+
r = rng.randrange(2, n)
|
|
88
|
+
return r
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_roundtrip_small_values():
|
|
92
|
+
pub, priv = generate_keypair(P, Q)
|
|
93
|
+
rng = random.Random(1)
|
|
94
|
+
for m in [0, 1, 5, 42, 1000]:
|
|
95
|
+
r = _random_r(pub["n"], rng)
|
|
96
|
+
c = encrypt(pub, m, r)
|
|
97
|
+
assert decrypt(pub, priv, c) == m
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_different_r_give_different_ciphertexts_same_plaintext():
|
|
101
|
+
pub, priv = generate_keypair(P, Q)
|
|
102
|
+
rng = random.Random(2)
|
|
103
|
+
r1, r2 = _random_r(pub["n"], rng), _random_r(pub["n"], rng)
|
|
104
|
+
c1 = encrypt(pub, 7, r1)
|
|
105
|
+
c2 = encrypt(pub, 7, r2)
|
|
106
|
+
assert c1 != c2 # aléatoire -> chiffrés différents...
|
|
107
|
+
assert decrypt(pub, priv, c1) == 7 # ... mais même message une fois déchiffré
|
|
108
|
+
assert decrypt(pub, priv, c2) == 7
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_negative_number_via_symmetric_representation():
|
|
112
|
+
pub, priv = generate_keypair(P, Q)
|
|
113
|
+
n = pub["n"]
|
|
114
|
+
rng = random.Random(3)
|
|
115
|
+
r = _random_r(n, rng)
|
|
116
|
+
# -5 est représenté comme (n - 5) dans Z_n
|
|
117
|
+
c = encrypt(pub, n - 5, r)
|
|
118
|
+
assert decrypt(pub, priv, c) == -5
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ex8 : Les propriétés homomorphes de Paillier
|
|
3
|
+
=============================================
|
|
4
|
+
|
|
5
|
+
Voici le cœur du sujet. Sans jamais déchiffrer, on veut :
|
|
6
|
+
- additionner deux messages chiffrés ;
|
|
7
|
+
- multiplier un message chiffré par un scalaire clair.
|
|
8
|
+
|
|
9
|
+
Avec ces deux opérations on peut déjà calculer une somme pondérée
|
|
10
|
+
bias + Σ wᵢ·xᵢ, c'est-à-dire un score linéaire.
|
|
11
|
+
|
|
12
|
+
Les propriétés (tout est modulo n2 = n*n) :
|
|
13
|
+
- Addition : E(a) * E(b) == E(a + b)
|
|
14
|
+
-> add_encrypted(pub, c1, c2) = (c1 * c2) % n2
|
|
15
|
+
- Multiplication par un scalaire k clair : E(a) ** k == E(k * a)
|
|
16
|
+
-> mul_by_scalar(pub, c, k) :
|
|
17
|
+
si k >= 0 : pow(c, k, n2)
|
|
18
|
+
si k < 0 : il faut l'inverse de c modulo n2, élevé à -k :
|
|
19
|
+
pow(_mod_inverse(c, n2), -k, n2)
|
|
20
|
+
|
|
21
|
+
Les fonctions des exercices précédents (clés, chiffrement, déchiffrement) sont
|
|
22
|
+
fournies ci-dessous. Vous n'implémentez que add_encrypted et mul_by_scalar.
|
|
23
|
+
|
|
24
|
+
Testez avec : fhecli run ex8_paillier_homomorphic
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# I AM NOT DONE
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _extended_gcd(a: int, b: int) -> tuple[int, int, int]:
|
|
31
|
+
if b == 0:
|
|
32
|
+
return a, 1, 0
|
|
33
|
+
g, x1, y1 = _extended_gcd(b, a % b)
|
|
34
|
+
return g, y1, x1 - (a // b) * y1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _mod_inverse(a: int, mod: int) -> int:
|
|
38
|
+
g, x, _ = _extended_gcd(a, mod)
|
|
39
|
+
if g != 1:
|
|
40
|
+
raise ValueError(f"{a} n'a pas d'inverse modulo {mod}")
|
|
41
|
+
return x % mod
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def generate_keypair(p: int, q: int) -> tuple[dict, dict]:
|
|
45
|
+
n = p * q
|
|
46
|
+
g = n + 1
|
|
47
|
+
lam = (p - 1) * (q - 1)
|
|
48
|
+
mu = _mod_inverse(lam, n)
|
|
49
|
+
return {"n": n, "g": g, "n2": n * n}, {"lam": lam, "mu": mu}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _l(u: int, n: int) -> int:
|
|
53
|
+
return (u - 1) // n
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def encrypt(pub: dict, m: int, r: int) -> int:
|
|
57
|
+
n, n2 = pub["n"], pub["n2"]
|
|
58
|
+
m = m % n
|
|
59
|
+
return ((1 + m * n) % n2) * pow(r, n, n2) % n2
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def decrypt(pub: dict, priv: dict, c: int) -> int:
|
|
63
|
+
n, n2 = pub["n"], pub["n2"]
|
|
64
|
+
u = pow(c, priv["lam"], n2)
|
|
65
|
+
m = (_l(u, n) * priv["mu"]) % n
|
|
66
|
+
if m > n // 2:
|
|
67
|
+
m -= n
|
|
68
|
+
return m
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def add_encrypted(pub: dict, c1: int, c2: int) -> int:
|
|
72
|
+
"""Renvoie E(a + b) à partir de E(a) = c1 et E(b) = c2."""
|
|
73
|
+
# TODO: implémentez l'addition homomorphe
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def mul_by_scalar(pub: dict, c: int, k: int) -> int:
|
|
78
|
+
"""Renvoie E(k * a) à partir de E(a) = c et d'un scalaire clair k (k peut être négatif)."""
|
|
79
|
+
# TODO: implémentez la multiplication par un scalaire (gérez k négatif)
|
|
80
|
+
raise NotImplementedError
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Tests : ne modifiez pas cette section, elle valide votre implémentation.
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
import math
|
|
88
|
+
import random
|
|
89
|
+
|
|
90
|
+
P, Q = 1009, 1013
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _random_r(n: int, rng: random.Random) -> int:
|
|
94
|
+
r = rng.randrange(2, n)
|
|
95
|
+
while math.gcd(r, n) != 1:
|
|
96
|
+
r = rng.randrange(2, n)
|
|
97
|
+
return r
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_homomorphic_addition():
|
|
101
|
+
pub, priv = generate_keypair(P, Q)
|
|
102
|
+
rng = random.Random(10)
|
|
103
|
+
c3 = encrypt(pub, 3, _random_r(pub["n"], rng))
|
|
104
|
+
c5 = encrypt(pub, 5, _random_r(pub["n"], rng))
|
|
105
|
+
c_sum = add_encrypted(pub, c3, c5)
|
|
106
|
+
assert decrypt(pub, priv, c_sum) == 8
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_homomorphic_addition_is_commutative():
|
|
110
|
+
pub, priv = generate_keypair(P, Q)
|
|
111
|
+
rng = random.Random(11)
|
|
112
|
+
c_a = encrypt(pub, 17, _random_r(pub["n"], rng))
|
|
113
|
+
c_b = encrypt(pub, 25, _random_r(pub["n"], rng))
|
|
114
|
+
assert decrypt(pub, priv, add_encrypted(pub, c_a, c_b)) == decrypt(
|
|
115
|
+
pub, priv, add_encrypted(pub, c_b, c_a)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_mul_by_positive_scalar():
|
|
120
|
+
pub, priv = generate_keypair(P, Q)
|
|
121
|
+
rng = random.Random(12)
|
|
122
|
+
c4 = encrypt(pub, 4, _random_r(pub["n"], rng))
|
|
123
|
+
assert decrypt(pub, priv, mul_by_scalar(pub, c4, 3)) == 12
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_mul_by_negative_scalar():
|
|
127
|
+
pub, priv = generate_keypair(P, Q)
|
|
128
|
+
rng = random.Random(13)
|
|
129
|
+
c4 = encrypt(pub, 4, _random_r(pub["n"], rng))
|
|
130
|
+
assert decrypt(pub, priv, mul_by_scalar(pub, c4, -2)) == -8
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_weighted_sum_like_scoring():
|
|
134
|
+
pub, priv = generate_keypair(P, Q)
|
|
135
|
+
rng = random.Random(14)
|
|
136
|
+
features = [100, 20, 3]
|
|
137
|
+
weights = [2, -5, -10]
|
|
138
|
+
ciphertexts = [encrypt(pub, x, _random_r(pub["n"], rng)) for x in features]
|
|
139
|
+
terms = [mul_by_scalar(pub, c, w) for c, w in zip(ciphertexts, weights)]
|
|
140
|
+
total = terms[0]
|
|
141
|
+
for t in terms[1:]:
|
|
142
|
+
total = add_encrypted(pub, total, t)
|
|
143
|
+
expected = sum(w * x for w, x in zip(weights, features))
|
|
144
|
+
assert decrypt(pub, priv, total) == expected
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ex9 : Un score bancaire chiffré de bout en bout
|
|
3
|
+
================================================
|
|
4
|
+
|
|
5
|
+
Synthèse de tout le module Paillier. On calcule un score de crédit
|
|
6
|
+
score = bias + Σ wᵢ · featureᵢ
|
|
7
|
+
où les features (revenu, ratio d'endettement, incidents...) sont chiffrées
|
|
8
|
+
par le client. Le serveur calcule le score SANS jamais voir une seule feature
|
|
9
|
+
en clair : il ne manipule que des chiffrés. Seul le score final est déchiffré.
|
|
10
|
+
|
|
11
|
+
C'est précisément le scénario du mémoire : les données restent chiffrées
|
|
12
|
+
pendant le calcul (le troisième état, « en cours d'utilisation »).
|
|
13
|
+
|
|
14
|
+
Tout l'outillage est fourni (clés, chiffrement, déchiffrement, add_encrypted,
|
|
15
|
+
mul_by_scalar, et _random_r pour tirer un aléa valide). Vous implémentez
|
|
16
|
+
encrypted_score.
|
|
17
|
+
|
|
18
|
+
À implémenter :
|
|
19
|
+
- encrypted_score(pub, priv, features, weights, bias) -> int
|
|
20
|
+
Étapes :
|
|
21
|
+
1. Chiffrer chaque feature : encrypt(pub, x, _random_r(pub["n"])).
|
|
22
|
+
2. Pour chaque chiffré et son poids : mul_by_scalar(pub, c, w).
|
|
23
|
+
3. Partir d'un chiffré du biais : total = encrypt(pub, bias, 1).
|
|
24
|
+
4. Additionner tous les termes à total avec add_encrypted.
|
|
25
|
+
5. Déchiffrer total à la toute fin et le renvoyer.
|
|
26
|
+
|
|
27
|
+
Testez avec : fhecli run ex9_scoring_paillier
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import math
|
|
31
|
+
import random
|
|
32
|
+
|
|
33
|
+
# I AM NOT DONE
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _extended_gcd(a: int, b: int) -> tuple[int, int, int]:
|
|
37
|
+
if b == 0:
|
|
38
|
+
return a, 1, 0
|
|
39
|
+
g, x1, y1 = _extended_gcd(b, a % b)
|
|
40
|
+
return g, y1, x1 - (a // b) * y1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _mod_inverse(a: int, mod: int) -> int:
|
|
44
|
+
g, x, _ = _extended_gcd(a, mod)
|
|
45
|
+
if g != 1:
|
|
46
|
+
raise ValueError(f"{a} n'a pas d'inverse modulo {mod}")
|
|
47
|
+
return x % mod
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def generate_keypair(p: int, q: int) -> tuple[dict, dict]:
|
|
51
|
+
n = p * q
|
|
52
|
+
g = n + 1
|
|
53
|
+
lam = (p - 1) * (q - 1)
|
|
54
|
+
mu = _mod_inverse(lam, n)
|
|
55
|
+
return {"n": n, "g": g, "n2": n * n}, {"lam": lam, "mu": mu}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _l(u: int, n: int) -> int:
|
|
59
|
+
return (u - 1) // n
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def encrypt(pub: dict, m: int, r: int) -> int:
|
|
63
|
+
n, n2 = pub["n"], pub["n2"]
|
|
64
|
+
m = m % n
|
|
65
|
+
return ((1 + m * n) % n2) * pow(r, n, n2) % n2
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def decrypt(pub: dict, priv: dict, c: int) -> int:
|
|
69
|
+
n, n2 = pub["n"], pub["n2"]
|
|
70
|
+
u = pow(c, priv["lam"], n2)
|
|
71
|
+
m = (_l(u, n) * priv["mu"]) % n
|
|
72
|
+
if m > n // 2:
|
|
73
|
+
m -= n
|
|
74
|
+
return m
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def add_encrypted(pub: dict, c1: int, c2: int) -> int:
|
|
78
|
+
return (c1 * c2) % pub["n2"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def mul_by_scalar(pub: dict, c: int, k: int) -> int:
|
|
82
|
+
n2 = pub["n2"]
|
|
83
|
+
if k >= 0:
|
|
84
|
+
return pow(c, k, n2)
|
|
85
|
+
return pow(_mod_inverse(c, n2), -k, n2)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _random_r(n: int) -> int:
|
|
89
|
+
"""Fourni : tire un aléa r valide (2 <= r < n, pgcd(r, n) == 1)."""
|
|
90
|
+
r = random.randrange(2, n)
|
|
91
|
+
while math.gcd(r, n) != 1:
|
|
92
|
+
r = random.randrange(2, n)
|
|
93
|
+
return r
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def encrypted_score(pub: dict, priv: dict, features: list[int], weights: list[int], bias: int) -> int:
|
|
97
|
+
"""
|
|
98
|
+
Calcule bias + Σ wᵢ·featureᵢ SANS déchiffrer les features individuelles :
|
|
99
|
+
seul le score final est déchiffré, à la toute fin.
|
|
100
|
+
"""
|
|
101
|
+
# TODO: suivez les 5 étapes décrites dans l'énoncé
|
|
102
|
+
raise NotImplementedError
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Tests : ne modifiez pas cette section, elle valide votre implémentation.
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
P, Q = 1009, 1013
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_matches_plaintext_computation():
|
|
113
|
+
pub, priv = generate_keypair(P, Q)
|
|
114
|
+
features = [4000 - 2500, 30, 1] # revenu centré, ratio, incidents
|
|
115
|
+
weights = [5, -9, -10] # poids « en points x 1000 » comme dans la démo
|
|
116
|
+
bias = 50
|
|
117
|
+
|
|
118
|
+
result = encrypted_score(pub, priv, features, weights, bias)
|
|
119
|
+
expected = bias + sum(w * x for w, x in zip(weights, features))
|
|
120
|
+
assert result == expected
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_matches_plaintext_computation_other_profile():
|
|
124
|
+
pub, priv = generate_keypair(P, Q)
|
|
125
|
+
features = [1000 - 2500, 55, 4]
|
|
126
|
+
weights = [5, -9, -10]
|
|
127
|
+
bias = 50
|
|
128
|
+
|
|
129
|
+
result = encrypted_score(pub, priv, features, weights, bias)
|
|
130
|
+
expected = bias + sum(w * x for w, x in zip(weights, features))
|
|
131
|
+
assert result == expected
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_handles_negative_final_score():
|
|
135
|
+
pub, priv = generate_keypair(P, Q)
|
|
136
|
+
features = [1000 - 2500, 60, 5]
|
|
137
|
+
weights = [5, -9, -10]
|
|
138
|
+
bias = 50
|
|
139
|
+
|
|
140
|
+
result = encrypted_score(pub, priv, features, weights, bias)
|
|
141
|
+
expected = bias + sum(w * x for w, x in zip(weights, features))
|
|
142
|
+
assert expected < 0 # vérification du scénario choisi
|
|
143
|
+
assert result == expected
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
FHECLI-SOLUTION/1
|
|
2
|
+
eNpdkcFqGzEQhu/7FNPNoevgLE4ooTX4FBZSQkvBvRRjhCzNOsJaSWhGrjdvlL6GX6y763UcItBB
|
|
3
|
+
+r/559coz/Olt4mNd6AR4vG1Pr5GdAoh+BQBD7czoXY7EhvvEpVQEckWX0B/lhsfNQQZYe8T3TTH
|
|
4
|
+
fw3CpzzPs4xjO8+gW6YJPjIwOkJpQRIwZXhQGBi+D1oVo49zgCsYGoAPfRaHFuYQTADjiKW1kNfP
|
|
5
|
+
eKOsWfVh1vngzgQL+OkdZlmmsYZG7lAo7xgPXExOCcZjBzKVZ21QTg7l8uGx+lGJ339+VeXD09Ny
|
|
6
|
+
+iYGb1vReJ1sIqFxGxEXX2+/3V0I5bGue0RsDAsyL0iL1f1sCl/GfT9bn+jJ+zDlFh1GySi20npD
|
|
7
|
+
YoctFR8Q6zfSClLSYhf+Dq6vO8cBicgpujM5vr37stgGRi20ZxFiF1txMTJT2EubkKbwF832mWkc
|
|
8
|
+
Tlck9qjYx3E+/UefLj6WTt4KIlKy/UAv1WXXszhbv8944UuNQ8Bispqts/86Pc5Q
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
FHECLI-SOLUTION/1
|
|
2
|
+
eNqVUEtOwzAQ3fsUD0sIRySVYMEiokhdcAJ2bCLXmahGrR2NHdT2RuUauRhxGqAgEOosRvLTPL+P
|
|
3
|
+
lPLJr7tovUNN4P7Q9AcmZwit7xi0vakMBc0zPIagd7RHfaWXnmu0mvHqu1Bs+rcN4UJKKYSoqUEV
|
|
4
|
+
VraJlVlpVqZEiJxjhEpYFzMUDwkrBYaxDaSWuJ/DpCX38oinWepAmGMQU8NNNuK0TozFCeP5D8bi
|
|
5
|
+
kxHo64IpduxgxOljxWpkXkMlqhksYgJG3xkucXuXTfGMToVUQ0m8a6OKtI3/hJx0pJy9eOvUt37y
|
|
6
|
+
D4nG85DIOqQPf2jVdK7WLyZzFEcp8Q7UuZgN
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
FHECLI-SOLUTION/1
|
|
2
|
+
eNqNUUtOwzAU3OcUgzfY0FK1y0qw6wmQ2CCInOSlWDix9eJUSW9UrtGLkU8/YYHAG7/nN+OZsYUQ
|
|
3
|
+
z87WwbgSGYGPh/x4YCpTgnc1g5pV3DiOC20t6cRYE9oHbKpKt7RHdqsTxxm8ZuxcXc2L41dBuBFC
|
|
4
|
+
RFGUUY6em7SBKqnXGIoZklOlMH8aq3WEbjGFmsvxRDZ4R4vcMZpZV5gSe+Ol7thKTe7unHLrgyyo
|
|
5
|
+
s7Sli8Yntb+omBylCwNg6AdlbSrCi7Y1bZgdS9GNUdRVGLAJgQofWqFOPj3pQFncgx4h++0O0lJ5
|
|
6
|
+
tqGwWKDvu5HCPZZKva4xBbxNE18f6TSe/dCY5s1ozJsa/0EcqAn/iDyROb/XlT8QzxJMO9J2+HGX
|
|
7
|
+
x95qU/aYSqbLi066+ltmTJMue7SKvgF3n8vr
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
FHECLI-SOLUTION/1
|
|
2
|
+
eNp9kU1ugzAQRvc+xdSbgpREkbpDSndF6rrZVS1y8NAgwLbGJop7o/QauVjBdqP+e4EYzzfvYcM5
|
|
3
|
+
f9D96FqtQCLQ+dScT4SqRjB6JMDjTdUgKXSVE4NBWsGdtcLjK8hrsdMkwQiCgx7tcji/DQhXnHPG
|
|
4
|
+
GtID1OSN0y8kzN6vIgXawWhyUIZqAffqIPpWbnWHijEmsYFJPs9VU2PELDwLsI4W0KEvYOcd2hyW
|
|
5
|
+
t/GtYDAtQjeSStRsyuWrhImAudISszxPEomfJW7WJ/QPzaT+U5IwERDKYEmSeGOVULKq91h3/3t2
|
|
6
|
+
WvdRJIUTsAktQSR8wl96j+sneN7A+liWYS96UKYZm82hGHfkI3Nev355ms0vqY9jit5i2MRjjcZ9
|
|
7
|
+
+VXF9/SWRmTvnBnDcQ==
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
FHECLI-SOLUTION/1
|
|
2
|
+
eNp1jksOwjAMRPc5xRCJr/gVsapUdpyAA1SFuCJS21RJw+9G5Rq9GEmoBCzwxuNJ5tmc84MqbCNV
|
|
3
|
+
BUHQXZt3rabqRKiV1aDbNi2VqNV1ib0x2Z0eEOPsqLRAnWlclDWLsnuWhAHnnDEmKIdLpC4yOWaG
|
|
4
|
+
YsiqmTtQ3Sv3GNQUi53vMYMrTcYWDRJEYfRJN4Q29JHgXs+yII/CDut3zpfMgzdC9PF+mJNezQJw
|
|
5
|
+
+kX82hWu/fPF41erBJv+1sbqqsezF2eSXfQ=
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
FHECLI-SOLUTION/1
|
|
2
|
+
eNpdUEFugzAQvPOKiaUqUEETDrlEore8oFIvVYVMvCAkYiODI1LEg9Jv8LEuNEmT7sGrHe/s7KwQ
|
|
3
|
+
4s1Uri2NhiLY8ZyPZ0t6T6iNs6Bukx6MKvXxBbumkSf6glrKzFiFWlocjWuiw/h9ICyEEJ7nKcqZ
|
|
4
|
+
1ZJWpNJir3y5RanbENmcA0SvaF1d0ceMXp/PrQeOMkeGJMH6t5zCUuushgwRh1jPcBGi4+IUI3nU
|
|
5
|
+
ykJIPCELvDtmMXVODETwJVYr/sczY5dt2V/KBsk2dFuWsb91OW9vuiHS/6py7g+uBgosEsR3BmTZ
|
|
6
|
+
EN5l5WhnrbF+Lno5QC8ln7Dhc17EpyGuMug5D/BrHp30xRCIBzcd++MG7wfYpIRI
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
FHECLI-SOLUTION/1
|
|
2
|
+
eNplUUtOwzAQ3ecUD0uIFFwgLFhUKjtOgMQGocipp5FF4qROXLVUPRBcoxdj7KYQwAuPZzyf994I
|
|
3
|
+
IZ6ayvemsdAEd/hYHj4c2QWhbbwDbe7zVpmqMuTyN9qWZK/x2HVqS+/QF6ponEarHNaN76b14bMm
|
|
4
|
+
nAkhkiTRtEROm56sJp2XC52qGYztJYpoJ5g+oPdtRS8xerpeZwn4mCUKzOe4PbrhOOq9s1ASmcRt
|
|
5
|
+
DJcSG3a2GeZ/hhUSCucoJsmotAypoQRTpAo3N/yPS46d8NaNzo1dk+voGy7HfgCznX1Plsj/zVWx
|
|
6
|
+
YHLiUOJsjmzEQZmO8KwqT4/ONS5dip3aw14olrFjSYfpoYmvGuzY7sUvEhumxdEBMm+EnOopbIc3
|
|
7
|
+
5dJ2gL36q7I2Cw6He5DYMviW6a+OhNizuEIWvUrV7KctC5UFidJVfMW/2gfWY6k4W8Ief1tfVGYR
|
|
8
|
+
4HDWTlgxg5UQJVvWTNi7EOCOdn9Md2Y9wI/53IoTYkNRe37Wfj9m/9NejmuTL6Yb0qQ=
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
FHECLI-SOLUTION/1
|
|
2
|
+
eNqdU8GO2jAQvecr3lqq1rSmu+FSCYm97RdU6gWhyElMFDV2jBNTKOKDtr/Bj3XsBBbonpqD7Znx
|
|
3
|
+
zLz3xmGMfW8b39etQangTm/r05tTplCwrXdQu2+ZlXXT1Mpl5M5KVXzFa9fJvfqN8lHmrSthpcO2
|
|
4
|
+
9d1Un/5ohQfGWJIkpVojU7temVKVWVWUXM5Rm14gj/sE0xf03jZqGb3nZTVPQF+9Ro7FAs+DGT6n
|
|
5
|
+
eu8MpEAq8BzdlcCOjH2KxV2zXEDiE/JJcpVahashBVNwiacniuMz+c54dVtmtdkq16kLXPK9A6Z9
|
|
6
|
+
fukskP3TV8aEyZlDhYcF0isOsu4UfsjGq1fnWsfX7CCPMI+SZOxI0rF7KOKbFgfaj+yGxI5okXeE
|
|
7
|
+
XCmjnOxV9lPtaVSO2xH25l7lsi7IHdZRYkPgLdHfDITIMviCNFqN1GRzS0KlQSK+iacY0z6wvpaK
|
|
8
|
+
bguYG5QHZtgcRoBVtJNazMyCg2qZo6Aw5ZAdM5n2dNT+eB5Dw/3Iwnwg/diB+wEcTdGMifRE3d72
|
|
9
|
+
3Hp6YwNfPRZyHxQidGYWRPD5kuCuxHiasdVAlGKa1DY3bXlKKunAYxJisyCPbX9xJ4aCg3dERD/M
|
|
10
|
+
PSLr6u35XPwHqqB+6FcMpZZRyVXsfEHNg4RhJAFbvEQSryYXLvQyNV5oGiTe7P11akwXt2x18hcu
|
|
11
|
+
2yi7
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
FHECLI-SOLUTION/1
|
|
2
|
+
eNqdlMGO2jAQhu88xaylak1ruhtOFRLc9gkq9YJQ5MQmGxE7xokpFPFA29fgxTp2HCC7aA9NpNge
|
|
3
|
+
2zP//9kKIeRnXbm2rDUICfb8tj6/WalzCaZ2FuT+R2p4WVWltOlrrfC15rXMv8NL0/CD/APikWe1
|
|
4
|
+
FWC4hV3tmok6/1USHggho9FIyDWkct9KLaRIi1xQPoNStwyy0I5hsoDWmUouQ7T/rGYjwKdcQwbz
|
|
5
|
+
OTx3Q/9Y2TqrgTNIGDyHcMFgj4NDAvN3xTIGHL5ANh7dbC38Ur8FJkA5PD3hPHzFWK9X1SIt9U7a
|
|
6
|
+
Rl7kYuwqGNvZpTKD9ENdHjaMew8FPMwhufHAy0bCL145+WJtbemaHPkJ9CNHjA0ijdV9ElfVcMT2
|
|
7
|
+
RAYm9mgLo1FyIbW0vJXpRh7wuCw1Ufb2PWVR5hj234hYo3iD9redIRxp+AZJGFVc4ZgaBJV4RHQb
|
|
8
|
+
emFOOe/6FhWuZqAHKo9EkxloBqTAFmkRPfUBzKVPDKdxD47DTqIcdpU79cdQURdd6DvoYwXqOnF4
|
|
9
|
+
ijpuxNtrD6alxuEd6/yqmMjeSYTq9NRDcNkS5a5Y7E3JqjOKcwpp60FZmiAl5X2M/dzU4zH1b2pZ
|
|
10
|
+
l7CLRkVCflBkbLnr+/l/qPL0fb28S7UMJFeh8kU19Qj9kXhtYREiXo0vXvBmKljgaSC86fV2KpjM
|
|
11
|
+
h25V9MGFSCNdKW7d5EkEnE8/Oao8QSF5QHM102VWrkqzQ9rkvOJ2kDkm3txjdAXUY0FLG1jc+2FE
|
|
12
|
+
WJsropuJwT3OwxIGk7j2HzSZck8=
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
FHECLI-SOLUTION/1
|
|
2
|
+
eNqdVEtuo0AQ3XOKCtIoMIOT4N1YcnY5wUizsSzUQBm3TDekgcRO5ANlrpGLTfUHDLYni0Gy+1tV
|
|
3
|
+
772qLt/3f1Vl1/JKQo6gPj82nx8KZYZQV50C3P9MmqxSXBZJzXhZclR38NQ07IBvkN+ytFI51EzB
|
|
4
|
+
S9U1M/H5RyDc+L7veVzUlWpBsHbbzxWTeSU8z8txAwnuW5Q55kmR5QFbAJdtBKkZQ5g9QtvVJa7M
|
|
5
|
+
bv+3XnhAH99ACsslPNil/hS2nZLAIogjeDDbRQR7WhxiWJ4FSyNg8A3S0BuZFvqqNoEZBAzu7+kc
|
|
6
|
+
vtNej1dUecLlC6oGB7i0dwJM42KIHEFyEZcZg7DnUMDNEuIRB8YbhN+s7PBJqUoFG/+dHUHeMlK4
|
|
7
|
+
IbVddO2kKyt4p/HoT0jsiRbtOsgFSlSsxWSHB8qeCmoH+/lc5ZxntK3/ncSSwNdE/9kSopWEHxCb
|
|
8
|
+
VckErYOahIq1RMGzmZkz0WnWY6nodgRygvLdl/4CZAR+QSOp5cu53iBf8hjRMdnQ2lj6oqOp6I59
|
|
9
|
+
GsqgcyzkFeldhKCz4CiL0hlSUatD3QZ1RzVm+QrnSF1xROjkXIvQpSuCu47cbO6vLVE6E6S2nIQN
|
|
10
|
+
YlJJaB6hPptreerqNVCRdWh3HaIcLxDVir/08+w/UGn1dbzMuloZJdcm8oA60BLqlGhs5hJJvA4H
|
|
11
|
+
LlSZAh4pGyTe/FSdAmbLKdv+JbM8T5y6mI/ZZLETOJt/kaosJiCZkeZExnoWXZmkB+pArGRq4tk5
|
|
12
|
+
3l3T6CRQLwtR2sHjtYbhxNqdJBodTOo4M1cimLm7rhxtT0tUcLUaCYm9cKcH+hUYzIfn8LrlJZoO
|
|
13
|
+
eafbgzJJOesJX3twYNW0xqnd6KaN/6qsDTIyw4aeGG9a3WKpRF6RF9t2updy1lyhlfF6i6ql3tYQ
|
|
14
|
+
vNXoZdnGN2jSl2kYwqZS1J24HILbzLSohHFykWpKMoGyhnqmbd94HYyCD6BDKhjjrWpZSd4miDSJ
|
|
15
|
+
qO9P2lurXZnAJ517y4tajuwRDaE3lnz0eK247mLo/QU6cCRW
|
fhecli/info.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Liste des exercices (info.toml embarqué dans le paquet)."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fhecli import resources
|
|
8
|
+
from fhecli.workspace import EXERCISES_DIR
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Exercise:
|
|
13
|
+
name: str
|
|
14
|
+
category: str
|
|
15
|
+
title: str
|
|
16
|
+
difficulty: str
|
|
17
|
+
bonus: bool
|
|
18
|
+
hint: str
|
|
19
|
+
path: Path # fichier de l'étudiant, dans son espace de travail
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def exercise_names() -> list[str]:
|
|
23
|
+
return [item["name"] for item in tomllib.loads(resources.info_text())["exercises"]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_exercises(workspace: Path) -> list[Exercise]:
|
|
27
|
+
data = tomllib.loads(resources.info_text())
|
|
28
|
+
return [
|
|
29
|
+
Exercise(
|
|
30
|
+
name=item["name"],
|
|
31
|
+
category=item["category"],
|
|
32
|
+
title=item["title"],
|
|
33
|
+
difficulty=item.get("difficulty", ""),
|
|
34
|
+
bonus=item.get("bonus", False),
|
|
35
|
+
hint=item.get("hint", ""),
|
|
36
|
+
path=workspace / EXERCISES_DIR / f"{item['name']}.py",
|
|
37
|
+
)
|
|
38
|
+
for item in data["exercises"]
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_exercise(workspace: Path, name: str) -> Exercise | None:
|
|
43
|
+
for ex in load_exercises(workspace):
|
|
44
|
+
if ex.name == name:
|
|
45
|
+
return ex
|
|
46
|
+
return None
|
fhecli/resources.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Accès aux données embarquées dans le paquet (lecture seule).
|
|
2
|
+
|
|
3
|
+
Les énoncés d'origine, la liste des exercices et les solutions encodées sont
|
|
4
|
+
livrés avec le paquet (dossier fhecli/data). Ils ne sont jamais modifiés :
|
|
5
|
+
l'étudiant travaille sur une copie, dans son espace de travail (`fhecli init`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
|
|
10
|
+
from fhecli import solutions
|
|
11
|
+
|
|
12
|
+
DATA = files("fhecli") / "data"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def info_text() -> str:
|
|
16
|
+
return (DATA / "info.toml").read_text(encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def original_text(name: str) -> str:
|
|
20
|
+
return (DATA / "originals" / f"{name}.py").read_text(encoding="utf-8")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def has_solution(name: str) -> bool:
|
|
24
|
+
return (DATA / "solutions" / f"{name}.sol").is_file()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def solution_text(name: str) -> str:
|
|
28
|
+
return solutions.decode((DATA / "solutions" / f"{name}.sol").read_text(encoding="ascii"))
|
fhecli/runner.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Exécution des tests d'un exercice, dans un sous-processus isolé."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
MARKER = "I AM NOT DONE"
|
|
9
|
+
TIMEOUT_SECONDS = 20
|
|
10
|
+
|
|
11
|
+
# Sous-commande interne : dans un exécutable PyInstaller, sys.executable est
|
|
12
|
+
# le binaire fhecli lui-même (il n'y a pas forcément de Python installé sur la
|
|
13
|
+
# machine). Le binaire se relance donc avec cette sous-commande, qui exécute le
|
|
14
|
+
# pytest embarqué.
|
|
15
|
+
PYTEST_SUBCOMMAND = "_run-pytest"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_frozen() -> bool:
|
|
19
|
+
return bool(getattr(sys, "frozen", False))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def pytest_command(path: Path) -> list[str]:
|
|
23
|
+
args = [str(path), "-q", "--tb=short", "--color=no"]
|
|
24
|
+
if is_frozen():
|
|
25
|
+
return [sys.executable, PYTEST_SUBCOMMAND, *args]
|
|
26
|
+
return [sys.executable, "-m", "pytest", *args]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def has_marker(path: Path) -> bool:
|
|
30
|
+
try:
|
|
31
|
+
return MARKER in path.read_text(encoding="utf-8", errors="replace")
|
|
32
|
+
except FileNotFoundError:
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_tests(path: Path) -> tuple[bool, str]:
|
|
37
|
+
"""Lance `pytest` sur ce fichier (implémentation + tests embarqués).
|
|
38
|
+
|
|
39
|
+
Renvoie (succès, sortie_texte). Un timeout est traité comme un échec,
|
|
40
|
+
pas comme une erreur fatale de l'outil (boucle infinie possible dans le
|
|
41
|
+
code de l'exercice). pytest est lancé depuis l'espace de travail, pour
|
|
42
|
+
qu'il y trouve le pytest.ini.
|
|
43
|
+
"""
|
|
44
|
+
if not path.exists():
|
|
45
|
+
return False, f"Fichier introuvable : {path}\nRecréez-le avec : fhecli reset {path.stem}"
|
|
46
|
+
env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
|
|
47
|
+
if is_frozen():
|
|
48
|
+
env["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1"
|
|
49
|
+
try:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
pytest_command(path),
|
|
52
|
+
cwd=path.parent.parent,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
errors="replace",
|
|
57
|
+
env=env,
|
|
58
|
+
timeout=TIMEOUT_SECONDS,
|
|
59
|
+
)
|
|
60
|
+
return result.returncode == 0, (result.stdout + result.stderr).strip()
|
|
61
|
+
except subprocess.TimeoutExpired:
|
|
62
|
+
return False, f"Délai dépassé ({TIMEOUT_SECONDS} s) : boucle infinie possible ?"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def status(path: Path) -> str:
|
|
66
|
+
"""Classe un exercice en 'done', 'almost' ou 'pending'."""
|
|
67
|
+
passed, _ = run_tests(path)
|
|
68
|
+
if not passed:
|
|
69
|
+
return "pending"
|
|
70
|
+
if has_marker(path):
|
|
71
|
+
return "almost"
|
|
72
|
+
return "done"
|