File indexing completed on 2026-09-21 09:33:40
0001
0002
0003 import argparse
0004 import os
0005 import json
0006 import logging
0007 from typing import Dict, Any
0008 from rucio.client.uploadclient import UploadClient
0009 from rucio.client import Client
0010 from rucio.common.exception import InputValidationError, RSEWriteBlocked, NoFilesUploaded, NotAllFilesUploaded
0011 from jsonschema import validate as json_validate, ValidationError
0012
0013
0014
0015 METADATA_SCHEMA = {
0016 "$schema": "http://json-schema.org/draft-07/schema#",
0017 "title": "ePICRucioMetadataTags",
0018 "description": "Optimized metadata tags for ePIC Rucio datasets using searchable slugs.",
0019 "type": "object",
0020 "properties": {
0021 "software_release": {
0022 "type": "string",
0023 "description": "Container version tag (e.g. 26.03.0-stable, nightly, unstable, or default)",
0024 "pattern": "^([0-9]+\\.[0-9]+\\.[0-9]+-stable|nightly|unstable|default)$"
0025 },
0026 "requester_pwg": {
0027 "type": "string",
0028 "description": "PWG requesting the dataset.",
0029 "enum": [
0030 "edt",
0031 "inclusive",
0032 "jets_hf",
0033 "semi_inclusive",
0034 "ew_bsm",
0035 "other"
0036 ]
0037 },
0038 "q2_min_gev2": {
0039 "type": "number",
0040 "description": "Minimum Q2 value (GeV^2). Optional - not applicable to all datasets."
0041 },
0042 "q2_max_gev2": {
0043 "type": "number",
0044 "description": "Maximum Q2 value (GeV^2). Optional - not applicable to all datasets."
0045 },
0046 "electron_beam_energy_gev": {
0047 "type": "number",
0048 "description": "Electron beam energy (GeV)"
0049 },
0050 "ion_beam_energy_gev": {
0051 "type": "number",
0052 "description": "Ion/nucleus beam energy (GeV)"
0053 },
0054 "is_background_mixed": {
0055 "type": "boolean",
0056 "description": "True if the sample includes background mixing; false if it is a regular/pure signal sample."
0057 },
0058 "ion_species": {
0059 "type": "string",
0060 "description": "Ion species.",
0061 "enum": [
0062 "p",
0063 "Au197",
0064 "Cu63",
0065 "He3",
0066 "H2",
0067 "Ru96",
0068 "Pb208",
0069 "Pb207"
0070 ]
0071 },
0072 "data_level": {
0073 "type": "string",
0074 "description": "Data processing level.",
0075 "enum": [
0076 "simulation",
0077 "reconstruction"
0078 ]
0079 },
0080 "gun_particle": {
0081 "type": "string",
0082 "description": "Single particle type. Optional - only applicable to single particle datasets.",
0083 "enum": [
0084 "e-",
0085 "e+",
0086 "proton",
0087 "neutron",
0088 "pi+",
0089 "pi-",
0090 "pi0",
0091 "kaon-",
0092 "kaon+",
0093 "gamma",
0094 "mu-"
0095 ]
0096 },
0097 "geometry_config": {
0098 "type": "string",
0099 "description": "Geometry configuration tag (e.g. craterlake_18x275, craterlake_5x41_He3)",
0100 "pattern": "^[a-z][a-z0-9_]*_[0-9]+x[0-9]+(_.+)?$"
0101 },
0102 "gun_momentum_min_gev": {
0103 "type": "number",
0104 "description": "Minimum particle gun momentum (GeV). For fixed-energy runs, equals gun_momentum_max_gev."
0105 },
0106 "gun_momentum_max_gev": {
0107 "type": "number",
0108 "description": "Maximum particle gun momentum (GeV). For fixed-energy runs, equals gun_momentum_min_gev."
0109 },
0110 "gun_theta_min_deg": {
0111 "type": "number",
0112 "description": "Minimum polar angle (degrees) for particle gun angular distribution."
0113 },
0114 "gun_theta_max_deg": {
0115 "type": "number",
0116 "description": "Maximum polar angle (degrees) for particle gun angular distribution."
0117 },
0118 "gun_phi_min_deg": {
0119 "type": "number",
0120 "description": "Minimum azimuthal angle (degrees) for particle gun distribution. Default is 0."
0121 },
0122 "gun_phi_max_deg": {
0123 "type": "number",
0124 "description": "Maximum azimuthal angle (degrees) for particle gun distribution. Default is 360."
0125 },
0126 "gun_distribution": {
0127 "type": "string",
0128 "description": "Angular distribution type for particle gun.",
0129 "enum": ["uniform", "cos(theta)", "eta", "pseudorapidity", "ffbar"]
0130 },
0131 "requester_dsc": {
0132 "type": "string",
0133 "description": "Detector Subsystem Collaboration requesting the dataset. Optional.",
0134 "enum": [
0135 "tracking",
0136 "other"
0137 ]
0138 },
0139 "generator": {
0140 "type": "string",
0141 "description": "Generator name",
0142 "enum": [
0143 "pythia6",
0144 "pythia8",
0145 "beagle",
0146 "djangoh",
0147 "rapgap",
0148 "dempgen",
0149 "sartre",
0150 "lager",
0151 "estarlight",
0152 "epic",
0153 "getalm",
0154 "eicmesonsfgen",
0155 "eic_sr_geant4",
0156 "eic_esr_xsuite",
0157 "sherpa",
0158 "single_particle",
0159 "other"
0160 ]
0161 },
0162 },
0163 "required": [
0164 "software_release",
0165 "is_background_mixed",
0166 "data_level",
0167 "geometry_config",
0168 "generator"
0169 ]
0170 }
0171
0172
0173 def validate_metadata(metadata: Dict[str, Any]) -> bool:
0174 """
0175 Validate metadata against the schema using jsonschema.
0176
0177 Parameters
0178 ----------
0179 metadata : dict
0180 The metadata dictionary to validate
0181
0182 Returns
0183 -------
0184 bool
0185 True if valid
0186
0187 Raises
0188 ------
0189 ValueError
0190 If metadata doesn't match the schema
0191 """
0192 if not isinstance(metadata, dict):
0193 raise ValueError("Metadata must be a JSON object (dictionary)")
0194
0195 try:
0196 json_validate(instance=metadata, schema=METADATA_SCHEMA)
0197 except ValidationError as e:
0198 raise ValueError(f"Metadata validation failed: {e.message}")
0199
0200 return True
0201
0202
0203 def load_metadata_file(filepath: str) -> Dict[str, Any]:
0204 """
0205 Load and validate metadata from a JSON file.
0206
0207 Parameters
0208 ----------
0209 filepath : str
0210 Path to the metadata JSON file
0211
0212 Returns
0213 -------
0214 dict
0215 The validated metadata dictionary
0216
0217 Raises
0218 ------
0219 FileNotFoundError
0220 If the metadata file doesn't exist
0221 ValueError
0222 If the JSON is invalid or doesn't match the schema
0223 """
0224 if not os.path.exists(filepath):
0225 raise FileNotFoundError(f"Metadata file not found: {filepath}")
0226
0227 try:
0228 with open(filepath, 'r') as f:
0229 metadata = json.load(f)
0230 except json.JSONDecodeError as e:
0231 raise ValueError(f"Invalid JSON in metadata file: {e}")
0232
0233 validate_metadata(metadata)
0234 return metadata
0235
0236
0237 if __name__ == "__main__":
0238 parser = argparse.ArgumentParser(
0239 prog='Register to RUCIO',
0240 description='Registers files to RUCIO with optional dataset metadata'
0241 )
0242 parser.add_argument(
0243 "-f", dest="file_paths",
0244 action="store", nargs='+', required=True,
0245 help="Enter the local file path(s)"
0246 )
0247 parser.add_argument(
0248 "-d", dest="did_names",
0249 action="store", nargs='+', required=True,
0250 help="Enter the data identifier(s) for rucio catalogue"
0251 )
0252 parser.add_argument(
0253 "-s", dest="scope",
0254 action="store", required=True,
0255 help="Enter the scope"
0256 )
0257 parser.add_argument(
0258 "-r", dest="rse",
0259 action="store", required=True,
0260 help="Enter the rucio storage element (e.g., EIC-XRD for production outputs)"
0261 )
0262 parser.add_argument(
0263 '--noregister', dest="noregister",
0264 action="store_true", default=False,
0265 help="Skip rucio registration (upload only)"
0266 )
0267 parser.add_argument(
0268 '--upload-metadata', dest="metadata_file",
0269 action="store", default=None,
0270 help="Path to JSON file containing dataset metadata"
0271 )
0272 parser.add_argument(
0273 '--metadata-json', dest="metadata_json",
0274 action="store", default=None,
0275 help="JSON string containing dataset metadata"
0276 )
0277
0278 args = parser.parse_args()
0279
0280 file_paths = args.file_paths
0281 did_names = args.did_names
0282 scope = args.scope
0283 rse = args.rse
0284 noregister = args.noregister
0285
0286
0287 if len(file_paths) != len(did_names):
0288 raise ValueError("The number of file paths must match the number of did names.")
0289
0290
0291 for file_path in file_paths:
0292 if not os.path.exists(file_path):
0293 raise FileNotFoundError(f"File not found: {file_path}")
0294
0295
0296 if args.metadata_file and args.metadata_json:
0297 raise ValueError("Cannot specify both --upload-metadata and --metadata-json")
0298 dataset_meta = None
0299 if args.metadata_file:
0300 dataset_meta = load_metadata_file(args.metadata_file)
0301 print(f"Loaded metadata: {json.dumps(dataset_meta, indent=2)}")
0302 elif args.metadata_json:
0303 try:
0304 dataset_meta = json.loads(args.metadata_json)
0305 except json.JSONDecodeError as e:
0306 raise ValueError(f"Invalid JSON in --metadata-json: {e}")
0307 validate_metadata(dataset_meta)
0308 print(f"Loaded metadata: {json.dumps(dataset_meta, indent=2)}")
0309
0310 upload_items = []
0311
0312
0313 for file_path, did_name in zip(file_paths, did_names):
0314 parent_directory = os.path.dirname(did_name)
0315
0316
0317 if not parent_directory:
0318 raise ValueError(
0319 f"DID name '{did_name}' does not contain a parent directory. "
0320 "Expected format: 'parent/filename'"
0321 )
0322
0323
0324 upload_item = {
0325 'path': file_path,
0326 'rse': rse,
0327 'did_scope': scope,
0328 'did_name': did_name,
0329 'dataset_scope': scope,
0330 'dataset_name': parent_directory,
0331 'no_register': noregister
0332 }
0333
0334
0335 if dataset_meta and not noregister:
0336 upload_item['dataset_meta'] = dataset_meta
0337
0338
0339 upload_items.append(upload_item)
0340
0341
0342 logger = logging.getLogger('upload_client')
0343 logger.addHandler(logging.StreamHandler())
0344 logger.setLevel(logging.INFO)
0345
0346 upload_client = UploadClient(logger=logger)
0347 client = Client()
0348
0349 try:
0350 upload_client.upload(upload_items)
0351 logger.info("Upload completed successfully!")
0352 except Exception as e:
0353 logger.error(f"Upload failed: {e}")
0354
0355 dids = [{'scope': scope, 'name': did_name} for did_name in did_names]
0356
0357
0358 replicas = client.list_replicas(
0359 dids,
0360 all_states=True,
0361 rse_expression=rse
0362 )
0363
0364
0365 files_to_update = []
0366 files_to_tombstone = []
0367
0368 for replica in replicas:
0369 did_name = replica['name']
0370 state = replica['states'].get(rse)
0371
0372 if state == 'COPYING':
0373 logger.warning(
0374 "Found COPYING replica %s:%s on %s — deleting",
0375 scope, did_name, rse
0376 )
0377 files_to_update.append({'scope': scope, 'name': did_name, 'state': 'U'})
0378 files_to_tombstone.append({'rse': rse, 'scope': scope, 'name': did_name})
0379
0380 if files_to_update:
0381
0382 client.update_replicas_states(rse=rse, files=files_to_update)
0383
0384 client.set_tombstone(files_to_tombstone)
0385
0386 raise