Coverage for changes_metadata_manager / folder_metadata_builder.py: 96%

116 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-03-04 14:41 +0000

1import argparse 

2import re 

3from pathlib import Path 

4 

5import pyshacl 

6from rdflib import Dataset, Graph, URIRef 

7from rdflib.namespace import DCTERMS 

8from rich.console import Console 

9from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, MofNCompleteColumn 

10 

11from changes_metadata_manager.generate_provenance import generate_provenance_snapshots 

12 

13 

14BASE_URI = "https://w3id.org/changes/4/aldrovandi" 

15KG_PATH = Path("data/kg.ttl") 

16SHAPES_PATH = Path("data/shapes-chadap.ttl") 

17RESP_AGENT = "https://w3id.org/changes/4/agent/morph-kgc-changes-metadata/1.0.1" 

18PRIMARY_SOURCE = "https://doi.org/10.5281/zenodo.18190642" 

19CC0 = URIRef("https://creativecommons.org/publicdomain/zero/1.0/") 

20 

21STAGE_STEPS = { 

22 "raw": ["00"], 

23 "rawp": ["00", "01"], 

24 "dcho": ["00", "01", "02"], 

25 "dchoo": ["00", "01", "02", "03", "04", "05", "06"], 

26} 

27 

28SKIP_FOLDERS = { 

29 "S1-CNR_SoffittoSala1", 

30 "S5-B basso-DICAM_FanoneBalenaAlto", 

31 "materials", 

32 "sala 4", 

33 "_files", 

34} 

35 

