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.

Files changed (37) hide show
  1. fhe_cli-1.3.1.dist-info/METADATA +280 -0
  2. fhe_cli-1.3.1.dist-info/RECORD +37 -0
  3. fhe_cli-1.3.1.dist-info/WHEEL +5 -0
  4. fhe_cli-1.3.1.dist-info/entry_points.txt +2 -0
  5. fhe_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
  6. fhe_cli-1.3.1.dist-info/top_level.txt +1 -0
  7. fhecli/__init__.py +5 -0
  8. fhecli/__main__.py +6 -0
  9. fhecli/cli.py +220 -0
  10. fhecli/data/info.toml +77 -0
  11. fhecli/data/originals/ex10_ckks_bonus.py +84 -0
  12. fhecli/data/originals/ex1_cesar.py +92 -0
  13. fhecli/data/originals/ex2_xor_malleability.py +102 -0
  14. fhecli/data/originals/ex3_fernet_tamper.py +92 -0
  15. fhecli/data/originals/ex4_modpow.py +73 -0
  16. fhecli/data/originals/ex5_modinv.py +77 -0
  17. fhecli/data/originals/ex6_paillier_keygen.py +85 -0
  18. fhecli/data/originals/ex7_paillier_enc_dec.py +118 -0
  19. fhecli/data/originals/ex8_paillier_homomorphic.py +144 -0
  20. fhecli/data/originals/ex9_scoring_paillier.py +143 -0
  21. fhecli/data/solutions/ex10_ckks_bonus.sol +8 -0
  22. fhecli/data/solutions/ex1_cesar.sol +6 -0
  23. fhecli/data/solutions/ex2_xor_malleability.sol +7 -0
  24. fhecli/data/solutions/ex3_fernet_tamper.sol +7 -0
  25. fhecli/data/solutions/ex4_modpow.sol +5 -0
  26. fhecli/data/solutions/ex5_modinv.sol +6 -0
  27. fhecli/data/solutions/ex6_paillier_keygen.sol +8 -0
  28. fhecli/data/solutions/ex7_paillier_enc_dec.sol +11 -0
  29. fhecli/data/solutions/ex8_paillier_homomorphic.sol +12 -0
  30. fhecli/data/solutions/ex9_scoring_paillier.sol +15 -0
  31. fhecli/info.py +46 -0
  32. fhecli/resources.py +28 -0
  33. fhecli/runner.py +72 -0
  34. fhecli/solutions.py +30 -0
  35. fhecli/ui.py +70 -0
  36. fhecli/watch.py +116 -0
  37. 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"