Coverage for changes_metadata_manager / folder_metadata_builder.py: 92%

121 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-29 18:29 +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.19898905" 

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 "S5-Vetrina_2_alto_n2_BezoarGazzella", 

36 "S5-Vetrina_2_alto_s_3_ghiandoleCastoroeCapra", 

37 "S5-vetrina_6_alto_s_1_Chamaleo", 

38 "S5-vetrina_6_alto_s_1_Scincus_Pescedellesabbie", 

39 "S5-Vetrina 3 alto N-4- DA- Apice vegetativo di palma", 

40 "materials", 

41 "sala 4", 

42 "_files", 

43} 

44 

45FOLDER_TO_ID = { 

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

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

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

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

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

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

52 "S4-ManicoColtelloZoomorfo": "50", 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

67 # Vetrina 1 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

89 # Vetrina 2 

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

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

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

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

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

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

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

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

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

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

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

101 # Vetrina 3 

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

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

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

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

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

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

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

109 "S5-Vetrina_3_alto_n2_uovamostruosedipolloefagiano": "vetrina_3_alto_n_2", 

110 "S5-vetrina_3_alto_s_3_nidipreparativegetali": "vetrina_3_alto_s_3", 

111 # Vetrina 4 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

137 # Vetrina 5 

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

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

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

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

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

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

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

145 # Vetrina 6 

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

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

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

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

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

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

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

153 "S5-Vetrina 6 basso-DBC_Scapola di balena": "vetrina_6_basso_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_2_t1", 

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

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

181 "S5-Vetrina 8 basso_2_DA-Preparati zoologici, pesce sega": "vetrina_8_basso_2", 

182 "S5-Vetrina 8 basso_3_DA-Pesce Martello": "vetrina_8_basso_3", 

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 

210P1_IS_IDENTIFIED_BY = URIRef("http://www.cidoc-crm.org/cidoc-crm/P1_is_identified_by") 

211 

212 

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

214 result = Graph() 

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

216 result.namespace_manager.bind(prefix, namespace) 

217 

218 steps = STAGE_STEPS[stage] 

219 

220 for s, p, o in graph: 

221 s_str = str(s) 

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

223 if step_match: 

224 step = step_match.group(1) 

225 if step in steps: 

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

227 if isinstance(o, URIRef): 

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

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

230 continue 

231 

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

233 if ob_match: 

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

235 if isinstance(o, URIRef): 

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

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

238 

239 appellation_uris = {o for _, p, o in result if p == P1_IS_IDENTIFIED_BY and isinstance(o, URIRef)} 

240 for uri in appellation_uris: 

241 for s2, p2, o2 in graph.triples((uri, None, None)): 

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

243 

244 return result 

245 

246 

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

248 conforms, _, results_text = pyshacl.validate( 

249 data_graph, 

250 shacl_graph=shapes_graph, 

251 ) 

252 return bool(conforms), str(results_text) 

253 

254 

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

256 structure = {} 

257 for sala_dir in root_path.iterdir(): 

258 if not sala_dir.is_dir(): 

259 continue 

260 sala_name = sala_dir.name 

261 structure[sala_name] = {} 

262 for folder_dir in sala_dir.iterdir(): 

263 if not folder_dir.is_dir(): 

264 continue 

265 folder_name = folder_dir.name 

266 structure[sala_name][folder_name] = {} 

267 for stage_dir in folder_dir.iterdir(): 

268 if not stage_dir.is_dir(): 

269 continue 

270 stage_name = stage_dir.name 

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

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

273 return {"structure": structure} 

274 

275 

276def process_all_folders( 

277 root: Path, 

278 kg_path: Path = KG_PATH, 

279 shapes_path: Path = SHAPES_PATH, 

280 validate: bool = True, 

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

282 structure = scan_folder_structure(root) 

283 kg = load_kg(kg_path) 

284 shapes_graph = load_kg(shapes_path) if validate else None 

285 

286 console = Console() 

287 validation_errors = [] 

288 

289 folders = [ 

290 (sala_name, folder_name, subfolders) 

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

292 for folder_name, subfolders in sala_items.items() 

293 if folder_name not in SKIP_FOLDERS 

294 ] 

295 

296 with Progress( 

297 SpinnerColumn(), 

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

299 BarColumn(), 

300 MofNCompleteColumn(), 

301 ) as progress: 

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

303 

304 for sala_name, folder_name, subfolders in folders: 

305 nr = extract_id_from_folder_name(folder_name) 

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

307 

308 existing_stages = [ 

309 s for s in subfolders.keys() 

310 if s.lower() in STAGE_STEPS 

311 ] 

312 

313 for stage_name in existing_stages: 

314 stage_key = stage_name.lower() 

315 stage_dir = root / sala_name / folder_name / stage_name 

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

317 

318 metadata = extract_metadata_for_stage(kg, nr, stage_key) 

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

320 

321 if shapes_graph is not None: 

322 conforms, results_text = validate_metadata(metadata, shapes_graph) 

323 if not conforms: 

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

325 validation_errors.append((label, results_text)) 

326 

327 meta_path = stage_dir / "meta.ttl" 

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

329 

330 prov_path = stage_dir / "prov.trig" 

331 generate_provenance_snapshots( 

332 input_directory=str(stage_dir), 

333 output_file=str(prov_path), 

334 output_format="trig", 

335 agent_orcid=RESP_AGENT, 

336 primary_source=PRIMARY_SOURCE, 

337 ) 

338 

339 progress.advance(task) 

340 

341 if shapes_graph is not None: 

342 if validation_errors: 

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

344 for label, results_text in validation_errors: 

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

346 console.print(results_text) 

347 else: 

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

349 

350 return validation_errors 

351 

352 

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

354 merged = Dataset(default_union=True) 

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

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

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

358 

359 

360def parse_arguments(): # pragma: no cover 

361 parser = argparse.ArgumentParser( 

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

363 ) 

364 parser.add_argument( 

365 "root", 

366 type=Path, 

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

368 ) 

369 parser.add_argument( 

370 "--no-validate", 

371 action="store_true", 

372 help="Skip SHACL validation", 

373 ) 

374 parser.add_argument( 

375 "--merge-provenance", 

376 type=Path, 

377 default=None, 

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

379 ) 

380 return parser.parse_args() 

381 

382 

383def main(): # pragma: no cover 

384 args = parse_arguments() 

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

386 if args.merge_provenance: 

387 merge_provenance_files(args.root, args.merge_provenance) 

388 

389 

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

391 main()