36FOLDER_TO_ID = { 

37 "S3-PT-DICAM_VetrinaMatriciXilografiche": "ptb", 

38 "S3-PT-DICAM_Matrice Xilografica Fiore": "ptb_1", 

39 "S3-PT-DICAM_Matrice Xilografica Pianta": "ptb_2", 

40 "S3-PT-DICAM_Matrice Xilografica Serpente": "ptb_3", 

41 "S3-VS6-DBC_Matrice 1 egizia": "ptb_4", 

42 "S5-s.n.-DBC_Busto di Ulisse Aldrovandi": "s_n", 

43 "S4-ManicoColtelloZoomorfo": "50", 

44 "S5-CNR-AAltoCentro_TestamentoUlisseAldrovandi": "a_alto_centro", 

45 "S5-B alto destra 1-FICLIT_Mammuthus1": "b_alto_destra_1", 

46 "S5-B alto destra 1-FICLIT_Mammuthus2": "b_alto_destra_2", 

47 "S5-B basso-DICAM_FanoneBalenaBasso": "b_basso", 

48 "S5-A_alto_sinistra_1-FICLIT_Medaglia-Archeologico": "a_alto_sinistra_1", 

49 "S5-A alto sinistra\xa0- 2-FICLIT_MedagliaCommemorativa": "a_alto_sinistra_2", 

50 "S5-A alto sinistra 3- DBC_Calchi in gesso": "a_alto_sinistra_3", 

51 "S5-B alto centro 1-FICLIT_HarpactocarcinusPunctulatus": "b_alto_centro_1", 

52 "S5-B alto centro 2-DBC_Harpactocarcinus sp": "b_alto_centro_2", 

53 "S5-B alto centro - 3-FICLIT_LophoraninaAldrovandi": "b_alto_centro_3", 

54 "S5-B alto sinistra - 1-FICLIT_Carbonifero": "b_alto_sinistra_1", 

55 "S5-B alto sinistra 2-CNR_Miocene": "b_alto_sinistra_2", 

56 "S5-B alto sinistra 3-FICLIT_DentiDiPesci": "b_alto_sinistra_3", 

57 "S5-B alto destra 3-FICLIT_Hippopotamus": "b_alto_destra_3", 

58 # Vetrina 1 

59 "S5-Vetrina 1 alto N - 3-FICLIT_SonaglioThevetiaPeruviana": "vetrina_1_alto_n_3", 

60 "S5-Vetrina 1 alto N 1-FICLIT_CollanaMesoamericana": "vetrina_1_alto_n_1", 

61 "S5-Vetrina 1 alto N-2-DBC_Bambù_lavorato": "vetrina_1_alto_n_2", 

62 "S5-Vetrina 1 alto N-2-t-TavolettaConBambù": "vetrina_1_alto_n_2_t", 

63 "S5-Vetrina 1 alto S-1-FICLIT-Statuetta ushabti in faïence": "vetrina_1_alto_s_1", 

64 "S5-Vetrina 1 alto S-10-DICAM_GemmaInDiasproGialloConCinocefaloeIscrizione": "vetrina_1_alto_s_10", 

65 "S5-Vetrina 1 alto S-11-DICAM_GemmaInDiasproConScorpione": "vetrina_1_alto_s_11", 

66 "S5-Vetrina 1 alto S-12-DICAM_GemmaInDiasproNeroConIscrizioneAraba": "vetrina_1_alto_s_12", 

67 "S5-Vetrina 1 alto S-14-FICLIT_PuntaFrecciaNeolitico": "vetrina_1_alto_s_14", 

68 "S5-Vetrina 1 alto S-15-DBC_Corno_lavorato": "vetrina_1_alto_s_15", 

69 "S5-Vetrina 1 alto S-16-DBC_Ascia_di_giadeite": "vetrina_1_alto_s_16", 

70 "S5-Vetrina 1 alto S-3-DICAM_ScarabeoInStileEgizio": "vetrina_1_alto_s_3", 

71 "S5-Vetrina 1 alto S-4-DICAM_GemmaInAgataConCapraPressoAlbero": "vetrina_1_alto_s_4", 

72 "S5-Vetrina 1 alto S-5-DICAM_GemmaInAgataconMascheraDellaCommediaDell’Arte": "vetrina_1_alto_s_5", 

73 "S5-Vetrina 1 alto S-6-DICAM_GemmaInAgataConSerapideInTrono": "vetrina_1_alto_s_6", 

74 "S5-Vetrina 1 alto S-7-DICAM_GemmaInAgataConUccello": "vetrina_1_alto_s_7", 

75 "S5-Vetrina 1 alto S-8-DICAM_GemmaInAgataConMercurioeFontana": "vetrina_1_alto_s_8", 

76 "S5-Vetrina 1 alto S-9-DICAM_GemmainPastaVitreaStratificataconFiguraAppoggiataAunBastone": "vetrina_1_alto_s_9", 

77 "S5-Vetrina 1 basso-DICAM_Carapaci": "vetrina_1_basso", 

78 "S5-vetrina_1_alto_s_2-FICLIT_Lucerna fittile a volute con spalla decorata": "vetrina_1_alto_s_2", 

79 "S5-vetrina_1_alto_s_13-FICLIT_Sferule di avorio e calcare": "vetrina_1_alto_s_13", 

80 # Vetrina 2 

81 "S5-Vetrina 2 ALTO N 3-FICLIT_SezioneDenteDiElefante": "vetrina_2_alto_n_3", 

82 "S5-Vetrina 2 alto N - 1-DICAM_Calcoli": "vetrina_2_alto_n_1", 

83 "S5-Vetrina 2 alto N - 3 - t-DICAM_MatriceElefante": "vetrina_2_alto_n_3_t", 

84 "S5-Vetrina 2 alto N-1-t1-DBC_Tavoletta_con_calcolo_1": "vetrina_2_alto_n_1_t1", 

85 "S5-Vetrina 2 alto N-1-t2-DBC_Tavoletta_con_calcolo_2": "vetrina_2_alto_n_1_t2", 

86 "S5-Vetrina 2 alto S - 1 - t-DICAM_MatriceZanna": "vetrina_2_alto_s_1_t", 

87 "S5-Vetrina 2 alto S - 1-DICAM_ZanneDiElefante": "vetrina_2_alto_s_1", 

88 "S5-Vetrina 2 alto S - 2-DICAM_CornaDiBovidiECervidi": "vetrina_2_alto_s_2", 

89 "S5-Vetrina 2 alto S-2-t-DBC_Tavoletta_con_cervo": "vetrina_2_alto_s_2_t", 

90 "S5-Vetrina 2 basso t-DICAM_MatriceRinoceronte": "vetrina_2_basso_t", 

91 "S5-Vetrina 2 basso-DICAM_Alce": "vetrina_2_basso", 

92 "S5-Vetrina_2_alto_n2_BezoarGazzella": "vetrina_2_alto_n_2", 

93 "S5-Vetrina_2_alto_s_3_ghiandoleCastoroeCapra": "vetrina_2_alto_s_3", 

94 # Vetrina 3 

95 "S5-Vetrina 3 alto N - 1-DICAM_UovaDiStruzzo": "vetrina_3_alto_n_1", 

96 "S5-Vetrina 3 alto N - 3-DA_Graminacea Subfossile": "vetrina_3_alto_n_3", 

97 "S5-Vetrina 3 alto N-4- DA- Apice vegetativo di palma": "vetrina_3_alto_n_4", 

98 "S5-Vetrina 3 alto S-1-t-TavolettaConBaccelli": "vetrina_3_alto_s_1_t", 

99 "S5-Vetrina 3 alto S-2-t-DBC_Tavoletta_con_nido": "vetrina_3_alto_s_2_t", 

100 "S5-Vetrina 3 alto S-4-FICLIT_NidoDiPendolino": "vetrina_3_alto_s_4", 

101 "S5-Vetrina 3 alto sinistra-1-DA-BaccelloConSeme": "vetrina_3_alto_s_1", 

102 "S5-Vetrina 3 basso-DICAM_Preparati di terre e Terre sigillate": "vetrina_3_basso", 

103 "S5-Vetrina_3_alto_n2_uovamostruosedipolloefagiano": "vetrina_3_alto_n_2", 

104 "S5-vetrina_3_alto_s_3_nidipreparativegetali": "vetrina_3_alto_s_3", 

105 # Vetrina 4 

106 "S5-Vetrina 4 alto N - 10-DICAM_Echinoidifossilin.10": "vetrina_4_alto_n_10", 

107 "S5-Vetrina 4 alto N - 11-DICAM_EchinoidiFossilin.11": "vetrina_4_alto_n_11", 

108 "S5-Vetrina 4 alto N - 7-DICAM_EchinoidiFossilin.7": "vetrina_4_alto_n_7", 

109 "S5-Vetrina 4 alto N - 8-DICAM_EchinoidiFossilin.8": "vetrina_4_alto_n_8", 

110 "S5-Vetrina 4 alto N - 9-FICLIT_EchinoidiFossilin.9": "vetrina_4_alto_n_9", 

111 "S5-Vetrina 4 alto N-1-DBC_Conoclypeus_conoideus": "vetrina_4_alto_n_1", 

112 "S5-Vetrina 4 alto N-2-DBC_Dollaro_di_mare": "vetrina_4_alto_n_2", 

113 "S5-Vetrina 4 alto N-3-DBC_Clypeaster_marginatus": "vetrina_4_alto_n_3", 

114 "S5-Vetrina 4 alto N-4-DBC_Mazettia_pareti": "vetrina_4_alto_n_4", 

115 "S5-Vetrina 4 alto N-5-DBC_Discoidea sp": "vetrina_4_alto_n_5", 

116 "S5-Vetrina 4 alto N-6-DBC_Macropneustes_sp": "vetrina_4_alto_n_6", 

117 "S5-Vetrina 4 alto S-1-DBC_Calcare a coralli lavorati": "vetrina_4_alto_s_1", 

118 "S5-Vetrina 4 alto S-10-DBC_Productus_geinitzianus": "vetrina_4_alto_s_10", 

119 "S5-Vetrina 4 alto S-11-DBC_Stephanoceras_bayleanus": "vetrina_4_alto_s_11", 

120 "S5-Vetrina 4 alto S-12-DBC_Tellina_planata": "vetrina_4_alto_s_12", 

121 "S5-Vetrina 4 alto S-13-DBC_Glossus_humanus": "vetrina_4_alto_s_13", 

122 "S5-Vetrina 4 alto S-2-DBC_Coralli": "vetrina_4_alto_s_2", 

123 "S5-Vetrina 4 alto S-3-DBC_Lumachella_a_Helicidi": "vetrina_4_alto_s_3", 

124 "S5-Vetrina 4 alto S-4-DBC_Calcare_a_lumachella": "vetrina_4_alto_s_4", 

125 "S5-Vetrina 4 alto S-5-DBC_Dentalium_elephantinum": "vetrina_4_alto_s_5", 

126 "S5-Vetrina 4 alto S-6-DBC_Glycymeris_glycymeris": "vetrina_4_alto_s_6", 

127 "S5-Vetrina 4 alto S-7-DBC_Bivalvi_indet": "vetrina_4_alto_s_7", 

128 "S5-Vetrina 4 alto S-8-DBC_Lytoceras_sp": "vetrina_4_alto_s_8", 

129 "S5-Vetrina 4 alto S-9-DBC_Megalodon_sp": "vetrina_4_alto_s_9", 

130 "S5-Vetrina 4 basso-FICLIT_Campioni di rocce levigate": "vetrina_4_basso", 

131 # Vetrina 5 

132 "S5-Vetrina 5 alto N - t-DICAM_MatriceBotroide1": "vetrina_5_alto_n_t", 

133 "S5-Vetrina 5 alto N-DICAM_Botroide1": "vetrina_5_alto_n", 

134 "S5-Vetrina 5 alto S - 1-DICAM_Botroide2": "vetrina_5_alto_s_1", 

135 "S5-Vetrina 5 alto S - 2 - t-DICAM_MatriceBotroide2": "vetrina_5_alto_s_2_t", 

136 "S5-Vetrina 5 alto S-2-DICAM_Botroide_triorchites": "vetrina_5_alto_s_2", 

137 "S5-Vetrina 5 basso t-DICAM_MatriceBotroide3": "vetrina_5_basso_t", 

138 "S5-Vetrina 5 basso-DICAM_Botroide3": "vetrina_5_basso", 

139 # Vetrina 6 

140 "S5-Vetrina 6 alto N-1-DBC_Bufo sp., Rospo": "vetrina_6_alto_n_1", 

141 "S5-Vetrina 6 alto N-1-t-TavolettaBufo": "vetrina_6_alto_n_1_t", 

142 "S5-Vetrina 6 alto N-2-DBC_Cordylus sp., lucertola": "vetrina_6_alto_n_2", 

143 "S5-Vetrina 6 alto N-2-t-TavolettaConLucertola": "vetrina_6_alto_n_2_t", 

144 "S5-Vetrina 6 alto S - 2 - t-DICAM_MatricePesce12_Pesce": "vetrina_6_alto_s_2_t", 

145 "S5-Vetrina 6 basso 2-ScheletroDiDelfino": "vetrina_6_basso_2", 

146 "S5-Vetrina 6 basso 2-t-TavolettaConDelfino": "vetrina_6_basso_2_t", 

147 "S5-Vetrina 6 basso-DBC_Scapola di balena": "vetrina_6_basso", 

148 "S5-vetrina_6_alto_s_1_Chamaleo": "vetrina_6_alto_s_1", 

149 "S5-vetrina_6_alto_s_1_Scincus_Pescedellesabbie": "vetrina_6_alto_s_1", 

150 # Vetrina 7 

151 "S5-Vetrina 7 alto N - 1 - t-DICAM_MatricePesce6_PesceForca": "vetrina_7_alto_n_1_t", 

152 "S5-Vetrina 7 alto N 1-FICLIT_PesceForca": "vetrina_7_alto_n_1", 

153 "S5-Vetrina 7 alto N 2-FICLIT_PesceScatola": "vetrina_7_alto_n_2", 

154 "S5-Vetrina 7 alto S - 1 - t-DICAM_MatricePesce11_PesceSpada": "vetrina_7_alto_s_1_t", 

155 "S5-Vetrina 7 alto S - 3 - t-DICAM_MatricePesce10_PesceVolante": "vetrina_7_alto_s_3_t", 

156 "S5-Vetrina 7 alto S - 3-FICLIT_PesceVolante": "vetrina_7_alto_s_3", 

157 "S5-Vetrina 7 alto S 2-FICLIT_SorcioMarino": "vetrina_7_alto_s_2", 

158 "S5-Vetrina 7 alto S-1-DA-Xiphias sp., Pesci spada": "vetrina_7_alto_s_1", 

159 "S5-Vetrina 7 basso - t-DICAM_MatricePesce13_PescePalla2": "vetrina_7_basso_t", 

160 "S5-Vetrina 7 basso - t-DICAM_MatricePesce9_PescePalla1": "vetrina_7_basso_t", 

161 "S5-Vetrina 7 basso-DICAM_PescePalla": "vetrina_7_basso", 

162 # Vetrina 8 

163 "S5-Vetrina 8 alto N - 1-FICLIT_ScazzoneMarino": "vetrina_8_alto_n_1", 

164 "S5-Vetrina 8 alto N - 3 - t-DICAM_MatricePesce4_Bocca1": "vetrina_8_alto_n_3_t", 

165 "S5-Vetrina 8 alto N - 3 - t-DICAM_MatricePesce8_Bocca2": "vetrina_8_alto_n_3_t", 

166 "S5-Vetrina 8 alto N - 3-DA-Apparato dentale di pesce cartilagineo (Elasmobrnchii)": "vetrina_8_alto_n_3", 

167 "S5-Vetrina 8 alto N-2-DA-Lophius piscatorius (Linnaeus, 1758), rana pescatrice": "vetrina_8_alto_n_2", 

168 "S5-Vetrina 8 alto S - 1 - t-DICAM_MatricePesce1_PesceChitarra": "vetrina_8_alto_s_1_t", 

169 "S5-Vetrina 8 alto S - 1-DA-Rhinobatos rhinobatos": "vetrina_8_alto_s_1", 

170 "S5-Vetrina 8 alto S - 2 - t-DICAM_MatricePesce2_Smeriglio": "vetrina_8_alto_s_2_t", 

171 "S5-Vetrina 8 alto S - 2-DA-Lamna nasus (Bonnaterre, 1788), smeriglio": "vetrina_8_alto_s_2", 

172 "S5-Vetrina 8 alto S - 3 - t-DICAM_MatricePesce3_RanaPescatrice": "vetrina_8_alto_s_3_t", 

173 "S5-Vetrina 8 alto S.3-DA-Lophius piscatorius (Linnaeus, 1758), apparato boccale rana pescatrice": "vetrina_8_alto_s_3", 

174 "S5-Vetrina 8 basso - t1-DICAM_MatricePesce7_DentiPesceSega": "vetrina_8_basso_t1", 

175 "S5-Vetrina 8 basso - t2-DICAM_MatricePesce5_PesceSega": "vetrina_8_basso_t2", 

176 "S5-Vetrina 8 basso-DA-Pesce Martello": "vetrina_8_basso", 

177 "S5-Vetrina 8 basso-DA-Preparati zoologici, Pescecane": "vetrina_8_basso", 

178 "S5-Vetrina 8 basso-DA-Preparati zoologici, pesce sega": "vetrina_8_basso", 

179 # Manoscritti 

180 "S5-Manoscritto-FICLIT_AdnotationesVariaePraesertimDeAnimalibus": "m1", 

181 "S5-Manoscritto-FICLIT_VulgataProverbia": "m2", 

182 "S5-Manoscritto-FICLIT_PandechionEpistemonicon": "m3", 

183 "S5-Manoscritto-FICLIT_LexiconRerumInanimatarum": "m4", 

184 "S5-Manoscritto-FICLIT_BibliothecaSecundumNominaAuthorum": "m5", 

185 "S5-Manoscritto-FICLIT_TheatrumBiblicumNaturale": "m6", 

186 "S5-Manoscritto-FICLIT_LibroDeiVisitatori": "m7", 

187 "S5-Manoscritto-FICLIT_DiscorsoNaturaleAldrovandi": "m8", 

188} 

