Coverage for changes_metadata_manager / folder_metadata_builder.py: 96%

116 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-03-21 12:19 +0000

1# SPDX-FileCopyrightText: 2025-2026 Arcangelo Massari <arcangelomas@gmail.com> 

2# 

3# SPDX-License-Identifier: ISC 

4 

5import argparse 

6import re 

7from pathlib import Path 

8 

9import pyshacl 

10from rdflib import Dataset, Graph, URIRef 

11from rdflib.namespace import DCTERMS 

12from rich.console import Console 

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

14 

15from changes_metadata_manager.generate_provenance import generate_provenance_snapshots 

16 

17 

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

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

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

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

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

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

24 

25STAGE_STEPS = { 

26 "raw": ["00"], 

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

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

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

30} 

31 

32SKIP_FOLDERS = { 

33 "S1-CNR_SoffittoSala1", 

34 "S5-B basso-DICAM_FanoneBalenaAlto", 

35 "materials", 

36 "sala 4", 

37 "_files", 

38} 

39 

40FOLDER_TO_ID = { 

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

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

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

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

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

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

47 "S4-ManicoColtelloZoomorfo": "50", 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

62 # Vetrina 1 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

84 # Vetrina 2 

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

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

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

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

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

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

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

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

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

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

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

96 "S5-Vetrina_2_alto_n2_BezoarGazzella": "vetrina_2_alto_n_2", 

97 "S5-Vetrina_2_alto_s_3_ghiandoleCastoroeCapra": "vetrina_2_alto_s_3", 

98 # Vetrina 3 

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

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

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

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

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

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

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

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

107 "S5-Vetrina_3_alto_n2_uovamostruosedipolloefagiano": "vetrina_3_alto_n_2", 

108 "S5-vetrina_3_alto_s_3_nidipreparativegetali": "vetrina_3_alto_s_3", 

109 # Vetrina 4 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

135 # Vetrina 5 

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

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

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

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

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

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

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

143 # Vetrina 6 

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

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

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

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

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

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

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

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

152 "S5-vetrina_6_alto_s_1_Chamaleo": "vetrina_6_alto_s_1", 

153 "S5-vetrina_6_alto_s_1_Scincus_Pescedellesabbie": "vetrina_6_alto_s_1", 

154 # Vetrina 7 

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

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

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

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

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

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

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

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

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

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

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

166 # Vetrina 8 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

183 # Manoscritti 

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

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

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

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

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

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

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

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

192} 

193 

194 

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

196 graph = Graph() 

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

198 return graph 

199 

200 

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

202 if folder_name in FOLDER_TO_ID: 

203 return FOLDER_TO_ID[folder_name] 

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

205 if not match: 

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

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

208 

209 

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

211 result = Graph() 

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

213 result.namespace_manager.bind(prefix, namespace) 

214 

215 steps = STAGE_STEPS[stage] 

216 

217 for s, p, o in graph: 

218 s_str = str(s) 

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

220 if step_match: 

221 step = step_match.group(1) 

222 if step in steps: 

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

224 if isinstance(o, URIRef): 

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

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

227 continue 

228 

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

230 if ob_match: 

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

232 if isinstance(o, URIRef): 

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

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

235 

236 return result 

237 

238 

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

240 conforms, _, results_text = pyshacl.validate( 

241 data_graph, 

242 shacl_graph=shapes_graph, 

243 ) 

244 return bool(conforms), str(results_text) 

245 

246 

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

248 structure = {} 

249 for sala_dir in root_path.iterdir(): 

250 if not sala_dir.is_dir(): 

251 continue 

252 sala_name = sala_dir.name 

253 structure[sala_name] = {} 

254 for folder_dir in sala_dir.iterdir(): 

255 if not folder_dir.is_dir(): 

256 continue 

257 folder_name = folder_dir.name 

258 structure[sala_name][folder_name] = {} 

259 for stage_dir in folder_dir.iterdir(): 

260 if not stage_dir.is_dir(): 

261 continue 

262 stage_name = stage_dir.name 

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

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

265 return {"structure": structure} 

266 

267 

268def process_all_folders( 

269 root: Path, 

270 kg_path: Path = KG_PATH, 

271 shapes_path: Path = SHAPES_PATH, 

272 validate: bool = True, 

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

274 structure = scan_folder_structure(root) 

275 kg = load_kg(kg_path) 

276 shapes_graph = load_kg(shapes_path) if validate else None 

277 

278 console = Console() 

279 validation_errors = [] 

280 

281 folders = [ 

282 (sala_name, folder_name, subfolders) 

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

284 for folder_name, subfolders in sala_items.items() 

285 if folder_name not in SKIP_FOLDERS 

286 ] 

287 

288 with Progress( 

289 SpinnerColumn(), 

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

291 BarColumn(), 

292 MofNCompleteColumn(), 

293 ) as progress: 

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

295 

296 for sala_name, folder_name, subfolders in folders: 

297 nr = extract_id_from_folder_name(folder_name) 

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

299 

300 existing_stages = [ 

301 s for s in subfolders.keys() 

302 if s.lower() in STAGE_STEPS 

303 ] 

304 

305 for stage_name in existing_stages: 

306 stage_key = stage_name.lower() 

307 stage_dir = root / sala_name / folder_name / stage_name 

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

309 

310 metadata = extract_metadata_for_stage(kg, nr, stage_key) 

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

312 

313 if shapes_graph is not None: 

314 conforms, results_text = validate_metadata(metadata, shapes_graph) 

315 if not conforms: 

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

317 validation_errors.append((label, results_text)) 

318 

319 meta_path = stage_dir / "meta.ttl" 

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

321 

322 prov_path = stage_dir / "prov.trig" 

323 generate_provenance_snapshots( 

324 input_directory=str(stage_dir), 

325 output_file=str(prov_path), 

326 output_format="trig", 

327 agent_orcid=RESP_AGENT, 

328 primary_source=PRIMARY_SOURCE, 

329 ) 

330 

331 progress.advance(task) 

332 

333 if shapes_graph is not None: 

334 if validation_errors: 

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

336 for label, results_text in validation_errors: 

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

338 console.print(results_text) 

339 else: 

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

341 

342 return validation_errors 

343 

344 

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

346 merged = Dataset(default_union=True) 

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

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

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

350 

351 

352def parse_arguments(): # pragma: no cover 

353 parser = argparse.ArgumentParser( 

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

355 ) 

356 parser.add_argument( 

357 "root", 

358 type=Path, 

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

360 ) 

361 parser.add_argument( 

362 "--no-validate", 

363 action="store_true", 

364 help="Skip SHACL validation", 

365 ) 

366 parser.add_argument( 

367 "--merge-provenance", 

368 type=Path, 

369 default=None, 

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

371 ) 

372 return parser.parse_args() 

373 

374 

375def main(): # pragma: no cover 

376 args = parse_arguments() 

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

378 if args.merge_provenance: 

379 merge_provenance_files(args.root, args.merge_provenance) 

380 

381 

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

383 main()