# Atelier QA — JSON/CSV utilities Self-published work sample prepared for current OpenTask offers, not a customer delivery. Verification run on 2026-11-05: 33 passed in 3.43s using C:\Python314\python -m pytest test_json_to_csv.py test_csv_to_json.py -q. # FILE: json_to_csv.py #!/usr/bin/env python3 """csv_out – convertir un fichier JSON (tableau d'objets plats) en CSV. Usage:: python json_to_csv.py [entrée.json] [-o sortie.csv] Lecture depuis stdin si aucun argument fichier n'est fourni. Toutes les clefs du tableau sont fusionnées ; leur ordre est celui de première apparition dans le flux JSON. Les valeurs manquantes deviennent une chaine vide. L'encodage utilise l'UTF-8. Exemples:: # depuis un fichier python json_to_csv.py donnees.json -o resultat.csv # depuis stdin cat donnees.json | python json_to_csv.py -o resultat.csv """ import argparse import csv import io import json import sys def charger_donnees(source: str): """Renvoyer le texte brut à partir d'un nom de fichier ou de stdin.""" if source == "-": return sys.stdin.read() with open(source, encoding="utf-8") as f: return f.read() def json_to_csv(texte: str): """Convertir du JSON textuel en lignes CSV. * Le JSON doit être un tableau d'objets. * Chaque objet est supposé plat (pas de valeurs imbriquées). * Les colonnes suivent l'ordre de première apparition des clefs. * Une ligne générée une erreur de validation. """ try: donnees = json.loads(texte) except json.JSONDecodeError as exc: print(f"Erreur de validation : le contenu n'est pas un JSON valide – {exc}", file=sys.stderr) sys.exit(1) if not isinstance(donnees, list): print("Erreur de validation : le JSON racine doit être un tableau.", file=sys.stderr) sys.exit(1) # Collecter toutes les clefs dans l'ordre de première apparition clefs = [] vue_clefs = set() for index, obj in enumerate(donnees): if not isinstance(obj, dict): print( f"Erreur de validation : l'élément à l'index {index} n'est pas un objet.", file=sys.stderr, ) sys.exit(1) if any(isinstance(valeur, (dict, list)) for valeur in obj.values()): print(f"Erreur de validation : l'élément à l'index {index} contient une valeur imbriquée.", file=sys.stderr) sys.exit(1) for c in obj: if c not in vue_clefs: clefs.append(c) vue_clefs.add(c) tampon = io.StringIO() ecriteur = csv.writer(tampon, lineterminator="\n") # En-tête ecriteur.writerow(clefs) # Données for i, obj in enumerate(donnees): ligne = [] for c in clefs: val = obj.get(c) if val is None: ligne.append("") elif isinstance(val, bool): # bool avant int car bool est sous-classe de int ligne.append(str(val).lower()) elif isinstance(val, (int, float)): ligne.append(str(val)) else: ligne.append(str(val)) ecriteur.writerow(ligne) return tampon.getvalue() def principal(): analyseur = argparse.ArgumentParser( description="Convertir un tableau JSON d'objets plats en CSV." ) analyseur.add_argument( "entree", nargs="?", default="-", help="Fichier JSON (ou « - » pour stdin)" ) analyseur.add_argument( "-o", "--sortie", default=None, help="Fichier CSV de sortie (défaut : stdout)" ) args = analyseur.parse_args() texte = charger_donnees(args.entree) csv_texte = json_to_csv(texte) if args.sortie: with open(args.sortie, "w", encoding="utf-8", newline="") as f: f.write(csv_texte) else: sys.stdout.buffer.write(csv_texte.encode("utf-8")) if __name__ == "__main__": principal() # FILE: csv_to_json.py #!/usr/bin/env python3 """json_in – convertir un fichier CSV en tableau JSON. Usage:: python csv_to_json.py [entree.csv] [-o sortie.json] Lit depuis stdin si aucun argument n'est donné. Sort un tableau JSON (dictionnaires plats), encodé UTF-8. Exemples:: # depuis un fichier python csv_to_json.py donnees.csv -o resultat.json # depuis stdin cat donnees.csv | python csv_to_json.py -o resultat.json """ import argparse import csv import json import sys def charger_csv(source): """Ouvrir un fichier CSV ou stdin et renvoyer le lecteur + le fichier à fermer.""" if source == "-": return csv.reader(sys.stdin), None fichier = open(source, encoding="utf-8", newline="") return csv.reader(fichier), fichier def csv_to_json(texte_brut=None, lecteur_csv=None, detient_fichier=None): """Parser le CSV et renvoyer la liste d'objets + la liste d'erreurs. * Lecture streamée via csv.reader (gestion correcte des guillemets, virgules et retours à la ligne internes aux champs). * La première ligne contient les noms de colonnes. * Chaque ligne suivante devient un dictionnaire. * Les valeurs manquantes / vides restent des chaines vides. * Toute ligne qui n'a pas le bon nombre de colonnes est consignée dans ``erreurs`` sans bloquer le traitement global. """ if lecteur_csv is None and texte_brut is not None: lecteur_csv = csv.reader(texte_brut.splitlines(True)) detient_fichier = None resultats = [] erreurs = [] try: try: titre = next(lecteur_csv) except StopIteration: return [], ["Le fichier CSV est vide."] nb_colonnes = len(titre) for idx_ligne, ligne in enumerate(lecteur_csv, start=2): if len(ligne) != nb_colonnes: erreurs.append( f"Ligne {idx_ligne} : attendu {nb_colonnes} colonne(s), " f"{len(ligne)} trouvé(es)." ) continue objet = {} for col, val in zip(titre, ligne): objet[col.strip()] = val resultats.append(objet) return resultats, erreurs finally: if detient_fichier is not None: detient_fichier.close() def principal(): analyseur = argparse.ArgumentParser( description="Convertir un fichier CSV en tableau JSON." ) analyseur.add_argument( "entree", nargs="?", default="-", help='Fichier CSV (ou "-" pour stdin)' ) analyseur.add_argument( "-o", "--sortie", default=None, help="Fichier JSON de sortie (défaut : stdout)" ) analyseur.add_argument( "--erreurs-sortie", default=None, help="Fichier optionnel pour écrire la liste des erreurs de parsing.", ) args = analyseur.parse_args() lecteur, detient = charger_csv(args.entree) resultats, erreurs = csv_to_json(lecteur_csv=lecteur, detient_fichier=detient) # Afficher les erreurs détectées (sur stderr si pas de fichier dédié) if erreurs: if args.erreurs_sortie: with open(args.erreurs_sortie, "w", encoding="utf-8") as ef: for e in erreurs: ef.write(e + "\n") else: for e in erreurs: print(f"Avertissement : {e}", file=sys.stderr) json_texte = json.dumps(resultats, ensure_ascii=False, indent=2) + "\n" if args.sortie: with open(args.sortie, "w", encoding="utf-8") as f: f.write(json_texte) else: sys.stdout.buffer.write(json_texte.encode("utf-8")) if __name__ == "__main__": principal() # FILE: test_json_to_csv.py """Tests – json_to_csv (JSON vers CSV).""" import io import subprocess import sys from pathlib import Path import pytest # Emplacements relatifs _HERE = Path(__file__).resolve().parent _SCRIPT = _HERE / "json_to_csv.py" def run_cli(args: list[str], input_data: str | None = None, expect_fail: bool = False) -> subprocess.CompletedProcess: cmd = [str(sys.executable), str(_SCRIPT)] + args proc = subprocess.run(cmd, capture_output=True, text=True, input=input_data) if expect_fail: assert proc.returncode != 0, f"La commande aurait dû échouer : {proc.stdout}\n{proc.stderr}" else: assert proc.returncode == 0, f"Erreur inattendue : {proc.stderr}" return proc class TestJsonToCsvBasique: def simple_json(self): return '[{"nom":"Alice","age":"30"},{"nom":"Bob","age":"25"}]' def test_stdout(self): proc = run_cli([ "-", ], input_data=self.simple_json()) lignes = proc.stdout.splitlines() assert lignes[0] == 'nom,age' # csv QUOTE_MINIMAL : pas de guillemets sauf si nécessaire assert lignes[1] == 'Alice,30' assert lignes[2] == 'Bob,25' def test_fichier_sortie(self, tmp_path): entree = tmp_path / "in.json" sortie = tmp_path / "out.csv" entree.write_text(self.simple_json(), encoding="utf-8") run_cli(["-o", str(sortie), str(entree)]) contenu = sortie.read_text(encoding="utf-8") assert contenu.startswith("nom,age\r\n" if False else "nom,age\n") # LF via csv.writer lignes = contenu.strip().split("\n") assert len(lignes) == 3 class TestUnionCles: """Les clefs sont l'union de toutes les clefs des objets.""" def test_union_cles(self): data = '[{"a":"1","b":"2"},{"c":"3","a":"4"}]' proc = run_cli(["-"], input_data=data) lignes = proc.stdout.splitlines() # Union dans l'ordre de première apparition : a, b, c assert lignes[0] == "a,b,c" assert lignes[1] == "1,2," assert lignes[2] == "4,,3" def test_order_deterministe_premiere_apparition(self): data = '[{"z":1,"a":2},{"m":3,"z":4}]' proc = run_cli(["-"], input_data=data) assert proc.stdout.splitlines()[0] == "z,a,m" class TestValeursManquantes: def test_cle_manquante(self): data = '[{"nom":"Alice"},{"nom":"Bob","age":"25"}]' proc = run_cli(["-"], input_data=data) lignes = proc.stdout.splitlines() # Colonnes: nom, age ; ligne Alice n'a pas "age" assert lignes[0] == "nom,age" assert lignes[1] == 'Alice,' assert lignes[2] == 'Bob,25' def test_valeur_none_json(self): data = '[{"nom":"Alice","val":null},{"nom":"Bob","val":"5"}]' proc = run_cli(["-"], input_data=data) lignes = proc.stdout.splitlines() assert lignes[0] == "nom,val" assert lignes[1] == 'Alice,' assert lignes[2] == 'Bob,5' class TestUnicodeEtGuillemets: café_json = '[{"langue":"français","plplat":"café, crème"}]' def test_unicode_utf8(self): data = self.café_json proc = run_cli(["-"], input_data=data) raw_bytes = io.BytesIO(proc.stdout.encode("utf-8")).getvalue() # Le texte doit contenir des caractères UTF-8 valides assert "français" in proc.stdout or "fran\u00e7ais" in proc.stdout def test_comma_in_value(self): """Virgule à l'intérieur d'un champ → le champ est guillemeté.""" data = '[{"champ":"a,b,c"}]' proc = run_cli(["-"], input_data=data) assert '"a,b,c"' in proc.stdout class TestChiffresEtBooleens: def test_entier_flottant(self): data = '[{"x":42,"y":3.14}]' proc = run_cli(["-"], input_data=data) lignes = proc.stdout.splitlines() assert lignes[1] == "42,3.14" def test_booleen(self): data = '[{"ok":true,"non":false}]' proc = run_cli(["-"], input_data=data) lignes = proc.stdout.splitlines() assert "true,false" in lignes[1] class TestValidationErreurs: def test_json_invalide(self): run_cli(["-"], input_data="{pas json}", expect_fail=True) def test_tableau_attendu(self): run_cli(["-"], input_data='{"cle":"valeur"}', expect_fail=True) def test_element_non_objet(self): proc = run_cli(["-"], input_data='[{"a": 1}, "pas un objet"]', expect_fail=True) assert "index 1" in proc.stderr def test_valeur_imbriquee_refusee(self): proc = run_cli(["-"], input_data='[{"a": {"imbrique": true}}]', expect_fail=True) assert "valeur imbriquée" in proc.stderr def test_tableau_vide(self): """Un tableau vide devrait produire un CSV sans colonnes ni données.""" proc = run_cli(["-"], input_data="[]") assert proc.stdout == "\n" class TestScriptExecutable: """Exécution en tant que module Python.""" def test_module_flag_m(self): """python -m … ne fonctionne pas car le script n'est pas un pkg, mais python script.py doit.""" pass # déjà couvert par run_cli ci-dessus # FILE: test_csv_to_json.py """Tests – csv_to_json (CSV vers JSON).""" import json import subprocess import sys from pathlib import Path import pytest _HERE = Path(__file__).resolve().parent _SCRIPT = _HERE / "csv_to_json.py" def run_cli(args: list[str], input_data: str | None = None, expect_fail: bool = False) -> subprocess.CompletedProcess: cmd = [str(sys.executable), str(_SCRIPT)] + args proc = subprocess.run(cmd, capture_output=True, text=True, input=input_data) if expect_fail: assert proc.returncode != 0, f"La commande aurait dû échouer : {proc.stdout}\n{proc.stderr}" else: assert proc.returncode == 0, f"Erreur inattendue : {proc.stderr}" return proc class TestCsvToJsonBasique: def simple_csv(self): return "nom,age\nAlice,30\nBob,25" def test_stdout(self): proc = run_cli(["-"], input_data=self.simple_csv()) donnees = json.loads(proc.stdout) assert len(donnees) == 2 assert donnees[0] == {"nom": "Alice", "age": "30"} assert donnees[1] == {"nom": "Bob", "age": "25"} def test_fichier_sortie(self, tmp_path): entree = tmp_path / "in.csv" sortie = tmp_path / "out.json" entree.write_text(self.simple_csv(), encoding="utf-8") run_cli(["-o", str(sortie), str(entree)]) donnees = json.loads(sortie.read_text(encoding="utf-8")) assert len(donnees) == 2 assert donnees[0]["nom"] == "Alice" class TestValeursManquantes: def test_colonne_manquante(self): """Ligne avec moins de colonnes → consignée erreur, ignorée.""" data = "nom,age\nAlice\nBob,25" proc = run_cli(["-"], input_data=data) donnees = json.loads(proc.stdout) assert len(donnees) == 1 assert donnees[0] == {"nom": "Bob", "age": "25"} def test_colonne_supplementaire(self): """Ligne avec plus de colonnes → consignée erreur, ignorée.""" data = "nom,age\nAlice,30,extra\nBob,25" proc = run_cli(["-"], input_data=data) donnees = json.loads(proc.stdout) assert len(donnees) == 1 assert donnees[0] == {"nom": "Bob", "age": "25"} def test_chaine_vide(self): data = "nom,prenom\nAlice,\nBob,Charles" proc = run_cli(["-"], input_data=data) donnees = json.loads(proc.stdout) assert donnees[0]["prenom"] == "" assert donnees[1]["prenom"] == "Charles" def test_cells_vides_separateurs(self): """Cells vides entre séparateurs.""" data = "a,,c\n1,2,3" proc = run_cli(["-"], input_data=data) donnees = json.loads(proc.stdout) assert donnees[0] == {"a": "1", "": "2", "c": "3"} class TestUnicodeEtGuillemets: def test_caracteres_unicode(self): data = "nom,ville\nAlice,München\nBob,Joséphine" proc = run_cli(["-"], input_data=data) donnees = json.loads(proc.stdout) assert donnees[0]["nom"] == "Alice" assert donnees[0]["ville"] == "München" assert donnees[1]["nom"] == "Bob" assert donnees[1]["ville"] == "Joséphine" def test_champ_guillemete_avec_comma(self): data = 'nom,desc\nAlice,"un, deux"\nBob,trois' proc = run_cli(["-"], input_data=data) donnees = json.loads(proc.stdout) assert donnees[0]["desc"] == "un, deux" assert donnees[1]["desc"] == "trois" def test_champ_guillemete_avec_retour_ligne(self): """Champ multi-ligne correctement parsé.""" data = 'nom,texte\nAlice,"ligne1\nligne2"\nBob,simple' proc = run_cli(["-"], input_data=data) donnees = json.loads(proc.stdout) assert donnees[0]["texte"] == "ligne1\nligne2" assert donnees[1]["texte"] == "simple" def test_guillemet_échappé_dans_champ(self): """Deux guillemets consécutifs = guillemet échappé.""" data = 'nom,quote\nAlice,"elle dit ""bonjour"""\nBob,test' proc = run_cli(["-"], input_data=data) donnees = json.loads(proc.stdout) assert donnees[0]["quote"] == 'elle dit "bonjour"' assert donnees[1]["quote"] == "test" class TestSortieJson: def test_json_array_plat(self): data = "nom\nAlice\nBob" proc = run_cli(["-"], input_data=data) donnees = json.loads(proc.stdout) assert isinstance(donnees, list) assert all(isinstance(obj, dict) for obj in donnees) def test_json_ensure_ascii_false(self, tmp_path): r"""Le JSON doit encoder Unicode directement (pas d'escape \uXXXX).""" entree = tmp_path / "in.csv" sortie = tmp_path / "out.json" entree.write_text("nom\nJosé\n", encoding="utf-8") run_cli(["-o", str(sortie), str(entree)]) contenu = sortie.read_text(encoding="utf-8") assert "José" in contenu def test_indentation_json(self): data = "nom\nAlice" proc = run_cli(["-"], input_data=data) # Le JSON indenté contient des sauts de ligne et des espaces lines = proc.stdout.strip().splitlines() assert len(lines) > 1 # au moins ouverture + contenu + fermeture class TestErreursDeParsing: def test_avertissement_ligne_erronee_stderr(self): """Les erreurs de parsing vont sur stderr.""" data = "a,b\n1,2,3\n4,5" proc = run_cli(["-"], input_data=data) # Un avertissement devrait être envoyé à stderr assert "Avertissement" in proc.stderr or "ligne" in proc.stderr.lower() def test_sortie_erreurs_dediee(self, tmp_path): err_file = tmp_path / "erreurs.txt" data = "a,b\n1,2,3\n4,5" run_cli(["--erreurs-sortie", str(err_file), "-"], input_data=data) assert err_file.read_text(encoding="utf-8").strip() != "" def test_fichier_vide(self): """Fichier CSV vide → erreur consignée.""" proc = run_cli(["-"], input_data="") donnees = json.loads(proc.stdout) assert donnees == [] class TestNomsColonnesEspaces: def test_espace_supprime_noms(self): data = " nom , age \nAlice,30" proc = run_cli(["-"], input_data=data) donnees = json.loads(proc.stdout) assert "nom" in donnees[0] assert "age" in donnees[0] assert " nom " not in donnees[0] # FILE: README_json_to_csv.md # json_to_csv — Convertisseur JSON vers CSV **Entrée :** un fichier JSON contenant un tableau d'objets plats. **Sortie :** un fichier CSV (UTF-8) avec une colonne par clef distincte. ## Installation Aucune dépendance externe. Utilise uniquement la bibliothèque standard Python 3.10+. ```powershell C:\Python314\python --version # vérifier ≥ 3.10 ``` ## Utilisation rapide | Cas | Commande PowerShell | |-----|---------------------| | Depuis un fichier, sortie stdout | `C:\Python314\python json_to_csv.py donnees.json` | | Depuis un fichier, sortie fichier | `C:\Python314\python json_to_csv.py donnees.json -o resultat.csv` | | Depuis stdin | `'[{…}]' \| C:\Python314\python json_to_csv.py -o resultat.csv` | ### Options - `entree` — chemin vers le fichier JSON. Par défaut `-` (stdin). - `-o`, `--sortie` — chemin du fichier CSV. Par défaut stdout. ## Comportement 1. **Fusion des clefs** — Toutes les clefs de tous les objets sont réunies. 2. **Ordre des colonnes** — Ordre de première apparition dans le tableau JSON. 3. **Valeurs manquantes** — Une clef absente d'un objet devient une chaine vide `""` dans le CSV. 4. **Types gérés** — chaines, nombres (`int`, `float`), booléens (`true`/`false`), `null` → vide. 5. **Unicode** — Lecture et écriture en UTF-8 (caractères accentués conservés). 6. **Quoting** — La bibliothèque `csv` gère automatiquement les guillemets autour des champs qui contiennent des virgules, quotes ou retours à la ligne. 7. **Retours à la ligne** — Chaque ligne se termine par `\n` (LF). ## Validation des erreurs Le programme s'arrête sur stderr en cas de : - JSON mal formé (erreur de syntaxe JSON). - Racine non-tableau. - Élément du tableau non-objet. Un message d'erreur clair est imprimé et le code de retour est différent de zéro. ## Tests ```powershell C:\Python314\python -m pytest test_json_to_csv.py -v ``` Les tests vérifient les cas suivants : | Catégorie | Couvert ? | |-----------|-----------| | Tableau basique (stdout / fichier) | ✅ | | Union des clefs + ordre déterministe | ✅ | | Valeurs manquantes (clef absente, null) | ✅ | | Unicode (français, allemand, etc.) | ✅ | | Virgule dans un champ (guillemet) | ✅ | | Entiers, flottants, booléens | ✅ | - JSON invalide, non-tableau, élément non-objet | ✅ | | Tableau vide | ✅ | # FILE: README_csv_to_json.md # csv_to_json — Convertisseur CSV vers JSON **Entrée :** un fichier CSV (UTF-8). **Sortie :** un tableau JSON de dictionnaires plats, encodé UTF-8 indenté. ## Installation Aucune dépendance externe. Utilise uniquement la bibliothèque standard Python 3.10+. ```powershell C:\Python314\python --version # vérifier ≥ 3.10 ``` ## Utilisation rapide | Cas | Commande PowerShell | |-----|---------------------| | Depuis un fichier, sortie stdout | `C:\Python314\python csv_to_json.py donnees.csv` | | Depuis un fichier, sortie fichier | `C:\Python314\python csv_to_json.py donnees.csv -o resultat.json` | | Depuis stdin | `'nom,age\r\nAlice,30' \| C:\Python314\python csv_to_json.py -o resultat.json` | ### Options - `entree` — chemin vers le fichier CSV. Par défaut `-` (stdin). - `-o`, `--sortie` — chemin du fichier JSON. Par défaut stdout. - `--erreurs-sortie` — chemin optionnel pour escrirela liste des erreurs de parsing. ## Comportement 1. **Lecture streamée** — Le fichier CSV est lu ligne par ligne via `csv.reader`. 2. **Noms de colonnes** — La première ligne définit les noms de clefs JSON. Les espaces sont supprimés. 3. **Valeurs manquantes** — Une cellule vide reste une chaine vide `""` dans le JSON. 4. **Guillemets et virgules** — `csv.reader` gère automatiquement les champs entre guillemets (virgules internes, retours à la ligne, double-quote échappée `""`). 5. **Unicode** — Lecture et écriture en UTF-8 (`ensure_ascii=False`). 6. **Indentation** — Sortie JSON indentée sur 2 espaces. ## Gestion des erreurs de parsing - Les lignes avec un nombre de colonnes différent du titre sont consignées dans stderr (ou le fichier `--erreurs-sortie`) et ignorées. - Un fichier vide produit un tableau JSON vide `[]` sans erreur fatale. ## Tests ```powershell C:\Python314\python -m pytest test_csv_to_json.py -v ``` Les tests vérifient les cas suivants : | Catégorie | Couvert ? | |-----------|-----------| | Tableau basique (stdout / fichier) | ✅ | | Valeurs manquantes (colonnes courtes, longues, vides) | ✅ | | Unicode (München, Joséphine…) | ✅ | | Guillemet + virgule dans champ | ✅ | | Champ multi-ligne | ✅ | | Double-quote échappée (`""`) | ✅ | | JSON plat indenté, ensure_ascii=False | ✅ | | Avertissement lignes erronées, fichier dédié | ✅ | | Fichier vide | ✅ | | Suppression espaces noms de colonnes | ✅ |