189 

190 

191def load_kg(path: Path) -> Graph: 

192 graph = Graph() 

193 graph.parse(path, format="turtle") 

194 return graph 

195 

196 

197def extract_id_from_folder_name(folder_name: str) -> str: 

198 if folder_name in FOLDER_TO_ID: 

199 return FOLDER_TO_ID[folder_name] 

200 match = re.match(r"S\d+-(\d+[a-z]?) ?[-_]", folder_name) 

201 if not match: 

202 raise ValueError(f"Cannot extract ID from folder name: {folder_name}") 

203 return match.group(1).lstrip("0") or "0" 

204 

205 

206def extract_metadata_for_stage(graph: Graph, nr: str, stage: str) -> Graph: 

207 result = Graph() 

208 for prefix, namespace in graph.namespace_manager.namespaces(): 

209 result.namespace_manager.bind(prefix, namespace) 

210 

211 steps = STAGE_STEPS[stage] 

212 

213 for s, p, o in graph: 

214 s_str = str(s) 

215 step_match = re.search(rf"/{nr}/(\d{{2}})/1$", s_str) 

216 if step_match: 

217 step = step_match.group(1) 

218 if step in steps: 

219 result.add((s, p, o)) 

220 if isinstance(o, URIRef): 

221 for s2, p2, o2 in graph.triples((o, None, None)): 

222 result.add((s2, p2, o2)) 

223 continue 

224 

225 ob_match = re.search(rf"/{nr}/ob\d+/1$", s_str) 

226 if ob_match: 

227 result.add((s, p, o)) 

228 if isinstance(o, URIRef): 

229 for s2, p2, o2 in graph.triples((o, None, None)): 

230 result.add((s2, p2, o2)) 

231 

232 return result 

233 

234 

235def validate_metadata(data_graph: Graph, shapes_graph: Graph) -> tuple[bool, str]: 

236 conforms, _, results_text = pyshacl.validate( 

237 data_graph, 

238 shacl_graph=shapes_graph, 

239 ) 

240 return bool(conforms), str(results_text) 

241 

242 

243def scan_folder_structure(root_path: Path) -> dict: 

244 structure = {} 

245 for sala_dir in root_path.iterdir(): 

246 if not sala_dir.is_dir(): 

247 continue 

248 sala_name = sala_dir.name 

249 structure[sala_name] = {} 

250 for folder_dir in sala_dir.iterdir(): 

251 if not folder_dir.is_dir(): 

252 continue 

253 folder_name = folder_dir.name 

254 structure[sala_name][folder_name] = {} 

255 for stage_dir in folder_dir.iterdir(): 

256 if not stage_dir.is_dir(): 

257 continue 

258 stage_name = stage_dir.name 

259 files = [f.name for f in stage_dir.iterdir() if f.is_file()] 

260 structure[sala_name][folder_name][stage_name] = {"_files": files} 

261 return {"structure": structure} 

262 

263 

264def process_all_folders( 

265 root: Path, 

266 kg_path: Path = KG_PATH, 

267 shapes_path: Path = SHAPES_PATH, 

268 validate: bool = True, 

269) -> list[tuple[str, str]]: 

270 structure = scan_folder_structure(root) 

271 kg = load_kg(kg_path) 

272 shapes_graph = load_kg(shapes_path) if validate else None 

273 

274 console = Console() 

275 validation_errors = [] 

276 

277 folders = [ 

278 (sala_name, folder_name, subfolders) 

279 for sala_name, sala_items in structure["structure"].items() 

280 for folder_name, subfolders in sala_items.items() 

281 if folder_name not in SKIP_FOLDERS 

282 ] 

283 

284 with Progress( 

285 SpinnerColumn(), 

286 TextColumn("[progress.description]{task.description}"), 

287 BarColumn(), 

288 MofNCompleteColumn(), 

289 ) as progress: 

290 task = progress.add_task("Processing folders", total=len(folders)) 

291 

292 for sala_name, folder_name, subfolders in folders: 

293 nr = extract_id_from_folder_name(folder_name) 

294 progress.update(task, description=f"{folder_name}") 

295 

296 existing_stages = [ 

297 s for s in subfolders.keys() 

298 if s.lower() in STAGE_STEPS 

299 ] 

300 

301 for stage_name in existing_stages: 

302 stage_key = stage_name.lower() 

303 stage_dir = root / sala_name / folder_name / stage_name 

304 stage_dir.mkdir(parents=True, exist_ok=True) 

305 

306 metadata = extract_metadata_for_stage(kg, nr, stage_key) 

307 metadata.add((URIRef(""), DCTERMS.license, CC0)) 

308 

309 if shapes_graph is not None: 

310 conforms, results_text = validate_metadata(metadata, shapes_graph) 

311 if not conforms: 

312 label = f"{folder_name}/{stage_name}" 

313 validation_errors.append((label, results_text)) 

314 

315 meta_path = stage_dir / "meta.ttl" 

316 metadata.serialize(destination=str(meta_path), format="turtle") 

317 

318 prov_path = stage_dir / "prov.trig" 

319 generate_provenance_snapshots( 

320 input_directory=str(stage_dir), 

321 output_file=str(prov_path), 

322 output_format="trig", 

323 agent_orcid=RESP_AGENT, 

324 primary_source=PRIMARY_SOURCE, 

325 ) 

326 

327 progress.advance(task) 

328 

329 if shapes_graph is not None: 

330 if validation_errors: 

331 console.print(f"\n[bold red]SHACL validation failed for {len(validation_errors)} stage(s):[/bold red]") 

332 for label, results_text in validation_errors: 

333 console.print(f"\n[bold yellow]{label}[/bold yellow]") 

334 console.print(results_text) 

335 else: 

336 console.print("\n[bold green]All metadata passed SHACL validation.[/bold green]") 

337 

338 return validation_errors 

339 

340 

341def merge_provenance_files(root: Path, output_path: Path) -> None: 

342 merged = Dataset(default_union=True) 

343 for prov_file in sorted(root.rglob("prov.trig")): 

344 merged.parse(str(prov_file), format="trig") 

345 merged.serialize(destination=str(output_path), format="trig") 

346 

347 

348def parse_arguments(): # pragma: no cover 

349 parser = argparse.ArgumentParser( 

350 description="Generate metadata and provenance files for folder structure" 

351 ) 

352 parser.add_argument( 

353 "root", 

354 type=Path, 

355 help="Root directory containing Sala/Folder/Stage structure", 

356 ) 

357 parser.add_argument( 

358 "--no-validate", 

359 action="store_true", 

360 help="Skip SHACL validation", 

361 ) 

362 parser.add_argument( 

363 "--merge-provenance", 

364 type=Path, 

365 default=None, 

366 help="Output path for merged provenance file (TriG format)", 

367 ) 

368 return parser.parse_args() 

369 

370 

371def main(): # pragma: no cover 

372 args = parse_arguments() 

373 process_all_folders(root=args.root, validate=not args.no_validate) 

374 if args.merge_provenance: 

375 merge_provenance_files(args.root, args.merge_provenance) 

376 

377 

378if __name__ == "__main__": # pragma: no cover 

379 main()