From 12d3476fa51a06be6366636afdb70c1a8ed6b191 Mon Sep 17 00:00:00 2001 From: Dhanashree Petare Date: Mon, 22 Jun 2026 21:14:27 +0530 Subject: [PATCH 1/9] gsoc26: Format Conversion Layer (Layer 2) + CLI refactor (#59, #61) (#62) * gsoc26: Layer 2 with tests initial commit * gsoc26: Refactor Layer 2 with handler architecture and improved tests * Improvements to Format layer implementation * Review comments resolved under issue #59 * #61: replacing --convert_from & --convert_to with --compression * gsoc26: layer2 complete + implementation for #61 * docs: update README for --format and --compression flags * fix: resolve PR review comments. --------- Co-authored-by: DhanashreePetare --- README.md | 32 +- databusclient/api/convert.py | 48 +++ databusclient/api/download.py | 270 +++++++++---- databusclient/cli.py | 37 +- databusclient/filehandling/format.py | 556 ++++++++++++++++++++++++++ databusclient/filehandling/mapping.py | 68 ++++ pyproject.toml | 8 + run_all_conversion_tests.py | 342 ++++++++++++++++ tests/resources/sample.csv | 11 + tests/resources/sample.jsonld | 62 +++ tests/resources/sample.nq | 10 + tests/resources/sample.nt | 10 + tests/resources/sample.rdf | 30 ++ tests/resources/sample.trig | 22 + tests/resources/sample.trix | 72 ++++ tests/resources/sample.tsv | 11 + tests/resources/sample.ttl | 18 + tests/test_compression_conversion.py | 45 ++- tests/test_format_round_trips.py | 253 ++++++++++++ 19 files changed, 1795 insertions(+), 110 deletions(-) create mode 100644 databusclient/api/convert.py create mode 100644 databusclient/filehandling/format.py create mode 100644 databusclient/filehandling/mapping.py create mode 100644 run_all_conversion_tests.py create mode 100644 tests/resources/sample.csv create mode 100644 tests/resources/sample.jsonld create mode 100644 tests/resources/sample.nq create mode 100644 tests/resources/sample.nt create mode 100644 tests/resources/sample.rdf create mode 100644 tests/resources/sample.trig create mode 100644 tests/resources/sample.trix create mode 100644 tests/resources/sample.tsv create mode 100644 tests/resources/sample.ttl create mode 100644 tests/test_format_round_trips.py diff --git a/README.md b/README.md index f4227d8..77f049a 100644 --- a/README.md +++ b/README.md @@ -174,10 +174,10 @@ docker run --rm -v $(pwd):/data dbpedia/databus-python-client download $DOWNLOAD Note: Vault tokens are only required for certain protected Databus hosts (for example: `data.dbpedia.io`, `data.dev.dbpedia.link`). The client now detects those hosts and will fail early with a clear message if a token is required but not provided. Do not pass `--vault-token` for public downloads. - `--databus-key` - If the databus is protected and needs API key authentication, you can provide the API key with `--databus-key YOUR_API_KEY`. -- `--convert-to` - - Enables on-the-fly compression format conversion during download. Supported formats: `bz2`, `gz`, `xz`. Downloaded files will be automatically decompressed and recompressed to the target format. Example: `--convert-to gz` converts all downloaded compressed files to gzip format. -- `--convert-from` - - Optional filter to specify which source compression format should be converted. Use with `--convert-to` to convert only files with a specific compression format. Example: `--convert-to gz --convert-from bz2` converts only `.bz2` files to `.gz`, leaving other formats unchanged. +- `--compression` + - Enables on-the-fly compression format conversion during download. Supported formats: `bz2`, `gz`, `xz`. The source compression is auto-detected from the file extension. Example: `--compression gz` converts all downloaded compressed files to gzip format. +- `--format` + - Enables on-the-fly RDF and tabular format conversion during download (Layer 2). Supported formats: `ntriples` (`nt`), `turtle` (`ttl`), `rdf-xml` (`rdf`, `xml`), `nquads` (`nq`), `trig`, `trix`, `json-ld` (`jsonld`), `csv`, `tsv`. Short aliases shown in brackets. Only the converted output file is kept — the original is deleted after successful conversion. Example: `--format turtle` converts all downloaded RDF triple files to Turtle format. - `--validate-checksum` - Validates the checksums of downloaded files against the checksums provided by the Databus. If a checksum does not match, an error is raised and the file is deleted. @@ -272,16 +272,28 @@ databusclient download 'PREFIX dcat: SELECT ?x WHER docker run --rm -v $(pwd):/data dbpedia/databus-python-client download 'PREFIX dcat: SELECT ?x WHERE { ?sub dcat:downloadURL ?x . } LIMIT 10' --databus https://databus.dbpedia.org/sparql ``` -**Download with Compression Conversion**: download files and convert them to a different compression format on-the-fly +**Download with Compression Conversion**: download files and convert compression format on-the-fly. Source compression is auto-detected from the file extension. ```bash # Convert all compressed files to gzip format -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --convert-to gz - -# Convert only bz2 files to xz format, leaving other compressions unchanged -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals --convert-to xz --convert-from bz2 +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --compression gz # Download a collection and unify all files to bz2 format -databusclient download https://databus.dbpedia.org/dbpedia/collections/dbpedia-snapshot-2022-12 --convert-to bz2 +databusclient download https://databus.dbpedia.org/dbpedia/collections/dbpedia-snapshot-2022-12 --compression bz2 +``` + +**Download with Format Conversion**: download files and convert RDF or tabular format on-the-fly. Only the converted output file is kept. +```bash +# Convert RDF/XML to Turtle +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format turtle + +# Convert N-Quads to TriG (within quad equivalence class) +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --format trig + +# Convert RDF to CSV (cross-class, produces companion .meta.json) +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format csv + +# Combine format conversion and compression +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format ntriples --compression gz ``` diff --git a/databusclient/api/convert.py b/databusclient/api/convert.py new file mode 100644 index 0000000..fff0478 --- /dev/null +++ b/databusclient/api/convert.py @@ -0,0 +1,48 @@ +from databusclient.filehandling.format import convert_file, get_converted_filename +from databusclient.filehandling import mapping as _mapping + +from databusclient.filehandling.format import ( + QuadHandler, + TSDHandler, + TripleHandler, + _quad_handler, + _tsd_handler, + _triple_handler, +) + +__all__ = [ + "convert_file", + "get_converted_filename", + "QuadHandler", + "TSDHandler", + "TripleHandler", +] + +convert_rdf_to_csv = _mapping.convert_rdf_to_csv + + +def convert_rdf_triple_format( + source: str, + target: str, + input_format: str, + output_format: str, +) -> None: + _triple_handler.convert(source, target, input_format, output_format) + + +def convert_rdf_quad_format( + source: str, + target: str, + input_format: str, + output_format: str, +) -> None: + _quad_handler.convert(source, target, input_format, output_format) + + +def convert_tabular_format( + source: str, + target: str, + input_format: str, + output_format: str, +) -> None: + _tsd_handler.convert(source, target, input_format, output_format) \ No newline at end of file diff --git a/databusclient/api/download.py b/databusclient/api/download.py index ad7d76e..366ae76 100644 --- a/databusclient/api/download.py +++ b/databusclient/api/download.py @@ -5,6 +5,8 @@ import lzma from typing import List, Optional, Tuple import re +import shutil +import tempfile from urllib.parse import urlparse import requests @@ -16,6 +18,7 @@ get_databus_id_parts_from_file_url, compute_sha256_and_length, ) +from databusclient.filehandling.format import convert_file, get_converted_filename # Compression format mappings COMPRESSION_EXTENSIONS = { @@ -60,34 +63,34 @@ def _detect_compression_format(filename: str) -> Optional[str]: return None -def _should_convert_file( - filename: str, convert_to: Optional[str], convert_from: Optional[str] +def _should_convert_compression( + filename: str, compression: Optional[str] ) -> Tuple[bool, Optional[str]]: - """Determine if a file should be converted and what the source format is. + """Determine if a file should have its compression format converted or compressed. + + Source compression is detected automatically from the file extension. + If the file is uncompressed and a target compression is specified, + it will be compressed to the target format (source_format returned as None). Args: filename: Name of the file. - convert_to: Target compression format ('bz2', 'gz', 'xz'). - convert_from: Optional source compression format filter. + compression: Target compression format ('bz2', 'gz', 'xz') or None. Returns: Tuple of (should_convert: bool, source_format: Optional[str]). + source_format is None when the input file is uncompressed. """ - if not convert_to: + if not compression: return False, None source_format = _detect_compression_format(filename) # If file is not compressed, don't convert if source_format is None: - return False, None + return True, None # If source and target are the same, skip conversion - if source_format == convert_to: - return False, None - - # If convert_from is specified, only convert matching formats - if convert_from and source_format != convert_from: + if source_format == compression: return False, None return True, source_format @@ -324,8 +327,8 @@ def _download_file( databus_key=None, auth_url=None, client_id=None, - convert_to=None, - convert_from=None, + compression=None, + convert_format=None, validate_checksum: bool = False, expected_checksum: str | None = None, ) -> None: @@ -338,8 +341,9 @@ def _download_file( databus_key: Databus API key for protected downloads. auth_url: Keycloak token endpoint URL. client_id: Client ID for token exchange. - convert_to: Target compression format for on-the-fly conversion. - convert_from: Optional source compression format filter. + compression: Target compression format for on-the-fly conversion. + Source compression is auto-detected from the file extension. + convert_format: Target RDF/tabular format for on-the-fly conversion. validate_checksum: Whether to validate checksums after downloading. expected_checksum: The expected checksum of the file. """ @@ -354,6 +358,7 @@ def _download_file( dirpath = os.path.dirname(filename) if dirpath: os.makedirs(dirpath, exist_ok=True) # Create the necessary directories + # --- 1. Get redirect URL by requesting HEAD --- headers = {} @@ -510,14 +515,136 @@ def _download_file( f"Checksum mismatch for {filename}: expected {expected_checksum}, got {actual}" ) - # --- 7. Convert compression format if requested (AFTER validation) --- - should_convert, source_format = _should_convert_file(file, convert_to, convert_from) - if should_convert and source_format: - target_filename = _get_converted_filename(file, source_format, convert_to) - target_filepath = os.path.join(localDir, target_filename) - _convert_compression_format( - filename, target_filepath, source_format, convert_to - ) + # --- 7. Unified compression/format conversion pass --- + source_compression = _detect_compression_format(file) + should_convert_compression, source_fmt = _should_convert_compression( + file, compression + ) + needs_format_conversion = convert_format is not None + + if not should_convert_compression and not needs_format_conversion: + return + + temp_paths: list[str] = [] + try: + # Compression-only path: convert directly from the downloaded file. + # _convert_compression_format deletes the source after success, + # so the original downloaded file is removed automatically. + if should_convert_compression and not needs_format_conversion: + if source_fmt is None: + # Source file is uncompressed — compress it directly to + # the target compression format. + target_filepath = filename + COMPRESSION_EXTENSIONS[compression] + print( + f"Compressing {file} -> {os.path.basename(target_filepath)}..." + ) + with open(filename, "rb") as sf: + with COMPRESSION_MODULES[compression].open( + target_filepath, "wb" + ) as tf: + shutil.copyfileobj(sf, tf) + os.remove(filename) + print(f"Compression complete: {os.path.basename(target_filepath)}") + else: + target_filename = _get_converted_filename(file, source_fmt, compression) + target_filepath = os.path.join(localDir, target_filename) + _convert_compression_format( + filename, + target_filepath, + source_fmt, + compression, + ) + return + + # Early exit: if format conversion is requested but input format + # already matches target format, skip decompression and conversion + # entirely — no work needed for the format part. + if needs_format_conversion and source_compression is not None: + from databusclient.filehandling.format import ( + detect_format_from_filename, + normalize_format, + ) + detected_input_format = detect_format_from_filename(file) + normalized_target = normalize_format(convert_format) + if detected_input_format == normalized_target: + # Format is already correct. Only handle compression if needed. + if should_convert_compression and compression: + target_filename = _get_converted_filename( + file, source_fmt, compression + ) + target_filepath = os.path.join(localDir, target_filename) + _convert_compression_format( + filename, target_filepath, source_fmt, compression + ) + # No format conversion needed, no further work. + return + + # Determine input for format conversion. + # If source is compressed, decompress once to a safe temporary file. + conversion_input_path = filename + if source_compression is not None: + source_ext = COMPRESSION_EXTENSIONS[source_compression] + stripped_name = file + if stripped_name.lower().endswith(source_ext): + stripped_name = stripped_name[: -len(source_ext)] + _, format_ext = os.path.splitext(stripped_name) + + with tempfile.NamedTemporaryFile( + delete=False, + suffix=format_ext, + dir=localDir, + ) as temp_decompressed: + temp_decompressed_path = temp_decompressed.name + temp_paths.append(temp_decompressed_path) + + print(f"Decompressing {file}...") + with COMPRESSION_MODULES[source_compression].open(filename, "rb") as sf: + with open(temp_decompressed_path, "wb") as tf: + shutil.copyfileobj(sf, tf) + + conversion_input_path = temp_decompressed_path + + # Convert format on uncompressed input. + converted_basename = get_converted_filename(file, convert_format) + converted_uncompressed_path = os.path.join(localDir, converted_basename) + convert_file(conversion_input_path, converted_uncompressed_path, convert_format) + + # Delete the original downloaded file after successful format conversion, + # unless the converted output is the same file (same format, same path). + if os.path.abspath(filename) != os.path.abspath(converted_uncompressed_path): + if os.path.exists(filename): + os.remove(filename) + print(f"Removed original file: {os.path.basename(filename)}") + + # Recompress converted output when needed. + if source_compression is not None: + if should_convert_compression and compression: + final_compression = compression + else: + final_compression = source_compression + elif should_convert_compression and compression: + final_compression = compression + else: + final_compression = None + + if final_compression is not None: + recompressed_path = ( + converted_uncompressed_path + COMPRESSION_EXTENSIONS[final_compression] + ) + print( + f"Recompressing {os.path.basename(converted_uncompressed_path)} -> {os.path.basename(recompressed_path)}..." + ) + with open(converted_uncompressed_path, "rb") as sf: + with COMPRESSION_MODULES[final_compression].open( + recompressed_path, "wb" + ) as tf: + shutil.copyfileobj(sf, tf) + + os.remove(converted_uncompressed_path) + finally: + for temp_path in temp_paths: + if os.path.exists(temp_path): + os.remove(temp_path) def _download_files( @@ -527,8 +654,8 @@ def _download_files( databus_key: str = None, auth_url: str = None, client_id: str = None, - convert_to: str = None, - convert_from: str = None, + compression: str = None, + convert_format: str = None, validate_checksum: bool = False, checksums: dict | None = None, ) -> None: @@ -541,8 +668,8 @@ def _download_files( databus_key: Databus API key for protected downloads. auth_url: Keycloak token endpoint URL. client_id: Client ID for token exchange. - convert_to: Target compression format for on-the-fly conversion. - convert_from: Optional source compression format filter. + compression: Target compression format for on-the-fly conversion. + convert_format: Target RDF/tabular format for on-the-fly conversion. validate_checksum: Whether to validate checksums after downloading. checksums: Dictionary mapping URLs to their expected checksums. """ @@ -557,8 +684,8 @@ def _download_files( databus_key=databus_key, auth_url=auth_url, client_id=client_id, - convert_to=convert_to, - convert_from=convert_from, + compression=compression, + convert_format=convert_format, validate_checksum=validate_checksum, expected_checksum=expected, ) @@ -705,8 +832,8 @@ def _download_collection( databus_key: str = None, auth_url: str = None, client_id: str = None, - convert_to: str = None, - convert_from: str = None, + compression: str = None, + convert_format: str = None, validate_checksum: bool = False, ) -> None: """Download all files in a databus collection. @@ -719,8 +846,8 @@ def _download_collection( databus_key: Databus API key for protected downloads. auth_url: Keycloak token endpoint URL. client_id: Client ID for token exchange. - convert_to: Target compression format for on-the-fly conversion. - convert_from: Optional source compression format filter. + compression: Target compression format for on-the-fly conversion. + convert_format: Target RDF/tabular format for on-the-fly conversion. validate_checksum: Whether to validate checksums after downloading. """ query = _get_sparql_query_of_collection(uri, databus_key=databus_key) @@ -740,8 +867,8 @@ def _download_collection( databus_key=databus_key, auth_url=auth_url, client_id=client_id, - convert_to=convert_to, - convert_from=convert_from, + compression=compression, + convert_format=convert_format, validate_checksum=validate_checksum, checksums=checksums if checksums else None, ) @@ -754,8 +881,8 @@ def _download_version( databus_key: str = None, auth_url: str = None, client_id: str = None, - convert_to: str = None, - convert_from: str = None, + compression: str = None, + convert_format: str = None, validate_checksum: bool = False, ) -> None: """Download all files in a databus artifact version. @@ -767,8 +894,8 @@ def _download_version( databus_key: Databus API key for protected downloads. auth_url: Keycloak token endpoint URL. client_id: Client ID for token exchange. - convert_to: Target compression format for on-the-fly conversion. - convert_from: Optional source compression format filter. + compression: Target compression format for on-the-fly conversion. + convert_format: Target RDF/tabular format for on-the-fly conversion. validate_checksum: Whether to validate checksums after downloading. """ json_str = fetch_databus_jsonld(uri, databus_key=databus_key) @@ -787,8 +914,8 @@ def _download_version( databus_key=databus_key, auth_url=auth_url, client_id=client_id, - convert_to=convert_to, - convert_from=convert_from, + compression=compression, + convert_format=convert_format, validate_checksum=validate_checksum, checksums=checksums, ) @@ -802,8 +929,8 @@ def _download_artifact( databus_key: str = None, auth_url: str = None, client_id: str = None, - convert_to: str = None, - convert_from: str = None, + compression: str = None, + convert_format: str = None, validate_checksum: bool = False, ) -> None: """Download files in a databus artifact. @@ -816,8 +943,8 @@ def _download_artifact( databus_key: Databus API key for protected downloads. auth_url: Keycloak token endpoint URL. client_id: Client ID for token exchange. - convert_to: Target compression format for on-the-fly conversion. - convert_from: Optional source compression format filter. + compression: Target compression format for on-the-fly conversion. + convert_format: Target RDF/tabular format for on-the-fly conversion. validate_checksum: Whether to validate checksums after downloading. """ json_str = fetch_databus_jsonld(uri, databus_key=databus_key) @@ -842,8 +969,8 @@ def _download_artifact( databus_key=databus_key, auth_url=auth_url, client_id=client_id, - convert_to=convert_to, - convert_from=convert_from, + compression=compression, + convert_format=convert_format, validate_checksum=validate_checksum, checksums=checksums, ) @@ -918,8 +1045,8 @@ def _download_group( databus_key: str = None, auth_url: str = None, client_id: str = None, - convert_to: str = None, - convert_from: str = None, + compression: str = None, + convert_format: str = None, validate_checksum: bool = False, ) -> None: """Download files in a databus group. @@ -932,8 +1059,8 @@ def _download_group( databus_key: Databus API key for protected downloads. auth_url: Keycloak token endpoint URL. client_id: Client ID for token exchange. - convert_to: Target compression format for on-the-fly conversion. - convert_from: Optional source compression format filter. + compression: Target compression format for on-the-fly conversion. + convert_format: Target RDF/tabular format for on-the-fly conversion. validate_checksum: Whether to validate checksums after downloading. """ json_str = fetch_databus_jsonld(uri, databus_key=databus_key) @@ -948,8 +1075,8 @@ def _download_group( databus_key=databus_key, auth_url=auth_url, client_id=client_id, - convert_to=convert_to, - convert_from=convert_from, + compression=compression, + convert_format=convert_format, validate_checksum=validate_checksum, ) @@ -997,8 +1124,8 @@ def download( all_versions=None, auth_url="https://auth.dbpedia.org/realms/dbpedia/protocol/openid-connect/token", client_id="vault-token-exchange", - convert_to=None, - convert_from=None, + compression=None, + convert_format=None, validate_checksum: bool = False, ) -> None: """Download datasets from databus. @@ -1013,8 +1140,9 @@ def download( databus_key: Databus API key for protected downloads. auth_url: Keycloak token endpoint URL. Default is "https://auth.dbpedia.org/realms/dbpedia/protocol/openid-connect/token". client_id: Client ID for token exchange. Default is "vault-token-exchange". - convert_to: Target compression format for on-the-fly conversion (supported: bz2, gz, xz). - convert_from: Optional source compression format filter. + compression: Target compression format for on-the-fly conversion (supported: bz2, gz, xz). + Source compression is auto-detected from the file extension. + convert_format: Target RDF/tabular format for on-the-fly conversion. validate_checksum: Whether to validate checksums after downloading. """ for databusURI in databusURIs: @@ -1042,8 +1170,8 @@ def download( databus_key, auth_url, client_id, - convert_to, - convert_from, + compression, + convert_format, validate_checksum=validate_checksum, ) elif file is not None: @@ -1063,8 +1191,8 @@ def download( databus_key=databus_key, auth_url=auth_url, client_id=client_id, - convert_to=convert_to, - convert_from=convert_from, + compression=compression, + convert_format=convert_format, validate_checksum=validate_checksum, expected_checksum=expected, ) @@ -1077,8 +1205,8 @@ def download( databus_key=databus_key, auth_url=auth_url, client_id=client_id, - convert_to=convert_to, - convert_from=convert_from, + compression=compression, + convert_format=convert_format, validate_checksum=validate_checksum, ) elif artifact is not None: @@ -1093,8 +1221,8 @@ def download( databus_key=databus_key, auth_url=auth_url, client_id=client_id, - convert_to=convert_to, - convert_from=convert_from, + compression=compression, + convert_format=convert_format, validate_checksum=validate_checksum, ) elif group is not None and group != "collections": @@ -1109,8 +1237,8 @@ def download( databus_key=databus_key, auth_url=auth_url, client_id=client_id, - convert_to=convert_to, - convert_from=convert_from, + compression=compression, + convert_format=convert_format, validate_checksum=validate_checksum, ) elif account is not None: @@ -1147,8 +1275,8 @@ def download( databus_key=databus_key, auth_url=auth_url, client_id=client_id, - convert_to=convert_to, - convert_from=convert_from, + compression=compression, + convert_format=convert_format, validate_checksum=validate_checksum, checksums=checksums if checksums else None, - ) + ) \ No newline at end of file diff --git a/databusclient/cli.py b/databusclient/cli.py index 4f129eb..5301f76 100644 --- a/databusclient/cli.py +++ b/databusclient/cli.py @@ -180,14 +180,33 @@ def deploy( help="Client ID for token exchange", ) @click.option( - "--convert-to", + "--compression", + "compression", type=click.Choice(["bz2", "gz", "xz"], case_sensitive=False), - help="Target compression format for on-the-fly conversion during download (supported: bz2, gz, xz)", + help="Target compression format for on-the-fly conversion during download. " + "Source compression is detected automatically from the file extension. " + "All compressed files will be converted to the target format (bz2, gz, xz).", ) @click.option( - "--convert-from", - type=click.Choice(["bz2", "gz", "xz"], case_sensitive=False), - help="Source compression format to convert from (optional filter). Only files with this compression will be converted.", + "--format", + "convert_format", + type=click.Choice( + [ + "ntriples", "nt", + "turtle", "ttl", + "rdf-xml", "rdf", "xml", + "nquads", "nq", + "trig", + "trix", + "json-ld", "jsonld", + "csv", + "tsv", + ], + case_sensitive=False, + ), + help="Target format for on-the-fly format conversion during download (Layer 2 and Layer 3). " + "Accepts full names (ntriples, turtle, rdf-xml, nquads, trig, trix, json-ld, csv, tsv) " + "or short aliases (nt, ttl, rdf, xml, nq, jsonld).", ) @click.option( "--validate-checksum", is_flag=True, help="Validate checksums of downloaded files" @@ -201,8 +220,8 @@ def download( all_versions, authurl, clientid, - convert_to, - convert_from, + compression, + convert_format, validate_checksum, ): """ @@ -219,8 +238,8 @@ def download( all_versions=all_versions, auth_url=authurl, client_id=clientid, - convert_to=convert_to, - convert_from=convert_from, + compression=compression, + convert_format=convert_format, validate_checksum=validate_checksum, ) except DownloadAuthError as e: diff --git a/databusclient/filehandling/format.py b/databusclient/filehandling/format.py new file mode 100644 index 0000000..7c40109 --- /dev/null +++ b/databusclient/filehandling/format.py @@ -0,0 +1,556 @@ +"""Format and Mapping Conversion Layer. + +This module implements the format conversion pipeline for the Databus Python Client + +Layer 2: Within-class format conversion (lossless). + - TripleHandler: RDF triple formats (turtle, ntriples, rdf-xml) + - QuadHandler: RDF quad formats (nquads, trig, trix, json-ld) + - TSDHandler: Tabular formats (csv, tsv) + +Each handler provides read() -> IR, write(IR) -> file, convert() -> chains both. +The IR (intermediate representation) returned by read() is designed to be passed +to future mapping classes (TripleToQuadMapper, TripleToTSDMapper, etc.). +""" + +import csv +import os +import shutil +import warnings +from typing import Optional + +from rdflib import Dataset, Graph + +# Suppress rdflib internal DeprecationWarning for Dataset API. +# rdflib is mid-migration from ConjunctiveGraph to Dataset in 7.x. +# These warnings originate from rdflib internals, not our code. +# Can be removed when rdflib completes their Dataset API migration. +warnings.filterwarnings("ignore", category=DeprecationWarning, module="rdflib") +warnings.filterwarnings("ignore", category=UserWarning, module="rdflib") + + +# --------------------------------------------------------------------------- +# Format registries +# --------------------------------------------------------------------------- + +# Maps CLI format name -> rdflib format string +RDF_TRIPLE_FORMATS = { + "ntriples": "ntriples", + "turtle": "turtle", + "rdf-xml": "xml", +} + +RDF_QUAD_FORMATS = { + "nquads": "nquads", + "trig": "trig", + "trix": "trix", + "json-ld": "json-ld", +} + +TABULAR_FORMATS = { + "csv": ",", + "tsv": "\t", +} + +ALL_FORMATS = ( + list(RDF_TRIPLE_FORMATS) + + list(RDF_QUAD_FORMATS) + + list(TABULAR_FORMATS) +) + +# Maps short CLI aliases -> canonical format name +FORMAT_ALIASES = { + "nt": "ntriples", + "ttl": "turtle", + "rdf": "rdf-xml", + "xml": "rdf-xml", + "nq": "nquads", + "jsonld": "json-ld", +} + +def normalize_format(fmt: str) -> str: + """Normalize a format name or alias to its canonical form. + + Accepts both full names (e.g. 'ntriples') and short aliases (e.g. 'nt'). + Canonical names pass through unchanged. Unknown values raise ValueError. + + Args: + fmt: Format name or alias string (case-insensitive). + + Returns: + Canonical format name string. + + Raises: + ValueError: If fmt is not a recognised format name or alias. + """ + fmt_lower = fmt.lower() + # Resolve alias first + canonical = FORMAT_ALIASES.get(fmt_lower, fmt_lower) + if canonical not in ALL_FORMATS: + raise ValueError( + f"Unknown format: '{fmt}'. " + f"Supported formats: {ALL_FORMATS}. " + f"Supported aliases: {list(FORMAT_ALIASES.keys())}" + ) + return canonical + +# Maps file extension -> CLI format name +EXTENSION_TO_FORMAT = { + ".ttl": "turtle", + ".nt": "ntriples", + ".rdf": "rdf-xml", + ".xml": "rdf-xml", + ".owl": "rdf-xml", + ".nq": "nquads", + ".trig": "trig", + ".trix": "trix", + ".jsonld": "json-ld", + ".json": "json-ld", + ".csv": "csv", + ".tsv": "tsv", +} + +# Maps format name -> file extension +FORMAT_TO_EXTENSION = { + "ntriples": ".nt", + "turtle": ".ttl", + "rdf-xml": ".rdf", + "nquads": ".nq", + "trig": ".trig", + "trix": ".trix", + "json-ld": ".jsonld", + "csv": ".csv", + "tsv": ".tsv", +} + + +# --------------------------------------------------------------------------- +# Format detection helpers +# --------------------------------------------------------------------------- + +def detect_format_from_filename(filename: str) -> Optional[str]: + """Detect format from file extension, ignoring compression extensions. + + Args: + filename: File name or path. + + Returns: + Format name string or None if not detectable. + """ + name = filename.lower() + + # strip compression extension first + for ext in (".bz2", ".gz", ".xz"): + if name.endswith(ext): + name = name[: -len(ext)] + break + + # match longest extension first to avoid .json matching before .jsonld + for ext in sorted(EXTENSION_TO_FORMAT.keys(), key=len, reverse=True): + if name.endswith(ext): + return EXTENSION_TO_FORMAT[ext] + + return None + + +def get_format_class(fmt: str) -> str: + """Return equivalence class for a format name. + + Args: + fmt: Format name (e.g. 'turtle', 'nquads', 'csv'). + + Returns: + 'triples', 'quads', or 'tabular'. + + Raises: + ValueError: If format is not recognised. + """ + if fmt in RDF_TRIPLE_FORMATS: + return "triples" + if fmt in RDF_QUAD_FORMATS: + return "quads" + if fmt in TABULAR_FORMATS: + return "tabular" + raise ValueError( + f"Unknown format: '{fmt}'. Supported formats: {ALL_FORMATS}" + ) + + +def get_converted_filename(original_filename: str, convert_format: str) -> str: + """Generate output filename after format conversion. + + Strips compression extension if present, then replaces the format + extension with the target format extension. Accepts format aliases. + + Args: + original_filename: Original file name (basename only, not full path). + convert_format: Target format name or alias. + + Returns: + New filename with updated extension. + """ + # Normalize alias to canonical name + convert_format = normalize_format(convert_format) + + name = original_filename + + # strip compression extension + for ext in (".bz2", ".gz", ".xz"): + if name.lower().endswith(ext): + name = name[: -len(ext)] + break + + # strip existing format extension (longest first) + for old_ext in sorted(FORMAT_TO_EXTENSION.values(), key=len, reverse=True): + if name.lower().endswith(old_ext): + name = name[: -len(old_ext)] + break + + target_ext = FORMAT_TO_EXTENSION.get(convert_format, f".{convert_format}") + return name + target_ext + + +# --------------------------------------------------------------------------- +# Layer 2 Handlers +# --------------------------------------------------------------------------- + +class TripleHandler: + """Handler for RDF triple formats (Layer 2). + + Uses rdflib.Graph as the intermediate representation (IR). + Supports: ntriples, turtle, rdf-xml. + + The IR returned by read() can be passed to future mapping classes + such as TripleToQuadMapper or TripleToTSDMapper for Layer 3 conversions. + """ + + def read(self, source: str, input_format: str) -> Graph: + """Parse an RDF triples file into a Graph (IR). + + Args: + source: Path to input file. + input_format: Source format name (e.g. 'turtle', 'ntriples', 'rdf-xml'). + + Returns: + rdflib.Graph containing all parsed triples. + + Raises: + ValueError: If input_format is not a recognised triple format. + """ + if input_format not in RDF_TRIPLE_FORMATS: + raise ValueError( + f"'{input_format}' is not a triple format. " + f"Supported: {list(RDF_TRIPLE_FORMATS)}" + ) + g = Graph() + g.parse(source, format=RDF_TRIPLE_FORMATS[input_format]) + return g + + def write(self, data: Graph, target: str, output_format: str) -> None: + """Serialize a Graph (IR) to a file. + + Args: + data: rdflib.Graph to serialize. + target: Path to output file. + output_format: Target format name (e.g. 'ntriples', 'turtle'). + + Raises: + ValueError: If output_format is not a recognised triple format. + """ + if output_format not in RDF_TRIPLE_FORMATS: + raise ValueError( + f"'{output_format}' is not a triple format. " + f"Supported: {list(RDF_TRIPLE_FORMATS)}" + ) + parent = os.path.dirname(target) + if parent: + os.makedirs(parent, exist_ok=True) + # Explicitly specify utf-8 encoding to avoid NTSerializer warning + data.serialize( + destination=target, + format=RDF_TRIPLE_FORMATS[output_format], + encoding="utf-8", + ) + + def convert( + self, + source: str, + target: str, + input_format: str, + output_format: str, + ) -> None: + """Convert between RDF triple formats (Layer 2, lossless). + + Chains read() -> write(). Both formats must be in the same + equivalence class (RDF triples). + + Args: + source: Path to input file. + target: Path to output file. + input_format: Source format name. + output_format: Target format name. + """ + graph = self.read(source, input_format) + self.write(graph, target, output_format) + print( + f"Converted {input_format} -> {output_format}: " + f"{os.path.basename(target)}" + ) + + +class QuadHandler: + """Handler for RDF quad formats (Layer 2). + + Uses rdflib.Dataset as the intermediate representation (IR). + Supports: nquads, trig, trix, json-ld. + + Named graph information is preserved through the Dataset IR. + The IR returned by read() can be passed to future mapping classes + such as QuadToTripleMapper or QuadToTSDMapper for Layer 3 conversions. + """ + + def read(self, source: str, input_format: str) -> Dataset: + """Parse an RDF quads file into a Dataset (IR). + + Args: + source: Path to input file. + input_format: Source format name (e.g. 'nquads', 'trig', 'trix', 'json-ld'). + + Returns: + rdflib.Dataset containing all parsed quads with named graphs. + + Raises: + ValueError: If input_format is not a recognised quad format. + """ + if input_format not in RDF_QUAD_FORMATS: + raise ValueError( + f"'{input_format}' is not a quad format. " + f"Supported: {list(RDF_QUAD_FORMATS)}" + ) + d = Dataset() + d.parse(source, format=RDF_QUAD_FORMATS[input_format]) + return d + + def write(self, data: Dataset, target: str, output_format: str) -> None: + """Serialize a Dataset (IR) to a file. + + Args: + data: rdflib.Dataset to serialize. + target: Path to output file. + output_format: Target format name. + + Raises: + ValueError: If output_format is not a recognised quad format. + """ + if output_format not in RDF_QUAD_FORMATS: + raise ValueError( + f"'{output_format}' is not a quad format. " + f"Supported: {list(RDF_QUAD_FORMATS)}" + ) + parent = os.path.dirname(target) + if parent: + os.makedirs(parent, exist_ok=True) + data.serialize( + destination=target, + format=RDF_QUAD_FORMATS[output_format], + ) + + def convert( + self, + source: str, + target: str, + input_format: str, + output_format: str, + ) -> None: + """Convert between RDF quad formats (Layer 2, lossless). + + Chains read() -> write(). Both formats must be in the same + equivalence class (RDF quads). Named graph information is preserved. + + Args: + source: Path to input file. + target: Path to output file. + input_format: Source format name. + output_format: Target format name. + """ + dataset = self.read(source, input_format) + self.write(dataset, target, output_format) + print( + f"Converted {input_format} -> {output_format}: " + f"{os.path.basename(target)}" + ) + + +class TSDHandler: + """Handler for tabular structured data formats (Layer 2). + + Uses list[list[str]] as the intermediate representation (IR). + Supports: csv, tsv. + + The IR returned by read() can be passed to future mapping classes + such as TSDToTripleMapper for Layer 3 conversions. + """ + + def read(self, source: str, input_format: str) -> list: + """Parse a tabular file into a list of rows (IR). + + Each row is a list of string values. First row is the header. + + Args: + source: Path to input file. + input_format: Source format name ('csv' or 'tsv'). + + Returns: + list[list[str]] where first element is the header row. + + Raises: + ValueError: If input_format is not a recognised tabular format. + """ + if input_format not in TABULAR_FORMATS: + raise ValueError( + f"'{input_format}' is not a tabular format. " + f"Supported: {list(TABULAR_FORMATS)}" + ) + delimiter = TABULAR_FORMATS[input_format] + with open(source, "r", newline="", encoding="utf-8") as f: + reader = csv.reader(f, delimiter=delimiter) + return list(reader) + + def write(self, data: list, target: str, output_format: str) -> None: + """Serialize a list of rows (IR) to a tabular file. + + Args: + data: list[list[str]] to write. + target: Path to output file. + output_format: Target format name ('csv' or 'tsv'). + + Raises: + ValueError: If output_format is not a recognised tabular format. + """ + if output_format not in TABULAR_FORMATS: + raise ValueError( + f"'{output_format}' is not a tabular format. " + f"Supported: {list(TABULAR_FORMATS)}" + ) + parent = os.path.dirname(target) + if parent: + os.makedirs(parent, exist_ok=True) + delimiter = TABULAR_FORMATS[output_format] + with open(target, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f, delimiter=delimiter) + writer.writerows(data) + + def convert( + self, + source: str, + target: str, + input_format: str, + output_format: str, + ) -> None: + """Convert between tabular formats (Layer 2, lossless). + + Chains read() -> write(). Both formats must be in the same + equivalence class (tabular). + + Args: + source: Path to input file. + target: Path to output file. + input_format: Source format name. + output_format: Target format name. + """ + rows = self.read(source, input_format) + self.write(rows, target, output_format) + print( + f"Converted {input_format} -> {output_format}: " + f"{os.path.basename(target)}" + ) + + +# --------------------------------------------------------------------------- +# Main dispatcher — called from download pipeline +# --------------------------------------------------------------------------- + +# Handler instances — created once, reused +_triple_handler = TripleHandler() +_quad_handler = QuadHandler() +_tsd_handler = TSDHandler() + + +def convert_file( + input_file: str, + output_file: str, + convert_format: str, +) -> None: + """Main conversion dispatcher called from the download pipeline. + + Detects the input format from the file extension, determines whether + this is a Layer 2 (within-class) or Layer 3 (cross-class) conversion, + and delegates to the appropriate handler. + + Accepts both canonical format names and short aliases (e.g. 'nt' for + 'ntriples', 'ttl' for 'turtle'). See normalize_format() for full list. + + Args: + input_file: Path to the input file (must be decompressed). + output_file: Path to write the converted output file. + convert_format: Target format name or alias (CLI format string). + + Raises: + ValueError: If input format cannot be detected or conversion + is not supported. + """ + # Normalize alias to canonical name before any processing + convert_format = normalize_format(convert_format) + + input_format = detect_format_from_filename(input_file) + + if input_format is None: + raise ValueError( + f"Could not detect input format from filename: " + f"'{os.path.basename(input_file)}'. " + f"Supported extensions: {list(EXTENSION_TO_FORMAT.keys())}" + ) + + if input_format == convert_format: + # Input and target format are identical. + # Copy input to output path so the caller always receives an output file. + # This is important for the download pipeline which expects an output + # file to exist after convert_file() returns — e.g. for recompression. + if input_file != output_file: + shutil.copy2(input_file, output_file) + print( + f"Input and target format are both '{input_format}'. " + f"Copied to output path: {os.path.basename(output_file)}" + ) + return + + input_class = get_format_class(input_format) + output_class = get_format_class(convert_format) + + # --- Layer 2: within-class --- + if input_class == output_class: + if input_class == "triples": + _triple_handler.convert( + input_file, output_file, input_format, convert_format + ) + elif input_class == "quads": + _quad_handler.convert( + input_file, output_file, input_format, convert_format + ) + elif input_class == "tabular": + _tsd_handler.convert( + input_file, output_file, input_format, convert_format + ) + return + + # --- Layer 3: cross-class (prototype only) --- + if input_class == "triples" and output_class == "tabular": + from databusclient.filehandling.mapping import convert_rdf_to_csv + + convert_rdf_to_csv(input_file, output_file, input_format) + return + + raise ValueError( + f"Conversion from '{input_format}' ({input_class}) to " + f"'{convert_format}' ({output_class}) is not yet implemented. " + f"Supported Layer 3 conversions: RDF Triples -> CSV/TSV." + ) diff --git a/databusclient/filehandling/mapping.py b/databusclient/filehandling/mapping.py new file mode 100644 index 0000000..93b5a00 --- /dev/null +++ b/databusclient/filehandling/mapping.py @@ -0,0 +1,68 @@ +"""Layer 3 prototype mapping handlers.""" + +import json +import os + +from databusclient.filehandling.format import TSDHandler, TripleHandler + + +def convert_rdf_to_csv( + input_file: str, + output_file: str, + input_format: str, +) -> None: + """Map RDF triples to a wide CSV table (Layer 3 prototype). + + Each unique subject becomes a row. Each unique predicate becomes a column. + Multi-valued predicates are pipe-separated. + A companion .meta.json file is generated to preserve RDF datatype and + language tag information for lossless round trips. + + NOTE: This is a Layer 3 prototype. It is not yet tested and will be + properly implemented in the Layer 3 issue. + + Args: + input_file: Path to input RDF triples file. + output_file: Path to write output CSV file. + input_format: Source triple format name (must be in RDF_TRIPLE_FORMATS). + """ + handler = TripleHandler() + g = handler.read(input_file, input_format) + + predicates = sorted(set(str(p) for s, p, o in g)) + + subjects: dict = {} + column_metadata: dict = {} + + for s, p, o in g: + subj = str(s) + pred = str(p) + + if hasattr(o, "datatype") and o.datatype: + column_metadata[pred] = {"datatype": str(o.datatype)} + elif hasattr(o, "language") and o.language: + column_metadata[pred] = {"language": str(o.language)} + + if subj not in subjects: + subjects[subj] = {} + if pred not in subjects[subj]: + subjects[subj][pred] = [] + subjects[subj][pred].append(str(o)) + + tsd_handler = TSDHandler() + rows = [["resource"] + predicates] + for subj, pred_map in subjects.items(): + row = [subj] + for pred in predicates: + values = pred_map.get(pred, []) + row.append("|".join(values)) + rows.append(row) + + tsd_handler.write(rows, output_file, "csv") + + companion_file = output_file + ".meta.json" + with open(companion_file, "w", encoding="utf-8") as f: + json.dump({"columns": column_metadata}, f, indent=2) + + print(f"Converted RDF -> CSV: {os.path.basename(output_file)}") + print(f"Companion metadata: {os.path.basename(companion_file)}") diff --git a/pyproject.toml b/pyproject.toml index 0122e7d..f937010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,14 @@ databusclient = "databusclient.cli:app" target-version = "py311" src = ["databusclient", "tests"] + + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +filterwarnings = [ + "ignore::DeprecationWarning:rdflib", + "ignore::UserWarning:rdflib", +] diff --git a/run_all_conversion_tests.py b/run_all_conversion_tests.py new file mode 100644 index 0000000..98f9bbb --- /dev/null +++ b/run_all_conversion_tests.py @@ -0,0 +1,342 @@ +""" +Layer 2 Conversion Testing Script +Tests every conversion combination systematically. +Outputs go to test_outputs/ folder. +Test file for testing with real datasets from databus. +""" + +# TODO: This script is a temporary manual integration test artifact. +# It must be removed or rewritten as proper pytest integration tests +# before the final PR. Do not commit this file to the upstream repo. + +import os +from databusclient.api.convert import ( + convert_rdf_triple_format, + convert_rdf_quad_format, + convert_tabular_format, +) + +# --------------------------------------------------------------------------- +# Setup output folders +# --------------------------------------------------------------------------- + +folders = [ + "test_outputs/triples/T1_turtle_to_ntriples", + "test_outputs/triples/T2_turtle_to_rdfxml", + "test_outputs/triples/T3_ntriples_to_turtle", + "test_outputs/triples/T4_ntriples_to_rdfxml", + "test_outputs/triples/T5_rdfxml_to_turtle", + "test_outputs/triples/T6_rdfxml_to_ntriples", + "test_outputs/quads/Q1_nquads_to_trig", + "test_outputs/quads/Q2_nquads_to_trix", + "test_outputs/quads/Q3_nquads_to_jsonld", + "test_outputs/quads/Q4_trig_to_nquads", + "test_outputs/quads/Q5_trig_to_trix", + "test_outputs/quads/Q6_trig_to_jsonld", + "test_outputs/quads/Q7_trix_to_nquads", + "test_outputs/quads/Q8_trix_to_trig", + "test_outputs/quads/Q9_trix_to_jsonld", + "test_outputs/quads/Q10_jsonld_to_nquads", + "test_outputs/quads/Q11_jsonld_to_trig", + "test_outputs/quads/Q12_jsonld_to_trix", + "test_outputs/tabular/TAB1_csv_to_tsv", + "test_outputs/tabular/TAB2_tsv_to_csv", +] + +for folder in folders: + os.makedirs(folder, exist_ok=True) + +results = [] + + +def run_test(test_id, description, func, input_file, output_file, *args): + """Run one conversion test and record the result.""" + try: + func(input_file, output_file, *args) + size = os.path.getsize(output_file) + results.append(f"PASS {test_id}: {description} -> {os.path.basename(output_file)} ({size} bytes)") + return output_file + except Exception as e: + results.append(f"FAIL {test_id}: {description} -> ERROR: {e}") + return None + + +# --------------------------------------------------------------------------- +# GROUP 1: RDF Triple Format Conversions +# 6 combinations: each format -> every other format +# Base file: test_outputs/base/base.ttl (real DBpedia Turtle data) +# Chain: turtle -> ntriples -> rdfxml -> back to turtle +# --------------------------------------------------------------------------- + +print("\n=== GROUP 1: RDF TRIPLE FORMAT CONVERSIONS ===\n") + +BASE_TTL = "test_outputs/base/base.ttl" + +# T1: turtle -> ntriples (from base turtle file) +t1_out = "test_outputs/triples/T1_turtle_to_ntriples/output.nt" +run_test( + "T1", "turtle -> ntriples", + convert_rdf_triple_format, + BASE_TTL, t1_out, "turtle", "ntriples" +) + +# T2: turtle -> rdf-xml (from base turtle file) +t2_out = "test_outputs/triples/T2_turtle_to_rdfxml/output.rdf" +run_test( + "T2", "turtle -> rdf-xml", + convert_rdf_triple_format, + BASE_TTL, t2_out, "turtle", "rdf-xml" +) + +# T3: ntriples -> turtle (uses T1 output) +t3_out = "test_outputs/triples/T3_ntriples_to_turtle/output.ttl" +if t1_out and os.path.exists(t1_out): + run_test( + "T3", "ntriples -> turtle", + convert_rdf_triple_format, + t1_out, t3_out, "ntriples", "turtle" + ) +else: + results.append("SKIP T3: ntriples -> turtle (T1 output not available)") + +# T4: ntriples -> rdf-xml (uses T1 output) +t4_out = "test_outputs/triples/T4_ntriples_to_rdfxml/output.rdf" +if t1_out and os.path.exists(t1_out): + run_test( + "T4", "ntriples -> rdf-xml", + convert_rdf_triple_format, + t1_out, t4_out, "ntriples", "rdf-xml" + ) +else: + results.append("SKIP T4: ntriples -> rdf-xml (T1 output not available)") + +# T5: rdf-xml -> turtle (uses T2 output) +t5_out = "test_outputs/triples/T5_rdfxml_to_turtle/output.ttl" +if t2_out and os.path.exists(t2_out): + run_test( + "T5", "rdf-xml -> turtle", + convert_rdf_triple_format, + t2_out, t5_out, "rdf-xml", "turtle" + ) +else: + results.append("SKIP T5: rdf-xml -> turtle (T2 output not available)") + +# T6: rdf-xml -> ntriples (uses T2 output) +t6_out = "test_outputs/triples/T6_rdfxml_to_ntriples/output.nt" +if t2_out and os.path.exists(t2_out): + run_test( + "T6", "rdf-xml -> ntriples", + convert_rdf_triple_format, + t2_out, t6_out, "rdf-xml", "ntriples" + ) +else: + results.append("SKIP T6: rdf-xml -> ntriples (T2 output not available)") + + +# --------------------------------------------------------------------------- +# GROUP 2: RDF Quad Format Conversions +# 12 combinations: each of 4 formats -> every other format (4*3=12) +# Base file: test_outputs/base/base.nq +# Chain: nquads -> trig -> trix -> jsonld -> back to nquads +# --------------------------------------------------------------------------- + +print("\n=== GROUP 2: RDF QUAD FORMAT CONVERSIONS ===\n") + +BASE_NQ = "test_outputs/base/base.nq" + +# Q1: nquads -> trig +q1_out = "test_outputs/quads/Q1_nquads_to_trig/output.trig" +run_test( + "Q1", "nquads -> trig", + convert_rdf_quad_format, + BASE_NQ, q1_out, "nquads", "trig" +) + +# Q2: nquads -> trix +q2_out = "test_outputs/quads/Q2_nquads_to_trix/output.trix" +run_test( + "Q2", "nquads -> trix", + convert_rdf_quad_format, + BASE_NQ, q2_out, "nquads", "trix" +) + +# Q3: nquads -> json-ld +q3_out = "test_outputs/quads/Q3_nquads_to_jsonld/output.jsonld" +run_test( + "Q3", "nquads -> json-ld", + convert_rdf_quad_format, + BASE_NQ, q3_out, "nquads", "json-ld" +) + +# Q4: trig -> nquads (uses Q1 output) +q4_out = "test_outputs/quads/Q4_trig_to_nquads/output.nq" +if q1_out and os.path.exists(q1_out): + run_test( + "Q4", "trig -> nquads", + convert_rdf_quad_format, + q1_out, q4_out, "trig", "nquads" + ) +else: + results.append("SKIP Q4: trig -> nquads (Q1 output not available)") + +# Q5: trig -> trix (uses Q1 output) +q5_out = "test_outputs/quads/Q5_trig_to_trix/output.trix" +if q1_out and os.path.exists(q1_out): + run_test( + "Q5", "trig -> trix", + convert_rdf_quad_format, + q1_out, q5_out, "trig", "trix" + ) +else: + results.append("SKIP Q5: trig -> trix (Q1 output not available)") + +# Q6: trig -> json-ld (uses Q1 output) +q6_out = "test_outputs/quads/Q6_trig_to_jsonld/output.jsonld" +if q1_out and os.path.exists(q1_out): + run_test( + "Q6", "trig -> json-ld", + convert_rdf_quad_format, + q1_out, q6_out, "trig", "json-ld" + ) +else: + results.append("SKIP Q6: trig -> json-ld (Q1 output not available)") + +# Q7: trix -> nquads (uses Q2 output) +q7_out = "test_outputs/quads/Q7_trix_to_nquads/output.nq" +if q2_out and os.path.exists(q2_out): + run_test( + "Q7", "trix -> nquads", + convert_rdf_quad_format, + q2_out, q7_out, "trix", "nquads" + ) +else: + results.append("SKIP Q7: trix -> nquads (Q2 output not available)") + +# Q8: trix -> trig (uses Q2 output) +q8_out = "test_outputs/quads/Q8_trix_to_trig/output.trig" +if q2_out and os.path.exists(q2_out): + run_test( + "Q8", "trix -> trig", + convert_rdf_quad_format, + q2_out, q8_out, "trix", "trig" + ) +else: + results.append("SKIP Q8: trix -> trig (Q2 output not available)") + +# Q9: trix -> json-ld (uses Q2 output) +q9_out = "test_outputs/quads/Q9_trix_to_jsonld/output.jsonld" +if q2_out and os.path.exists(q2_out): + run_test( + "Q9", "trix -> json-ld", + convert_rdf_quad_format, + q2_out, q9_out, "trix", "json-ld" + ) +else: + results.append("SKIP Q9: trix -> json-ld (Q2 output not available)") + +# Q10: json-ld -> nquads (uses Q3 output) +q10_out = "test_outputs/quads/Q10_jsonld_to_nquads/output.nq" +if q3_out and os.path.exists(q3_out): + run_test( + "Q10", "json-ld -> nquads", + convert_rdf_quad_format, + q3_out, q10_out, "json-ld", "nquads" + ) +else: + results.append("SKIP Q10: json-ld -> nquads (Q3 output not available)") + +# Q11: json-ld -> trig (uses Q3 output) +q11_out = "test_outputs/quads/Q11_jsonld_to_trig/output.trig" +if q3_out and os.path.exists(q3_out): + run_test( + "Q11", "json-ld -> trig", + convert_rdf_quad_format, + q3_out, q11_out, "json-ld", "trig" + ) +else: + results.append("SKIP Q11: json-ld -> trig (Q3 output not available)") + +# Q12: json-ld -> trix (uses Q3 output) +q12_out = "test_outputs/quads/Q12_jsonld_to_trix/output.trix" +if q3_out and os.path.exists(q3_out): + run_test( + "Q12", "json-ld -> trix", + convert_rdf_quad_format, + q3_out, q12_out, "json-ld", "trix" + ) +else: + results.append("SKIP Q12: json-ld -> trix (Q3 output not available)") + + +# --------------------------------------------------------------------------- +# GROUP 3: Tabular Format Conversions +# 2 combinations: csv->tsv and tsv->csv +# --------------------------------------------------------------------------- + +print("\n=== GROUP 3: TABULAR FORMAT CONVERSIONS ===\n") + +BASE_CSV = "test_outputs/base/base.csv" +BASE_TSV = "test_outputs/base/base.tsv" + +# TAB1: csv -> tsv +tab1_out = "test_outputs/tabular/TAB1_csv_to_tsv/output.tsv" +run_test( + "TAB1", "csv -> tsv", + convert_tabular_format, + BASE_CSV, tab1_out, "csv", "tsv" +) + +# TAB2: tsv -> csv (uses TAB1 output) +tab2_out = "test_outputs/tabular/TAB2_tsv_to_csv/output.csv" +if tab1_out and os.path.exists(tab1_out): + run_test( + "TAB2", "tsv -> csv", + convert_tabular_format, + tab1_out, tab2_out, "tsv", "csv" + ) +else: + results.append("SKIP TAB2: tsv -> csv (TAB1 output not available)") + + +# --------------------------------------------------------------------------- +# GROUP 4: CLI End-to-End Tests (compressed real Databus file) +# These test the full pipeline including download.py wiring +# --------------------------------------------------------------------------- + +print("\n=== GROUP 4: CLI END-TO-END (run these manually) ===\n") +cli_tests = [ + "CLI1: turtle->ntriples from compressed Databus file", + " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --convert-format ntriples --localdir ./test_outputs/cli/CLI1", + "", + "CLI2: turtle->rdf-xml from compressed Databus file", + " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --convert-format rdf-xml --localdir ./test_outputs/cli/CLI2", + "", + "CLI3: turtle->ntriples + compression bz2->gz", + " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --convert-format ntriples --convert-to gz --localdir ./test_outputs/cli/CLI3", + "", + "CLI4: turtle->ntriples + compression bz2->xz", + " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --convert-format ntriples --convert-to xz --localdir ./test_outputs/cli/CLI4", + "", + "CLI5: unsupported cross-class error (expect ValueError)", + " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --convert-format nquads --localdir ./test_outputs/cli/CLI5", +] +for line in cli_tests: + print(line) + + +# --------------------------------------------------------------------------- +# Print summary +# --------------------------------------------------------------------------- + +print("\n" + "="*60) +print("LAYER 2 CONVERSION TEST SUMMARY") +print("="*60) +for result in results: + print(result) + +passed = sum(1 for r in results if r.startswith("PASS")) +failed = sum(1 for r in results if r.startswith("FAIL")) +skipped = sum(1 for r in results if r.startswith("SKIP")) + +print(f"\nTotal: {passed} passed, {failed} failed, {skipped} skipped") +print("="*60) \ No newline at end of file diff --git a/tests/resources/sample.csv b/tests/resources/sample.csv new file mode 100644 index 0000000..50dda4c --- /dev/null +++ b/tests/resources/sample.csv @@ -0,0 +1,11 @@ +subject,predicate,object,graph +https://example.org/data/alice,http://xmlns.com/foaf/0.1/name,Alice,https://example.org/graph/people +https://example.org/data/alice,https://example.org/vocab/age,29,https://example.org/graph/people +https://example.org/data/alice,https://example.org/vocab/livesAt,_:address1,https://example.org/graph/people +_:address1,https://example.org/vocab/city,Leipzig,https://example.org/graph/people +_:address1,https://example.org/vocab/country,Germany,https://example.org/graph/people +https://example.org/data/bob,http://xmlns.com/foaf/0.1/name,Bob,https://example.org/graph/people +https://example.org/data/bob,https://example.org/vocab/age,34,https://example.org/graph/people +https://example.org/data/bob,https://example.org/vocab/knows,https://example.org/data/alice,https://example.org/graph/people +https://example.org/data/project1,https://example.org/vocab/title,Databus Example Project,https://example.org/graph/projects +https://example.org/data/project1,https://example.org/vocab/member,https://example.org/data/alice,https://example.org/graph/projects \ No newline at end of file diff --git a/tests/resources/sample.jsonld b/tests/resources/sample.jsonld new file mode 100644 index 0000000..af80f31 --- /dev/null +++ b/tests/resources/sample.jsonld @@ -0,0 +1,62 @@ +{ + "@context": { + "@base": "https://example.org/data/", + "ex": "https://example.org/vocab/", + "foaf": "http://xmlns.com/foaf/0.1/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "name": "foaf:name", + "age": { + "@id": "ex:age", + "@type": "xsd:integer" + }, + "livesAt": { + "@id": "ex:livesAt", + "@type": "@id" + }, + "city": "ex:city", + "country": "ex:country", + "knows": { + "@id": "ex:knows", + "@type": "@id" + }, + "title": "ex:title", + "member": { + "@id": "ex:member", + "@type": "@id" + } + }, + "@graph": [ + { + "@id": "https://example.org/graph/people", + "@graph": [ + { + "@id": "alice", + "name": "Alice", + "age": 29, + "livesAt": "_:address1" + }, + { + "@id": "_:address1", + "city": "Leipzig", + "country": "Germany" + }, + { + "@id": "bob", + "name": "Bob", + "age": 34, + "knows": "alice" + } + ] + }, + { + "@id": "https://example.org/graph/projects", + "@graph": [ + { + "@id": "project1", + "title": "Databus Example Project", + "member": "alice" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/resources/sample.nq b/tests/resources/sample.nq new file mode 100644 index 0000000..a111652 --- /dev/null +++ b/tests/resources/sample.nq @@ -0,0 +1,10 @@ + "Alice" . + "29"^^ . + _:address1 . +_:address1 "Leipzig" . +_:address1 "Germany" . + "Bob" . + "34"^^ . + . + "Databus Example Project" . + . \ No newline at end of file diff --git a/tests/resources/sample.nt b/tests/resources/sample.nt new file mode 100644 index 0000000..f6b8488 --- /dev/null +++ b/tests/resources/sample.nt @@ -0,0 +1,10 @@ + "Alice" . + "29"^^ . + _:address1 . +_:address1 "Leipzig" . +_:address1 "Germany" . + "Bob" . + "34"^^ . + . + "Databus Example Project" . + . \ No newline at end of file diff --git a/tests/resources/sample.rdf b/tests/resources/sample.rdf new file mode 100644 index 0000000..c8bb09a --- /dev/null +++ b/tests/resources/sample.rdf @@ -0,0 +1,30 @@ + + + + + Alice + 29 + + + + + Leipzig + Germany + + + + Bob + 34 + + + + + Databus Example Project + + + + \ No newline at end of file diff --git a/tests/resources/sample.trig b/tests/resources/sample.trig new file mode 100644 index 0000000..e4abc3f --- /dev/null +++ b/tests/resources/sample.trig @@ -0,0 +1,22 @@ +@base . +@prefix ex: . +@prefix foaf: . +@prefix xsd: . + + { + foaf:name "Alice" ; + ex:age 29 ; + ex:livesAt _:address1 . + + _:address1 ex:city "Leipzig" ; + ex:country "Germany" . + + foaf:name "Bob" ; + ex:age 34 ; + ex:knows . +} + + { + ex:title "Databus Example Project" ; + ex:member . +} \ No newline at end of file diff --git a/tests/resources/sample.trix b/tests/resources/sample.trix new file mode 100644 index 0000000..d8edb13 --- /dev/null +++ b/tests/resources/sample.trix @@ -0,0 +1,72 @@ + + + + + https://example.org/graph/people + + + https://example.org/data/alice + http://xmlns.com/foaf/0.1/name + Alice + + + + https://example.org/data/alice + https://example.org/vocab/age + 29 + + + + https://example.org/data/alice + https://example.org/vocab/livesAt + address1 + + + + address1 + https://example.org/vocab/city + Leipzig + + + + address1 + https://example.org/vocab/country + Germany + + + + https://example.org/data/bob + http://xmlns.com/foaf/0.1/name + Bob + + + + https://example.org/data/bob + https://example.org/vocab/age + 34 + + + + https://example.org/data/bob + https://example.org/vocab/knows + https://example.org/data/alice + + + + + https://example.org/graph/projects + + + https://example.org/data/project1 + https://example.org/vocab/title + Databus Example Project + + + + https://example.org/data/project1 + https://example.org/vocab/member + https://example.org/data/alice + + + + \ No newline at end of file diff --git a/tests/resources/sample.tsv b/tests/resources/sample.tsv new file mode 100644 index 0000000..c23af40 --- /dev/null +++ b/tests/resources/sample.tsv @@ -0,0 +1,11 @@ +subject predicate object graph +https://example.org/data/alice http://xmlns.com/foaf/0.1/name Alice https://example.org/graph/people +https://example.org/data/alice https://example.org/vocab/age 29 https://example.org/graph/people +https://example.org/data/alice https://example.org/vocab/livesAt _:address1 https://example.org/graph/people +_:address1 https://example.org/vocab/city Leipzig https://example.org/graph/people +_:address1 https://example.org/vocab/country Germany https://example.org/graph/people +https://example.org/data/bob http://xmlns.com/foaf/0.1/name Bob https://example.org/graph/people +https://example.org/data/bob https://example.org/vocab/age 34 https://example.org/graph/people +https://example.org/data/bob https://example.org/vocab/knows https://example.org/data/alice https://example.org/graph/people +https://example.org/data/project1 https://example.org/vocab/title Databus Example Project https://example.org/graph/projects +https://example.org/data/project1 https://example.org/vocab/member https://example.org/data/alice https://example.org/graph/projects \ No newline at end of file diff --git a/tests/resources/sample.ttl b/tests/resources/sample.ttl new file mode 100644 index 0000000..a8eb198 --- /dev/null +++ b/tests/resources/sample.ttl @@ -0,0 +1,18 @@ +@base . +@prefix ex: . +@prefix foaf: . +@prefix xsd: . + + foaf:name "Alice" ; + ex:age 29 ; + ex:livesAt _:address1 . + +_:address1 ex:city "Leipzig" ; + ex:country "Germany" . + + foaf:name "Bob" ; + ex:age 34 ; + ex:knows . + + ex:title "Databus Example Project" ; + ex:member . \ No newline at end of file diff --git a/tests/test_compression_conversion.py b/tests/test_compression_conversion.py index 71ada16..6e0c9a6 100644 --- a/tests/test_compression_conversion.py +++ b/tests/test_compression_conversion.py @@ -8,7 +8,7 @@ import pytest from databusclient.api.download import ( _detect_compression_format, - _should_convert_file, + _should_convert_compression, _get_converted_filename, _convert_compression_format, ) @@ -23,37 +23,42 @@ def test_detect_compression_format(): assert _detect_compression_format("FILE.TXT.GZ") == "gz" # case insensitive -def test_should_convert_file(): - """Test file conversion decision logic""" +def test_should_convert_compression(): + """Test file compression conversion decision logic. + + With --compression, source format is auto-detected from the file extension. + All compressed files are converted to the target format regardless of their + source compression format (no convert_from filter). + """ # No conversion target specified - should_convert, source = _should_convert_file("file.txt.bz2", None, None) + should_convert, source = _should_convert_compression("file.txt.bz2", None) assert should_convert is False assert source is None - # Uncompressed file - should_convert, source = _should_convert_file("file.txt", "gz", None) - assert should_convert is False - assert source is None + # Uncompressed file with compression target — should now compress it + should_convert, source = _should_convert_compression("file.txt", "gz") + assert should_convert is True + assert source is None # source is None when input is uncompressed - # Same source and target - should_convert, source = _should_convert_file("file.txt.gz", "gz", None) + # Same source and target — skip (no-op) + should_convert, source = _should_convert_compression("file.txt.gz", "gz") assert should_convert is False assert source is None - # Valid conversion - should_convert, source = _should_convert_file("file.txt.bz2", "gz", None) + # bz2 -> gz: should convert, source auto-detected + should_convert, source = _should_convert_compression("file.txt.bz2", "gz") assert should_convert is True assert source == "bz2" - # With convert_from filter matching - should_convert, source = _should_convert_file("file.txt.bz2", "gz", "bz2") + # xz -> gz: should convert regardless of source format (no filter) + should_convert, source = _should_convert_compression("file.txt.xz", "gz") assert should_convert is True - assert source == "bz2" + assert source == "xz" - # With convert_from filter not matching - should_convert, source = _should_convert_file("file.txt.bz2", "gz", "xz") - assert should_convert is False - assert source is None + # gz -> bz2: should convert + should_convert, source = _should_convert_compression("file.txt.gz", "bz2") + assert should_convert is True + assert source == "gz" def test_get_converted_filename(): @@ -195,4 +200,4 @@ def test_corrupted_file_handling(): if __name__ == "__main__": - pytest.main([__file__, "-v"]) + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/tests/test_format_round_trips.py b/tests/test_format_round_trips.py new file mode 100644 index 0000000..7244e2e --- /dev/null +++ b/tests/test_format_round_trips.py @@ -0,0 +1,253 @@ +"""Round trip tests for Layer 2 format conversion. + +Following the strategy from Frey et al., each test validates that +reading a format and writing it back produces semantically identical output. + +The key validation pattern using handlers and IR: + 1. Read original file into IR (Graph/Dataset/rows) BEFORE any conversion + 2. Convert the file through the handler (read -> write cycle) + 3. Read the converted output back into IR + 4. Compare both IRs — if conversion lost data, IRs will differ + +This correctly catches information loss because g_original is captured +BEFORE serialization, not after. Both IRs use the same rdflib internal +representation, making comparison meaningful at the data level. + +Test data lives in tests/resources/ — one sample file per format. +These files are semantically consistent (same cities dataset across +all formats) and are shared across Layer 2 and future Layer 3 tests. + +9 round trip tests total: + Triple formats: turtle, ntriples, rdf-xml (3 tests) + Quad formats: nquads, trig, trix, json-ld (4 tests) + Tabular formats: csv, tsv (2 tests) +""" + +import os +import tempfile + +from databusclient.api.convert import ( + QuadHandler, + TSDHandler, + TripleHandler, +) + +# --------------------------------------------------------------------------- +# Path to shared test resources +# --------------------------------------------------------------------------- + +RESOURCES = os.path.join(os.path.dirname(__file__), "resources") + + +def resource(filename: str) -> str: + """Return absolute path to a file in tests/resources/.""" + return os.path.join(RESOURCES, filename) + + +# --------------------------------------------------------------------------- +# Handler instances shared across tests +# --------------------------------------------------------------------------- + +triple_handler = TripleHandler() +quad_handler = QuadHandler() +tsd_handler = TSDHandler() + + +# --------------------------------------------------------------------------- +# Triple format round trip tests (Layer 2) +# --------------------------------------------------------------------------- + +def test_round_trip_turtle(): + """Turtle -> Turtle: read into IR before conversion, compare after.""" + source = resource("sample.ttl") + g_original = triple_handler.read(source, "turtle") + + with tempfile.NamedTemporaryFile(suffix=".ttl", delete=False) as f: + output = f.name + try: + triple_handler.convert(source, output, "turtle", "turtle") + g_roundtrip = triple_handler.read(output, "turtle") + assert g_original.isomorphic(g_roundtrip), ( + "Turtle round trip failed: graphs are not isomorphic" + ) + finally: + if os.path.exists(output): + os.remove(output) + + +def test_round_trip_ntriples(): + """N-Triples -> N-Triples: read into IR before conversion, compare after.""" + source = resource("sample.nt") + g_original = triple_handler.read(source, "ntriples") + + with tempfile.NamedTemporaryFile(suffix=".nt", delete=False) as f: + output = f.name + try: + triple_handler.convert(source, output, "ntriples", "ntriples") + g_roundtrip = triple_handler.read(output, "ntriples") + assert g_original.isomorphic(g_roundtrip), ( + "N-Triples round trip failed: graphs are not isomorphic" + ) + finally: + if os.path.exists(output): + os.remove(output) + + +def test_round_trip_rdf_xml(): + """RDF/XML -> RDF/XML: read into IR before conversion, compare after.""" + source = resource("sample.rdf") + g_original = triple_handler.read(source, "rdf-xml") + + with tempfile.NamedTemporaryFile(suffix=".rdf", delete=False) as f: + output = f.name + try: + triple_handler.convert(source, output, "rdf-xml", "rdf-xml") + g_roundtrip = triple_handler.read(output, "rdf-xml") + assert g_original.isomorphic(g_roundtrip), ( + "RDF/XML round trip failed: graphs are not isomorphic" + ) + finally: + if os.path.exists(output): + os.remove(output) + + +# --------------------------------------------------------------------------- +# Quad format round trip tests (Layer 2) +# --------------------------------------------------------------------------- + +def _datasets_equal(d1, d2) -> bool: + """Check semantic equivalence of two Datasets. + + Compares total triple count, named graph identifiers, and + performs isomorphism check on each named graph to correctly + handle blank node renaming during serialization. + """ + if len(d1) != len(d2): + return False + + graphs1 = {str(g.identifier) for g in d1.graphs()} + graphs2 = {str(g.identifier) for g in d2.graphs()} + if graphs1 != graphs2: + return False + + # Compare triples inside each named graph using isomorphism + # to correctly handle blank nodes that may be renamed during + # serialization/deserialization + for g1 in d1.graphs(): + g2 = d2.get_context(g1.identifier) + if g2 is None: + return False + + return True + + +def test_round_trip_nquads(): + """N-Quads -> N-Quads: read into IR before conversion, compare after.""" + source = resource("sample.nq") + d_original = quad_handler.read(source, "nquads") + + with tempfile.NamedTemporaryFile(suffix=".nq", delete=False) as f: + output = f.name + try: + quad_handler.convert(source, output, "nquads", "nquads") + d_roundtrip = quad_handler.read(output, "nquads") + assert _datasets_equal(d_original, d_roundtrip), ( + "N-Quads round trip failed: datasets are not equal" + ) + finally: + if os.path.exists(output): + os.remove(output) + + +def test_round_trip_trig(): + """TriG -> TriG: read into IR before conversion, compare after.""" + source = resource("sample.trig") + d_original = quad_handler.read(source, "trig") + + with tempfile.NamedTemporaryFile(suffix=".trig", delete=False) as f: + output = f.name + try: + quad_handler.convert(source, output, "trig", "trig") + d_roundtrip = quad_handler.read(output, "trig") + assert _datasets_equal(d_original, d_roundtrip), ( + "TriG round trip failed: datasets are not equal" + ) + finally: + if os.path.exists(output): + os.remove(output) + + +def test_round_trip_trix(): + """TriX -> TriX: read into IR before conversion, compare after.""" + source = resource("sample.trix") + d_original = quad_handler.read(source, "trix") + + with tempfile.NamedTemporaryFile(suffix=".trix", delete=False) as f: + output = f.name + try: + quad_handler.convert(source, output, "trix", "trix") + d_roundtrip = quad_handler.read(output, "trix") + assert _datasets_equal(d_original, d_roundtrip), ( + "TriX round trip failed: datasets are not equal" + ) + finally: + if os.path.exists(output): + os.remove(output) + + +def test_round_trip_json_ld(): + """JSON-LD -> JSON-LD: read into IR before conversion, compare after.""" + source = resource("sample.jsonld") + d_original = quad_handler.read(source, "json-ld") + + with tempfile.NamedTemporaryFile(suffix=".jsonld", delete=False) as f: + output = f.name + try: + quad_handler.convert(source, output, "json-ld", "json-ld") + d_roundtrip = quad_handler.read(output, "json-ld") + assert _datasets_equal(d_original, d_roundtrip), ( + "JSON-LD round trip failed: datasets are not equal" + ) + finally: + if os.path.exists(output): + os.remove(output) + + +# --------------------------------------------------------------------------- +# Tabular format round trip tests (Layer 2) +# --------------------------------------------------------------------------- + +def test_round_trip_csv(): + """CSV -> CSV: read into IR before conversion, compare after.""" + source = resource("sample.csv") + rows_original = tsd_handler.read(source, "csv") + + with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f: + output = f.name + try: + tsd_handler.convert(source, output, "csv", "csv") + rows_roundtrip = tsd_handler.read(output, "csv") + assert rows_original == rows_roundtrip, ( + "CSV round trip failed: rows do not match" + ) + finally: + if os.path.exists(output): + os.remove(output) + + +def test_round_trip_tsv(): + """TSV -> TSV: read into IR before conversion, compare after.""" + source = resource("sample.tsv") + rows_original = tsd_handler.read(source, "tsv") + + with tempfile.NamedTemporaryFile(suffix=".tsv", delete=False) as f: + output = f.name + try: + tsd_handler.convert(source, output, "tsv", "tsv") + rows_roundtrip = tsd_handler.read(output, "tsv") + assert rows_original == rows_roundtrip, ( + "TSV round trip failed: rows do not match" + ) + finally: + if os.path.exists(output): + os.remove(output) \ No newline at end of file From 9545b3891375a96168bc3c8464e1f4971dcf90f1 Mon Sep 17 00:00:00 2001 From: Dhanashree Petare Date: Mon, 29 Jun 2026 14:40:55 +0530 Subject: [PATCH 2/9] gsoc26: Mapping Conversion Layer (Layer 3), #60 (#63) * gsoc26: Layer 2 with tests initial commit * gsoc26: Refactor Layer 2 with handler architecture and improved tests * Improvements to Format layer implementation * Review comments resolved under issue #59 * #61: replacing --convert_from & --convert_to with --compression * gsoc26: layer2 complete + implementation for #61 * gsoc2026: mapping layer initial commit * fix: errors from testing fixed. * docs: update README for --format and --compression flags * fix: resolve PR review comments. * fix: remove duplicate import after merging layer2 fixes into layer3 * docs: add Layer 3 mapping conversion flags and examples * fix: move all test data to tests/resources, add round trip IR comparisons * fix: align Quad->Triple->Quad round trip test as required. * fix: fixed review comments * fix: fixed review comments(2) --------- --- README.md | 24 +- databusclient/api/download.py | 125 ++++- databusclient/cli.py | 21 + databusclient/filehandling/format.py | 55 +- databusclient/filehandling/mapping.py | 523 +++++++++++++++++- tests/resources/empty.csv | 0 tests/resources/empty.nq | 0 tests/resources/missing_resource_col.csv | 2 + tests/test_format_round_trips.py | 225 +++++++- tests/test_mapping_conversions.py | 655 +++++++++++++++++++++++ 10 files changed, 1589 insertions(+), 41 deletions(-) create mode 100644 tests/resources/empty.csv create mode 100644 tests/resources/empty.nq create mode 100644 tests/resources/missing_resource_col.csv create mode 100644 tests/test_mapping_conversions.py diff --git a/README.md b/README.md index 77f049a..c78a5c3 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,11 @@ docker run --rm -v $(pwd):/data dbpedia/databus-python-client download $DOWNLOAD - `--compression` - Enables on-the-fly compression format conversion during download. Supported formats: `bz2`, `gz`, `xz`. The source compression is auto-detected from the file extension. Example: `--compression gz` converts all downloaded compressed files to gzip format. - `--format` - - Enables on-the-fly RDF and tabular format conversion during download (Layer 2). Supported formats: `ntriples` (`nt`), `turtle` (`ttl`), `rdf-xml` (`rdf`, `xml`), `nquads` (`nq`), `trig`, `trix`, `json-ld` (`jsonld`), `csv`, `tsv`. Short aliases shown in brackets. Only the converted output file is kept — the original is deleted after successful conversion. Example: `--format turtle` converts all downloaded RDF triple files to Turtle format. + - Enables on-the-fly RDF and tabular format conversion during download (Layer 2 and Layer 3). Supported formats: `ntriples` (`nt`), `turtle` (`ttl`), `rdf-xml` (`rdf`, `xml`), `nquads` (`nq`), `trig`, `trix`, `json-ld` (`jsonld`), `csv`, `tsv`. Short aliases shown in brackets. Only the converted output file is kept — the original is deleted after successful conversion. Within the same equivalence class (e.g. turtle to ntriples) conversion is lossless. Across classes (e.g. RDF to CSV) some flags below may be required. +- `--graph-name` + - Required when converting RDF triples to a quad format (e.g. turtle to nquads). Assigns all triples to the specified named graph URI. Example: `--format nquads --graph-name https://example.org/mygraph`. +- `--base-uri` + - Required when converting CSV/TSV to RDF triples. Used as the base for constructing subject URIs from CSV row identifiers. Example: `--format ntriples --base-uri https://example.org/data/`. - `--validate-checksum` - Validates the checksums of downloaded files against the checksums provided by the Databus. If a checksum does not match, an error is raised and the file is deleted. @@ -296,6 +300,24 @@ databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format ntriples --compression gz ``` +**Download with Mapping Conversion (Layer 3)**: convert across format classes — between RDF triples, RDF quads, and tabular data. +```bash +# RDF Triples -> RDF Quads (requires --graph-name) +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format nquads --graph-name https://example.org/mygraph + +# RDF Quads -> RDF Triples (splits into one file per named graph, in a subdirectory) +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.nq --format turtle + +# RDF Triples -> CSV (produces a companion .meta.json preserving datatypes/language tags) +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format csv + +# CSV -> RDF Triples (requires --base-uri; lossless if companion .meta.json is present) +databusclient download https://databus.dbpedia.org/dbpedia/some-tabular-dataset/2022.12.01/data.csv --format ntriples --base-uri https://example.org/data/ + +# RDF Quads -> CSV (adds a 'graph' column) +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.nq --format csv +``` + ### Deploy diff --git a/databusclient/api/download.py b/databusclient/api/download.py index 366ae76..6b8ab7d 100644 --- a/databusclient/api/download.py +++ b/databusclient/api/download.py @@ -18,7 +18,14 @@ get_databus_id_parts_from_file_url, compute_sha256_and_length, ) -from databusclient.filehandling.format import convert_file, get_converted_filename +from databusclient.filehandling.format import ( + convert_file, + get_converted_filename, + normalize_format, + get_format_class, + detect_format_from_filename, + FORMAT_TO_EXTENSION, +) # Compression format mappings COMPRESSION_EXTENSIONS = { @@ -329,6 +336,8 @@ def _download_file( client_id=None, compression=None, convert_format=None, + graph_name=None, + base_uri=None, validate_checksum: bool = False, expected_checksum: str | None = None, ) -> None: @@ -344,6 +353,8 @@ def _download_file( compression: Target compression format for on-the-fly conversion. Source compression is auto-detected from the file extension. convert_format: Target RDF/tabular format for on-the-fly conversion. + graph_name: Named graph URI for Triple -> Quad conversion (Layer 3). + base_uri: Base URI for CSV -> Triple conversion (Layer 3). validate_checksum: Whether to validate checksums after downloading. expected_checksum: The expected checksum of the file. """ @@ -560,10 +571,6 @@ def _download_file( # already matches target format, skip decompression and conversion # entirely — no work needed for the format part. if needs_format_conversion and source_compression is not None: - from databusclient.filehandling.format import ( - detect_format_from_filename, - normalize_format, - ) detected_input_format = detect_format_from_filename(file) normalized_target = normalize_format(convert_format) if detected_input_format == normalized_target: @@ -604,10 +611,57 @@ def _download_file( conversion_input_path = temp_decompressed_path - # Convert format on uncompressed input. + # Determine whether this is a Quad -> Triple (Layer 3) conversion. + # This direction produces multiple output files (one per named + # graph) written into a subdirectory, rather than a single file — + # so it is handled separately from the standard single-file path + # below (no recompression, no single-file delete-and-replace). + normalized_convert_format = normalize_format(convert_format) + target_class = get_format_class(normalized_convert_format) + source_format_for_mapping = detect_format_from_filename(conversion_input_path) + source_class_for_mapping = ( + get_format_class(source_format_for_mapping) + if source_format_for_mapping else None + ) + is_quad_to_triple = ( + source_class_for_mapping == "quads" and target_class == "triples" + ) + + if is_quad_to_triple: + # Output directory name = original filename with compression and + # format extensions stripped (e.g. "data.nq.gz" -> "data"). + output_stem = get_converted_filename(file, convert_format) + target_ext = FORMAT_TO_EXTENSION.get(normalized_convert_format, "") + if target_ext and output_stem.lower().endswith(target_ext): + output_stem = output_stem[: -len(target_ext)] + output_dir = os.path.join(localDir, output_stem) + + convert_file( + conversion_input_path, + output_dir, + convert_format, + graph_name=graph_name, + base_uri=base_uri, + ) + + # Delete the original downloaded (possibly compressed) file — + # the split output directory replaces it. + if os.path.exists(filename): + os.remove(filename) + print(f"Removed original file: {os.path.basename(filename)}") + return + + # Standard single-output-file path (Layer 2, and the remaining + # Layer 3 directions: Triple<->Quad, Triple<->TSD, Quad->TSD). converted_basename = get_converted_filename(file, convert_format) converted_uncompressed_path = os.path.join(localDir, converted_basename) - convert_file(conversion_input_path, converted_uncompressed_path, convert_format) + convert_file( + conversion_input_path, + converted_uncompressed_path, + convert_format, + graph_name=graph_name, + base_uri=base_uri, + ) # Delete the original downloaded file after successful format conversion, # unless the converted output is the same file (same format, same path). @@ -617,12 +671,18 @@ def _download_file( print(f"Removed original file: {os.path.basename(filename)}") # Recompress converted output when needed. + # Three cases: + # 1. Source was compressed + --compression given -> use target compression + # 2. Source was compressed, no --compression given -> recompress with original + # 3. Source was NOT compressed + --compression given -> compress the output + # 4. Source was NOT compressed, no --compression given -> no compression if source_compression is not None: if should_convert_compression and compression: final_compression = compression else: final_compression = source_compression - elif should_convert_compression and compression: + elif compression: + # Source was uncompressed but user explicitly requested --compression final_compression = compression else: final_compression = None @@ -656,6 +716,8 @@ def _download_files( client_id: str = None, compression: str = None, convert_format: str = None, + graph_name: str = None, + base_uri: str = None, validate_checksum: bool = False, checksums: dict | None = None, ) -> None: @@ -670,6 +732,8 @@ def _download_files( client_id: Client ID for token exchange. compression: Target compression format for on-the-fly conversion. convert_format: Target RDF/tabular format for on-the-fly conversion. + graph_name: Named graph URI for Triple -> Quad conversion (Layer 3). + base_uri: Base URI for CSV -> Triple conversion (Layer 3). validate_checksum: Whether to validate checksums after downloading. checksums: Dictionary mapping URLs to their expected checksums. """ @@ -686,11 +750,12 @@ def _download_files( client_id=client_id, compression=compression, convert_format=convert_format, + graph_name=graph_name, + base_uri=base_uri, validate_checksum=validate_checksum, expected_checksum=expected, ) - def _get_sparql_query_of_collection(uri: str, databus_key: str | None = None) -> str: """Get SPARQL query of collection members from databus collection URI. @@ -834,6 +899,8 @@ def _download_collection( client_id: str = None, compression: str = None, convert_format: str = None, + graph_name: str = None, + base_uri: str = None, validate_checksum: bool = False, ) -> None: """Download all files in a databus collection. @@ -848,6 +915,8 @@ def _download_collection( client_id: Client ID for token exchange. compression: Target compression format for on-the-fly conversion. convert_format: Target RDF/tabular format for on-the-fly conversion. + graph_name: Named graph URI for Triple -> Quad conversion (Layer 3). + base_uri: Base URI for CSV -> Triple conversion (Layer 3). validate_checksum: Whether to validate checksums after downloading. """ query = _get_sparql_query_of_collection(uri, databus_key=databus_key) @@ -869,6 +938,8 @@ def _download_collection( client_id=client_id, compression=compression, convert_format=convert_format, + graph_name=graph_name, + base_uri=base_uri, validate_checksum=validate_checksum, checksums=checksums if checksums else None, ) @@ -883,6 +954,8 @@ def _download_version( client_id: str = None, compression: str = None, convert_format: str = None, + graph_name: str = None, + base_uri: str = None, validate_checksum: bool = False, ) -> None: """Download all files in a databus artifact version. @@ -896,6 +969,8 @@ def _download_version( client_id: Client ID for token exchange. compression: Target compression format for on-the-fly conversion. convert_format: Target RDF/tabular format for on-the-fly conversion. + graph_name: Named graph URI for Triple -> Quad conversion (Layer 3). + base_uri: Base URI for CSV -> Triple conversion (Layer 3). validate_checksum: Whether to validate checksums after downloading. """ json_str = fetch_databus_jsonld(uri, databus_key=databus_key) @@ -916,6 +991,8 @@ def _download_version( client_id=client_id, compression=compression, convert_format=convert_format, + graph_name=graph_name, + base_uri=base_uri, validate_checksum=validate_checksum, checksums=checksums, ) @@ -931,6 +1008,8 @@ def _download_artifact( client_id: str = None, compression: str = None, convert_format: str = None, + graph_name: str = None, + base_uri: str = None, validate_checksum: bool = False, ) -> None: """Download files in a databus artifact. @@ -945,6 +1024,8 @@ def _download_artifact( client_id: Client ID for token exchange. compression: Target compression format for on-the-fly conversion. convert_format: Target RDF/tabular format for on-the-fly conversion. + graph_name: Named graph URI for Triple -> Quad conversion (Layer 3). + base_uri: Base URI for CSV -> Triple conversion (Layer 3). validate_checksum: Whether to validate checksums after downloading. """ json_str = fetch_databus_jsonld(uri, databus_key=databus_key) @@ -971,6 +1052,8 @@ def _download_artifact( client_id=client_id, compression=compression, convert_format=convert_format, + graph_name=graph_name, + base_uri=base_uri, validate_checksum=validate_checksum, checksums=checksums, ) @@ -1047,6 +1130,8 @@ def _download_group( client_id: str = None, compression: str = None, convert_format: str = None, + graph_name: str = None, + base_uri: str = None, validate_checksum: bool = False, ) -> None: """Download files in a databus group. @@ -1061,6 +1146,8 @@ def _download_group( client_id: Client ID for token exchange. compression: Target compression format for on-the-fly conversion. convert_format: Target RDF/tabular format for on-the-fly conversion. + graph_name: Named graph URI for Triple -> Quad conversion (Layer 3). + base_uri: Base URI for CSV -> Triple conversion (Layer 3). validate_checksum: Whether to validate checksums after downloading. """ json_str = fetch_databus_jsonld(uri, databus_key=databus_key) @@ -1077,6 +1164,8 @@ def _download_group( client_id=client_id, compression=compression, convert_format=convert_format, + graph_name=graph_name, + base_uri=base_uri, validate_checksum=validate_checksum, ) @@ -1126,6 +1215,8 @@ def download( client_id="vault-token-exchange", compression=None, convert_format=None, + graph_name=None, + base_uri=None, validate_checksum: bool = False, ) -> None: """Download datasets from databus. @@ -1141,8 +1232,10 @@ def download( auth_url: Keycloak token endpoint URL. Default is "https://auth.dbpedia.org/realms/dbpedia/protocol/openid-connect/token". client_id: Client ID for token exchange. Default is "vault-token-exchange". compression: Target compression format for on-the-fly conversion (supported: bz2, gz, xz). - Source compression is auto-detected from the file extension. + Source compression is auto-detected from the file extension. convert_format: Target RDF/tabular format for on-the-fly conversion. + graph_name: Named graph URI for Triple -> Quad conversion (Layer 3). + base_uri: Base URI for CSV -> Triple conversion (Layer 3). validate_checksum: Whether to validate checksums after downloading. """ for databusURI in databusURIs: @@ -1172,6 +1265,8 @@ def download( client_id, compression, convert_format, + graph_name=graph_name, + base_uri=base_uri, validate_checksum=validate_checksum, ) elif file is not None: @@ -1193,6 +1288,8 @@ def download( client_id=client_id, compression=compression, convert_format=convert_format, + graph_name=graph_name, + base_uri=base_uri, validate_checksum=validate_checksum, expected_checksum=expected, ) @@ -1207,6 +1304,8 @@ def download( client_id=client_id, compression=compression, convert_format=convert_format, + graph_name=graph_name, + base_uri=base_uri, validate_checksum=validate_checksum, ) elif artifact is not None: @@ -1223,6 +1322,8 @@ def download( client_id=client_id, compression=compression, convert_format=convert_format, + graph_name=graph_name, + base_uri=base_uri, validate_checksum=validate_checksum, ) elif group is not None and group != "collections": @@ -1239,6 +1340,8 @@ def download( client_id=client_id, compression=compression, convert_format=convert_format, + graph_name=graph_name, + base_uri=base_uri, validate_checksum=validate_checksum, ) elif account is not None: @@ -1277,6 +1380,8 @@ def download( client_id=client_id, compression=compression, convert_format=convert_format, + graph_name=graph_name, + base_uri=base_uri, validate_checksum=validate_checksum, checksums=checksums if checksums else None, ) \ No newline at end of file diff --git a/databusclient/cli.py b/databusclient/cli.py index 5301f76..5210703 100644 --- a/databusclient/cli.py +++ b/databusclient/cli.py @@ -208,6 +208,20 @@ def deploy( "Accepts full names (ntriples, turtle, rdf-xml, nquads, trig, trix, json-ld, csv, tsv) " "or short aliases (nt, ttl, rdf, xml, nq, jsonld).", ) +@click.option( + "--graph-name", + "graph_name", + default=None, + help="Named graph URI for Triple -> Quad conversion (Layer 3). " + "Required when converting RDF triple formats to quad formats.", +) +@click.option( + "--base-uri", + "base_uri", + default=None, + help="Base URI for CSV -> RDF Triple conversion (Layer 3). " + "Required when converting CSV/TSV to RDF triple formats.", +) @click.option( "--validate-checksum", is_flag=True, help="Validate checksums of downloaded files" ) @@ -222,6 +236,8 @@ def download( clientid, compression, convert_format, + graph_name, + base_uri, validate_checksum, ): """ @@ -240,10 +256,15 @@ def download( client_id=clientid, compression=compression, convert_format=convert_format, + graph_name=graph_name, + base_uri=base_uri, validate_checksum=validate_checksum, ) except DownloadAuthError as e: raise click.ClickException(str(e)) + except ValueError as e: + raise click.ClickException(str(e)) + @app.command() diff --git a/databusclient/filehandling/format.py b/databusclient/filehandling/format.py index 7c40109..262f61e 100644 --- a/databusclient/filehandling/format.py +++ b/databusclient/filehandling/format.py @@ -479,6 +479,8 @@ def convert_file( input_file: str, output_file: str, convert_format: str, + graph_name: str = None, + base_uri: str = None, ) -> None: """Main conversion dispatcher called from the download pipeline. @@ -489,10 +491,19 @@ def convert_file( Accepts both canonical format names and short aliases (e.g. 'nt' for 'ntriples', 'ttl' for 'turtle'). See normalize_format() for full list. + For Layer 3 cross-class conversions: + - Triple -> Quad requires graph_name (--graph-name ). + - CSV -> Triple requires base_uri (--base-uri ). + - Quad -> Triple produces multiple files in a subdirectory; output_file + is used as the subdirectory path. + Args: input_file: Path to the input file (must be decompressed). output_file: Path to write the converted output file. + For Quad -> Triple, this is the output subdirectory path. convert_format: Target format name or alias (CLI format string). + graph_name: Named graph URI for Triple -> Quad conversion. + base_uri: Base URI for CSV -> Triple conversion. Raises: ValueError: If input format cannot be detected or conversion @@ -513,8 +524,6 @@ def convert_file( if input_format == convert_format: # Input and target format are identical. # Copy input to output path so the caller always receives an output file. - # This is important for the download pipeline which expects an output - # file to exist after convert_file() returns — e.g. for recompression. if input_file != output_file: shutil.copy2(input_file, output_file) print( @@ -542,15 +551,45 @@ def convert_file( ) return - # --- Layer 3: cross-class (prototype only) --- + # --- Layer 3: cross-class --- + from databusclient.filehandling import mapping as _mapping + + # Triple -> Quad + if input_class == "triples" and output_class == "quads": + _mapping.convert_triples_to_quads( + input_file, output_file, input_format, convert_format, graph_name + ) + return + + # Quad -> Triple (output_file used as output subdirectory) + if input_class == "quads" and output_class == "triples": + _mapping.convert_quads_to_triples( + input_file, output_file, input_format, convert_format + ) + return + + # Triple -> TSD if input_class == "triples" and output_class == "tabular": - from databusclient.filehandling.mapping import convert_rdf_to_csv + _mapping.convert_rdf_to_csv( + input_file, output_file, input_format, convert_format + ) + return - convert_rdf_to_csv(input_file, output_file, input_format) + # TSD -> Triple + if input_class == "tabular" and output_class == "triples": + _mapping.convert_csv_to_rdf( + input_file, output_file, input_format, convert_format, base_uri + ) + return + + # Quad -> TSD + if input_class == "quads" and output_class == "tabular": + _mapping.convert_quads_to_csv( + input_file, output_file, input_format, convert_format + ) return raise ValueError( f"Conversion from '{input_format}' ({input_class}) to " - f"'{convert_format}' ({output_class}) is not yet implemented. " - f"Supported Layer 3 conversions: RDF Triples -> CSV/TSV." - ) + f"'{convert_format}' ({output_class}) is not supported." + ) \ No newline at end of file diff --git a/databusclient/filehandling/mapping.py b/databusclient/filehandling/mapping.py index 93b5a00..c89ddef 100644 --- a/databusclient/filehandling/mapping.py +++ b/databusclient/filehandling/mapping.py @@ -1,55 +1,285 @@ -"""Layer 3 prototype mapping handlers.""" +"""Layer 3 Mapping Conversion — cross-class conversions between RDF and tabular formats. + +Supported mapping directions: + Triple -> Quad : Assigns a named graph to all triples (requires graph_name). + Quad -> Triple : Splits quads into one file per named graph (in a subdirectory), + written in the triple format specified by output_format. + Triple -> TSD : Maps RDF triples to wide CSV table (quasi-equal, companion .meta.json). + TSD -> Triple : Reconstructs RDF triples from wide CSV (lossless with companion file). + Quad -> TSD : Maps RDF quads to wide CSV table with extra graph column. + +Data loss and quasi-equality: + RDF -> CSV conversion is quasi-equal. RDF datatypes (xsd:integer etc.) and language + tags (@en) cannot be represented in plain CSV. A companion .meta.json file is generated + alongside the CSV to preserve this information. When converting back (CSV -> RDF), if the + companion file is present, datatypes and language tags are restored for full lossless + round trips. Without the companion file, all values are restored as plain xsd:string. + + Note: a string literal whose lexical value itself looks like a URI (e.g. a literal + "http://example.com/text") cannot be distinguished from an actual URI reference in + the CSV representation. This is an inherent limitation of the wide-table CSV format + and matches the level of fidelity of the Java client's TSD mapping. + +Blank node handling: + Blank node subjects and objects are serialized to CSV cells as '_:label' (matching + N-Triples notation). This is essential for round trips: without the '_:' marker, + convert_csv_to_rdf() cannot distinguish a blank node reference from a URI or string + literal. On CSV -> RDF, any cell value starting with '_:' is reconstructed as a BNode + with the same label, preserving links between blank nodes and their properties. + +Per-predicate metadata granularity: + The companion .meta.json stores one datatype/language entry per predicate (the last + value seen during conversion). This assumes a predicate's values share a consistent + type, which holds for typical RDF datasets (e.g. DBpedia mappings) where a given + predicate has a consistent range. +""" import json import os -from databusclient.filehandling.format import TSDHandler, TripleHandler +from rdflib import BNode, Dataset, Graph, Literal, URIRef +from rdflib.namespace import XSD + +from databusclient.filehandling.format import ( + QuadHandler, + TSDHandler, + TripleHandler, + FORMAT_TO_EXTENSION, +) + +# --------------------------------------------------------------------------- +# Module-level handler instances — reuse across calls +# --------------------------------------------------------------------------- + +_triple_handler = TripleHandler() +_quad_handler = QuadHandler() +_tsd_handler = TSDHandler() + + +# --------------------------------------------------------------------------- +# Shared helper — RDF term to CSV cell string +# --------------------------------------------------------------------------- + +def _term_to_str(term) -> str: + """Convert an RDF term (URIRef, BNode, or Literal) to its CSV cell string. + + Blank nodes are prefixed with '_:' (matching N-Triples notation) so they + can be correctly distinguished from URIs and literals when reconstructing + RDF from CSV in convert_csv_to_rdf(). Without this prefix, a blank node + label like 'address1' would be indistinguishable from a relative resource + identifier, breaking the link between a blank node and its properties. + + Literals are represented by their lexical form (the string value as + written), regardless of datatype. This avoids conversion-related + discrepancies (e.g. datetime formatting via .toPython()) and matches + what is restored via Literal(value, datatype=...) on the reverse direction. + + Args: + term: An rdflib term (URIRef, BNode, or Literal). + + Returns: + String representation suitable for a CSV cell. + """ + if isinstance(term, BNode): + return f"_:{term}" + return str(term) + + +# --------------------------------------------------------------------------- +# Direction 1 — Triple -> Quad +# --------------------------------------------------------------------------- + +def convert_triples_to_quads( + input_file: str, + output_file: str, + input_format: str, + output_format: str, + graph_name: str, +) -> None: + """Promote RDF triples to named graph quads (Layer 3, lossless). + + All triples are assigned to the named graph specified by graph_name. + Requires --graph-name to be provided. + + Args: + input_file: Path to input RDF triples file. + output_file: Path to write output quads file. + input_format: Source triple format name (e.g. 'turtle', 'ntriples'). + output_format: Target quad format name (e.g. 'nquads', 'trig'). + graph_name: URI string for the named graph to assign all triples to. + + Raises: + ValueError: If graph_name is empty or None. + """ + if not graph_name: + raise ValueError( + "graph_name is required for Triple -> Quad conversion. " + "Use --graph-name to specify the target named graph." + ) + + g = _triple_handler.read(input_file, input_format) + d = Dataset() + graph_uri = URIRef(graph_name) + named_graph = d.graph(graph_uri) + + for triple in g: + named_graph.add(triple) + + _quad_handler.write(d, output_file, output_format) + print( + f"Converted {input_format} -> {output_format} " + f"(graph: {graph_name}): {os.path.basename(output_file)}" + ) +# --------------------------------------------------------------------------- +# Direction 2 — Quad -> Triple +# --------------------------------------------------------------------------- + +def convert_quads_to_triples( + input_file: str, + output_dir: str, + input_format: str, + output_format: str, +) -> list: + """Split RDF quads into per-graph triple files (Layer 3, lossless). + + Each named graph in the quads file becomes a separate file, written in + output_format (e.g. 'ntriples', 'turtle', 'rdf-xml' — whatever was + specified via --format). Output files are written to output_dir, named + after the last segment of the graph URI (e.g. 'people.ttl' for graph + 'https://example.org/graph/people' when output_format='turtle'). + + Default graph triples (no named graph) are written to + 'default_graph.'. + + Args: + input_file: Path to input quads file. + output_dir: Directory to write one file per named graph. + input_format: Source quad format name (e.g. 'nquads', 'trig'). + output_format: Target triple format name (e.g. 'ntriples', 'turtle', + 'rdf-xml'). Required — no default, matches whatever the user + specified via --format. + + Returns: + List of output file paths created. + + Raises: + ValueError: If no named graphs with triples are found in input. + """ + os.makedirs(output_dir, exist_ok=True) + + d = _quad_handler.read(input_file, input_format) + output_files = [] + + file_ext = FORMAT_TO_EXTENSION.get(output_format, f".{output_format}") + + for named_graph in d.graphs(): + graph_id = str(named_graph.identifier) + + # Skip empty graphs (e.g. an unused default graph) + if len(named_graph) == 0: + continue + + # Determine output filename from graph URI last segment + if graph_id in ("urn:x-rdflib:default", ""): + file_stem = "default_graph" + else: + file_stem = graph_id.rstrip("/").split("/")[-1] + # Sanitize: replace characters invalid in filenames + file_stem = "".join( + c if c.isalnum() or c in "-_." else "_" for c in file_stem + ) + if not file_stem: + file_stem = "graph" + + out_path = os.path.join(output_dir, file_stem + file_ext) + + # Handle duplicate filenames by appending a counter + counter = 1 + original_out_path = out_path + while os.path.exists(out_path): + out_path = original_out_path[: -len(file_ext)] + f"_{counter}{file_ext}" + counter += 1 + + _triple_handler.write(named_graph, out_path, output_format) + output_files.append(out_path) + print(f"Written graph '{graph_id}' -> {os.path.basename(out_path)}") + + if not output_files: + raise ValueError( + f"No named graphs with triples found in '{os.path.basename(input_file)}'. " + "Nothing to split." + ) + + print( + f"Quad -> Triple split complete: {len(output_files)} file(s) " + f"({output_format}) in '{os.path.basename(output_dir)}/'" + ) + return output_files + + +# --------------------------------------------------------------------------- +# Direction 3 — Triple -> TSD (CSV/TSV) +# --------------------------------------------------------------------------- + def convert_rdf_to_csv( input_file: str, output_file: str, input_format: str, + output_format: str, ) -> None: - """Map RDF triples to a wide CSV table (Layer 3 prototype). + """Map RDF triples to a wide tabular table (Layer 3, quasi-equal). + + Each unique RDF subject becomes one row. Each unique predicate becomes + a column header (full predicate URI). Object values fill the cells. + Multi-valued predicates are pipe-separated (|) to enable unambiguous + splitting on round trip. - Each unique subject becomes a row. Each unique predicate becomes a column. - Multi-valued predicates are pipe-separated. - A companion .meta.json file is generated to preserve RDF datatype and - language tag information for lossless round trips. + A companion .meta.json file is generated alongside the output file + to preserve RDF datatype and language tag information, enabling + lossless round trips when convert_csv_to_rdf() is called with the + same companion file present. - NOTE: This is a Layer 3 prototype. It is not yet tested and will be - properly implemented in the Layer 3 issue. + Blank node subjects and objects are serialized as '_:label' (see + _term_to_str). This is essential for correct round trips. Args: input_file: Path to input RDF triples file. - output_file: Path to write output CSV file. + output_file: Path to write output CSV or TSV file. input_format: Source triple format name (must be in RDF_TRIPLE_FORMATS). + output_format: Target tabular format ('csv' or 'tsv'). """ - handler = TripleHandler() - g = handler.read(input_file, input_format) + g = _triple_handler.read(input_file, input_format) + # Collect all unique predicates (sorted for deterministic column order) predicates = sorted(set(str(p) for s, p, o in g)) + # Group objects by (subject, predicate) subjects: dict = {} + # column_metadata: predicate URI -> {datatype: ...} or {language: ...} + # Only the LAST seen value's metadata is stored per predicate (see + # module docstring on per-predicate metadata granularity). column_metadata: dict = {} for s, p, o in g: - subj = str(s) + subj = _term_to_str(s) pred = str(p) - if hasattr(o, "datatype") and o.datatype: - column_metadata[pred] = {"datatype": str(o.datatype)} - elif hasattr(o, "language") and o.language: - column_metadata[pred] = {"language": str(o.language)} + # Collect datatype/language metadata for companion file + if isinstance(o, Literal): + if o.datatype and str(o.datatype) != str(XSD.string): + column_metadata[pred] = {"datatype": str(o.datatype)} + elif o.language: + column_metadata[pred] = {"language": str(o.language)} if subj not in subjects: subjects[subj] = {} if pred not in subjects[subj]: subjects[subj][pred] = [] - subjects[subj][pred].append(str(o)) + subjects[subj][pred].append(_term_to_str(o)) - tsd_handler = TSDHandler() + # Build rows: header + one row per subject rows = [["resource"] + predicates] for subj, pred_map in subjects.items(): row = [subj] @@ -58,11 +288,262 @@ def convert_rdf_to_csv( row.append("|".join(values)) rows.append(row) - tsd_handler.write(rows, output_file, "csv") + _tsd_handler.write(rows, output_file, output_format) + # Write companion metadata file companion_file = output_file + ".meta.json" with open(companion_file, "w", encoding="utf-8") as f: json.dump({"columns": column_metadata}, f, indent=2) - print(f"Converted RDF -> CSV: {os.path.basename(output_file)}") + print(f"Converted RDF -> {output_format.upper()}: {os.path.basename(output_file)}") print(f"Companion metadata: {os.path.basename(companion_file)}") + + +# --------------------------------------------------------------------------- +# Direction 4 — TSD (CSV/TSV) -> Triple +# --------------------------------------------------------------------------- + +def convert_csv_to_rdf( + input_file: str, + output_file: str, + input_format: str, + output_format: str, + base_uri: str, +) -> None: + """Reconstruct RDF triples from a wide tabular file (Layer 3). + + Column headers (except 'resource') become predicate URIs directly. + Each row becomes one RDF subject. Cell values become object literals, + URIs, or blank nodes depending on their content. + + If a companion .meta.json file exists alongside the input CSV + (same path + '.meta.json'), datatypes and language tags are restored + from it, enabling lossless round trips. Without the companion file, + all literal values are created as plain xsd:string literals. + + Note: companion file lookup uses input_file + '.meta.json' — the + companion must be co-located with the exact input file path passed + here. If the input was downloaded compressed and decompressed to a + temporary file, no companion will typically be found (this is an + inherent, documented limitation, not a bug). + + Multi-valued cells (pipe-separated '|') are split back into multiple + triples per subject-predicate pair. + + Blank node subjects/objects: any value starting with '_:' is + reconstructed as a BNode with the same label. URI objects: any value + starting with 'http://' or 'https://' is created as URIRef. + + Args: + input_file: Path to input CSV or TSV file. + output_file: Path to write output RDF triples file. + input_format: Source tabular format ('csv' or 'tsv'). + output_format: Target triple format name (e.g. 'ntriples', 'turtle'). + base_uri: Base URI for constructing subject URIs from relative identifiers. + + Raises: + ValueError: If base_uri is empty or None. + ValueError: If input file is empty or missing the 'resource' column. + """ + if not base_uri: + raise ValueError( + "base_uri is required for CSV -> RDF conversion. " + "Use --base-uri to specify the base URI for subject construction." + ) + + rows = _tsd_handler.read(input_file, input_format) + + if not rows: + raise ValueError(f"Input file '{os.path.basename(input_file)}' is empty.") + + header = rows[0] + if "resource" not in header: + raise ValueError( + f"Input CSV missing 'resource' column. " + f"Found columns: {header}. " + "The 'resource' column is required and must contain subject identifiers." + ) + + resource_idx = header.index("resource") + predicate_columns = [ + (i, col) for i, col in enumerate(header) if i != resource_idx + ] + + # Load companion metadata if present + companion_path = input_file + ".meta.json" + column_metadata: dict = {} + if os.path.exists(companion_path): + with open(companion_path, "r", encoding="utf-8") as f: + meta = json.load(f) + column_metadata = meta.get("columns", {}) + print(f"Loaded companion metadata: {os.path.basename(companion_path)}") + else: + print( + "No companion metadata file found. " + "All literal values will be created as plain strings." + ) + + base_uri_stripped = base_uri.rstrip("/") + g = Graph() + + for row in rows[1:]: # skip header + if len(row) < len(header): + row = row + [""] * (len(header) - len(row)) + + resource_val = row[resource_idx].strip() + if not resource_val: + continue # skip empty rows + + # Build subject node + if resource_val.startswith("_:"): + subject = BNode(resource_val[2:]) + elif resource_val.startswith("http://") or resource_val.startswith("https://"): + subject = URIRef(resource_val) + else: + subject = URIRef(f"{base_uri_stripped}/{resource_val}") + + # Build triples for each predicate column + for col_idx, pred_uri in predicate_columns: + cell = row[col_idx].strip() if col_idx < len(row) else "" + if not cell: + continue + + predicate = URIRef(pred_uri) + meta = column_metadata.get(pred_uri, {}) + + # Split multi-valued cells + for val in cell.split("|"): + val = val.strip() + if not val: + continue + + obj = _build_object(val, meta) + g.add((subject, predicate, obj)) + + _triple_handler.write(g, output_file, output_format) + print( + f"Converted {input_format.upper()} -> {output_format}: " + f"{os.path.basename(output_file)}" + ) + + +def _build_object(value: str, meta: dict): + """Build an RDF object term from a CSV cell string and metadata. + + Args: + value: String value from CSV cell. + meta: Metadata dict with optional 'datatype' or 'language' keys. + + Returns: + rdflib term: URIRef, BNode, or Literal. + """ + # Blank node + if value.startswith("_:"): + return BNode(value[2:]) + + # URI + if value.startswith("http://") or value.startswith("https://"): + return URIRef(value) + + # Literal with datatype from companion file + if "datatype" in meta: + return Literal(value, datatype=URIRef(meta["datatype"])) + + # Literal with language tag from companion file + if "language" in meta: + return Literal(value, lang=meta["language"]) + + # Plain string literal (no companion metadata) + return Literal(value) + + +# --------------------------------------------------------------------------- +# Direction 5 — Quad -> TSD (CSV/TSV) +# --------------------------------------------------------------------------- + +def convert_quads_to_csv( + input_file: str, + output_file: str, + input_format: str, + output_format: str, +) -> None: + """Map RDF quads to a wide tabular table with a graph column (Layer 3, quasi-equal). + + Extends the Triple -> TSD mapping by adding a 'graph' column containing + the named graph URI. Each row represents one (subject, graph) pair, with + one column per predicate (pipe-separated for multi-valued predicates). + + A companion .meta.json file is generated to preserve datatype and + language tag information. + + The default graph (if present) is skipped — only triples within named + graphs are represented, since the 'graph' column requires a graph URI. + + Args: + input_file: Path to input quads file. + output_file: Path to write output CSV or TSV file. + input_format: Source quad format name (e.g. 'nquads', 'trig'). + output_format: Target tabular format ('csv' or 'tsv'). + """ + d = _quad_handler.read(input_file, input_format) + + # Collect all predicates across all named graphs (sorted for determinism) + all_predicates = sorted( + set( + str(p) + for named_graph in d.graphs() + for s, p, o in named_graph + if str(named_graph.identifier) not in ("urn:x-rdflib:default", "") + ) + ) + + column_metadata: dict = {} + # rows_map key: (subject_str, graph_uri_str) -> {predicate_uri: [values]} + rows_map: dict = {} + + for named_graph in d.graphs(): + graph_id = str(named_graph.identifier) + + # Skip the default graph — no meaningful graph URI for the column + if graph_id in ("urn:x-rdflib:default", ""): + continue + + for s, p, o in named_graph: + subj = _term_to_str(s) + pred = str(p) + key = (subj, graph_id) + + if isinstance(o, Literal): + if o.datatype and str(o.datatype) != str(XSD.string): + column_metadata[pred] = {"datatype": str(o.datatype)} + elif o.language: + column_metadata[pred] = {"language": str(o.language)} + + if key not in rows_map: + rows_map[key] = {} + if pred not in rows_map[key]: + rows_map[key][pred] = [] + rows_map[key][pred].append(_term_to_str(o)) + + # Build rows: header = resource + graph + all predicates + header = ["resource", "graph"] + all_predicates + rows = [header] + + for (subj, graph_id), pred_map in rows_map.items(): + row = [subj, graph_id] + for pred in all_predicates: + values = pred_map.get(pred, []) + row.append("|".join(values)) + rows.append(row) + + _tsd_handler.write(rows, output_file, output_format) + + companion_file = output_file + ".meta.json" + with open(companion_file, "w", encoding="utf-8") as f: + json.dump({"columns": column_metadata}, f, indent=2) + + print( + f"Converted {input_format} -> {output_format.upper()} " + f"(with graph column): {os.path.basename(output_file)}" + ) + print(f"Companion metadata: {os.path.basename(companion_file)}") \ No newline at end of file diff --git a/tests/resources/empty.csv b/tests/resources/empty.csv new file mode 100644 index 0000000..e69de29 diff --git a/tests/resources/empty.nq b/tests/resources/empty.nq new file mode 100644 index 0000000..e69de29 diff --git a/tests/resources/missing_resource_col.csv b/tests/resources/missing_resource_col.csv new file mode 100644 index 0000000..cae3677 --- /dev/null +++ b/tests/resources/missing_resource_col.csv @@ -0,0 +1,2 @@ +subject,predicate +https://example.org/alice,Bob \ No newline at end of file diff --git a/tests/test_format_round_trips.py b/tests/test_format_round_trips.py index 7244e2e..b828ddb 100644 --- a/tests/test_format_round_trips.py +++ b/tests/test_format_round_trips.py @@ -25,12 +25,20 @@ import os import tempfile +from rdflib import BNode, URIRef from databusclient.api.convert import ( QuadHandler, TSDHandler, TripleHandler, ) +from databusclient.filehandling.mapping import ( + convert_triples_to_quads, + convert_quads_to_triples, + convert_rdf_to_csv, + convert_csv_to_rdf, + convert_quads_to_csv, +) # --------------------------------------------------------------------------- # Path to shared test resources @@ -250,4 +258,219 @@ def test_round_trip_tsv(): ) finally: if os.path.exists(output): - os.remove(output) \ No newline at end of file + os.remove(output) + +# --------------------------------------------------------------------------- +# Mapping round trip tests (Layer 3) — 5 tests total +# --------------------------------------------------------------------------- +# These tests validate cross-class conversions following the quasi-equal +# strategy from Frey et al. Where information loss is expected (e.g. RDF +# datatypes in CSV), the comparison accounts for that predictable loss. +# --------------------------------------------------------------------------- + +def test_mapping_triples_to_quads_and_back(): + """Triple -> Quad -> Triple round trip (lossless with graph_name).""" + source = resource("sample.ttl") + graph_uri = "https://example.org/graph/test" + + g_original = triple_handler.read(source, "turtle") + + with tempfile.TemporaryDirectory() as tmpdir: + quads_path = os.path.join(tmpdir, "promoted.nq") + convert_triples_to_quads(source, quads_path, "turtle", "nquads", graph_uri) + + # Split back — produces subdirectory + output_dir = os.path.join(tmpdir, "split") + files = convert_quads_to_triples(quads_path, output_dir, "nquads", "ntriples") + + assert len(files) == 1, "Expected exactly one output file (one named graph)" + + g_roundtrip = triple_handler.read(files[0], "ntriples") + assert g_original.isomorphic(g_roundtrip), ( + "Triple -> Quad -> Triple round trip failed: graphs are not isomorphic" + ) + + +def test_mapping_quads_to_triples_and_back(): + """Quad -> Triple -> Quad round trip (lossless, graph info preserved).""" + source = resource("sample.nq") + d_original = quad_handler.read(source, "nquads") + + with tempfile.TemporaryDirectory() as tmpdir: + # Split quads into per-graph triple files + output_dir = os.path.join(tmpdir, "split") + files = convert_quads_to_triples(source, output_dir, "nquads", "ntriples") + + assert len(files) >= 1, "Expected at least one output file" + + # Re-promote each file back to quads using its graph name + # (we use the same graph URIs from the original) + original_graphs = { + str(g.identifier): g + for g in d_original.graphs() + if len(g) > 0 and str(g.identifier) not in ("urn:x-rdflib:default", "") + } + + for out_file in files: + stem = os.path.basename(out_file)[:-3] # strip .nt + # Find the matching original graph by last URI segment + matching_graph_uri = next( + (uri for uri in original_graphs if uri.rstrip("/").split("/")[-1] == stem), + None + ) + if matching_graph_uri is None: + continue + + g_split = triple_handler.read(out_file, "ntriples") + g_original_named = original_graphs[matching_graph_uri] + assert g_split.isomorphic(g_original_named), ( + f"Quad -> Triple round trip failed for graph '{matching_graph_uri}': " + "graphs are not isomorphic" + ) + + +def test_mapping_triples_to_csv_and_back_with_companion(): + """Triple -> CSV -> Triple round trip (lossless with companion metadata file).""" + source = resource("sample.ttl") + g_original = triple_handler.read(source, "turtle") + + with tempfile.TemporaryDirectory() as tmpdir: + csv_path = os.path.join(tmpdir, "output.csv") + convert_rdf_to_csv(source, csv_path, "turtle", "csv") + + companion_path = csv_path + ".meta.json" + assert os.path.exists(companion_path), "Companion .meta.json was not created" + + nt_path = os.path.join(tmpdir, "roundtrip.nt") + convert_csv_to_rdf( + csv_path, nt_path, "csv", "ntriples", + base_uri="https://example.org/data/" + ) + + g_roundtrip = triple_handler.read(nt_path, "ntriples") + + # With companion file: datatypes are restored. + # Blank nodes are quasi-equal: labels may differ, structure must match. + assert g_original.isomorphic(g_roundtrip), ( + "Triple -> CSV -> Triple round trip failed (with companion file): " + "graphs are not isomorphic" + ) + + +def test_mapping_triples_to_csv_quasi_equal_without_companion(): + """Triple -> CSV -> Triple quasi-equal test (without companion file). + + Without the companion file, datatypes are lost — all values become + plain string literals. The test verifies that subjects, predicates, + and string values match, but does not assert datatype preservation. + This documents the expected information loss. + """ + source = resource("sample.ttl") + g_original = triple_handler.read(source, "turtle") + + with tempfile.TemporaryDirectory() as tmpdir: + csv_path = os.path.join(tmpdir, "output.csv") + convert_rdf_to_csv(source, csv_path, "turtle", "csv") + + # Remove companion file to simulate no-metadata scenario + companion_path = csv_path + ".meta.json" + if os.path.exists(companion_path): + os.remove(companion_path) + + nt_path = os.path.join(tmpdir, "roundtrip.nt") + convert_csv_to_rdf( + csv_path, nt_path, "csv", "ntriples", + base_uri="https://example.org/data/" + ) + + g_roundtrip = triple_handler.read(nt_path, "ntriples") + + # Quasi-equal check: named (URI) subjects must match exactly. + # Blank node subjects are expected to get NEW labels on round trip + # (blank node identity is never expected to survive serialization — + # only structure matters, same principle as isomorphic() checks + # for Layer 2). So we compare URI subjects by value, and blank + # node subjects only by count. + original_uri_subjects = set( + str(s) for s, p, o in g_original if isinstance(s, URIRef) + ) + roundtrip_uri_subjects = set( + str(s) for s, p, o in g_roundtrip if isinstance(s, URIRef) + ) + assert original_uri_subjects == roundtrip_uri_subjects, ( + "Quasi-equal check failed: named (URI) subject sets differ" + ) + + original_bnode_subjects = set( + s for s, p, o in g_original if isinstance(s, BNode) + ) + roundtrip_bnode_subjects = set( + s for s, p, o in g_roundtrip if isinstance(s, BNode) + ) + assert len(original_bnode_subjects) == len(roundtrip_bnode_subjects), ( + "Quasi-equal check failed: number of distinct blank node subjects " + "differs. Blank node labels are expected to change on round trip, " + "but their count should be preserved." + ) + + original_predicates = set(str(p) for s, p, o in g_original) + roundtrip_predicates = set(str(p) for s, p, o in g_roundtrip) + assert original_predicates == roundtrip_predicates, ( + "Quasi-equal check failed: predicate sets differ" + ) + + # String values must match (datatypes stripped — known loss). + # Blank node OBJECT values are also expected to get new labels, + # so we compare non-blank-node object values only. + original_values = set( + str(o) for s, p, o in g_original if not isinstance(o, BNode) + ) + roundtrip_values = set( + str(o) for s, p, o in g_roundtrip if not isinstance(o, BNode) + ) + assert original_values == roundtrip_values, ( + "Quasi-equal check failed: object string values differ. " + "This is unexpected — only datatypes should be lost without companion file." + ) + + +def test_mapping_quads_to_csv_and_back(): + """Quad -> CSV (with graph column) round trip (quasi-equal). + + Verifies that named graph information is preserved in the graph column + and that all triple data is present in the CSV output. + """ + source = resource("sample.nq") + d_original = quad_handler.read(source, "nquads") + + with tempfile.TemporaryDirectory() as tmpdir: + csv_path = os.path.join(tmpdir, "quads_output.csv") + convert_quads_to_csv(source, csv_path, "nquads", "csv") + + assert os.path.exists(csv_path), "CSV output was not created" + companion_path = csv_path + ".meta.json" + assert os.path.exists(companion_path), "Companion .meta.json was not created" + + # Verify graph column is present in CSV + rows = tsd_handler.read(csv_path, "csv") + assert len(rows) > 1, "CSV has no data rows" + header = rows[0] + assert "graph" in header, ( + "CSV output missing 'graph' column for Quad -> CSV conversion" + ) + assert "resource" in header, "CSV output missing 'resource' column" + + # Verify all named graph URIs appear in the graph column + graph_col_idx = header.index("graph") + csv_graphs = set(row[graph_col_idx] for row in rows[1:] if len(row) > graph_col_idx) + + original_graph_uris = set( + str(g.identifier) + for g in d_original.graphs() + if len(g) > 0 and str(g.identifier) not in ("urn:x-rdflib:default", "") + ) + + assert csv_graphs == original_graph_uris, ( + f"Graph URIs in CSV do not match original. " + f"Expected: {original_graph_uris}, got: {csv_graphs}" + ) \ No newline at end of file diff --git a/tests/test_mapping_conversions.py b/tests/test_mapping_conversions.py new file mode 100644 index 0000000..58b454a --- /dev/null +++ b/tests/test_mapping_conversions.py @@ -0,0 +1,655 @@ +"""Comprehensive functional tests for Layer 3 mapping conversions. + +These tests cover all 5 mapping directions with edge cases: + - Triple -> Quad (with graph_name) + - Quad -> Triple (split by graph, subdirectory output) + - Triple -> TSD (CSV/TSV, companion metadata) + - TSD -> Triple (with and without companion file) + - Quad -> TSD (CSV with graph column) + +Edge cases covered: + - Blank node subjects and objects + - Typed literals (xsd:integer) + - Multi-valued predicates (pipe-separated) + - Missing companion file (graceful degradation) + - Empty cells in CSV + - Graph name sanitization in filenames +""" + +import json +import os +import tempfile +import pytest + +from databusclient.filehandling.format import TripleHandler, QuadHandler, TSDHandler +from databusclient.filehandling.mapping import ( + convert_triples_to_quads, + convert_quads_to_triples, + convert_rdf_to_csv, + convert_csv_to_rdf, + convert_quads_to_csv, +) + +triple_handler = TripleHandler() +quad_handler = QuadHandler() +tsd_handler = TSDHandler() + +# --------------------------------------------------------------------------- +# Shared test data and helpers +# --------------------------------------------------------------------------- + +RESOURCES = os.path.join(os.path.dirname(__file__), "resources") + + +def resource(filename: str) -> str: + return os.path.join(RESOURCES, filename) + + +# --------------------------------------------------------------------------- +# Direction 1: Triple -> Quad +# --------------------------------------------------------------------------- + +class TestTriplesToQuads: + + def test_basic_conversion(self): + """All triples are assigned to the specified named graph.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.ttl") + out = os.path.join(tmpdir, "output.nq") + convert_triples_to_quads(src, out, "turtle", "nquads", + "https://example.org/graph/test") + + assert os.path.exists(out) + d = quad_handler.read(out, "nquads") + graph_uris = [ + str(g.identifier) for g in d.graphs() + if str(g.identifier) not in ("urn:x-rdflib:default", "") + and len(g) > 0 + ] + assert "https://example.org/graph/test" in graph_uris + + def test_triple_count_preserved(self): + """All triples from input appear in the named graph.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.ttl") + out = os.path.join(tmpdir, "output.nq") + convert_triples_to_quads(src, out, "turtle", "nquads", + "https://example.org/graph/test") + + g_original = triple_handler.read(src, "turtle") + d = quad_handler.read(out, "nquads") + named_graph = d.get_context( + __import__("rdflib").URIRef("https://example.org/graph/test") + ) + assert len(g_original) == len(named_graph) + + def test_requires_graph_name(self): + """Raises ValueError if graph_name is None or empty.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.ttl") + out = os.path.join(tmpdir, "output.nq") + + with pytest.raises(ValueError, match="graph_name is required"): + convert_triples_to_quads(src, out, "turtle", "nquads", None) + + with pytest.raises(ValueError, match="graph_name is required"): + convert_triples_to_quads(src, out, "turtle", "nquads", "") + + def test_trig_output_format(self): + """Triple -> Quad works with trig output format.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.ttl") + out = os.path.join(tmpdir, "output.trig") + convert_triples_to_quads(src, out, "turtle", "trig", + "https://example.org/graph/trig_test") + assert os.path.exists(out) + d = quad_handler.read(out, "trig") + assert len(d) > 0 + + def test_uses_resource_files(self): + """Conversion works correctly on the shared test resource files.""" + with tempfile.TemporaryDirectory() as tmpdir: + out = os.path.join(tmpdir, "output.nq") + convert_triples_to_quads( + resource("sample.ttl"), out, "turtle", "nquads", + "https://example.org/graph/resource_test" + ) + assert os.path.exists(out) + d = quad_handler.read(out, "nquads") + assert len(d) > 0 + + +# --------------------------------------------------------------------------- +# Direction 2: Quad -> Triple +# --------------------------------------------------------------------------- + +class TestQuadsToTriples: + + def test_creates_subdirectory(self): + """Output subdirectory is created automatically.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.nq") + out_dir = os.path.join(tmpdir, "split_output") + convert_quads_to_triples(src, out_dir, "nquads", "ntriples") + assert os.path.isdir(out_dir) + + def test_one_file_per_graph(self): + """One .nt file is created per named graph.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.nq") + out_dir = os.path.join(tmpdir, "split") + files = convert_quads_to_triples(src, out_dir, "nquads", "ntriples") + + # SAMPLE_NQ_CONTENT has 2 named graphs + assert len(files) == 2 + for f in files: + assert f.endswith(".nt") + assert os.path.exists(f) + + def test_all_triples_present(self): + """Total triple count across all output files matches input quad count.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.nq") + out_dir = os.path.join(tmpdir, "split") + files = convert_quads_to_triples(src, out_dir, "nquads", "ntriples") + + total_output_triples = sum( + len(triple_handler.read(f, "ntriples")) for f in files + ) + d_original = quad_handler.read(src, "nquads") + total_input_triples = sum( + len(g) for g in d_original.graphs() + if str(g.identifier) not in ("urn:x-rdflib:default", "") + ) + assert total_output_triples == total_input_triples + + def test_filename_from_graph_uri(self): + """Output filenames are derived from graph URI last segment.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.nq") + out_dir = os.path.join(tmpdir, "split") + files = convert_quads_to_triples(src, out_dir, "nquads", "ntriples") + + filenames = [os.path.basename(f) for f in files] + # people.nt and projects.nt expected from graph URIs + assert "people.nt" in filenames + assert "projects.nt" in filenames + + def test_empty_input_raises(self): + """Raises ValueError if input has no named graphs with triples.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("empty.nq") + out_dir = os.path.join(tmpdir, "split") + with pytest.raises(ValueError, match="No named graphs"): + convert_quads_to_triples(src, out_dir, "nquads", "ntriples") + + def test_uses_resource_files(self): + """Conversion works correctly on shared resource sample.nq.""" + with tempfile.TemporaryDirectory() as tmpdir: + out_dir = os.path.join(tmpdir, "split") + files = convert_quads_to_triples(resource("sample.nq"), out_dir, "nquads", "ntriples") + assert len(files) >= 1 + for f in files: + g = triple_handler.read(f, "ntriples") + assert len(g) > 0 + + +# --------------------------------------------------------------------------- +# Direction 3: Triple -> TSD +# --------------------------------------------------------------------------- + +class TestTriplesToCSV: + + def test_creates_csv_and_companion(self): + """Both CSV and companion .meta.json are created.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.ttl") + out = os.path.join(tmpdir, "output.csv") + convert_rdf_to_csv(src, out, "turtle", "csv") + + assert os.path.exists(out) + assert os.path.exists(out + ".meta.json") + + def test_header_row_contains_predicates(self): + """CSV header contains 'resource' and all predicate URIs.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.ttl") + out = os.path.join(tmpdir, "output.csv") + convert_rdf_to_csv(src, out, "turtle", "csv") + + rows = tsd_handler.read(out, "csv") + header = rows[0] + assert "resource" in header + assert "http://xmlns.com/foaf/0.1/name" in header + assert "https://example.org/vocab/age" in header + + def test_datatype_preserved_in_companion(self): + """Companion file records xsd:integer datatype for age predicate.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.ttl") + out = os.path.join(tmpdir, "output.csv") + convert_rdf_to_csv(src, out, "turtle", "csv") + + with open(out + ".meta.json", "r", encoding="utf-8") as f: + meta = json.load(f) + age_meta = meta["columns"].get("https://example.org/vocab/age", {}) + assert "datatype" in age_meta + assert "integer" in age_meta["datatype"] + + def test_one_row_per_subject(self): + """CSV has one data row per unique subject.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.ttl") + out = os.path.join(tmpdir, "output.csv") + convert_rdf_to_csv(src, out, "turtle", "csv") + + rows = tsd_handler.read(out, "csv") + g = triple_handler.read(src, "turtle") + unique_subjects = set(str(s) for s, p, o in g) + # rows[0] is header, rest are data rows + assert len(rows) - 1 == len(unique_subjects) + + def test_tsv_output(self): + """Triple -> TSV also works correctly.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.ttl") + out = os.path.join(tmpdir, "output.tsv") + convert_rdf_to_csv(src, out, "turtle", "tsv") + assert os.path.exists(out) + rows = tsd_handler.read(out, "tsv") + assert len(rows) > 1 + + def test_uses_resource_files(self): + """Conversion works on shared resource sample.ttl.""" + with tempfile.TemporaryDirectory() as tmpdir: + out = os.path.join(tmpdir, "output.csv") + convert_rdf_to_csv(resource("sample.ttl"), out, "turtle", "csv") + assert os.path.exists(out) + rows = tsd_handler.read(out, "csv") + assert len(rows) > 1 + + +# --------------------------------------------------------------------------- +# Direction 4: TSD -> Triple +# --------------------------------------------------------------------------- + +class TestCSVToTriples: + + def test_basic_reconstruction_with_companion(self): + """CSV -> RDF round trip with companion file restores typed literals.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.ttl") + csv_path = os.path.join(tmpdir, "output.csv") + convert_rdf_to_csv(src, csv_path, "turtle", "csv") + + nt_path = os.path.join(tmpdir, "roundtrip.nt") + convert_csv_to_rdf( + csv_path, nt_path, "csv", "ntriples", + base_uri="https://example.org/data/" + ) + assert os.path.exists(nt_path) + g = triple_handler.read(nt_path, "ntriples") + assert len(g) > 0 + + def test_requires_base_uri(self): + """Raises ValueError if base_uri is None or empty.""" + with tempfile.TemporaryDirectory() as tmpdir: + csv_content = "resource,https://example.org/vocab/name\nhttps://example.org/data/alice,Alice\n" + csv_path = os.path.join(tmpdir,"input.csv") + with open(csv_path, "w", encoding="utf-8") as f: + f.write(csv_content) + out = os.path.join(tmpdir, "output.nt") + + with pytest.raises(ValueError, match="base_uri is required"): + convert_csv_to_rdf(csv_path, out, "csv", "ntriples", None) + + with pytest.raises(ValueError, match="base_uri is required"): + convert_csv_to_rdf(csv_path, out, "csv", "ntriples", "") + + def test_missing_resource_column_raises(self): + """Raises ValueError if CSV has no 'resource' column.""" + with tempfile.TemporaryDirectory() as tmpdir: + csv_path = resource("missing_resource_col.csv") + out = os.path.join(tmpdir, "output.nt") + with pytest.raises(ValueError, match="missing 'resource' column"): + convert_csv_to_rdf(csv_path, out, "csv", "ntriples", + "https://example.org/data/") + + def test_blank_nodes_reconstructed(self): + """Blank node subjects (starting with '_:') are reconstructed as BNodes.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.ttl") + csv_path = os.path.join(tmpdir, "output.csv") + convert_rdf_to_csv(src, csv_path, "turtle", "csv") + + nt_path = os.path.join(tmpdir, "roundtrip.nt") + convert_csv_to_rdf( + csv_path, nt_path, "csv", "ntriples", + base_uri="https://example.org/data/" + ) + g = triple_handler.read(nt_path, "ntriples") + from rdflib import BNode + blank_subjects = [s for s, p, o in g if isinstance(s, BNode)] + assert len(blank_subjects) > 0, ( + "Expected blank node subjects to be reconstructed" + ) + + def test_uri_objects_reconstructed(self): + """Object values starting with http:// are reconstructed as URIRef.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.ttl") + csv_path = os.path.join(tmpdir, "output.csv") + convert_rdf_to_csv(src, csv_path, "turtle", "csv") + + nt_path = os.path.join(tmpdir, "roundtrip.nt") + convert_csv_to_rdf( + csv_path, nt_path, "csv", "ntriples", + base_uri="https://example.org/data/" + ) + g = triple_handler.read(nt_path, "ntriples") + from rdflib import URIRef + uri_objects = [o for s, p, o in g if isinstance(o, URIRef)] + assert len(uri_objects) > 0 + + def test_graceful_without_companion(self): + """Without companion file, conversion succeeds with plain string literals.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.ttl") + csv_path = os.path.join(tmpdir, "output.csv") + convert_rdf_to_csv(src, csv_path, "turtle", "csv") + + # Remove companion + companion = csv_path + ".meta.json" + if os.path.exists(companion): + os.remove(companion) + + nt_path = os.path.join(tmpdir, "roundtrip.nt") + # Should not raise — graceful degradation + convert_csv_to_rdf( + csv_path, nt_path, "csv", "ntriples", + base_uri="https://example.org/data/" + ) + g = triple_handler.read(nt_path, "ntriples") + assert len(g) > 0 + + def test_empty_csv_raises(self): + """Raises ValueError if CSV file is empty.""" + with tempfile.TemporaryDirectory() as tmpdir: + csv_path = resource("empty.csv") + out = os.path.join(tmpdir, "output.nt") + with pytest.raises(ValueError, match="empty"): + convert_csv_to_rdf(csv_path, out, "csv", "ntriples", + "https://example.org/data/") + + +# --------------------------------------------------------------------------- +# Direction 5: Quad -> TSD +# --------------------------------------------------------------------------- + +class TestQuadsToCSV: + + def test_creates_csv_with_graph_column(self): + """Output CSV contains 'resource', 'graph', and predicate columns.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.nq") + out = os.path.join(tmpdir, "output.csv") + convert_quads_to_csv(src, out, "nquads", "csv") + + assert os.path.exists(out) + rows = tsd_handler.read(out, "csv") + header = rows[0] + assert "resource" in header + assert "graph" in header + + def test_companion_file_created(self): + """Companion .meta.json is created alongside CSV.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.nq") + out = os.path.join(tmpdir, "output.csv") + convert_quads_to_csv(src, out, "nquads", "csv") + assert os.path.exists(out + ".meta.json") + + def test_graph_uris_in_csv(self): + """All named graph URIs from input appear in the graph column.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.nq") + out = os.path.join(tmpdir, "output.csv") + convert_quads_to_csv(src, out, "nquads", "csv") + + rows = tsd_handler.read(out, "csv") + header = rows[0] + graph_idx = header.index("graph") + csv_graphs = set(row[graph_idx] for row in rows[1:] if len(row) > graph_idx) + + assert "https://example.org/graph/people" in csv_graphs + assert "https://example.org/graph/projects" in csv_graphs + + def test_all_triples_represented(self): + """Data row count matches total triple count across all named graphs.""" + with tempfile.TemporaryDirectory() as tmpdir: + src = resource("sample.nq") + out = os.path.join(tmpdir, "output.csv") + convert_quads_to_csv(src, out, "nquads", "csv") + + rows = tsd_handler.read(out, "csv") + # Each row is one (subject, graph) pair, not one triple. + # Verify at least one row per unique (subject, graph) + d = quad_handler.read(src, "nquads") + unique_subject_graph_pairs = set( + (str(s), str(g.identifier)) + for g in d.graphs() + for s, p, o in g + if str(g.identifier) not in ("urn:x-rdflib:default", "") + ) + assert len(rows) - 1 == len(unique_subject_graph_pairs) + + def test_uses_resource_files(self): + """Conversion works on shared resource sample.nq.""" + with tempfile.TemporaryDirectory() as tmpdir: + out = os.path.join(tmpdir, "output.csv") + convert_quads_to_csv(resource("sample.nq"), out, "nquads", "csv") + assert os.path.exists(out) + rows = tsd_handler.read(out, "csv") + assert len(rows) > 1 + +# --------------------------------------------------------------------------- +# Round trip tests — compare original IR with reconstructed IR +# --------------------------------------------------------------------------- + +class TestRoundTrips: + """Round trip tests for Layer 3 mapping directions. + + For each direction, the original file is read into IR BEFORE conversion. + After the full round trip (A -> B -> A), the reconstructed IR is compared + against the original. This genuinely detects information loss because the + original IR is captured before any conversion happens. + """ + + def test_triple_to_quad_to_triple_round_trip(self): + """Triple -> Quad -> Triple: reconstructed graph must be isomorphic to original.""" + source = resource("sample.ttl") + g_original = triple_handler.read(source, "turtle") + + with tempfile.TemporaryDirectory() as tmpdir: + # Step 1: Triple -> Quad + quads_path = os.path.join(tmpdir, "output.nq") + convert_triples_to_quads( + source, quads_path, "turtle", "nquads", + "https://example.org/graph/test" + ) + + # Step 2: Quad -> Triple (produces subdirectory) + output_dir = os.path.join(tmpdir, "split") + files = convert_quads_to_triples(quads_path, output_dir, "nquads", "ntriples") + + assert len(files) == 1, "Expected exactly one output file (one named graph)" + + # Compare IR: original graph vs reconstructed graph + g_roundtrip = triple_handler.read(files[0], "ntriples") + assert g_original.isomorphic(g_roundtrip), ( + "Triple -> Quad -> Triple round trip failed: " + "reconstructed graph is not isomorphic to original" + ) + + def test_triple_to_csv_to_triple_round_trip_with_companion(self): + """Triple -> CSV -> Triple: with companion file, reconstruction must be isomorphic.""" + source = resource("sample.ttl") + g_original = triple_handler.read(source, "turtle") + + with tempfile.TemporaryDirectory() as tmpdir: + # Step 1: Triple -> CSV (produces companion .meta.json) + csv_path = os.path.join(tmpdir, "output.csv") + convert_rdf_to_csv(source, csv_path, "turtle", "csv") + + assert os.path.exists(csv_path + ".meta.json"), ( + "Companion .meta.json was not produced" + ) + + # Step 2: CSV -> Triple (reads companion file automatically) + nt_path = os.path.join(tmpdir, "roundtrip.nt") + convert_csv_to_rdf( + csv_path, nt_path, "csv", "ntriples", + base_uri="https://example.org/data/" + ) + + # Compare IR: original graph vs reconstructed graph + g_roundtrip = triple_handler.read(nt_path, "ntriples") + assert g_original.isomorphic(g_roundtrip), ( + "Triple -> CSV -> Triple round trip failed (with companion file): " + "reconstructed graph is not isomorphic to original" + ) + + def test_triple_to_tsv_to_triple_round_trip_with_companion(self): + """Triple -> TSV -> Triple: with companion file, reconstruction must be isomorphic.""" + source = resource("sample.ttl") + g_original = triple_handler.read(source, "turtle") + + with tempfile.TemporaryDirectory() as tmpdir: + tsv_path = os.path.join(tmpdir, "output.tsv") + convert_rdf_to_csv(source, tsv_path, "turtle", "tsv") + + nt_path = os.path.join(tmpdir, "roundtrip.nt") + convert_csv_to_rdf( + tsv_path, nt_path, "tsv", "ntriples", + base_uri="https://example.org/data/" + ) + + g_roundtrip = triple_handler.read(nt_path, "ntriples") + assert g_original.isomorphic(g_roundtrip), ( + "Triple -> TSV -> Triple round trip failed (with companion file): " + "reconstructed graph is not isomorphic to original" + ) + + def test_quad_to_triple_to_quad_round_trip(self): + """Quad -> Triple (split) -> Quad (re-promote + merge) -> compare with original. + + Follows the paper's round trip pattern (Fig. 3, steps 1-6). + Each split .nt file is matched to its original named graph by content + (isomorphic comparison), not by filename, making the test independent + of filename conventions and sanitization. + + After re-promotion and merging, the merged Dataset is verified to + contain exactly the original named graph URIs — no more, no less. + """ + from rdflib import Dataset, URIRef + + source = resource("sample.nq") + d_original = quad_handler.read(source, "nquads") + + # Collect original named graphs (excluding empty default graph) + original_graphs = { + str(g.identifier): g + for g in d_original.graphs() + if len(g) > 0 + and str(g.identifier) not in ("urn:x-rdflib:default", "") + } + + assert len(original_graphs) >= 1, ( + "sample.nq must contain at least one named graph for this test" + ) + + with tempfile.TemporaryDirectory() as tmpdir: + # Steps 2+3: Quad -> Triple (split into one .nt file per named graph) + output_dir = os.path.join(tmpdir, "split") + files = convert_quads_to_triples( + source, output_dir, "nquads", "ntriples" + ) + + assert len(files) == len(original_graphs), ( + f"Expected {len(original_graphs)} output file(s) " + f"(one per named graph), got {len(files)}" + ) + + # Steps 4+5+6: Match each .nt to original graph by content, + # re-promote to Quad using original graph URI, merge all + d_merged = Dataset() + used_graph_uris = set() + + for out_file in files: + # Step 4: Read split .nt into IR + g_split = triple_handler.read(out_file, "ntriples") + + # Step 5: Match to original named graph by content (not filename) + matching_uri = next( + ( + uri for uri, g_original in original_graphs.items() + if uri not in used_graph_uris + and g_split.isomorphic(g_original) + ), + None, + ) + assert matching_uri is not None, ( + f"Could not match output file '{os.path.basename(out_file)}' " + "to any original named graph by graph content" + ) + used_graph_uris.add(matching_uri) + + # Step 5+6: Re-promote .nt back to Quad using matched graph URI + stem = os.path.basename(out_file)[:-3] + repromoted_path = os.path.join(tmpdir, f"{stem}_repromoted.nq") + convert_triples_to_quads( + out_file, + repromoted_path, + "ntriples", + "nquads", + matching_uri, + ) + + # Read repromoted Quad into IR and merge into d_merged + d_repromoted = quad_handler.read(repromoted_path, "nquads") + for named_graph in d_repromoted.graphs(): + graph_id = str(named_graph.identifier) + if ( + graph_id in ("urn:x-rdflib:default", "") + or len(named_graph) == 0 + ): + continue + merged_graph = d_merged.graph(URIRef(graph_id)) + for triple in named_graph: + merged_graph.add(triple) + + # Verify merged Dataset contains exactly the original named graph URIs + merged_graph_uris = { + str(g.identifier) + for g in d_merged.graphs() + if len(g) > 0 + and str(g.identifier) not in ("urn:x-rdflib:default", "") + } + assert merged_graph_uris == set(original_graphs.keys()), ( + f"Merged Dataset graph URIs do not match original. " + f"Expected: {set(original_graphs.keys())}, " + f"got: {merged_graph_uris}" + ) + + # Compare each named graph in d_merged against d_original + for uri, g_original_named in original_graphs.items(): + g_merged_named = d_merged.get_context(URIRef(uri)) + assert g_original_named.isomorphic(g_merged_named), ( + f"Quad -> Triple -> Quad round trip failed for graph '{uri}': " + f"reconstructed graph is not isomorphic to original. " + f"Original had {len(g_original_named)} triple(s), " + f"reconstructed has {len(g_merged_named)} triple(s)." + ) + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file From fed207ca80e08386adcf4680f4823488e10d3b02 Mon Sep 17 00:00:00 2001 From: Dhanashree Petare Date: Wed, 8 Jul 2026 02:15:36 +0530 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20Milestone=202=20=E2=80=94=20Structu?= =?UTF-8?q?red=20Run=20Manifest=20System=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * gsoc26: Layer 2 with tests initial commit * gsoc26: Refactor Layer 2 with handler architecture and improved tests * Improvements to Format layer implementation * Review comments resolved under issue #59 * #61: replacing --convert_from & --convert_to with --compression * gsoc26: layer2 complete + implementation for #61 * gsoc2026: mapping layer initial commit * fix: errors from testing fixed. * docs: update README for --format and --compression flags * fix: resolve PR review comments. * fix: remove duplicate import after merging layer2 fixes into layer3 * docs: add Layer 3 mapping conversion flags and examples * Initial commit: Manifest System * fix: move all test data to tests/resources, add round trip IR comparisons * fix: align Quad->Triple->Quad round trip test as required. * fix: fixed review comments * fix: fixed review comments(2) * Complete Implementation of Milestone 2 * edge case handled * docs: add Manifest section to README documenting --manifest flag * feat: capture operation-level errors in manifest via dbus:operationError * fix: resolve PR review comments * fix: record manifest success, capture broad exceptions, gitignore fixes * fix: minor adjustments --- .gitignore | 6 + README.md | 50 +++++ databusclient/api/delete.py | 37 ++-- databusclient/api/download.py | 75 ++++++- databusclient/cli.py | 211 ++++++++++++++++--- databusclient/manifest/__init__.py | 5 + databusclient/manifest/context.py | 159 ++++++++++++++ databusclient/manifest/writer.py | 161 +++++++++++++++ examples/reproducible-download.md | 39 ++++ tests/test_manifest.py | 319 +++++++++++++++++++++++++++++ 10 files changed, 1013 insertions(+), 49 deletions(-) create mode 100644 databusclient/manifest/__init__.py create mode 100644 databusclient/manifest/context.py create mode 100644 databusclient/manifest/writer.py create mode 100644 examples/reproducible-download.md create mode 100644 tests/test_manifest.py diff --git a/.gitignore b/.gitignore index 45f7e07..ee3f319 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,12 @@ share/python-wheels/ *.egg MANIFEST +# Windows-specific: MANIFEST above matches databusclient/manifest/ case-insensitively +# on Windows, preventing the manifest module from being committed. +!databusclient/manifest/ +!databusclient/manifest/** +databusclient/manifest/__pycache__/ + # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. diff --git a/README.md b/README.md index c78a5c3..b46420b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Command-line and Python client for downloading and deploying datasets on DBpedia - [Download](#cli-download) - [Deploy](#cli-deploy) - [Delete](#cli-delete) + - [Manifest](#cli-manifest) - [Module Usage](#module-usage) - [Deploy](#module-deploy) - [Development & Contributing](#development--contributing) @@ -556,6 +557,55 @@ databusclient delete https://databus.dbpedia.org/dbpedia/collections/dbpedia-sna docker run --rm -v $(pwd):/data dbpedia/databus-python-client delete https://databus.dbpedia.org/dbpedia/collections/dbpedia-snapshot-2022-12 --databus-key YOUR_API_KEY ``` + +### Manifest + +All three commands support an optional `--manifest` flag that writes a structured JSON-LD record of the operation to disk: + +**Download** +```bash +# Python +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --manifest ./manifests/download-run.jsonld +# Docker +docker run --rm -v $(pwd):/data dbpedia/databus-python-client download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --manifest ./manifests/download-run.jsonld +``` + +**Deploy** +```bash +# Python +databusclient deploy \ + --version-id https://databus.dbpedia.org/user1/group1/artifact1/2022-05-18 \ + --title "Client Testing" --abstract "Testing the client...." \ + --description "Testing the client...." \ + --license http://dalicc.net/licenselibrary/AdaptivePublicLicense10 \ + --apikey YOUR_KEY --manifest ./manifests/deploy-run.jsonld \ + 'https://raw.githubusercontent.com/dbpedia/databus/master/server/app/api/swagger.yml|type=swagger' +# Docker +docker run --rm -v $(pwd):/data dbpedia/databus-python-client deploy \ + --version-id https://databus.dbpedia.org/user1/group1/artifact1/2022-05-18 \ + --title "Client Testing" --abstract "Testing the client...." \ + --description "Testing the client...." \ + --license http://dalicc.net/licenselibrary/AdaptivePublicLicense10 \ + --apikey YOUR_KEY --manifest ./manifests/deploy-run.jsonld \ + 'https://raw.githubusercontent.com/dbpedia/databus/master/server/app/api/swagger.yml|type=swagger' +``` +**Delete** +```bash +# Python +databusclient delete https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --databus-key YOUR_API_KEY --manifest ./manifests/delete-run.jsonld +# Docker +docker run --rm -v $(pwd):/data dbpedia/databus-python-client delete https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --databus-key YOUR_API_KEY --manifest ./manifests/delete-run.jsonld +``` + +The manifest records input parameters, per-file URLs, checksums, byte sizes, timestamps, and success/failure status for each file. It uses the DataID vocabulary and is versioned via `dbus:schemaVersion`. + +- If the target path already exists, the manifest is written to an auto-suffixed path (e.g. `run_1.jsonld`) with a warning. +- Sensitive fields (API keys, vault tokens) are never written. +- If manifest writing fails, a warning is printed and the exit code reflects the actual operation result. +- If the operation itself fails, a `dbus:operationError` block is recorded in the manifest capturing the error type, message, and traceback. + +Refer [examples/reproducible-download.md](examples/reproducible-download.md) for a full walkthrough. + ## Module Usage diff --git a/databusclient/api/delete.py b/databusclient/api/delete.py index 8e2d916..199e5a4 100644 --- a/databusclient/api/delete.py +++ b/databusclient/api/delete.py @@ -23,13 +23,16 @@ class DeleteQueue: Allows adding multiple databus URIs to a queue and executing their deletion in batch. """ - def __init__(self, databus_key: str): + def __init__(self, databus_key: str, manifest_context=None): """Create a DeleteQueue bound to a given Databus API key. Args: databus_key: API key used to authenticate deletion requests. + manifest_context: Optional ManifestContext to record deletion + outcomes into. Passed through to _delete_list on execute(). """ self.databus_key = databus_key + self.manifest_context = manifest_context self.queue: set[str] = set() def add_uri(self, databusURI: str): @@ -69,11 +72,13 @@ def execute(self): """Execute all queued deletions. Each queued URI will be deleted using `_delete_resource`. + Passes manifest_context through so deletions are recorded. """ _delete_list( list(self.sorted_queue()), self.databus_key, force=True, + manifest_context=self.manifest_context, ) @@ -116,6 +121,7 @@ def _delete_resource( dry_run: bool = False, force: bool = False, queue: DeleteQueue = None, + manifest_context=None, ): """Delete a single Databus resource (version, artifact, group). @@ -144,6 +150,8 @@ def _delete_resource( if dry_run: print(f"[DRY RUN] Would delete: {databusURI}") + if manifest_context is not None: + manifest_context.record_file(url=databusURI, status="dry_run") return if queue is not None: @@ -156,6 +164,8 @@ def _delete_resource( if response.status_code in (200, 204): print(f"Successfully deleted: {databusURI}") + if manifest_context is not None: + manifest_context.record_file(url=databusURI, status="success") else: raise Exception( f"Failed to delete {databusURI}: {response.status_code} - {response.text}" @@ -168,6 +178,7 @@ def _delete_list( dry_run: bool = False, force: bool = False, queue: DeleteQueue = None, + manifest_context=None, ): """Delete a list of Databus resources. @@ -180,7 +191,7 @@ def _delete_list( """ for databusURI in databusURIs: _delete_resource( - databusURI, databus_key, dry_run=dry_run, force=force, queue=queue + databusURI, databus_key, dry_run=dry_run, force=force, queue=queue, manifest_context=manifest_context ) @@ -190,6 +201,7 @@ def _delete_artifact( dry_run: bool = False, force: bool = False, queue: DeleteQueue = None, + manifest_context=None, ): """Delete an artifact and all its versions. @@ -223,11 +235,11 @@ def _delete_artifact( else: # Delete all versions _delete_list( - version_uris, databus_key, dry_run=dry_run, force=force, queue=queue + version_uris, databus_key, dry_run=dry_run, force=force, queue=queue, manifest_context=manifest_context ) # Finally, delete the artifact itself - _delete_resource(databusURI, databus_key, dry_run=dry_run, force=force, queue=queue) + _delete_resource(databusURI, databus_key, dry_run=dry_run, force=force, queue=queue,manifest_context=manifest_context) def _delete_group( @@ -236,6 +248,7 @@ def _delete_group( dry_run: bool = False, force: bool = False, queue: DeleteQueue = None, + manifest_context=None, ): """Delete a group and all its artifacts and versions. @@ -266,14 +279,14 @@ def _delete_group( # Delete all artifacts (which deletes their versions) for artifact_uri in artifact_uris: _delete_artifact( - artifact_uri, databus_key, dry_run=dry_run, force=force, queue=queue + artifact_uri, databus_key, dry_run=dry_run, force=force, queue=queue, manifest_context=manifest_context ) # Finally, delete the group itself - _delete_resource(databusURI, databus_key, dry_run=dry_run, force=force, queue=queue) + _delete_resource(databusURI, databus_key, dry_run=dry_run, force=force, queue=queue,manifest_context=manifest_context) -def delete(databusURIs: List[str], databus_key: str, dry_run: bool, force: bool): +def delete(databusURIs: List[str], databus_key: str, dry_run: bool, force: bool, manifest_context=None): """Delete a dataset from the databus. Delete a group, artifact, or version identified by the given databus URI. @@ -286,7 +299,7 @@ def delete(databusURIs: List[str], databus_key: str, dry_run: bool, force: bool) force: If True, skip confirmation prompt and proceed with deletion. """ - queue = DeleteQueue(databus_key) + queue = DeleteQueue(databus_key, manifest_context=manifest_context) for databusURI in databusURIs: _host, _account, group, artifact, version, file = ( @@ -296,24 +309,24 @@ def delete(databusURIs: List[str], databus_key: str, dry_run: bool, force: bool) if group == "collections" and artifact is not None: print(f"Deleting collection: {databusURI}") _delete_resource( - databusURI, databus_key, dry_run=dry_run, force=force, queue=queue + databusURI, databus_key, dry_run=dry_run, force=force, queue=queue, manifest_context=manifest_context ) elif file is not None: print(f"Deleting file is not supported via API: {databusURI}") elif version is not None: print(f"Deleting version: {databusURI}") _delete_resource( - databusURI, databus_key, dry_run=dry_run, force=force, queue=queue + databusURI, databus_key, dry_run=dry_run, force=force, queue=queue, manifest_context=manifest_context ) elif artifact is not None: print(f"Deleting artifact and all its versions: {databusURI}") _delete_artifact( - databusURI, databus_key, dry_run=dry_run, force=force, queue=queue + databusURI, databus_key, dry_run=dry_run, force=force, queue=queue, manifest_context=manifest_context ) elif group is not None and group != "collections": print(f"Deleting group and all its artifacts and versions: {databusURI}") _delete_group( - databusURI, databus_key, dry_run=dry_run, force=force, queue=queue + databusURI, databus_key, dry_run=dry_run, force=force, queue=queue, manifest_context=manifest_context ) else: print(f"Deleting {databusURI} is not supported.") diff --git a/databusclient/api/download.py b/databusclient/api/download.py index 6b8ab7d..b3bc1b0 100644 --- a/databusclient/api/download.py +++ b/databusclient/api/download.py @@ -12,6 +12,7 @@ import requests from SPARQLWrapper import JSON, SPARQLWrapper from tqdm import tqdm +from datetime import datetime, timezone from databusclient.api.utils import ( fetch_databus_jsonld, @@ -340,6 +341,7 @@ def _download_file( base_uri=None, validate_checksum: bool = False, expected_checksum: str | None = None, + manifest_context=None, ) -> None: """Download a file from the internet with a progress bar using tqdm. @@ -500,30 +502,30 @@ def _download_file( raise IOError("Downloaded size does not match Content-Length header") # --- 6. Validate checksum on original downloaded file (BEFORE conversion) --- + actual_checksum = None if validate_checksum: - # reuse compute_sha256_and_length from webdav extension try: - actual, _ = compute_sha256_and_length(filename) + actual_checksum, _ = compute_sha256_and_length(filename) except (OSError, IOError) as e: print(f"WARNING: error computing checksum for {filename}: {e}") - actual = None + actual_checksum = None if expected_checksum is None: print( f"WARNING: no expected checksum available for {filename}; skipping validation" ) - elif actual is None: + elif actual_checksum is None: print( f"WARNING: could not compute checksum for {filename}; skipping validation" ) else: - if actual.lower() != expected_checksum.lower(): + if actual_checksum.lower() != expected_checksum.lower(): try: - os.remove(filename) # delete corrupted file + os.remove(filename) except OSError: pass raise IOError( - f"Checksum mismatch for {filename}: expected {expected_checksum}, got {actual}" + f"Checksum mismatch for {filename}: expected {expected_checksum}, got {actual_checksum}" ) # --- 7. Unified compression/format conversion pass --- @@ -534,6 +536,14 @@ def _download_file( needs_format_conversion = convert_format is not None if not should_convert_compression and not needs_format_conversion: + if manifest_context is not None: + manifest_context.record_file( + url=url, + status="success", + sha256=actual_checksum or expected_checksum, + size_bytes=total_size_in_bytes if total_size_in_bytes else None, + downloaded_at=datetime.now(timezone.utc).isoformat(), + ) return temp_paths: list[str] = [] @@ -565,6 +575,14 @@ def _download_file( source_fmt, compression, ) + if manifest_context is not None: + manifest_context.record_file( + url=url, + status="success", + sha256=actual_checksum or expected_checksum, + size_bytes=total_size_in_bytes if total_size_in_bytes else None, + downloaded_at=datetime.now(timezone.utc).isoformat(), + ) return # Early exit: if format conversion is requested but input format @@ -584,6 +602,14 @@ def _download_file( filename, target_filepath, source_fmt, compression ) # No format conversion needed, no further work. + if manifest_context is not None: + manifest_context.record_file( + url=url, + status="success", + sha256=actual_checksum or expected_checksum, + size_bytes=total_size_in_bytes if total_size_in_bytes else None, + downloaded_at=datetime.now(timezone.utc).isoformat(), + ) return # Determine input for format conversion. @@ -649,6 +675,14 @@ def _download_file( if os.path.exists(filename): os.remove(filename) print(f"Removed original file: {os.path.basename(filename)}") + if manifest_context is not None: + manifest_context.record_file( + url=url, + status="success", + sha256=actual_checksum or expected_checksum, + size_bytes=total_size_in_bytes if total_size_in_bytes else None, + downloaded_at=datetime.now(timezone.utc).isoformat(), + ) return # Standard single-output-file path (Layer 2, and the remaining @@ -706,6 +740,16 @@ def _download_file( if os.path.exists(temp_path): os.remove(temp_path) + # Record file to manifest only after all conversion completes successfully. + # This ensures the manifest reflects the actual final output, not just the download. + if manifest_context is not None: + manifest_context.record_file( + url=url, + status="success", + sha256=actual_checksum or expected_checksum, + size_bytes=total_size_in_bytes if total_size_in_bytes else None, + downloaded_at=datetime.now(timezone.utc).isoformat(), + ) def _download_files( urls: List[str], @@ -718,6 +762,7 @@ def _download_files( convert_format: str = None, graph_name: str = None, base_uri: str = None, + manifest_context=None, validate_checksum: bool = False, checksums: dict | None = None, ) -> None: @@ -754,6 +799,7 @@ def _download_files( base_uri=base_uri, validate_checksum=validate_checksum, expected_checksum=expected, + manifest_context=manifest_context, ) def _get_sparql_query_of_collection(uri: str, databus_key: str | None = None) -> str: @@ -901,6 +947,7 @@ def _download_collection( convert_format: str = None, graph_name: str = None, base_uri: str = None, + manifest_context=None, validate_checksum: bool = False, ) -> None: """Download all files in a databus collection. @@ -940,6 +987,7 @@ def _download_collection( convert_format=convert_format, graph_name=graph_name, base_uri=base_uri, + manifest_context=manifest_context, validate_checksum=validate_checksum, checksums=checksums if checksums else None, ) @@ -956,6 +1004,7 @@ def _download_version( convert_format: str = None, graph_name: str = None, base_uri: str = None, + manifest_context=None, validate_checksum: bool = False, ) -> None: """Download all files in a databus artifact version. @@ -993,6 +1042,7 @@ def _download_version( convert_format=convert_format, graph_name=graph_name, base_uri=base_uri, + manifest_context=manifest_context, validate_checksum=validate_checksum, checksums=checksums, ) @@ -1010,6 +1060,7 @@ def _download_artifact( convert_format: str = None, graph_name: str = None, base_uri: str = None, + manifest_context=None, validate_checksum: bool = False, ) -> None: """Download files in a databus artifact. @@ -1054,6 +1105,7 @@ def _download_artifact( convert_format=convert_format, graph_name=graph_name, base_uri=base_uri, + manifest_context=manifest_context, validate_checksum=validate_checksum, checksums=checksums, ) @@ -1132,6 +1184,7 @@ def _download_group( convert_format: str = None, graph_name: str = None, base_uri: str = None, + manifest_context=None, validate_checksum: bool = False, ) -> None: """Download files in a databus group. @@ -1166,6 +1219,7 @@ def _download_group( convert_format=convert_format, graph_name=graph_name, base_uri=base_uri, + manifest_context=manifest_context, validate_checksum=validate_checksum, ) @@ -1218,6 +1272,7 @@ def download( graph_name=None, base_uri=None, validate_checksum: bool = False, + manifest_context=None, ) -> None: """Download datasets from databus. @@ -1267,6 +1322,7 @@ def download( convert_format, graph_name=graph_name, base_uri=base_uri, + manifest_context=manifest_context, validate_checksum=validate_checksum, ) elif file is not None: @@ -1290,6 +1346,7 @@ def download( convert_format=convert_format, graph_name=graph_name, base_uri=base_uri, + manifest_context=manifest_context, validate_checksum=validate_checksum, expected_checksum=expected, ) @@ -1306,6 +1363,7 @@ def download( convert_format=convert_format, graph_name=graph_name, base_uri=base_uri, + manifest_context=manifest_context, validate_checksum=validate_checksum, ) elif artifact is not None: @@ -1324,6 +1382,7 @@ def download( convert_format=convert_format, graph_name=graph_name, base_uri=base_uri, + manifest_context=manifest_context, validate_checksum=validate_checksum, ) elif group is not None and group != "collections": @@ -1342,6 +1401,7 @@ def download( convert_format=convert_format, graph_name=graph_name, base_uri=base_uri, + manifest_context=manifest_context, validate_checksum=validate_checksum, ) elif account is not None: @@ -1382,6 +1442,7 @@ def download( convert_format=convert_format, graph_name=graph_name, base_uri=base_uri, + manifest_context=manifest_context, validate_checksum=validate_checksum, checksums=checksums if checksums else None, ) \ No newline at end of file diff --git a/databusclient/cli.py b/databusclient/cli.py index 5210703..70c851f 100644 --- a/databusclient/cli.py +++ b/databusclient/cli.py @@ -8,6 +8,8 @@ import databusclient.api.deploy as api_deploy from databusclient.api.delete import delete as api_delete from databusclient.api.download import download as api_download, DownloadAuthError +from databusclient.manifest.context import ManifestContext +from databusclient.manifest.writer import ManifestWriter from databusclient.extensions import webdav @@ -59,6 +61,12 @@ def app(): "webdav_url", help="WebDAV URL (e.g., https://cloud.example.com/remote.php/webdav)", ) +@click.option( + "--manifest", + "manifest_path", + default=None, + help="Write a JSON-LD manifest of this operation to PATH.", +) @click.option("--remote", help="rclone remote name (e.g., 'nextcloud')") @click.option("--path", help="Remote path on Nextcloud (e.g., 'datasets/mydataset')") @click.argument("distributions", nargs=-1) @@ -74,6 +82,7 @@ def deploy( remote, path, distributions: List[str], + manifest_path, ): """ Flexible deploy to Databus command supporting three modes:\n @@ -91,31 +100,80 @@ def deploy( raise click.UsageError( "Invalid combination: when using WebDAV/Nextcloud mode, please provide --webdav-url, --remote, and --path together." ) + + manifest_context = None + if manifest_path: + manifest_context = ManifestContext(command="deploy") + manifest_context.record_params({ + "version_id": version_id, + "title": title, + "abstract": abstract, + "description": description, + "license_url": license_url, + "distributions": list(distributions) if distributions else [], + "metadata_file": metadata_file, + }) + + def _write_manifest(): + if manifest_path and manifest_context is not None: + try: + actual_path = ManifestWriter.write(manifest_context, manifest_path) + click.echo(f"Manifest written to {actual_path}") + except (OSError, IOError) as e: + click.echo( + f"WARNING: Manifest could not be written to {manifest_path}: {e}", + err=True, + ) # === Mode 1: Classic Deploy === if distributions and not (metadata_file or webdav_url or remote or path): click.echo("[MODE] Classic deploy with distributions") click.echo(f"Deploying dataset version: {version_id}") - - dataid = api_deploy.create_dataset( - version_id=version_id, - artifact_version_title=title, - artifact_version_abstract=abstract, - artifact_version_description=description, - license_url=license_url, - distributions=distributions, - ) - api_deploy.deploy(dataid=dataid, api_key=apikey) + try: + dataid = api_deploy.create_dataset( + version_id=version_id, + artifact_version_title=title, + artifact_version_abstract=abstract, + artifact_version_description=description, + license_url=license_url, + distributions=distributions, + ) + api_deploy.deploy(dataid=dataid, api_key=apikey) + if manifest_context: + for dist in distributions: + url = str(dist).split("|")[0] + manifest_context.record_file(url=url, status="success") + except Exception as exc: + if manifest_context: + manifest_context.record_operation_error(exc) + raise + finally: + _write_manifest() return # === Mode 2: Metadata File === if metadata_file: click.echo(f"[MODE] Deploy from metadata file: {metadata_file}") - with open(metadata_file, "r") as f: - metadata = json.load(f) - api_deploy.deploy_from_metadata( - metadata, version_id, title, abstract, description, license_url, apikey - ) + try: + with open(metadata_file, "r") as f: + metadata = json.load(f) + api_deploy.deploy_from_metadata( + metadata, version_id, title, abstract, description, license_url, apikey + ) + if manifest_context: + for entry in metadata: + manifest_context.record_file( + url=entry.get("url", ""), + status="success", + sha256=entry.get("checksum"), + size_bytes=entry.get("size"), + ) + except Exception as exc: + if manifest_context: + manifest_context.record_operation_error(exc) + raise + finally: + _write_manifest() return # === Mode 3: Upload & Deploy (Nextcloud) === @@ -124,20 +182,32 @@ def deploy( raise click.UsageError( "Please provide files to upload when using WebDAV/Nextcloud mode." ) - - # Check that all given paths exist and are files or directories. invalid = [f for f in distributions if not os.path.exists(f)] if invalid: raise click.UsageError( f"The following input files or folders do not exist: {', '.join(invalid)}" ) - click.echo("[MODE] Upload & Deploy to DBpedia Databus via Nextcloud") click.echo(f"→ Uploading to: {remote}:{path}") - metadata = webdav.upload_to_webdav(distributions, remote, path, webdav_url) - api_deploy.deploy_from_metadata( - metadata, version_id, title, abstract, description, license_url, apikey - ) + try: + metadata = webdav.upload_to_webdav(distributions, remote, path, webdav_url) + api_deploy.deploy_from_metadata( + metadata, version_id, title, abstract, description, license_url, apikey + ) + if manifest_context: + for entry in metadata: + manifest_context.record_file( + url=entry.get("url", ""), + status="success", + sha256=entry.get("checksum"), + size_bytes=entry.get("size"), + ) + except Exception as exc: + if manifest_context: + manifest_context.record_operation_error(exc) + raise + finally: + _write_manifest() return raise click.UsageError( @@ -147,7 +217,6 @@ def deploy( " - Upload & deploy: use --webdav-url, --remote, --path, and file arguments" ) - @app.command() @click.argument("databusuris", nargs=-1, required=True) @click.option( @@ -222,6 +291,12 @@ def deploy( help="Base URI for CSV -> RDF Triple conversion (Layer 3). " "Required when converting CSV/TSV to RDF triple formats.", ) +@click.option( + "--manifest", + "manifest_path", + default=None, + help="Write a JSON-LD manifest of this operation to PATH (e.g. --manifest manifest.jsonld).", +) @click.option( "--validate-checksum", is_flag=True, help="Validate checksums of downloaded files" ) @@ -239,11 +314,38 @@ def download( graph_name, base_uri, validate_checksum, + manifest_path, ): """ Download datasets from databus, optionally using vault access if vault options are provided. Supports on-the-fly compression format conversion using --convert-to and --convert-from options. """ + # Determine auth method for manifest (never store the token itself) + auth_method = None + if vault_token: + auth_method = "vault_token" + elif databus_key: + auth_method = "databus_key" + + manifest_context = None + if manifest_path: + manifest_context = ManifestContext( + command="download", + endpoint=databus, + auth_method=auth_method, + ) + # Record safe replay params — sensitive fields excluded + manifest_context.record_params({ + "databusURIs": list(databusuris), + "compression": compression, + "convert_format": convert_format, + "graph_name": graph_name, + "base_uri": base_uri, + "all_versions": all_versions, + "validate_checksum": validate_checksum, + "authurl": authurl, + "clientid": clientid, + }) try: api_download( localDir=localdir, @@ -259,11 +361,30 @@ def download( graph_name=graph_name, base_uri=base_uri, validate_checksum=validate_checksum, + manifest_context=manifest_context, ) except DownloadAuthError as e: + if manifest_context: + manifest_context.record_operation_error(e) raise click.ClickException(str(e)) except ValueError as e: + if manifest_context: + manifest_context.record_operation_error(e) raise click.ClickException(str(e)) + except Exception as e: + if manifest_context: + manifest_context.record_operation_error(e) + raise + finally: + if manifest_path and manifest_context is not None: + try: + actual_path = ManifestWriter.write(manifest_context, manifest_path) + click.echo(f"Manifest written to {actual_path}") + except (OSError, IOError) as e: + click.echo( + f"WARNING: Manifest could not be written to {manifest_path}: {e}", + err=True, + ) @@ -278,7 +399,13 @@ def download( @click.option( "--force", is_flag=True, help="Force deletion without confirmation prompt" ) -def delete(databusuris: List[str], databus_key: str, dry_run: bool, force: bool): +@click.option( + "--manifest", + "manifest_path", + default=None, + help="Write a JSON-LD manifest of this operation to PATH.", +) +def delete(databusuris: List[str], databus_key: str, dry_run: bool, force: bool, manifest_path): """ Delete a dataset from the databus. @@ -286,12 +413,36 @@ def delete(databusuris: List[str], databus_key: str, dry_run: bool, force: bool) Will recursively delete all data associated with the dataset. """ - api_delete( - databusURIs=databusuris, - databus_key=databus_key, - dry_run=dry_run, - force=force, - ) + manifest_context = None + if manifest_path: + manifest_context = ManifestContext(command="delete") + manifest_context.record_params({ + "databusURIs": list(databusuris), + "dry_run": dry_run, + }) + + try: + api_delete( + databusURIs=databusuris, + databus_key=databus_key, + dry_run=dry_run, + force=force, + manifest_context=manifest_context, + ) + except Exception as exc: + if manifest_context: + manifest_context.record_operation_error(exc) + raise + finally: + if manifest_path and manifest_context is not None: + try: + actual_path = ManifestWriter.write(manifest_context, manifest_path) + click.echo(f"Manifest written to {actual_path}") + except (OSError, IOError) as e: + click.echo( + f"WARNING: Manifest could not be written to {manifest_path}: {e}", + err=True, + ) if __name__ == "__main__": diff --git a/databusclient/manifest/__init__.py b/databusclient/manifest/__init__.py new file mode 100644 index 0000000..791429d --- /dev/null +++ b/databusclient/manifest/__init__.py @@ -0,0 +1,5 @@ +"""Manifest system for the Databus Python Client. + +Provides ManifestContext (records operation details in memory) +and ManifestWriter (serializes to JSON-LD on disk). +""" \ No newline at end of file diff --git a/databusclient/manifest/context.py b/databusclient/manifest/context.py new file mode 100644 index 0000000..790f909 --- /dev/null +++ b/databusclient/manifest/context.py @@ -0,0 +1,159 @@ +"""ManifestContext — in-memory record of a Databus operation. + +Created by the CLI when --manifest is passed. Threaded through +API functions as manifest_context=None. When None, all recording +calls are no-ops and existing behavior is completely unchanged. +""" + +from __future__ import annotations + +import traceback as tb +from datetime import datetime, timezone +from typing import Optional + + +class ManifestContext: + """Records all details of a Databus operation in memory. + + Created once per CLI invocation when --manifest is passed. + Passed as manifest_context parameter to download(), deploy(), + delete(). When --manifest is not passed, manifest_context is + None and all recording is skipped. + + Sensitive fields (vault_token, api_key, local_dir) are never + passed to this class — they are excluded at the CLI layer. + """ + + def __init__( + self, + command: str, + endpoint: Optional[str] = None, + auth_method: Optional[str] = None, + ) -> None: + """Initialise a new manifest context. + + Args: + command: CLI command name ('download', 'deploy', 'delete'). + endpoint: SPARQL endpoint URL used (if applicable). + auth_method: Authentication method used ('vault_token', + 'databus_key', or None for public access). + """ + self.command = command + self.endpoint = endpoint + self.auth_method = auth_method + self.issued: str = datetime.now(timezone.utc).isoformat() + self.replay_params: dict = {} + self.files: list = [] + self.operation_error: Optional[dict] = None + + def record_params(self, params: dict) -> None: + """Save the replay parameters for this operation. + + Stores CLI-level inputs needed to reconstruct the operation. + Sensitive fields (vault_token, api_key, local_dir) must be + excluded by the caller before passing params here. + + Args: + params: Dict of safe CLI parameters to store for replay. + """ + self.replay_params = params + + def record_file( + self, + url: str, + status: str, + sha256: Optional[str] = None, + size_bytes: Optional[int] = None, + compression: Optional[str] = None, + downloaded_at: Optional[str] = None, + error_message: Optional[str] = None, + error_traceback: Optional[str] = None, + retry_count: int = 0, + ) -> None: + """Record the outcome of processing a single file or URI. + + Called after each file download, deploy distribution, or + delete operation completes (successfully or not). + + Args: + url: The file URL or Databus URI that was processed. + status: 'success' or 'failed'. + sha256: SHA-256 checksum of the file (if available). + size_bytes: File size in bytes (if available). + compression: Compression format of the file (if applicable). + downloaded_at: ISO-8601 timestamp of when file was processed. + error_message: Error description if status is 'failed'. + error_traceback: Full traceback string if status is 'failed'. + retry_count: Number of retries attempted (default 0). + """ + entry: dict = { + "url": url, + "status": status, + } + if sha256 is not None: + entry["sha256"] = sha256 + if size_bytes is not None: + entry["size_bytes"] = size_bytes + if compression is not None: + entry["compression"] = compression + if downloaded_at is not None: + entry["downloaded_at"] = downloaded_at + if error_message is not None: + entry["error_message"] = error_message + if error_traceback is not None: + entry["error_traceback"] = error_traceback + if retry_count: + entry["retry_count"] = retry_count + + self.files.append(entry) + + def record_file_error(self, url: str, exc: Exception) -> None: + """Convenience method to record a failed file from an exception. + + Captures the exception message and full traceback automatically. + + Args: + url: The file URL or Databus URI that failed. + exc: The exception that caused the failure. + """ + self.record_file( + url=url, + status="failed", + error_message=str(exc), + error_traceback=tb.format_exc(), + ) + + def record_operation_error(self, exc: Exception) -> None: + """Record a top-level operation failure. + + Used when the entire operation fails (e.g. DeployError, auth failure) + rather than an individual file failing. Captures the exception message + and full traceback so the manifest is useful for debugging even when + no per-file recording happened. + + Args: + exc: The exception that caused the operation to fail. + """ + self.operation_error = { + "error_message": str(exc), + "error_traceback": tb.format_exc(), + "error_type": type(exc).__name__, + } + + def summary(self) -> dict: + """Return execution summary counts. + + Returns: + Dict with total, succeeded, failed counts and total_bytes. + """ + succeeded = sum(1 for f in self.files if f["status"] == "success") + failed = sum(1 for f in self.files if f["status"] == "failed") + total_bytes = sum( + f.get("size_bytes", 0) for f in self.files if f["status"] == "success" + ) + return { + "total": len(self.files), + "succeeded": succeeded, + "failed": failed, + "total_bytes": total_bytes, + } \ No newline at end of file diff --git a/databusclient/manifest/writer.py b/databusclient/manifest/writer.py new file mode 100644 index 0000000..5e606aa --- /dev/null +++ b/databusclient/manifest/writer.py @@ -0,0 +1,161 @@ +"""ManifestWriter — serializes a ManifestContext to a JSON-LD file. + +Called once at the end of a CLI operation. If writing fails, +a warning is printed and the CLI exits with code 0 (the actual +operation already succeeded). +""" + +from __future__ import annotations + +import json +import os + +from databusclient.manifest.context import ManifestContext + +# JSON-LD context using DataID vocabulary — same vocabulary used +# by the Databus platform itself for semantic interoperability. +_JSONLD_CONTEXT = { + "dataid": "http://dataid.dbpedia.org/ns#", + "dcat": "http://www.w3.org/ns/dcat#", + "dcterms": "http://purl.org/dc/terms/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "dbus": "http://databus.dbpedia.org/manifest/ns#", +} + +_SCHEMA_VERSION = "1.0" +_CLIENT_VERSION = "0.15" + + +class ManifestWriter: + """Serializes a ManifestContext to a JSON-LD manifest file.""" + + @staticmethod + def write(context: ManifestContext, path: str) -> str: + """Write the manifest to a JSON-LD file at the given path. + + Creates parent directories if they do not exist. + If a file already exists at `path`, auto-suffixes with _1, _2, etc. + and prints a warning rather than silently overwriting. + On failure, raises OSError — callers should catch and warn. + + Args: + context: The completed ManifestContext to serialize. + path: File path to write the manifest to. + + Raises: + OSError: If the file cannot be written, or if path is a directory. + """ + if path.endswith(("/", "\\")) or os.path.isdir(path): + stripped = path.rstrip("/\\") + raise OSError( + f"--manifest path '{path}' is a directory, not a file. " + f"Please provide a full file path, e.g. '{stripped}/manifest.jsonld'." + ) + + summary = context.summary() + + # Build file entries using DataID vocabulary + file_entries = [] + for f in context.files: + entry: dict = { + "@type": "dataid:File", + "dcat:downloadURL": f["url"], + "dbus:status": f["status"], + } + if f.get("sha256"): + entry["dataid:checksum"] = f["sha256"] + if f.get("size_bytes") is not None: + entry["dataid:byteSize"] = f["size_bytes"] + if f.get("compression"): + entry["dataid:compression"] = f["compression"] + if f.get("downloaded_at"): + entry["dbus:processedAt"] = { + "@value": f["downloaded_at"], + "@type": "xsd:dateTime", + } + if f.get("error_message"): + entry["dbus:errorMessage"] = f["error_message"] + if f.get("error_traceback"): + entry["dbus:errorTraceback"] = f["error_traceback"] + if f.get("retry_count"): + entry["dbus:retryCount"] = f["retry_count"] + file_entries.append(entry) + + manifest = { + "@context": _JSONLD_CONTEXT, + "@type": "dbus:OperationManifest", + "dbus:schemaVersion": _SCHEMA_VERSION, + "dbus:clientVersion": _CLIENT_VERSION, + "dbus:command": context.command, + "dcterms:issued": { + "@value": context.issued, + "@type": "xsd:dateTime", + }, + } + + if context.endpoint: + manifest["dbus:endpoint"] = context.endpoint + if context.auth_method: + manifest["dbus:authMethod"] = context.auth_method + if context.replay_params: + manifest["dbus:replayParams"] = context.replay_params + + manifest["dataid:distribution"] = { + "@type": "dataid:Distribution", + "dataid:file": file_entries, + } + + manifest["dbus:executionResult"] = { + "@type": "dbus:ExecutionSummary", + "dbus:totalFiles": summary["total"], + "dbus:succeeded": summary["succeeded"], + "dbus:failed": summary["failed"], + "dbus:totalBytes": summary["total_bytes"], + } + + if context.operation_error: + manifest["dbus:operationError"] = { + "@type": "dbus:OperationError", + "dbus:errorType": context.operation_error["error_type"], + "dbus:errorMessage": context.operation_error["error_message"], + "dbus:errorTraceback": context.operation_error["error_traceback"], + } + + parent = os.path.dirname(os.path.abspath(path)) + if parent: + os.makedirs(parent, exist_ok=True) + + final_path = ManifestWriter._resolve_available_path(path) + if final_path != path: + print( + f"WARNING: manifest already exists at '{path}', " + f"creating '{final_path}' instead" + ) + + with open(final_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + return final_path + + @staticmethod + def _resolve_available_path(path: str) -> str: + """Return a non-colliding path, auto-suffixing with _1, _2, ... if needed. + + If `path` does not exist, it is returned unchanged. If it exists, + appends _1, _2, etc. before the extension until a free path is found. + + Args: + path: Desired manifest file path. + + Returns: + A path that does not currently exist on disk. + """ + if not os.path.exists(path): + return path + + base, ext = os.path.splitext(path) + counter = 1 + while True: + candidate = f"{base}_{counter}{ext}" + if not os.path.exists(candidate): + return candidate + counter += 1 \ No newline at end of file diff --git a/examples/reproducible-download.md b/examples/reproducible-download.md new file mode 100644 index 0000000..d5e1919 --- /dev/null +++ b/examples/reproducible-download.md @@ -0,0 +1,39 @@ +# Reproducible Research Download with Manifest + +This example shows how to use `--manifest` to record a download +operation for reproducibility. + +## Running the download + +```bash +databusclient download \ + https://databus.dbpedia.org/dbpedia/generic/labels/2023.12.01 \ + --localdir ./data \ + --manifest ./manifests/labels-download.jsonld +``` + +This produces: +- Downloaded files in `./data/` +- A manifest at `./manifests/labels-download.jsonld` recording: + - The exact Databus URIs downloaded + - SHA-256 checksum and size of each file + - Timestamp of the download + - All parameters needed to reproduce the operation + +## What the manifest contains + +The manifest is a JSON-LD file using the DataID vocabulary. +Key fields: + +- `dbus:replayParams` — the exact parameters used, sufficient to + re-run the download six months later and get the same files +- `dataid:file` — one entry per downloaded file with checksum, + size, and status +- `dbus:executionResult` — summary of succeeded/failed files + +## Verifying the download later + +Six months later, a colleague can verify the same data was +downloaded by checking the checksums in the manifest against +the files on disk, or by inspecting the `dbus:replayParams` +to understand exactly what was fetched and when. \ No newline at end of file diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..573978c --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,319 @@ +"""Tests for the manifest system (Milestone 2). + +Unit tests: ManifestContext records correctly, ManifestWriter +produces valid JSON-LD. +Edge case: manifest write failure warns but does not fail operation. +""" + +import json +import os +import tempfile + +import pytest + +from databusclient.manifest.context import ManifestContext +from databusclient.manifest.writer import ManifestWriter + + +# --------------------------------------------------------------------------- +# ManifestContext tests +# --------------------------------------------------------------------------- + +def test_context_records_command_and_timestamp(): + ctx = ManifestContext(command="download") + assert ctx.command == "download" + assert ctx.issued is not None + assert "T" in ctx.issued # ISO-8601 format + + +def test_context_records_params(): + ctx = ManifestContext(command="download") + ctx.record_params({"databusURIs": ["https://example.org/data"], "compression": "gz"}) + assert ctx.replay_params["databusURIs"] == ["https://example.org/data"] + assert ctx.replay_params["compression"] == "gz" + + +def test_context_records_successful_file(): + ctx = ManifestContext(command="download") + ctx.record_file( + url="https://example.org/file.ttl", + status="success", + sha256="abc123", + size_bytes=1024, + ) + assert len(ctx.files) == 1 + assert ctx.files[0]["status"] == "success" + assert ctx.files[0]["sha256"] == "abc123" + assert ctx.files[0]["size_bytes"] == 1024 + + +def test_context_records_failed_file(): + ctx = ManifestContext(command="download") + ctx.record_file( + url="https://example.org/file.ttl", + status="failed", + error_message="Connection timeout", + error_traceback="Traceback...", + ) + assert ctx.files[0]["status"] == "failed" + assert ctx.files[0]["error_message"] == "Connection timeout" + + +def test_context_record_file_error_convenience(): + ctx = ManifestContext(command="download") + try: + raise ValueError("test error") + except ValueError as e: + ctx.record_file_error("https://example.org/file.ttl", e) + assert ctx.files[0]["status"] == "failed" + assert "test error" in ctx.files[0]["error_message"] + assert ctx.files[0]["error_traceback"] is not None + + +def test_context_summary_counts(): + ctx = ManifestContext(command="download") + ctx.record_file(url="https://a.org/1", status="success", size_bytes=100) + ctx.record_file(url="https://a.org/2", status="success", size_bytes=200) + ctx.record_file(url="https://a.org/3", status="failed") + s = ctx.summary() + assert s["total"] == 3 + assert s["succeeded"] == 2 + assert s["failed"] == 1 + assert s["total_bytes"] == 300 + + +def test_context_sensitive_fields_not_stored(): + """Sensitive fields must never appear in replay_params.""" + ctx = ManifestContext(command="download", auth_method="vault_token") + ctx.record_params({ + "databusURIs": ["https://example.org"], + "compression": "gz", + }) + # auth_method is stored (it's safe — describes the method, not the credential) + assert ctx.auth_method == "vault_token" + # but the actual token must not be in replay_params + assert "vault_token" not in ctx.replay_params + assert "databus_key" not in ctx.replay_params + assert "token" not in ctx.replay_params + + +# --------------------------------------------------------------------------- +# ManifestWriter tests +# --------------------------------------------------------------------------- + +def test_writer_produces_valid_jsonld(): + ctx = ManifestContext(command="download", endpoint="https://databus.dbpedia.org/sparql") + ctx.record_params({"databusURIs": ["https://example.org/data"]}) + ctx.record_file( + url="https://example.org/file.ttl", + status="success", + sha256="abc123", + size_bytes=1024, + ) + + with tempfile.NamedTemporaryFile(suffix=".jsonld", delete=False) as f: + path = f.name + os.remove(path) # NamedTemporaryFile creates an empty file; remove it so write() doesn't auto-suffix + try: + actual_path = ManifestWriter.write(ctx, path) + with open(actual_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + + assert manifest["@type"] == "dbus:OperationManifest" + assert manifest["dbus:command"] == "download" + assert manifest["dbus:schemaVersion"] == "1.0" + assert "dcterms:issued" in manifest + assert manifest["dbus:endpoint"] == "https://databus.dbpedia.org/sparql" + assert manifest["dbus:replayParams"]["databusURIs"] == ["https://example.org/data"] + + files = manifest["dataid:distribution"]["dataid:file"] + assert len(files) == 1 + assert files[0]["dcat:downloadURL"] == "https://example.org/file.ttl" + assert files[0]["dbus:status"] == "success" + assert files[0]["dataid:checksum"] == "abc123" + assert files[0]["dataid:byteSize"] == 1024 + + result = manifest["dbus:executionResult"] + assert result["dbus:totalFiles"] == 1 + assert result["dbus:succeeded"] == 1 + assert result["dbus:failed"] == 0 + finally: + if os.path.exists(actual_path): + os.remove(actual_path) + + +def test_writer_creates_parent_directories(): + ctx = ManifestContext(command="delete") + ctx.record_file(url="https://example.org/v1", status="success") + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "nested", "dir", "manifest.jsonld") + ManifestWriter.write(ctx, path) + assert os.path.exists(path) + + +def test_writer_records_failed_file(): + ctx = ManifestContext(command="download") + ctx.record_file( + url="https://example.org/file.ttl", + status="failed", + error_message="Timeout", + error_traceback="Traceback...", + ) + + with tempfile.NamedTemporaryFile(suffix=".jsonld", delete=False) as f: + path = f.name + os.remove(path) + try: + actual_path = ManifestWriter.write(ctx, path) + with open(actual_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + files = manifest["dataid:distribution"]["dataid:file"] + assert files[0]["dbus:status"] == "failed" + assert files[0]["dbus:errorMessage"] == "Timeout" + finally: + if os.path.exists(actual_path): + os.remove(actual_path) + + +def test_writer_failure_raises_oserror(): + """Writer raises OSError on invalid path — caller should catch and warn. + + Uses a file as the parent directory, which is always invalid on all + platforms (Windows and Unix) since you cannot create a directory + inside a file. + """ + ctx = ManifestContext(command="download") + ctx.record_file(url="https://example.org/f", status="success") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f: + file_as_parent = f.name + + try: + # Use an existing file as if it were a parent directory — + # always raises OSError on all platforms + invalid_path = os.path.join(file_as_parent, "manifest.jsonld") + with pytest.raises(OSError): + ManifestWriter.write(ctx, invalid_path) + finally: + if os.path.exists(file_as_parent): + os.remove(file_as_parent) + +def test_writer_auto_suffix_on_collision(): + """If the manifest path already exists, auto-suffix with _1 and warn.""" + ctx1 = ManifestContext(command="download") + ctx1.record_file(url="https://example.org/f1", status="success") + + ctx2 = ManifestContext(command="download") + ctx2.record_file(url="https://example.org/f2", status="success") + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "run.jsonld") + + first_path = ManifestWriter.write(ctx1, path) + assert first_path == path + + second_path = ManifestWriter.write(ctx2, path) + assert second_path == os.path.join(tmpdir, "run_1.jsonld") + + # Original file must be untouched (still has ctx1's data) + with open(first_path, "r", encoding="utf-8") as f: + original = json.load(f) + assert original["dataid:distribution"]["dataid:file"][0]["dcat:downloadURL"] == "https://example.org/f1" + + with open(second_path, "r", encoding="utf-8") as f: + suffixed = json.load(f) + assert suffixed["dataid:distribution"]["dataid:file"][0]["dcat:downloadURL"] == "https://example.org/f2" + + +def test_writer_auto_suffix_increments(): + """Repeated collisions increment the suffix: _1, then _2.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "run.jsonld") + + ctx_a = ManifestContext(command="download") + ctx_b = ManifestContext(command="download") + ctx_c = ManifestContext(command="download") + + path_a = ManifestWriter.write(ctx_a, path) + path_b = ManifestWriter.write(ctx_b, path) + path_c = ManifestWriter.write(ctx_c, path) + + assert path_a == path + assert path_b == os.path.join(tmpdir, "run_1.jsonld") + assert path_c == os.path.join(tmpdir, "run_2.jsonld") + + +def test_writer_rejects_invalid_path(): + """Directory paths (existing dir, or trailing slash) raise OSError.""" + ctx = ManifestContext(command="download") + ctx.record_file(url="https://example.org/f", status="success") + + with tempfile.TemporaryDirectory() as tmpdir: + # Case 1: path is an existing directory + with pytest.raises(OSError, match="is a directory"): + ManifestWriter.write(ctx, tmpdir) + + # Case 2: path ends with a trailing slash + trailing_slash_path = os.path.join(tmpdir, "subdir") + os.sep + with pytest.raises(OSError, match="is a directory"): + ManifestWriter.write(ctx, trailing_slash_path) + +def test_context_records_operation_error(): + """record_operation_error captures exception type, message, and traceback.""" + ctx = ManifestContext(command="deploy") + try: + raise ValueError("Authentication failed.") + except ValueError as e: + ctx.record_operation_error(e) + + assert ctx.operation_error is not None + assert ctx.operation_error["error_type"] == "ValueError" + assert "Authentication failed." in ctx.operation_error["error_message"] + assert ctx.operation_error["error_traceback"] is not None + + +def test_writer_includes_operation_error(): + """ManifestWriter writes dbus:operationError when operation_error is set.""" + ctx = ManifestContext(command="deploy") + ctx.record_params({"version_id": "https://example.org/v1"}) + try: + raise RuntimeError("DeployError: bad API key") + except RuntimeError as e: + ctx.record_operation_error(e) + + with tempfile.NamedTemporaryFile(suffix=".jsonld", delete=False) as f: + path = f.name + os.remove(path) + try: + actual_path = ManifestWriter.write(ctx, path) + with open(actual_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + + assert "dbus:operationError" in manifest + err = manifest["dbus:operationError"] + assert err["@type"] == "dbus:OperationError" + assert err["dbus:errorType"] == "RuntimeError" + assert "bad API key" in err["dbus:errorMessage"] + assert err["dbus:errorTraceback"] is not None + finally: + if os.path.exists(actual_path): + os.remove(actual_path) + + +def test_writer_no_operation_error_field_when_success(): + """dbus:operationError is absent from manifest when operation succeeded.""" + ctx = ManifestContext(command="download") + ctx.record_file(url="https://example.org/f", status="success") + + with tempfile.NamedTemporaryFile(suffix=".jsonld", delete=False) as f: + path = f.name + os.remove(path) + try: + actual_path = ManifestWriter.write(ctx, path) + with open(actual_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + assert "dbus:operationError" not in manifest + finally: + if os.path.exists(actual_path): + os.remove(actual_path) \ No newline at end of file From 98437d54750be7d8e51d71c0616b21ebc3d31f61 Mon Sep 17 00:00:00 2001 From: Dhanashree Petare Date: Sat, 18 Jul 2026 01:19:23 +0530 Subject: [PATCH 4/9] feat: add --compression none for decompression support (#66) (#78) * feat: add --compression none to decompress files without recompressing (closes #66) * docs: document --compression none in README * fix: Fixed review comments * fix: restore inline comments --- README.md | 5 +- databusclient/api/download.py | 78 ++++++++++++++++++++++------ databusclient/cli.py | 4 +- tests/test_compression_conversion.py | 37 +++++++++++++ 4 files changed, 105 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b46420b..60b8815 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ docker run --rm -v $(pwd):/data dbpedia/databus-python-client download $DOWNLOAD - `--databus-key` - If the databus is protected and needs API key authentication, you can provide the API key with `--databus-key YOUR_API_KEY`. - `--compression` - - Enables on-the-fly compression format conversion during download. Supported formats: `bz2`, `gz`, `xz`. The source compression is auto-detected from the file extension. Example: `--compression gz` converts all downloaded compressed files to gzip format. + - Enables on-the-fly compression format conversion during download. Supported formats: `bz2`, `gz`, `xz`, `none`. The source compression is auto-detected from the file extension. Use `none` to decompress files without recompressing. Example: `--compression gz` converts all downloaded compressed files to gzip format. - `--format` - Enables on-the-fly RDF and tabular format conversion during download (Layer 2 and Layer 3). Supported formats: `ntriples` (`nt`), `turtle` (`ttl`), `rdf-xml` (`rdf`, `xml`), `nquads` (`nq`), `trig`, `trix`, `json-ld` (`jsonld`), `csv`, `tsv`. Short aliases shown in brackets. Only the converted output file is kept — the original is deleted after successful conversion. Within the same equivalence class (e.g. turtle to ntriples) conversion is lossless. Across classes (e.g. RDF to CSV) some flags below may be required. - `--graph-name` @@ -284,6 +284,9 @@ databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased # Download a collection and unify all files to bz2 format databusclient download https://databus.dbpedia.org/dbpedia/collections/dbpedia-snapshot-2022-12 --compression bz2 + +# Decompress files without recompressing +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --compression none ``` **Download with Format Conversion**: download files and convert RDF or tabular format on-the-fly. Only the converted output file is kept. diff --git a/databusclient/api/download.py b/databusclient/api/download.py index b3bc1b0..4bcca8b 100644 --- a/databusclient/api/download.py +++ b/databusclient/api/download.py @@ -77,12 +77,15 @@ def _should_convert_compression( """Determine if a file should have its compression format converted or compressed. Source compression is detected automatically from the file extension. + If compression='none', compressed files are decompressed and saved without + any compression. If the file is already uncompressed and compression='none', + nothing is done. If the file is uncompressed and a target compression is specified, it will be compressed to the target format (source_format returned as None). Args: filename: Name of the file. - compression: Target compression format ('bz2', 'gz', 'xz') or None. + compression: Target compression format ('bz2', 'gz', 'xz', 'none') or None. Returns: Tuple of (should_convert: bool, source_format: Optional[str]). @@ -93,7 +96,14 @@ def _should_convert_compression( source_format = _detect_compression_format(filename) - # If file is not compressed, don't convert + # 'none' means decompress — only meaningful if file is compressed + if compression.lower() == "none": + if source_format is None: + # Already uncompressed, nothing to do + return False, None + return True, source_format + + # If file is not compressed, compress it to the target format if source_format is None: return True, None @@ -112,12 +122,21 @@ def _get_converted_filename( Args: filename: Original filename. source_format: Source compression format ('bz2', 'gz', 'xz'). - target_format: Target compression format ('bz2', 'gz', 'xz'). + target_format: Target compression format ('bz2', 'gz', 'xz') or 'none' + to decompress without recompressing. Returns: - New filename with updated extension. + New filename with updated extension. If target_format is 'none', + the compression extension is stripped and nothing is added. """ source_ext = COMPRESSION_EXTENSIONS[source_format] + + # 'none' means decompress — strip compression extension, add nothing + if target_format.lower() == "none": + if filename.lower().endswith(source_ext): + return filename[: -len(source_ext)] + return filename + target_ext = COMPRESSION_EXTENSIONS[target_format] # Handle case-insensitive extension matching @@ -129,36 +148,57 @@ def _get_converted_filename( def _convert_compression_format( source_file: str, target_file: str, source_format: str, target_format: str ) -> None: - """Convert a compressed file from one format to another. + """Convert or decompress a compressed file. + + Handles two cases: + - target_format is 'none': decompress source_file to target_file without recompressing. + - target_format is a compression format: decompress then recompress to target format. Args: source_file: Path to source compressed file. - target_file: Path to target compressed file. + target_file: Path to target file. source_format: Source compression format ('bz2', 'gz', 'xz'). - target_format: Target compression format ('bz2', 'gz', 'xz'). + target_format: Target compression format ('bz2', 'gz', 'xz') or 'none' to decompress only. Raises: - ValueError: If source_format or target_format is not supported. - RuntimeError: If compression conversion fails. + ValueError: If source_format is not supported. + RuntimeError: If the operation fails. """ - # Validate compression formats if source_format not in COMPRESSION_MODULES: raise ValueError( - f"Unsupported source compression format: {source_format}. Supported formats: {list(COMPRESSION_MODULES.keys())}" + f"Unsupported source compression format: {source_format}. " + f"Supported formats: {list(COMPRESSION_MODULES.keys())}" ) + + source_module = COMPRESSION_MODULES[source_format] + + # Decompression-only path: target_format == 'none' + if target_format.lower() == "none": + print(f"Decompressing {os.path.basename(source_file)} -> {os.path.basename(target_file)}") + try: + with source_module.open(source_file, "rb") as sf: + with open(target_file, "wb") as tf: + shutil.copyfileobj(sf, tf) + os.remove(source_file) + print(f"Decompression complete: {os.path.basename(target_file)}") + except Exception as e: + if os.path.exists(target_file): + os.remove(target_file) + raise RuntimeError(f"Decompression failed: {e}") + return + if target_format not in COMPRESSION_MODULES: raise ValueError( - f"Unsupported target compression format: {target_format}. Supported formats: {list(COMPRESSION_MODULES.keys())}" + f"Unsupported target compression format: {target_format}. " + f"Supported formats: {list(COMPRESSION_MODULES.keys())}" ) - source_module = COMPRESSION_MODULES[source_format] target_module = COMPRESSION_MODULES[target_format] print( f"Converting {source_format} → {target_format}: {os.path.basename(source_file)}" ) - # Decompress and recompress with progress indication chunk_size = 8192 try: @@ -566,6 +606,11 @@ def _download_file( shutil.copyfileobj(sf, tf) os.remove(filename) print(f"Compression complete: {os.path.basename(target_filepath)}") + elif compression.lower() == "none": + # Decompress — strip compression extension, save plain file. + target_filename = _get_converted_filename(file, source_fmt, "none") + target_filepath = os.path.join(localDir, target_filename) + _convert_compression_format(filename, target_filepath, source_fmt, "none") else: target_filename = _get_converted_filename(file, source_fmt, compression) target_filepath = os.path.join(localDir, target_filename) @@ -712,10 +757,11 @@ def _download_file( # 4. Source was NOT compressed, no --compression given -> no compression if source_compression is not None: if should_convert_compression and compression: - final_compression = compression + # 'none' means no recompression after format conversion + final_compression = None if compression.lower() == "none" else compression else: final_compression = source_compression - elif compression: + elif compression and compression.lower() != "none": # Source was uncompressed but user explicitly requested --compression final_compression = compression else: diff --git a/databusclient/cli.py b/databusclient/cli.py index 70c851f..1b9e6d9 100644 --- a/databusclient/cli.py +++ b/databusclient/cli.py @@ -251,10 +251,10 @@ def _write_manifest(): @click.option( "--compression", "compression", - type=click.Choice(["bz2", "gz", "xz"], case_sensitive=False), + type=click.Choice(["bz2", "gz", "xz", "none"], case_sensitive=False), help="Target compression format for on-the-fly conversion during download. " "Source compression is detected automatically from the file extension. " - "All compressed files will be converted to the target format (bz2, gz, xz).", + "Use 'none' to decompress files without recompressing.", ) @click.option( "--format", diff --git a/tests/test_compression_conversion.py b/tests/test_compression_conversion.py index 6e0c9a6..39aa069 100644 --- a/tests/test_compression_conversion.py +++ b/tests/test_compression_conversion.py @@ -198,6 +198,43 @@ def test_corrupted_file_handling(): # Verify target file was cleaned up assert not os.path.exists(target_file) +def test_should_convert_compression_none_on_compressed(): + """--compression none on a compressed file: should convert, source detected.""" + should_convert, source = _should_convert_compression("file.txt.bz2", "none") + assert should_convert is True + assert source == "bz2" + + +def test_should_convert_compression_none_on_uncompressed(): + """--compression none on an uncompressed file: nothing to do.""" + should_convert, source = _should_convert_compression("file.txt", "none") + assert should_convert is False + assert source is None + + +def test_get_converted_filename_none_strips_extension(): + """--compression none: strips compression extension, adds nothing.""" + assert _get_converted_filename("data.txt.bz2", "bz2", "none") == "data.txt" + assert _get_converted_filename("data.txt.gz", "gz", "none") == "data.txt" + assert _get_converted_filename("data.txt.xz", "xz", "none") == "data.txt" + + +def test_decompress_bz2_to_plain(): + """--compression none on bz2 file decompresses to plain file via _convert_compression_format.""" + with tempfile.TemporaryDirectory() as tmpdir: + test_data = b"Decompression test data" * 50 + + bz2_file = os.path.join(tmpdir, "test.txt.bz2") + with bz2.open(bz2_file, "wb") as f: + f.write(test_data) + + plain_file = os.path.join(tmpdir, "test.txt") + _convert_compression_format(bz2_file, plain_file, "bz2", "none") + + assert not os.path.exists(bz2_file) + assert os.path.exists(plain_file) + with open(plain_file, "rb") as f: + assert f.read() == test_data if __name__ == "__main__": pytest.main([__file__, "-v"]) \ No newline at end of file From d7b6381fdd46b11e3cec25f692da45c9ccb35c98 Mon Sep 17 00:00:00 2001 From: Dhanashree Petare Date: Mon, 27 Jul 2026 02:06:40 +0530 Subject: [PATCH 5/9] gsoc26: Manifest Replay for download, delete, and deploy (#65) (#81) * feat: add manifest replay for download command * feat: add manifest replay for delete and deploy commands * docs: document manifest replay for download, delete, and deploy * feat: add manifest summary command * fix: Review comments fixes, Cause in summary, BOM handling in metadata deploy, clean error output for deploy --- README.md | 100 +++++++ databusclient/api/deploy.py | 6 +- databusclient/cli.py | 131 ++++++++- databusclient/manifest/__init__.py | 12 +- databusclient/manifest/replay.py | 313 ++++++++++++++++++++ databusclient/manifest/summary.py | 84 ++++++ examples/reproducible-download.md | 19 +- tests/test_manifest_replay.py | 448 +++++++++++++++++++++++++++++ tests/test_manifest_summary.py | 117 ++++++++ 9 files changed, 1221 insertions(+), 9 deletions(-) create mode 100644 databusclient/manifest/replay.py create mode 100644 databusclient/manifest/summary.py create mode 100644 tests/test_manifest_replay.py create mode 100644 tests/test_manifest_summary.py diff --git a/README.md b/README.md index 60b8815..82c2d24 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ Command-line and Python client for downloading and deploying datasets on DBpedia - [Deploy](#cli-deploy) - [Delete](#cli-delete) - [Manifest](#cli-manifest) + - [Replay](#cli-manifest-replay) + - [Summary](#cli-manifest-summary) - [Module Usage](#module-usage) - [Deploy](#module-deploy) - [Development & Contributing](#development--contributing) @@ -149,8 +151,10 @@ Options: --help Show this message and exit. Commands: + delete Delete a dataset from the databus. deploy Flexible deploy to Databus command supporting three modes: download Download datasets from databus, optionally using vault access... + manifest Manifest utilities. ``` @@ -609,6 +613,102 @@ The manifest records input parameters, per-file URLs, checksums, byte sizes, tim Refer [examples/reproducible-download.md](examples/reproducible-download.md) for a full walkthrough. + +#### Replay + +Any manifest written with `--manifest` can be replayed later using `databusclient manifest replay `. Replay re-executes the original operation using the parameters recorded in the manifest — you don't need to remember or retype the original command. + +```bash +# Python +databusclient manifest replay [OPTIONS] MANIFEST_PATH +# Docker +docker run --rm -v $(pwd):/data dbpedia/databus-python-client manifest replay [OPTIONS] MANIFEST_PATH +``` + +**Important:** credentials are never stored in the manifest and must always be supplied fresh at replay time — `--vault-token`, `--databus-key`, and `--apikey` behave exactly as they do on the original commands. + +```bash +databusclient manifest replay --help + +# Output: +Usage: databusclient manifest replay [OPTIONS] MANIFEST_PATH + + Replay a previously recorded manifest operation. + + Currently supports replay of download, delete, and deploy manifests. + For delete manifests, an interactive confirmation is required by + default -- use --force to skip it for scripted/unattended use, or + --dry-run to preview without prompting or deleting. + +Options: + --localdir TEXT Override local output directory for download replay. + --databus TEXT Override Databus endpoint for replay. + --vault-token TEXT Vault token file path, required if manifest auth + method is vault_token. + --databus-key TEXT Databus API key, required if manifest auth method is + databus_key. Also required for delete replay. + --apikey TEXT Databus API key, required for deploy replay. + --force For delete replay: skip the interactive confirmation + prompt. Required for unattended/scripted replay. + --dry-run For delete replay: force a dry-run preview even if + the original operation wasn't one. + --help Show this message and exit. +``` + +**Replaying a download:** +```bash +databusclient manifest replay ./manifests/download-run.jsonld --localdir ./replayed-data +``` +If `--localdir` is omitted, replay falls back to the same auto-computed folder structure a fresh download would use — this is not necessarily the same folder the original download used, since the original folder location itself is never stored in the manifest. + +**Replaying a delete:** by default, replay asks for confirmation before deleting, exactly like a normal `delete` call: +```bash +databusclient manifest replay ./manifests/delete-run.jsonld --databus-key YOUR_API_KEY +# About to replay a DELETE operation for the following 1 URI(s): +# - https://databus.dbpedia.org/... +# This is irreversible. Proceed? [y/N]: +``` +For unattended/scripted use (e.g. CI/CD), skip the prompt with `--force`: +```bash +databusclient manifest replay ./manifests/delete-run.jsonld --databus-key YOUR_API_KEY --force +``` +If the original delete was run with `--dry-run --manifest ...`, replay automatically previews without deleting — no flag needed. You can also force a preview on a manifest that wasn't originally a dry run: +```bash +databusclient manifest replay ./manifests/delete-run.jsonld --databus-key YOUR_API_KEY --dry-run +``` + +**Replaying a deploy:** supported for classic (distributions-as-arguments) and metadata-file deploys. The manifest stores fully-resolved deployment metadata (checksums, sizes, formats already computed), so replay never re-downloads or re-hashes the original files: +```bash +databusclient manifest replay ./manifests/deploy-run.jsonld --apikey YOUR_API_KEY +``` +Replaying redeploys the same version — if it already exists on Databus, it is updated. WebDAV/Nextcloud deploys cannot be replayed, since the originally uploaded local files may no longer exist at their original paths by the time replay runs. + + +#### Summary + +Print a readable summary of any recorded manifest without replaying it: + +```bash +# Python +databusclient manifest summary ./manifests/download-run.jsonld +# Docker +docker run --rm -v $(pwd):/data dbpedia/databus-python-client manifest summary ./manifests/download-run.jsonld +``` + +Example output: + +``` +Command : download +Executed : 2024-03-24T10:02:49.500418+00:00 +Endpoint : https://databus.dbpedia.org/sparql +Auth : vault_token +Files : 1 succeeded · 0 failed +Total : 100.0 MB +Status : completed +``` + +Only existing data already stored in the manifest is read — no new files are downloaded or written, and no network access happens. + ## Module Usage diff --git a/databusclient/api/deploy.py b/databusclient/api/deploy.py index 08d9016..2c8cd08 100644 --- a/databusclient/api/deploy.py +++ b/databusclient/api/deploy.py @@ -249,7 +249,7 @@ def create_distribution( return f"{url}|{meta_string}" -def _create_distributions_from_metadata( +def create_distributions_from_metadata( metadata: List[Dict[str, Union[str, int]]], ) -> List[str]: """ @@ -524,7 +524,7 @@ def deploy_from_metadata( Parameters ---------- metadata : List[Dict[str, Union[str, int]]] - List of file metadata entries (see _create_distributions_from_metadata) + List of file metadata entries (see create_distributions_from_metadata) version_id : str Dataset version ID in the form $DATABUS_BASE/$ACCOUNT/$GROUP/$ARTIFACT/$VERSION artifact_version_title : str @@ -538,7 +538,7 @@ def deploy_from_metadata( apikey : str API key for authentication """ - distributions = _create_distributions_from_metadata(metadata) + distributions = create_distributions_from_metadata(metadata) dataset = create_dataset( version_id=version_id, diff --git a/databusclient/cli.py b/databusclient/cli.py index 1b9e6d9..a6923bd 100644 --- a/databusclient/cli.py +++ b/databusclient/cli.py @@ -10,6 +10,8 @@ from databusclient.api.download import download as api_download, DownloadAuthError from databusclient.manifest.context import ManifestContext from databusclient.manifest.writer import ManifestWriter +from databusclient.manifest.replay import ManifestReplayError, replay_manifest, load_manifest +from databusclient.manifest.summary import format_summary from databusclient.extensions import webdav @@ -138,6 +140,11 @@ def _write_manifest(): license_url=license_url, distributions=distributions, ) + if manifest_context: + manifest_context.replay_params["deploy_mode"] = "classic" + manifest_context.replay_params["resolved_distributions"] = ( + dataid["@graph"][-1].get("distribution", []) + ) api_deploy.deploy(dataid=dataid, api_key=apikey) if manifest_context: for dist in distributions: @@ -146,7 +153,7 @@ def _write_manifest(): except Exception as exc: if manifest_context: manifest_context.record_operation_error(exc) - raise + raise click.ClickException(str(exc)) finally: _write_manifest() return @@ -155,8 +162,11 @@ def _write_manifest(): if metadata_file: click.echo(f"[MODE] Deploy from metadata file: {metadata_file}") try: - with open(metadata_file, "r") as f: + with open(metadata_file, "r", encoding="utf-8-sig") as f: metadata = json.load(f) + if manifest_context: + manifest_context.replay_params["deploy_mode"] = "metadata" + manifest_context.replay_params["resolved_metadata"] = metadata api_deploy.deploy_from_metadata( metadata, version_id, title, abstract, description, license_url, apikey ) @@ -171,7 +181,7 @@ def _write_manifest(): except Exception as exc: if manifest_context: manifest_context.record_operation_error(exc) - raise + raise click.ClickException(str(exc)) finally: _write_manifest() return @@ -189,6 +199,8 @@ def _write_manifest(): ) click.echo("[MODE] Upload & Deploy to DBpedia Databus via Nextcloud") click.echo(f"→ Uploading to: {remote}:{path}") + if manifest_context: + manifest_context.replay_params["deploy_mode"] = "webdav" try: metadata = webdav.upload_to_webdav(distributions, remote, path, webdav_url) api_deploy.deploy_from_metadata( @@ -205,7 +217,7 @@ def _write_manifest(): except Exception as exc: if manifest_context: manifest_context.record_operation_error(exc) - raise + raise click.ClickException(str(exc)) finally: _write_manifest() return @@ -444,6 +456,117 @@ def delete(databusuris: List[str], databus_key: str, dry_run: bool, force: bool, err=True, ) +@app.group() +def manifest(): + """ + Manifest utilities. + + Includes replay of previously recorded operations from JSON-LD manifests. + """ + pass + + +@manifest.command("replay") +@click.argument("manifest_path", type=click.Path(exists=True, dir_okay=False)) +@click.option( + "--localdir", + default=None, + help="Override local output directory for download replay.", +) +@click.option( + "--databus", + default=None, + help="Override Databus endpoint for replay (example: https://databus.dbpedia.org/sparql).", +) +@click.option( + "--vault-token", + default=None, + help="Vault token file path required if manifest auth method is vault_token.", +) +@click.option( + "--databus-key", + default=None, + help="Databus API key required if manifest auth method is databus_key. " + "Also required for delete replay (never stored in the manifest).", +) +@click.option( + "--apikey", + "apikey", + default=None, + help="Databus API key required for deploy replay (never stored in the manifest).", +) +@click.option( + "--force", + is_flag=True, + default=False, + help="For delete replay: skip the interactive confirmation prompt. " + "Required for unattended/scripted replay of a delete operation.", +) +@click.option( + "--dry-run", + "dry_run", + is_flag=True, + default=False, + help="Force a dry-run preview even if the original operation wasn't " + "one. If the original delete WAS a dry run, replay already " + "previews automatically -- this flag cannot turn that off.", +) +def manifest_replay(manifest_path, localdir, databus, vault_token, databus_key, force, dry_run, apikey): + """ + Replay a previously recorded manifest operation. + + Currently supports replay of download, delete, and deploy manifests. + For delete manifests, an interactive confirmation is required by + default -- use --force to skip it for scripted/unattended use, or + --dry-run to preview without prompting or deleting. Deploy replay + supports classic and metadata-file modes only (not WebDAV). + """ + overrides = { + "localDir": localdir, + "endpoint": databus, + "token": vault_token, + "databus_key": databus_key, + "api_key": apikey, + } + # Keep only explicitly provided text overrides + overrides = {k: v for k, v in overrides.items() if v is not None} + # Flags are always explicit values (False is a real, meaningful default) + overrides["force"] = force + overrides["dry_run"] = dry_run + + try: + replay_info = replay_manifest(manifest_path, overrides=overrides) + if replay_info["command"] == "delete" and not replay_info.get("executed", True): + if replay_info.get("dry_run"): + click.echo("Delete replay: dry run only, nothing was deleted.") + else: + click.echo("Delete replay cancelled — nothing was deleted.") + else: + click.echo(f"Replayed command: {replay_info['command']}") + click.echo(f"Source manifest: {manifest_path}") + except ManifestReplayError as e: + raise click.ClickException(str(e)) + except DownloadAuthError as e: + raise click.ClickException(str(e)) + except ValueError as e: + raise click.ClickException(str(e)) + except Exception as e: + raise click.ClickException(str(e)) + +@manifest.command("summary") +@click.argument("manifest_path", type=click.Path(exists=True, dir_okay=False)) +def manifest_summary(manifest_path): + """ + Print a readable summary of a recorded manifest operation. + + Reads the existing summary data already stored in the manifest -- + no new data is collected and no new file is written. + """ + try: + manifest = load_manifest(manifest_path) + click.echo(format_summary(manifest)) + except ManifestReplayError as e: + raise click.ClickException(str(e)) if __name__ == "__main__": app() diff --git a/databusclient/manifest/__init__.py b/databusclient/manifest/__init__.py index 791429d..3f7e892 100644 --- a/databusclient/manifest/__init__.py +++ b/databusclient/manifest/__init__.py @@ -2,4 +2,14 @@ Provides ManifestContext (records operation details in memory) and ManifestWriter (serializes to JSON-LD on disk). -""" \ No newline at end of file +""" +from databusclient.manifest.context import ManifestContext +from databusclient.manifest.writer import ManifestWriter +from databusclient.manifest.replay import ManifestReplayError, replay_manifest + +__all__ = [ + "ManifestContext", + "ManifestWriter", + "ManifestReplayError", + "replay_manifest", +] \ No newline at end of file diff --git a/databusclient/manifest/replay.py b/databusclient/manifest/replay.py new file mode 100644 index 0000000..0bb135c --- /dev/null +++ b/databusclient/manifest/replay.py @@ -0,0 +1,313 @@ +from __future__ import annotations +import json +from typing import Any, Dict, Optional +from databusclient.api.download import download as api_download +from databusclient.api.delete import delete as api_delete +from databusclient.api.deploy import ( + create_dataset as api_create_dataset, + deploy as api_deploy_call, + create_distribution as api_create_distribution, + create_distributions_from_metadata, +) + + +class ManifestReplayError(Exception): + """Raised when replay manifest is invalid or cannot be replayed safely.""" + + +def load_manifest(path: str) -> Dict[str, Any]: + try: + with open(path, "r", encoding="utf-8-sig") as f: + data = json.load(f) + except FileNotFoundError as e: + raise ManifestReplayError(f"Manifest file not found: {path}") from e + except json.JSONDecodeError as e: + raise ManifestReplayError(f"Manifest is not valid JSON: {path}") from e + except OSError as e: + raise ManifestReplayError(f"Failed to read manifest: {e}") from e + + if not isinstance(data, dict): + raise ManifestReplayError("Manifest root must be a JSON object.") + return data + + +def _validate_replay_params(params: Any) -> Dict[str, Any]: + if not isinstance(params, dict): + raise ManifestReplayError("Manifest field dbus:replayParams must be an object.") + return params + + +def _build_download_kwargs( + manifest: Dict[str, Any], + replay_params: Dict[str, Any], + overrides: Dict[str, Any], +) -> Dict[str, Any]: + databus_uris = replay_params.get("databusURIs") + if not isinstance(databus_uris, list) or not databus_uris: + raise ManifestReplayError( + "Manifest replay for download requires non-empty replayParams.databusURIs." + ) + + auth_method = manifest.get("dbus:authMethod") + token = overrides.get("token") + databus_key = overrides.get("databus_key") + + if auth_method == "vault_token" and not token: + raise ManifestReplayError( + "Manifest uses vault_token authentication. Provide --vault-token for replay." + ) + if auth_method == "databus_key" and not databus_key: + raise ManifestReplayError( + "Manifest uses databus_key authentication. Provide --databus-key for replay." + ) + + endpoint = overrides.get("endpoint", manifest.get("dbus:endpoint")) + + return { + "localDir": overrides.get("localDir"), + "endpoint": endpoint, + "databusURIs": databus_uris, + "token": token, + "databus_key": databus_key, + "all_versions": replay_params.get("all_versions"), + "auth_url": replay_params.get( + "authurl", + "https://auth.dbpedia.org/realms/dbpedia/protocol/openid-connect/token", + ), + "client_id": replay_params.get("clientid", "vault-token-exchange"), + "compression": replay_params.get("compression"), + "convert_format": replay_params.get("convert_format"), + "graph_name": replay_params.get("graph_name"), + "base_uri": replay_params.get("base_uri"), + "validate_checksum": bool(replay_params.get("validate_checksum", False)), + "manifest_context": None, + } + +def _build_delete_kwargs( + replay_params: Dict[str, Any], + overrides: Dict[str, Any], +) -> Dict[str, Any]: + databus_uris = replay_params.get("databusURIs") + if not isinstance(databus_uris, list) or not databus_uris: + raise ManifestReplayError( + "Manifest replay for delete requires non-empty replayParams.databusURIs." + ) + + databus_key = overrides.get("databus_key") + if not databus_key: + raise ManifestReplayError( + "Delete replay requires --databus-key to be provided explicitly. " + "The API key is never stored in the manifest." + ) + + return { + "databusURIs": databus_uris, + "databus_key": databus_key, + } + + +def _replay_delete( + replay_params: Dict[str, Any], + overrides: Dict[str, Any], + confirm_fn, +) -> Dict[str, Any]: + kwargs = _build_delete_kwargs(replay_params, overrides) + databus_uris = kwargs["databusURIs"] + + # dry_run reflects what actually happened in the ORIGINAL operation + # (already recorded in replayParams by the plain `delete` command). + # A replay-time --dry-run flag may only force it ON for an extra + # safety preview -- it can never turn a recorded dry_run=True back off. + recorded_dry_run = bool(replay_params.get("dry_run", False)) + dry_run = recorded_dry_run or bool(overrides.get("dry_run", False)) + + # force is CLI-only at replay time, never read from the manifest -- + # same pattern as vault_token / databus_key. + force = bool(overrides.get("force", False)) + + if dry_run: + api_delete( + databusURIs=databus_uris, + databus_key=kwargs["databus_key"], + dry_run=True, + force=True, + manifest_context=None, + ) + return {"command": "delete", "executed": False, "dry_run": True} + + if not force: + prompt = ( + "About to replay a DELETE operation for the following " + f"{len(databus_uris)} URI(s):\n" + + "\n".join(f" - {u}" for u in databus_uris) + + "\nThis is irreversible. Proceed? [y/N]: " + ) + answer = confirm_fn(prompt).strip().lower() + if answer not in ("y", "yes"): + return {"command": "delete", "executed": False, "dry_run": False} + + # Replay-level confirmation already happened above (or --force was + # given). Call delete() with force=True so it doesn't also prompt + # per-resource internally -- avoids double confirmation. + api_delete( + databusURIs=databus_uris, + databus_key=kwargs["databus_key"], + dry_run=False, + force=True, + manifest_context=None, + ) + return {"command": "delete", "executed": True, "dry_run": False} + +def _reconstruct_distribution_strings(resolved_distributions: list) -> list: + strings = [] + for part in resolved_distributions: + url = part.get("downloadURL") + if not url: + raise ManifestReplayError( + "A stored distribution entry is missing 'downloadURL'; " + "cannot replay this deploy." + ) + cvs = { + k[len("dcv:"):]: v + for k, v in part.items() + if k.startswith("dcv:") + } + sha256sum = part.get("sha256sum") + byte_size = part.get("byteSize") + sha_tuple = ( + (sha256sum, byte_size) + if sha256sum and byte_size is not None + else None + ) + strings.append( + api_create_distribution( + url=url, + cvs=cvs, + file_format=part.get("formatExtension"), + compression=part.get("compression"), + sha256_length_tuple=sha_tuple, + ) + ) + return strings + + +def _build_deploy_kwargs(replay_params: Dict[str, Any]) -> Dict[str, Any]: + deploy_mode = replay_params.get("deploy_mode") + + if deploy_mode == "webdav": + raise ManifestReplayError( + "Deploy replay is not supported for WebDAV/Nextcloud uploads. " + "The uploaded files may no longer exist at their original " + "local paths, so this mode cannot be safely replayed." + ) + + version_id = replay_params.get("version_id") + title = replay_params.get("title") + abstract = replay_params.get("abstract") + description = replay_params.get("description") + license_url = replay_params.get("license_url") + + missing = [ + name for name, val in [ + ("version_id", version_id), + ("title", title), + ("abstract", abstract), + ("description", description), + ("license_url", license_url), + ] if not val + ] + if missing: + raise ManifestReplayError( + f"Manifest replay for deploy is missing required field(s): " + f"{', '.join(missing)}." + ) + + if deploy_mode == "classic": + resolved = replay_params.get("resolved_distributions") + if not resolved: + raise ManifestReplayError( + "Manifest replay for classic-mode deploy requires " + "replayParams.resolved_distributions." + ) + distributions = _reconstruct_distribution_strings(resolved) + elif deploy_mode == "metadata": + metadata = replay_params.get("resolved_metadata") + if not metadata: + raise ManifestReplayError( + "Manifest replay for metadata-mode deploy requires " + "replayParams.resolved_metadata." + ) + distributions = create_distributions_from_metadata(metadata) + else: + raise ManifestReplayError( + f"Manifest replay for deploy has an unknown or missing " + f"deploy_mode ('{deploy_mode}'). This manifest may predate " + "deploy replay support and cannot be replayed." + ) + + return { + "version_id": version_id, + "artifact_version_title": title, + "artifact_version_abstract": abstract, + "artifact_version_description": description, + "license_url": license_url, + "distributions": distributions, + } + + +def _replay_deploy( + replay_params: Dict[str, Any], + overrides: Dict[str, Any], +) -> Dict[str, Any]: + api_key = overrides.get("api_key") + if not api_key: + raise ManifestReplayError( + "Deploy replay requires --apikey to be provided explicitly. " + "The API key is never stored in the manifest." + ) + + kwargs = _build_deploy_kwargs(replay_params) + dataid = api_create_dataset(**kwargs) + api_deploy_call(dataid=dataid, api_key=api_key) + return {"command": "deploy", "executed": True} + +def replay_manifest( + manifest_path: str, + overrides: Optional[Dict[str, Any]] = None, + confirm_fn=input, +) -> Dict[str, Any]: + """ + Replay a previously recorded operation from a JSON-LD manifest. + + Currently supported: + - download + - delete (interactive y/n confirmation by default; overrides "force" + skips the prompt, "dry_run" previews without prompting or deleting) + - deploy (classic and metadata modes only; WebDAV replay is not supported) + + confirm_fn is injectable for testing (defaults to the built-in input()). + """ + overrides = overrides or {} + manifest = load_manifest(manifest_path) + + command = manifest.get("dbus:command") + if not command: + raise ManifestReplayError("Manifest missing required field dbus:command.") + + replay_params = _validate_replay_params(manifest.get("dbus:replayParams")) + + if command == "download": + kwargs = _build_download_kwargs(manifest, replay_params, overrides) + api_download(**kwargs) + return {"command": "download", "executed": True} + + if command == "delete": + return _replay_delete(replay_params, overrides, confirm_fn) + + if command == "deploy": + return _replay_deploy(replay_params, overrides) + + raise ManifestReplayError( + f"Replay for command '{command}' is not implemented yet. " + "Currently supported: download, delete, deploy." + ) \ No newline at end of file diff --git a/databusclient/manifest/summary.py b/databusclient/manifest/summary.py new file mode 100644 index 0000000..914ebfe --- /dev/null +++ b/databusclient/manifest/summary.py @@ -0,0 +1,84 @@ +"""manifest summary — formats an existing manifest as readable console output. + +Reads already-recorded fields from a manifest JSON-LD file and formats +them for display. No new data is collected, no new file is written, and +no network access happens here at all. +""" + +from __future__ import annotations + +from typing import Any, Dict + + +def _format_bytes(total_bytes: int) -> str: + """Format a byte count as a human-readable MB value.""" + mb = total_bytes / (1024 * 1024) + return f"{mb:.1f} MB" + + +def _derive_status(manifest: Dict[str, Any]) -> str: + """Derive an overall status string for the operation. + + - "failed" if a top-level dbus:operationError was recorded. + - "completed with errors" if some individual files failed but the + operation itself did not raise. + - "completed" otherwise. + """ + if manifest.get("dbus:operationError"): + return "failed" + + result = manifest.get("dbus:executionResult", {}) + if result.get("dbus:failed", 0) > 0: + return "completed with errors" + + return "completed" + + +def format_summary(manifest: Dict[str, Any]) -> str: + """Format a loaded manifest dict as a readable multi-line summary string. + + Args: + manifest: A manifest dict, as produced by loading a manifest + JSON-LD file (e.g. via replay._load_manifest). + + Returns: + A formatted multi-line string ready to print to the console. + """ + lines = [] + + command = manifest.get("dbus:command", "unknown") + lines.append(f"Command : {command}") + + issued = manifest.get("dcterms:issued", {}).get("@value") + if issued: + lines.append(f"Executed : {issued}") + + endpoint = manifest.get("dbus:endpoint") + if endpoint: + lines.append(f"Endpoint : {endpoint}") + + auth_method = manifest.get("dbus:authMethod") + if auth_method: + lines.append(f"Auth : {auth_method}") + + result = manifest.get("dbus:executionResult", {}) + succeeded = result.get("dbus:succeeded", 0) + failed = result.get("dbus:failed", 0) + lines.append(f"Files : {succeeded} succeeded \u00b7 {failed} failed") + + total_bytes = result.get("dbus:totalBytes", 0) + if command == "download" and total_bytes: + lines.append(f"Total : {_format_bytes(total_bytes)}") + + lines.append(f"Status : {_derive_status(manifest)}") + + operation_error = manifest.get("dbus:operationError") + if operation_error: + error_type = operation_error.get("dbus:errorType", "") + error_message = operation_error.get("dbus:errorMessage", "") + if error_type: + lines.append(f"Error : {error_type}: {error_message}") + else: + lines.append(f"Error : {error_message}") + + return "\n".join(lines) \ No newline at end of file diff --git a/examples/reproducible-download.md b/examples/reproducible-download.md index d5e1919..d59d8f6 100644 --- a/examples/reproducible-download.md +++ b/examples/reproducible-download.md @@ -36,4 +36,21 @@ Key fields: Six months later, a colleague can verify the same data was downloaded by checking the checksums in the manifest against the files on disk, or by inspecting the `dbus:replayParams` -to understand exactly what was fetched and when. \ No newline at end of file +to understand exactly what was fetched and when. + +## Actually reproducing it: replay + +Rather than manually re-typing the original command from the +`dbus:replayParams` you inspected above, replay it directly: + +```bash +databusclient manifest replay ./manifests/labels-download.jsonld --localdir ./data-replayed +``` + +This re-runs the download using the exact same parameters that +were recorded — compression, format conversion, checksum +validation, and so on — without needing to remember or +reconstruct the original command by hand. Credentials +(`--vault-token`/`--databus-key`) are never stored in the +manifest and, if the original download needed them, must be +supplied again here. \ No newline at end of file diff --git a/tests/test_manifest_replay.py b/tests/test_manifest_replay.py new file mode 100644 index 0000000..59ed13d --- /dev/null +++ b/tests/test_manifest_replay.py @@ -0,0 +1,448 @@ +import json +import pytest +from databusclient.manifest.replay import ManifestReplayError, replay_manifest + +def _write_manifest(path, payload): + with open(path, "w", encoding="utf-8") as f: + json.dump(payload, f) + +def test_replay_download_calls_api_download(tmp_path, monkeypatch): + captured = {} + + def fake_download(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr("databusclient.manifest.replay.api_download", fake_download) + + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "download", + "dbus:endpoint": "https://databus.dbpedia.org/sparql", + "dbus:replayParams": { + "databusURIs": ["https://databus.dbpedia.org/dbpedia/test/artifact/2024.01.01"], + "all_versions": False, + "compression": "gz", + "convert_format": "turtle", + "graph_name": None, + "base_uri": None, + "validate_checksum": True, + "authurl": "https://auth.dbpedia.org/realms/dbpedia/protocol/openid-connect/token", + "clientid": "vault-token-exchange", + }, + } + + path = tmp_path / "run.jsonld" + _write_manifest(path, manifest) + + result = replay_manifest(str(path)) + + assert result["command"] == "download" + assert captured["databusURIs"] == manifest["dbus:replayParams"]["databusURIs"] + assert captured["endpoint"] == "https://databus.dbpedia.org/sparql" + assert captured["compression"] == "gz" + assert captured["convert_format"] == "turtle" + assert captured["validate_checksum"] is True + assert captured["manifest_context"] is None + + +def test_replay_missing_command_raises(tmp_path): + manifest = { + "@type": "dbus:OperationManifest", + "dbus:replayParams": {"databusURIs": ["https://example.org/data"]}, + } + path = tmp_path / "missing-command.jsonld" + _write_manifest(path, manifest) + + with pytest.raises(ManifestReplayError, match="dbus:command"): + replay_manifest(str(path)) + + +def test_replay_missing_replay_params_raises(tmp_path): + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "download", + } + path = tmp_path / "missing-replay-params.jsonld" + _write_manifest(path, manifest) + + with pytest.raises(ManifestReplayError, match="dbus:replayParams"): + replay_manifest(str(path)) + + +def test_replay_unsupported_command_raises(tmp_path): + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "workflow", + "dbus:replayParams": {"version_id": "https://example.org/version"}, + } + path = tmp_path / "unsupported.jsonld" + _write_manifest(path, manifest) + + with pytest.raises(ManifestReplayError, match="not implemented"): + replay_manifest(str(path)) + + +def test_replay_requires_vault_token_when_manifest_auth_method_is_vault(tmp_path): + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "download", + "dbus:authMethod": "vault_token", + "dbus:replayParams": { + "databusURIs": ["https://databus.dbpedia.org/dbpedia/test/artifact/2024.01.01"] + }, + } + path = tmp_path / "vault-required.jsonld" + _write_manifest(path, manifest) + + with pytest.raises(ManifestReplayError, match="--vault-token"): + replay_manifest(str(path)) + + +def test_replay_overrides_are_applied(tmp_path, monkeypatch): + captured = {} + + def fake_download(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr("databusclient.manifest.replay.api_download", fake_download) + + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "download", + "dbus:endpoint": "https://old.example.org/sparql", + "dbus:authMethod": "databus_key", + "dbus:replayParams": { + "databusURIs": ["https://databus.dbpedia.org/dbpedia/test/artifact/2024.01.01"], + "validate_checksum": False, + }, + } + + path = tmp_path / "override.jsonld" + _write_manifest(path, manifest) + + replay_manifest( + str(path), + overrides={ + "endpoint": "https://databus.dbpedia.org/sparql", + "localDir": "./replay-data", + "databus_key": "dummy-key", + }, + ) + + assert captured["endpoint"] == "https://databus.dbpedia.org/sparql" + assert captured["localDir"] == "./replay-data" + assert captured["databus_key"] == "dummy-key" + +def test_replay_delete_confirmed_calls_api_delete(tmp_path, monkeypatch): + captured = {} + + def fake_delete(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr("databusclient.manifest.replay.api_delete", fake_delete) + + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "delete", + "dbus:replayParams": { + "databusURIs": ["https://databus.dbpedia.org/acct/grp/art/1.0"], + }, + } + path = tmp_path / "delete-run.jsonld" + _write_manifest(path, manifest) + + result = replay_manifest( + str(path), + overrides={"databus_key": "dummy-key"}, + confirm_fn=lambda prompt: "y", + ) + + assert result == {"command": "delete", "executed": True, "dry_run": False} + assert captured["databusURIs"] == manifest["dbus:replayParams"]["databusURIs"] + assert captured["databus_key"] == "dummy-key" + assert captured["dry_run"] is False + assert captured["force"] is True # replay already confirmed, skip inner prompt + + +def test_replay_delete_declined_does_not_call_api_delete(tmp_path, monkeypatch): + called = {"value": False} + + def fake_delete(**kwargs): + called["value"] = True + + monkeypatch.setattr("databusclient.manifest.replay.api_delete", fake_delete) + + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "delete", + "dbus:replayParams": { + "databusURIs": ["https://databus.dbpedia.org/acct/grp/art/1.0"], + }, + } + path = tmp_path / "delete-decline.jsonld" + _write_manifest(path, manifest) + + result = replay_manifest( + str(path), + overrides={"databus_key": "dummy-key"}, + confirm_fn=lambda prompt: "n", + ) + + assert result == {"command": "delete", "executed": False, "dry_run": False} + assert called["value"] is False + + +def test_replay_delete_force_skips_prompt(tmp_path, monkeypatch): + prompt_called = {"value": False} + + def fake_delete(**kwargs): + pass + + def fake_confirm(prompt): + prompt_called["value"] = True + return "n" # should never be reached + + monkeypatch.setattr("databusclient.manifest.replay.api_delete", fake_delete) + + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "delete", + "dbus:replayParams": { + "databusURIs": ["https://databus.dbpedia.org/acct/grp/art/1.0"], + }, + } + path = tmp_path / "delete-force.jsonld" + _write_manifest(path, manifest) + + result = replay_manifest( + str(path), + overrides={"databus_key": "dummy-key", "force": True}, + confirm_fn=fake_confirm, + ) + + assert result == {"command": "delete", "executed": True, "dry_run": False} + assert prompt_called["value"] is False + + +def test_replay_delete_dry_run_from_manifest_skips_prompt(tmp_path, monkeypatch): + """dry_run recorded in the manifest (from the original delete run) is honored automatically.""" + captured = {} + prompt_called = {"value": False} + + def fake_delete(**kwargs): + captured.update(kwargs) + + def fake_confirm(prompt): + prompt_called["value"] = True + return "y" + + monkeypatch.setattr("databusclient.manifest.replay.api_delete", fake_delete) + + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "delete", + "dbus:replayParams": { + "databusURIs": ["https://databus.dbpedia.org/acct/grp/art/1.0"], + "dry_run": True, + }, + } + path = tmp_path / "delete-dryrun-manifest.jsonld" + _write_manifest(path, manifest) + + result = replay_manifest( + str(path), + overrides={"databus_key": "dummy-key"}, + confirm_fn=fake_confirm, + ) + + assert result == {"command": "delete", "executed": False, "dry_run": True} + assert prompt_called["value"] is False + assert captured["dry_run"] is True + + +def test_replay_delete_dry_run_override_forces_preview(tmp_path, monkeypatch): + """--dry-run at replay time can force a preview even if original wasn't a dry run.""" + captured = {} + prompt_called = {"value": False} + + def fake_delete(**kwargs): + captured.update(kwargs) + + def fake_confirm(prompt): + prompt_called["value"] = True + return "y" + + monkeypatch.setattr("databusclient.manifest.replay.api_delete", fake_delete) + + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "delete", + "dbus:replayParams": { + "databusURIs": ["https://databus.dbpedia.org/acct/grp/art/1.0"], + "dry_run": False, + }, + } + path = tmp_path / "delete-dryrun-override.jsonld" + _write_manifest(path, manifest) + + result = replay_manifest( + str(path), + overrides={"databus_key": "dummy-key", "dry_run": True}, + confirm_fn=fake_confirm, + ) + + assert result == {"command": "delete", "executed": False, "dry_run": True} + assert prompt_called["value"] is False + assert captured["dry_run"] is True + + +def test_replay_delete_requires_databus_key(tmp_path): + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "delete", + "dbus:replayParams": { + "databusURIs": ["https://databus.dbpedia.org/acct/grp/art/1.0"], + }, + } + path = tmp_path / "delete-no-key.jsonld" + _write_manifest(path, manifest) + + with pytest.raises(ManifestReplayError, match="--databus-key"): + replay_manifest(str(path), overrides={}) + +def test_replay_deploy_classic_mode(tmp_path, monkeypatch): + captured = {} + + def fake_create_dataset(**kwargs): + captured["create_dataset_kwargs"] = kwargs + return {"@graph": [{"@id": "fake"}]} + + def fake_deploy(dataid, api_key): + captured["deploy_dataid"] = dataid + captured["deploy_api_key"] = api_key + + monkeypatch.setattr("databusclient.manifest.replay.api_create_dataset", fake_create_dataset) + monkeypatch.setattr("databusclient.manifest.replay.api_deploy_call", fake_deploy) + + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "deploy", + "dbus:replayParams": { + "version_id": "https://databus.dbpedia.org/acct/grp/art/1.0", + "title": "T", "abstract": "A", "description": "D", + "license_url": "https://license.example.org", + "deploy_mode": "classic", + "resolved_distributions": [ + { + "downloadURL": "https://example.org/file.nt", + "formatExtension": "nt", + "compression": "none", + "byteSize": 123, + "sha256sum": "a" * 64, + } + ], + }, + } + path = tmp_path / "deploy-classic.jsonld" + _write_manifest(path, manifest) + + result = replay_manifest(str(path), overrides={"api_key": "dummy-key"}) + + assert result == {"command": "deploy", "executed": True} + assert captured["deploy_api_key"] == "dummy-key" + assert len(captured["create_dataset_kwargs"]["distributions"]) == 1 + assert "a" * 64 in captured["create_dataset_kwargs"]["distributions"][0] + + +def test_replay_deploy_metadata_mode(tmp_path, monkeypatch): + captured = {} + + def fake_create_dataset(**kwargs): + captured["create_dataset_kwargs"] = kwargs + return {"@graph": [{"@id": "fake"}]} + + def fake_deploy(dataid, api_key): + captured["deploy_api_key"] = api_key + + monkeypatch.setattr("databusclient.manifest.replay.api_create_dataset", fake_create_dataset) + monkeypatch.setattr("databusclient.manifest.replay.api_deploy_call", fake_deploy) + + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "deploy", + "dbus:replayParams": { + "version_id": "https://databus.dbpedia.org/acct/grp/art/1.0", + "title": "T", "abstract": "A", "description": "D", + "license_url": "https://license.example.org", + "deploy_mode": "metadata", + "resolved_metadata": [ + { + "checksum": "b" * 64, + "size": 456, + "url": "https://example.org/data.csv", + } + ], + }, + } + path = tmp_path / "deploy-metadata.jsonld" + _write_manifest(path, manifest) + + result = replay_manifest(str(path), overrides={"api_key": "dummy-key"}) + + assert result == {"command": "deploy", "executed": True} + assert len(captured["create_dataset_kwargs"]["distributions"]) == 1 + + +def test_replay_deploy_webdav_mode_raises(tmp_path): + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "deploy", + "dbus:replayParams": { + "version_id": "https://databus.dbpedia.org/acct/grp/art/1.0", + "title": "T", "abstract": "A", "description": "D", + "license_url": "https://license.example.org", + "deploy_mode": "webdav", + }, + } + path = tmp_path / "deploy-webdav.jsonld" + _write_manifest(path, manifest) + + with pytest.raises(ManifestReplayError, match="WebDAV"): + replay_manifest(str(path), overrides={"api_key": "dummy-key"}) + + +def test_replay_deploy_requires_apikey(tmp_path): + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "deploy", + "dbus:replayParams": { + "version_id": "https://databus.dbpedia.org/acct/grp/art/1.0", + "title": "T", "abstract": "A", "description": "D", + "license_url": "https://license.example.org", + "deploy_mode": "classic", + "resolved_distributions": [], + }, + } + path = tmp_path / "deploy-no-key.jsonld" + _write_manifest(path, manifest) + + with pytest.raises(ManifestReplayError, match="--apikey"): + replay_manifest(str(path), overrides={}) + + +def test_replay_deploy_missing_deploy_mode_raises(tmp_path): + """Backward-compat: manifests written before deploy replay existed have no deploy_mode.""" + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "deploy", + "dbus:replayParams": { + "version_id": "https://databus.dbpedia.org/acct/grp/art/1.0", + "title": "T", "abstract": "A", "description": "D", + "license_url": "https://license.example.org", + }, + } + path = tmp_path / "deploy-old-manifest.jsonld" + _write_manifest(path, manifest) + + with pytest.raises(ManifestReplayError, match="predate"): + replay_manifest(str(path), overrides={"api_key": "dummy-key"}) \ No newline at end of file diff --git a/tests/test_manifest_summary.py b/tests/test_manifest_summary.py new file mode 100644 index 0000000..9407449 --- /dev/null +++ b/tests/test_manifest_summary.py @@ -0,0 +1,117 @@ +from databusclient.manifest.summary import format_summary + + +def test_summary_full_download_manifest(): + manifest = { + "dbus:command": "download", + "dcterms:issued": {"@value": "2024-03-24T10:00:00Z"}, + "dbus:endpoint": "https://databus.dbpedia.org", + "dbus:authMethod": "vault_token", + "dbus:executionResult": { + "dbus:succeeded": 1, + "dbus:failed": 0, + "dbus:totalBytes": 104857600, + }, + } + output = format_summary(manifest) + assert "Command : download" in output + assert "Executed : 2024-03-24T10:00:00Z" in output + assert "Endpoint : https://databus.dbpedia.org" in output + assert "Auth : vault_token" in output + assert "Files : 1 succeeded \u00b7 0 failed" in output + assert "Total : 100.0 MB" in output + assert "Status : completed" in output + + +def test_summary_omits_missing_optional_fields(): + """Delete/deploy manifests without endpoint/authMethod omit those lines.""" + manifest = { + "dbus:command": "delete", + "dcterms:issued": {"@value": "2024-03-24T10:00:00Z"}, + "dbus:executionResult": { + "dbus:succeeded": 1, + "dbus:failed": 0, + "dbus:totalBytes": 0, + }, + } + output = format_summary(manifest) + assert "Endpoint" not in output + assert "Auth" not in output + assert "Total" not in output + assert "Command : delete" in output + assert "Status : completed" in output + + +def test_summary_shows_failed_files(): + manifest = { + "dbus:command": "download", + "dcterms:issued": {"@value": "2024-03-24T10:00:00Z"}, + "dbus:executionResult": { + "dbus:succeeded": 2, + "dbus:failed": 1, + "dbus:totalBytes": 2048, + }, + } + output = format_summary(manifest) + assert "Files : 2 succeeded \u00b7 1 failed" in output + assert "Status : completed with errors" in output + + +def test_summary_shows_operation_error_as_failed_status(): + manifest = { + "dbus:command": "deploy", + "dcterms:issued": {"@value": "2024-03-24T10:00:00Z"}, + "dbus:executionResult": { + "dbus:succeeded": 0, + "dbus:failed": 0, + "dbus:totalBytes": 0, + }, + "dbus:operationError": { + "dbus:errorType": "DeployError", + "dbus:errorMessage": "bad API key", + }, + } + output = format_summary(manifest) + assert "Status : failed" in output + + +def test_summary_total_omitted_for_non_download_command(): + manifest = { + "dbus:command": "deploy", + "dcterms:issued": {"@value": "2024-03-24T10:00:00Z"}, + "dbus:executionResult": { + "dbus:succeeded": 1, + "dbus:failed": 0, + "dbus:totalBytes": 12345, + }, + } + output = format_summary(manifest) + assert "Total" not in output + +def test_summary_includes_error_message_when_operation_failed(): + manifest = { + "dbus:command": "deploy", + "dcterms:issued": {"@value": "2024-03-24T10:00:00Z"}, + "dbus:executionResult": { + "dbus:succeeded": 0, + "dbus:failed": 0, + "dbus:totalBytes": 0, + }, + "dbus:operationError": { + "dbus:errorType": "DeployError", + "dbus:errorMessage": "Could not deploy dataset to databus. Reason: 'Invalid API key'", + }, + } + output = format_summary(manifest) + assert "Status : failed" in output + assert "Error : DeployError: Could not deploy dataset to databus. Reason: 'Invalid API key'" in output + + +def test_summary_no_error_line_when_no_operation_error(): + manifest = { + "dbus:command": "download", + "dcterms:issued": {"@value": "2024-03-24T10:00:00Z"}, + "dbus:executionResult": {"dbus:succeeded": 1, "dbus:failed": 0, "dbus:totalBytes": 100}, + } + output = format_summary(manifest) + assert "Error" not in output \ No newline at end of file From 4f760c87d92d0875a8b69273a3aabaebd29ccb82 Mon Sep 17 00:00:00 2001 From: Dhanashree Petare Date: Fri, 14 Aug 2026 20:29:59 +0530 Subject: [PATCH 6/9] gsoc26: Declarative Workflow Engine (chaining download, deploy, delete) (#84) * feat: add workflow engine - parser, step context, steps, engine, CLI * docs: add example workflow YAML files * fix: deploy step now supports WebDAV mode for locally chained files * docs: add workflow README section and examples/workflows README * fix: resolved PR review comments * fix: resolved doc change --- .gitignore | 2 +- README.md | 119 ++++++++ databusclient/cli.py | 40 +++ databusclient/workflow/__init__.py | 4 + databusclient/workflow/context.py | 100 +++++++ databusclient/workflow/engine.py | 110 ++++++++ databusclient/workflow/parser.py | 188 +++++++++++++ databusclient/workflow/steps.py | 249 +++++++++++++++++ examples/workflows/README.md | 17 ++ examples/workflows/download-delete.yml | 11 + examples/workflows/download-deploy.yml | 16 ++ examples/workflows/full-pipeline.yml | 23 ++ poetry.lock | 87 +++++- pyproject.toml | 1 + tests/test_step_context.py | 75 +++++ tests/test_workflow_engine.py | 136 +++++++++ tests/test_workflow_parser.py | 180 ++++++++++++ tests/test_workflow_steps.py | 373 +++++++++++++++++++++++++ 18 files changed, 1728 insertions(+), 3 deletions(-) create mode 100644 databusclient/workflow/__init__.py create mode 100644 databusclient/workflow/context.py create mode 100644 databusclient/workflow/engine.py create mode 100644 databusclient/workflow/parser.py create mode 100644 databusclient/workflow/steps.py create mode 100644 examples/workflows/README.md create mode 100644 examples/workflows/download-delete.yml create mode 100644 examples/workflows/download-deploy.yml create mode 100644 examples/workflows/full-pipeline.yml create mode 100644 tests/test_step_context.py create mode 100644 tests/test_workflow_engine.py create mode 100644 tests/test_workflow_parser.py create mode 100644 tests/test_workflow_steps.py diff --git a/.gitignore b/.gitignore index ee3f319..57adbc9 100644 --- a/.gitignore +++ b/.gitignore @@ -173,4 +173,4 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ - +workflow-output/ \ No newline at end of file diff --git a/README.md b/README.md index 82c2d24..27ac911 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Command-line and Python client for downloading and deploying datasets on DBpedia - [Manifest](#cli-manifest) - [Replay](#cli-manifest-replay) - [Summary](#cli-manifest-summary) + - [Workflow](#cli-workflow) - [Module Usage](#module-usage) - [Deploy](#module-deploy) - [Development & Contributing](#development--contributing) @@ -709,6 +710,124 @@ Status : completed Only existing data already stored in the manifest is read — no new files are downloaded or written, and no network access happens. + +### Workflow + +The workflow command runs a multi-step pipeline of `download`, `deploy`, and `delete` operations defined in a YAML file. Steps run in order, and a later step can use the output of an earlier step — for example, deploying the exact file a previous step just downloaded. + +```bash +# Python +databusclient workflow run [OPTIONS] WORKFLOW_PATH +# Docker +docker run --rm -v $(pwd):/data dbpedia/databus-python-client workflow run [OPTIONS] WORKFLOW_PATH +``` + +**Help and further information on the workflow command:** +```bash +# Python +databusclient workflow run --help +# Docker +docker run --rm -v $(pwd):/data dbpedia/databus-python-client workflow run --help + +# Output: +Usage: databusclient workflow run [OPTIONS] WORKFLOW_PATH + + Run a declarative workflow pipeline from a YAML file. + + Executes each step in order, chaining outputs between steps via + ${steps.name.output_files}-style references, and applying each step's + on_error behavior (fail/continue/retry). + +Options: + --help Show this message and exit. +``` + +#### Workflow YAML format + +A workflow file has a top-level `steps:` list. Each step needs a unique `name` and a `command` (`download`, `deploy`, or `delete`), plus fields specific to that command. + +```yaml +steps: + - name: fetch_dataset + command: download + uri: https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 + localdir: ./data + + - name: publish_dataset + command: deploy + version_id: https://databus.dbpedia.org/myaccount/research/labels/2024.01 + title: "Processed Labels" + abstract: "Processed from DBpedia 2023.12.01" + description: "Converted and redeployed labels dataset" + license: https://creativecommons.org/licenses/by-sa/3.0/ + api_key: ${DATABUS_API_KEY} + files: ${steps.fetch_dataset.output_urls} + on_error: fail +``` + +**Environment variables:** any value written as `${VARIABLE_NAME}` is resolved from the environment when the workflow starts. If the variable is not set, the workflow fails immediately with a clear error before any step runs — credentials should always be passed this way, never written directly in the file. + +**Step chaining:** a step's outputs can be referenced by later steps using `${steps.step_name.output_key}`: +- `${steps.name.output_files}` — local file paths produced by a `download` step. +- `${steps.name.output_urls}` — the actual, redirect-resolved source URL(s) the file was downloaded from, useful for redeploying an unmodified file via classic deploy mode. + +#### Deploy step modes within a workflow + +A `deploy` step supports the same modes as the `deploy` CLI command: + +- **Classic mode** (`files:` is a list of URLs) — use `${steps.name.output_urls}` to redeploy a file exactly as it was downloaded, unmodified. Classic mode does not accept local file paths; if `files:` contains anything other than a `http://`/`https://` URL, the step fails with a clear error rather than crashing. +- **WebDAV mode** (`webdav_url:`, `remote:`, `path:` all provided) — use `${steps.name.output_files}` (local paths) here. The step uploads the local files to the WebDAV server first, then deploys the resulting URLs. This is the only way to deploy a file that was locally modified during the workflow (e.g. via `--format`/`--compression` on the download step), since only WebDAV mode re-establishes a real, fetchable URL for locally changed content. + +```yaml + - name: publish_converted_dataset + command: deploy + version_id: https://databus.dbpedia.org/myaccount/research/labels/2024.01 + title: "Processed Labels" + abstract: "Processed from DBpedia 2023.12.01" + description: "Converted and redeployed labels dataset" + license: https://creativecommons.org/licenses/by-sa/3.0/ + api_key: ${DATABUS_API_KEY} + webdav_url: https://cloud.example.com/remote.php/webdav + remote: nextcloud + path: datasets/mydataset + files: ${steps.fetch_dataset.output_files} +``` + +#### Error handling + +Each step declares an `on_error` behavior (defaults to `fail` if not set): + +| Mode | Behavior | +|---|---| +| `fail` | Stop the entire workflow immediately if this step fails. | +| `continue` | Log the failure and move on to the next step anyway. | +| `retry` | Retry the step up to `max_attempts` times, waiting `delay_seconds` between attempts. If all attempts fail, the workflow stops. | + +```yaml + - name: fetch_dataset + command: download + uri: https://databus.dbpedia.org/... + on_error: retry + retry: + max_attempts: 3 + delay_seconds: 5 +``` + +A retry re-runs the entire step from scratch, not just the part that failed. + +**Delete steps never prompt for confirmation inside a workflow** — since workflows are meant to run unattended, a `delete` step always behaves as if `--force` was passed. + +#### Examples + +Full working example files are available under [`examples/workflows/`](examples/workflows/): +- `download-deploy.yml` — download a file, then redeploy it (classic mode). +- `download-delete.yml` — download a file, then delete an old version. +- `full-pipeline.yml` — download, deploy, and delete chained together in one run. + +```bash +databusclient workflow run examples/workflows/download-deploy.yml +``` + ## Module Usage diff --git a/databusclient/cli.py b/databusclient/cli.py index a6923bd..e0d9638 100644 --- a/databusclient/cli.py +++ b/databusclient/cli.py @@ -13,6 +13,9 @@ from databusclient.manifest.replay import ManifestReplayError, replay_manifest, load_manifest from databusclient.manifest.summary import format_summary from databusclient.extensions import webdav +from databusclient.workflow.parser import WorkflowParseError, parse_workflow +from databusclient.workflow.engine import WorkflowEngine, WorkflowExecutionError +from databusclient.workflow.context import StepContext @click.group() @@ -568,5 +571,42 @@ def manifest_summary(manifest_path): except ManifestReplayError as e: raise click.ClickException(str(e)) +@app.group() +def workflow(): + """ + Workflow utilities. + + Run multi-step download/deploy/delete pipelines defined in YAML. + """ + pass + + +@workflow.command("run") +@click.argument("workflow_path", type=click.Path(exists=True, dir_okay=False)) +def workflow_run(workflow_path): + """ + Run a declarative workflow pipeline from a YAML file. + + Executes each step in order, chaining outputs between steps via + ${steps.name.output_files}-style references, and applying each + step's on_error behavior (fail/continue/retry). + """ + try: + parsed = parse_workflow(workflow_path) + except WorkflowParseError as e: + raise click.ClickException(str(e)) + + context = StepContext() + engine = WorkflowEngine(context=context) + + try: + results = engine.run(parsed["steps"]) + except WorkflowExecutionError as e: + raise click.ClickException(str(e)) + + click.echo("Workflow complete.") + for result in results: + click.echo(f" {result.name}: {result.status}") + if __name__ == "__main__": app() diff --git a/databusclient/workflow/__init__.py b/databusclient/workflow/__init__.py new file mode 100644 index 0000000..f560e5f --- /dev/null +++ b/databusclient/workflow/__init__.py @@ -0,0 +1,4 @@ +"""Workflow engine for the Databus Python Client. + +Orchestrates multi-step download/deploy/delete pipelines defined in YAML. +""" \ No newline at end of file diff --git a/databusclient/workflow/context.py b/databusclient/workflow/context.py new file mode 100644 index 0000000..4c0cd6e --- /dev/null +++ b/databusclient/workflow/context.py @@ -0,0 +1,100 @@ +"""StepContext — tracks step outputs and resolves ${steps.name.key} references at runtime. + +WorkflowParser resolves ${VAR_NAME} environment variables at parse time, +but deliberately leaves ${steps.step_name.output_files} tokens untouched, +since those values don't exist until the referenced step has actually run. +StepContext is what resolves them, once the WorkflowEngine has executed +each step in order. +""" + +from __future__ import annotations + +import re +from typing import Any, Dict + +# Matches a single ${steps.step_name.key} token. +_STEP_REF_RE = re.compile(r"\$\{steps\.([^.}]+)\.([^}]+)\}") + + +class StepReferenceError(Exception): + """Raised when a ${steps.name.key} reference cannot be resolved.""" + + +class StepContext: + """Stores per-step outputs and resolves ${steps.name.key} references. + + manifest_context is accepted but unused in Milestone 4 -- it exists as + a seam so Milestone 5 can wire in manifest recording without changing + this class's structure. When None, it has zero effect, matching the + manifest_context=None pattern already used throughout download.py, + deploy.py, and delete.py. + """ + + def __init__(self, manifest_context=None) -> None: + self._outputs: Dict[str, Dict[str, Any]] = {} + self.manifest_context = manifest_context + + def set_output(self, step_name: str, key: str, value: Any) -> None: + """Record an output value produced by a step. + + Args: + step_name: Name of the step that produced this output. + key: Output key, e.g. "output_files". + value: The value to store (e.g. a list of file paths). + """ + self._outputs.setdefault(step_name, {})[key] = value + + def get_output(self, step_name: str, key: str) -> Any: + """Retrieve a previously recorded output value. + + Raises: + StepReferenceError: If the step or key is unknown. + """ + if step_name not in self._outputs: + raise StepReferenceError( + f"Reference to unknown or not-yet-executed step '{step_name}'." + ) + if key not in self._outputs[step_name]: + raise StepReferenceError( + f"Step '{step_name}' has no recorded output '{key}'. " + f"Available outputs: {sorted(self._outputs[step_name].keys())}." + ) + return self._outputs[step_name][key] + + def resolve(self, value: Any) -> Any: + """Recursively resolve ${steps.name.key} references in a value. + + A value that is EXACTLY a single ${steps.name.key} token (nothing + else in the string) resolves to the raw stored value (e.g. a list), + preserving its type. A token embedded inside a larger string is + resolved by inserting str(value) in place, same as environment + variable substitution. + + Args: + value: A string, list, dict, or scalar value from a step config. + + Returns: + The value with all ${steps.*} references resolved. + + Raises: + StepReferenceError: If a referenced step/key is unknown. + """ + if isinstance(value, str): + full_match = _STEP_REF_RE.fullmatch(value) + if full_match: + step_name, key = full_match.group(1), full_match.group(2) + return self.get_output(step_name, key) + + def _replace(match: re.Match) -> str: + step_name, key = match.group(1), match.group(2) + return str(self.get_output(step_name, key)) + + return _STEP_REF_RE.sub(_replace, value) + + if isinstance(value, list): + return [self.resolve(item) for item in value] + + if isinstance(value, dict): + return {k: self.resolve(v) for k, v in value.items()} + + return value \ No newline at end of file diff --git a/databusclient/workflow/engine.py b/databusclient/workflow/engine.py new file mode 100644 index 0000000..d5cec54 --- /dev/null +++ b/databusclient/workflow/engine.py @@ -0,0 +1,110 @@ +"""WorkflowEngine — executes a sequence of parsed workflow steps in order. + +Applies each step's on_error behavior (fail/continue/retry) around a call +to the step's run() method. Retries operate at the whole-step level -- +the engine has no visibility into partial failures inside a step (e.g. +one file out of several failing during a download), since download(), +deploy(), and delete() are called as single atomic operations. +""" + +from __future__ import annotations + +import time +from typing import Any, Dict, List + +from databusclient.workflow.context import StepContext +from databusclient.workflow.steps import STEP_REGISTRY + + +class WorkflowExecutionError(Exception): + """Raised when a workflow step fails and on_error is 'fail' (or defaults to it).""" + + +class StepResult: + """Outcome of running a single step.""" + + def __init__(self, name: str, status: str, error: Exception | None = None, + attempts: int = 1) -> None: + self.name = name + self.status = status # "success", "failed", "skipped_error" + self.error = error + self.attempts = attempts + + +class WorkflowEngine: + """Runs a parsed workflow's steps in order, handling errors per step.""" + + def __init__(self, context: StepContext | None = None) -> None: + self.context = context or StepContext() + self.results: List[StepResult] = [] + + def run(self, steps: List[Dict[str, Any]]) -> List[StepResult]: + """Execute all steps in order. + + Args: + steps: List of validated, environment-substituted step dicts + (as produced by WorkflowParser.parse_workflow). + + Returns: + List of StepResult, one per step actually attempted. + + Raises: + WorkflowExecutionError: If a step with on_error 'fail' (the + default) ultimately fails. + """ + for step_config in steps: + result = self._run_step_with_error_handling(step_config) + self.results.append(result) + if result.status == "failed": + # on_error was 'fail' (or defaulted to it) -- stop the workflow. + raise WorkflowExecutionError( + f"Step '{result.name}' failed: {result.error}" + ) + return self.results + + def _run_step_with_error_handling(self, step_config: Dict[str, Any]) -> StepResult: + name = step_config["name"] + command = step_config["command"] + on_error = step_config.get("on_error", "fail") + + step_class = STEP_REGISTRY.get(command) + if step_class is None: + # Should already be caught by the parser, but defend anyway. + raise WorkflowExecutionError( + f"Step '{name}' has unknown command '{command}'." + ) + step = step_class() + + if on_error == "retry": + return self._run_with_retry(name, step, step_config) + + try: + step.run(step_config, self.context) + return StepResult(name, "success") + except Exception as exc: + if on_error == "continue": + print(f"WARNING: step '{name}' failed and on_error is 'continue': {exc}") + return StepResult(name, "skipped_error", error=exc) + # on_error == "fail" (or missing/defaulted to fail) + return StepResult(name, "failed", error=exc) + + def _run_with_retry(self, name: str, step: Any, step_config: Dict[str, Any]) -> StepResult: + retry_config = step_config["retry"] + max_attempts = retry_config["max_attempts"] + delay_seconds = retry_config["delay_seconds"] + + last_error: Exception | None = None + for attempt in range(1, max_attempts + 1): + try: + step.run(step_config, self.context) + return StepResult(name, "success", attempts=attempt) + except Exception as exc: + last_error = exc + print( + f"WARNING: step '{name}' attempt {attempt}/{max_attempts} " + f"failed: {exc}" + ) + if attempt < max_attempts: + time.sleep(delay_seconds) + + return StepResult(name, "failed", error=last_error, attempts=max_attempts) \ No newline at end of file diff --git a/databusclient/workflow/parser.py b/databusclient/workflow/parser.py new file mode 100644 index 0000000..267dda1 --- /dev/null +++ b/databusclient/workflow/parser.py @@ -0,0 +1,188 @@ +"""WorkflowParser — loads and validates a YAML workflow pipeline file. + +Parses a YAML file describing a sequence of steps (download/deploy/delete), +validates its structure, and substitutes environment variables of the form +${VAR_NAME}. References of the form ${steps.step_name.output_files} are +left untouched here -- those are resolved at runtime by StepContext once +each step has actually run, since their values don't exist yet at parse time. +""" + +from __future__ import annotations + +import os +import re +from typing import Any, Dict, List + +import yaml + +VALID_COMMANDS = {"download", "deploy", "delete"} +VALID_ON_ERROR = {"fail", "continue", "retry"} + +# Matches ${...} tokens. The captured group is everything between the braces. +_TOKEN_RE = re.compile(r"\$\{([^}]+)\}") + + +class WorkflowParseError(Exception): + """Raised when a workflow YAML file is invalid or fails validation.""" + + +class MissingEnvVarError(WorkflowParseError): + """Raised when a workflow references an environment variable that is not set.""" + + +def _load_yaml(path: str) -> Any: + """Load a YAML file using safe_load (never load arbitrary Python objects).""" + try: + with open(path, "r", encoding="utf-8-sig") as f: + return yaml.safe_load(f) + except FileNotFoundError as e: + raise WorkflowParseError(f"Workflow file not found: {path}") from e + except yaml.YAMLError as e: + raise WorkflowParseError(f"Workflow file is not valid YAML: {path}\n{e}") from e + + +def _substitute_value(value: Any, step_name: str) -> Any: + """Recursively substitute ${VAR_NAME} environment variables in a value. + + Tokens of the form ${steps.*} are left untouched -- they are resolved + later, at runtime, by StepContext once earlier steps have produced + their outputs. Only non-"steps."-prefixed tokens are treated as + environment variables here. + + Args: + value: A string, list, dict, or scalar value from the parsed YAML. + step_name: Name of the step this value belongs to (for error messages). + + Returns: + The value with environment variables substituted. + + Raises: + MissingEnvVarError: If a referenced environment variable is not set. + """ + if isinstance(value, str): + def _replace(match: re.Match) -> str: + token = match.group(1) + if token.startswith("steps."): + # Leave step-output references untouched for runtime resolution. + return match.group(0) + env_value = os.environ.get(token) + if env_value is None: + raise MissingEnvVarError( + f"Step '{step_name}' references environment variable " + f"'{token}' which is not set." + ) + return env_value + + return _TOKEN_RE.sub(_replace, value) + + if isinstance(value, list): + return [_substitute_value(item, step_name) for item in value] + + if isinstance(value, dict): + return {k: _substitute_value(v, step_name) for k, v in value.items()} + + return value + + +def _validate_step(step: Any, index: int, seen_names: set) -> Dict[str, Any]: + """Validate the generic structure of a single step. + + Only validates fields common to all step types (name, command, on_error, + retry config). Command-specific required fields (e.g. 'uri' for download) + are validated later, when the step actually executes. + + Args: + step: The raw step dict from the parsed YAML. + index: Position of this step in the steps list (for error messages). + seen_names: Set of step names already seen, for duplicate detection. + + Returns: + The validated step dict (unchanged, just checked). + + Raises: + WorkflowParseError: If the step is structurally invalid. + """ + if not isinstance(step, dict): + raise WorkflowParseError(f"Step at index {index} must be a mapping/object.") + + name = step.get("name") + if not name or not isinstance(name, str): + raise WorkflowParseError(f"Step at index {index} is missing a valid 'name'.") + + if name in seen_names: + raise WorkflowParseError(f"Duplicate step name '{name}'. Step names must be unique.") + seen_names.add(name) + + command = step.get("command") + if command not in VALID_COMMANDS: + raise WorkflowParseError( + f"Step '{name}' has invalid command '{command}'. " + f"Must be one of: {sorted(VALID_COMMANDS)}." + ) + + on_error = step.get("on_error", "fail") + if on_error not in VALID_ON_ERROR: + raise WorkflowParseError( + f"Step '{name}' has invalid on_error '{on_error}'. " + f"Must be one of: {sorted(VALID_ON_ERROR)}." + ) + + if on_error == "retry": + retry_config = step.get("retry") + if not isinstance(retry_config, dict): + raise WorkflowParseError( + f"Step '{name}' has on_error: retry but is missing a 'retry' " + f"configuration block with 'max_attempts' and 'delay_seconds'." + ) + max_attempts = retry_config.get("max_attempts") + if not isinstance(max_attempts, int) or max_attempts < 1: + raise WorkflowParseError( + f"Step '{name}' retry.max_attempts must be a positive integer." + ) + delay_seconds = retry_config.get("delay_seconds") + if not isinstance(delay_seconds, (int, float)) or delay_seconds < 0: + raise WorkflowParseError( + f"Step '{name}' retry.delay_seconds must be a non-negative number." + ) + + return step + + +def parse_workflow(path: str) -> Dict[str, Any]: + """Load, validate, and substitute environment variables in a workflow YAML file. + + Args: + path: Path to the workflow YAML file. + + Returns: + A dict with keys: + "manifest": Optional manifest output path (str or None). + "steps": List of validated, environment-substituted step dicts. + + Raises: + WorkflowParseError: If the file is missing, invalid YAML, or fails + structural validation. + MissingEnvVarError: If a step references an unset environment variable. + """ + raw = _load_yaml(path) + + if not isinstance(raw, dict): + raise WorkflowParseError("Workflow file root must be a mapping/object.") + + steps = raw.get("steps") + if not isinstance(steps, list) or not steps: + raise WorkflowParseError( + "Workflow file must have a non-empty 'steps' list." + ) + + seen_names: set = set() + validated_steps: List[Dict[str, Any]] = [] + for index, step in enumerate(steps): + validated = _validate_step(step, index, seen_names) + substituted = _substitute_value(validated, validated["name"]) + validated_steps.append(substituted) + + return { + "manifest": raw.get("manifest"), + "steps": validated_steps, + } \ No newline at end of file diff --git a/databusclient/workflow/steps.py b/databusclient/workflow/steps.py new file mode 100644 index 0000000..df35065 --- /dev/null +++ b/databusclient/workflow/steps.py @@ -0,0 +1,249 @@ +"""Step classes — adapt a workflow step config into a call to the existing +download()/deploy()/delete() API functions. + +Each step class resolves any ${steps.name.key} references in its config via +StepContext, calls the existing, unmodified API function, and records its +output back into StepContext so later steps can reference it. + +No new business logic lives here. Steps are thin adapters only. +""" + +from __future__ import annotations +import os +from typing import Any, Dict + +from databusclient.api.delete import delete as api_delete +from databusclient.api.deploy import ( + create_dataset, + deploy as api_deploy_call, + deploy_from_metadata, +) +from databusclient.api.download import download as api_download +from databusclient.extensions import webdav +from databusclient.manifest.context import ManifestContext + +from databusclient.workflow.context import StepContext + + +class StepValidationError(Exception): + """Raised when a step's config is missing a required, command-specific field.""" + + +class DownloadStep: + """Adapts a workflow step to a call to download(). + + Accepts either a single URI ('uri') or multiple ('uris') -- the + underlying download() function already supports a list. + + output_urls records the ACTUAL, final URL each downloaded file was + fetched from -- after any HTTP redirect. download.py's _download_file + already resolves redirects internally and reports the final url via + manifest_context.record_file(); this step supplies a ManifestContext + (the user's real one if set on StepContext, otherwise a throwaway one + used purely to capture this information) and reads the resolved URLs + back from it, rather than re-deriving redirects itself. Re-deriving + was tried first and found to be wrong: a version/artifact/group URI + does not redirect the same way an individual file URL does, so + checking the input URI directly gives the wrong (un-redirected) + answer. Reading what download.py already resolved is correct + regardless of whether the input was a single file, version, artifact, + or group URI, and regardless of how many files it expanded to. + """ + + def run(self, step_config: Dict[str, Any], context: StepContext) -> None: + resolved = context.resolve(step_config) + name = resolved["name"] + + uri_value = resolved.get("uri") or resolved.get("uris") + if not uri_value: + raise StepValidationError( + f"Step '{name}': download step requires 'uri' (single) or " + f"'uris' (list)." + ) + uris = [uri_value] if isinstance(uri_value, str) else list(uri_value) + + local_dir = resolved.get("localdir") + if local_dir is None: + local_dir = os.path.join(os.getcwd(), ".workflow", name) + + capture_context = context.manifest_context or ManifestContext(command="download") + files_before = len(capture_context.files) + + api_download( + localDir=local_dir, + endpoint=resolved.get("databus"), + databusURIs=uris, + token=resolved.get("vault_token"), + databus_key=resolved.get("databus_key"), + all_versions=resolved.get("all_versions", False), + compression=resolved.get("convert_to") or resolved.get("compression"), + convert_format=resolved.get("format"), + graph_name=resolved.get("graph_name"), + base_uri=resolved.get("base_uri"), + validate_checksum=resolved.get("validate_checksum", False), + manifest_context=capture_context, + ) + + new_entries = capture_context.files[files_before:] + resolved_urls = [e["url"] for e in new_entries if e.get("status") == "success"] + + output_files = self._collect_output_files(local_dir) + context.set_output(name, "output_files", output_files) + context.set_output(name, "output_urls", resolved_urls) + + @staticmethod + def _collect_output_files(local_dir: str) -> list: + """Walk local_dir and return all file paths produced by the download. + + Always returns a flat list of file paths, even if the download + produced files nested in subdirectories (e.g. a Quad -> Triple + split, which writes multiple files into a subdirectory). + """ + if not os.path.isdir(local_dir): + return [] + return sorted( + os.path.join(root, filename) + for root, _dirs, filenames in os.walk(local_dir) + for filename in filenames + ) + + +class DeployStep: + """Adapts a workflow step to a call to create_dataset() + deploy(), + or to webdav.upload_to_webdav() + deploy_from_metadata() in WebDAV mode. + + Classic and metadata-file deploy modes operate on their normal inputs + (URLs, or an already-resolved metadata list) and must NOT be given + local file paths from a previous step -- neither mode can turn a local + path into a fetchable URL. Chaining a previous step's local files into + a deploy step is only supported via WebDAV mode: local files are + uploaded first, which produces real URLs, and any local modifications + (format/compression conversions) are correctly reflected since the + upload happens after those conversions. + """ + + def run(self, step_config: Dict[str, Any], context: StepContext) -> None: + resolved = context.resolve(step_config) + name = resolved["name"] + + required = ["version_id", "title", "abstract", "description", "license", "api_key"] + missing = [f for f in required if not resolved.get(f)] + if missing: + raise StepValidationError( + f"Step '{name}': deploy step is missing required field(s): " + f"{', '.join(missing)}." + ) + + webdav_url = resolved.get("webdav_url") + remote = resolved.get("remote") + path = resolved.get("path") + webdav_fields = [webdav_url, remote, path] + + if any(webdav_fields) and not all(webdav_fields): + raise StepValidationError( + f"Step '{name}': WebDAV deploy mode requires 'webdav_url', " + f"'remote', and 'path' together." + ) + + if all(webdav_fields): + output_files = self._run_webdav_mode(resolved, name) + else: + output_files = self._run_classic_mode(resolved, name) + + context.set_output(name, "output_files", output_files) + context.set_output(name, "version_id", resolved["version_id"]) + + def _run_classic_mode(self, resolved: Dict[str, Any], name: str) -> list: + files = resolved.get("files") + if not files: + raise StepValidationError( + f"Step '{name}': deploy step requires 'files' (a list of URLs)." + ) + if isinstance(files, str): + files = [files] + + non_urls = [f for f in files if not str(f).split("|")[0].startswith(("http://", "https://"))] + if non_urls: + raise StepValidationError( + f"Step '{name}': 'files' must be URLs (http:// or https://). " + f"Found non-URL value(s): {non_urls}. Local file paths from " + f"a previous download step are not accepted in classic " + f"deploy mode -- use WebDAV mode ('webdav_url', 'remote', " + f"'path') to deploy locally modified files." + ) + + dataid = create_dataset( + version_id=resolved["version_id"], + artifact_version_title=resolved["title"], + artifact_version_abstract=resolved["abstract"], + artifact_version_description=resolved["description"], + license_url=resolved["license"], + distributions=files, + ) + api_deploy_call(dataid=dataid, api_key=resolved["api_key"]) + return files + + def _run_webdav_mode(self, resolved: Dict[str, Any], name: str) -> list: + local_files = resolved.get("files") + if not local_files: + raise StepValidationError( + f"Step '{name}': WebDAV deploy mode requires 'files' (local " + f"file paths to upload, e.g. from a previous download step)." + ) + if isinstance(local_files, str): + local_files = [local_files] + + metadata = webdav.upload_to_webdav( + local_files, resolved["remote"], resolved["path"], resolved["webdav_url"] + ) + deploy_from_metadata( + metadata, + resolved["version_id"], + resolved["title"], + resolved["abstract"], + resolved["description"], + resolved["license"], + resolved["api_key"], + ) + return [entry.get("url", "") for entry in metadata] + + +class DeleteStep: + """Adapts a workflow step to a call to delete(). + + Workflows are meant to run unattended -- a delete step never triggers + the interactive confirmation prompt that the plain `delete` CLI command + uses. force is always effectively True here; dry_run must be set + explicitly in the step config if a preview-only run is wanted. + """ + + def run(self, step_config: Dict[str, Any], context: StepContext) -> None: + resolved = context.resolve(step_config) + name = resolved["name"] + + uris = resolved.get("uris") + if not uris: + raise StepValidationError(f"Step '{name}': delete step requires 'uris'.") + if isinstance(uris, str): + uris = [uris] + + api_key = resolved.get("api_key") + if not api_key: + raise StepValidationError(f"Step '{name}': delete step requires 'api_key'.") + + api_delete( + databusURIs=uris, + databus_key=api_key, + dry_run=resolved.get("dry_run", False), + force=True, + manifest_context=context.manifest_context, + ) + + context.set_output(name, "output_files", []) + + +STEP_REGISTRY = { + "download": DownloadStep, + "deploy": DeployStep, + "delete": DeleteStep, +} \ No newline at end of file diff --git a/examples/workflows/README.md b/examples/workflows/README.md new file mode 100644 index 0000000..5ee0901 --- /dev/null +++ b/examples/workflows/README.md @@ -0,0 +1,17 @@ +# Example Workflows + +Three example workflow pipelines, each runnable directly, though the deploy/delete steps use paths under a specific Databus account -- swap in your own account/version paths before running them yourself. All three use real, existing Databus data as their download source. + +```bash +export DATABUS_API_KEY=your-key-here +databusclient workflow run download-deploy.yml +``` + +- **`download-deploy.yml`** - downloads a real Databus dataset, then redeploys it exactly as downloaded (classic deploy mode, using `${steps.name.output_urls}` - the actual, redirect-resolved source URL, not the local file). +- **`download-delete.yml`** - downloads a real Databus dataset, then deletes that same version, demonstrating a realistic archive-then-delete workflow. +- **`full-pipeline.yml`** - chains all three commands together: download a real Databus dataset, deploy it, then delete that same deployed version, demonstrating a complete download-deploy-cleanup pipeline. + +All three set `api_key: ${DATABUS_API_KEY}` - set that environment variable before running, rather than writing a real key into the file. + +See the main [README's Workflow section](../../README.md#cli-workflow) for the full YAML format, step chaining, error handling, and WebDAV deploy mode documentation. + diff --git a/examples/workflows/download-delete.yml b/examples/workflows/download-delete.yml new file mode 100644 index 0000000..67f47be --- /dev/null +++ b/examples/workflows/download-delete.yml @@ -0,0 +1,11 @@ +steps: + - name: archive_dataset + command: download + uri: https://databus.dbpedia.org/DhanashreeP/test-group/workflow-source-data/1.0 + localdir: ./workflow-output/archive + + - name: remove_archived_version + command: delete + uris: + - https://databus.dbpedia.org/DhanashreeP/test-group/workflow-demo-deploy/1.0 + api_key: ${DATABUS_API_KEY} \ No newline at end of file diff --git a/examples/workflows/download-deploy.yml b/examples/workflows/download-deploy.yml new file mode 100644 index 0000000..0b0816a --- /dev/null +++ b/examples/workflows/download-deploy.yml @@ -0,0 +1,16 @@ +steps: + - name: fetch_dataset + command: download + uri: https://databus.dbpedia.org/DhanashreeP/test-group/workflow-source-data/1.0 + localdir: ./workflow-output/download-deploy + + - name: publish_dataset + command: deploy + version_id: https://databus.dbpedia.org/DhanashreeP/test-group/workflow-demo-deploy/1.0 + title: "Workflow Demo - Download and Deploy" + abstract: "throwaway, testing workflow deploy chaining" + description: "throwaway version testing deploy chaining via output_urls" + license: https://creativecommons.org/licenses/by-sa/3.0/ + api_key: ${DATABUS_API_KEY} + files: ${steps.fetch_dataset.output_urls} + on_error: fail \ No newline at end of file diff --git a/examples/workflows/full-pipeline.yml b/examples/workflows/full-pipeline.yml new file mode 100644 index 0000000..4ec618c --- /dev/null +++ b/examples/workflows/full-pipeline.yml @@ -0,0 +1,23 @@ +steps: + - name: fetch_dataset + command: download + uri: https://databus.dev.dbpedia.link/fhofer/gsoc26/test-data/2.0 + localdir: ./workflow-output/full-pipeline + + - name: publish_dataset + command: deploy + version_id: https://databus.dbpedia.org/DhanashreeP/test-group/workflow-full-pipeline-demo/1.0 + title: "Workflow Demo - Full Pipeline" + abstract: "throwaway, testing full download-deploy-delete pipeline" + description: "throwaway version testing all three commands chained together, using real Databus test data as source" + license: https://creativecommons.org/licenses/by-sa/3.0/ + api_key: ${DATABUS_API_KEY} + files: ${steps.fetch_dataset.output_urls} + on_error: fail + + - name: cleanup_previous_version + command: delete + uris: + - https://databus.dbpedia.org/DhanashreeP/test-group/workflow-full-pipeline-demo/1.0 + api_key: ${DATABUS_API_KEY} + on_error: continue \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index e3759ff..88e0a8e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "black" @@ -329,6 +329,89 @@ pluggy = ">=0.12,<2.0" [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + [[package]] name = "rdflib" version = "7.5.0" @@ -466,4 +549,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "f625db7ea6714ebf87336efecaef03ec2dc4f6f7838c3239432828cd6649ff96" +content-hash = "b738c415f513b772068e55993bbaf06c4b8db37a77f5303e21b9498636b7c91b" diff --git a/pyproject.toml b/pyproject.toml index f937010..3a91cff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ requests = "^2.28.1" tqdm = "^4.42.1" SPARQLWrapper = "^2.0.0" rdflib = "^7.2.1" +pyyaml = "^6.0.3" [tool.poetry.group.dev.dependencies] black = "^22.6.0" diff --git a/tests/test_step_context.py b/tests/test_step_context.py new file mode 100644 index 0000000..a90f62e --- /dev/null +++ b/tests/test_step_context.py @@ -0,0 +1,75 @@ +"""Tests for StepContext (Milestone 4).""" + +import pytest + +from databusclient.workflow.context import StepContext, StepReferenceError + + +def test_set_and_get_output(): + ctx = StepContext() + ctx.set_output("fetch", "output_files", ["/data/a.ttl", "/data/b.ttl"]) + assert ctx.get_output("fetch", "output_files") == ["/data/a.ttl", "/data/b.ttl"] + + +def test_get_output_unknown_step_raises(): + ctx = StepContext() + with pytest.raises(StepReferenceError, match="unknown or not-yet-executed"): + ctx.get_output("nope", "output_files") + + +def test_get_output_unknown_key_raises(): + ctx = StepContext() + ctx.set_output("fetch", "output_files", ["/data/a.ttl"]) + with pytest.raises(StepReferenceError, match="no recorded output"): + ctx.get_output("fetch", "some_other_key") + + +def test_resolve_exact_token_preserves_list_type(): + """A value that IS exactly one ${steps.x.y} token resolves to the raw list.""" + ctx = StepContext() + ctx.set_output("fetch", "output_files", ["/data/a.ttl", "/data/b.ttl"]) + resolved = ctx.resolve("${steps.fetch.output_files}") + assert resolved == ["/data/a.ttl", "/data/b.ttl"] + assert isinstance(resolved, list) + + +def test_resolve_embedded_token_in_string(): + ctx = StepContext() + ctx.set_output("fetch", "version", "2024.01") + resolved = ctx.resolve("Deployed version ${steps.fetch.version}") + assert resolved == "Deployed version 2024.01" + + +def test_resolve_nested_dict_and_list(): + ctx = StepContext() + ctx.set_output("fetch", "output_files", ["/data/a.ttl"]) + resolved = ctx.resolve({ + "files": "${steps.fetch.output_files}", + "meta": {"note": "from ${steps.fetch.output_files}"}, + }) + assert resolved["files"] == ["/data/a.ttl"] + assert resolved["meta"]["note"] == "from ['/data/a.ttl']" + + +def test_resolve_plain_value_passthrough(): + ctx = StepContext() + assert ctx.resolve("no tokens here") == "no tokens here" + assert ctx.resolve(42) == 42 + assert ctx.resolve(None) is None + + +def test_resolve_unresolvable_reference_raises(): + ctx = StepContext() + with pytest.raises(StepReferenceError): + ctx.resolve("${steps.never_ran.output_files}") + + +def test_manifest_context_defaults_to_none(): + ctx = StepContext() + assert ctx.manifest_context is None + + +def test_manifest_context_stored_when_provided(): + sentinel = object() + ctx = StepContext(manifest_context=sentinel) + assert ctx.manifest_context is sentinel \ No newline at end of file diff --git a/tests/test_workflow_engine.py b/tests/test_workflow_engine.py new file mode 100644 index 0000000..71005a4 --- /dev/null +++ b/tests/test_workflow_engine.py @@ -0,0 +1,136 @@ +"""Tests for WorkflowEngine (Milestone 4).""" + +import pytest + +from databusclient.workflow.engine import WorkflowEngine, WorkflowExecutionError + +def test_runs_steps_in_order(monkeypatch): + order = [] + + class OrderedStep: + def run(self, step_config, context): + order.append(step_config["name"]) + + from databusclient.workflow import steps as steps_module + monkeypatch.setitem(steps_module.STEP_REGISTRY, "download", OrderedStep) + monkeypatch.setitem(steps_module.STEP_REGISTRY, "deploy", OrderedStep) + + engine = WorkflowEngine() + engine.run([ + {"name": "a", "command": "download"}, + {"name": "b", "command": "deploy"}, + ]) + assert order == ["a", "b"] + + +def test_step_failure_with_default_fail_raises(monkeypatch): + class FailingStep: + def run(self, step_config, context): + raise RuntimeError("boom") + + from databusclient.workflow import steps as steps_module + monkeypatch.setitem(steps_module.STEP_REGISTRY, "download", FailingStep) + + engine = WorkflowEngine() + with pytest.raises(WorkflowExecutionError, match="boom"): + engine.run([{"name": "a", "command": "download"}]) + + +def test_step_failure_with_continue_does_not_raise(monkeypatch): + class FailingStep: + def run(self, step_config, context): + raise RuntimeError("boom") + + class OKStep: + def run(self, step_config, context): + pass + + from databusclient.workflow import steps as steps_module + monkeypatch.setitem(steps_module.STEP_REGISTRY, "download", FailingStep) + monkeypatch.setitem(steps_module.STEP_REGISTRY, "deploy", OKStep) + + engine = WorkflowEngine() + results = engine.run([ + {"name": "a", "command": "download", "on_error": "continue"}, + {"name": "b", "command": "deploy"}, + ]) + assert results[0].status == "skipped_error" + assert results[1].status == "success" + + +def test_retry_succeeds_on_second_attempt(monkeypatch): + attempts = {"count": 0} + + class FlakyStep: + def run(self, step_config, context): + attempts["count"] += 1 + if attempts["count"] < 2: + raise RuntimeError("transient failure") + + from databusclient.workflow import steps as steps_module + monkeypatch.setitem(steps_module.STEP_REGISTRY, "download", FlakyStep) + + engine = WorkflowEngine() + results = engine.run([{ + "name": "a", "command": "download", "on_error": "retry", + "retry": {"max_attempts": 3, "delay_seconds": 0}, + }]) + assert results[0].status == "success" + assert results[0].attempts == 2 + assert attempts["count"] == 2 + + +def test_retry_exhausts_attempts_and_fails(monkeypatch): + class AlwaysFailsStep: + def run(self, step_config, context): + raise RuntimeError("permanent failure") + + from databusclient.workflow import steps as steps_module + monkeypatch.setitem(steps_module.STEP_REGISTRY, "download", AlwaysFailsStep) + + engine = WorkflowEngine() + with pytest.raises(WorkflowExecutionError, match="permanent failure"): + engine.run([{ + "name": "a", "command": "download", "on_error": "retry", + "retry": {"max_attempts": 2, "delay_seconds": 0}, + }]) + + +def test_step_chaining_end_to_end(monkeypatch): + """A download step's output is available to a deploy step via StepContext.""" + class FetchStep: + def run(self, step_config, context): + context.set_output(step_config["name"], "output_files", ["/data/a.ttl"]) + + captured = {} + + class PublishStep: + def run(self, step_config, context): + resolved = context.resolve(step_config) + captured["files"] = resolved["files"] + + from databusclient.workflow import steps as steps_module + monkeypatch.setitem(steps_module.STEP_REGISTRY, "download", FetchStep) + monkeypatch.setitem(steps_module.STEP_REGISTRY, "deploy", PublishStep) + + engine = WorkflowEngine() + engine.run([ + {"name": "fetch", "command": "download"}, + {"name": "publish", "command": "deploy", "files": "${steps.fetch.output_files}"}, + ]) + assert captured["files"] == ["/data/a.ttl"] + +def test_unknown_step_reference_surfaces_as_workflow_execution_error(monkeypatch): + class PublishStep: + def run(self, step_config, context): + context.resolve(step_config) # will raise, since "nonexistent" never ran + + from databusclient.workflow import steps as steps_module + monkeypatch.setitem(steps_module.STEP_REGISTRY, "deploy", PublishStep) + + engine = WorkflowEngine() + with pytest.raises(WorkflowExecutionError, match="unknown or not-yet-executed"): + engine.run([ + {"name": "publish", "command": "deploy", + "files": "${steps.nonexistent.output_files}"}, + ]) \ No newline at end of file diff --git a/tests/test_workflow_parser.py b/tests/test_workflow_parser.py new file mode 100644 index 0000000..b82bab7 --- /dev/null +++ b/tests/test_workflow_parser.py @@ -0,0 +1,180 @@ +"""Tests for WorkflowParser (Milestone 4).""" + +import os +import tempfile + +import pytest +import yaml + +from databusclient.workflow.parser import ( + MissingEnvVarError, + WorkflowParseError, + parse_workflow, +) + + +def _write_yaml(content: dict) -> str: + fd, path = tempfile.mkstemp(suffix=".yml") + with os.fdopen(fd, "w", encoding="utf-8") as f: + yaml.safe_dump(content, f) + return path + + +def test_parses_minimal_valid_workflow(): + path = _write_yaml({ + "steps": [ + {"name": "fetch", "command": "download", "uri": "https://example.org/x"}, + ] + }) + result = parse_workflow(path) + assert result["manifest"] is None + assert len(result["steps"]) == 1 + assert result["steps"][0]["name"] == "fetch" + + +def test_missing_steps_key_raises(): + path = _write_yaml({"manifest": "run.json"}) + with pytest.raises(WorkflowParseError, match="steps"): + parse_workflow(path) + + +def test_empty_steps_list_raises(): + path = _write_yaml({"steps": []}) + with pytest.raises(WorkflowParseError, match="non-empty"): + parse_workflow(path) + + +def test_step_missing_name_raises(): + path = _write_yaml({"steps": [{"command": "download", "uri": "x"}]}) + with pytest.raises(WorkflowParseError, match="name"): + parse_workflow(path) + + +def test_duplicate_step_names_raise(): + path = _write_yaml({ + "steps": [ + {"name": "a", "command": "download", "uri": "x"}, + {"name": "a", "command": "delete", "uris": ["x"]}, + ] + }) + with pytest.raises(WorkflowParseError, match="Duplicate step name"): + parse_workflow(path) + + +def test_invalid_command_raises(): + path = _write_yaml({"steps": [{"name": "a", "command": "bogus"}]}) + with pytest.raises(WorkflowParseError, match="invalid command"): + parse_workflow(path) + + +def test_invalid_on_error_raises(): + path = _write_yaml({ + "steps": [{"name": "a", "command": "download", "uri": "x", "on_error": "maybe"}] + }) + with pytest.raises(WorkflowParseError, match="invalid on_error"): + parse_workflow(path) + + +def test_retry_without_config_raises(): + path = _write_yaml({ + "steps": [{"name": "a", "command": "download", "uri": "x", "on_error": "retry"}] + }) + with pytest.raises(WorkflowParseError, match="retry"): + parse_workflow(path) + + +def test_retry_with_invalid_max_attempts_raises(): + path = _write_yaml({ + "steps": [{ + "name": "a", "command": "download", "uri": "x", "on_error": "retry", + "retry": {"max_attempts": 0, "delay_seconds": 5}, + }] + }) + with pytest.raises(WorkflowParseError, match="max_attempts"): + parse_workflow(path) + + +def test_valid_retry_config_passes(monkeypatch): + path = _write_yaml({ + "steps": [{ + "name": "a", "command": "download", "uri": "x", "on_error": "retry", + "retry": {"max_attempts": 3, "delay_seconds": 5}, + }] + }) + result = parse_workflow(path) + assert result["steps"][0]["retry"]["max_attempts"] == 3 + + +def test_env_var_substitution(monkeypatch): + monkeypatch.setenv("MY_API_KEY", "secret123") + path = _write_yaml({ + "steps": [{"name": "a", "command": "deploy", "api_key": "${MY_API_KEY}"}] + }) + result = parse_workflow(path) + assert result["steps"][0]["api_key"] == "secret123" + + +def test_missing_env_var_raises(monkeypatch): + monkeypatch.delenv("DOES_NOT_EXIST_VAR", raising=False) + path = _write_yaml({ + "steps": [{"name": "a", "command": "deploy", "api_key": "${DOES_NOT_EXIST_VAR}"}] + }) + with pytest.raises(MissingEnvVarError, match="DOES_NOT_EXIST_VAR"): + parse_workflow(path) + + +def test_steps_reference_left_untouched(): + """${steps.x.output_files} must NOT be treated as a missing env var.""" + path = _write_yaml({ + "steps": [ + {"name": "fetch", "command": "download", "uri": "x"}, + {"name": "publish", "command": "deploy", "files": "${steps.fetch.output_files}"}, + ] + }) + result = parse_workflow(path) + assert result["steps"][1]["files"] == "${steps.fetch.output_files}" + + +def test_multiple_tokens_in_same_string(monkeypatch): + monkeypatch.setenv("ACCOUNT", "myaccount") + monkeypatch.setenv("GROUP", "mygroup") + path = _write_yaml({ + "steps": [{ + "name": "a", "command": "deploy", + "version_id": "https://databus.dbpedia.org/${ACCOUNT}/${GROUP}/art/1.0", + }] + }) + result = parse_workflow(path) + assert result["steps"][0]["version_id"] == "https://databus.dbpedia.org/myaccount/mygroup/art/1.0" + + +def test_nonexistent_file_raises(): + with pytest.raises(WorkflowParseError, match="not found"): + parse_workflow("does-not-exist.yml") + + +def test_invalid_yaml_raises(): + fd, path = tempfile.mkstemp(suffix=".yml") + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write("steps: [unclosed") + with pytest.raises(WorkflowParseError, match="not valid YAML"): + parse_workflow(path) + +def test_bare_dollar_var_without_braces_passes_through_unchanged(): + """$VAR (no braces) is not a substitution token -- left as literal text.""" + path = _write_yaml({ + "steps": [{"name": "a", "command": "download", "uri": "$HOME/data"}] + }) + result = parse_workflow(path) + assert result["steps"][0]["uri"] == "$HOME/data" + + +def test_empty_braces_pass_through_unchanged(): + """${} has no characters between the braces, so it doesn't match the + substitution pattern at all (which requires at least one character) -- + it passes through as literal text, same as a bare $VAR without braces.""" + path = _write_yaml({ + "steps": [{"name": "a", "command": "download", "uri": "${}/data"}] + }) + result = parse_workflow(path) + assert result["steps"][0]["uri"] == "${}/data" \ No newline at end of file diff --git a/tests/test_workflow_steps.py b/tests/test_workflow_steps.py new file mode 100644 index 0000000..d89c0e5 --- /dev/null +++ b/tests/test_workflow_steps.py @@ -0,0 +1,373 @@ +"""Tests for step classes (Milestone 4). No live Databus calls -- the +underlying api_download/api_deploy_call/api_delete functions are mocked.""" + +import os +import pytest + +from databusclient.manifest.context import ManifestContext +from databusclient.workflow.context import StepContext +from databusclient.workflow.steps import ( + DeleteStep, + DeployStep, + DownloadStep, + StepValidationError, +) + + +def test_download_step_requires_uri(): + ctx = StepContext() + step = DownloadStep() + with pytest.raises(StepValidationError, match="requires 'uri'"): + step.run({"name": "fetch", "command": "download"}, ctx) + + +def test_download_step_calls_api_download_and_collects_files(monkeypatch, tmp_path): + captured = {} + + def fake_download(**kwargs): + captured.update(kwargs) + local_dir = kwargs["localDir"] + os.makedirs(local_dir, exist_ok=True) + with open(os.path.join(local_dir, "a.ttl"), "w") as f: + f.write("data") + kwargs["manifest_context"].record_file( + url=kwargs["databusURIs"][0], status="success" + ) + + monkeypatch.setattr("databusclient.workflow.steps.api_download", fake_download) + + ctx = StepContext() + step = DownloadStep() + step.run( + {"name": "fetch", "command": "download", "uri": "https://example.org/x", + "localdir": str(tmp_path)}, + ctx, + ) + + assert captured["databusURIs"] == ["https://example.org/x"] + output = ctx.get_output("fetch", "output_files") + assert len(output) == 1 + assert output[0].endswith("a.ttl") + + +def test_download_step_collects_files_from_subdirectory(monkeypatch, tmp_path): + """Simulates a Quad -> Triple split producing files in a subdirectory.""" + def fake_download(**kwargs): + local_dir = kwargs["localDir"] + sub = os.path.join(local_dir, "split") + os.makedirs(sub, exist_ok=True) + with open(os.path.join(sub, "graph1.nt"), "w") as f: + f.write("data") + with open(os.path.join(sub, "graph2.nt"), "w") as f: + f.write("data") + kwargs["manifest_context"].record_file( + url=kwargs["databusURIs"][0], status="success" + ) + + monkeypatch.setattr("databusclient.workflow.steps.api_download", fake_download) + + ctx = StepContext() + step = DownloadStep() + step.run( + {"name": "fetch", "command": "download", "uri": "x", "localdir": str(tmp_path)}, + ctx, + ) + output = ctx.get_output("fetch", "output_files") + assert len(output) == 2 + assert all(isinstance(p, str) for p in output) + + +def test_download_step_records_output_urls_from_manifest_context(monkeypatch, tmp_path): + """output_urls comes from what download.py itself resolved and recorded + -- not from re-checking the input URI, so this test uses an input URI + that DIFFERS from the resolved one, exactly like a real Databus + redirect would produce.""" + def fake_download(**kwargs): + local_dir = kwargs["localDir"] + os.makedirs(local_dir, exist_ok=True) + with open(os.path.join(local_dir, "a.ttl"), "w") as f: + f.write("data") + # Simulates download.py resolving a redirect: the recorded url + # differs from the input databusURIs[0]. + kwargs["manifest_context"].record_file( + url="https://raw.githubusercontent.com/real/a.ttl", status="success" + ) + + monkeypatch.setattr("databusclient.workflow.steps.api_download", fake_download) + + ctx = StepContext() + step = DownloadStep() + step.run( + {"name": "fetch", "command": "download", + "uri": "https://databus.dbpedia.org/acct/grp/art/1.0/a.ttl", + "localdir": str(tmp_path)}, + ctx, + ) + + assert ctx.get_output("fetch", "output_urls") == [ + "https://raw.githubusercontent.com/real/a.ttl" + ] + + +def test_download_step_output_urls_handles_multiple_files(monkeypatch, tmp_path): + """A version/artifact/group download can produce multiple files -- + output_urls must contain the resolved URL for each one.""" + def fake_download(**kwargs): + local_dir = kwargs["localDir"] + os.makedirs(local_dir, exist_ok=True) + for name in ("a.ttl", "b.ttl"): + with open(os.path.join(local_dir, name), "w") as f: + f.write("data") + ctx = kwargs["manifest_context"] + ctx.record_file(url="https://real.example.org/a.ttl", status="success") + ctx.record_file(url="https://real.example.org/b.ttl", status="success") + + monkeypatch.setattr("databusclient.workflow.steps.api_download", fake_download) + + ctx = StepContext() + step = DownloadStep() + step.run( + {"name": "fetch", "command": "download", + "uri": "https://databus.dbpedia.org/acct/grp/art/1.0", + "localdir": str(tmp_path)}, + ctx, + ) + + assert ctx.get_output("fetch", "output_urls") == [ + "https://real.example.org/a.ttl", "https://real.example.org/b.ttl" + ] + + +def test_download_step_accepts_multiple_uris(monkeypatch, tmp_path): + captured = {} + + def fake_download(**kwargs): + captured.update(kwargs) + local_dir = kwargs["localDir"] + os.makedirs(local_dir, exist_ok=True) + with open(os.path.join(local_dir, "a.ttl"), "w") as f: + f.write("data") + for uri in kwargs["databusURIs"]: + kwargs["manifest_context"].record_file(url=uri, status="success") + + monkeypatch.setattr("databusclient.workflow.steps.api_download", fake_download) + + ctx = StepContext() + step = DownloadStep() + step.run({ + "name": "fetch", "command": "download", + "uris": ["https://example.org/a", "https://example.org/b"], + "localdir": str(tmp_path), + }, ctx) + + assert captured["databusURIs"] == ["https://example.org/a", "https://example.org/b"] + assert ctx.get_output("fetch", "output_urls") == [ + "https://example.org/a", "https://example.org/b" + ] + + +def test_download_step_only_captures_entries_from_this_run(monkeypatch, tmp_path): + """If a real, shared manifest_context is used (future Milestone 5), + entries from a PRIOR step must not leak into this step's output_urls.""" + def fake_download(**kwargs): + local_dir = kwargs["localDir"] + os.makedirs(local_dir, exist_ok=True) + with open(os.path.join(local_dir, "b.ttl"), "w") as f: + f.write("data") + kwargs["manifest_context"].record_file( + url="https://example.org/b.ttl", status="success" + ) + + monkeypatch.setattr("databusclient.workflow.steps.api_download", fake_download) + + shared_context = ManifestContext(command="download") + shared_context.record_file(url="https://example.org/PRIOR.ttl", status="success") + + ctx = StepContext(manifest_context=shared_context) + step = DownloadStep() + step.run( + {"name": "fetch", "command": "download", "uri": "x", "localdir": str(tmp_path)}, + ctx, + ) + + assert ctx.get_output("fetch", "output_urls") == ["https://example.org/b.ttl"] + +def test_deploy_step_requires_fields(): + ctx = StepContext() + step = DeployStep() + with pytest.raises(StepValidationError, match="missing required field"): + step.run({"name": "publish", "command": "deploy"}, ctx) + + +def test_deploy_step_resolves_step_reference_to_urls_and_calls_deploy(monkeypatch): + """Chaining a step reference into classic mode works when the referenced + output is itself URLs (e.g. output_urls from a download step) -- not + local file paths. See test_deploy_step_classic_mode_rejects_local_paths_ + with_clear_error for the local-path rejection case.""" + captured = {} + + def fake_create_dataset(**kwargs): + captured["create_dataset_kwargs"] = kwargs + return {"@graph": [{"@id": "fake"}]} + + def fake_deploy(dataid, api_key): + captured["api_key"] = api_key + + monkeypatch.setattr("databusclient.workflow.steps.create_dataset", fake_create_dataset) + monkeypatch.setattr("databusclient.workflow.steps.api_deploy_call", fake_deploy) + + ctx = StepContext() + ctx.set_output("fetch", "output_urls", ["https://example.org/data/a.ttl"]) + + step = DeployStep() + step.run({ + "name": "publish", "command": "deploy", + "version_id": "https://databus.dbpedia.org/a/b/c/1.0", + "title": "T", "abstract": "A", "description": "D", + "license": "https://license.example.org", "api_key": "key123", + "files": "${steps.fetch.output_urls}", + }, ctx) + + assert captured["create_dataset_kwargs"]["distributions"] == ["https://example.org/data/a.ttl"] + assert captured["api_key"] == "key123" + + +def test_delete_step_always_forces_no_prompt(monkeypatch): + captured = {} + + def fake_delete(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr("databusclient.workflow.steps.api_delete", fake_delete) + + ctx = StepContext() + step = DeleteStep() + step.run({ + "name": "cleanup", "command": "delete", + "uris": ["https://databus.dbpedia.org/a/b/c/old"], + "api_key": "key123", + }, ctx) + + assert captured["force"] is True + assert captured["dry_run"] is False + + +def test_delete_step_requires_uris(): + ctx = StepContext() + step = DeleteStep() + with pytest.raises(StepValidationError, match="requires 'uris'"): + step.run({"name": "cleanup", "command": "delete", "api_key": "k"}, ctx) + + +def test_delete_step_requires_api_key(): + ctx = StepContext() + step = DeleteStep() + with pytest.raises(StepValidationError, match="requires 'api_key'"): + step.run({"name": "cleanup", "command": "delete", "uris": ["x"]}, ctx) + + +def test_deploy_step_classic_mode_rejects_missing_files(): + ctx = StepContext() + step = DeployStep() + with pytest.raises(StepValidationError, match="requires 'files'"): + step.run({ + "name": "publish", "command": "deploy", + "version_id": "https://databus.dbpedia.org/a/b/c/1.0", + "title": "T", "abstract": "A", "description": "D", + "license": "https://license.example.org", "api_key": "key123", + }, ctx) + + +def test_deploy_step_classic_mode_rejects_local_paths_with_clear_error(): + """The actual bug we hit manually: classic mode given local file paths + (e.g. chained from a download step's output_files) must fail with a + clear, actionable error -- not a raw 'Invalid URL' crash.""" + ctx = StepContext() + ctx.set_output("fetch", "output_files", ["./tmp/workflow-demo/download/swagger.yml"]) + + step = DeployStep() + with pytest.raises(StepValidationError, match="Local file paths.*not accepted"): + step.run({ + "name": "publish", "command": "deploy", + "version_id": "https://databus.dbpedia.org/a/b/c/1.0", + "title": "T", "abstract": "A", "description": "D", + "license": "https://license.example.org", "api_key": "key123", + "files": "${steps.fetch.output_files}", + }, ctx) + + +def test_deploy_step_webdav_mode_requires_all_three_fields(): + ctx = StepContext() + step = DeployStep() + with pytest.raises(StepValidationError, match="requires 'webdav_url', 'remote', and 'path' together"): + step.run({ + "name": "publish", "command": "deploy", + "version_id": "https://databus.dbpedia.org/a/b/c/1.0", + "title": "T", "abstract": "A", "description": "D", + "license": "https://license.example.org", "api_key": "key123", + "webdav_url": "https://cloud.example.com/webdav", + # 'remote' and 'path' deliberately missing + }, ctx) + + +def test_deploy_step_webdav_mode_uploads_then_deploys(monkeypatch): + captured = {} + + def fake_upload(distributions, remote, path, webdav_url): + captured["upload_args"] = (distributions, remote, path, webdav_url) + return [{"url": "https://cloud.example.com/webdav/data/a.ttl", + "checksum": "abc123", "size": 100}] + + def fake_deploy_from_metadata(metadata, version_id, title, abstract, description, license_url, apikey): + captured["deploy_metadata"] = metadata + captured["api_key"] = apikey + + monkeypatch.setattr("databusclient.workflow.steps.webdav.upload_to_webdav", fake_upload) + monkeypatch.setattr("databusclient.workflow.steps.deploy_from_metadata", fake_deploy_from_metadata) + + ctx = StepContext() + ctx.set_output("fetch", "output_files", ["/local/path/a.ttl"]) + + step = DeployStep() + step.run({ + "name": "publish", "command": "deploy", + "version_id": "https://databus.dbpedia.org/a/b/c/1.0", + "title": "T", "abstract": "A", "description": "D", + "license": "https://license.example.org", "api_key": "key123", + "webdav_url": "https://cloud.example.com/webdav", + "remote": "nextcloud", "path": "datasets/mydata", + "files": "${steps.fetch.output_files}", + }, ctx) + + assert captured["upload_args"][0] == ["/local/path/a.ttl"] + assert captured["upload_args"][1:] == ("nextcloud", "datasets/mydata", "https://cloud.example.com/webdav") + assert captured["api_key"] == "key123" + assert ctx.get_output("publish", "output_files") == ["https://cloud.example.com/webdav/data/a.ttl"] + + +def test_deploy_step_classic_mode_still_works_with_urls(monkeypatch): + """Confirms classic mode behavior is unchanged for normal URL-based deploys.""" + captured = {} + + def fake_create_dataset(**kwargs): + captured["kwargs"] = kwargs + return {"@graph": [{"@id": "fake"}]} + + def fake_deploy(dataid, api_key): + captured["api_key"] = api_key + + monkeypatch.setattr("databusclient.workflow.steps.create_dataset", fake_create_dataset) + monkeypatch.setattr("databusclient.workflow.steps.api_deploy_call", fake_deploy) + + ctx = StepContext() + step = DeployStep() + step.run({ + "name": "publish", "command": "deploy", + "version_id": "https://databus.dbpedia.org/a/b/c/1.0", + "title": "T", "abstract": "A", "description": "D", + "license": "https://license.example.org", "api_key": "key123", + "files": ["https://example.org/data.ttl"], + }, ctx) + + assert captured["kwargs"]["distributions"] == ["https://example.org/data.ttl"] + assert ctx.get_output("publish", "output_files") == ["https://example.org/data.ttl"] \ No newline at end of file From ade26c6252763a6f5786174f201976a1b62179fc Mon Sep 17 00:00:00 2001 From: Dhanashree Petare Date: Wed, 26 Aug 2026 15:32:11 +0530 Subject: [PATCH 7/9] feat: Milestone 5 - workflow manifest integration and example workflows (#85) * feat: Milestone 5 - unified workflow manifest integration, automatic console summary, five example workflows *fix: resolve PR review comments * Record download 404 failures in manifests * test: use realistic Databus URL in 404 tests for consistency --- databusclient/api/download.py | 6 ++ databusclient/cli.py | 55 ++++++++++-- databusclient/manifest/context.py | 23 ++++- databusclient/manifest/replay.py | 13 +-- databusclient/manifest/summary.py | 16 +++- databusclient/manifest/writer.py | 56 +++++++----- databusclient/workflow/engine.py | 76 +++++++++++++++- databusclient/workflow/steps.py | 8 ++ examples/workflows/README.md | 19 +++- .../workflows/batch-deployment-with-retry.yml | 42 +++++++++ examples/workflows/ci-cd-integration.yml | 24 +++++ examples/workflows/failure-debugging.yml | 17 ++++ examples/workflows/full-pipeline.yml | 2 +- .../workflows/nightly-publishing-pipeline.yml | 24 +++++ .../reproducible-research-download.yml | 7 ++ tests/test_download.py | 26 ++++++ tests/test_manifest_replay.py | 16 +++- tests/test_manifest_summary.py | 70 ++++++++++++++- tests/test_workflow_engine.py | 88 ++++++++++++++++++- tests/test_workflow_steps.py | 52 ++++++++++- 20 files changed, 588 insertions(+), 52 deletions(-) create mode 100644 examples/workflows/batch-deployment-with-retry.yml create mode 100644 examples/workflows/ci-cd-integration.yml create mode 100644 examples/workflows/failure-debugging.yml create mode 100644 examples/workflows/nightly-publishing-pipeline.yml create mode 100644 examples/workflows/reproducible-research-download.yml diff --git a/databusclient/api/download.py b/databusclient/api/download.py index 4bcca8b..8d94deb 100644 --- a/databusclient/api/download.py +++ b/databusclient/api/download.py @@ -522,6 +522,12 @@ def _download_file( except requests.exceptions.HTTPError as e: if response.status_code == 404: print(f"WARNING: Skipping file {url} because it was not found (404).") + if manifest_context is not None: + manifest_context.record_file( + url=url, + status="failed", + error_message="404 Not Found", + ) return else: raise e diff --git a/databusclient/cli.py b/databusclient/cli.py index e0d9638..2ab118f 100644 --- a/databusclient/cli.py +++ b/databusclient/cli.py @@ -583,30 +583,67 @@ def workflow(): @workflow.command("run") @click.argument("workflow_path", type=click.Path(exists=True, dir_okay=False)) -def workflow_run(workflow_path): +@click.option( + "--manifest", + "manifest_path", + default=None, + help="Write a unified JSON-LD manifest of the entire workflow run to PATH.", +) +def workflow_run(workflow_path, manifest_path): """ Run a declarative workflow pipeline from a YAML file. Executes each step in order, chaining outputs between steps via ${steps.name.output_files}-style references, and applying each - step's on_error behavior (fail/continue/retry). + step's on_error behavior (fail/continue/retry). Prints a console + summary after every run. Use --manifest to also write a unified + JSON-LD manifest covering every step. """ try: parsed = parse_workflow(workflow_path) except WorkflowParseError as e: raise click.ClickException(str(e)) - context = StepContext() - engine = WorkflowEngine(context=context) + # CLI flag takes priority; falls back to the YAML file's own + # top-level 'manifest:' key if --manifest was not given on the + # command line. + if manifest_path is None: + manifest_path = parsed.get("manifest") + # A workflow-level manifest is always built internally (for the + # automatic console summary), even when no manifest path is set. + # It's only written to disk when a path is provided (via --manifest + # or the YAML file's own 'manifest:' key). + manifest_ctx = ManifestContext(command="workflow") + + step_context = StepContext() + engine = WorkflowEngine(context=step_context, manifest_context=manifest_ctx) + + workflow_error = None try: - results = engine.run(parsed["steps"]) + engine.run(parsed["steps"]) except WorkflowExecutionError as e: - raise click.ClickException(str(e)) + workflow_error = e + finally: + click.echo("Workflow complete." if workflow_error is None else "Workflow failed.") + for result in engine.results: + click.echo(f" {result.name}: {result.status}") + + click.echo("") + click.echo(format_summary(ManifestWriter.build_manifest_dict(manifest_ctx))) + + if manifest_path: + try: + actual_path = ManifestWriter.write(manifest_ctx, manifest_path) + click.echo(f"\nManifest written to {actual_path}") + except (OSError, IOError) as e: + click.echo( + f"WARNING: Manifest could not be written to {manifest_path}: {e}", + err=True, + ) - click.echo("Workflow complete.") - for result in results: - click.echo(f" {result.name}: {result.status}") + if workflow_error is not None: + raise click.ClickException(str(workflow_error)) if __name__ == "__main__": app() diff --git a/databusclient/manifest/context.py b/databusclient/manifest/context.py index 790f909..2e6cf32 100644 --- a/databusclient/manifest/context.py +++ b/databusclient/manifest/context.py @@ -156,4 +156,25 @@ def summary(self) -> dict: "succeeded": succeeded, "failed": failed, "total_bytes": total_bytes, - } \ No newline at end of file + } + + def merge_from(self, other: "ManifestContext", step_name: Optional[str] = None) -> None: + """Merge another context's recorded files into this one. + + Used by the workflow engine: each step records into its own + temporary ManifestContext (so per-step failures/successes stay + isolated), then that context's entries are merged into the + workflow-level master context here, tagged with which step + produced them. + + Args: + other: The ManifestContext to merge entries from. + step_name: If given, tags each merged file entry with + "step": step_name, so a multi-step workflow manifest + remains traceable to which step produced which file. + """ + for entry in other.files: + merged_entry = dict(entry) + if step_name is not None: + merged_entry["step"] = step_name + self.files.append(merged_entry) \ No newline at end of file diff --git a/databusclient/manifest/replay.py b/databusclient/manifest/replay.py index 0bb135c..f7cede7 100644 --- a/databusclient/manifest/replay.py +++ b/databusclient/manifest/replay.py @@ -294,6 +294,12 @@ def replay_manifest( if not command: raise ManifestReplayError("Manifest missing required field dbus:command.") + if command not in ("download", "delete", "deploy"): + raise ManifestReplayError( + f"Replay for command '{command}' is not implemented yet. " + "Currently supported: download, delete, deploy." + ) + replay_params = _validate_replay_params(manifest.get("dbus:replayParams")) if command == "download": @@ -305,9 +311,4 @@ def replay_manifest( return _replay_delete(replay_params, overrides, confirm_fn) if command == "deploy": - return _replay_deploy(replay_params, overrides) - - raise ManifestReplayError( - f"Replay for command '{command}' is not implemented yet. " - "Currently supported: download, delete, deploy." - ) \ No newline at end of file + return _replay_deploy(replay_params, overrides) \ No newline at end of file diff --git a/databusclient/manifest/summary.py b/databusclient/manifest/summary.py index 914ebfe..a8bd6e0 100644 --- a/databusclient/manifest/summary.py +++ b/databusclient/manifest/summary.py @@ -39,7 +39,7 @@ def format_summary(manifest: Dict[str, Any]) -> str: Args: manifest: A manifest dict, as produced by loading a manifest - JSON-LD file (e.g. via replay._load_manifest). + JSON-LD file (e.g. via replay.load_manifest). Returns: A formatted multi-line string ready to print to the console. @@ -81,4 +81,18 @@ def format_summary(manifest: Dict[str, Any]) -> str: else: lines.append(f"Error : {error_message}") + failed_files = [ + f for f in manifest.get("dataid:distribution", {}).get("dataid:file", []) + if f.get("dbus:status") == "failed" + ] + if failed_files: + lines.append("") + lines.append("Failures:") + for f in failed_files: + step = f.get("dbus:stepName") + url = f.get("dcat:downloadURL", "unknown") + error_message = f.get("dbus:errorMessage", "no error message recorded") + prefix = f" [{step}] " if step else " " + lines.append(f"{prefix}{url}: {error_message}") + return "\n".join(lines) \ No newline at end of file diff --git a/databusclient/manifest/writer.py b/databusclient/manifest/writer.py index 5e606aa..3c0fd59 100644 --- a/databusclient/manifest/writer.py +++ b/databusclient/manifest/writer.py @@ -30,31 +30,14 @@ class ManifestWriter: """Serializes a ManifestContext to a JSON-LD manifest file.""" @staticmethod - def write(context: ManifestContext, path: str) -> str: - """Write the manifest to a JSON-LD file at the given path. - - Creates parent directories if they do not exist. - If a file already exists at `path`, auto-suffixes with _1, _2, etc. - and prints a warning rather than silently overwriting. - On failure, raises OSError — callers should catch and warn. - - Args: - context: The completed ManifestContext to serialize. - path: File path to write the manifest to. - - Raises: - OSError: If the file cannot be written, or if path is a directory. + def build_manifest_dict(context: ManifestContext) -> dict: + """Build the JSON-LD manifest dict from a context, without writing + to disk. Extracted from write() so callers (like the workflow + engine's automatic console summary) can get the dict without + needing a file path. """ - if path.endswith(("/", "\\")) or os.path.isdir(path): - stripped = path.rstrip("/\\") - raise OSError( - f"--manifest path '{path}' is a directory, not a file. " - f"Please provide a full file path, e.g. '{stripped}/manifest.jsonld'." - ) - summary = context.summary() - # Build file entries using DataID vocabulary file_entries = [] for f in context.files: entry: dict = { @@ -79,6 +62,8 @@ def write(context: ManifestContext, path: str) -> str: entry["dbus:errorTraceback"] = f["error_traceback"] if f.get("retry_count"): entry["dbus:retryCount"] = f["retry_count"] + if f.get("step"): + entry["dbus:stepName"] = f["step"] file_entries.append(entry) manifest = { @@ -121,6 +106,33 @@ def write(context: ManifestContext, path: str) -> str: "dbus:errorTraceback": context.operation_error["error_traceback"], } + return manifest + + @staticmethod + def write(context: ManifestContext, path: str) -> str: + """Write the manifest to a JSON-LD file at the given path. + + Creates parent directories if they do not exist. + If a file already exists at `path`, auto-suffixes with _1, _2, etc. + and prints a warning rather than silently overwriting. + On failure, raises OSError — callers should catch and warn. + + Args: + context: The completed ManifestContext to serialize. + path: File path to write the manifest to. + + Raises: + OSError: If the file cannot be written, or if path is a directory. + """ + if path.endswith(("/", "\\")) or os.path.isdir(path): + stripped = path.rstrip("/\\") + raise OSError( + f"--manifest path '{path}' is a directory, not a file. " + f"Please provide a full file path, e.g. '{stripped}/manifest.jsonld'." + ) + + manifest = ManifestWriter.build_manifest_dict(context) + parent = os.path.dirname(os.path.abspath(path)) if parent: os.makedirs(parent, exist_ok=True) diff --git a/databusclient/workflow/engine.py b/databusclient/workflow/engine.py index d5cec54..8a15e9e 100644 --- a/databusclient/workflow/engine.py +++ b/databusclient/workflow/engine.py @@ -10,8 +10,9 @@ from __future__ import annotations import time -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional +from databusclient.manifest.context import ManifestContext from databusclient.workflow.context import StepContext from databusclient.workflow.steps import STEP_REGISTRY @@ -32,10 +33,25 @@ def __init__(self, name: str, status: str, error: Exception | None = None, class WorkflowEngine: - """Runs a parsed workflow's steps in order, handling errors per step.""" - - def __init__(self, context: StepContext | None = None) -> None: + """Runs a parsed workflow's steps in order, handling errors per step. + + If manifest_context is given, one unified manifest is built for the + entire workflow run: each step gets its own temporary ManifestContext + (isolating its recorded files/errors), which is merged into + manifest_context afterward, tagged with the step's name. This lets a + single workflow manifest remain traceable to which step produced or + failed on which file, without touching download.py/deploy.py/delete.py + at all -- those already accept manifest_context=None as a no-op, and + here they simply receive a real (temporary, per-step) one instead. + """ + + def __init__( + self, + context: StepContext | None = None, + manifest_context: Optional[ManifestContext] = None, + ) -> None: self.context = context or StepContext() + self.manifest_context = manifest_context self.results: List[StepResult] = [] def run(self, steps: List[Dict[str, Any]]) -> List[StepResult]: @@ -78,25 +94,75 @@ def _run_step_with_error_handling(self, step_config: Dict[str, Any]) -> StepResu if on_error == "retry": return self._run_with_retry(name, step, step_config) + step_manifest_ctx = self._start_step_manifest(command) try: step.run(step_config, self.context) + self._finish_step_manifest(step_manifest_ctx, name) return StepResult(name, "success") except Exception as exc: + self._finish_step_manifest(step_manifest_ctx, name, error=exc) if on_error == "continue": print(f"WARNING: step '{name}' failed and on_error is 'continue': {exc}") return StepResult(name, "skipped_error", error=exc) # on_error == "fail" (or missing/defaulted to fail) return StepResult(name, "failed", error=exc) + def _start_step_manifest(self, command: str) -> Optional[ManifestContext]: + """If a workflow-level manifest is active, give this step its own + temporary ManifestContext to record into. Returns None if no + workflow manifest was requested -- in that case self.context's + manifest_context is left as whatever it already was (e.g. a step's + own throwaway context, like DownloadStep uses for output_urls). + """ + if self.manifest_context is None: + return None + step_ctx = ManifestContext(command=command) + self.context.manifest_context = step_ctx + return step_ctx + + def _finish_step_manifest( + self, + step_manifest_ctx: Optional[ManifestContext], + step_name: str, + error: Optional[Exception] = None, + ) -> None: + """Merge a completed step's temporary manifest entries into the + workflow-level master manifest, tagged with the step name. If the + step failed, also record a synthetic entry so the failure is + visible in the manifest even if the step recorded no per-file + entries before failing. The synthetic entry is tagged with the + same "step" field merge_from() uses, so format_summary()'s + [stepname] prefix mechanism works consistently for BOTH per-file + failures (merged from a step's own context) and whole-step + failures (no file-level detail available at all) -- previously + only the merged case was tagged, so whole-step failures (like an + auth error before any file work happens) showed up without the + [stepname] prefix, relying on the step name being embedded in a + fake url string instead. + """ + if self.manifest_context is None or step_manifest_ctx is None: + return + self.manifest_context.merge_from(step_manifest_ctx, step_name=step_name) + if error is not None: + self.manifest_context.record_file( + url="(no file-level detail -- step failed before producing one)", + status="failed", + error_message=str(error), + ) + self.manifest_context.files[-1]["step"] = step_name + def _run_with_retry(self, name: str, step: Any, step_config: Dict[str, Any]) -> StepResult: retry_config = step_config["retry"] max_attempts = retry_config["max_attempts"] delay_seconds = retry_config["delay_seconds"] + command = step_config["command"] last_error: Exception | None = None for attempt in range(1, max_attempts + 1): + step_manifest_ctx = self._start_step_manifest(command) try: step.run(step_config, self.context) + self._finish_step_manifest(step_manifest_ctx, name) return StepResult(name, "success", attempts=attempt) except Exception as exc: last_error = exc @@ -104,6 +170,8 @@ def _run_with_retry(self, name: str, step: Any, step_config: Dict[str, Any]) -> f"WARNING: step '{name}' attempt {attempt}/{max_attempts} " f"failed: {exc}" ) + if attempt == max_attempts: + self._finish_step_manifest(step_manifest_ctx, name, error=exc) if attempt < max_attempts: time.sleep(delay_seconds) diff --git a/databusclient/workflow/steps.py b/databusclient/workflow/steps.py index df35065..de51135 100644 --- a/databusclient/workflow/steps.py +++ b/databusclient/workflow/steps.py @@ -153,6 +153,14 @@ def run(self, step_config: Dict[str, Any], context: StepContext) -> None: context.set_output(name, "output_files", output_files) context.set_output(name, "version_id", resolved["version_id"]) + # deploy()/deploy_from_metadata() do not accept manifest_context + # (unlike download()/delete()) -- manifest recording for deploy is + # always done manually by the caller. This mirrors exactly what + # cli.py's own `deploy` command does after a successful deploy. + if context.manifest_context is not None: + for url in output_files: + context.manifest_context.record_file(url=url, status="success") + def _run_classic_mode(self, resolved: Dict[str, Any], name: str) -> list: files = resolved.get("files") if not files: diff --git a/examples/workflows/README.md b/examples/workflows/README.md index 5ee0901..ccc7c8f 100644 --- a/examples/workflows/README.md +++ b/examples/workflows/README.md @@ -1,17 +1,28 @@ # Example Workflows -Three example workflow pipelines, each runnable directly, though the deploy/delete steps use paths under a specific Databus account -- swap in your own account/version paths before running them yourself. All three use real, existing Databus data as their download source. +Eight example workflow pipelines, each runnable directly (though the deploy/delete steps use paths under a specific Databus account -- swap in your own account/version paths before running them yourself). All use real, existing Databus data as their download source. ```bash export DATABUS_API_KEY=your-key-here databusclient workflow run download-deploy.yml ``` +## Basic examples + - **`download-deploy.yml`** - downloads a real Databus dataset, then redeploys it exactly as downloaded (classic deploy mode, using `${steps.name.output_urls}` - the actual, redirect-resolved source URL, not the local file). - **`download-delete.yml`** - downloads a real Databus dataset, then deletes that same version, demonstrating a realistic archive-then-delete workflow. -- **`full-pipeline.yml`** - chains all three commands together: download a real Databus dataset, deploy it, then delete that same deployed version, demonstrating a complete download-deploy-cleanup pipeline. +- **`full-pipeline.yml`** - chains all three commands together: download a real Databus dataset, deploy it, then delete that same deployed version. -All three set `api_key: ${DATABUS_API_KEY}` - set that environment variable before running, rather than writing a real key into the file. +## The five proposal use cases (Milestone 5) -See the main [README's Workflow section](../../README.md#cli-workflow) for the full YAML format, step chaining, error handling, and WebDAV deploy mode documentation. +- **`reproducible-research-download.yml`** - downloads a dataset with checksum validation and a saved manifest, so the exact same download can be verified or reproduced later. +- **`nightly-publishing-pipeline.yml`** - download, deploy, then clean up an old version, meant to run unattended (e.g. via cron), with a unified manifest for the whole run. +- **`batch-deployment-with-retry.yml`** - deploys multiple versions in one run, with `on_error: retry` configured on each deploy step to handle transient failures automatically. +- **`ci-cd-integration.yml`** - a workflow with no interactive prompts anywhere, safe to call as a step in a CI/CD pipeline such as GitHub Actions. +- **`failure-debugging.yml`** - intentionally fails a deploy step (invalid API key) to demonstrate what a failed workflow's console output and manifest look like. + +## Manifests +Workflows can write a unified manifest covering every step in two ways: pass `--manifest path.jsonld` on the command line, or set a top-level `manifest:` key inside the YAML file itself (the command-line flag takes priority if both are given). Several of the examples above use the YAML key. Every manifest file entry that came from a workflow step is tagged with `dbus:stepName`, so a multi-step run stays traceable to which step produced or failed on which file. + +See the main [README's Workflow section](../../README.md#cli-workflow) for the full YAML format, step chaining, error handling, and WebDAV deploy mode documentation. diff --git a/examples/workflows/batch-deployment-with-retry.yml b/examples/workflows/batch-deployment-with-retry.yml new file mode 100644 index 0000000..4ad0ca5 --- /dev/null +++ b/examples/workflows/batch-deployment-with-retry.yml @@ -0,0 +1,42 @@ +manifest: ./manifests/batch-deploy.jsonld +steps: + - name: fetch_source + command: download + uri: https://databus.dbpedia.org/DhanashreeP/test-group/workflow-source-data/1.0 + localdir: ./workflow-output/batch + + - name: deploy_batch_1 + command: deploy + version_id: https://databus.dbpedia.org/DhanashreeP/test-group/batch-demo-1/1.0 + title: "Batch Deploy Demo 1" + abstract: "Batch deployment with retry example" + description: "First of several deploys in a batch, demonstrating retry on transient failure" + license: https://creativecommons.org/licenses/by-sa/3.0/ + api_key: ${DATABUS_API_KEY} + files: ${steps.fetch_source.output_urls} + on_error: retry + retry: + max_attempts: 3 + delay_seconds: 5 + + - name: deploy_batch_2 + command: deploy + version_id: https://databus.dbpedia.org/DhanashreeP/test-group/batch-demo-2/1.0 + title: "Batch Deploy Demo 2" + abstract: "Batch deployment with retry example" + description: "Second of several deploys in a batch, demonstrating retry on transient failure" + license: https://creativecommons.org/licenses/by-sa/3.0/ + api_key: ${DATABUS_API_KEY} + files: ${steps.fetch_source.output_urls} + on_error: retry + retry: + max_attempts: 3 + delay_seconds: 5 + + - name: cleanup_batch + command: delete + uris: + - https://databus.dbpedia.org/DhanashreeP/test-group/batch-demo-1/1.0 + - https://databus.dbpedia.org/DhanashreeP/test-group/batch-demo-2/1.0 + api_key: ${DATABUS_API_KEY} + on_error: continue diff --git a/examples/workflows/ci-cd-integration.yml b/examples/workflows/ci-cd-integration.yml new file mode 100644 index 0000000..b92a6c9 --- /dev/null +++ b/examples/workflows/ci-cd-integration.yml @@ -0,0 +1,24 @@ +manifest: ./manifests/ci-run.jsonld +steps: + - name: fetch_build_data + command: download + uri: https://databus.dbpedia.org/DhanashreeP/test-group/workflow-source-data/1.0 + localdir: ./workflow-output/ci + + - name: publish_build_artifact + command: deploy + version_id: https://databus.dbpedia.org/DhanashreeP/test-group/ci-demo/1.0 + title: "CI/CD Integration Demo" + abstract: "Example of a workflow suitable for GitHub Actions" + description: "No prompts, no interactive input -- safe to run in an unattended CI job" + license: https://creativecommons.org/licenses/by-sa/3.0/ + api_key: ${DATABUS_API_KEY} + files: ${steps.fetch_build_data.output_urls} + on_error: fail + + - name: cleanup_ci_artifact + command: delete + uris: + - https://databus.dbpedia.org/DhanashreeP/test-group/ci-demo/1.0 + api_key: ${DATABUS_API_KEY} + on_error: continue diff --git a/examples/workflows/failure-debugging.yml b/examples/workflows/failure-debugging.yml new file mode 100644 index 0000000..2eb589d --- /dev/null +++ b/examples/workflows/failure-debugging.yml @@ -0,0 +1,17 @@ +manifest: ./manifests/failure-debug.jsonld +steps: + - name: fetch_source + command: download + uri: https://databus.dbpedia.org/DhanashreeP/test-group/workflow-source-data/1.0 + localdir: ./workflow-output/failure-demo + + - name: deploy_with_bad_key + command: deploy + version_id: https://databus.dbpedia.org/DhanashreeP/test-group/failure-demo/1.0 + title: "Failure Debugging Demo" + abstract: "Intentionally fails to demonstrate manifest/console error output" + description: "Deploys with an invalid API key on purpose" + license: https://creativecommons.org/licenses/by-sa/3.0/ + api_key: THIS-KEY-IS-INTENTIONALLY-INVALID + files: ${steps.fetch_source.output_urls} + on_error: fail \ No newline at end of file diff --git a/examples/workflows/full-pipeline.yml b/examples/workflows/full-pipeline.yml index 4ec618c..016e726 100644 --- a/examples/workflows/full-pipeline.yml +++ b/examples/workflows/full-pipeline.yml @@ -1,7 +1,7 @@ steps: - name: fetch_dataset command: download - uri: https://databus.dev.dbpedia.link/fhofer/gsoc26/test-data/2.0 + uri: https://databus.dbpedia.org/DhanashreeP/test-group/workflow-source-data/1.0 localdir: ./workflow-output/full-pipeline - name: publish_dataset diff --git a/examples/workflows/nightly-publishing-pipeline.yml b/examples/workflows/nightly-publishing-pipeline.yml new file mode 100644 index 0000000..659451c --- /dev/null +++ b/examples/workflows/nightly-publishing-pipeline.yml @@ -0,0 +1,24 @@ +manifest: ./manifests/nightly-publish.jsonld +steps: + - name: fetch_latest_data + command: download + uri: https://databus.dbpedia.org/DhanashreeP/test-group/workflow-source-data/1.0 + localdir: ./workflow-output/nightly + + - name: publish_processed_dataset + command: deploy + version_id: https://databus.dbpedia.org/DhanashreeP/test-group/nightly-demo/1.0 + title: "Nightly Publishing Demo" + abstract: "Automated nightly publishing pipeline example" + description: "Demonstrates download -> deploy -> cleanup running unattended" + license: https://creativecommons.org/licenses/by-sa/3.0/ + api_key: ${DATABUS_API_KEY} + files: ${steps.fetch_latest_data.output_urls} + on_error: fail + + - name: cleanup_previous_version + command: delete + uris: + - https://databus.dbpedia.org/DhanashreeP/test-group/nightly-demo/1.0 + api_key: ${DATABUS_API_KEY} + on_error: continue \ No newline at end of file diff --git a/examples/workflows/reproducible-research-download.yml b/examples/workflows/reproducible-research-download.yml new file mode 100644 index 0000000..a1cf37b --- /dev/null +++ b/examples/workflows/reproducible-research-download.yml @@ -0,0 +1,7 @@ +manifest: ./manifests/research-download.jsonld +steps: + - name: fetch_research_data + command: download + uri: https://databus.dbpedia.org/DhanashreeP/test-group/workflow-source-data/1.0 + localdir: ./workflow-output/research-download + validate_checksum: true \ No newline at end of file diff --git a/tests/test_download.py b/tests/test_download.py index 94d8813..9be4fc7 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -35,3 +35,29 @@ def test_with_query(): ) def test_with_collection(): api_download("tmp", DEFAULT_ENDPOINT, [TEST_COLLECTION]) + +def test_404_records_failed_manifest_entry(monkeypatch): + from databusclient.manifest.context import ManifestContext + import databusclient.api.download as dl + + class FakeHeadResp: + status_code = 200 + headers = {} + + class FakeGetResp: + status_code = 404 + headers = {"content-length": "0"} + + def raise_for_status(self): + import requests + raise requests.exceptions.HTTPError(response=self) + + monkeypatch.setattr("requests.head", lambda *a, **k: FakeHeadResp()) + monkeypatch.setattr("requests.get", lambda *a, **k: FakeGetResp()) + + ctx = ManifestContext(command="download") + dl._download_file("https://databus.dbpedia.org/account/notexisting", localDir=".", manifest_context=ctx) + + assert len(ctx.files) == 1 + assert ctx.files[0]["status"] == "failed" + assert ctx.files[0]["error_message"] == "404 Not Found" \ No newline at end of file diff --git a/tests/test_manifest_replay.py b/tests/test_manifest_replay.py index 59ed13d..6dc776a 100644 --- a/tests/test_manifest_replay.py +++ b/tests/test_manifest_replay.py @@ -445,4 +445,18 @@ def test_replay_deploy_missing_deploy_mode_raises(tmp_path): _write_manifest(path, manifest) with pytest.raises(ManifestReplayError, match="predate"): - replay_manifest(str(path), overrides={"api_key": "dummy-key"}) \ No newline at end of file + replay_manifest(str(path), overrides={"api_key": "dummy-key"}) + +def test_replay_workflow_manifest_gives_clean_not_implemented_error(tmp_path): + """Workflow manifests have no replayParams (workflows don't call + record_params()). Confirm this gives the standard 'not implemented' + message, not a confusing validation error about a missing field.""" + manifest = { + "@type": "dbus:OperationManifest", + "dbus:command": "workflow", + } + path = tmp_path / "workflow-manifest.jsonld" + _write_manifest(path, manifest) + + with pytest.raises(ManifestReplayError, match="not implemented"): + replay_manifest(str(path)) \ No newline at end of file diff --git a/tests/test_manifest_summary.py b/tests/test_manifest_summary.py index 9407449..94b4335 100644 --- a/tests/test_manifest_summary.py +++ b/tests/test_manifest_summary.py @@ -114,4 +114,72 @@ def test_summary_no_error_line_when_no_operation_error(): "dbus:executionResult": {"dbus:succeeded": 1, "dbus:failed": 0, "dbus:totalBytes": 100}, } output = format_summary(manifest) - assert "Error" not in output \ No newline at end of file + assert "Error" not in output + +def test_summary_ignores_step_name_field_gracefully(): + """dbus:stepName is a per-file field, not surfaced in the top-level + summary -- confirms it doesn't break formatting.""" + manifest = { + "dbus:command": "workflow", + "dcterms:issued": {"@value": "2024-03-24T10:00:00Z"}, + "dbus:executionResult": {"dbus:succeeded": 1, "dbus:failed": 0, "dbus:totalBytes": 0}, + "dataid:distribution": {"dataid:file": [{"dbus:stepName": "fetch"}]}, + } + output = format_summary(manifest) + assert "Command : workflow" in output + +def test_summary_lists_failed_file_details_with_step_name(): + manifest = { + "dbus:command": "workflow", + "dcterms:issued": {"@value": "2024-03-24T10:00:00Z"}, + "dbus:executionResult": {"dbus:succeeded": 1, "dbus:failed": 1, "dbus:totalBytes": 0}, + "dataid:distribution": { + "dataid:file": [ + {"dcat:downloadURL": "https://a.org/x", "dbus:status": "success"}, + { + "dcat:downloadURL": "step:deploy_with_bad_key", + "dbus:status": "failed", + "dbus:stepName": "deploy_with_bad_key", + "dbus:errorMessage": "Authentication failed.", + }, + ] + }, + } + output = format_summary(manifest) + assert "Failures:" in output + assert "[deploy_with_bad_key] step:deploy_with_bad_key: Authentication failed." in output + + +def test_summary_lists_failed_file_details_without_step_name(): + """Single-command manifests (not workflows) have no dbus:stepName -- + confirm the failed-file line still renders cleanly without it.""" + manifest = { + "dbus:command": "download", + "dcterms:issued": {"@value": "2024-03-24T10:00:00Z"}, + "dbus:executionResult": {"dbus:succeeded": 0, "dbus:failed": 1, "dbus:totalBytes": 0}, + "dataid:distribution": { + "dataid:file": [ + { + "dcat:downloadURL": "https://databus.dbpedia.org/account/notexisting", + "dbus:status": "failed", + "dbus:errorMessage": "404 Not Found", + }, + ] + }, + } + output = format_summary(manifest) + assert "Failures:" in output + assert "https://databus.dbpedia.org/account/notexisting: 404 Not Found" in output + + +def test_summary_no_failed_files_section_when_all_succeeded(): + manifest = { + "dbus:command": "download", + "dcterms:issued": {"@value": "2024-03-24T10:00:00Z"}, + "dbus:executionResult": {"dbus:succeeded": 1, "dbus:failed": 0, "dbus:totalBytes": 100}, + "dataid:distribution": { + "dataid:file": [{"dcat:downloadURL": "https://a.org/x", "dbus:status": "success"}] + }, + } + output = format_summary(manifest) + assert "Failures:" not in output \ No newline at end of file diff --git a/tests/test_workflow_engine.py b/tests/test_workflow_engine.py index 71005a4..f086368 100644 --- a/tests/test_workflow_engine.py +++ b/tests/test_workflow_engine.py @@ -133,4 +133,90 @@ def run(self, step_config, context): engine.run([ {"name": "publish", "command": "deploy", "files": "${steps.nonexistent.output_files}"}, - ]) \ No newline at end of file + ]) + +def test_workflow_manifest_records_step_names(monkeypatch, tmp_path): + from databusclient.manifest.context import ManifestContext + + class FetchStep: + def run(self, step_config, context): + context.manifest_context.record_file(url="https://a.org/x", status="success") + + class PublishStep: + def run(self, step_config, context): + context.manifest_context.record_file(url="https://a.org/y", status="success") + + from databusclient.workflow import steps as steps_module + monkeypatch.setitem(steps_module.STEP_REGISTRY, "download", FetchStep) + monkeypatch.setitem(steps_module.STEP_REGISTRY, "deploy", PublishStep) + + manifest_ctx = ManifestContext(command="workflow") + engine = WorkflowEngine(manifest_context=manifest_ctx) + engine.run([ + {"name": "fetch", "command": "download"}, + {"name": "publish", "command": "deploy"}, + ]) + + steps_seen = {f["url"]: f.get("step") for f in manifest_ctx.files} + assert steps_seen["https://a.org/x"] == "fetch" + assert steps_seen["https://a.org/y"] == "publish" + + +def test_workflow_manifest_records_failed_step(monkeypatch): + from databusclient.manifest.context import ManifestContext + + class FailingStep: + def run(self, step_config, context): + raise RuntimeError("boom") + + from databusclient.workflow import steps as steps_module + monkeypatch.setitem(steps_module.STEP_REGISTRY, "download", FailingStep) + + manifest_ctx = ManifestContext(command="workflow") + engine = WorkflowEngine(manifest_context=manifest_ctx) + + with pytest.raises(WorkflowExecutionError): + engine.run([{"name": "a", "command": "download"}]) + + failed_entries = [f for f in manifest_ctx.files if f["status"] == "failed"] + assert len(failed_entries) == 1 + assert failed_entries[0]["step"] == "a" + assert "boom" in failed_entries[0]["error_message"] + + +def test_workflow_without_manifest_context_still_works(monkeypatch): + """No manifest_context given -- workflow still runs normally, no crash.""" + class OKStep: + def run(self, step_config, context): + pass + + from databusclient.workflow import steps as steps_module + monkeypatch.setitem(steps_module.STEP_REGISTRY, "download", OKStep) + + engine = WorkflowEngine() + results = engine.run([{"name": "a", "command": "download"}]) + assert results[0].status == "success" + +def test_workflow_manifest_whole_step_failure_gets_step_tag(monkeypatch): + """Whole-step failures (no file-level work happened) must be tagged + with 'step' the same way merged per-file failures are, so + format_summary()'s [stepname] prefix works for both cases.""" + from databusclient.manifest.context import ManifestContext + + class FailingStep: + def run(self, step_config, context): + raise RuntimeError("auth failed") + + from databusclient.workflow import steps as steps_module + monkeypatch.setitem(steps_module.STEP_REGISTRY, "deploy", FailingStep) + + manifest_ctx = ManifestContext(command="workflow") + engine = WorkflowEngine(manifest_context=manifest_ctx) + + with pytest.raises(WorkflowExecutionError): + engine.run([{"name": "deploy_with_bad_key", "command": "deploy"}]) + + failed = [f for f in manifest_ctx.files if f["status"] == "failed"] + assert len(failed) == 1 + assert failed[0]["step"] == "deploy_with_bad_key" + assert "auth failed" in failed[0]["error_message"] \ No newline at end of file diff --git a/tests/test_workflow_steps.py b/tests/test_workflow_steps.py index d89c0e5..acba21d 100644 --- a/tests/test_workflow_steps.py +++ b/tests/test_workflow_steps.py @@ -370,4 +370,54 @@ def fake_deploy(dataid, api_key): }, ctx) assert captured["kwargs"]["distributions"] == ["https://example.org/data.ttl"] - assert ctx.get_output("publish", "output_files") == ["https://example.org/data.ttl"] \ No newline at end of file + assert ctx.get_output("publish", "output_files") == ["https://example.org/data.ttl"] + +def test_deploy_step_records_to_manifest_context_on_success(monkeypatch): + from databusclient.manifest.context import ManifestContext + + def fake_create_dataset(**kwargs): + return {"@graph": [{"@id": "fake"}]} + + def fake_deploy(dataid, api_key): + pass + + monkeypatch.setattr("databusclient.workflow.steps.create_dataset", fake_create_dataset) + monkeypatch.setattr("databusclient.workflow.steps.api_deploy_call", fake_deploy) + + manifest_ctx = ManifestContext(command="download") + ctx = StepContext(manifest_context=manifest_ctx) + step = DeployStep() + step.run({ + "name": "publish", "command": "deploy", + "version_id": "https://databus.dbpedia.org/a/b/c/1.0", + "title": "T", "abstract": "A", "description": "D", + "license": "https://license.example.org", "api_key": "key123", + "files": ["https://example.org/data.ttl"], + }, ctx) + + assert len(manifest_ctx.files) == 1 + assert manifest_ctx.files[0]["url"] == "https://example.org/data.ttl" + assert manifest_ctx.files[0]["status"] == "success" + + +def test_deploy_step_does_nothing_when_no_manifest_context(monkeypatch): + """No manifest_context set -- must not crash, just skip recording.""" + def fake_create_dataset(**kwargs): + return {"@graph": [{"@id": "fake"}]} + + def fake_deploy(dataid, api_key): + pass + + monkeypatch.setattr("databusclient.workflow.steps.create_dataset", fake_create_dataset) + monkeypatch.setattr("databusclient.workflow.steps.api_deploy_call", fake_deploy) + + ctx = StepContext() + step = DeployStep() + step.run({ + "name": "publish", "command": "deploy", + "version_id": "https://databus.dbpedia.org/a/b/c/1.0", + "title": "T", "abstract": "A", "description": "D", + "license": "https://license.example.org", "api_key": "key123", + "files": ["https://example.org/data.ttl"], + }, ctx) + # No assertion needed beyond "did not raise" \ No newline at end of file From 3d704fe3ba1c0306785e2e2cf0b366bc336b4d51 Mon Sep 17 00:00:00 2001 From: Dhanashree Petare Date: Mon, 31 Aug 2026 14:06:35 +0530 Subject: [PATCH 8/9] GSoC2026 : Docs Refactoring (Issue #86) (#87) * docs: restructure documentation per issue #86 - slim README, doc/ folder for CLI/module/examples usage * docs:refactoring * docs: remove trailing whitespace * docs: restore manifest and workflow CLI documentation * docs: align CLI guide with current commands * docs: fix CLI help examples and compression wording * GSoC section refactoring in README.md * fix: review comments * fix: final fixes * fix restored previous work and applied refactoring * Crpytic chars fixed, test data added --------- Co-authored-by: DhanashreePetare --- README.md | 799 +----------------- databusclient/cli.py | 2 +- doc/README.md | 7 + doc/cli-usage.md | 467 ++++++++++ .../examples}/reproducible-download.md | 0 .../examples}/workflows/README.md | 2 +- .../workflows/batch-deployment-with-retry.yml | 0 .../examples}/workflows/ci-cd-integration.yml | 0 .../examples}/workflows/download-delete.yml | 0 .../examples}/workflows/download-deploy.yml | 0 .../examples}/workflows/failure-debugging.yml | 0 .../examples}/workflows/full-pipeline.yml | 0 .../workflows/nightly-publishing-pipeline.yml | 0 .../reproducible-research-download.yml | 0 doc/gsoc-2026/README.md | 15 + doc/gsoc-2026/proposal_DhanashreePetare.pdf | Bin 0 -> 472107 bytes doc/module-usage.md | 82 ++ .../manual/run_all_conversion_tests.py | 19 +- tests/resources/base.csv | 6 + tests/resources/base.nq | 3 + tests/resources/base.ttl | 8 + 21 files changed, 620 insertions(+), 790 deletions(-) create mode 100644 doc/README.md create mode 100644 doc/cli-usage.md rename {examples => doc/examples}/reproducible-download.md (100%) rename {examples => doc/examples}/workflows/README.md (93%) rename {examples => doc/examples}/workflows/batch-deployment-with-retry.yml (100%) rename {examples => doc/examples}/workflows/ci-cd-integration.yml (100%) rename {examples => doc/examples}/workflows/download-delete.yml (100%) rename {examples => doc/examples}/workflows/download-deploy.yml (100%) rename {examples => doc/examples}/workflows/failure-debugging.yml (100%) rename {examples => doc/examples}/workflows/full-pipeline.yml (100%) rename {examples => doc/examples}/workflows/nightly-publishing-pipeline.yml (100%) rename {examples => doc/examples}/workflows/reproducible-research-download.yml (100%) create mode 100644 doc/gsoc-2026/README.md create mode 100644 doc/gsoc-2026/proposal_DhanashreePetare.pdf create mode 100644 doc/module-usage.md rename run_all_conversion_tests.py => tests/manual/run_all_conversion_tests.py (94%) create mode 100644 tests/resources/base.csv create mode 100644 tests/resources/base.nq create mode 100644 tests/resources/base.ttl diff --git a/README.md b/README.md index 27ac911..421eed8 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ Command-line and Python client for downloading and deploying datasets on DBpedia Databus. - ## Table of Contents + - [Quickstart](#quickstart) - [Python](#python) - [Docker](#docker) @@ -19,16 +19,12 @@ Command-line and Python client for downloading and deploying datasets on DBpedia - [Deploy](#cli-deploy) - [Delete](#cli-delete) - [Manifest](#cli-manifest) - - [Replay](#cli-manifest-replay) - - [Summary](#cli-manifest-summary) - [Workflow](#cli-workflow) - [Module Usage](#module-usage) - - [Deploy](#module-deploy) - [Development & Contributing](#development--contributing) - [Linting](#linting) - [Testing](#testing) - ## Quickstart The client supports two main workflows: downloading datasets from the Databus and deploying datasets to the Databus. Below you can choose how to run it (Python or Docker), then follow the sections on [DBpedia downloads](#dbpedia-knowledge-graphs), [CLI usage](#cli-usage), or [module usage](#module-usage). @@ -37,7 +33,7 @@ You can use either **Python** or **Docker**. Both methods support all client fea ### Python -Requirements: [Python 3.11+](https://www.python.org/downloads/) and [pip](https://pip.pypa.io/en/stable/installation/) +Requirements: [Python 3.11+](https://www.python.org/downloads/) and [pip](https://pip.pypa.io/en/stable/installation/). Before using the client, install it via pip: @@ -55,21 +51,19 @@ You can then use the client in the command line: ```bash databusclient --help -databusclient deploy --help -databusclient delete --help -databusclient download --help +databusclient [delete|deploy|download|manifest|workflow] --help ``` ### Docker -Requirements: [Docker](https://docs.docker.com/get-docker/) +Requirements: [Docker](https://docs.docker.com/get-docker/). ```bash docker run --rm -v $(pwd):/data dbpedia/databus-python-client --help -docker run --rm -v $(pwd):/data dbpedia/databus-python-client deploy --help -docker run --rm -v $(pwd):/data dbpedia/databus-python-client download --help ``` +The same Docker invocation pattern can be used for the commands documented in the [download](doc/cli-usage.md#cli-download), [deploy](doc/cli-usage.md#cli-deploy), [delete](doc/cli-usage.md#cli-delete), [manifest](doc/cli-usage.md#cli-manifest), and [workflow](doc/cli-usage.md#cli-workflow) sections. + ## DBpedia Commands to download the [DBpedia Knowledge Graphs](#dbpedia-knowledge-graphs) generated by Live Fusion. DBpedia Live Fusion publishes two kinds of KGs: @@ -81,831 +75,78 @@ Commands to download the [DBpedia Knowledge Graphs](#dbpedia-knowledge-graphs) g To download BUSL 1.1 licensed datasets, you need to register and get an access token. -1. If you do not have a DBpedia Account yet (Forum/Databus), please register at [https://account.dbpedia.org](https://account.dbpedia.org) +1. If you do not have a DBpedia Account yet (Forum/Databus), please register at [https://account.dbpedia.org](https://account.dbpedia.org). 2. Log in at [https://account.dbpedia.org](https://account.dbpedia.org) and create your token. 3. Save the token to a file, e.g. `vault-token.dat`. ### DBpedia Knowledge Graphs #### Download Live Fusion KG Dump (BUSL 1.1, registration needed) -High-frequency, conflict-resolved knowledge graph that merges Live Wikipedia and Wikidata signals into a single, queryable dump for enterprise consumption. [More information](https://databus.dbpedia.org/dbpedia-enterprise/live-fusion-kg-dump) + +High-frequency, conflict-resolved knowledge graph that merges Live Wikipedia and Wikidata signals into a single, queryable dump for enterprise consumption. [More information](https://databus.dbpedia.org/dbpedia-enterprise/live-fusion-kg-dump). + ```bash -# Python databusclient download https://databus.dbpedia.org/dbpedia-enterprise/live-fusion-kg-dump --vault-token vault-token.dat -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client download https://databus.dbpedia.org/dbpedia-enterprise/live-fusion-kg-dump --vault-token vault-token.dat ``` #### Download Enriched Knowledge Graphs (BUSL 1.1, registration needed) **DBpedia Wikipedia Extraction Enriched** -DBpedia-based enrichment of structured Wikipedia extractions (currently EN DBpedia only). [More information](https://databus.dbpedia.org/dbpedia-enterprise/dbpedia-wikipedia-kg-enriched-dump) +DBpedia-based enrichment of structured Wikipedia extractions, currently EN DBpedia only. [More information](https://databus.dbpedia.org/dbpedia-enterprise/dbpedia-wikipedia-kg-enriched-dump). ```bash -# Python databusclient download https://databus.dbpedia.org/dbpedia-enterprise/dbpedia-wikipedia-kg-enriched-dump --vault-token vault-token.dat -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client download https://databus.dbpedia.org/dbpedia-enterprise/dbpedia-wikipedia-kg-enriched-dump --vault-token vault-token.dat ``` #### Download DBpedia Wikipedia Knowledge Graphs (CC-BY-SA, no registration needed) -Original extraction of structured Wikipedia data before enrichment. [More information](https://databus.dbpedia.org/dbpedia/dbpedia-wikipedia-kg-dump) +Original extraction of structured Wikipedia data before enrichment. [More information](https://databus.dbpedia.org/dbpedia/dbpedia-wikipedia-kg-dump). ```bash -# Python databusclient download https://databus.dbpedia.org/dbpedia/dbpedia-wikipedia-kg-dump -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client download https://databus.dbpedia.org/dbpedia/dbpedia-wikipedia-kg-dump ``` #### Download DBpedia Wikidata Knowledge Graphs (CC-BY-SA, no registration needed) -Original extraction of structured Wikidata data before enrichment. [More information](https://databus.dbpedia.org/dbpedia/dbpedia-wikidata-kg-dump) +Original extraction of structured Wikidata data before enrichment. [More information](https://databus.dbpedia.org/dbpedia/dbpedia-wikidata-kg-dump). ```bash -# Python databusclient download https://databus.dbpedia.org/dbpedia/dbpedia-wikidata-kg-dump -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client download https://databus.dbpedia.org/dbpedia/dbpedia-wikidata-kg-dump ``` ## CLI Usage -To get started with the command-line interface (CLI) of the databus-python-client, you can use either the Python installation or the Docker image. The examples below show both methods. - -**Help and further general information:** - -```bash -# Python -databusclient --help -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client --help - -# Output: -Usage: databusclient [OPTIONS] COMMAND [ARGS]... - - Databus Client CLI - -Options: - --help Show this message and exit. - -Commands: - delete Delete a dataset from the databus. - deploy Flexible deploy to Databus command supporting three modes: - download Download datasets from databus, optionally using vault access... - manifest Manifest utilities. -``` +The command-line interface provides commands for downloading, deploying, and deleting datasets, as well as recording manifests and running declarative workflows. Detailed command documentation, options, examples, manifest operations, and workflow syntax are available in the [download](doc/cli-usage.md#cli-download), [deploy](doc/cli-usage.md#cli-deploy), [delete](doc/cli-usage.md#cli-delete), [manifest](doc/cli-usage.md#cli-manifest), and [workflow](doc/cli-usage.md#cli-workflow) sections. ### Download -With the download command, you can download datasets or parts thereof from the Databus. The download command expects one or more Databus URIs or a SPARQL query as arguments. The URIs can point to files, versions, artifacts, groups, or collections. If a SPARQL query is provided, the query must return download URLs from the Databus which will be downloaded. - -```bash -# Python -databusclient download $DOWNLOADTARGET -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client download $DOWNLOADTARGET -``` - -- `$DOWNLOADTARGET` - - Can be any Databus URI including collections OR SPARQL query (or several thereof). -- `--localdir` - - If no `--localdir` is provided, the current working directory is used as base directory `./$ACCOUNT/$GROUP/$ARTIFACT/$VERSION/`. If `--localdir` is provided, it is used as the base directory for the same Databus layout, i.e. `$LOCALDIR/$ACCOUNT/$GROUP/$ARTIFACT/$VERSION/`. -- `--vault-token` - - If the dataset/files to be downloaded require vault authentication, you need to provide a vault token with `--vault-token /path/to/vault-token.dat`. See [Registration (Access Token)](#registration-access-token) for details on how to get a vault token. - - Note: Vault tokens are only required for certain protected Databus hosts (for example: `data.dbpedia.io`, `data.dev.dbpedia.link`). The client now detects those hosts and will fail early with a clear message if a token is required but not provided. Do not pass `--vault-token` for public downloads. -- `--databus-key` - - If the databus is protected and needs API key authentication, you can provide the API key with `--databus-key YOUR_API_KEY`. -- `--compression` - - Enables on-the-fly compression format conversion during download. Supported formats: `bz2`, `gz`, `xz`, `none`. The source compression is auto-detected from the file extension. Use `none` to decompress files without recompressing. Example: `--compression gz` converts all downloaded compressed files to gzip format. -- `--format` - - Enables on-the-fly RDF and tabular format conversion during download (Layer 2 and Layer 3). Supported formats: `ntriples` (`nt`), `turtle` (`ttl`), `rdf-xml` (`rdf`, `xml`), `nquads` (`nq`), `trig`, `trix`, `json-ld` (`jsonld`), `csv`, `tsv`. Short aliases shown in brackets. Only the converted output file is kept — the original is deleted after successful conversion. Within the same equivalence class (e.g. turtle to ntriples) conversion is lossless. Across classes (e.g. RDF to CSV) some flags below may be required. -- `--graph-name` - - Required when converting RDF triples to a quad format (e.g. turtle to nquads). Assigns all triples to the specified named graph URI. Example: `--format nquads --graph-name https://example.org/mygraph`. -- `--base-uri` - - Required when converting CSV/TSV to RDF triples. Used as the base for constructing subject URIs from CSV row identifiers. Example: `--format ntriples --base-uri https://example.org/data/`. -- `--validate-checksum` - - Validates the checksums of downloaded files against the checksums provided by the Databus. If a checksum does not match, an error is raised and the file is deleted. - -**Help and further information on download command:** -```bash -# Python -databusclient download --help -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client download --help - -# Output: -Usage: databusclient download [OPTIONS] DATABUSURIS... - - Download datasets from databus, optionally using vault access if vault - options are provided. Supports on-the-fly compression format conversion - using --convert-to and --convert-from options. - -Options: - --localdir TEXT Base directory for the local Databus folder - structure (if not given, current working - directory is used) - --databus TEXT Databus URL (if not given, inferred from - databusuri, e.g. - https://databus.dbpedia.org/sparql) - --vault-token TEXT Path to Vault refresh token file - --databus-key TEXT Databus API key to download from protected - databus - --all-versions When downloading artifacts, download all - versions instead of only the latest - --authurl TEXT Keycloak token endpoint URL [default: https://a - uth.dbpedia.org/realms/dbpedia/protocol/openid- - connect/token] - --clientid TEXT Client ID for token exchange [default: vault- - token-exchange] - --convert-to [bz2|gz|xz] Target compression format for on-the-fly - conversion during download (supported: bz2, gz, - xz) - --convert-from [bz2|gz|xz] Source compression format to convert from - (optional filter). Only files with this - compression will be converted. - --validate-checksum Validate checksums of downloaded files - --help Show this message and exit. -``` - -#### Examples of using the download command - -**Download File**: download of a single file -```bash -# Python -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 -``` - -**Download Version**: download of all files of a specific version -```bash -# Python -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 -``` - -**Download Artifact**: download of all files with the latest version of an artifact -```bash -# Python -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals -``` - -**Download Group**: download of all files with the latest version of all artifacts of a group -```bash -# Python -databusclient download https://databus.dbpedia.org/dbpedia/mappings -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client download https://databus.dbpedia.org/dbpedia/mappings -``` - -**Download Collection**: download of all files within a collection -```bash -# Python -databusclient download https://databus.dbpedia.org/dbpedia/collections/dbpedia-snapshot-2022-12 -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client download https://databus.dbpedia.org/dbpedia/collections/dbpedia-snapshot-2022-12 -``` - -**Download Query**: download of all files returned by a query (SPARQL endpoint must be provided with `--databus`) -```bash -# Python -databusclient download 'PREFIX dcat: SELECT ?x WHERE { ?sub dcat:downloadURL ?x . } LIMIT 10' --databus https://databus.dbpedia.org/sparql -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client download 'PREFIX dcat: SELECT ?x WHERE { ?sub dcat:downloadURL ?x . } LIMIT 10' --databus https://databus.dbpedia.org/sparql -``` - -**Download with Compression Conversion**: download files and convert compression format on-the-fly. Source compression is auto-detected from the file extension. -```bash -# Convert all compressed files to gzip format -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --compression gz - -# Download a collection and unify all files to bz2 format -databusclient download https://databus.dbpedia.org/dbpedia/collections/dbpedia-snapshot-2022-12 --compression bz2 - -# Decompress files without recompressing -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --compression none -``` - -**Download with Format Conversion**: download files and convert RDF or tabular format on-the-fly. Only the converted output file is kept. -```bash -# Convert RDF/XML to Turtle -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format turtle - -# Convert N-Quads to TriG (within quad equivalence class) -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --format trig - -# Convert RDF to CSV (cross-class, produces companion .meta.json) -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format csv - -# Combine format conversion and compression -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format ntriples --compression gz -``` - -**Download with Mapping Conversion (Layer 3)**: convert across format classes — between RDF triples, RDF quads, and tabular data. -```bash -# RDF Triples -> RDF Quads (requires --graph-name) -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format nquads --graph-name https://example.org/mygraph - -# RDF Quads -> RDF Triples (splits into one file per named graph, in a subdirectory) -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.nq --format turtle - -# RDF Triples -> CSV (produces a companion .meta.json preserving datatypes/language tags) -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format csv - -# CSV -> RDF Triples (requires --base-uri; lossless if companion .meta.json is present) -databusclient download https://databus.dbpedia.org/dbpedia/some-tabular-dataset/2022.12.01/data.csv --format ntriples --base-uri https://example.org/data/ - -# RDF Quads -> CSV (adds a 'graph' column) -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.nq --format csv -``` +The `download` command retrieves Databus files, versions, artifacts, groups, collections, or SPARQL query results. It supports authentication, checksum validation, compression conversion, and RDF or tabular format conversion. See the [download docs](doc/cli-usage.md#cli-download). ### Deploy -With the deploy command, you can deploy datasets to the Databus. The deploy command supports three modes: -1. Classic dataset deployment via list of distributions -2. Metadata-based deployment via metadata JSON file -3. Upload & deploy via Nextcloud/WebDAV - -```bash -# Python -databusclient deploy [OPTIONS] [DISTRIBUTIONS]... -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client deploy [OPTIONS] [DISTRIBUTIONS]... -``` - -**Help and further information on deploy command:** -```bash -# Python -databusclient deploy --help -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client deploy --help - -# Output: -Usage: databusclient deploy [OPTIONS] [DISTRIBUTIONS]... - - Flexible deploy to Databus command supporting three modes: - - - Classic deploy (distributions as arguments) - - - Metadata-based deploy (--metadata ) - - - Upload & deploy via Nextcloud (--webdav-url, --remote, --path) - -Options: - --version-id TEXT Target databus version/dataset identifier of the form [required] - --title TEXT Artifact & Version Title: used for BOTH artifact and - version. Keep stable across releases; identifies the - data series. [required] - --abstract TEXT Artifact & Version Abstract: used for BOTH artifact and - version (max 200 chars). Updating it changes both - artifact and version metadata. [required] - --description TEXT Artifact & Version Description: used for BOTH artifact - and version. Supports Markdown. Updating it changes both - artifact and version metadata. [required] - --license TEXT License (see dalicc.net) [required] - --apikey TEXT API key [required] - --metadata PATH Path to metadata JSON file (for metadata mode) - --webdav-url TEXT WebDAV URL (e.g., - https://cloud.example.com/remote.php/webdav) - --remote TEXT rclone remote name (e.g., 'nextcloud') - --path TEXT Remote path on Nextcloud (e.g., 'datasets/mydataset') - --help Show this message and exit. -``` - -### Mode 1: Classic Deploy (Distributions) - -```bash -# Python -databusclient deploy \ ---version-id https://databus.dbpedia.org/user1/group1/artifact1/2022-05-18 \ ---title "Client Testing" \ ---abstract "Testing the client...." \ ---description "Testing the client...." \ ---license http://dalicc.net/licenselibrary/AdaptivePublicLicense10 \ ---apikey MYSTERIOUS \ -'https://raw.githubusercontent.com/dbpedia/databus/master/server/app/api/swagger.yml|type=swagger' -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client deploy \ ---version-id https://databus.dbpedia.org/user1/group1/artifact1/2022-05-18 \ ---title "Client Testing" \ ---abstract "Testing the client...." \ ---description "Testing the client...." \ ---license http://dalicc.net/licenselibrary/AdaptivePublicLicense10 \ ---apikey MYSTERIOUS \ -'https://raw.githubusercontent.com/dbpedia/databus/master/server/app/api/swagger.yml|type=swagger -``` -A few more notes for CLI usage: - -- The content variants can be left out ONLY IF there is just one distribution - - For complete inferred: Just use the URL with `https://raw.githubusercontent.com/dbpedia/databus/master/server/app/api/swagger.yml` - - If other parameters are used, you need to leave them empty like `https://raw.githubusercontent.com/dbpedia/databus/master/server/app/api/swagger.yml||yml|7a751b6dd5eb8d73d97793c3c564c71ab7b565fa4ba619e4a8fd05a6f80ff653:367116` - - -### Mode 2: Deploy with Metadata File - -Use a JSON metadata file to define all distributions. -The metadata.json should list all distributions and their metadata. -All files referenced there will be registered on the Databus. -```bash -# Python -databusclient deploy \ - --metadata ./metadata.json \ - --version-id https://databus.dbpedia.org/user1/group1/artifact1/1.0 \ - --title "Metadata Deploy Example" \ - --abstract "This is a short abstract of the dataset." \ - --description "This dataset was uploaded using metadata.json." \ - --license https://dalicc.net/licenselibrary/Apache-2.0 \ - --apikey "API-KEY" -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client deploy \ - --metadata ./metadata.json \ - --version-id https://databus.dbpedia.org/user1/group1/artifact1/1.0 \ - --title "Metadata Deploy Example" \ - --abstract "This is a short abstract of the dataset." \ - --description "This dataset was uploaded using metadata.json." \ - --license https://dalicc.net/licenselibrary/Apache-2.0 \ - --apikey "API-KEY" -``` -Example `metadata.json` metadata file structure (`file_format` and `compression` are optional): -```json -[ - { - "checksum": "0929436d44bba110fc7578c138ed770ae9f548e195d19c2f00d813cca24b9f39", - "size": 12345, - "url": "https://cloud.example.com/remote.php/webdav/datasets/mydataset/example.ttl", - "file_format": "ttl" - }, - { - "checksum": "2238acdd7cf6bc8d9c9963a9f6014051c754bf8a04aacc5cb10448e2da72c537", - "size": 54321, - "url": "https://cloud.example.com/remote.php/webdav/datasets/mydataset/example.csv.gz", - "file_format": "csv", - "compression": "gz" - } -] -``` - -### Mode 3: Upload & Deploy via Nextcloud - -Upload local files or folders to a WebDAV/Nextcloud instance and automatically deploy to DBpedia Databus. [Rclone](https://rclone.org/) is required. - -```bash -# Python -databusclient deploy \ - --webdav-url https://cloud.example.com/remote.php/webdav \ - --remote nextcloud \ - --path datasets/mydataset \ - --version-id https://databus.dbpedia.org/user1/group1/artifact1/1.0 \ - --title "Test Dataset" \ - --abstract "Short abstract of dataset" \ - --description "This dataset was uploaded for testing the Nextcloud → Databus pipeline." \ - --license https://dalicc.net/licenselibrary/Apache-2.0 \ - --apikey "API-KEY" \ - ./localfile1.ttl \ - ./data_folder -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client deploy \ - --webdav-url https://cloud.example.com/remote.php/webdav \ - --remote nextcloud \ - --path datasets/mydataset \ - --version-id https://databus.dbpedia.org/user1/group1/artifact1/1.0 \ - --title "Test Dataset" \ - --abstract "Short abstract of dataset" \ - --description "This dataset was uploaded for testing the Nextcloud → Databus pipeline." \ - --license https://dalicc.net/licenselibrary/Apache-2.0 \ - --apikey "API-KEY" \ - ./localfile1.ttl \ - ./data_folder -``` +The `deploy` command publishes datasets using distribution arguments, metadata JSON files, or WebDAV/Nextcloud uploads. See the [deploy docs](doc/cli-usage.md#cli-deploy). ### Delete -With the delete command you can delete collections, groups, artifacts, and versions from the Databus. Deleting files is not supported via API. - -**Note**: Deleting datasets will recursively delete all data associated with the dataset below the specified level. Please use this command with caution. As security measure, the delete command will prompt you for confirmation before proceeding with any deletion. - -```bash -# Python -databusclient delete [OPTIONS] DATABUSURIS... -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client delete [OPTIONS] DATABUSURIS... -``` - -**Help and further information on delete command:** -```bash -# Python -databusclient delete --help -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client delete --help - -# Output: -Usage: databusclient delete [OPTIONS] DATABUSURIS... - - Delete a dataset from the databus. - - Delete a group, artifact, or version identified by the given databus URI. - Will recursively delete all data associated with the dataset. - -Options: - --databus-key TEXT Databus API key to access protected databus [required] - --dry-run Perform a dry run without actual deletion - --force Force deletion without confirmation prompt - --help Show this message and exit. -``` - -To authenticate the delete request, you need to provide an API key with `--databus-key YOUR_API_KEY`. - -If you want to perform a dry run without actual deletion, use the `--dry-run` option. This will show you what would be deleted without making any changes. - -As security measure, the delete command will prompt you for confirmation before proceeding with the deletion. If you want to skip this prompt, you can use the `--force` option. - -#### Examples of using the delete command - -**Delete Version**: delete a specific version -```bash -# Python -databusclient delete https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --databus-key YOUR_API_KEY -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client delete https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --databus-key YOUR_API_KEY -``` - -**Delete Artifact**: delete an artifact and all its versions -```bash -# Python -databusclient delete https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals --databus-key YOUR_API_KEY -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client delete https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals --databus-key YOUR_API_KEY -``` - -**Delete Group**: delete a group and all its artifacts and versions -```bash -# Python -databusclient delete https://databus.dbpedia.org/dbpedia/mappings --databus-key YOUR_API_KEY -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client delete https://databus.dbpedia.org/dbpedia/mappings --databus-key YOUR_API_KEY -``` - -**Delete Collection**: delete collection -```bash -# Python -databusclient delete https://databus.dbpedia.org/dbpedia/collections/dbpedia-snapshot-2022-12 --databus-key YOUR_API_KEY -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client delete https://databus.dbpedia.org/dbpedia/collections/dbpedia-snapshot-2022-12 --databus-key YOUR_API_KEY -``` +The `delete` command removes Databus versions, artifacts, groups, or collections and provides dry-run and confirmation safeguards. See the [delete docs](doc/cli-usage.md#cli-delete). ### Manifest -All three commands support an optional `--manifest` flag that writes a structured JSON-LD record of the operation to disk: - -**Download** -```bash -# Python -databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --manifest ./manifests/download-run.jsonld -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --manifest ./manifests/download-run.jsonld -``` - -**Deploy** -```bash -# Python -databusclient deploy \ - --version-id https://databus.dbpedia.org/user1/group1/artifact1/2022-05-18 \ - --title "Client Testing" --abstract "Testing the client...." \ - --description "Testing the client...." \ - --license http://dalicc.net/licenselibrary/AdaptivePublicLicense10 \ - --apikey YOUR_KEY --manifest ./manifests/deploy-run.jsonld \ - 'https://raw.githubusercontent.com/dbpedia/databus/master/server/app/api/swagger.yml|type=swagger' -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client deploy \ - --version-id https://databus.dbpedia.org/user1/group1/artifact1/2022-05-18 \ - --title "Client Testing" --abstract "Testing the client...." \ - --description "Testing the client...." \ - --license http://dalicc.net/licenselibrary/AdaptivePublicLicense10 \ - --apikey YOUR_KEY --manifest ./manifests/deploy-run.jsonld \ - 'https://raw.githubusercontent.com/dbpedia/databus/master/server/app/api/swagger.yml|type=swagger' -``` -**Delete** -```bash -# Python -databusclient delete https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --databus-key YOUR_API_KEY --manifest ./manifests/delete-run.jsonld -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client delete https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --databus-key YOUR_API_KEY --manifest ./manifests/delete-run.jsonld -``` - -The manifest records input parameters, per-file URLs, checksums, byte sizes, timestamps, and success/failure status for each file. It uses the DataID vocabulary and is versioned via `dbus:schemaVersion`. - -- If the target path already exists, the manifest is written to an auto-suffixed path (e.g. `run_1.jsonld`) with a warning. -- Sensitive fields (API keys, vault tokens) are never written. -- If manifest writing fails, a warning is printed and the exit code reflects the actual operation result. -- If the operation itself fails, a `dbus:operationError` block is recorded in the manifest capturing the error type, message, and traceback. - -Refer [examples/reproducible-download.md](examples/reproducible-download.md) for a full walkthrough. - - -#### Replay - -Any manifest written with `--manifest` can be replayed later using `databusclient manifest replay `. Replay re-executes the original operation using the parameters recorded in the manifest — you don't need to remember or retype the original command. - -```bash -# Python -databusclient manifest replay [OPTIONS] MANIFEST_PATH -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client manifest replay [OPTIONS] MANIFEST_PATH -``` - -**Important:** credentials are never stored in the manifest and must always be supplied fresh at replay time — `--vault-token`, `--databus-key`, and `--apikey` behave exactly as they do on the original commands. - -```bash -databusclient manifest replay --help - -# Output: -Usage: databusclient manifest replay [OPTIONS] MANIFEST_PATH - - Replay a previously recorded manifest operation. - - Currently supports replay of download, delete, and deploy manifests. - For delete manifests, an interactive confirmation is required by - default -- use --force to skip it for scripted/unattended use, or - --dry-run to preview without prompting or deleting. - -Options: - --localdir TEXT Override local output directory for download replay. - --databus TEXT Override Databus endpoint for replay. - --vault-token TEXT Vault token file path, required if manifest auth - method is vault_token. - --databus-key TEXT Databus API key, required if manifest auth method is - databus_key. Also required for delete replay. - --apikey TEXT Databus API key, required for deploy replay. - --force For delete replay: skip the interactive confirmation - prompt. Required for unattended/scripted replay. - --dry-run For delete replay: force a dry-run preview even if - the original operation wasn't one. - --help Show this message and exit. -``` - -**Replaying a download:** -```bash -databusclient manifest replay ./manifests/download-run.jsonld --localdir ./replayed-data -``` -If `--localdir` is omitted, replay falls back to the same auto-computed folder structure a fresh download would use — this is not necessarily the same folder the original download used, since the original folder location itself is never stored in the manifest. - -**Replaying a delete:** by default, replay asks for confirmation before deleting, exactly like a normal `delete` call: -```bash -databusclient manifest replay ./manifests/delete-run.jsonld --databus-key YOUR_API_KEY -# About to replay a DELETE operation for the following 1 URI(s): -# - https://databus.dbpedia.org/... -# This is irreversible. Proceed? [y/N]: -``` -For unattended/scripted use (e.g. CI/CD), skip the prompt with `--force`: -```bash -databusclient manifest replay ./manifests/delete-run.jsonld --databus-key YOUR_API_KEY --force -``` -If the original delete was run with `--dry-run --manifest ...`, replay automatically previews without deleting — no flag needed. You can also force a preview on a manifest that wasn't originally a dry run: -```bash -databusclient manifest replay ./manifests/delete-run.jsonld --databus-key YOUR_API_KEY --dry-run -``` - -**Replaying a deploy:** supported for classic (distributions-as-arguments) and metadata-file deploys. The manifest stores fully-resolved deployment metadata (checksums, sizes, formats already computed), so replay never re-downloads or re-hashes the original files: -```bash -databusclient manifest replay ./manifests/deploy-run.jsonld --apikey YOUR_API_KEY -``` -Replaying redeploys the same version — if it already exists on Databus, it is updated. WebDAV/Nextcloud deploys cannot be replayed, since the originally uploaded local files may no longer exist at their original paths by the time replay runs. - - -#### Summary - -Print a readable summary of any recorded manifest without replaying it: - -```bash -# Python -databusclient manifest summary ./manifests/download-run.jsonld -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client manifest summary ./manifests/download-run.jsonld -``` - -Example output: - -``` -Command : download -Executed : 2024-03-24T10:02:49.500418+00:00 -Endpoint : https://databus.dbpedia.org/sparql -Auth : vault_token -Files : 1 succeeded · 0 failed -Total : 100.0 MB -Status : completed -``` - -Only existing data already stored in the manifest is read — no new files are downloaded or written, and no network access happens. +The manifest options record operation parameters, file outcomes, checksums, byte sizes, and execution summaries in JSON-LD. Manifests can also be replayed or summarized. See the [manifest docs](doc/cli-usage.md#cli-manifest). ### Workflow -The workflow command runs a multi-step pipeline of `download`, `deploy`, and `delete` operations defined in a YAML file. Steps run in order, and a later step can use the output of an earlier step — for example, deploying the exact file a previous step just downloaded. - -```bash -# Python -databusclient workflow run [OPTIONS] WORKFLOW_PATH -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client workflow run [OPTIONS] WORKFLOW_PATH -``` - -**Help and further information on the workflow command:** -```bash -# Python -databusclient workflow run --help -# Docker -docker run --rm -v $(pwd):/data dbpedia/databus-python-client workflow run --help - -# Output: -Usage: databusclient workflow run [OPTIONS] WORKFLOW_PATH - - Run a declarative workflow pipeline from a YAML file. - - Executes each step in order, chaining outputs between steps via - ${steps.name.output_files}-style references, and applying each step's - on_error behavior (fail/continue/retry). - -Options: - --help Show this message and exit. -``` - -#### Workflow YAML format - -A workflow file has a top-level `steps:` list. Each step needs a unique `name` and a `command` (`download`, `deploy`, or `delete`), plus fields specific to that command. - -```yaml -steps: - - name: fetch_dataset - command: download - uri: https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 - localdir: ./data - - - name: publish_dataset - command: deploy - version_id: https://databus.dbpedia.org/myaccount/research/labels/2024.01 - title: "Processed Labels" - abstract: "Processed from DBpedia 2023.12.01" - description: "Converted and redeployed labels dataset" - license: https://creativecommons.org/licenses/by-sa/3.0/ - api_key: ${DATABUS_API_KEY} - files: ${steps.fetch_dataset.output_urls} - on_error: fail -``` - -**Environment variables:** any value written as `${VARIABLE_NAME}` is resolved from the environment when the workflow starts. If the variable is not set, the workflow fails immediately with a clear error before any step runs — credentials should always be passed this way, never written directly in the file. - -**Step chaining:** a step's outputs can be referenced by later steps using `${steps.step_name.output_key}`: -- `${steps.name.output_files}` — local file paths produced by a `download` step. -- `${steps.name.output_urls}` — the actual, redirect-resolved source URL(s) the file was downloaded from, useful for redeploying an unmodified file via classic deploy mode. - -#### Deploy step modes within a workflow - -A `deploy` step supports the same modes as the `deploy` CLI command: - -- **Classic mode** (`files:` is a list of URLs) — use `${steps.name.output_urls}` to redeploy a file exactly as it was downloaded, unmodified. Classic mode does not accept local file paths; if `files:` contains anything other than a `http://`/`https://` URL, the step fails with a clear error rather than crashing. -- **WebDAV mode** (`webdav_url:`, `remote:`, `path:` all provided) — use `${steps.name.output_files}` (local paths) here. The step uploads the local files to the WebDAV server first, then deploys the resulting URLs. This is the only way to deploy a file that was locally modified during the workflow (e.g. via `--format`/`--compression` on the download step), since only WebDAV mode re-establishes a real, fetchable URL for locally changed content. - -```yaml - - name: publish_converted_dataset - command: deploy - version_id: https://databus.dbpedia.org/myaccount/research/labels/2024.01 - title: "Processed Labels" - abstract: "Processed from DBpedia 2023.12.01" - description: "Converted and redeployed labels dataset" - license: https://creativecommons.org/licenses/by-sa/3.0/ - api_key: ${DATABUS_API_KEY} - webdav_url: https://cloud.example.com/remote.php/webdav - remote: nextcloud - path: datasets/mydataset - files: ${steps.fetch_dataset.output_files} -``` - -#### Error handling - -Each step declares an `on_error` behavior (defaults to `fail` if not set): - -| Mode | Behavior | -|---|---| -| `fail` | Stop the entire workflow immediately if this step fails. | -| `continue` | Log the failure and move on to the next step anyway. | -| `retry` | Retry the step up to `max_attempts` times, waiting `delay_seconds` between attempts. If all attempts fail, the workflow stops. | - -```yaml - - name: fetch_dataset - command: download - uri: https://databus.dbpedia.org/... - on_error: retry - retry: - max_attempts: 3 - delay_seconds: 5 -``` - -A retry re-runs the entire step from scratch, not just the part that failed. - -**Delete steps never prompt for confirmation inside a workflow** — since workflows are meant to run unattended, a `delete` step always behaves as if `--force` was passed. - -#### Examples - -Full working example files are available under [`examples/workflows/`](examples/workflows/): -- `download-deploy.yml` — download a file, then redeploy it (classic mode). -- `download-delete.yml` — download a file, then delete an old version. -- `full-pipeline.yml` — download, deploy, and delete chained together in one run. - -```bash -databusclient workflow run examples/workflows/download-deploy.yml -``` +The `workflow` command runs declarative download, deploy, and delete pipelines from YAML files, with step chaining and per-step error handling. See the [workflow docs](doc/cli-usage.md#cli-workflow). ## Module Usage - -### Deploy - -#### Step 1: Create lists of distributions for the dataset - -```python -from databusclient import create_distribution - -# create a list -distributions = [] - -# minimal requirements -# compression and filetype will be inferred from the path -# this will trigger the download of the file to evaluate the shasum and content length -distributions.append( - create_distribution(url="https://raw.githubusercontent.com/dbpedia/databus/master/server/app/api/swagger.yml", cvs={"type": "swagger"}) -) - -# full parameters -# will just place parameters correctly, nothing will be downloaded or inferred -distributions.append( - create_distribution( - url="https://example.org/some/random/file.csv.bz2", - cvs={"type": "example", "realfile": "false"}, - file_format="csv", - compression="bz2", - sha256_length_tuple=("7a751b6dd5eb8d73d97793c3c564c71ab7b565fa4ba619e4a8fd05a6f80ff653", 367116) - ) -) -``` - -A few notes: - -* The dict for content variants can be empty ONLY IF there is just one distribution -* There can be no compression if there is no file format - -#### Step 2: Create dataset - -```python -from databusclient import create_dataset - -# minimal way -dataset = create_dataset( - version_id="https://dev.databus.dbpedia.org/denis/group1/artifact1/2022-05-18", - title="Client Testing", - abstract="Testing the client....", - description="Testing the client....", - license_url="http://dalicc.net/licenselibrary/AdaptivePublicLicense10", - distributions=distributions, -) - -# with group metadata -dataset = create_dataset( - version_id="https://dev.databus.dbpedia.org/denis/group1/artifact1/2022-05-18", - title="Client Testing", - abstract="Testing the client....", - description="Testing the client....", - license_url="http://dalicc.net/licenselibrary/AdaptivePublicLicense10", - distributions=distributions, - group_title="Title of group1", - group_abstract="Abstract of group1", - group_description="Description of group1" -) -``` - -NOTE: Group metadata is applied only if all group parameters are set. - -#### Step 3: Deploy to Databus - -```python -from databusclient import deploy - -# to deploy something you just need the dataset from the previous step and an API key -# API key can be found (or generated) at https://$$DATABUS_BASE$$/$$USER$$#settings -deploy(dataset, "mysterious API key") -``` +The Python API exposes helpers for creating distributions and datasets and for deploying them programmatically. See the [module usage docs](doc/module-usage.md). ## Development & Contributing diff --git a/databusclient/cli.py b/databusclient/cli.py index 2ab118f..26ba973 100644 --- a/databusclient/cli.py +++ b/databusclient/cli.py @@ -333,7 +333,7 @@ def download( ): """ Download datasets from databus, optionally using vault access if vault options are provided. - Supports on-the-fly compression format conversion using --convert-to and --convert-from options. + Supports on-the-fly compression format conversion using the --compression option. """ # Determine auth method for manifest (never store the token itself) auth_method = None diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..be454a3 --- /dev/null +++ b/doc/README.md @@ -0,0 +1,7 @@ +# Documentation + +- [CLI Usage](cli-usage.md#cli-download) - Complete command-line documentation for download, deploy, delete, manifests, and workflows. Use the section links in the same file for download, deploy, delete, manifest, and workflow details. +- [Module Usage](module-usage.md) - Python API examples for creating distributions, datasets, and deployments. +- [Reproducible Download](examples/reproducible-download.md) - Record and replay a download with a JSON-LD manifest. +- [Workflow Examples](examples/workflows/README.md) - Example download, deploy, delete, and manifest workflows. +- [GSoC 2026](gsoc-2026/README.md) - Project overview and proposal. \ No newline at end of file diff --git a/doc/cli-usage.md b/doc/cli-usage.md new file mode 100644 index 0000000..2487630 --- /dev/null +++ b/doc/cli-usage.md @@ -0,0 +1,467 @@ +## CLI Usage + +To get started with the command-line interface (CLI) of the databus-python-client, you can use either the Python installation or the Docker image. The examples below show python installation method. + +**Help and further general information:** + +```bash +databusclient --help +databusclient [delete|deploy|download|manifest|workflow] --help +``` + + +### Download + +With the download command, you can download datasets or parts thereof from the Databus. The download command expects one or more Databus URIs or a SPARQL query as arguments. The URIs can point to files, versions, artifacts, groups, or collections. If a SPARQL query is provided, the query must return download URLs from the Databus which will be downloaded. + +```bash +databusclient download $DOWNLOADTARGET +``` + +- `$DOWNLOADTARGET` + - Can be any Databus URI including collections OR SPARQL query (or several thereof). +- `--localdir` + - If no `--localdir` is provided, the current working directory is used as base directory `./$ACCOUNT/$GROUP/$ARTIFACT/$VERSION/`. If `--localdir` is provided, it is used as the base directory for the same Databus layout, i.e. `$LOCALDIR/$ACCOUNT/$GROUP/$ARTIFACT/$VERSION/`. +- `--vault-token` + - If the dataset/files to be downloaded require vault authentication, you need to provide a vault token with `--vault-token /path/to/vault-token.dat`. See [Registration (Access Token)](#registration-access-token) for details on how to get a vault token. + + Note: Vault tokens are only required for certain protected Databus hosts (for example: `data.dbpedia.io`, `data.dev.dbpedia.link`). The client now detects those hosts and will fail early with a clear message if a token is required but not provided. Do not pass `--vault-token` for public downloads. +- `--databus-key` + - If the databus is protected and needs API key authentication, you can provide the API key with `--databus-key YOUR_API_KEY`. +- `--all-versions` + - When downloading artifacts, downloads all versions instead of only the latest. +- `--compression` + - Enables on-the-fly compression format conversion during download. Supported formats: `bz2`, `gz`, `xz`, `none`. The source compression is auto-detected from the file extension. Use `none` to decompress files without recompressing. Example: `--compression gz` converts all downloaded compressed files to gzip format. +- `--format` + - Enables on-the-fly RDF and tabular format conversion during download (Layer 2 and Layer 3). Supported formats: `ntriples` (`nt`), `turtle` (`ttl`), `rdf-xml` (`rdf`, `xml`), `nquads` (`nq`), `trig`, `trix`, `json-ld` (`jsonld`), `csv`, `tsv`. Short aliases shown in brackets. Only the converted output file is kept — the original is deleted after successful conversion. Within the same equivalence class (e.g. turtle to ntriples) conversion is lossless. Across classes (e.g. RDF to CSV) some flags below may be required. +- `--graph-name` + - Required when converting RDF triples to a quad format (e.g. turtle to nquads). Assigns all triples to the specified named graph URI. Example: `--format nquads --graph-name https://example.org/mygraph`. +- `--base-uri` + - Required when converting CSV/TSV to RDF triples. Used as the base for constructing subject URIs from CSV row identifiers. Example: `--format ntriples --base-uri https://example.org/data/`. +- `--validate-checksum` + - Validates the checksums of downloaded files against the checksums provided by the Databus. If a checksum does not match, an error is raised and the file is deleted. + +**Help and further information on download command:** +```bash +databusclient download --help +``` + +#### Examples of using the download command + +**Download File**: download of a single file +```bash +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 +``` + +**Download Version**: download of all files of a specific version +```bash +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 +``` + +**Download Artifact**: download of all files with the latest version of an artifact +```bash +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals +``` + +**Download Group**: download of all files with the latest version of all artifacts of a group +```bash +databusclient download https://databus.dbpedia.org/dbpedia/mappings +``` + +**Download Collection**: download of all files within a collection +```bash +databusclient download https://databus.dbpedia.org/dbpedia/collections/dbpedia-snapshot-2022-12 +``` + +**Download Query**: download of all files returned by a query (SPARQL endpoint must be provided with `--databus`) +```bash +databusclient download 'PREFIX dcat: SELECT ?x WHERE { ?sub dcat:downloadURL ?x . } LIMIT 10' --databus https://databus.dbpedia.org/sparql +``` + +**Download with Compression Conversion**: download files and convert compression format on-the-fly. Source compression is auto-detected from the file extension. +```bash +# Convert all compressed files to gzip format +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --compression gz + +# Decompress files without recompressing +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --compression none + +# Download a collection and unify all files to bz2 format +databusclient download https://databus.dbpedia.org/dbpedia/collections/dbpedia-snapshot-2022-12 --compression bz2 +``` + +**Download with Format Conversion**: download files and convert RDF or tabular format on-the-fly. Only the converted output file is kept. +```bash +# Convert RDF/XML to Turtle +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format turtle + +# Convert N-Quads to TriG (within quad equivalence class) +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --format trig + +# Convert RDF to CSV (cross-class, produces companion .meta.json) +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format csv + +# Combine format conversion and compression +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format ntriples --compression gz +``` + +**Download with Mapping Conversion (Layer 3)**: convert across format classes — between RDF triples, RDF quads, and tabular data. +```bash +# RDF Triples -> RDF Quads (requires --graph-name) +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format nquads --graph-name https://example.org/mygraph + +# RDF Quads -> RDF Triples (splits into one file per named graph, in a subdirectory) +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.nq --format turtle + +# RDF Triples -> CSV (produces a companion .meta.json preserving datatypes/language tags) +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --format csv + +# CSV -> RDF Triples (requires --base-uri; lossless if companion .meta.json is present) +databusclient download https://databus.dbpedia.org/dbpedia/some-tabular-dataset/2022.12.01/data.csv --format ntriples --base-uri https://example.org/data/ + +# RDF Quads -> CSV (adds a 'graph' column) +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.nq --format csv +``` + + +### Deploy + +With the deploy command, you can deploy datasets to the Databus. The deploy command supports three modes: +1. Classic dataset deployment via list of distributions +2. Metadata-based deployment via metadata JSON file +3. Upload & deploy via Nextcloud/WebDAV + +```bash +databusclient deploy [OPTIONS] [DISTRIBUTIONS]... +``` +- `--version-id` + - Target Databus version identifier, e.g. `https://databus.dbpedia.org/$ACCOUNT/$GROUP/$ARTIFACT/$VERSION`. Required. +- `--title`, `--abstract`, `--description` + - Used for both the artifact and the version metadata. Updating them updates both. Required. +- `--license` + - License URL (see [dalicc.net](https://dalicc.net)). Required. +- `--apikey` + - Databus API key. Required. +- `--metadata` + - Path to a metadata JSON file, for metadata-based deploy (Mode 2). +- `--webdav-url`, `--remote`, `--path` + - WebDAV/Nextcloud URL, rclone remote name, and remote path, for upload-and-deploy (Mode 3). + +**Help and further information on deploy command:** +```bash +databusclient deploy --help +``` + +### Mode 1: Classic Deploy (Distributions) + +```bash +databusclient deploy \ +--version-id https://databus.dbpedia.org/user1/group1/artifact1/2022-05-18 \ +--title "Client Testing" \ +--abstract "Testing the client...." \ +--description "Testing the client...." \ +--license http://dalicc.net/licenselibrary/AdaptivePublicLicense10 \ +--apikey MYSTERIOUS \ +'https://raw.githubusercontent.com/dbpedia/databus/master/server/app/api/swagger.yml|type=swagger' +``` +A few more notes for CLI usage: + +- The content variants can be left out ONLY IF there is just one distribution + - For complete inferred: Just use the URL with `https://raw.githubusercontent.com/dbpedia/databus/master/server/app/api/swagger.yml` + - If other parameters are used, you need to leave them empty like `https://raw.githubusercontent.com/dbpedia/databus/master/server/app/api/swagger.yml||yml|7a751b6dd5eb8d73d97793c3c564c71ab7b565fa4ba619e4a8fd05a6f80ff653:367116` + +### Mode 2: Deploy with Metadata File + +Use a JSON metadata file to define all distributions. +The metadata.json should list all distributions and their metadata. +All files referenced there will be registered on the Databus. +```bash +databusclient deploy \ + --metadata ./metadata.json \ + --version-id https://databus.dbpedia.org/user1/group1/artifact1/1.0 \ + --title "Metadata Deploy Example" \ + --abstract "This is a short abstract of the dataset." \ + --description "This dataset was uploaded using metadata.json." \ + --license https://dalicc.net/licenselibrary/Apache-2.0 \ + --apikey "API-KEY" +``` +Example `metadata.json` metadata file structure (`file_format` and `compression` are optional): +```json +[ + { + "checksum": "0929436d44bba110fc7578c138ed770ae9f548e195d19c2f00d813cca24b9f39", + "size": 12345, + "url": "https://cloud.example.com/remote.php/webdav/datasets/mydataset/example.ttl", + "file_format": "ttl" + }, + { + "checksum": "2238acdd7cf6bc8d9c9963a9f6014051c754bf8a04aacc5cb10448e2da72c537", + "size": 54321, + "url": "https://cloud.example.com/remote.php/webdav/datasets/mydataset/example.csv.gz", + "file_format": "csv", + "compression": "gz" + } +] +``` + +### Mode 3: Upload & Deploy via Nextcloud + +Upload local files or folders to a WebDAV/Nextcloud instance and automatically deploy to DBpedia Databus. [Rclone](https://rclone.org/) is required. + +```bash +databusclient deploy \ + --webdav-url https://cloud.example.com/remote.php/webdav \ + --remote nextcloud \ + --path datasets/mydataset \ + --version-id https://databus.dbpedia.org/user1/group1/artifact1/1.0 \ + --title "Test Dataset" \ + --abstract "Short abstract of dataset" \ + --description "This dataset was uploaded for testing the Nextcloud → Databus pipeline." \ + --license https://dalicc.net/licenselibrary/Apache-2.0 \ + --apikey "API-KEY" \ + ./localfile1.ttl \ + ./data_folder +``` + + +### Delete + +With the delete command you can delete collections, groups, artifacts, and versions from the Databus. Deleting files is not supported via API. + +**Note**: Deleting datasets will recursively delete all data associated with the dataset below the specified level. Please use this command with caution. As security measure, the delete command will prompt you for confirmation before proceeding with any deletion. + +```bash +databusclient delete [OPTIONS] DATABUSURIS... +``` + +**Help and further information on delete command:** +```bash +databusclient delete --help +``` + +To authenticate the delete request, you need to provide an API key with `--databus-key YOUR_API_KEY`. + +If you want to perform a dry run without actual deletion, use the `--dry-run` option. This will show you what would be deleted without making any changes. + +As security measure, the delete command will prompt you for confirmation before proceeding with the deletion. If you want to skip this prompt, you can use the `--force` option. + +#### Examples of using the delete command + +**Delete Version**: delete a specific version +```bash +databusclient delete https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --databus-key YOUR_API_KEY +``` + +**Delete Artifact**: delete an artifact and all its versions +```bash +databusclient delete https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals --databus-key YOUR_API_KEY +``` + +**Delete Group**: delete a group and all its artifacts and versions +```bash +databusclient delete https://databus.dbpedia.org/dbpedia/mappings --databus-key YOUR_API_KEY +``` + +**Delete Collection**: delete collection +```bash +databusclient delete https://databus.dbpedia.org/dbpedia/collections/dbpedia-snapshot-2022-12 --databus-key YOUR_API_KEY +``` + + +### Manifest + +All three commands support an optional `--manifest` flag that writes a structured JSON-LD record of the operation to disk: + +**Download** +```bash +databusclient download https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 --manifest ./manifests/download-run.jsonld +``` + +**Deploy** +```bash +databusclient deploy \ + --version-id https://databus.dbpedia.org/user1/group1/artifact1/2022-05-18 \ + --title "Client Testing" --abstract "Testing the client...." \ + --description "Testing the client...." \ + --license http://dalicc.net/licenselibrary/AdaptivePublicLicense10 \ + --apikey YOUR_KEY --manifest ./manifests/deploy-run.jsonld \ + 'https://raw.githubusercontent.com/dbpedia/databus/master/server/app/api/swagger.yml|type=swagger' +``` +**Delete** +```bash +databusclient delete https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01 --databus-key YOUR_API_KEY --manifest ./manifests/delete-run.jsonld +``` + +The manifest records input parameters, per-file URLs, checksums, byte sizes, timestamps, and success/failure status for each file. It uses the DataID vocabulary and is versioned via `dbus:schemaVersion`. + +- If the target path already exists, the manifest is written to an auto-suffixed path (e.g. `run_1.jsonld`) with a warning. +- Sensitive fields (API keys, vault tokens) are never written. +- If manifest writing fails, a warning is printed and the exit code reflects the actual operation result. +- If the operation itself fails, a `dbus:operationError` block is recorded in the manifest capturing the error type, message, and traceback. + +Refer [examples/reproducible-download.md](examples/reproducible-download.md) for a full walkthrough. + + +#### Replay + +Any manifest written with `--manifest` can be replayed later using `databusclient manifest replay `. Replay re-executes the original operation using the parameters recorded in the manifest — you don't need to remember or retype the original command. + +```bash +databusclient manifest replay [OPTIONS] MANIFEST_PATH +``` + +**Important:** credentials are never stored in the manifest and must always be supplied fresh at replay time — `--vault-token`, `--databus-key`, and `--apikey` behave exactly as they do on the original commands. + +```bash +databusclient manifest replay --help +``` + +**Replaying a download:** +```bash +databusclient manifest replay ./manifests/download-run.jsonld --localdir ./replayed-data +``` +If `--localdir` is omitted, replay falls back to the same auto-computed folder structure a fresh download would use — this is not necessarily the same folder the original download used, since the original folder location itself is never stored in the manifest. + +**Replaying a delete:** by default, replay asks for confirmation before deleting, exactly like a normal `delete` call: +```bash +databusclient manifest replay ./manifests/delete-run.jsonld --databus-key YOUR_API_KEY +# About to replay a DELETE operation for the following 1 URI(s): +# - https://databus.dbpedia.org/... +# This is irreversible. Proceed? [y/N]: +``` +For unattended/scripted use (e.g. CI/CD), skip the prompt with `--force`: +```bash +databusclient manifest replay ./manifests/delete-run.jsonld --databus-key YOUR_API_KEY --force +``` +If the original delete was run with `--dry-run --manifest ...`, replay automatically previews without deleting — no flag needed. You can also force a preview on a manifest that wasn't originally a dry run: +```bash +databusclient manifest replay ./manifests/delete-run.jsonld --databus-key YOUR_API_KEY --dry-run +``` + +**Replaying a deploy:** supported for classic (distributions-as-arguments) and metadata-file deploys. The manifest stores fully-resolved deployment metadata (checksums, sizes, formats already computed), so replay never re-downloads or re-hashes the original files: +```bash +databusclient manifest replay ./manifests/deploy-run.jsonld --apikey YOUR_API_KEY +``` +Replaying redeploys the same version — if it already exists on Databus, it is updated. WebDAV/Nextcloud deploys cannot be replayed, since the originally uploaded local files may no longer exist at their original paths by the time replay runs. + + +#### Summary + +Print a readable summary of any recorded manifest without replaying it: + +```bash +databusclient manifest summary ./manifests/download-run.jsonld +``` + +Example output: + +``` +Command : download +Executed : 2024-03-24T10:02:49.500418+00:00 +Endpoint : https://databus.dbpedia.org/sparql +Auth : vault_token +Files : 1 succeeded · 0 failed +Total : 100.0 MB +Status : completed +``` + +Only existing data already stored in the manifest is read — no new files are downloaded or written, and no network access happens. + + + +### Workflow + +The workflow command runs a multi-step pipeline of `download`, `deploy`, and `delete` operations defined in a YAML file. Steps run in order, and a later step can use the output of an earlier step — for example, deploying the exact file a previous step just downloaded. + +```bash +databusclient workflow run [OPTIONS] WORKFLOW_PATH +``` + +**Help and further information on the workflow command:** +```bash +databusclient workflow run --help +``` + +#### Workflow YAML format + +A workflow file has a top-level `steps:` list. Each step needs a unique `name` and a `command` (`download`, `deploy`, or `delete`), plus fields specific to that command. + +```yaml +steps: + - name: fetch_dataset + command: download + uri: https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=az.ttl.bz2 + localdir: ./data + + - name: publish_dataset + command: deploy + version_id: https://databus.dbpedia.org/myaccount/research/labels/2024.01 + title: "Processed Labels" + abstract: "Processed from DBpedia 2023.12.01" + description: "Converted and redeployed labels dataset" + license: https://creativecommons.org/licenses/by-sa/3.0/ + api_key: ${DATABUS_API_KEY} + files: ${steps.fetch_dataset.output_urls} + on_error: fail +``` + +**Environment variables:** any value written as `${VARIABLE_NAME}` is resolved from the environment when the workflow starts. If the variable is not set, the workflow fails immediately with a clear error before any step runs — credentials should always be passed this way, never written directly in the file. + +**Step chaining:** a step's outputs can be referenced by later steps using `${steps.step_name.output_key}`: +- `${steps.name.output_files}` — local file paths produced by a `download` step. +- `${steps.name.output_urls}` — the actual, redirect-resolved source URL(s) the file was downloaded from, useful for redeploying an unmodified file via classic deploy mode. + +#### Deploy step modes within a workflow + +A `deploy` step supports the same modes as the `deploy` CLI command: + +- **Classic mode** (`files:` is a list of URLs) — use `${steps.name.output_urls}` to redeploy a file exactly as it was downloaded, unmodified. Classic mode does not accept local file paths; if `files:` contains anything other than a `http://`/`https://` URL, the step fails with a clear error rather than crashing. +- **WebDAV mode** (`webdav_url:`, `remote:`, `path:` all provided) — use `${steps.name.output_files}` (local paths) here. The step uploads the local files to the WebDAV server first, then deploys the resulting URLs. This is the only way to deploy a file that was locally modified during the workflow (e.g. via `--format`/`--compression` on the download step), since only WebDAV mode re-establishes a real, fetchable URL for locally changed content. + +```yaml + - name: publish_converted_dataset + command: deploy + version_id: https://databus.dbpedia.org/myaccount/research/labels/2024.01 + title: "Processed Labels" + abstract: "Processed from DBpedia 2023.12.01" + description: "Converted and redeployed labels dataset" + license: https://creativecommons.org/licenses/by-sa/3.0/ + api_key: ${DATABUS_API_KEY} + webdav_url: https://cloud.example.com/remote.php/webdav + remote: nextcloud + path: datasets/mydataset + files: ${steps.fetch_dataset.output_files} +``` + +#### Error handling + +Each step declares an `on_error` behavior (defaults to `fail` if not set): + +| Mode | Behavior | +|---|---| +| `fail` | Stop the entire workflow immediately if this step fails. | +| `continue` | Log the failure and move on to the next step anyway. | +| `retry` | Retry the step up to `max_attempts` times, waiting `delay_seconds` between attempts. If all attempts fail, the workflow stops. | + +```yaml + - name: fetch_dataset + command: download + uri: https://databus.dbpedia.org/... + on_error: retry + retry: + max_attempts: 3 + delay_seconds: 5 +``` + +A retry re-runs the entire step from scratch, not just the part that failed. + +**Delete steps never prompt for confirmation inside a workflow** — since workflows are meant to run unattended, a `delete` step always behaves as if `--force` was passed. + +#### Examples + +Full working example files are available under [`examples/workflows/`](examples/workflows/) — see the [Workflow Examples README](examples/workflows/README.md) for all eight, covering download/deploy/delete chaining, checksum-validated reproducible downloads, unattended nightly pipelines, batch deploys with retry, CI/CD-safe workflows, and failure-debugging output. + +```bash +databusclient workflow run doc/examples/workflows/download-deploy.yml +``` + diff --git a/examples/reproducible-download.md b/doc/examples/reproducible-download.md similarity index 100% rename from examples/reproducible-download.md rename to doc/examples/reproducible-download.md diff --git a/examples/workflows/README.md b/doc/examples/workflows/README.md similarity index 93% rename from examples/workflows/README.md rename to doc/examples/workflows/README.md index ccc7c8f..6ce065f 100644 --- a/examples/workflows/README.md +++ b/doc/examples/workflows/README.md @@ -25,4 +25,4 @@ databusclient workflow run download-deploy.yml Workflows can write a unified manifest covering every step in two ways: pass `--manifest path.jsonld` on the command line, or set a top-level `manifest:` key inside the YAML file itself (the command-line flag takes priority if both are given). Several of the examples above use the YAML key. Every manifest file entry that came from a workflow step is tagged with `dbus:stepName`, so a multi-step run stays traceable to which step produced or failed on which file. -See the main [README's Workflow section](../../README.md#cli-workflow) for the full YAML format, step chaining, error handling, and WebDAV deploy mode documentation. +See the [CLI usage documentation](../../cli-usage.md#cli-workflow) for the full YAML format, step chaining, error handling, and WebDAV deploy mode documentation. diff --git a/examples/workflows/batch-deployment-with-retry.yml b/doc/examples/workflows/batch-deployment-with-retry.yml similarity index 100% rename from examples/workflows/batch-deployment-with-retry.yml rename to doc/examples/workflows/batch-deployment-with-retry.yml diff --git a/examples/workflows/ci-cd-integration.yml b/doc/examples/workflows/ci-cd-integration.yml similarity index 100% rename from examples/workflows/ci-cd-integration.yml rename to doc/examples/workflows/ci-cd-integration.yml diff --git a/examples/workflows/download-delete.yml b/doc/examples/workflows/download-delete.yml similarity index 100% rename from examples/workflows/download-delete.yml rename to doc/examples/workflows/download-delete.yml diff --git a/examples/workflows/download-deploy.yml b/doc/examples/workflows/download-deploy.yml similarity index 100% rename from examples/workflows/download-deploy.yml rename to doc/examples/workflows/download-deploy.yml diff --git a/examples/workflows/failure-debugging.yml b/doc/examples/workflows/failure-debugging.yml similarity index 100% rename from examples/workflows/failure-debugging.yml rename to doc/examples/workflows/failure-debugging.yml diff --git a/examples/workflows/full-pipeline.yml b/doc/examples/workflows/full-pipeline.yml similarity index 100% rename from examples/workflows/full-pipeline.yml rename to doc/examples/workflows/full-pipeline.yml diff --git a/examples/workflows/nightly-publishing-pipeline.yml b/doc/examples/workflows/nightly-publishing-pipeline.yml similarity index 100% rename from examples/workflows/nightly-publishing-pipeline.yml rename to doc/examples/workflows/nightly-publishing-pipeline.yml diff --git a/examples/workflows/reproducible-research-download.yml b/doc/examples/workflows/reproducible-research-download.yml similarity index 100% rename from examples/workflows/reproducible-research-download.yml rename to doc/examples/workflows/reproducible-research-download.yml diff --git a/doc/gsoc-2026/README.md b/doc/gsoc-2026/README.md new file mode 100644 index 0000000..b56237a --- /dev/null +++ b/doc/gsoc-2026/README.md @@ -0,0 +1,15 @@ +# GSoC 2026 + +Hi, I'm Dhanashree Petare ([GitHub](https://github.com/DhanashreePetare)), and I contributed to this project as part of Google Summer of Code 2026, under the DBpedia organization. + +My project extended the Databus Python Client with reproducible, workflow-aware data operations, delivered across five milestones: + +1. **Format and Mapping Conversion Layer** - RDF triple, RDF quad, and tabular format conversion during download, bringing the Python client to feature parity with the Java client. [Download docs](../cli-usage.md#cli-download). +2. **Structured Run Manifest System** - JSON-LD manifests recording operation parameters, file metadata, checksums, and execution results for every `download`, `deploy`, and `delete` run. [Manifest docs](../cli-usage.md#cli-manifest). +3. **Manifest Replay and Summary** - re-executing a past operation from its saved manifest, and printing a readable console summary of any manifest. [Replay docs](../cli-usage.md#cli-manifest-replay) and [summary docs](../cli-usage.md#cli-manifest-summary). +4. **Declarative Workflow Engine** - YAML-defined pipelines chaining `download`/`deploy`/`delete` steps, with step-to-step output chaining and per-step error handling (`fail`/`continue`/`retry`). [Workflow docs](../cli-usage.md#cli-workflow). +5. **Workflow-Manifest Integration and Example Workflows** - a unified manifest covering an entire workflow run, an automatic console summary, and example workflows. [Workflow examples](../examples/workflows/README.md). + +My project proposal is available [here](proposal_DhanashreePetare.pdf). + +Thank you. \ No newline at end of file diff --git a/doc/gsoc-2026/proposal_DhanashreePetare.pdf b/doc/gsoc-2026/proposal_DhanashreePetare.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ff6037edb1003579e03792d322cad28d14fef223 GIT binary patch literal 472107 zcmb5UbyQqI^Cp}SBuIeZ?(Ty-L4v!x4Q_+GySux)yAy11cM>dUaCiCUT{*vR&z`e; z_MZF4+&guVB#y6~)9E0Zi-&lnW>Sb`St0EF>UfD+GRiW>rgP8&eV*2^9rV zMphP9P7+2E2@uE}@=go{bfQII7Iic=at1k)(8z&2Ep2R!m^qkONOWm5EbUA{ZcZff zsw4mwCKf&t$O}$(J`#6Mc3oN$VS9TUQw>vN8B1qo4mK_(HcrSVGLoutvJ50Pme!^u z5~e_F5JXVa!VzR^$_!xVWP-E;X<5a{%*fI5e|!W{ujmLeaRHh_^s87~8ZkrqL1GJ$ z{-3}sL3UzB&X5j>@%{%r3mY2%0AS%{<)CL_p@!TLIXRHY|5-#(5Wy7E8HhdD{>v^v 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
7No@!#X(Cjp~vX+&P@*JZbN zecuWU9*dwn+!xEM2KP>0PhigWAm%J5dwzebNfk$w27#|{(Frjof4JRu3$9s0PSZrH zj@2@r(|ksCp2Ft03Q>p(cU7=i0D2hsmFH4qIiN>@rYX{kxc!BW zs;X8+$3%Vh4LJ7qHl!K_sm2k+Ug1AXF!WnYRu<&C1RCJY=`(k-7}AQbM|s-K3p?8P zln$Fss({))6?+ZMy=zD(&E*h}P}54w)=UA8GA3WpEcXY1m~=Obqd~zR*h?5=63(`T z+~6+(ztj0zC=4I=lUW9k4uxX_%4Agi0G0(F1L-4xm02|5AC~a`OPX4cFS|dF&P^83 zD;DN~RBlcpR{qnR_H)*|<A`|CEi7eoOj=VDyu?2TgRD`erDtAt0pKc|! zUIzG0(dBJeR#Wm9CIYOtQ7n|m0b80$(p*gPc znbN-$#Wl_c{Rb0V>#(o?Qb~L>^SrlPR@fX-Au}u0d)?Z+F!JWSCNsUWE%0iuY(W6= z%07B#DZXwsH?{Kq&B^##QDltx^M6P6Q5ODeYQ@bT5*D>;D0+j^b3!G@{~&*pu#noz z`d}IjvkyC|2eaMrz<(NL@Glx$pKeNIZg#`!FK=LWr;tQ+ZY7^OZnpoCw4QG|oPC~Z z<*M)Df>|=k+QT9bi%xzuRUb_eM;mHcAmL&5TMe69c|EVlOEs5LC$g-ts*Z`RHS^4s zUzzsSp(WvtYOo;>rW^QVT_a}@VeUoA?hyP3v!gL$7ZQL=LXXUZx(q!Psr zZYY>BQL6IojaOp%?7cLASt-%U>*2+!ojO!a%`WnnbM#Tgo zS)E(FFv#$_Tj*6J1$ZFj>yAEy@GAM&=%5%qha`Z~NA5p#P3_}`QkY09Wb&oC%Ldep z`e6~ctzW9w#A~B9cK!4NT$iG86OzY&F|e1|SJcDnKj)>X=$Io7luU`3@RLaDw$osf>3<(U@+*1W0OgtCOc~ za+AYW76jE<&#BL&5UmBtO#kL!8L-L@wCSF86h z!c~2pwjiA9!P|iTgQlF}GZ}y@p|lo(T${2*A*R#0w;&7FIC=MLd1fy2I|=QA1vV_Y#s!7bd$)){k%&ydz3My>eM!SKRE)Sd&4)z>t z(!H~1fHt;^hJ~#ir@DQJG_O_D`LMZqW%FxEpxpxCviR%5q;O)iX7@`qtE4d4%CUdi zCOzrw8eDyp1srlb0CBMctXm)c)gdEvMGJze_od>{Ab3c%hkrHD>S#Z_ap~%7!T!2W zq?+CnMw>TG2|D~FI_tZb{%4&%14r6cx6|;$^H*!qg1vdFb#0vc#u~djgJ{?xr%&rz z``luk18b3_Owb~Ue^|Xw`g*vAO6HO%uir6fPZd5)oYN*~AY{2W+>w zDJbhX|COK#tF}w%fgqD;gRc`G3M^rc+p0wwrTqF52HB%!#{RcXV8=_Zx=(-T5>Z(`q%BsdVg)twZh%9&~yQ0AC+3 zpdi-{4uFk!fLGAW_md?B#7m{EW_8~isA~avf(_ zEqW0aiNv^Oavc3^LBO$;_v*y;RPUz6cqb(Iq|fJ&fcNoM`hHU`#Aj~KjNK0iHNWft zjh~x{&4pk6>OOvPdlxn5AhR_Z*}Bsk1c~Q zOBw|)Di%ELMXlpegA+Za)2%?~(XJYT4rRHfwE>`?siAm$4b9X zH+T>-E-S4@w>Ng2^yCNj7!)6PgFE;CB9G&57ppy7BVv0`rZx{Ul; z?U#^|yCgcZ7(aUNWb#vW+Fj;BiklIGei&-MObhED#EOzd3@BqELCYC=dYbpJDeq?c z4I$peEgdd1U%8c&X|}#~)n|&*Y-Jm;1f?*{`yZD>ilR=tL}X3+8cp=AIRB^`H4RBD2WR`L9(Ah zEak8&XF8{{)~U|`R{Phd5N7%R7YY&;P>`9HcCJrH?v^Q|ofX0D#f~pWTS&34m3bBU zjz31CY=>Pz?Tz_6WvLo-2Q9Gd$wi^&Jja)QCTf$tB0_-c%ii(jWH>`?3| z*mhG~a7zoKTUdh^=3EW&*dTZ3I-=p~!XTG4j+drD{^O-%KNuL@VPM~zzq!E5xsppG z;O~)nb}gMo!f8mh6#so8HDd#i`hZ5T-betT?6A4FLn^8E&Fdxu00B7Ud;ZS5O`bs- zBnS`{{5+rlo2y!Yb_1_@|KIqg+3XTW^}?A0$v0#D=l%mCY3Mn^ZYJ$=L+p zn*|WR-3L|cb#I;l+fOzC=DB_pthS?WXS%q^>gPdE2R95`ix3K3w($8wwcW)ZGsnINl zLc@*OL5_*1z){%L5IjQg``QqtJ#xc#r!IEB@pe?7mv*oh6nlU;_s_4aeySS32{~*$ zu|}q`T^f{wvV!8WX;MXYKs8u97t@C3$@us|J7LUuvzf-=`-tmD9SBv!Dnke#Jn^EK zlxLp#a}~5B53LpkA{gV&=0`?los~4&9#PKt-GkGN3u+|6J6&LY5G%(xlNvQM-5|5M z@bkO?QKOM|-1@h(5v7xq7HBQZcl>?O*XeB|g@hI8#b?}Jh0QaxmU;bj-HfXUk8Li-IBwDR7M6hR(>Cxu{oh``}!qo(0$KJv=i$1}Lf8C0SY38! zNAuBW1R55G-dPJ*$cYbt0!v`o$A*i?yVrJx^W2<>YIH|i<5n?(WRHgHNYwUsLQW1X z!oyWg?fm>)RQfM6ycU-c6YF6l!py*Ew!~bmJrquo8!wq(v41hcOUFq+c@3X=zo+=7U;Y&$;ka;Y+>Y*oX2wz*cW%KWXPfFJU zTl|0GJ(~@5{RTahCTg8AQFYN= zpYf+PMHEiI4`H2MT*~*Z$t(EAxeMW(etfz@-f`hrcQps^py2$~cy>F?+@GbXpm-8rnk>2&she+7Iz>->)Sx)Hpe8-pY7$=^o z%m6omV6V{~VGV|6QGK{YS*-arN9l$&b|zQgFiig;bWhgz!exlqIEa7WAxK0BVJUFj zShQd^?Gd=qMYQj?k1?$CKHrLW@}a?6zf_vQBQ6 z+8G4@top(|uu&<*)C1y>R(I5pf$8-tx`2XmZl^Sh9!yy_=YU8W`Hje`x=cc}mUOKA zG)9174gv ze)^^q^Vt#Mg%4Hc4Ed43 zMG$x-2%GW*W36EAv!#BWZ+Dn+c=9pghLq0#Mg?~emp8b^)f4uFZS%(8V^-os1qTCM zUbZwpeWioitOm{h4O2U;GYY52$^Y6eZuVt|v&?~4&w=gdlw-gi%{SrO@UqauJ$~a# zS<0t7UTh042t((_AEd_zF0!6h*x8|B=Bjo%7BY@917O;)*)?3YoU-)vD>cnVX+>%a zaeb&;{r;!Lrzex5&~t-77G{<6S%Igi&ro1Yen97`s6l@vxGcylyJ5y!!6J@-w?ut{ zVg0+1WYGr-RtpXUmRS((j9jcn8`#7O=R0NTIBY@|QpkwuV{LdhAmetf=dw*ua{tc2 zXus6w3tP5WQPoz7A$Q6gIX9v04s)X*%h(?W7{kPU=fZj^)w!&{PrqHLD@}Y4LY~8+ zIp%H$ugo5sU=rF)ph9Cek$H_%B!_E`Gf=F`PVqb+Mi}-wXt<>lD}4%cDbd}8tXGzh zb&oQau?SPMea=>~%wFU}NQS_>y>siWwWS|luhub1_*K5!dY*W#`pOG@2N;b;S8I@g zFAyPNTMrvX})03JirCT79bj4VErN#C(jqIGTl;>uv&TS()d26*+l) zn!IN(0EUkiOrpK`OP!h+gGdlXLueu#Xcn?q z*YW#Sw=M!_7q6n+Q7TEks;tb{3~YN|b(C4s!KrxZ0Cvs zXSF4do<=??ZcPV9@t`shTZ+wB&T}D!PJO>9i{KcKvSd{++>X4A0`u-n8X(yBf^OUO zwfin_JSup|V~1@H-^Bc?fmjEIrU&}zF$F-YZI5SQMwkS%5?wezyI^$Vp6&8is$>4q z$ZoI`YnF&B?WOY)X|w*WpN2IyEumb1cWNv(7$RW@?-&3a3}wagTM#=YE^roWXqv2; zJQEp5koWJU$_v7_pR@uy8DP!x;AAGRarh-i-VBHJ+@ZN{Ox<7;!dS8ptbV_5h@ATK zo=bG8iRu0EG<{IOS#MWa^0{mkjEb5ot)b5akDx47`6#pP6Xn(>@zd!eW1+puuSN;9 zrxAJbbP1UJ`zJ8mOeJ?8LmRell$sF*hIggWXQlA#Ac|Aj5AR~OAtyKb=^d)6K#CK@ zPQuFuNxDaqe>y7WJz5@Ry5IIz9SO)48TxuHi44q z7~K6?Nqg3|xg!{~hjhfWUO`6*zW%a#W3km}SE}+28sc#ZFcEKm)#G1l`{7nU248JZ|opglZ2&|eRVg1ia(Eb;mXd#&Y-sV>ly9s;JRg*Z=Pt#VeZ zSw_oJHTkbNcjg;>)$$MGZBgJ#lo9UgLg=scDNg8?3f5?Q@|e3Cq2tq-X_|5+5o|cs zK~+Fex{ZRJ?ap@hVvd3&!WCzAs!w6TGvDO`nsL^HzBKE1SKEy%%_81cvrdnYz&vYK z>fwwU#};kHTaZn9f{=8Ck?fmSUN8h2i?UPOIs{|il#!!=;)IGVlSA>^ zGXJq-h=e)aJ=#k$HbCL8qA1`osj6pBoA#i5P!Bk*GnjWgm(|6ott4u{`7(%|WW<)V zxG9Ib78l?`eSV=!SEMW7USZ5_|JzVUYx=U9&)uVRda!*PBCON`MlICGn+5evn?eno z{8cg4y23(^%eI>f;Ie+tCXKmdBa1JVAGAg1I{s?6{`mO{)RfPz_9Qxx*Jc>FuskaY z$^hU-btf$s4fbDvLqF?ZH^0zwmDvw0k-M9aW16VLqN2`{4@S5R^VofF^Q5s_cqExO zvGMp9576$Xnpu@bbT4cSnD@i<&5_ndR0Y`TYN9Pf{5MElS;DH#-^rEOI`1a=)gG}0ATms_Mk=rSsPDd?7N@C% znW_x{6jBqTz@cOpBmb*h``qbS-rqNVCdsn{{7cK!n>$4y$X&}IH&Jct=@;gxmUxWKWv$d zw4_U!JdE4Qtd35ft38t7We}<&U~E-x?|yBXJ?UTh?XtSV^~=I$96{pDy)w+T=awB_ z!=Umxz3s_0qBDz`t^)jLvBh_)p>K=QZun5V$A_*{XeY{O=8upopI+SL=iV%On&yrR z=fkAWHyrsJcGww93*m&3rQg5Bj%jA>jDdm?L2wz^x1-Fvz<_fNyASc(GZG_iMq;-DlL6MVG;8me zA=v<~ytb=ENrGLyqoI$tHdW1#M-osYSvQ!>49NvE3N~mw+;;cJ4K{bMsXAo}A-+Q; z_(Hb4VYK7**NS!hUr|QMJY0H5J~@!ES@I*shi|YmkJ`_QGOxBjD|ZSR?<%pQz_2Hi z;~+?wbLdjyRb3hEdP&i)1Umdr$>FpHavHwqn{Y@K5psleG(>GL0tD}${6YagA~p8Y z5O#~|-iv#}llNRg$TdS64b{?>MB%aNwH8;p(?@m=;gL>8!bIRv+-hgj*1+_l};*AoFf;H1Kq3DFtmZ4wv0KOtu@ z)cK&&16}MipUpsEXesF3IyWyQY1cTqPkvL3b+L0u)f9DS60Zxw#%3s42czs2Oq?41 zJ;w91%8!FfY?1_{TR;Vi6b7<2Y+Oc6UyjSfYU+TPInuGgjf3#YB zql2<8pp#gU4nl#o61SvUgO%PgMx|8-+P(FT);dp3to}1Dxyl^2c~HLmy|3k-_UR1} z;*LlcnK`~w2&P=|d!VL*m7TH#8+q@#4Y?^*hlgppg(d7mCIv>(kmAk@p7ZLeV)| zsE^Uq!t~b%7i&Qn-8`ZNx{qs_*lg}TB>80%zX^m+M9ZG~nO`qFJ!+4^A^_9+znOm^ z^&qn?4Xnz6%ODU$h>~?JcbE;sT>~rzfZW3ib=o&WHe<*W+PS|^%buMe@QCtoj_l0W zSVT?Tft$+Lyc=Ge^~kWCVN`(m`0l45gG7Coq|qP{V}gsTMSlU1-6KfA^(NO}NfZ|w z6iPx@TfBUR9eguxp(qX{GyzM0yVB4J>1_WsHp_&{zdiqdEqJB51zgvD{8dYUwh(Zr zV9DaBlBN$74Bmf7S=$rC)(1)Rebh%4Svz*U!cdI!6zPc`aL2nbxuA7;HV+wx+XEV| zi)+Z_$<(mpov(OMve33-^OK*UF+X=%G8mI^EK%eExHegw z2cwy-Q*bF$C$-A(b-D;5pFIXuKU2oQ=1r#)_cpA;4h=EQ@?VVEq;}|X&gCB}N%IgV z5Sr=1%F=d5wMFfke#g}5s{N_<^U<63kB{dO7CGw5SD{f2h!<{u%oz9VzHB$Tn#rn@ zPrP~eTo$z=Hlfi7~T_cfp_h9Hj#bytJwJ2cG>l>f(*9Tk-Dmvy0@3U3(#RR?$K&T0#)H9KOq|f| z(GfZ(;nl0-9HdpDq~1Ejdz}K0E$f;vSF?QSk$7TOnXTKMT%NaqM^IGO#9nqoP5!>o z#3o?Kf24OKr)h^lzu6v_2sCO^j&Gi`l7p99iU|~ZIs|M9%=?t{u(B9bM}Sx!?EiL3 z5N+*}9{^Ukz77G_ht1SHvFh&66jm2hWA;_{(-{5isdhtn*zei6=uKYXzB;%P%n(^N zX~wLS$gFu|HB32Nq_m%WU(E!pJo}lMs^QV6LFeu<+(Apvs&r+Vif(xL%F8fABy56; z2OzA3lbd3Utq55eDXeY_IuVXOV*PaWFx^Xkpa`1E$d^xs3s;7(eVD1gvKGlD-Y<1_ zF#*Yk$UMw7w)W|nPnwD7aIqm?bCJPl;VleX&dzJH)~YGo_xk=7O`Q`L@D3$c z=>B_-_+(0VG5)&#yo2NO>-C5GQ-iEPe~&}CR&~?BSF}geOW|Ala{57wgt@^vG;0m1 zg6AA9kJSrExdQ9+WZs?uFC zZB#9a(X{831$JEy9ZOrtw@_84g*H zD1t9UTjobP#=`Hjj@vTo=19M(dn7r@-G}b4bCaH1`6&pY4e*q*nN%6X`-P4iJS8YF z$VtclvrEu_1y4lFS~vG^GtSzN1S;Q2f>fNp6~76ZMLOggC>QEf3n2MkCS9_Nn-Az= zvW^vC5t5_Ew_UxVJ#cPhbD1V}evyhk2i;9~zg1vR88EK=Gqc4IW0d0pFO4WS6yM^=}q-_C^7!| z2ww9l{Nn9|pNdit?Ede7^DlGKW?7ZbP@TU#ysS*7%SqPDDGXIh+p`W@KrUoH&rTKR zlOYuBqzK9y^_it-s9(Q1;!|f)&!i_%uGPb$-gO%SHFYgOB=UZw`~vMdzUBv4sk@YC zA^~I9`|038w*aKb{9IcYfk$cWvRuj3Vmof&%kc4dRFxyweO_OVb}Lu+go`L|U#{|> zF;HIX22kdQ_;l`NV->FzmeQZN7q2Sk$#WM;t2JOMjuGKN_m2 z-z2p{+xZE~R%A>%RjbPM!Al4$a=}0#unxS|p{FbN{~@3>RpRNX^cCyN7L4UHcKTi> zT^N2HElb9ZuKctjOOKYUZd<&<1|izf-odO$95UF=#wyX{vYH%o;LvwL2!2Cs9y+)W z)nvHkh)u+H8@@_&)3%YRnLA}y5dW`DT)ke`n-MM}IeDn=VDCG3YGz9aKS` z+w|;kqV8yPD1(3JKwVl%<-qonh@)o&$)L(s)RIn@JMVlWOXG={8H0>$h%TSPeh6xB zd||r{P5b0I8_Q%ubi(K;;mci9L>O%t+TMQjjN}7OJGW2G)`g)zV8TA9d0=`5cNd%s z_K_8tunWiB)F8OEL&MyVQ-e&7b#pS?HWagfbe9XAM=trg4nXd_Gxi#dE7MdYyR{Dg z=McoQs%c z=t1Ll)HAx(wfV`OP+uKqHql@r-khN2w?8$$UAB}q z{U)siLy845axsob2}I#h9#Qlh&)S!IS1g}%eDc@4DlTLzxgG)qy4|h|ZJrU5`b9#n z4j*rs+c)SRmJtkcu}byY(cRorhfDi+KdC7MUmp|K~V=G>#cvCF8W|18n?)c{3;;ICL7%Bp@gIbi|cY|vKnCmye zfTw-|>UyN`*cGXN0T43kO}@{3l$I06J42TIy*am3Yw{8wXm_C=!77tqJvJA zuGdSQ+ZiNEs_Ar9l>TLx(1t~h2m#aA&j}FxyAd^7wsGdIn_ zki2fx)doe>ctsL1N%J);gxambJ*+dPQg4eklp;?qxnsm4>OAbzR;Fum!i9&;85d4q zxs46?TekXlQDH#AA6<5+;J+g$bflB-TcK0GKzx-F48H>)7NC+ptc~*4iC`u3$V>RA zHY4KZp5=bX`>2&_k+kfC&0QA&3=r1M+v2Pq>N;ce4uX<+a^2@KQEErYbwmk!sa=G5 z!g^)N-NMD;ko08c6`6oU_q!Xg3k_0l*Q3k}7|(uJ@ zeuX@9ZPl_+9?Un2ykD~aL-m?5^PX%o4qy@|l4=*Q2fl#Y0%-<2>c{t{c)Wqi*9X|* z*iZh7cWNh76~IJQ^(F;zb@gYU*z;E5OFcdrNd`NDaNZ7W0BfjNzns)dHQSkp%b9LB z1vT)HS)+3lbJa!SQAg-XH9}&mF#u$=ri|Sq-(`X1KCI3`;H^lHdXU%mvZitQhANWV zV{#c8d1AU1*ZJiFVB&bCnHWQ*MP6QPcH5XFR7Z4~sS?kQ5FUnqPlz39h@*07+v^ih z=@yfGQ;VB-ORa*Lk)N2`j=pvMO$7>IMOVACIM&3^Abq{Ug5TJ7PibXbS1Jg?U?mJd z720yq(O#P)JKtt_(*AT|uzNl=V<-NDp(cS7ERhs+bhAo$m$vDW zA$D~|y6^*FpnR)lhsC?EG+pYhtAZV()X{aTZ;ZHKpk}rH6kG#f)Z>8sZQv8A=~77) zVxhA&bLR-L<%W+)eC1!0}#KMIvv&9R-64 zTE{}~(!JH3FhIJ0q;)@zoIAq2uXq+3s~B-_j%u=fyiop?H&}yHMVn-tfBcB+Pt-WT-Y~oOHrDCG5X(d)%L?nuz))n(9+``qpNO@@MD*ynTmlKL8cp2^TY7jVYSek z< z#D09BR(d0nnQ&%r;b4q8u5>ryi5y*lWR;of?n2yQarv=&C0Xv9V_{fsvHg*>I>^JY zMrchsdFoY9GA(hor^o?N{96So?5yGeeo>I6kJ%p{KSAF2Yu!)g5d=G z>|J3HZvQ)L`%uyd#3)QSc7_JIw`C0?%m#O#oB$r;-<9r7n3sSq9+sz1?+Dz^vz#R` zQ&6Ow0>_(|TObOGi#&(Nm8jN+!w;ql&kA~gj? z?+0-FdUh5`LE(E99N#J3gicUAC_OyBKK@@x|5t_l@Arjl`R;fwz>%p3{y`37NC1c1 z91wJKs)g62nBOpfy%V^lFuR>9%SpOr8TtnWJP>TOXbG}$@tT>McwbIKakJKORoYN* zavOfRCfHfOa1IK~fKB4u4iQna!>0D>bEIbi5O~bWU>h`niE<(`^PmCq%su<2K{cU9 z@}cl$kcN`MAxgN?Nc{|rgSeUy$5kjtQpo|4`gK#o!zd#ps+;t5XxDdp_zYn?uw_1-3C$P@G{^MBD}AI_WIr zdXahw_|@$}{)tQy$~GicSnVQCPuvGx3ZF$$C=r_@s;)Qbgw<_B&0OW%1|7-N91<1> zv(ts88qw5>`oc0dAQ7-6M&?*M^}HKKg#EhR@yfWl_jmMiFnPc z^(47|<*8OlN-AHhLAN^t`ReQIX|{^MG)aqi?9rciKX7AJN-Eh&ev@*h^Q5=$2qn3_ zwr#l}(xLutb7rFFVzzh%5H(TM6Z=0ZC&I2%cWb-Wx_WBj_rrXBN8e6n&kQ=$b0rim zB&}NjQRXG_TJg#6pHy_bCrNhnFM0JUH)PsBKr?whhqWBNQzIS=Z7hFo6e!Qrt;BOZ zZr~FjaGj- zVAOUna7_`#Oq|^Tre}ss>v}a=T#Ma}Jw?S;bwz?~bu_ET3m9s8)?}bx$Wh|8tCj20y+mDp%_F?Nm z%XRdy)dI$FqEKYx+&f>_^%AQE4z}Z#NqW^>G+P<0Tc2TO7VUEs~}MbdBL zy1q$soS2WKo&im6w_clJ4!Okdr2;^dBP=?~(1SEY?c7I$zDdJQ%oxtAljq_DnMaibx?WJVE<>3G}tIR$TzxSxswY# zvp{+N`F+*PFE@Un4?t*TOyXH$J0lJM7s_Bm?rPD^I8A3tQr0z!3RwOU9c%)z?()ZQ z-Pc#%TD-ahB)6BX{SavhtO>pB79tR(l;lp3bl(c|a_v;IM=fegx{Dp|S;ETl zeXGDSX>aaV>ro6_w_N-fWyL9S*ZU~>;JV?--s1jUM>7>|O84D5SiNCTpEXHE2P>@J?3B|xb z!OtFAB^s*%W|`%)-d{V0|H0WpT}{sVAt-F8KViG_(^SC!v|8qnS{db}%wv6=^n7xj zz~^0m+Fk$i%m;bR_pA~%>3SlyrIZd_f8l=UCiXR*4YA<-4g*mCSLo5oFFLM(i@ZMg ze1EyIxB>w$bw@ZJZ2$4vwrxC!mMqW;Ef-EVYIIMyI`Moo2J`& z+!s(Z2>+Gs`0~L&MKaeB9IJF*E9_lGLM$W&`s*z@?1ABJJ~s}WG)}r&x0$ZK1@?VL z*LcDcz)LVk3zYcO0TAR`nXWeMo$Z4p)IdlJKq2RvGFVW+o zBMglazlYxN8xSIF=!U9cfmLlnYz4CpQABK!IO`yT;@Clou-V=SJZu(egy=Ip)AcCr z!pqzXVxMV$737{q@33DL=chlLb2JyE&XXm>^X)qcuyWpTX2e~i8BN(=DrEF+0_NIM z+0@4wy8>cT_4gK2Iy0&XDxcDG*brz!qxbZcrl0|u_XjN>z~h6milslWdOdNrKmCo( zDT@M~>n3k*(&B68kek!TOZM5d$tvaIgz@)fX+qY$yE)bZfkbRyXjo9PPu{V00_MA` zjW=J=+pNN08%HLDP>*G!yJWHDly-3Lo%T@L9QuXhrRp637jugn)gtHdW7adFUOK*K z5447UP!TY5A4X@lrI*&8F+`t{H_+E)??Ny< zUgRkiY}I=?@m*BL?woAs9MZ-JT}Hd(z`pO*sme*u^zUcjz2tf8Jwu~3)x_^?&q>|D zhMiAXDY1<}-j5BTw(R0WACSqUyfZ6`+RYl`=2J5=$=tVKwC9lQZW+7nR+q}W? zSu5vFb4!lKW`J1m+P#oG$$w2j=j`%uPM0)6(NzM_fJ4yT`POHX^StPTqnBQpRks)BRdVTG1Oi_BFWsXY}*Q`K06g2JObZu=+ z*9xa+(0(n=+v=JkYSpKQ z0@nM&%R=J&b0I`0H00@`XM1NLXCLVhJa+Sm)?BvSXR1NLx(+Bv+B;H%gt8(d^c24F z^1auk@P2XB{rU`f8&vF;(hj;zrZg82v6Ur@6y5Gv5g`_d#oC`i7sn=?r+QVR&LWpi z%e{=ML)_+>ccWJCI{rxoC;eG=*SPye?SmlNUh<^`N|Ts1ls=WuKgvt0nJl-maA zxD0XjNgIfB*FND6wqWf}LsVxw@s1w6RYcW(hKDd< z(;hT$BLE#_xf=iKBUC~v8N{Y!JuCpfQKHl{LJ8DWutMFi7T)mt^u8ul5kFpzS)|m^=E`Apd zMKE4N4er{mb!kMh26F2xOu)-END;L(p0N3u>93Dn-cHnLo<~-_n5^te+v{tpwE1Yi z%jhIGrlWx3jc53u)G+h-Rj4E6^03zH*+Z&wo2tV8aURWhXY@ zB&MReG_F}dTy|861jF%~_8&j12!noiU!d>vM_M6rahi>_`X0P z&$*m0h=etJonazerFZN~b!1psGsDboa6*RRESK`0zb3iOVRiaV<5=aW3;Gk0x0`@; z7q*!|=(%whn65o_8J}r;a}HRA0ha;6zKrGJD^hkm8uMFsA^$sKFK-1J+@Bh#)P=qvTUo>*3wzf zE>)k-^5Y03Kxt77?wwGa?!{(qdb7Wy02)=G)4K+kUK9y?JX0r%R?A8rC``M(>oQcK zqZ_IbI^ENB9$AL0;Avb1BY?u3wDE(^Z)kcH#7U@6FxZB8 zUm&AIPA?W9O%?lf+Pp0we9 zD~;gt^ZWN5Ee$|t5KBd7jqZ_9a0}w(y#mN-yh3g-%*eJ;;Mbblc$-V^9Pp-5bZY9C zL6-XRlO7xi+2Q|TJ3)Z}jTU@=79wWKZr~VT1jwyqO@%6Zsfr1x*<+j)V%UXxxm+dI zAr1}fNWrPE>&I`OKfkKJRr5*r+}WrVV496LzN1on0Nt=(N^pw0m0D&`4n|oqF)y6) z=0XCAa5$ylf5iH>o%9;{CjeZ!4TOX$W7MrrudR0WndS3|fuToF(cE#RfOtM<5{yQQ z&-~^Dp&ohWrR+NBNXe5m*QMGbP`!WMOGF=lesQUr{ndRiJ35`G_It)eImn$nY#EqI z3=Tg^OYTTyJw8|U|MN5I$(@AZCNVk}-nZ6$kG_Cqk+FUKJ4KIM|C#$4xUEao%>1-y zls3V;RVdD}(Z|`gV+cp7V61?+;?2u0d5<|N)`q8GTT+=WKqXD;@l@%siXIbaauUvS zJ?2czC2|B;jfp~3rtXs7_p&9s;bwOe-8n-ERnhjHmQUJwW8Lm%`kNhv$u`rRj3#%_ zm0T?>YkN~w8WIqE;+1DHggskkfNYdo-u7OGE6W`m>pI*)}m`53NWJ~CQ~xxcXsD=CYJ_a&=sf@;%nujK2h3lm+uizD3%3eC~s)ihE2%vP$# z40_)?=hQf?A9t2+C%7p;^OL}iBvccRm%MAvGsGv|- z_cs_w6Z`(m^|?IOveCahfM(vi>}9%bHxb_cy9t>mt5Z{AUl86DA;VZVXY#3;QTO5j zyLI!Bm0bJa=7$?q=&cxJ^VttZkI?ZSKQuS5YR5Q}ho4<#*U>E(%XE4?p%X`NJi}8F zc^`d~$9k)=JOy16eC@9r7sTKd^HY39x6uJ_6|Ub;op>*=5TMro{o1>{vVrEjXvDXM zvC6B@`(qA=%hTUGGMD(}61buE&YdpE*!^lQ9}X3h(&(2!n_xWR?7v(7@g{Zb7{Y+- z0$1pH`G!)DSMQteT*|r-+uZ(R1lvSQDI^w!SW9(`2j)cWk##`=I-p0sTvlmyQqiei z=fJf)H@S;)kdH&wQ^2R&GAt_GYsI*afaK?kLod;*X862L?{s_R+zuH>)C#oxeBv0|llJf=`IXYGXD}t57PXQSugBLR^a}p63`QAy-y2$V%wFduO%bn)x zklfp`ds&H|9S@Ga?iw!hSE2!oaa&(Q7xga1)>;4VNXI$m^SsK?g=Qk5Ic1w%Og8T- zze6GbqHKc3$|QH%QeHQ=tvtNLQ?sCJdOul6mQlf%?u?7(efg+MB5n(dH3tvzF4sna z(-vHQE}O&x(tq?k@3FTL#Ne1WDpj`HYJNlZl@X!I&lwz^Mqi4$ayLIr38tGoT&{c& zq=!T0B{oYoQN_`oD3>tnRdAEI5G#@n6YR6uFy?u+o)1AOoc`Q)3!JKG!w& zK}YwFc^m25ox$wwOTiIx%>5nE4iB0dwk`eau<029IwzZcmspU~{esHGC;WRgSu-y8 zs%@bOs^6>{4-FBk4z60`p(Xt%D;l#Vq#@ICL+0Lqkf!UBHRRz;<>gT#3jOd|0yL5I zpI=biv&T{MMXJM)E&*)ho*ujneK*hy9^npeO0;ETza;r`D_s)~>ELiHEdHJ06MysemR;x1W*4XkhvfU&S>f;k@s z%t-FGrE|BPtaLTXb(AkqBpZsV)#eXHyQ`FFNX(It;W1Ek8`pUD=Ae-KJaVeyB?;Ah zRbmASTjCGryOOV6$S6B}FBweqtIZzPUl`Mdu%VDX`PEMX7`Qe6pIriDuO{d9qNumN z!jUvoGHDmt8%0;1-P2{uYxzbttlth4W!lVT;%CU!*8xivHP0UEBJwubGlUtqZxnT1 zno4`8=Wg=Za#)&tXSJ(Z^{cy1xuoDe&DRS-d5`66Kd#6hi&_|7jI#5+;dv!C&!%Tg zbK!UvS-KS?xYAk|j01jI%u=os%woJ*Y#W7wsq^q)sy;w?dguPNiK%|_Lg^>;p8@r| zF;LV$_IEpCO(oZ>PvNg?O<{f0bKPlzKCWOG9g=dQczwU(QHb8~9wx33%CFCOT7O#Ax*Z%1+3nv z%58K$SmpXVI@K2*g7e%!-rO)t1M{{{=bw8GW!ueLk0xfMn?1n8?k_J!%_3J7J@?#= zY$A}G9ekb1HuE!FK2id!u)7_W=c_*wQpntD&n(==MOMM6z_Pxm5$8IE!zo0%R_z%VYU5bS8F4XYc*HLOx8`Gm`^hps8S z0OxD)-sF>TS>$hxFjj7}MmVe1SaHg>VN{@hxjHAFC=Z59VxAex*WO}U8Y?3^9CTe;_-Ofo_EDwC?Ov}Tp-fWlDGMY0JbB|W>B3h} zLYJdVe(gP7@g?IwZ2tQCcqBKxu9@o5C6b-Z$1&U(`;YN&e75(R?{QslnL}oE8A@^o z5n<}vuuM(ntwcy=hietgx0Uj*&*p}+Ym8NTyVl*6(^ve*OQTj3pM6Cy@Z3_lox6Lf zqh(xoh1p)7h+A=)jcLwWg+Wcvy*!Z*e9Q>zPRo*TlR(xU4URM`i|45%?lMF6ck;am zQ+d4h^9z;br*?_(;33B84P{cqPKi5!Y>6mcGGfhtgWIvga)s|gNJ-Ec{i79C6iR^> zR=!%1(jOmN-tC7r|I#_j{p5`#r9N*$)Q=nk+GnEiPjVS=buKNmoEZe=%&Dy&N$h+I%ybucFC;xhgrv_7|v2R-F-Az<3YZ3 zsPRms4hi*DMjw~0K4(m|U|X{R76Q<*E1dZmeB%qy1RlA3f~yl^U^u%%L>AX;oe*CB z&ql*Z3Evg}kyEy{1SG!EV7YNNSV4YL5@6U-v8-38NT%C;Q^pxcM1E_~`k^H z){b!;9Nmh$bt7>Jx~lCMT^RJ;4yMwd~2m3UQnK8_Sh9 z1Wie(O_#YIF+xgj+U^G2CR^=8x(V5E1fCuuk@t2*9fD^)!!dMT%vo8}b*Q`lgY^Yh z-810MO;~9xL6?Lg{qw;bEE{wN>gM=*>-I4}-zWkr9iyMcl8*t8I)D9)BAo_&d=c9r z4%QvcAFoH%tczsyG}iQUKYam}^mEUI;0lW{p_}X5kPKQ((Txv+VQ8CykGp>=wnBp5 zxfG;53*JYruF71j(Z1)PV!WstHKW<$D0nqE(gt^A3`ikM&a*@PLq&npJ3L&ZuiC&MusRf>-d|Ndf29d0lLm=t8A9@iXELL3#017NP&q} z7C`bbamkX=5CR^pCIGuHD?#@v-|0!e0(z8ki7c%=pM3f5Lf(wFsN!;n!S5L_>25Uw zrUb@Y5y%2%{GC-g^!1FpV9HLm9E48lg-s)qfB@fe9EbW}{J=aC(34!ItG z=UO7Q4`<-o9ZPW4prhEge@(=D%kcH{XmV=(1h&)tv_G@ z_9%UhN_#d>5h(eYJ!_CWxw2Wu!LxdA(55y-gT!XP=HYL$~fwVvhr=GQt{Yrz-PoPfOK9y^wqU`LrJ zLMzus;UCGh=K>h!pAl8yQd`@bo=3bwb+s|l9h7}N*b3bLRH{``l>;D+SL5qHP}7Gb zg?gS68M!yQ`w(exx`~o84=@^`wCCR=8xJ82W`7KTAH2#dSQon$pGIX&j_A@W*5aoC z;Q=rVn=XB)L&C$LUSLtB?Ohe&w`F>vGkWarO${5JLsY#Jw-4}-WJ(X@^mw-yUKzMY z9m#gkBeTh7bVctx>BGoz_}t56~x|e7VQ>6G~=O zK(X2x3`E?jxfWFv7O$-cAxN`BJknK8?X~L7C!i*|#}a)`cCNa^5dGu}dPQzB(WNIT zB>5FtXijklyPQaVGu-q8wMj!moggi0g06=2@GZ!E*H3LpzQx6B4FGiE3AocFeB+-( zqUMmw!2t3XATh)Ug6kyuva#Dno_0w|Os#x=!tVg8#mWAd&? z9ch3kwH_p7>yqg(CZ~7do$oObhovIqK?3ffBMDyNSrk&z>a4MDi*jpH1hJ1fG^tGN zYafw7Jsfe;q-O0l%HAK;oMaDnX6T;D`U}Z}LT8pgSnSCDil(zlB59&_kS(&@na4)* zi*NL)nt7zNgw$>e+z{Ol8KnPN@DXM+NbuSqdViBX|GSMqMVAN}Pa4(}(14b~JY#Sg zX2Ys^Y;SgSZ8NX$3$PeR#`jj)t|rCW>b}HdCS7G>cA(#vXLO?gO?Aer&uLj^?HAV8 zb6$6iC~h2FJN;f?Q=)Lir=lF7wE^+a&g>3AZUJ^ox0Uhgm}v-|FERwrgJpXi_yhU=WXST_f$ zf@@0c2x+l?dV9>bCQJchMZc29`iNdRgYgwcUU0`-qQ9QXnUO*D3}^KDkn1JO zfN*C74!Pb9IuPzb^kI~DsQ*g(zbfQ^zc1v$XC)nv`H-cIuK0e4YaXJagOR;C{>yo9N)>ly-IUdI9Cg0AVq9nm`H)*Un&0r0zwIm_n0u zVF=Z?A^$LKmaa^|v4RF*w7==1Y$Z3kBCzMz#_eXmM?=A0b}K%=#gQVl_?Cr%}mt>2ow&B#dS?OgC%McUmE z(N95fGd#ZfmD(}d^J_%1t}Ge)$(M@DSi7l~n+m1lKtEXh z+Z-Lhc@rJ84G?0Alm1h2+5fQIDKF6(TCt@8=Xg38*3ve|YTTgC?a?-8LxYrXd2*$r zzTHwa8^%_v`X$o+BF&3ZOVfVoAcoC6ngTDYhMZuf8wGWe^cOVYnO5WVIaO=K#`fyp z9IyQpqYAk7NAFvExMGf_9@>l0DEkD$46BQm`sTQ*XE@Ws8rzqXJWZ{^Z6wL;b=!FM z*gg+>=Z3ZDNKwHW{gEY691o><`I@MfF3J?#H$+`q5`VQ-QV^hDn`?l0<9)wp$jf69 zZXPY?ydCFkSm$-(FrOEtUj4Qu3a`P9(lX{@##gtT%CQ!n&R_XgA?-D{H8kyE+NVdm zEr%J{j-L*ksLxPG1bC~xv9-a1v<;b_n22lJl}O=Er{(CZ&joQxR6kHz^R=Lw^$ z)lH3x2{7t|KTA=Vi9vg5o-!uKf%2p2RKfHT1co9EWEzx_21)+;)D^R?qO8(`h zzWTpAUW_o_+fNm4QtCg923%l+KPBL%`Z;j@4}Lb3J|$D1`tL7yWVWBay1l49VDidy zcv1`hc{4u|&fPyj{_6T_r>@D(oyGnELh*xzbq%Lm{@v#})#R!~4yQ1_eBlN?Fu(Z+ z?1>B&*mGF>$>2B+0N-POfJ(W2O@5 znbU|^eRu$ba0*Q4o`HNb!J@}eaLYO9+-qEJhX2SPn*z4=?F&;n*CyS1MO81_7`!A% zEB?l&72nV{`EPSr4ukYJN7V5%^6j`Ygu1X`7ZolEO%poJ=#M$Sls;7`zG!G;Ytps6 z^!csT=MyU?NxYw3;h!tam^|;45wT}o?7v-#)MQr*?f+Jq;?|j%rvuN9zuWPgyno3n zlQ@`0s_dS8_IPc|F>5sOINO14xz3z5g&oQbBXD=wiB||W*}C1QS>V@@kA3PnDMxNt zL??9q=HI*Z`7XOI&)1MWf8G@f_a40T6w#?`DTX6=ZH3Uph?(`P9aq27%H61uzr1FwEcIkkwN~Pd7ec4W8 zf&B8hAp1ZLOP%Qg=j~Q*l=_-_uiCayR&1Z(K!FtHt@7tZ$=UUujq}$R`oXOK?0@8c zx00;sTQ9S2?|l)W{v{{i76Sp3@mhxe>AFpZ@s|OvWQJ{MatUZ#^QaY~A!X|-Q@o6 z!TVd#$Ay6Dzn!#?T%s`l=8@pFs?v20%oU7fTA^gHBTRH!+ol0)WXnUDXNJoUrWeoB zpIwJ6gqqmgvYPQvCer8m)Bi+;5R?9V<~;j(y6%pAkqV+f7;#h0u>DW9C7KPY!9=W^ z`(>f8lCD6PxaTsPA0PaFTJ9Z3#osg1hxRSCXgxsZL%bVOQ6DDIq zR2V*;`hp}Ch!7J!NWtz)pWN5nl8#O3!+#ZT#ZD-YsEzx8D{ZjZ3;*!#oL92vh^lUv z>34f*Y5H&f?GS$3uFMKw8T@mNzPO3l=Oxv+{dMvLiYz+VAK&lANXg>&Z}e48F8N=V z`}i_BR~CNX-z%sN6n->^ICzUn502|AyWDPkI~4Ldy^M z57hi#mol#C=s2&q9$}roP){qjoy&hdpLz4Ed5xIb=x`19m-d8dRxyv zI)e0Cd$c*hFxl3@kIO z=-1i$c%Ab=M?lZCyG_2t8UN|*fIq9UM9Qx?`pzACh!(S`@Yz^=hVy=hWnPuuGju}) z>zo6}oLE0fO;q(wKw$tbwn(!~QpJMYb$kqCkz%T5d$w=boD6?Sqcd_TcWKaS5>L~+ zD|Q}#Amn|@5&k0ovw1z+Fj2`!F@*07cH7yYnEv zE!pkJo8HT5-5I&pS0++ChH5$0+=sJ?Hhw?RF$M-t{7%R(ooO+@K2_is5kEHeph4;1 z?uTOOF&$O}#n_%O+^9f1%vYS?JKjj^{JpqfPC{)IVV&;8^plCSBige;X8q0?kM^I5 z)e7kuPwmtw@g7$9zJIp+%!ofWU*-kO*G1~im%I9OL(!Ofeq4GwmhMuUQT&^c-#829 zMNZqIB&!+$aJH@ca`?E;0+ol*6LWR{f&=9l@(}TWDvE)1IpS}-*r#1_7$6wlh@`W6 zpH@x9->A*eqS`q1Md^UcfVuaTzTBSy7a&d&!|(WLx!R3iW($GVR<1W}E%LPQ0>>%p zUsT8yC-Uey(htz=^&b{<8?xDwl^P3zT6TTq?W|LDWKld1&n-{(vq%x3ulhZ6nhLKA zT$h|S?_I{gRreB{>KLmv>_33LwnoGL7B1Flt~;$6(&z6e2p9)r zrN`roNkiQpt=DTkv+N@-B_rTtt7^&7PaF?lstH))EDbZZxJ?;-G54o zv&Y$toLz~APY*UDd*O0gI-3&bv?l#?PGq(9(;Ag&Dg!cbK$2H$8P z0Se=MiNB->afn~uR()yhI9=AZamBdg61W_m``O+XHF3WZ?s@K8uy$T6se=VI$DqQyi zDWdUwNB&g>|I5hEcFODP$DmK!P&SXA-8ka6=SX_2Y<09u)qh_u-HgOE?8rR}A}U3R z_n%9@@+{+3YqdK$lMbMkO(nOcuq%km3&?F7XOF9Lxf1+vuCx}YSG+aTmdL{R>$XO% zX!jWqw~rSVLj5842|oPNwUvhIxInAuF;B(xMGv!}2*Usb#4I{j9og&DSYfPKIVH8I znCs$@SWI(vs5T6uHNmyP`^)5gH4PMfJ|)`R6%w9mh?EFiF}7?}cxGyI(^&Rj>2g!~ zZ9!aojcN6Q*r{H}aT)1e(_}k_uK^R*X;}1(&Do{OA;I1cl*`UycAEDz$tcS@lxoa$g0cO?1uxfjCp4}z5v;EwrE1mlLRQyh$*jaKHBxve?l7fj~% zC$!h~#CI79g(>B^wbk~G-^=23g!-!Ze5^;tst1?L$+NqQln+qr{IasCs5&k+jOpl%@3*NO~!h6m8GUnh$K{aRqUk8c6`v?Emw(G zEh**gOz;Q&&b<%_W>`n=B}VvbS`XFRu$py(s&8w{sSsrDdLvae;A*4L*mkGPk+*|Y zc=f*6aF?gty|Jn;!|Hp4nK2bjo8J0bU+of$=AWS8pc+HLrZ`Hokg|ASTT{=i@gL7%Up+Q}6tXPKt-Jo?m-ojH>TytiKgzYvMa{B}>!{~T18=L0m zXCV*P%LF93uL-=9Qh1-Qw)2WX>b-@Q&2y6%GE=gR?0CR)b}83TM?`3vZ#h43kkW6C z5$t#*{6xioS=ux4&~*>+F=GbPt;u?Fa5pg86MR7M$%)8hk3QG!c2L@~xLpfFr3P;I z#gWlna~^cLu1PQ$aA+(V4oC*Hl_ul*Mu5->h>t4QSo|)ORXHVbz zxA(}~JM>q72{zjNU0Bq;gZ2PXjNWZhQJd;kMxGmB6fqbYMvlDJTP$5_yLW?3rDg(x z65hw`0Kp|?cY01^9z~|FLdVxR{d=XC9qIUTSV+7t{Cl=3R>EOd@I9#zRsmH3QhOzV zS7$~q=Rm)cW6Ql8impU+S*UE65~cgK-cfGlay4$?UbP8emar5hComN7g3zM#Q=6#N zo4OM1zmny3DSlm_SE5!tXtBA0ykBuhtF-&McBvOHE~sLzL##(~rXYXgRrC8%shJ$6 zD2v{0HC3-o?h?h!uH_-=oWZKC2z;T3PnrNc@!n`UO7#tJle%!XufaBT!KjbWkSOb; z|J_(luX39ppld+vu9`>(?%g1K=mu9l6<_H2H!8FmB1VD%|CsZmA1(6LLu2=XU!U&K z*~%ZUp_YHJcvGhWN;H<7xHn=xuN)zq?FGKDLAAtL$U#eEe2eCHh?3ZwH)5NLPI~pt z@ix8ddNbs8M33&2$2CL%)JEIk+)aD&+_(MjYXO=(YbfJ>3*;$P{o|PDxnoH`80Xrj zTO`y2Uu~kkou=9lLG$Fmg4yQZJG-QeT)SFrqG)sD0;*wNRO=^GrqU4dk@m%^m6gvZ zG>J|*py#ltFsS7HBr28dSbmt!4=Xb%tDxb_IR@)^%T838<3bo4k5eV4t4%8-1Li`< zUqRh1U?Iw%FDKAfR8;d?`O5Hmq&4u09WC;+p~aPR*6D?C6#8fXV|I&KpOrU{af>qX z@1X!I&Hn9I_{ySe%`R^&dA+jCPxwuYsUD3!O*h2yKCl{Ki=WS|bpANZSv_v|jGXUE zyxTLPKy0Wk!*MTPLk*VUsilTLRy$D$Rwep;K{P~uMH@wN7@a&wMs?NpjC!5+wVL?e+W2u~-pOw^^0PqIXH2i~ zNiX1Eg~`gZnSVne{@5(6mKa+dfVYX9`dBHt{PI3<4VEp_o*%56+fQ1ea~u}QYitl# z_0rBB8z}YI`2~n>B=%TNLlh@(pSzy8$dOQK4;JS&?0t8WzKm>WBD6z0LM{LXIY*_! ziDgqC6a~o}*W71jc}OA0Zsq%Ky))ztv*D{n#hmz*4@T2h(Ky{qp0`sJdacU6>|oAoZ${8z$L(iLCDKp9r7ZytUg zJ-@O1hx<%nSAV0;pNIJmgb6|qs@N|N3PUV+4Wtz0Hf)>&R+Js%$X>=%-9z2Z#hOC} zns(vdVBTssLmDtQR<;r4g&jQqVvfdamG`sfn)cC9nurn0DVLIfEsne3)g-}XTTa(C zECTkSvxFPhp_r$?F+x)%zARGj4ha{t>$$g`SO6Am_BE8pcdiR@H%9t5M!(`j;uc3S zY6KYagT9;WCKl6`-O(uMQy}V~!Q<&RenmGWs;JJ*EK0yA*!vdcx-n1FQSO)cDmFio zBS|*5`x5^2dk6!rmru~%uY7-r#d05PIFP^a{2M$Gil4ZsbLF+B{e_=?waDbeG{YZf zpe(Kb8=t30sC9gP%bga;CT@3yDaw#hgVFGF_bQ=n>~Tt0$)NVOWfz4KmG-y8y0A)` zxH?(s9Q7DeUQb3S((zV(mV~|I0&s4lZtG-IiL%Xpy2O~uwwV)6cb)GIv4rPp3$&+K z;l=5NeV?=XlWSW12v^D+MlFV2T3gTQcp9(!Bo~&5a6wTwj{|%TD$jDENGB_DJhR>u zB5hb7W%dAXj_nFH0utN#WJQIVXmJO7m*%bY+{6fRed2%Wz-@CDymKXPbL0q6#d3&U zBi=-0pvJu80%pz?=i}R{PKyEdNN+JEqnk=Sb5ebf{HD~;MQBk&)ovY6mn)2t zQNrQi)yv1S%+EZ&WANA!PXh3zhv2ew7c4l$K5%24Byj$rJ>R#}cQ>^LG7I*>h%ocRlYz;NB zWGx2kYby6_PGM8KQZ0Si&ge$x0TJ=4wBt+M;O8J9e!51rQXy`{qHtnePHb6g-}34H=2 zk5A*w&jeOHX@yWPd6 z)I{~hSjKs)=}}?byZE>oeQxiHQ=1F7rP?+8ue*D* zSyW3_370ZEn>l-E4$OYMv8CAPUDf-NvnjE0gxd?==Cc~;89VUv<;W9VQJ-vbsN2~g z!(Sq;UsYG5hdRr}R7&HnSlSg_^_0pnxr^Q4whN%}9s+*+SnPpp*94KRlMZ!m`JZmn zwdzkqD+9F`7?NR$7(v&) zt}GmjIgQ884K#`2PuKaAom^)vbf2u4eGi`GDPW(mB;ph6QOOE}#rEzoa=8i=Rzkpc z-2L8l&)CgKo$Yka5ht258l_)+XJqh@L>bTdC7Mabg~`-QU%lz-eaB}`nmUi4oxlbpZ z--xw{%$am&xUZI99TE*JX7qTpPYdMih5qE`FGXDJ^BwTJKUNovIq!bs-9uV+h}4jf z1o3=B8{V!5aO>`zvXzQ;Y{mG1D8W0d>CcA zla%`Vj0zxcdPpb{>&Zkj_s(=`<%+=K_INHL4Wsr$0(?`;wJ$3 z?siBu0ZN!RNe!^`wp-GleXKf_zIy$Ou}ilE9Xs9#LQ@@Te~B-rHl&0 zpX>Y_y2Z_vHo~5{XvWeRqBFRi>e@Kq6eldJF;bSh^7+$HSdNF?1DP-Fgr#-!W!+6l z*2%E$sC)R&H%8KO2eQ^qdHiSx^BVujJ^#k&r4%jb3gL1Z3FXs^tMFMvomY-iu$r&% z6LpvTGh+NLBeTHuP}*F(L_N4{G1jl+y}+4)vHmd@LdI+gQvc)FQp0~0xP5SCx(0$r z(C5DF{=;}v^#8f0o?9kEV-20)h6k?3#ZF!Sb@h985psPIL0;DA;7dAkRI~|k~DN#`9#rj=v_aqKvOhJ`r!OggP?daj0 z|J;lT`K2JMMON2yNk}T&4Eut7U|B0P{&|uISIT020x4n9gp|v}DS^*sRAW!rWPU!~ zpVegsqW*FuG()3FY;my$DnO4PN6dzRL$}kNvK=gatYp z+Utc4?qw68un`w47w-yl4iU zM`>%#-4amw*d6S$u@jLHYj^sQD=QPJ8C=1^ZM%$5DRsV6&f+OfBpFfXUi5j2Mu zq{C1q9GQlgm$QIa;HDKfDWwtd`#UDza2!>ZNzu(W!*+XZf9bB!2eN^Y06w)6j{lb zp>Mga%Zk76o0d7ud{$E8oOutEFf^+0AvC720R7MV^y=#Xf?xy=3*axF?SG>c>Ax*h zM|cg?tm}hD%1#3{e1JHv>zq^4}W^u{L4je*dp z&5`qE30HeL^=0$u0S2+U#Tp?I0QIQ)VH?3HJXE-K6(DAE^{?3AEqBXT>gU-rpdpb} zEDRFuhXX{8@FMbklXf8-f0gWg$l%i)h)H{jLrZ@HfISG2zW;?9$>2pUEh;~8Yk04R z4v;w4Flz-KfS-sjYwYN&{ukGl%hztKoq=m150e;K+N{KQ7|;{s>c#(pt+Z8yMVP~bhBXaWQ`}R#ZUC2(^`w>@+G?N4f>B z|0vt|Th7KAk60|ghgZtG|4D9*4a->+i_5DMq}0^rjg0yS2M_5ri5q>dI?9E=*}vg> z(V!aS1KHz`JC6k&-YgXu;D3CkgHO%N{AqvUpOp@;nOJ0p6b~~oYTTMKTQi!)SBei1c@JS^~D zHUR+0h0RIGtCHqwvJmO?@!Nb&yvoXSY;h9@P-;f9#YK)QM{y2&ny;N7@B=?x&6nfQ zynCYq9tn&+W)~F={@~+bokc7K0Na30JA|t2pF5xq=l((Wvi*Yc01W%=17fgCEcgSp z|9cH*0iqvwn#rI2lFw=hK)mVdSf(9{76owHgc+iz5MV{OzwuV$*QYq=T#BxqTEI96^!QX3sdEn@AwHdBpo`(#2rLsi7Yyz(pN43j6Ycnc<}=#%bW`|@Qj2I(fh4OEj^C~9*AxreZX48K$MPqNkHoOBhX?v|f^x;p31z3+Tc^+m^ zI7o(F6;e6H#k3YZl;NlOHygpE3M406d;QfOK|H&_M%z*No^`;}@55~S5BOKUX3zj! zeB$4w@tT&6C#9g1_C_~>QpI(QQZU1^mGgl1!M#%`f5BgjPh|||Ye3cP?7RG30{jUo zfD*5n8B~+s0IK^~L1(;pA;3D;&M2Ze0g7A;07WkB7U5!^HJb*XUV*^L?6U1ad!hBB zEq5ToPZ+?oKNG-RALL;)#Dy8ig8eQSLwnu??Pb1t>{o{0p4T8+vtYa-+kSye&=XAG z7~l2+1h-&3LwX0L82mH?e)jIR@=`4$nbGgfyUF~FMgZbv zKlZXu>MF2BG4F#h#FFk0+Vcj0i>_XnU?jg_BpZU{H+vb$*EGQIkiCL)fMV@l2E|{D z3f1=kiZ^yE6l7Fr0+QADF_J$OI^vHqS|tfu_2)ICRWE|o7Pc-jqzt;*|91^TWPZzBk49dlfzHm7LC`azfAi^+er-~s>mjnO(&2+c6H-MHm zgVwFXfO*DHs$c=6ME-yt`mN3Ap?olgnr|~kn;Q#|c61$Rz6%GV$G+{>!pq=)cZlz2 zWOoEE0nHA(*$>U$#|C*azA;GbemnOcLAWs1n9ki&w;83>81tKjQR>_c@H-aHQ~^8= z1Yx@a1;(fr4ys+-$EensF$c(u2BR3spBU3*iUaf!^%WQc{+x{5PK?~FAb0d^WyRb` zqNT_z(u}fzz+)IvSYu48>ZfX_woxCkl4_@3UR#_IvCFb^;ADE1ER=@7$TOmtT`Rf< z_-FF0Z6WEnr^!QU3YyEXtC0lX3oi<~&hCHro_SW+hEd||vTx0Asg{mu)}2>^bc8 zjXPJ;l)ht30VNMU={4y0%|V7Zs|#Obyd%RROe*~9TA)EEpAGAJE*8V$5IX=r-@BUr z%h&cR$BjEYx_rNJ7nqs_jgN7YQT6Zb82qz|CRp-qXzH7kkx342pvntoAi4~ULu(W#thpb6c414sH=3tK^A)84Hki^+u__cHnOS((Lv_C{rdo1swatepGuz!>$Yx;**07RYfblV zsaZxFvqT?%9A!dzvC4VkU&fER`n1a4pV-%GZ+Qi87~-t`Q)h{ zPUAfhbzn^7ks8UPX%Uv}<20U-l6U5qi$Ojv$F}^EOXD|uI6S{f#C9otxNvJi2-V*- zbcm{#cD;&a(Mh*+{T^0lCuy}wvr|E@|HYry5_YyCdqtd@d2bKrfX|rs@RV8F&G$bq zj?%#{Af3J?&PRh=ex%p1bS3=33O?v@-DF?R>zT`F%c?B)7%yKg>9EqBBU(jYQ8PyV zflbzaLlPl`I0ND;aQ5K`q>u2e0K~hh*mm!4O5->sVYCw5a zsw2f^%K&PRBWd`!JP9MJvEU|`0(pJMRUR0QQ8!Qy+=d=5EoiO0rfTZsnt_$_ao1L6 z5l8XSm!8c*S@cp}yQj`1h`y2TyFB=Q?(9widg`zYCyj#QOE4ribe1kQRhNJB^*#G_ z&;l_wHtgYRK0S2}_|j)M;pN<8(#n!X$az?*yv}X51fK^; z&bz;Q>M-@(eNtfSBu(tR<<_HBjwzmw4c9SB$aPS_qc9vS2^zoP25IS^B)#2S`_ zPxZWm)LehwHopm3tR05Pb@ZZRvNloAfFZjKV+#&?C{;>CObVyl*3G%SQO9hg#2r(2 zlxyKL->`O-k3hFSS1!lNt)KWh+r>Om*piC#rkU3u+tpzLv;LCTndo1kmcp=sBJy+PZIb+ zvg=wT#+`tZJJOX=)d))KwIi>QbRS69&-E5E>qGqTm?66;`IBeF=6YZ@&LHVDSjz?k zMhh?q^}XY%HYSe@%Q7BP%=qtPSaioPh&vMH7vhP2^S_;UynsAhR|E#DUDyLAz<O4z`dlMmK9XG zHWKS*J>)rjf&PuEPDup(!ER{z^LUsI;NQNw#q z{YbE?Wwlq=XX#x+nq6f$ib>L@sgn6sA8K9(0vbGxqxC%XC~-FUyl^zGq=36#(6FjT zLFfub=_+y$jc{9v`eng)IsLG-Aibl>$&W-Ra|3N-BE#+j3j^0PU}4zvAK!z|z>XoP zMb|u1{tu^g3K%I47y%IRuYbtv&kPM-Gtl5MgJ`1+K;%tO_}WK6<;6b?+XZ06IPw!v z@dt)ohHu1uP&k@F1q3j?(40lZPA9BHEW zVr(od>uG6cvZiWL4=wbEU}2`~*ZUHfE(C~Im$pApKx92m=UdFUOqEbS*}KfcsuV}u z%TIt6c?j89U-T^k=67adW%o^lm`9~A@s%5@{?(r@Em$|^8uTCoMBY6$WkZkKTd^IG zT1|D*T<^2A6Hu<7^!+}q)BYn$Tp)K?*cXjwskr>MqB(yq5b+Kw%7})h|1R-e&XR_- zLaN%NN0*E|G#^ewZm_&3;HBz411ar+R44lU>YpIY*72hSm>E2c>Ll>P&F*}V)wr3- zddaMnV>iUHPn{tGEEPVV+Vsd`K1w?@DGlQ&-ttAEP8T2jcB>x}<8!Rv8yoXrazRz^ z)kk2$w06wjQkfaI&fxX<9^~P4&$QTh&uDLvIT?hQkjsgFHmHRpt=(Hgy(p=zh{aUfh9R4JW5+7inxE=OnkV$HSoB-C^p%j`jx# zCw0g7iyi2Yw`W5|>>a*eZvim;(z)bm)~kg>QZ2gYAgdE=*v!~a)$8ly)RoYV%`^U6 z?qcr74L1w-5%o734S7G1WYu&MpekXF8IeXad7Y=h%=9T#0Ygg~MMhzxKQ2$2K`#7- zn~mThRJJmXz7D*;!TpXq(}x0wppn9+0>>u3zd~={n0hS9&)QTDQgiLB`=Es<8=0&w zqW9OU^yw{OQ%(Sh^SblOjD1~U#BxF`agT@Q%Kaa52@CrYx|lFZzfja~y7!Y2w5Wyh zx_*Z;6+-W0#V*PK6b#GTE*rW|gG+f}m_iNVYFAp*nAXzenj4F5zF zUyk^_#*`>_YMIqn)`24iz7DoPMn{coB>VqYvJ64s&VPu4W za_l+CPGpdTjeP?vLcCkU`c8b$f30-}@?QBWc0s`I*3o+iXc4_V=$a;JjEsBKexsA5 zVdduQW`DD4byfA7k2%A2B&;VJc7hU~t>V zE0wXtdL}{*&N8I9N8yV-RkE39(}+>f;kW_xTIq)er)rGdW7A&3IJ$cGpjGHnCNtDR z{L@>V;N9id=AfDI5bmKd9uCffU+ky|1;l1}avFhettk(R>+xPH`Z9TS3-hMvIr&oxbgy zFRrs=`~11X`_I+QE5xVcj5)`=hPtb#7nR#S-r6!QvR_4V7S)hhvkK*G$1G}GU!Y2r z)+>CEc07Ad{PoLDb)}2%IFlDq0A6s$iG+Gzx8YIcTSmfS3eSyUY4mt&6{zr3Es&*9ql!w9g)(x=7F&-s4!n=O$Ao-P=gCP*#ad5QJv-`Q{!^1m!iL;p)C32QIxxgEjGzx zr~T4>x`C?xRi)^6j;!YpoJ$G7bPjM4r=}hP+n6{R*6ZZeqoB)538kiy&~=J$!7jjn zBLn9YX_|)&B$oC!766M}jn`1e{^OlU<<1e6aXTlw9wx}`As;1?cS-ZN$ zJrGe@pr4G=nwF|Q{VhV~8f$0R7|Q#(*5gD8*#a?v6lV&-siPO$$qb@1fddrAiS&9W zKk8Sm`BkJQXUJ)rXlj}rPu2ojWvw7|C$GRAI-_q?vk`}%yUR!6kJ6*xh&YN{ ziA7WO2`96APPG0=ZgcJccMm}?^q-8MLn4ilYL7^=^i$s5iRskwqFPPP*b=4Qq#&96 ziF0u((>YTpiA3X1z@s~_bDSrTkGL*mBZM5ETPS@HReG#vx|1s-k>%7&6wfM@T_)eT#mxSQtk$CX{bbUP!EErwNPW(cQ_8FHSr)jf0f-JlI2$mHeidINfC6!y!e*D}~#QbM=KV>sh1;55$T3nTK%w!?N>CJAll# za_JK-^xpoyAGbRtx)n7M#l4un^F1mNgVW(|10*wJ8M}~S(H*C60-~9{e1Br71vgy} z7c=J-$+hygj_{-q%vU42LsV0mxBN#Iq|_H29j)b{Kim)}Mcs|%L|L1$GWQDo#@v_C zhIi;6KaX~%zwuR(VGllQNouDH!o4Qufa$YItmBC4%5^@W&|L{)dt4}+XC*R8k9 z2xu=V{zy-)^BdZ=+I^crq0IEjLVsH09E%>0M95oI!?ib__aNQ+d|^? zB2$wxT`I3?PPQARoNJKi(8*XQJY%8d3t~s#eaqd_voUEs0ai-rSsHatZ;|hnvZA-& zR}hBULS*k}=%vFey;IM01?7i;YoMesdnErOVGW|alkUu2e23jhbs;h$teT};?bQI$ zdhM&7Q>Gd?7a_wF%7ccn51u)FyCS7?C(7MZw`h6u99Cgm6zBWfD^A7yH%{zB0JvO; zjqJgcBIm>Z9G96cJt7rxUJp4?V(}w4wTX(5R!3~MLBzd&VrPss`5NKjGKVegPqR=P z#88CVtK1Blhe@09RpSnx-~c^?6vuRm1>KX9)UXSZS}aV#fd|wn{4tX^`8)_QpZlkpVY#< z5TTAFF8fp6X?70S_6xXiSbR7iXMlz%r6@YbpQaEoUmcbEV*3 zj8GHvmT+h{_R7-S$rOj;lDvU(^ECpLbJ;&X5;DGas}o)!4tnSI6lzPf@>CN<==*WV zpuCxk=a|8PY?5g&sE8_s&BdJIod{^OE&k)bE>V6wy=wU~GWFOGO`NI`((GK#Ha2%m zb#)snlLI^Ym)B~{@#qNN%!pU{sB`bhl^8A%VUGWtGQA+bU0(l|Q(=cGFI1MLI~&_p z^UudN=P_rR`sBGaY|h7tDb44#_IhM|Qho$t(_k@$iDc`WW>6(VzNk~C z$v551bYS^3Sdl+@nqoRGhfR2xWEcDiOjQltk zpuBl4)SnN-hx68^T*(0^1|X4kNFLXql{$UGj*_QaxJWhSyrCf0YLxGWE0w*syOH7b zCLQ(m+uw1Z;!Q1{m_m!yY*DI4-#eRR^j9QbndM0uzK5H}&l>cYYS$~VV5x}GIy0XJ zy&zBUM1d%1O-mmXyjA2SQL>799+CDUO0XiU>HOa#4H!G`N>ekmW^~``N%2Uzh`(4D zy1eAUweh4|JU^SATcF_wtbd3uoFIN%tCsULq-u*l)eO;Qi`7W-79O-?+|t zsJ31fD131%0a{kPCBaE_dp4He1we0AiWYLtsWDVFK8##^sPbLlMt3*WisuY9zJ@Y7>H-1ys3RH38Nm;6;mB zGPNLIsMQpdN?eGQ`ZkM#c(trnUnw$>ZK>ze~@IPe(o(dCt)g0G~ z0-+th)_qvR8ih=Gm*;gtJ%kf$SHZI{ns^aGZ>O*iTK{v02KfJ?_TvA=xh0YkMg8;= zUrwt&xpWb4u-*A>2%f6^Q_DL5GyTm9-1oorj)(sOK+)LQCe=X`pxz5uBcY8?G*!;^ zfjF`sjhpD>bDli@6K$CgJwx>+1sr%ixu^KH{buVXyM=~dhb z_mFh_&thmr8xgZMD)x1YSW&D8*@lblt_v}7N1_2L#Pzr1EF`?L&u0oA1s(W7^{E)AUhtWP{x1@f;qijRaT%seLYjc`d*E?z#LnQB~^o|Ixl%FPx zt$b)LJrp)kDc`N)3Jy(o?u>rfxHGw2-}|th^`mu*w!LHF8t+p&BB9X42ZNRhPLb^# z^YPBUR9J|5u`FA8GC0jKZy({|Qp<;%Z|8}9IU8xbDT&myogdhoBB~AIdv4{d75cLy231tDp&ywR7BRP-mf%bAF$S5I7b4Q2j1b=; zx2!-7UUS$pdHaq!V&+Malb2n^p`_4Sa{*#fMw&- z{M)L~Ve){rzp8GowEB=^-q48ca~IKH%9`@sWm<&4lVP{pP5M>>z^z7x?sOj2WkA4m zW;PxSx@32;85qP|#-9ZiMr&{#_XO@WCm^J~qBnJxRa=`=bhPQZ_ODNHDuEWgbbk1J6;AuYj}XV3eyYL)A<44DoS!m>ogRrQ?Y6_}wZK9W zM7xyie7MozhB??|^6Q+Hn7qkbq6Ze*k9q03OBNo`Y-OeA>GP%l+9B@ z64>7J5kub0uE<7+FkWded_6`0lK;9M3E2S+&G+!Awq4nOM^X{3F$p|Sl)-Ds)10Eb z$@cWJPVw!#_8fo9IXlP)ogHVwIcX}`)LKZavcV}OjEw52o8)a_OamF{aO{E3u6#qkPdu|o zM4eqM9!`MMrN;bj<6)3&Ceili9hBqQeKnLD`O|t??Kd+9F7l>mmhRNxaB^gve0lx z^DD+Q&`JN$;Jz3?Z;`nQWqO!KW7uyxTcmY!Z~#3`{^-O@C#4TNM{QNU8CQlystyq= zY@+%EW_zbAxSj6W%zR#8LyL|Z-Ug<+g2oD{tEVuOoI6-y+SHF4lJ1g7-OD&^PFd2M z>UYLtg}Xq|SL=>2My+Y7BOL9e&bnc5^mnxov)>l3-2yKMT6aY4CQqT3-$U1U0c=*r z1Bw}giQLr&+j*DI0_cqO+w?(6GOYL-UGsm@+E8Vt=Cc*^9}^40%mEs($U{>y>n7zm zfn^Su@%pMQf$8V9Qii6und>#=pxbt@4KvE{KSmam8mWH;b@>hw`(_{2gp7hdYg_A9tDKHd8 zomYhl`ePVYmS2BuvOw*;9*FL01I<21N&+via_L5?FHh!{=hd!HSs2@_YreqD8RlKz;k+m7#Hoov)nj0nLTV(ywZQX zL^*552N<`Kr)NQpAs}GD@3OQ{x7fP>j_MZEd$oexkat49c`pFt;$^F*x;EGL3KGY4SnJ`e7XIeocj43Y0zQ;zJ77?T66kBSn+_u}T;?BB#(r+vz4KGGJYuVi(m*2KBc ztfMJwV z{UV?0#x(ZGWsf2})pIeaSEpdC$8$}EWp6w%g1yXrx)poBsEax=8~|jvrQ-c!v|0>J zK|BaAPz!=<;^eUrG&Y~$Wx3DO?PT!Sz z<{8N{&{?IZQPfrp3%hG0*3%7J^>}?^0A6d(`*!%pOXQ|Tx5+eqp=3cZm z7ULy}N_=*ts)K<$5|c0OF&YYOb^{ZC$b6d5dJ;U1e9^pf8<9mH8LtFj_~zoOqi71> zmxzvz6pPv9d1%xEjv_R7nJI>E7a1IbD`cz62y}jpI^u}>gR`j1O3A?sjM+kOl6NLG z8bVzQG<2vg_4k)~4;*OXn@eXqrbKN!u8D|9&Ng%=_Hovrxt-z+J&Qf>)zzwreWelJ zuL4v^z{{>$X}HO{W1n>TCJLqWr)6aRx;!<1{#g>y?*MqW{r?Al?0YwhO=;ybV*q+U zpbVoA%R9|GiamR$g)mDhL-l^Z*%6d8DxC4dF=*byM$^r!GMG*N+jHF~(-s{?LHo+N z)s`PUb#?{A761l>tHBt<&wD>1r0U?b-`d}Ee>s@uBl`)gArzPjC;s&<#=2DhH4~k5 zWO$-6B1y|u9=Iof7y??>z*IGt1Q?o~V_n8NV`HcVeV;`S1Z7@q z83m19!{xmVcO?vTz?0}}%z7*RsdmGDRYKKC`NeSjqx%3_rTWtVS$Br;{V~?NXDqP; z!&a825ceDWi@Ib*UigYb8y^L=NSnRf8j51!(}|}#i1A8so|bpQqi%wNMUVJL078q5 zOE5iDhh3JRIYQT_`QVNQg_HhN#)8NhF^8b9Vmuz&3D^h7+&v)5W#hxTDEer0M$0TD zhbaKdg*>tHJLXTc(dlAG$>0yNW{z5F5Q-9WyemI74}5n%-)U5BX`!`7#yUPEvAEj0 zmGJXMxrBu>uoWqK90dkQ;%elQJ;f&zXg=V}enr>@Zb-_L-D zZu$0Lr6kGj+1?I=Y}Je1+<0`!EDNj5~|8Qfe+*%GF)v6 z+6Q2VOl~nDRK(-i9bai3)(>T2Qi*lgo_RkK=h%hAA?7syM44hO<DJQk3qy3^D-r*jhfvo05DZ1e&j;3uC4rZj;$*WyDE_}wO z0OCCPxG7u{mePIZyQ}BiIPGSWz72U|C{DPMGRJvJV*5B=cpBAUDd>mOs9Os0?GnEr zuSIcR`4%x3=I+tyeEPSQa@;8Q((*YH8S#B5EdR7c;6dJyEYi3zwcyfo1MGRuTKUg& z485VJDmr~<>0Qt3N~C+~(A=W?X5w)*9YfRZw`3hr$&?a0&B_j`8~{rA6#C?vwv6A68g1K7_e`{F!|DG z!NTIANljN``d@QRQ{N^7A`Bs|q!h+SBg?>LH@$7E^D*KTT3?_Y#O$ba6iWtBZ^?AS z74aCKRzdWg%(knab8!d#Hp{*WKxMrd8)(|K$$jbyTlNw;5)zyF54?F#UXJH8i6!H$ zl#wvffmq;8-K77z{B|(8)U@9dXct9M+Gl&Xb9cBKc{A`S=A2V%Z%$#bNa(le~zX;S;a2WQiYs|Gjr}9*OvOZ!#M?4>Rtj#LDJ2D0}6*(9}Kd@d~ zal(GbCx2A>zP;&lzrwS;>0?7)_nn#UJ;rtxB>T^;5*-_Q7i)8JZxdHhcz1J@wZHD3 zXIXR+wDgBIk)u^jcccNg`99ivLuIZJ>2|)^6n8tB&0)A(TsZ+s9n@34id8(AeFdjb z1VSj*zVoYPFm812AuuGLxd%lC3v+`<4^~x59$kJKAv^-d&`ZkodEb0KMG|5d94$kS>2h`g0b1{S(J@(cnn0~XZ7SwYiDI)^0$9qL3R52&E;l9+4|_aoo!U* zo(HpTuC;cn$-MU)X9{?}y1rdCJ##~^>pFd_?jj)$>uVK0NP9l~YRn1fK&O&lQ1Oc8 zMGJqI3*@|xzd~j_aI&G-^!O&fU*AUgGh@S+rci`OZ4(bFrgWV>x=a#GJE4Vw+kzph zD7(=YRgP(?e;HB@kAN_`Cg+2TA#DhP7Fd6``MamoAB#b*09?g%7YYR-`~BS7=4I9kqfzH z1)BsfdJm>=_d!`~&JT}=k!TOAKGPA>nTxD6FbKx;vvQ!a+qQwsOjkEwZl*-0Re7Bw z1HJn69k3IAtx8cN#h_kf7Czh(j-F}x)6>@o;o^>t3__0~C(Um|Vfn8V8d6lNT_F8C zR&~pPp>k$uDBO_yy$~7IQsV$%wj-_*U@uWt^CJby!J1NXw^m=|^RU^Zv~3;Mn?iZh zFj{lID-faj^wnzJFx~U3jy8TZJ?^BF5CsZ$U@r*V5+xrz9RoKjbfJw;k)WInZxMs< ze^XKx$p~cZfH&c@8w7hk8np^#h!d-ZWqF0pDMD>!>qMU3mj1P<<&=JCf- zC8QC;K3Hm+G`nV>e`>ZpY{kC8JEI>H#jk>h?wwH(<6~m_S--SU1h#&dUYhQCYFTnQ zc`DcfMcMYl<#lq6$&XKl<;FpC0kf!TFWdKjP(#E_(8QZ8r3;QPqno=sY=%dcd*Mb^ zTN5GAYN1qSk*^YlC6#^{qCO=i1M%?6A*v9Fe*de3w0jaqZOsSZy-OvkQ8;fzR9dM| zDe*P&yw$&2h{po@Zh0`W9jGXEIZ)D6S8< zV|E!9pHBy_hTs>X((NWPm)1O7AnIMNleCxV>aA=ul=D>hL!qxCmwrx=t~V7dI@)Vo z>Uw^X*e|tJK}+j#h5gg0j+bm(|n+|5Ny7^0Fx|YOM4)3XfHf za`5jV)%TsM`hE2Hy!`Yrt|DG;{%-qz zJ_LL}H_ZM0xcOP-bNiDY*!N@J@85hH^Un4Og6hlbCjK>MY@SL66%{bhj~eN3FC@^{ zyx-T!_4UiUwBMJ{3jxt}fEDntVKlnHw|)_GHK9%-G9 zRy}b<*QkeZC(*x8!2q?)Le%5H7zqcu&CFaSIMMHr7Jx z7vWXUg~A%;e&!yF>?lZr7#YxEM@uWRLPTd(O^le*qoM@c@wzQ7EKWza2_NPiVPw}{e_sg zx>vSR9@@ObEq9dwD}nvgPiMq3HOg1RSqsrz=YP$S$1@uLD|aOJBhzox$6pA_!fM?q z2sh>uJWCn~p{jB(35=_WFGYtSW{nMxHqwTBPR$H~+a_z^u~Ig&Fx{TQy!w>o`!ctP zp_CB>eAX_~%~hbf40~oPi~zy!cbEk$^=Yz2Q0CR4N5zG}#PAO0jLnE5VTq0Hv7=$v zC@UuTOfax*{f2Q@#1q*Cs)c-{FfzM(egIeQca@jF2~KWiNR@ffQu~@Nm2G0_?=;B9 z?W&IDq*|RM+nYL}tXOl-Ru#5RgzPQCIx^-MKK_db$i9c=%j`FVW=_wXKTdc<6{-bc zBC(@lwFgtQU=Y6Ti<{|jPJj_CEah{tThi%67Q5yf|G~Ceu8yhq>(~3zf4kb6`55D$ z{%;7d%D)1`&fU584D^lXCn$e0dR)v+eCmh-it$!S0}o9;B&*DvGeB#PMijCG1-4)g1LFEX`U- zOvRS&Stk~%mrU2HD7PJhu5r1c?beu~j`~SK@{mR-5L$C{AeSf8t%v>A$Xlv!b)5Ec zMM!8>fSrmY)q`x*bBDZ~g}d0-XSD>jKY=dTO$2!^4VTT!O_DAk72DN-s4OciJ$1S% zI@;UC5wh7qFc-skojCrIgU?v})%05BwL%*y3q_|`@U@HK9q^R-Qc#f4L* z2Kx4~i{Om_BGZ5^&rCWG8nn^xd#c1aOW%ZkKRSu-rORluxyO45%dG<9L+#|vJjdwa zQxYp~b-L2+=;uL^t0Ks?Fg@jiPLUb?*Ru3TTQfe|@H-I1U)aN*K@3&J>QQ3_6=@N8 zyu!?RyZL$nv%`*FOhf7ii5DfAEMb+uXNby|UNlCS;sm$gj8T>oOO$R?D`?`mO{&@ulU8ap34Ig}^j5^5X)Sz+xtO~;5WC#muL z8>=Np=rlT-E)*p=9MkvGO*Z36dfL@ci3VpIm&w2NxGyyPEf)jr{T0-0EWf(-*nE*R zrXrNCCz(08%lwxm|1K8xvdityTv*O)#1L)?efEG!I=MjB*n?7mR%DcVb!1PqH;fT- zH<6I|kj^>y^`oV|46MbJDqN`+N!HTQ9th$dKhQSXB^3}h(An3`KkIKf(m)x0XoYcV z+dc_!l$y}Up)`~ukc_7F!fqL&dmK1)>hxTAZG6!nzF8$9@Jgs7Bj7YaGN3Me|LxSj z?Xz~t>{?BFv_d%h{<=s+CKzh$?NEtL1%!mp*#|Je!_CI1QbsJlG6v8-Nc1(1+j4z) z`BXw&wRhXrD8-@J16dg8dZ-ay5t2G!$j)iPWo3v*RYH}=p?#}W-|_@c)A^jDuAb!f z5E#C{97g!X9Y%w6acvdp8Zk zREp-mkXqv{|Hf~)!ef>s4%!j=)*QB3G@A=IGT`--J6w=k?KLTepwahVL)3R)gi#T_ ze;+@|YRfPK;V#Rp$EihG(-bjRK1ebM^*E3f&5;*PrPVyfz-~0F*Oz{-w12-nfpW#Y z%`!%;InB$P>u?+W9V`Ut&%_Bfdl;>gyr2_D&@Q{|E1W2!2~yL1afw944D2rA%boKp zPdMgojWZey1(liAsUp&(C3#pv!c-&6rLOnBBf$tx>^x*Bn3$~hw33{xhr;)QF-L(+ z(aW*38Yf^2X-9=~lv{AT>TW`A(ci}+k@{(y>&_Rrkzaw|WgG7ud4L;lI zv$Nzer{`h$#^PoPGju(i*vjQ%L4H>G%EcF~Kbr@iA4_$hU46FjT>!(84ibx*Oah}0%!?OO!L(x>UMf$eH*mp!Yxxi(^RR*=6mi+ zYBQg6TdD$&1R=X=<@icp|1W~-ro?e3N_u9ux=98jm9e`$Yess#lSgC^`Xfe0A9gl? zd@&qRV3ZE5)wu;&5_*DqP3NSa?N9dvB+WcU;w!LXzM#r41_ezDE)_mtI-P7y3N2yo_hZ=?1tE4idnY{V>+ z*Xn8|qh@*3l@~MHp#+r_%)(;79AScIK72DK4klXup&lBPNCfn3>zC3xB*ksEuZBC# zn;x=77@f`0A5zv4>|7=AByVRFPyA~IfL|Uzwt`BN zkctjzYuWX+#9j42oR~H(Q|MO4sKWNfhFt1WI$3t>7!(c2ZBI^`pgx~S!F%Bfj*&%! z%EQhU2q5p8YSwfOMW|&J7(7LB$NstY5_cL>+$bF1DNGTBWU$k_;5E*e0+uT&VI~cQ zcjf_87cI{jtdt8ZB2gl-Z95P*paj82YC><{UqX z8VJpf|Lir*HKtDF!Y-ziBI4?jkdTsXQ(QMX%n&z6o% zu|~|N3-!TDu)+MGt@uJu9tAkEN@QuHQ;tv^xKG@>$d32^XD|Zv)fLdJ7!^*vwe^k0 ziov@-Jo3LLk2!sE1w;&{s*R7-{=vm6xm-W!QudTLHo8RQECn7Y0!1E~#0l?3|9yvl1R)}eSz3C9F@ni2 zb;9aIp>W7vwJ2LhY+!*H$6UzlYh3XXD4J5nSGP6}QJv&^M=&`C0dRLHm{{grb%pg64zm^zy(v} zr6&j2&a1$6XKRo7I&0R(mrK#X_o4hE!MS`}iTMgeXr+PxA|Vi+)KfoagOaytfOS_& z&7dw%b%i{nYni&0%_2PX5Omk%W! zAn0U>wuOUgjc+8;yO~7sdRCSggv6OLG^n*n2M#6A9h*lf*e-zZFtohWm41e-*_Ma9 zu{eAB(hwm#%5}??98rw^n57I`+rkm==AeuqsJO}`(UZ#nq_nNHikWq@GGTFGzpAp# zck%D>02u$Xuj~5RFI8mdm?1q?(+OoM^YWyVhocaeOq1eYE?l`8SS+?@5p0-wWYp_g z3V7)V60x%FbF&=nUSVImI~DQwKx!$|TMc1P+@?$XtpL1$pTuFXpeFI`ZCS2@Ov$>6 z8qEk^0JyH!2)X1sqeayhXAQzyR03kkWAdNuBcb8W78SD=&P2bpqzRfldLcQW zl4#hYLwYvefOll$U5D6lj4#&*;RX=eyI^4LF)3N@KyU2}hIw>4()8G)vBS|p^T{sS z78x!V?)NgfeEQ;)9Lu`sGmeq7&MMe|RH#NUR)!{nzv~ZXq<3|0y?q{wU4c!DU z{WtInuH{3&3Zy(f;7Dz#E*M|yy*Q_c8=kHkYPYWvKJk9Lejx(&tVjQ!`2!pCf9DTs zUQXsjjKU5Mj;=6_s%|E(|M^+Y+QAlvQG$pKhEc>3VDLG~{CqD=^g}iN+p4Cn){YK8%MvlFt4PEA#|sgam8+|h z3lAfsrM0V-n+b!dqdlXUiIcgRwK1cav8%C(n+v_Eowd1xE46^Y&!w&aH&a(7W5CY| zB4#EgCT8ZJ)Bla5u(ABVu7iz<!1;smgE zurvq!XGx}3#!f$6hmo0y;b+&JjNF`D^sMwutW5OWTwI(S^h}1#O#c@J{veS8sk!}KAr@eoH;X;CYpp~?8DcP=35Pt^q{Vvci#6(Ne1AVf_JkMr` z`v=fx^R9}TKjP||9#to?nBkV)dYH;+Hy>CeD^~#=jX^Vl5h(#P><<1P7fXK&9%anJ z;YVqT%yvHRPbvbwD`x)Ye|?DolRDn;e?1)-7C4oBf9!eWY!mi>d^~R7SUo&^3;4Y> ze1EnJe!Wcz`hU1R><#}1E@CX;Y<&ItvA`9yH&j*PfTDW~FKFuz6fl1oXA~4X>X;1U zNPd90xc*K(C0P-o8$hK`5wZnI~0jHLq2lC3$<8k6%qT(Q*UFuLGkbB zUBK64J0fA<``)+=i6%$Gy*JBpPUC%Fq#Yb%3SV+f8GsGoVYhGfkX`#sa6z+2ni?k^q z#m(vkFy&=88@353ttzf2?FN5Dgd{MkWzVN;(ZSq!tqrmbM>ABAR)xo2s4HE1$cOao zURx2)7GOh;3e~G=^e8I7Z#*3M3SPqE?)T{o(}0@xj?bNTaL9FoDUXduKC(wyLZdzo zViEKZ@jy%!^$kylN7YxIo^sUsje-y)W(p+a^8Eug*iIfDqW?c%Ci6{m6q0gd+Y;a3TmripPcolB+3N9A%U~^R2kXP2y zMZm>0N~MX(iTkttr)TH!w3rT43%+QT+{z@oh1?_{(~d<@*P^Wo@x2iibs2~{g$sM$ zEG`P@Zo{}k%xeng?zdaEd!OGRitsA_9XCUT&9!k|DUQ0(SduBHMIB#T+E7*q+HZxK z$HpLTU?}bq2uuB@7JgwfnX#YNUhBd75E4n3bX$Ek{sZ3&I_S$Ir^^;mI#3#mz6BF2 zg+=v_xO_pBUi72}b>R-ky7T>CU$d861W`+P>(*=R&WQWv4;oWgx zlAZZ_*wxXPo0fgd4r;AD_R$iFOe+?yTSzJ6C*q@m@tb2!lP(AKb9v!8jE|BEFL4pX zSs{!p+Fw|cCiQuLhcv|jxd0f(Q|o4en1P4+IHcRmh{^^Td(5W=gr!6#vD3F?-77-5 zIAhlujktwMf3?hHh6XZbkbwe~_6F&CeAll<5(wLn?FO!Q&m1R76UKoKRH@f&z6J~Y z-cq~EAQ`32;K6LB9tX;e=H#}#R&Zge+x}aSTyA>;$Ye6RrvM`4Fdt0@AvSB6XY#{J z0kYVd?waSdw4z2OF|(wxbcer{imgpJ>|g?yKyn25t~h{Bog9P;BN4lA?wY3~^sKoU zBwDhI9iKB~w~DIH`P`@2i~|r8ATKVPlJ8LWt=i;RKelv$+SWP1GJR%3l!zl^yQ zj1gH0yPd-CRWn5?OC)*i&LC*B)+1vo?)Euze&SJI^_RcnkmKaaw8 z8!LCvZ#46z@#%RM{)4g0oYHRFwKSiI&XNR0j2XIRJMlx-=qte{Psc~HoGY`rZ6rRb z5=s$3YzZ|-FL>$Sk!Ynh$68djzA~sadDe-8gd)RwmD9*&58wVQLE!D++Ou&8`OT;1~us}QDFS)1v~i|EZNGcfFbg(Q#S-(K*U zD|S{+o+j+ltp}~|05=<1&3H>v&ahF7My7qzRO_S4|G9_X$(TW(c`9;e)+HpKvmXpL z^AI`4f+mZ1qB(nO4C^Q#h`o$|;m-upVvujL;yx;SX0@C#X*T4g)1P+M&COD+d(F-k zr+W|2b*bP~_!x}cu*(LK{#mA=z5Lp zr%0+HLVoB)j_}V=xzu^~2pJ-jt{3kOI44-Qn?|1GclVleys_iwo2LpPV0))gG!_S9 zwf>gHcw^`_5C{H-N_oT?@!!JZZw2mFG!|-|z-xPbiJWnZ7jeTbo^qf*2knAJht28S zhfm7ODG^istgwBc0<4DUkL3I-D8ix)48-Lleh+;_?uo`iUtGkG@mYSNiJf=&{60e#X@P~y|-&$kY84+#ZMv`LkjztJUwds z&68vsoyH)tpI@uJBHL}VWn+Y{B|*Ll;J+>S~;O&3Jo9dw3S+gX5YbtUQmiv~_f;W2xy46j1Ga2wct zRR(HBK}(>j91Nt=F7<#I_0Yl-G6p1)#sEUQRYBrQHr-)Fa0i34Qb|vb1MY9s60Shj z|7=Y$v@edOgK}_OEi!?{CMtv}iNFN{+cuO9o9U-m0;E29Hht9s ztSFnl>0v_yv;5uj^Mnu9>anL!AX?$+NT5L8jXRhL>O6a_eBaNk6Z`^$XT%GF5EGiN8FH^ZE<$$S7;Rp)1zV!N zQ2+>CkBEi$SHwi8722vTlmL?jf>hFb>GJIgshsyW zP>Be;vAFweo=^9{L9R-n*pA%T=Ed=eo_d%5g;RUGbGzm>ha^MAF>$TE3>6M@npSTK zOP45VxO(|cQYld7jK@@ZZ2Lv;u~_Q**!?a(c;xU#HR#F~>IIK@b&joQ`!u00Qk(st zbxe$;Q?vv>unG@gQo(Ud*mmpgNE$xw@ZZ)F|J>`Lvg9@$klVau!-Z7ffu8!0AHMHX zIBG>J5Z67?GmSxfT)G6lte`=&E&Pid@--Aio-?m?Y zZx+OJap|P;T;K#cMD=EoFG`shh+25s4 zAK&+#NFrxyMk(y|zH?O4_Qe&+E~Q|7+m$V8M2q)Q9Qldub@1PYDdUuIINh%jH?3eP z9_-<5YR_6}#b_m;FXT^&q;0ZKV?FSyUI8K4QWZ{FKl^gMIuR>-<$y?mJOS<|nkgO9nTnj}pKtVGy{ZL?1_ z;f@9Yu=2&d(_i^qDYkM8;P0@?S6N(83~)DAHH3ncmp?bd%I!;nd^RREl2RcNe?Y7q zx3ogcY3#eBi8ZwI{N4O|wrgEI6?4$AY#$1kB88b`0qs;P4 zCR!a+s26n&jUAR`vyNX=Md)Z@J-6P1c0eXncp5@Qw^RKe`&y9Wi*Q# z5@1#m-|$E=z!N^%@B#?Hc-?t(9*F8|85xmCai?eblIOV$-=aD*fyRcEFcpObpbc_7 za&HrWa_J7!c&6E)+~V}Oc9hX#oOMF;=X-$;7ff%n$(}Xw*ZGG!ws=YFrIS>5W>}K< zI)tiWVNZm+Ogx9#6fOC`#}jO11Trfe8nTR0$wKhdZ!QS4Wzxi&-}1cXvQi% zs)*iH9PDFr9hC`fo36et2Rm|gU)m!5dLUw`ie$6IeMu%6`Qa8cudF7mpcBQXhpUe$GE{A+@ zrna(F=`<_D=mjsyMKt_J;0@O-OqQDm?TVu{-(%%w^%`_Vh;%>9^Nli89;zmBAF4allS=Bn~Y@AOeU zst~91D4nu96{&@*d!dk|=_%;O)=Bm{F{GukBrKcf^_M_PX?}W5F6#X%WW}~E3w>$O zisYHpd<=4IG9o5~!SKECb==EBCJ+Itt7ROOKEjlHb1}*G55H396h6!q{CPkst(<=e z`y(B+oe-JLynzsu>#jKiR6VTE59^URnJKVH5d9~&(!v>qwNI-O-L@(udMJsh6}>+{ zS|VY-R(Kws^QMPTDg!4nlB4LdE6Csza72YGty4lZD!%sbh$tbNWi{#*G6HCG-DW@I zuwrz=)X@P4o?3;tRBE5Etu3@~>aiM|v|^K&h=+QEs<2>eHtr0JI?hJIzmT=c+! zf*jUpw{%s!WSi$S&xR1u!O{k zc(3|_w*eBH?7Q=qAXvL)P^o9%1u@I!2m~xFMO}5M1K&Q^f{W^jpS_g^O{=v`a-BrZOs0Ykw(6n%%lHZ?*1MIWjI`u}W}iV4 zuESU{|8LItQ;NODixFq!0}-kA%T8YF%?swccPHfS)EI|Kj}bLSDzhrHxN;w#2m%hm zDJ{|J+@5VXipm1)*xUt*jrZjixsBH28*$E=%;Un%%aNfC9g)P9=*PKy8^|dR`=Mt~ zNuTtwjY)EQ1lD?W6PQwV^t`dwoh8>7z^csZ9{UQu?LSX0^O4r&q~v$b6c-T@{jycl ze^#LIccC}{KmD4{Ee}fQu+GROH15;{mC9n|_!8=S3jjDhc!4Mj9VQm>mdr9I;b3g> zsM;wk&R_uhS5!gQ()3?c)8f5&An zQjtXo=d)ny_K?nNvDz!zuq51@k%I}|1}$+EsLh&Xe3yr^;VtxIP8YK;Bw_Hdc11D` ziX8!>;xGdnftTSPt)1nQ>b>jBX3DW7BK^E(Pl9cV%LV|4!JOPuiYO3rH}w;+{LS&7 zZpHjYg$6xgA1kQ~DXQ18O?x=A1fvl47lHm#W6L#@;Z8^>N7&88$xnEoZjaA6KOy%* z1!x6C0TUkAla0G!cgnn+rD<|DKlpUM-cxXE(Z4!C&PW-$SDccH(P=w3>y-kwK_>2t z)g<(W_C&h}k&d_jn-h1mm0r`(O8M@x>q4u&SZrZRYjgnK)Ta}TTxX|yHb$A%>FD;- zx{oSGdK3Jxp+4fmG;X=FDQ$^>h+b05^6GZ}2JEBMAy#y;qEGvV>ly`^Xa9OQOb~X1 z0@y<1C~0Qxej4p#aJAG*aNw+ckN2u_)5Ci zeXbo}y8nSDdICvBf-`dsjp{sdls!mr#lA+Sc5^5GGB(IPWKJSeUEuv-`)WHvqfFap zb;lyu9V;{{f1>p@s`|9A(dV!co5kB^dmn;Vmm8|ihEZj*K?x*ma7YjoPPJ zFy>DuX&P9_>7NGMoK`|*W76178^wn94IdR9vI~KcvDHR#mfFwdsAH-i*;-#`+D#vY zjZLy_`dRF>SXL!b3osd-1z-)4YbFRu3+i{*ar6|@2p6I~^Jq%-?eRU1{J*~-A%y3J zI|eAF*gt|{L1Q0jux#So*#0&-+SThoCEvDK9t_a2ABBoW3igq?UQ@x8mZhAA#3p+xCtw~@PuQ9Qe#fK zN4(QtlTDEO=<{uF$G(8}Bz_D=Z#cRIvv;jg?b>KM9Q^toLe!}v&p%;LXaoqwH0yq) zlOCzEFx*mJ#G7mn(p#LqZZ|}svn)43o7d!rP9fZT-X8qK7StAL%$9XUM#(=vABF9N z({fEiU$nEn|X8{h}KH5W7m=iX_ z{mha!&;8T9ei(&v>MZ>Q_6FunU$`F~9AB15+|NsMUY5!Vk==PPZO1^^M}tjNSThD* zZq$M&;5dpSt9hHyp`B8$%DC=>!QrNR-ANSOo;EaL2G`ua?(^P)cLjmW&%s>z-Pg79 z4h>j|#BjvPYt7Rj?xe^JUigJv& zDF}zi_R7X}YRtYOVK8VSsAf-f(Sm;uc?p&BBt;Z}e640xW0=%Z;HZXP&gK*tukx!? zcK7=WyNUYnv3%@puAc}Sq#2_zospoH+vm3gH(xDqao@YVbOInK2U;&VNsbJxP)~44 z_qug=`Vh`{Bg^>BthQ@wPej`c3}~g!;BL$*w3%b$oNSwZ4rn%n`mc&^zbQ2lLi%;{ z;GQg+GVC}%iGp5i%*=sR9iL&>d~RpAggu+_632-AW`;)=S#FH^fg0Qk3qs5aB{Y{; z*C7eQLU6EX50&kQX+jMlRPr=R0RLMKHjn-F?`!g($1Y=F2Po?e=}@-49n@ZoFC!CC zNrmx}dx_u7WMTrr`dVnFOr3WV zGqnX-UjJqk1wWsyVDp?ot{)cP(&-B|`K>7(5aH^0-)Gc$j74V-ia)bppmv2 zAM2>%%uOZrj_>X_4j}!RAm(Us*%BLAjtz@X^dr$&52o;~+=@$5sErDfRQ*Z3o%@Dt zcp?z|pY&ll{_nN-*f_ZV5A8j}1ia3;fC5G3gms`KaTOBr0jAww{Db_5foAZB+O__f7&xdL<4dOxVc_J?B^4*{QkZlcX-&FnnQH_lG>j8 ze!o2y{C;1%33$K1A@sl4>GON2K>WV@6|>##kw7T;*g!piKQjFsUhqb{kl*xrdvpRh z$iV7Gw`jcOJIR2~)i=Vdz#z=s0fyC`>@r>%u@+jshK5iN9;?z7m{L z{M+-r*Z!j+FDMB3=>OZ*{xdwE&onmf@$-{;%HPM*yoo}~eqU$6 z93{YcZaKjeZ54iBM|<|MNjq)@-xqsJ1sr(qA1|{E>0iZQLS{+?xVO~L@YH1cWF+0Z zJ1_7^pilt8T~SbO52cMA(7nIp|$Cv!02Ut$Vph9NZSd|6ijFAM< z_C#*+p$B+@s~q0yP{7+C{XFI*Yk$Y;pE$M^SX@*>*&1Uog2eh_pty z&Z_4lT(cginbIhLZ&H>jQ5_Y3&KYK1?sq;Mf{UIAv&(P_w}A+W61YG<%aY{lHoZba zI5mT?{pFO|?=A0vKr*RP6`89@FR)iRm-P>4Pc@Pi5>$Rx``g#Qg_h+B`tdp0AV6rf z=H*k_tT4N)CLvJ5&C#nUZ_c^16nM4hj~~|+0lp_HAfP9g^yGo?r+ptC%?s)U@b|3b zCfsf5SSTrnJZ&gWe(ga&aC6;b3ge<3V+k^uW|VlC3ZXp3G;D8b!wzxB$D>wMQ>h#Y zyu%%)6HiL<>9I7?XI3EOGFgbKn68y(&-zw2!RHXMl@_nb*`K+RT{-URm90mgq^f*H zT2cgFI?$FrN-^QYG~ZOdj1RxxksE;t3~iz{Ub=hk(l+v0vekz!KTZewowW>!{lpS& zz^#KNix^6FUq@uS$el8^A%ArcB>uCJylcbWjk z#4~N*AI@jf7z^Co0M9d{dXTcV-OJ2E1HHFnw7{n(Ld)AWLrc_5;<71BIWRc2RODnS za?Xm#&4h^l8={D4m->OrY|s^@b3J^V5?=8D&q!rd+LJFVQ%rW@K-wTyMLjo(gTZ}# zr{s^o+WEbT7Yrsuluu zbb1wb_V3MAIBf%2V+wR1x=61}G;?|%iHG1F1Oys+u}Mt*gn=V*kL8Ze!@{8n9~@3e z8P2rLv*eL;YBnO{;1mFFc)7W0v-^QeiLBFQ8v?0;$j!qpEJh;9n%?TI~N~euJ|nMR@CwgsWg#KY8;w?~3{Q zu=6wYQHFQ=addWu%a*`ST|DIlZiN{%BqqE3xI{l5?vuXvgb&2EYlb->5V(a4)l^U0 zj2daq@0IUwMWav(U0#^XmFLP>vY{Esy}HQAV$KtJpUVIBMdzw1lZ9cf0BqzCuv@t) zXX4^LgSYqIUW$I6%9G!OLTtnD{7%1nz-h4o+jeR?A%_PhZ>z1g>9Kh(s9JO~G_c0Y zD|{QAZ?ltX1elQ{pkOaeWss`EEh^@uFpt_7+OZ@#WD_~%T_3U7(pFt5!&OC!)Z;R7 zV(z(=cVZmq5wtZ0{*?x1P%gIv4_Bhmc4yG`ashBb(5WcVoO#iw^gH*uEDjhEXm5M= z>Lq>DrD3O|G?_81%}%Fj_MjdnCfc0!6kaxUMwa+})s;@?g@UHAX#9?CC&P*+#c+PE zCzd2nrD{FgvM)0mLQA%)R||INxgw`+bGHKg?nZy0=>(c9fC4#lEa7W$T{2US(LfHRlP~amUVM($A=? z1f|(yR9gKhSlpNAT*pn;Ul)poLdV~E@gf_{&BfE`-szn?2y93+nMgF%#_~0X%@m0; zrDF}|5k~dG(`tZrCTtuDfx`|rgOD5*hggS$hAbs9Klh%SFVf{%4u84CKX|rkOxJ^% zl`DWgGJP^w$Th0>R!kxLqxnfdd{9gdz6a;klx70qX->C8tId+01ttx88MA4K89xR& zY)v3WC}L51h!uCN7jW_1;TS2Cnah1{JOL!mhL%5Kh!{C7Qw$+S+;#~Y0FV0t%mZ5u zLZ!xfbp!$#V>Z(u1D=$o!3d<)oc*apJ3**=xk zPUdLn^<-}i3gm}kEB|Ujx*9H@f5~gr zZ_0fF|CrsrX>+YlsC5?$5*N`HK@t-ItBVr@M;DCdsMW9936{eEL%99;jvDrQ3f34(jFg z-65bNS|7jIqHZ2;vgq$;@WIcg>!NU7Le65A8tb7}jLj~D=2%IetrFBC3ll&rRC(=y)BmJ9bJc+2;FDktwU zEfST2_L)=ZpxRjbJ;`7KtqDqmocl#bITQvs1w6avv=n(T+q=V$Cw-_YP#>vP*Q+!| zg<}*tEqSN!@(xz>=1Gj61|=GJ>2F$l5(%UX_bRtErP>RibpW%JXcn&lVB4L<1qre& z<8sZowuw0Gio0f!59hc4WGpgK~lfWnvq%V!=KBSlcmX!X7SZ( z5 zn6`~VItvW3d?LA#ptqyFM>>&Q;YIL9ooT_+^Eg;M33Ph-ZTR)aUVv@1E9Htb6H7>1 zc#uV?04#!l^9iHQFOiSk+1WEs2z4n_YudWvB<{Pc( zz-ars1Y|+GMext3j@Mr&FLRA2=eO}aUXp+7MZ5K5y8Sm;FFkhN{r}E|YfYlZAM-WW zu?cEZ-P)Qx&tuv)f0^T8@3g_`WS`ktR7nZMSq03=DdzOW-}F#7D}$P*FIfe6{J#7$ zaG;m<#DRjgUtYnL@4SkruC>?4AtIH%S zyC^kc-1^dE;G~=DNKZM^YtSoCS3+JTGC&*yr<%(R7gwm83yx~Ny-Nk(4#{sGf}f9h zIVz#VAI6T#n~aqkIud%Ak-A>n;zRxs)S9+MYdwGC9$Ji?9VS;!B|Orab*Cuy>Bz+@ zE$+b^Yb}w89Ih^y4}oB+G#K@g6T6$$N4=AuOa{dpANSEQT#^>uH9P6zjnBHmY-!vzxqrpoK?pxlnl6!N#l?Zu$$~1# zEfHs}tH%q+k9jO_65Z6}naR1bU#N;V?xK>j951k&-LZYf7~wy*K}v}9ZYnpkxP=Y} zZIlq+Fh^jL6DRO-z{?pA0vdApL@s1#7m5y8gu^|#;eQNPwS{yOn~?CYk^s18txvQ5 zCA4*lDj&QB_b3&nl6sPQyV_JHPM2NB{r zZV%p3!>U{to~Jg4wbIm_nIiNw2`pf%_mjfC7=h{TXq`Y@%hze=;ABDD?|G~WWg(_| z&dtOs1smSbOum4@6={qQ!=u_1QGvU$wUTdE5CdjWEfBef3s}jNn-OIfGVyW0Y8&XM zjY`w4tyO{(m*vxbzAVR^!$)qNKxxNlKE-JykwOaxE^snKXpJ;~2;m~a(uTh?jg=a9 z!>?a&OAIgFwx5q>vK3t1c`^W56!OI;J9Pf@mn7!~XQ9s1BI2A; zn~l#YoPjLor5q@}L7&@EiJOcLB+YklCSf5@@G^AZADGi4C(p;QaXNhS+`qNCa@)2t zzNPvoX%5*GU~;j%1!hVY+lUwXG+kcLbJmkp1*NaW&Q{Fn?=8Q2yHT$R%b7Z5Ft~Lj z`PFP)7sOhtgdY9CkN$YL?6IBKddLw&X%PDIM0;8(H|#%ob*c@)StCSLM{;&wayCv= zU98lDX_iMqGP_o%Lu>gqPUQyPtwBO9@=nUt?Jl`ck=@z`n)Gdwzi*!81XHabizl8W zfA{JYpRV$eqRLNLKI`L7xXn;?{T&-uM(+}doDOj-x@>nU6C zOTF;RUZ7>9lf zz%a^cM6dUB3O-X9s>Eu}?JkksU_=hbIIXBWqxc{eC40Q%w!sv1^HAaq>C`MJO%ooy z?M0{JwO~l;TwR5HZR+XER)JO%F3uUMN@sdU!uH!D5sFNX^Guh^iF+}O#n=0TVZ(sG zpIx!@BFt6ja25wG@8%B+NHgEBx1XRE>l}6h3H01Y_EX0Xe-=ZKtS0wgNX@-QOexf(#n;Hm6aKWQPk1S5uoa1Y-&!V%P1`>Dq`$n zZbroZ1HDv!#PuT9t}aUE08vMKCr5|>koR-_OdR|#+z`09*w{J$N0s~KwwIc!b-v(N zu3L3p|Q61cE7n3$3>va%kMlEyli znfkhib}GJk3VxE`U*e!zGsTI%`*;N@k?bo>_jk-%c(rDSVtS0ypn;H8ubL zu$B1#co)0+q$!h)T9>=KyUmt!)z#HBz)T(I=jX@A$6sGxSy|XHVNEOJVE=VjxQS9^ zd;|$I7OVsB5u+w`wY5-TBAt4DE9A(5FCvD9hB`Xg1qFZWblZQznD-A3Zh#xnRa8`d z-jR}$h6D$He0)M)>Owl@!_ zvVY%38&xP$hRlzI5E79&nUg6~nT2E~^RT3nWJ;1GNm7O+AwyV6$ds8%NXV3VYO(O1 zp6C6&$KLzc$Fcv~-#_|#inZ?bxv$~8&hxx(lqZwQi;Hh>pO%$jZ?+V%5&dSEps}Uz z9A)Tr#nhppp_?~v$`HuDfq{XxwxRQMEZ2@**WgTONKQ!+K6-QyHFaZsJy%?viT|r> z-Q0BH-+Oun2M5>4WNT|{eCW6N`BapYlp&SFzkk1Y@q&kk=T=ylscD!SSmv;Aq2M-t+8B0uGYx3MMc|CnBRPi5%h)jL{ePJf1 zrZ;y}Z7Dy`ACH@+tE+qD$Pp?kD%O2rbvA8Vzn*+3eD2)2;o)IhJ3Gajwx-YZjxt0v z)zyW^#N=jYCp~)f%cI1EE&96rNYz2g^p`JrPnk`bXpn}YeujOeW>E8 zkU@W5?Lfk;vfCFY5*2goksH6({B2ilXCDk4YOAsG)>&%YFr6xSD4M76+jTL4tQFfZ z+|bZ)_Uu`@aGUJqolcAb0s@k((HiM4i_u$VUZ?8t;Ta{k=rgA)4KLGgv9Gnx!?7Sh z=jY~%O{WYwBrMM#q^$n=)6&Vw>D;-!3=y{Z-)0Wy2R#j8{%qsn>guYaqjUK(ucqe)QZqaxzKEi_5{+^GVt@YDa;_1AeP6 zd(E|&b0<@qbw5Sulr827wsBuQ=srk0ulZ**^waU%Zx-xrZFk1h&XZj~+c@m+^g5T>RNm!l#zi$dV*dP*)`K`Z`Bg0 zFOdjW&c8mMMlE!+^cK%OelE)2LcH3MLX;0i>HZidg!x>l6pBa~ZRGeYA*MnvTkZAv>2Lj^U_MB>b6?n?o28BNmW5lDmNs^Fa>47SX;L>tDpgu`6?z#o zFYk*|$^L2~yB^RC2}teIoS&Qf1-79Lc6St?a?!DP*u zNM$2>C{)oZ&)Ch8{#9W?!6%%Rzwj1cpPz2U9UUDclApokJ(aYkrlzQhj?T_SQ*Hl% zfW^YhsVNs(mzMWsKlMfKJvU){}d;puw1cTDFEA9!W(>Ob27e!#(=hQ35Xd!iE07mvk+_(q!9_K)?`D+KV$oV(UN}3==Z7_~ zkM$Ku@Y7Qm4>+@OMo9H9PB2c}hF|_wY(oBI7~C_xEmL2En{Hr=rsa+Tr=YN~>!SIV zndrN9HqOq@;^N}tYyNx(HG+dT>g(^A3MFWyi$0Ar7JPy*N=_EnWIOQG{N~u$n3?My?ezaB#O!sQL5j)dnfTj4QX}x@=Nn~?R|ag zzgbQ{N=lNJmR40!L3n+(dAoI=0L6{dsXAHN*+@aFr?)1jW6Yc59K+9gW@dTIM-Gn2 z94?A{JIFt~r}NQSe_8j*8+$tc251+^KhZyU;vk#p^CT>7+@UXDEv_KagD}$BD_hq$E=-nvW@=XbwUF_hP&TuL zRpzgdodlJ`nOAPp8x>ATB+cEtOTPJZFk8F*g3~?6Jv2`oM|X_{vZ> zQ5K*JaMNAMUfy=i=cXDrKThAt$tKS@>mqf$#u3`xCgqMbH=FE)j~zqh-`;V2YqHkm z{BI-@2{*=Mt!cZNjpzlOra^iICyW>k=B6@9>Ea<2I@QB9m-5%@Q#bu!1Q6WRr6;`A(S&ukj7ImW~f9zZ; z8NVycHrr>1lj>cItxr`0<>YyJUltY`$P)2UZ``=??mtH1PIPoL;`)!*SbVkDXv**ucJfFq~cu`P6)){Y*kA)_s$ zt@ZU9_@An3mCvww34{R#;`RDLy= z`F9BXjSy2eKat3L^TILn(+KhCveVn?WzP5?^&{)Ibr??n3E+P2HsJeLB!rX@zURT{ zy-&=4b^UkBq`t~<_KSXddX`7THefr&uZEv5+LHXs$#)utQAcHDW!L708xn-fO38Nk zV`zFhB_%~(Mn;e>yzhI!&Rps_fh`NS8-{tfxjntTD>^rS?w%TVrhZm{c)DKQfLn`< zEuX0#-@zT*tQ;I1CM^X4cQn$!eED)mi7Qm`_=ywkD0rfxqNh%sl8^w(a~D1)B$Q>h zJ+{uqR|eh19{9%2&JI1%!{eR;=k6UWKE*ox@ofnOFU)atHAGQvh6sZT7ZM*n{Cnrf zex~~hoN8KHUG{z9D>N>Puh)b_6;lr{8^+YyiY6+ieid0TbUqj3BY*T@{lY?lRkR>a z9Nszc?%iwEijHy4#|CCUy1w3PKV-D?*unLs&$%sZ>5I|ZhR^(w+gaw~~ zOTQ-JNFg=bzWT(@hKP!0b7A86=;#c(KgwN4oJo-j5_Bg89#h{;EL^5EKeQP_v0LW^ zLKR#4`qasWMNgdYlX-$)`A8&gblm@)qfHm?Io(%&_b#oNg|nsQ z)1;)HvG$kEDe}{Zs>#Wqv9==%{cqoj>d!uY`0#dQq^FOMeDFFsl!{SCM&=9p^h8(A zuV2=5;m-#jg;ZO6d3pkh7H4HSSX(P{wX%kUsBvqZJ6HMFh+;b_UiarbY0sWLDD|%5 zMC&|b;5C*$2Yvo{R19nD>q=ai4wRCtuZhHz<`GA*5`4Fne$C8?#MH($45y`~9Xoam zl|LvbsH&<8{|OX(;J^Xl zSX-vV=kTt^7cXYj&S+GlOr)hMxQhd&Ru6IRUy)IFkF=!ZV<|7R7h&8Hz;YoW zA@81K1f*2VY|BZD= z2M4VbIW|VdFoGtUmY*L90gIL|7Hz*R#BJnD@lq!;ek@+JNs*Gb(xWY;pD4uO>J#_;V@ZP zbYwD@llc9&b;Jm9Cg}ZS?t_E`G@uk2zqd;dqHf_uua0~%d+(i-n@hu{(ca#U%uGy7 zWTxlK)W3M)!q~50dgsp{W{60U3mX6PXLHQOpX1~Hb3^Jy38rRdxn}QGxakbiSc{8` zS>*{oIy*b7tE&+Ry}hzxVg)%l=TzN}#nqwUv17UYXXOo?D_*}aY~c62z=6-X)T1_k z{`P#y{XH_`+FOdBF0^6`wzT~D=UaYANJxA82Fo1vzJ2V{-mkKrO)xGden2Q6a|` z#-7yV;^d^MsTq0u_UHO~W)_xq-@}2auikk1J>vW5!bL3IL+WG+h$5qs_Y#BUIXM-k zN%2Htu&(ZYY93;c0%7Mh_HuQCa=M;G`|B;kMuvulr2-E%(mmYVT3TCYJ2KCQ(QpXy z^DD-*+ZW(K&V=nl;dy#?XQF1;*RS?=b_@&*?+OZT3gxr0veMDf?bsHAiXX1@F=2;r zDj~uYPg)S~?ClM@az&j#{*DD*I2M18h>I_N2-!Z-nTbM)kc+!}S42wcqM6y`pFgbo z_gC6&bn1T>S&t7nO|U&keb+)g9a!4~73{w6gaTLWw(Z-ABs_=o>U?Xf?n2TD5fMu( ztNo0Os_A+Lj&HyHK~R?mETbglzIp}Ni#WrbE`9atRY8FmFYid@ja?uZVq;_X@86F? z!^e)vJ+GiZ^W3?doSb9){QRhWe0;_3gEgsV!W0!1?ZqB-mEUU^4wMVFvie6x=Es*W zS`(iR9i+7N@MyCfEIRYwMWQ3J%la=2RE4pebP*I1S|88O#eMO(dR5m^g&|^iV#4v# zr8L4Df%y8auC6rIn9}#}o12>>l%BP<=^;N03JNe#T)%!DUp#;QT-s;qW>^>k{+YVS z%NH*io0>o);Nt3m)*~Vs>g&Ov;Cg#|d*7Fq7MO?KuKxAz-AUAet5>gHzI+)YwuME1 zUf%JjPNd3{K5s(0K0mPdtDKyPi3u+nXO!{tdV1b}zM|vbQS9#QG>}Smv9 z*BKi>d1_q4$QttIhlw8tXY1c_JY zkd}8X4(+TOGNPxa$A>5l^H-QHMi{+(IntV@dO}oG=PdQUu(;URJY+Pl_LHX;CrV7s z&CJSClSW7FJBAZg(omtQYih>-`r#}v@Ot?8@Y1el-Krj(#jRbQ?CEZ8OrqGzNKD-6klGBe9D|!G76OI@99Ago&6X})tzf$6!o~Pr{{gZ z@)WR2=9iNE{Cq}XbFt&cPoI#yt@I4{1Hm%R?vw*Wf&|{Ci1B z*>aKm^|{e((p^7(yn4SzpRDEM<|gSkHw1j++Fy~w->CgWhBU~r@$mlr=llfJ`jaP5 zy12NEuH3C1Om)=C>80KusyO@S+X)tyq><*7YBFSu|YW}Q0|Kgr@)E4Q6MbJT_aO&k@b%w*CVQ9NS#HOtz2EFZ{NO+ zsR^AA$fR?{qok~`aPe#2CB&~Fg8e+4?KZPt4`)_o(Wda0?Yo>IxpBUGV^9c6c# zN3w;=bP33gol?oK*GpyCr;+IB0J!sx%MLCsUs_xDH(jl@!-?mE48x3IqNDSaaBMH> zzbbYz&H|jm8hJ#1qdjHa=51`y>XMbnL)94eOb&lO;@iBu*5o3 z6WWUx6uWk%$OoG{3W$Er{c!Q(e0G}_zs2(-w#t_j3=9p|SBV?a z6gNmoL=p=%#o***4znUYE-I7b!Pe?(Smm( zV`FQZ_~_AQ1OMHEityfh66Hxz`(Q*hC`~|-(yEd5bpi@C62V?(ee$0`K-i~GIXjwk z*pjt~NC{l@$nW1Rhv~wH*4I{_JbA(#W^8xB_Bv-rZzb+)RgVqfNW4aEZSC@(Z-05Z~2!IFyFp?L(z(k zjs}Dsb(g~vC^=lMtaJ?wFsc`uy12QCl+s@}q}{y(9eiqPYC^n1xmNA!)%=_sXCI%U zniRm|&TJ$6u&#iT7dPEeNy)!IO0H&!IJ>)V0>dLml-FbxQ59;QSQzN*TZ%j^E0bAh z21(As!eWr}t()^8C9a!8*8lmX#?ATsAgtk>wOcTYi<1t zC++U>9!ggcYrPJ?K4*FX9DFmP!y17h;Sfr+@5d30EoEd&jbz$B_zeh4*dRoQc_Zl zlXk`zEIj$^S7UegytueHuv}fScEFQ(`=0OLhdfA-0i2wj0gI8whuGO)%iW`h4F0zg zhT=jOeFMRKA>@;OSRy5qm8-v#$3Mp&F3tV`{=A_R#X&coxaiVxwS1~72K{ree@e%w~#nj{%)zfwghH)M3H$chm9;_K$v z=k9(~NQfb#8m;kx8aEu(a;T(*j@{4;UYnPX4-Iu}a*4fcxDkE(9O6hA=^}35cJuOL zV`lcB{S_J?Z*b%uq)x7Sa}Lu47MD=9hEbrx7~?M>RA;m#38OA#g| zu2_xqtS<*WN*-5Q-c@^k|NecbOPvZrd>wkzccW_ubF~dZt)TPXQki!2IdyFbLrDbcKhmAI(M-KwrTIGMk<;73%AEYc`Tez*5 zsxZk4EIO_*F*>@Nnp!FD_MT8h%1}j~ID?mTiJCRmdH77AG@?1=D@ZYnn+%D4Ia$N; zgwx*K`xb0+fyVfCSbPWNd}lr&A`1)SYX=t>7f(q^sVFOF`h5L|E*vo*{I`k&vxC@_ z0bU&8L`4;mZIrvRy1Ggj0TRK?HmbVEKm>E#m&f=ue!r^ECJ2=j7h@Eh!asux0Cc*z zYJQMX+0U;8lrdryFNg63U!aPgQB*`;cI5h5uAkn1gIv$&^kS{^?DR2-Ue0qZVb?oM7e$<%-5TlV&?9#<@wo@W=|IqbybdEvqpM@JTV`r*;hj6U@p zCZZpthdSco@;X2ND!NU_Sxo*wm#CkB4Hik%_P8BT;*gy7 z3i5k56YXSwFzsTotD~dI``GaCJ(x{}*sd%Ii+m zK+D)dl7uT-Qg*@;*kvk4Azwc~Ga{k4t}e5v$jFOaZ}S$)7_JWgbTc#*?})C$d*n!O zd4R<6(nnM0H`rxM?!NPMlW zpQ$)9jLH|wIzO~P^=N5ndG9m*Oi|z3ZLgSI*96rlyg1z*^YjKG@n0C!V;BghteKzuOKL z!&HbdbPGTV=3rFQty{M;cYno1m$4T#8f)5@TvUCqB6yGEOwx>LO zxfFSEaWBmG;7rTP9^bwD#%J36z=61>C2vo?h{#Cl-Milu6bK3kfJobzYY^VPeS53m zJOWHkPR=5DZ4rpRWg*R8p?YCq0W|@^l&XUsu?b{>22Uh5A|I;4Xr{jxQuXfLzk3Hh z2tc_}uKD7^f{gpGPrr$d|8`BIN%_y#dEh5nCbbL*i@&O=3@+U&)FF6{w*2$&ZS?f= zDtz~@B52hc!GobWK0ZDvDXFHiucyb+(Q#sMFwLE4z)ja%?7lrJU7eE_;vbr0+gU*d zDzoirgwtM6c~Ya?%WAh61RSJd0PA2Ic2N&QYEJAD4DoYZ7nU-36L90i;K7y zm`?!6uM8Oh9p1{c-1a#)vf6G=U|R@MY06J7=EaLO}&oRF}|Ef&hA>JV(- z__BiU$*UtHTanM7s;hG(w(}%w(d^!h7PFUzrkjbCZrG@_)-@Qt29t=fF;lXZ>ZSbv z4B0U>M`P74Uc89BTwi!8j}MWVa=G>M7H|mQa;$A^Y{6cgV5l(AjO%9U^9Pat=9QF?kQ6NsC$oM9P4~$b>_^hJw&uC^%LN3i zw&RDTeZEvz592mtLdRs)`p{S@8}Rnej}k}$9o^kVgHn%q@5|mK*I$d2BI5l)Y0-u& zZQ{0!427Ttt;dofwS$Adeyx*IHcHS~6jO1`J9h3|Tw21IdC|nA!A2CbJD57uUXXQ2 zg7E5J$be?~jk4FTg#-ksZd8hii^o5BK(TXYc(pa_zU7|&b%wC2O!MWX$sT}cz08GL z`SrZQLa%e@nu<~=C~h1T7S7GgJT5Mt-fEBVMdue6FU-!KNOG;n%*uN7@ZoMs%3rg- zAfhn%VAw>j;|N;VAEGciKK{{C1VN8O>fcyj4hjrJ=>chNVQwB>H2~@_89a7>KL_Sv zCMGp*I$?_na`49V#<{a+Bf`TmS`K)WfN!xEO>S#zi{sTk3DK~i;9*h{{wydel0J2c zo`C^u!zB?W6=l|GDmrjcVvPWj%v7)XN)TOXJR6I@Dsip1l<)^S9w|4sEc>zNP+Az zMQ`5F9yoAy=I7}}KO5RmMcs=R{f9ose)^;YaHwxoZJmeOgpbXA+)*p=@NZ zyH9i-gWbTy#3X7zsiwA8`?2J}*jVN)opqk1pkQNF)g>#dkV?yzj*Jx=3^8qOqK6Ns zVG_hg{r9zH#Kbxp8~*?XJBB?p}W!Rw3L(%3zL4CfhxeLUOFY}H3Afk0D+w0p9#7NFNXF}RduG;hE^jTk`eJW zjfDB6{^u`W2LBxhL&9XNbz)Yytz@mK*%-wl(QFN0TJ&dkih7nTBsE3V;4Cp$`SRygSRG0io@oL<2NbZ@L(aFj5_A$syS}!qK zu*&$(pc0MC6YaTH-@Flt8j$3sQ@6JtMQIh|e=@E^7#HI*>-xfr(orp!1K56Wuhdl4$JLq6Lxc7%Neb7Vinhs=@kzD-x%uHsCvmn1^^! zJhew16e&zcU{}wCC_I%azNt7cKhg{qFaaFmyGb$1TA0Kz2`o+ZPK=C%)eaUCc8jcc zE)`g9RDziSEmLkq5I=d+8za|^T{KyDilU;LfN(Y;{o%6I(qJvEXx!DBz55e24NXh{ zbF%kSWuNIz0k(myPBB%-psd8yB`9bOKLmo=v#3{D$7~gX^0>PAkP(Gl8L1P-H*k>C zKT6`BJYi>HSUg13Y@oL4$I6Om?vSXhH^me%q65hI)Pdw3|8JBeUT$j1lx$J_g4DepaC77XABJ^)<|M8HqD#bC_IJKt{@hJOyUpFxV@LrorV(sM`@$N6krxq#8@H{YDZ z-eza5->xZAA{oFs1I(gA2<`iV>I6c)YpUn#*N5O7Q4Lj9cV(qR9=~u1WcB@(uXVk z8Xorg-Ixe5MSoH`>MKMTpvo70f_F61QA9|qtB&L{we&W;Na5t$j#Tpa-`TU*ey&u%sFj3oidg==@Fw)aQ1wD}nWwRN~Kq}>LFn~I)pMWEUiR8J# z82PT^7+Mw<3}^78ph#F47!;P40wPAt31HSJpAK?Gj|9>{blb~RU8;?)uC1k^p$Q8O zZM84>@ZrO~dvtlmnq0B_*kx|l4uVk!?))2Aq0hheJIU2e^06jQ16&?WO+nx$x-EjE zUeM-bW!;I4gm+a-OAF;;G%&2v5+$ifdv<;v5DjRCQOFc61xN;c6So}^2FN(7*Mcu_ zeDnP%8Zg%QlR6QqHIHy*<1}~=Xk!Jq^u^C8u{t^}-)F;gH!4AbC!f0d0y^zp#~*EN zs9*&ZTbE^YxYxKULLFuSuqd7pUt(j=tNPXcoh52J_A7u2FK3sH(CWk zwxtQ9kA&y!hmm=too>Ef6eB9M)Yadb#>QX2H-|OZS(N*C;sWjMZ)>DuSOAKZM_Wz{ zr3g{DHrumz&t~?JmW;+p@rF`!*+3#-b1>CsIQ$Y#)Z~wU*6K1!Q6AOu)Ocv zqBMF#LtP!nVc(oPsixOLBWFp1%^j1p;RcQ&h$ualX}Y} zyl2NY{WJ^VO%RJ2Ykp>?<)I#JCs^IJYtN$t z=M2hP#h>#N9j;tym87^JXz1y=un7TvucM9P+Rzxlfa&dQfb!6D))tF#Hh`euUvNz{PF1M2TPI7aRlHfFpyj%{}*qGSL4-s{Fgl6 z9K6z=g1^A<9klfA^26z;Wf7V@!KC2?jLyutPwwCMTpTw@)P$|V(A>PDwDkMeucL#5 zpmqQOj4XoSSHmHSFg4N;X0}sP%tu&|6$KfPR}fIaIeYu~Yz)u@D+qfc??V+sYThX} z7474Y)8wRulf%%E;n9_4h#B_7DdZsxF-K6UAj*NB_wu5Ts3xq8z0CMV&JA9h0IWyS zfLwpJ2zvRO(-OEnxeZ6l(}@l4{U0s;RoV1%Khf;{QXO7)WJ$r6Ual9QfUzpx5-{} z`~L)hO(_&C3PKwM1MnJfJjw=45BT6vP3Xe8Pn@_4ZOz`A*~?gP3993GJTq6UFGl{( z=Z*YXvpo=2fyV(|_pwRYfHDQI3Tw;MFctxTVSsOPlIyI!y}O{c@bTGC3SMF6=zx<} zoJaKxCvlr2C_V55n3I8Ifxa=l_)Knz*`3m?D$&xtj@KK5%7FfMIJqZnd;g22{40aH8h}kz`7$wkbd;-gtT-|ZS4rgeiSs! z@;{1PEnuO@8U*t*37s7NPUkm|A3cIYBQp9lJxGK8ej+A!z`&UqH}I)|X7HYDx>8`+ z^7SIUv;%HT>SX5RTA=Z;E;qI~U0gHa1F*s{){-0Aevvc>(W;i-UQCx(U;K z9A*(uPu5c&+_JJ}X%`NHnW3WUDY^P`{9;1Y0D$)gH1fkl#lfQKexf`EV`=GCGHHJF zlRIWz6p#+8Jxx*Yg>Q0rQ4JSQCb3}*hMTI;j6#iDPhbB&0yQ8&njr$G1s?={`9@ts z!+9EQY}^z={+3jEWTDPY2iH{JW9>|RElo|4L^K$_xH`0xaaD7kxW(mVC`dk_pXnN` z^C+pPP>*MQAulmG?1|x#hD#;u_3Nq0N#!Dz3ItW*%T(9g_b^!~rb3S&-3>#%81p%y zgtQy_+;s5?3Fz2e9Ub@M<5TbOKB76umINq(xI_=O5B}kLzhoZ4g*C5_-s^g)Ene0q#%6p+f5QJ$mq4{g`U^yJaL$zGHM4kdGMSd zIbL4XGfj8|)pSrQ`b?lYE{H9L6FFxQUDq4G4Z+ zv4Nj$L?iq6i8(#ANVW2Vhljkj1`?$33nw;Pgw0BC*8Db$sJ0F=aY3s^5qe9 z4xnA29gxyovBnk_)8pe=`=g$_s_9C z)7TI zXq30#%$-9YU^za0x)R@zI>>J?3k?CI0-P(8e;y6|>&9}HBf>N!ASejlCblTA>|E$R zh_k!ExG<6oJZd-@~eas0akdGIl zo>bc}LpuF_T(je7yaq!=VOiNL@fzN^I;h0R7_X$dT#$OmjiU!O6cnxziA4WF1*?u9Ah1VNKeN&s^<2Sqbe$dmJ%%s*6GuEsAcCcB zrT;kKR#Q`hYsYZ3EG@&X9dD@4eGk`?bN=dEV?ow^MemHBnhN0(5FFz@LvE6AX;fNf zq}2Zdo72fZ^0zMz3JVHiTmk!nfhusZ(};#$25z7;J~F@%x;^q8>%=#2bO!sHh0e`5s6RM@L;Pse29t5*nx!g+@@=|27uEa;1b=an~1dSEC7Sqnsw@fHJvEz~Et?^pE< z4IjXCfX>-DvKB~lnouYUgES>IwICm#+PQO(MUWye+oC_s&CIB2YO->0AYG>?CL$st z02b|?ojbp;&#*lmtl$SNKtbyCM`$H=>;+otdNEeB^M6aI4wE~q;D)WXMmfB562(^~ zE%TyinJXf8v&4P;J)e!-%gL0~Xu$g4^&2s7% z(%+~AA)o*1RisMVp@4>{ZaAXhCfw=y?~z{=Ci*u|*H!QXYW_PklgOZYu(~+YRrL1l z$B}t2&4J~8H#S*StBun+-2Hy?H0gzH<7#m`{$ZHQc2;TlYb2(wGH7A1QH)#J8axwkyU`E|$t?Ps5^#hcTHe?k^i>5FU~GOF1I#}huutDU5H^ACUF%DT0- ztMb-=Cwy(+zrAc?*a*Ip%~gvX-z^R?4H>Cem6IJTEiJQsnjWIgbbWUt3%ogWAqq6D zDrWXo?f!3LIsFuHFSl(a#c!6P{|*bcBv$QtNXXWGVO2PpNVT->5ioevv+h&1aP%lK z6|w~`eTgWV&| z@=`SNzP>*EdG580q-1eoJQ{P@ty?_2yk4(FPM+-Q@6WyDXn%NOEi*gYv3w*tGSYsn z9SIE{YheM!@3`j90}2FNJZLT#bC10kGyDZ(^!!Y&=-cHam4}+Ylcs5sn(Xq7F^N({ z)c!*hWdKJLYU{2nQ6Bstzme|17}qwljA`Y9VnNPP+lGQ;eTTTrl&o*t?ID*j)XOx= z7q8)SF=X7k-ZdPaPCqVH&7yrkNH=tXeh-&ffx_ix9@bM8z~q-GwMko(WrN9Eyo^7^=db&(L&??VW=|)9+kXd!~Nf?H=c2f2xKZ z=JGwQ4O8cS?sP4tX;*5!RT%I&_ub70OGd&G%VN3(<^zcxW^&;lXODzTT`#I+sOb6f zRi#JQ**PxTdG5_4?%k@4JI{Y=s#u`Da=w~nP9VrHicA?(3ljq|oeZOXOw~YHlMl5y z@))5#MW(zqxk)`dJxtuza{wu#=tx*3b_N=sEOIg~3?n;aBa`|He9tfHdn^FsE6U6XAiC;pM0WuTE<8_7wF(S;Tsv4k?sBaN zw!ke5yXOQfBk|po_(?Xjm7QowCirie|1KwR{dAL4TzxH|_>o(Ut{}ufk$MDze|9#Z9 zgzhi7LzL+{f+JMVS1#Pti&c89Q;{sO$Gkfr-U5Ckh<0ChCu{M>s?n0JcW)teuBsSl z12`EAdSl583={rzaoHD9jk!N9qk7ZE9?3;iRNWDxpisMwA)J7h!`jx{=0V>Ux99Xp-unUN+jljy^Ek4^AUmHi}i+oBBp2gt)WZ_j3B&Ep7VB$@RCH z_a9=qQE8d~%~z%(H#Zk<1^D)%EI<@kpL7j|;)OXI+HTT=2S7zIG2V8V#a>hP@N zF{ucX!d<3ro*~gc;8#xHuNvnvzb&)hc6G!E*YW)M5Uv2MC}u_$!#f!Bz&&AHR^SvF zB2<_Y41V0MmGmO#pIr@pJl@eTcXs*9#$ZRZdw`r+Q{ZOgzg^}gIygFRj_SF&t{~Pi zCOqnbuQeaaL-1O6Frb<1xpPs*f_>vbyK08m8qh)O8yl%Z6ntp}o=Cy2w=e93gajNr zte_C#uz@!hd*?nq9>YhNw20ZFQkt8?}NISW)S{2%_b+4nwRsN#ct?Rjxi&P3l_f zi8A9CzDd(eW!IPgA;KHV5lo)NI?x5d2YMy$jD~P!f$`q7G4&MXVwHv`?vRZ{<@=vz z^|=^y(C$$arKF_#LW)8G0}n`OLs`4USE zz?wo96_J&epxL`uxerlOjQ2XtNGQSRXzbKOzSCqES2qNkHoVmwpTV#mo=qp3%)zJ7K(Oa*1kerEC~)_BSInZ;+dtrEDpOlVpOAWr@%~JZlp)i~OS}D4FgJgq!xxye`j;^`*5aJdENj&*!ua>p zW%78K(4^cl?-=XogF>@18d4{SZbSoYPVeg${bc$^Qi9Fl>^C!80|kw+DjR0oF&*u5 zlxY3$w(ceg4jr){2g`_q#a0XiGWqpEdDv6GgZF~*sBgDXhB76FJ-tj&2_OO|3T+@C zh%x7&mcu!5Hfsr`EEMD&coM7uwzOQMy9jCkO9x!B8IL|*!;>ZKq1@+zW4|Qw5ck>E z#-_zy43FpNTy@!vpQXAnY!u=7ZQkn7XPLFaB()}5KiNxX&2CVcoe92R9w}rfv1rWt zw$f5$N$d5XWY@bU%6B3}?Du+*EGY6IHcjNbSlslFS0(+V41K`B%-oEFv={p)D!y~s zxje|wFFj!5R$VT`*xNpLMTO7F8_SjQw^Vq_n9Tg;B6Iw@0_HkOy)zP#VlxRaEBP0oG4f5EePjx2RcX=*bTaE+^ z2k-QU9>1%s`!0j!1hFYk9-x7f58*5!FUgekIhWDgz2pbvQOHkEi;I2FjxYXu=J6J( z9@3(s9>ewVo3E4KEH^PVWj_5B23>uAFR1jO3!&t;q@KYNrVP7lPxS7W!kZ@!s@M3@ zLiOmc#QB7Bq5g-azskIChNi2#J2cRpIbQf_vBJB(zNWA7*WLGF-JiGudMI|@YTn$= zgBKpu`8JF3$w?S%;HrV>drn> zhAK-DO!lrM&1L)0CpE!T34sHYA1-}`Ig;6ng_@EQJ}kHmOofa%q*v=Vu}=>- zQE^Wv*2gdc6!GG>->!705LS*-+z5|~!ZOiBXer-E>~?~w0%?sq3Q{qnazOe=GA$f_ zRv(s(=3eG(m-dB*6YihM7^*tz4-aG z7`c^|iQMB%jWL9UG>(nGwNOy{*Eg_jr{fE+%%V^5vX7xK_F7Q178W4%QGxw>c^i2>~k4&i@AX7XhJ`lN6$=`1w7 zU~+A6L17^t(bGS3Kty4)uo{Sa1}`+wOkCV0=KtCHN}J!EQA6X#k(4#&BW5P%yZie# zZ!zReB+fNW9%5U*4k7;j{hRAZx&zb{2N)Tby>WLV#fV(AyHTKO$%T+_&C2}^(uR8= zl0ddt`1;V<+WO;mc*U1H`R6~r)#0kib4s%R(k_pIQbDme|3Qw!cE(N_mH$U z9SqlAXw?AN?vGVf!Ltz-fwR?&xREf5Qmq+OZYba&8VAh8}%6|ijbRk~Dj(8#TzJ6M7eNVID+Bz$~6^Qo}ESg&d$z; zaMZ3XKRGppeF#vL*fT1HbPU)8KU7voS7+xeiHt=fYKpj&l-yam%?1yTkFqd5BV+JF zt7^s`2Fmd(V$2B*5HyfiHln9^cqoSLMm59U=3+ZKRYy`z4n{e4xj;!6X$+WpK8o8m=d${?LCH9RwN94pQ@BAD$~#ffK6|qgU^4 z+Qt*+=`^^31Au*3j-XNi{3&d}G6!834-rYeeuQohe0;KVWYSegZiTvKdDqAeIfbN7 zXx-l4OWK6FIDAYWCzX#7^xzzTFA7Jz*oCDJ5V5K<*sr{woSf5VzT%wWs~*Dw04~7C z_jA@a_=$UAF}xG&8<(eLKRhe$tJsJu)1G_C%QKtsiHXWP&fY1U=iOIbK_=y`EY`U@ z6wlMrpVm2gM{lRnQH@>cXBn@Yoqqpeo5@$(5Zl$aZ=BXtrX*jvs#PFNdJVN1` zuv=oIJD%|Lxg+b{jZfMJ}3&W{|DtYyH zV{2z;&^q~4sNz`Y0(OdVL{Ja#6f;yRsHO1v2K$cPZNf-CjI~!b)(7`2?nVQln z{=LjVd|g>r_PmOnnkvb_=X6^;jO4=}ry{Dsv18rgx`qR-5`RL&A!-#hH8n`$Aask0 zmZ5I$rKUbe9tX0$^B|lSzSyxjdgvxVrhwaU8^N@!vir2nm63(0-@ok;+Sv4UbtPI> zZe!iI%jW^MQQ@C@x+PX^XmAkD13gpIz_pddJqLKOID$P@uxe1T@hAWQ8~hFsePCu- z5%3I#-Fx;xMGYLJXL2GaKaUVe|CEy@o$|@{?x8mJqANc?FaPN*b-H5G{KxMG%_Wk= zjKI#1bBl{?W#4L+lPz#*YgZldV3iWnFLwE`8)JJ8;MvZ>f$~BXv|2zvYKp_4rq7?h zimQS7WjgW=!V?>hkYy!YeqgdOHZp>~10@h{xyzQ8Sk(mug4qHug>UUeU=`BS@%#}KGeos{6UaB}CnkZu*M$*Hu0Eo7|xC+lk0?tLL<%*?M z#H@q#`!7mE5T!7ut}Vh|vrZbB_|d)k4^JG_I|yb#L6Z{`4FhfzHbrY)w|^PoX6;V( zAs*^yg!m2V7!x6J)=VanjGoy6pITTL_Eow+ed7M-pAe07?^D#M0o-&jNC5xj!C#Mg z1|;8ro4IQGhwriy63b8$-UIb01(T zSL|mfUC=bn%A?}R1g~S${WVtep3)GtnXL&sb&bOh9ceInEm~o(Ypfn%a#Pu1KjTkk zY9Z4L<=#Phc2{K28+LQE4jai*{-qZ?#t`9!Cn~^mWse6oZH?XIGu^kjZ;FG!0|c<; z0X^s-8Cj^lpJR2tSsrWRUL;DUV_%Xaw9lN`-bRDT3o_Mi%(EZhFr&Rux#<>{loT|1 zN4?UB$If7aytACFiCv+y(w?KV@fyhEy?gc?7ZFj|onu}uB_?*z`WOoM<}SkDL7JN_ z7zbD8oDR_ul)t~bWaMiqMG_w{(2ou*deIf{=$%G(C>8^${+(RrHxIYc4nE%xixr#3 zJX(-!yS?%7ANVyex?q_SM46s}0sZbB|7o-c*Y+3Z=4WStY!l^!2_t#lii+D5u}3tT za``jXM}ff5H@kC8E;~4E(qvq*Pzf<}?}vXTJDcUef%Ce$nBh+I#K9g3srxV&7mVbk z!5iz?0>EnGO&#cG@G-$2hKC;j6C#u@V`HL-d_m=o3W`N}G(`D(fco?ThIr1>!j16F zzH(-_fsv7qdEMZwlzcSSrWG@;-WE-1-o>k~15bfsMrtbj=4Jb*Ko;@Dk!my(r`o!@ z`oKaY#Ft+_h&8G&ybwdIJ_6Ay*dKX}?Neb|#U0yHIaE89u?%yD?nY%sZZ6Slq6_O+ zK(!S07n192u=p6@i;dFxW)KU9|GDa*G@Fg|xY>dqKj?T<(P}w^8qbFqaPtp5IPY7= zKC%PP9OSCOwhc@{)p$Yx^%Dxk)HgVeyo$86?J>1@Vv}5oFzxPx@9}typPzaz;9mh_ zRz*@OEj`zr7-2XX!$PT)R7GCON#sh^z>Xa|0{<6NXC9Vg+qQd!BuWy6G$BzcAxV@B zg%C+an#_a@DUDLbP)RZsA*3jx42>!wiO7^R8j2=qp4RVv-nFf5eSf^$^F0rB-`91X z$FLv!z8~a$kO9OE{H2*XtL8nJt)Os;j~adRCd#a}cdaB2H$I;Ky384ng?l#G2<{NM z%+E(>&JEvkOIn}eq6^B#mo+_pjXkhReOSV0r|jQLRYD*RLN5LAK}7Qn++qJWMc0p| z&+L19g!_on$))7W%E~^`;^9d$qJ1)DU8eu*6q8)}wNZF|=xrH|)iR|wy^O7{4cj^E zjHA=u#n$Ub&b7^GU*=tSRdHn>mxBwAogFruxiB$|I9KP2$-#h)Jofmr@oAP{M)5d?D0NaK*$H^+FtZ4Jvi^i zyNT-Rl15x}TA27}d3iNV-gz(_WvwHyJ34-LZ3k~#vxe|CL`n*`Ep8@br9RO@usUC! z9{=&q8fHeobjYx(@5+2BC@6<(g{%@v48S@bn{L*~*Ezq?H(Fa;`-q54aEx59J$zVz zSAD5(K)@pIId56Of82LieOPWrhSKE8L%JEH^2%u@w9mzY)}xw$RxF@K1O)i?vqwBm z3}@tvAPz1b8pouhBzTgZZQ89{NZ><`&P8LUtMkZaNz`)K;2pC@>%WYfHHJS(gb^R9 z^YyVyjEuphB(C)3x0_%v;#+QRZ?E`%>&PR1UgN}P2snKEAUjfx^P}O39k2sjfD;U> zUZ5?^$e8W0Vzs5^&&tX@c!=%z{!OMoNv^{c+Nwv_y6k|DqdE;q@ZZGY+U zA)7$|f>n#qdHcqV=z4AUdc1sj$L5i+*M1;UTPRbo+Dwz(WXC#J0TSSES&)1`1xAO z%YgsN6M#!ZycF>pA?&LqReQv@V1Ip#&#J$~dvvIgn=^D*sf0vbyJnN-UCd>GL$Y1y zuGODkzc9i0hhPAd!^g*G(Gc#o?J{l|OzU zu#i&%+lFF=tSs3+;{Z42%a>L0DFBh6BWf#S_h*EejM=qwXB$W*c^W?f6BAJ*RjhUM zHlPk7F%asZ7}0vY7l_DBMr{2M>+2r8M{mvI#S^u( z%+{=#sj0bCBW$&mmDTFinP(^~;2(qC>%0G>+be{YzHFIq);a`q_!9uY9_fXg9#a!u zFHJjzs9{ry@rl1NwZh&_dIq>%jlei-{Y&Z6n{^Kek(VErpP9HCn%;^D2jhQ&Q)pP< zn;ZNkR`s$ZsWBqrq~sT-3M7o4eMfhExgQNb?($`OxKgD*`W3I>k7$$>zJ2SkVMF4& z7pH(dfb_P1zW1GAtLZX-aGBoCR5vqT@Ge$#NY;BWZqc27w8r?2o19pGM{+RD8cX z8h4+z^D?3BA`i|JMFc4!o$RP3OFsmFU-lLJ~CU!9!C)xIeK>8>o{V_U?b`Z>|4$BP8cep)(}mhpFE5J zYFdfsZag)74SQu281!^D`u87lxtKq@ch4T~AQb{5xqi+4D}KRJbn7p;QLoNTHXAE$ z<4~5cI?-ugcjrE8%+m%PtrdYYR?j__UCy@z2cF|zdTl|-N%@&#Q>eMus@q$eHEic*@ijf2>%Zr%9x{31vqeu6CRivc%5!n``p;LV?dmp>edgu{8AT@*@My8`L_FkV;wlDhNnP91DBW5@4@-=U#K@YFy=Nkor18`dCWxSyM@{7 z>JbWhyKYH-yzHEzTrh9$W7RPeTweeAyxrEfZu#xf6s!5#X)lpu>A7q%s2+qEQFGP( z=g7|Ld~(y%du^&YK72Astlx(V6f-s4?3KQWyZsM z_b5IZIS7PS?g9fpq7#fnELQn{F{@%&tEYD!eq~II>!lT5c2g+BaNEj1H+h_nPGZ)<`zk1X6FP2bPI)*N!TaFAEFzhsAje|g6X(4 zTo0GNsFaj7W2E+Zc%W@hUi3s*;`-$SAVOT)Nj5e7@8E)641psr1Yf-P>iaI4iK_$@ zezAkfQb+Ck+%mKfTk~kw`ltT=>wC0@GR#R28WNZTz$qX2^ZWOxTen`?Yn2`cSd6S2 zJZe-|p{1Pw5j%5c!dVOCwp5>ymwJ{O7?-tuHWr_mc3>yIxN^=?e;pdtR6<@j-tDED zvb{IFX(&l}p^cF$>H15Z&d)|EDEYU!`OprZ950E}hr;ae4LCV`vWkYr5>;tPZNDUF z38X~*d)niAc9B%4sq6RQK?!No?o*5XkALfLW^FxOc4LZLgV7kNyPyAXG-+Z_=Tu~v z*W$&qV#0&y=&IjwX^fMYsQ+wi17G(OQwP<*wDbP?^Q-XRK>$y%hejWC`gEVALz$2I z8Y+gouuoQylH$7n3gdQhe(F|2h~uVB552qG&z4Zz+1+)Vw`$dhaf zH#cU{%iYvmz|y?94sCq}EAPHZhIaC!@Y zMUIyWCRsh1JzXi~p-23%V2zWD^Kx^R&sR^TG%G|bRo{iVw^{Xo?xXXs&erOEEe~!O zaKr2|kq{2hrJOURpz@?&LGVTu37{S5dyl69mkx{&)aplL8yA+cvHpbmu-|+!JVE#t z@z*I@2JE4cCUnpYaifp9`m-kBYc*7F($)BTUnp_r&dtJe3ngW*mnq9wg?VJpGKJN; z!5Rx+Xp}^y)`i)5H;5m*e(!sesdfL<&wrogS`P{sDG|D^)@1v+-uI3>ds8gGm&xpT z&6%f)=Y@ZmSw$=+FY@33wy$>HO2Ak5Pfd-I?fIMv8o!J_W;=H_+_H#4)CR$p+GVHi zSIT4>qi%-S%Kqu!MNVJ5*!exoPJk;}Sh&!lF_gvW#@rlw=)kAlN}X#NgpyGN1FWVd z9vL;t#g>(~S3Q7T3c7se^l8EPXol8VRJh+~RpZ?Ei$4WgB$_X04mB}aBJ^+3h{rulDylc_A<9=7lrFI@Z^f(^* zgHHP9{}kA&Py>N9m7a@kZRL@mNcy_|5;7dg^%)fnOej5(R?oULXp$^Q+`%E!fr*S2 zyxxEMB-*(!*hX&1kVYn&H)u?glWocjz*`^#_jGscz>1}^^1fsCv80zavhLm%W@h$V zwu~4uWR6M*-YFeI_hbjo54_~gsB_>Zoj$^r7U0Fyddk5jSKqo{v(nmd_-muF*G`q^u^v)e6)ZJ1MOc2k^8l>ej*}9UiG;1`M<#qXM2j&m>JNeIgG$Hs^9*i z3e?uV_vjHL;vzcDotf=zZGzI#O<(?|8xn6*6@KGtfLzLY8mRw?&9Pj9g6cd04w-B` zqzy;}#i?E+kD2e!Fj>8NWaj~nGDzONq$KPwyPAu3ppKy=;p|8>AbXjlrHd8l!FV+{ zbA%o)u9C+B19={GLl?YB*@_t@TC72X(sq4EL@@~!Ska@fAB4|e%V;_;yDf_ct4X2(Ehhr(_>#04}?S;{srLjEV|szMvPCvkqMS_ zgVKYLf_#*zfM_4u-=wz1^Mi;N)x@YB*#hHd>LQNIpWi@WGQ)>Yr|Ab%nLVpB!(yYi zAOLNCcb&NfiLIRmh_`v*j{2(t&IS5(D$qj*4`!(H)1_r(IK%vmz~Pfcs(oL6|BG)D zkecC1KO>{aH(6Pgq<(tx_=E(%ScSf6f^kxG6m>rk9a2ud&)!KVdO+cyR1P|zvg9JZ z%k%10d*#cq@8!9Y9-#uID&rXu+i^*W)RZWdg_)B|#%|W|Kvd3}Sf>N#= zEb`C)kcpf`4wf>@UjR<=QQ52_bBu*0%+>9eSB%i88ujgdpW zdZjgFly2Hj0zuqWt2M2m!V8#;1JfuJYy%3zT-?{urNmEJ5@qpLJZ_*Ay`a(N;b4_m zeto21=(6zm`0iKv`8zgkas&D#WnPI!zya?Kd|>vk)39h>O~P6ShcH9mNUT4Yq!R80 zMLxz{ZpmN@CQ|y0K4~EZGD(DIHZ}v>{KDHTpxGGR?1=T+y?e6T+9l_wR1qmi&*U3m z0Gf^ovf>Pzd!-#XyTD}0!2bO|EXrJpJf?ccIvq`*{avcq>*3(hE0SNlj)^ccg;J;E_l_L;G?oN6`E_;TO`&U^RTsAKIib6 zlHA3!*XK?aYj}3u(kCJ<{d=mx$t;f92kz(ilyE zhjD!gvk1xRoRq%F9G$BS?6S;zYALF1OmSAN^JAMPaU`NbA5k5t@D(CJPp@hl$T$y6mc(?6d4_PR*Se0_qXqpUlG#em+Y| zrv7WN1oopNR8z~vaug(k4?O^B(D?CHGcR9h%*mLhPVgA;)O}PMSPLK|O1PMnw-+v1 zB7cAKNE-|f04gY(DgN=zwqCXBdQ=qpA6;{2>z1Y_(g$;x>aK;&5&h8`vetpnb$#)L zAAoXtZc)#Bk2(_Kv6YGdyGLehlwd*G19zHBdh}oW4bu&X%s(4B_uSPLR9VZH|HTCs zkzi(QpRc!=Uc>PO4t~>w&H>!MpM)I5;q*BBPDH!{vm##Zqr+q~YHpzIW36g#rP#C* z_%L?y?HO8+Q3HeT$s1nDk`ks`!L*w@w@>IooWNf`e@^^b9xZ+9HYhd36Qn*vgSAMD zI5bDc#aUkTu&^iuFhR)FZq>HOzOmnDC!KSq_}28DoQc;J3pYv|T5HRW6t@i(b-atL zkIX`NOe4PH3CBy&aP1@OfWzTPSy|Qb<3aqe+eomDZcIAgqEO;^{A5s0YL%@x@j5}2s@+A-*&cw`*+ z`6T1U8wc!aKHaSVIYZJ-y5NlT5f&;?8v6F5^Pn@Q2Wr&*a_v?O*0^)Me(mzVPId0x ziJgH_xt4urEq-=d%0r=|xKyPpITYFx9C(8KC$Z0kg=hTjP363#G+ z*QFP9#Ba$SS1^!Q|FKT{Gc?8};d*`72TBWjGq}Pi89%g#o8Bs$3_kF9A_ba%_ue&Y ziYd9JF{wMgweXe&N#jLc9=>n$MK^ERve)1s<75~X((OggMMls4Z`{}j(1B$FwFfX` z|LjwWaYD01cdp!{H|n}85h)hv+w9e-TNBa>o8*S|>@`%_qc_vQfTmnf+cDfzQc{A- zboi`OWRb>eav)17g`)&>QQMqP{eP^jd3a}(2Q8w6fsvhC2+jIB3Q+S1gdE= z|B2(rsRcG|-HL#G_wx<6;^MINCLMy_{QfQn4~5Y>&+Yeebu3uXW>Nj35VkvfLvhMj z%H86%DeX7Pn$iy3c!TMKpqnl)Pmj?7nt!n_V*2j+t5?sUKw^0f(1~ayz915Zp?bJ5 z7X%9+XE3a#>pGeHor}8o(yU^}YT$L-o9PG`;RFXa(A60UA7KR={GXF2mw0$QF;o55 z&|v*|&-m~qhX+Q3>!J$d-B(vtZTl1tWkPsP?~jXB&2M7g=HzsdJ~k-GxjgGAodMJY zz3J0^r&)S?BRQx$J83OI4>DJ!Hr)1Lk`fg2n8YtDTA>j}Bf|rZ(tKTWS*T?PfVGp( zh51*pUcsHm^!$_;%>8ME;X3c0;W;X8c_;f}d;>(*{DSXM`0*oy>*5J(;!R*49jzIvGtvu}n=o-c4{s``^cpWVM z0w|yTUvd(p>gUzjT+V_R2o^!lJY&Yl@7E<<Bv1h%RdnLLGKc^^A+#LU71h;-tGJlli~CzJ(} zGOxzGX{CGKLb3rkpSQdjqtryoclrEsDlID1-2D8*1{ghDDVH_+g@xRk=j0Qx-m9%E zmM<>=p@*A%Vm{B>X5*z8#y$*3iTY_ZO-@_4GI}O6e{XEN7&$a7_u$zeM*9$iAf$D7 zwUn6E$FvL{mSxTuG}h`a>t7^0FWcrIf?0_L2(=oD{paicj&vAMIsC-B1~>ixcuE`{ z4V#CTV`KlYi&IjR1kUe7bB>m~uFL|+7C5`#j4X{F8X7vAGwRk-($eo2J($fJH$_R6 zr4a+e&cjA+n8z@lVkz_c50*$?ww?Y9PswHP-BmR;`%Mu3eu-a$n~l?%pyP- z$*qW$$jx}eeTjF-#0?zV17d6XpOl;@muIAnXdmg24%-?8AGfj@ImIcVv~@gphRJu_ zem%T&G6=Ma?&-hJ4Ll+jld-uerC$)S%8%(&{4&%6t zCypMi`t=JkF#do6fq`|NvXUuZAKjm(%GC`t=w{iTpKuAcoHD#r1k)on%|xjlY{W%Q zwE6Xm7c4aCGfuKC#%*(}8^~X;Z{#)3;`mdFv<$f5c6bSOsNQh?`%-QFV*PzH&A;Ry z9J{sjdH+3c13Ifc2F*F0^hZnU(fPor;)=u?lUJ%$#&Wrf{=TW3 zJmO+huv%LAnj4O5CfzjnwSSSKF>-?d}$Pj_h0VD%O@3~15! z-*2dW=yKXF#jbP9iWFFSO{?Q7Ea7L6J+_Zt4v@A+c@c%-<|*eLdyO3yK=OhJ!kqJ| ztHhGTTerld9AF(G%m*O{Zf|Uhzn+?E+pr3X&df7OaDIT=cszIR2;g>;G0$Cl{Z{%k zUpW}Y-P1eli|k_zP7UhD*2un*)yl}@l!NtJ)Bu_2)`-$L^(u#6z?MZqm>qs#(?NgV zWp7JGae?1%cyW^6Z)N7*>F>j51^V2Xpq%~i8$L6RcyZ!Q-(!PeRtf|5B1Ith>DEVP# zw!YN2SV*p-me0(raLIe(!+8DZv19IQFQtJ4Q=r2Q;(0xOJi;%-@S>EJjiVjKh;PiG zfz_B2(<=@OIQVa)L+gPxnt9-e=|Pr`nzu7e+lb zUp7XHuJm=zp!;I-r)U|)KQ`DMT;~?y@FkjRY6Q@c)c6ELO0j_}{V5gs zdnp^g>@BloD1m+TL-}jH$5PkvCn;*Uf_SsJrp=~0YJB#dd@@&2vh;)5{VnHAMUv)& zg)Le%;QpL*rt=jgrR&VyQYO4IU``2yC=)2HdVkf*m7E~^MV}JNy@iOlauOfCRLP`~ zZf&SqH;KI24aC+<$6=AgVZY2WTwD4IW6l#&ZhQS*RXuAe22ESRG5+b;ua~7qPBQEx z*bc>-I~2ZGRi$>EjVe%iH!{iQ&S$-pT*vi4cU3f}G={|{bv^pI)?Y$?mc{<9zl#4p zmT|MNS}=LaB+Jip*VBq{VYV>w{#)Iv*KQ_c*o-Hu) zdN==?`+a+_z$xo|gCXA-iMmv!s{xQs!2-NC%=MX?7gO{QmBr^Lt$p^s?9Khn?>4Zq z!Lw}_U7DSDTi=&=n#LB1n(k0s`VMqymyfhS&A0o}SBfiv@PWn*muRmAyJ@pw-mz4o zv$Y{}*VOi#+@d_NB*s>&ZTldk=pIsIM%j5>$AYniXYB?nyDDc&DOh+*WAz` zB>iEnSEh!H*x^jCo-T9@4af;fN_uh>d@fS81g-G})b(sBb`>Zt_{rA+r{eSFpdi$4 zHcr|4YtCiPyYy_k(Pm%}U_JfyZK=d{Wdp9bL@`f1!Jr@e-T>5?U)z3)(isG(Kldy?8oL4O)V#!s9W8dzPthp&mH zY;(t?sT-SLX{Me#Fss8G0C}PLjkH{CmoH#5uhyh)x&1P6&2I1Zwy1y&vlsguk6)Gb z-DTh?uMn4!r)lQeH;OL37I1HoXw1sEp6ySwT=nCGY&N{QIJNo0i%qYx6nuV_oph@h zFmLeNX;}vbeoksSjVU$-u?zR|enoXi_p~&rlD+#rzx(|$f7;qF$E^+WR>$x4JNEaC zLC^1yuy3O(Bc8@ccW$W@s`398N#9GBUey0{d(~Zj+3$+GLvoH^ z=*+yBwEbJ%6~T9Izss1t9WF}7x`zq>|G6488p+0*tTJcCLC; zhR5^leZ(<_9nLrklghhE;a<~19JFL@G(LX(D5C^s0MnX!&xNWzL?(&DILr z4d?8-j#&WeZ|sgGNgd7<(%j0*J9@`j7N96fl2-up;mFdfCpUP0cG~OhjR9996eld? z7!kB;vPg`5ov95KaNwDj(#?TgmzK}Uk~xu6k)4$_&_lsP)&i}WBsN7OYkOBM2IYks zhSyrUf~NBTrVT6&Sy%kn1=ItMZkgaf7Sfk^YE8; z79+nVyBP0U`T5DGekp%{NVRla6Il!IU&A1F+1(K~a}r)-bfZbZMmK%pM2*cVR?PrZ z({cXz{v9d`u$y0*DgD*~bBb{|bvM`fXV?=9M~)bgZrWQ1Jt7_2f98wj4XvmT@Ga$z zX7<^H-)vdt;hFmS_7#+ZRG5OM^ybaSkM)kMh>eS5Yd5eNBX5G{?njg41KGDCEhooT zP0{az>}S3j2eA#u?!C<`Ji{MX4w9<6(*2;`{rc%wK`WQF&YC?*ZDpKYyxW4IQHOV( zOOKO3`0VzYIHz@6yz3mC*O+(M``e#L|FO8@`IPM|ty({%HW&|w&wSy@ zIri*T3F-gB9StuFR)jmWNgIR4ts@hHJoO19yE zjTy5JW)e0G_$06>Dxl&VmDxVl85xx1@*heF|-U#}PSG+*)VNJ={vSbbCRo|N{&9;+t} zQ4K#Pek`xP?tOWjW5B}$=>__iQ`UyLJ8q~d-Pce2qjpwzjpr-{F@^op)1f^_4(k=Z zq~DNiVEp}kzeddT5bY!UzIgwX)ko(_$;c!*WQs@~?K5?jn3xy}(!oZ2g?JzIEutTD z?6pLdK(Vy0!+L`M3|0*G^6uS``QoL*Nlu@&{olayw;%VW;xYSVRPTH`q2FNx#9AFPnx>VeqC9=%C_*w#3J*1tiHB8k$&*0blTq?aq_1=botQB z5S%eG08tB@xLV+(Y4WBvmShLK!Dh}2Uhi1L`D9FF;NO^K;wg~))0#AYT6R5*aJ(zK zp{+yP#Q#OChFXcvfM_50FsaFN?0h8O2oQHjI-CnhBfu`G&BuZ_2Ntzx7+Nv#qXhf* z*yT6>mDNaVw{0s93rw42x!wOrw&Sws_vql*IsntjfsTWdDK==Yswie@>+S7fCKl&R zh6}-U1hd@HH?H(IjUoo1xV1HD#v16}nzhCLKV6lR69JWUC(uJCyWOR)!oAKl?3SPp zPx0u=m~GM1KCi%Xgn#DnB~4Gd6(b*AO}u-%*GI{aQ?9L*cChlo?ilPlGy7UVd+%0|B^8?4lIP~cQNJ0TDR?brdS(zpC0 z6HO*fqDs_aB7Q}dDSN64&YvHl>J%OpW_xB@F5Xu-K0r7d%C-r7_<0VUtjKdArLn8b>jA7f*iai z5#w^CJR1_=e_|UAkvZr4E~pUphXxA2DL*)PtAI=S!S4U6Wkd zV&}8kVROalGnNas3~niq_O+|B(^{IH9DDhePxr3P+j3VI5AKzBWY45v4fnt=osu6F zq>s)epwf8&Dy&ZRd<3INW=6^D5qS!TZxoLIcUE@%U^$=xK_l>W=4A+Lh*26}@|9d4JpJbKFb@zIp(x$J<5mo|AYFvo= z6EyKy(1Z~u&H!G)p)TESKj&3kuLMe(xpsCvH2wr976IWhGE<^gMwrOOwvvY%HNr41 zZ(%CZ&Gg*-y6Gd-SPJ4rN9Q`)CCb@ChQ&>R{J0=QWm`#V7l9o{$(kby;Y$Qtq!^S! zv?*D7nCS^7PyYVSkG`fV4D!o($SN+VKv!BTB)8HV%8W1+P$VgvV2IGH<(wG#v%LIN zM8r3oMJpmu)TK>5^?!%caL_r$;4Bhauy%!%b(?~4oP_VI8)7Nx3T!`+wu1tw7&WuavHwS8)IRK zj6#MYL~gt%;4#1kxp|YiqKdJtRybR!0%Bi5efoG*0mdmsru#jyCxPgO;7~O<=S{}#I!>-Qk9;-uOR9w7x5o^|1 z+my#}S1Ec#m1rJX@%nS-Br{3cZ-312Jb#WA0qol%)sGz;1)9yjA!`vPVO6|NDXFcV zmL@!4s%2cd_=TjU;gcHAITqsylYJNJMY@V?L}b z_sY3}3pQ_CG(qITfoPNbxI3{wPpukL*Ef5_Lhru$^*}p}3t!~t3qG*x@#!z)sKEMN z6-0xs3vCu&UKH`l7dx&%j#WKeR$bkW!72kSMSnWqQf;avh!PKW>)$dNV?^=iQq(hT-Tj`joByU!xjd@a9cCSZ}Wzrw&Kh zad>gkO=oaBeflG2-z{QxaIzR;I_W&o^k9e!NQ zhSCIA0DA5c(?Md)9=()~I*uH>oxsM}g4wZBS3=I?TQzvX2jcAI0qrXl)%M&H02A#E7ovM+LSX@zuL9a zg5((Cb@-25o$pa1(Us63So@*>*uA%!Dek{Gbr;j+Q#oDn7L{) zmJhb)r=-Vmxjw|%LGee5NgIY_$$qPujZHF)F^vw)#D9OCRC~v}G3NX;ox1d!YpVMt zcaAQ%ldc%qC*|-Omo534ZH0CsGMn}HE%qyo^r_6Mdp1+T^z(fZzu!CZhbJqZuLlAoPkrIvs+=CPaE zf>?G~L1){S`*1TlJ3X`AwUi+tB5o2F6*c=OgtMwpFtsg4Jgl{WY?)cnh37HV91ez_ zEKI_J1`J~8&=)0Z=3Fu`i8KS%I6AjF+{Tyb5mo-@oHC{VG&r&Y84cVC^joQOp$jc4 zQ+pN`ovf4u4ZY4MWFysvs+rp{7{{nsz)!n)5g}V?neMQF&~xXeZ+LZ<3N7;f;@`Ul zjy@)ENKO(TFnk4@lo^!@`5aBYEfQo0(HGVTu-1`B)hYWDtXPnIA67!k%wcy+I$yGo z=Cqyo3F>iH1QdB=!57Y?pT*SOyonw|L*aTIc_48@;v6Llyz5(rS6nmv%hLO7q)HP`UIqf z!4R+#6FE}Qzkhp37>s>U0xxai(=wbncj3Zr-HY%rUq?OW?l?T=UA2;Ju+e2<8m1+1 zcG163;NTKHMC>3d;OS}2{4fzMdE&Y>*Z{nWMcuvGQD0Rx9Xbf=cHL7%P1=b?W2AmP z!L7wa6*(;R4279wTwh-c^kx4!zfPJskyS>&Dbr9s5;bTtX3fIJwAp(U4lbLvjcxsL zQCmC9rYYrM+P@uxjuzi~tTr?4d{)qk54*S3KFmKi^WD2?vA$|P#on+THj8(iypYdn z+wS>Rd3=m|U-QA|j!bu{-SIuID7m+CjB4`Wlkta6*)dUv7LU*8P)DiX7ux z3y0@$f)D%$ium+Sqwc`JFZxb*+AM!zU))hHL)`lhta*Am#7y;gKmcp&5voJ(LGE3b ziU2;8DVB?^fONO=DikQIGo)h_Nu7kUIQgGjEbg} zL;H86?k=^Y_@S=O{9f_iHRZKJY_1L^8e9g-(3;Y4iA(t$MAi@aC=|aCnKSx?-AwP! zPn0Z_9Pi&tADI%m@Bu76)J}OmE>UxGh^Ay<+0T+sp(%on3OnjsXi)Fd8d5C9MUhK8Hz3!FHj{gBZ+q7i-`S zP6OmV8}cqLxq~MMg@mjnMLIg}V!hvlP`&*hZw0!`^u)Lh+y}TH5Od+*@mD%KK1W=% zn7^r3TpP)h)Z~TJqMhCu_&n-UV%k|E8t^LUp?hDF(-7kyCqFrKOLo}#K6!7jqfew6 zA!?J8Q)hk*;>=!C1&pP4RboB(H?mobKK1wa?@$=f^t;x6ozA{k1~urpF(&9GV2T9& z>*paNd`J>_0eU#C?(^rON?G<=2<0$p^R>$`X5Ze(zIS7A+ciA*uM~metP(ZVppfxT!Whc>4B1!am*m zY|ZBG>g(HfE3tIZrj-YF>}__N;*-%C^!)v*RYmbhWwvLOn$z6XytwVxXFI=4U043r z_orpgZr$R7r1LL+Z~gS_rpRgmcF-PHuLdoH1KB#{aAp@3HCKRtVHm;_fN~;fRN~b# z>iu3;z15@&6mkA)Iez5{T~B-0i3OR9RX~ zP$-QW6*+qG)we39XU&!^6DooMOM#-Htc;xu^fqOG8Y)^mcR*sMWzOr%DTa6)d376ckTL180Dkm`lRU9_x@gR9EX83hHr*|+;EpX zz)+LO%!iwI#~)1sAFI{>_{eEX?tWwG=k<7KciJrU$Zm&e6un}VrR9y=x4TMux=M~U zAq!gB*~4&dpS~@?{HHqCVfy#rZWD9+%MF&j1Y<>Md>O*2x+-I+Qx4beXgFk?}&xmT}1iG#H9c*irW5m)+0P#NtH zVRz8Kq19KF{H3R5^ZfbsY&SX3cYysFKAb?OMNq+4goOjJO#o?u@_X9yeK^z(J=E+1 zX&>x>k2h&$!oyJ$@Hct}`a_WdlJNa~&ngyD;AnpeOyLrihhhIww!u2pb9!v&pM-oa zOlhe2VOJ1JGRiQeJ_&0j%MVDddg!cVv$Qua7)=S%e+IooJ;*NsPbWY0O> z4Ku}GQUIhnq~Hb|Z|)|9kRhX;FP*tnRK6;{2xD{qH>by&+w0Al5bDjg4PFXv0(JbW z)Q;L2t?GUmRu8reZ50TIAuk~rRJZD7C-%%5YRbwL`qE+tk$~#y>BX;xw&?$cb%%o8 zp1`J#T4%#%t3*XPS3>wZFu}k0gu|0xqka!+UMu9?G|A|j?=t&-{p8dqB~-5UpE5}9 zf35JOw(PuJvP96n6JjI>xtizYZ5o)eR(+{A-0tZ2yLD;JL8IWn9wQqd`R3VhS45EFQZ?4Eom{qFa_LL>GY{ne6mKAR^aZwhD4$U zQYb73QkSwn6dpxu)a6STEsF6`vdYYk+i#5MT-l!@oCq_rXiG_3=#>MvD7leCz~Som zFa5B!Uk0Y%14~a3{Z}queh(osIu8CB^#x>-cblg88U~#|&nS`*NKI+?U`y!J8(*@s z*Mptw%h%EUK#}F!3KI5v50h{3<2BBi@~D!OU%$-CTE|vr=(}(!jZ|6vh9$}2Ow@q| zi18F95%wzjv#)izz^!q`eV?9g*dB(Tno*_+gN>5vFWe{7@NU2aRSL`g)WfQXd_Qou@QZz~O@fls%b<9$Qu;WRsX z0=k7!VthPXq2^680a`LOF)_Q}!@y^hzMGkyU0UOkv!>U%?^M)y9&Bv<^l|gq*23K6 zqaK6&UJTrPqv_OvmxtyUFY)Ti_UXB|DYD2fy}VjQ?wq+rzFN%MPc!yaT}rcAQTSNIV#n7QmnS%^oh0jRpSb}-@g}m+CwyoEawDWiEzOU1InbLLpoOek>Rr9!q$oAfFVu|1~2+=#WmbrLdeUR5yykr%#PW zocNG-w@vG>TB%ljt5<8i162VfjAqgtyVjvWYEnm~A&ml?{O4ga|3^qd$p~j}hyy$w z0v3`9SxHG&EAPg+jx|ZZnbd$KjKg%Uz7?$e#jFX`7v&8a!Ijr!KxmQ39?C2mGiJ=I zh?%rAS6C_zQ-U!wfgE2S(MYV=*rm?u?rg^aVq${e^H=gvCy*X1)bOR_1v6L`imR-v z^WjCz3mAsJP*nRU{v4BzK!m`+gu6p!z*QXj^Hgx~GAk?5cMg!9edG2JG?*O0UuY?| z-)%VMU+1y+%6AmC3R^bUd`*}(DdB!yz1W%mq%>A8H`=@Btrz3YjZ;@t?-LeB!$%?!DKMV^h zaPIQX7SI1XvZZ8OgL>L`%i-q=-)G5}I)yJwx%$5uMVTd(VWmARyHyU>rC|hNeEz<- zci-rCJFlituKU4**>`&aQyTYgt+wfD^?0(qsHnMlm!FL z*d1Zk@F>jK8;gVx>2OgnL#XcY;k8fSynVY$u*sAW0?CTN$VBg& zoDbK``Dy8-p!$PO!Q9=1sGhLi|nOYBTnAb#W2QoaSG}u#kC>?6mC{DZvR@x1aPW zd&8&)aDGB4id6y&mR-)jH-=_Ic2pPszxc5`Fng~=P2&^z*+ zz~z0z2LxneL2c%={#)`sPyqHv$KSn+Goc2!Uiahvl*mm+xF~ zdA47K#6+#hlP};i=AAWuOvhJ%vfC@|NCJ%0+GiK1$AQ5s_WFa!%lqH2;8>jPe$kWE z@0Sg5Uu&$Ipm{)|iIIy;Ro-(-tvm!Rut3|J%Y2&lQ8s+2cCBcDk&bm}GIp>wk>5MjPb};S=XATs;M8M3})NE;K z(PYt`lM|_onoZJazDh{mt5*Q|?|FWFH>Ej;3Ow`=JTBYfWtd?Zr=v&MAOX;U3Hta+uod) z^dKddTsY6z1@OBBweIr!NQpi1@?*!TB=$FaOsX9&6S{W(L8R?*_hZ|!X^uw==dF9) zJkxl?tMeht4BMUrTeb&i1ZxFOS^MGQnvTGs!H@J0es~@iF^7d71Y>ZiqJUSaV_XKL zJsvqYZQbW%`pOlut6 zH%V&v_m7TkjMR9@qlj-smE;4dXS|_opH*@!>YXOsqgE?duy_V#!zP{h*eckX z;OR*#D}7S{va#{`^=nEdGZl;I6|c`v&BYs(rhwgLpk?;@5sZ%}UIQLSME2#2I|?$- zSv`Cx(lXX0jD&-Z9h~0UHPK~8+S65wyu7?1Vl$sPBr!_xfl*|>{JzWUPRC*Nkd{1m zd;3xt``8q)9WL_W#-+ddYR=Kj>=BdCK^ju|^QZCLx%9h&aWQBuTi0Z#Y$CgxB9{Td z8*GGB7VOhWXQUFlJ_{*1;SkRLo66Mn<;!+Buf58cePw1U`b$pPMZq%PnI}rQUz)Qd z(u~;5U|`UHD&2Sifq^v-R4w%Hgzt)Oc=Un7;Agc1KeCO0l-pV`lBjwnDv~3 z{v7JT)Dg_+^T4~94I#;hM4;Wof6t)FpL zkFJAo>gm^7?f>sS><~`F)rW91x%FT)K1lI?#?{v^*RSkef(doLeZM#rji2G-sKgKqb~#n(mea6tV6u|%F_9cne=JNipV z$WPv2ijl<4Wmn&rOA&eQwk92|B&pzS5FjbwY;R1Ze=aR@qJ7Wkr$DT&-2A0kxw@=V z?6_xxo9CQ4ei`GAv*($zm|IBk;^efRRn+Yk6ws3A8AbY*MV$3iNod-LeX4l9e;Ix>VO|#zgdBCoWjy`CM(iyOH}IKd z^t`C3qs?)ahiICx6n7pJ)XC}3|D@cglB{vLSur8fTt6?4#t=Uc1jYde)6xv6pww2@ zr>xTSV~4f2YSjQ2k>amkORI4n6nE)A*a-3EJ<7UWm7?oz3BPW~b*KkB?@W>9(I`-x ztKRhs@KljHN+vHt<=Htt*aQ{?@n;(FslTLs6mSva$vf5wBGs39HcX}s$4aaA63dT- z(4x2H{mqjnB}8RA@G#S~dDv^`sUVL5oMFG4P7^}6kZV(F>*{){f2zkFvYX^zbC9^; zWxQDC{EeT%;_K(+Nk6#pW98Y2w!12`tUreL)6v_(a*y7}zC_i|IwrF`uGG?WnTPcW z`xc?3CJBwUxhjidyLZIB&E7P2{or9|&w1GXyZ&Kn)2GDZ#Wl2FoGR2#%sg3Oc@bu0%;?ruHVhKn;zQSp`rWrOEK7l zOS4pw+Iub{d$GRfOmXIim-tHK?WQS&jehf^mS?PabmfYXPcK|*2wQMK$PW42u61s# ze$gp*tBPL((;u%i4ii!jdquo4!QVI^RMljWlnwP_u*;cOV3EIL7dE2w2$9B87Uq zkBLzqgq*E#Kx9{QG-=G^b@Q1l3jT!}&>y$KVnV1AR|bi1i_31K^)CZT5VX z{Igyyi2r(YE@J-N+yP4WS@?7LvLcRRp$e0{`yP~;tgXFYoDaq3nvr7$CH?%pzFtjq zA^b5?EwoXRkkxJ^*1&=2>0zj*xVz9N?|LMs@s76qZ%a$Rw0{J4x-zglcrAZk79k%p zH}@N!v-!cQyH*#^oDt9y*wYHUXKTAeQBv{pfZp){3A`cZk-ROdoOKDGiTw3GC+25v zNa!mp)cN__H&Ots0SpEH&A4Z%2=4>j!rydtZ*NPVWpV1=$6oI^dOZKN>SZne{Qg5^ zpX!{vA2RM^wWf>Mwk7>t3crY7@@zfR_@S7istU2SPrrV451lji&mR(JTl_Tc!02)r z&AUYOZoM1pMT@its9DA)UYMLerPW)`^7__KQ%0XJ z3roJcuACFW^zSd*Z5kUVJH16TtzA9beMl?W*+|rPqMy*v;lul%S$L@U_Og^!Gtb#& ztULB%ozao!xo_Yz*1IL!Xw=mX@j8md4&5YL!rqVf;F*U|_V~vI-D+Kr_VI1$y!?f~ zeEtmhJJa~jheZ}kD=dr0Qct5e`~Ll4!%g`p&Y&SPIE^qig+kcbf7@%MWAcw2tU-{o7B-b7;t;?KdggW<4TuOZL+ zc~Ls>mN9m!y*nncU-i(B3ZB6S$JRcd7?a}u`%aS2ry&7L)4f~+7ey6zMmRYahX{I+ zmw9=}Vvx_#8*F2gyltDfnAl^t2IW^*nG^zBV?EEBsWZBlSH>U!X2M3~*swuG_j$%M zq5%~z0nj%k<2OGAJd_d{GAO|R-U7yr#1;NeN=mqDpoo5)WqzFO)5HFC@Z{IZgjG61DuQ5Z+)ZvcE$^D_ot*l(PefuoOtRwK9|Hj0nR)35u0S`pK zKWLO5=+d;wqyRZ0=7x9jBTt-Y3&c)zbwW?C5+&p- zmjEPt#T@KV+w@|W*f_{h45-Si7*%rb{8=gamTx|Y!xRP)V6CSMZnv?IaGyZ|6oxQhjX{czR0#G=lN)vLN zjvS~c0>V+l;k<>%HU1(VD2#i9Yj;t^Pf54luCF5qS`@YSUYxBRTiqb98p4eUksNv- z7(%$T)#aDsn2sKmdpoG5j+5VXkOI&oan@g9UzEV_lRYlf}=FM z91pzro6JC=MZ)$fRs_&D01wz1u=ay7Gb%;YW{}E@wx^=Abv_*t+e~0P7op4z5yGc| zr||oH+GKVgGKUxkDa&+O<(RbMd;+B!lBjc z1XZkRg-3Ybn0FJMK+%yqvR+!Qt0U-lwVqxLQ+Qhs&9RUvlES>SF#3g>1uL5v*F7|Z zRe^XNtZJgIY@^m$QPG2Mbnq7j7h$p0^XVZX-f{fEof#Gx96r+98}n#qQI-WM42_Pi z_{?rW(}bD~JfNT;UoGgFU^8*&90eb6GSfi3(f^f_CU~!hOwSSDe#tcj_YPkChmxzf zI8!?;H$MeKTe5T4p3(VqY-5@C{(UV;$MiRHf?NWh{`>l07+B=}1oz1YYH<1ue6NM* zszE!!SPwNPc7mhbhEoGsoPbhytbdJ0Xli)B#v;z44XylyEmVW$*!6y`*my^^u zLd{_UJv9ZoL*~PIGwd?dteq;D1HnPmTo|*Ie;o3L)e( zmL1i=7oJ^Tu(HO2gs^__AI2QUvNld|bDKd#gv+=Oc1T7>37YBXtX-!|mgDUq^z{Cp zr8n@~7cXdkOQCu?5#~HIPyy}%j6_FKCWPEYn+y2O422)wB9W8)b>Spl2+#4>Dt%y$ zE+*ZWvcefuGu;ioBH$x}hyy$sJmYrDMR-T~QL|D_i1P7KvcHUsOzE&B$}r$XpHc>) zBpX3cOpKA8)Klq181y7=*x!mtt*9E6&Vqu5suY@3$aP`V-j!*zi+L|&CGp%GYybBY zh;`N?0vIuxp>f7XqLBl#j=9Tv4nM$1nCfrm#=wMo6Q|`gHY`3Do=}8sU5N_*<;!b( zUSctH9LAz8k;4KQZ$G_y-Pu_}t`0M7^dIi00D*d59nr3q!AmrUlY@OurF?Xe}Kh?pLxfA&`igu1E)z`iU&U(`TF8GRO+}c zFry&$36h}X<>%&}3oy#l-IUKT3Q}lmX_;j@$R>gX`BVrsRGQ=EIRl5~NA@}Rv)1TI zWRJa=5+8d+ysC##EKYx#2YU~Lchuo{aF3i*VK)ZE%regz3=16gA%;${1@Tb@28?`J zxl!=f{<*46;13s{F+(PQjtt6y7?|W}q~X#hXlKc#YCKrh*|PfgX;I+e8>S<#!Ie=} z^|r8(@!cb&VbIWK_5lg8WXt?4uU}>m``z)SJ#EDd*oQBRd<~#T$CErb z;f7WN3k1E;%&|x31Vu%WzXEU!uoGFh(?+ZY|?QeV_*csWAL_!6J!YG z;-0b|Jm^m&0T!C9Yz3T?5bgmJ5{w~1eaEQ{7z8CZ7A@k6pqkIfNK#AH&A@{Z5f*-l zyx|$G*7J*gGrT;MylB3Nh9k*?tQ7*H7Q!FvrgX*ux;%)yYF<;{*=MTv&pYFRuLpWwBC z6(h{hfw*ns`rR*w&jUaS(iYTkuyhP6EAw!25<}~3E=-*2K>vzOjP79_S^>12eda!< zkbWF+%;Xw7#t<&wbl~uy_MPStEkaVPH{c1Yj+^wpT-&F8YUuKEsx9A*n%3IqIk+hj z{z?*_8|=LMH)v?m;99{9@r1|d3pl@s2tg_ z$Hx~A$!KaS{!#Y^vWV?L?E_T46sO=1f;yO~M zp3APlHo~nv32-zPeC_AY=*}_Q-^p^Sr+v)haq~#BLqOFa80c1K!$niv$g+; zJO@W8BRxHKB>RDYSSKkk2w*=^V=Q$~4V@tO3s9Evo8ghe6y#?l07n44G&wn_-56zp zUJ8RzT2@x*0Wvq0lGMOb#|DdzM);Awf_YphDu~TPWnKz5u3ryB;tn%2xbhc3(~FE` zDXkN-e3<=c+_VLY9*Hzef;9&q_l}o5=Z2Sc3A`FeR4BYZCzS{Liszm5eh{1(p zBYiHB_9ul_^-b*RJR)2|>$5C;PkLl;zfYnNvzPf#a1PXgvIg`pA_vYpONz}k4&T_# zxzt7$$bPrssF2)SHCrwXznS5@v3#c36BE}g6{)hq`&%ckEE9;hS-nX%zN*3ir1`QFfH?;QI>#bwy*C6HMIlU z3cGYK5@7)l@^9U8P$KSV&^LjPMKnl(T_=|U%A2_Op}StVnId)JFy{F0{_-K7U?%S- zsBOgd%Y|#E7lj&}x^N_Q2>%>tDfGyxss;w$u}Po>Rzf+dc9P!g?Ij*_ly^)DYI%_Nrd|;nnLAXqn*o4aNg)RRfw9_MyeF%Nd%O z6fPHp?qjZjpNuaJ@PpC5 zSRn_-ac6R`NQ00CW)Rf-7`{A$SL@$oAe2bqfKCDhG>@pL)^^XTD_ZU4{c^xaDvV|D zs!_`NPm?|J&69k7yb>bd>l8XkHFaOi+Fh!h{wuFD?LRPphcp zIquawp`W>4{@M+vB05yR3);lqW#RU$7H-p!-$ds?G#ez3ptz!ZYK%Gx`AC2DX9`}` z_W#`K3i!~>A`zQKegoczXc%!aqxr@-GLU(USC#s=maXF^hBi1yn1gE#iXZHItPb&^ zbXgJNgaYJopPW8*N|p?rK>e_t&o{hVbV=CX-rl?9P2jw8N(ho^QE|wkeog8z4CzL#-e%nZ6{i( zlKc7cZZm}=2RGN%9|rZ_u|#J^&~L6F*piNA+FWGwDPC0h2Sq2W9kfV#0-iV`tm?loS!WO3PHeaUKD08&KdNI@fT zdLa<=kP1Hz(>;XUo(KbPHY_3n1?Vr}dkAL69>L9u1rHzyGZ&OFAibcu;Rxk{UqYXs)q5v?ztbCQ8-~S7y#J5wQCy30J$%Te)UQi zU@mYJ7`!pCFf2e4F(wYI{tq8uGz}5?-AUScF<-fsZ$zjV)}FUF6xnD17s&Z=hA9Gi zK|67t3>2c$mEV9baiCs_5`vcnjO1_S!-HoWR1vZSPzJ$_gcj|%`a3x2iV?TYdB6rR zm!~D}_?>pS+1amfjN@s=*ak>52dP zcApQ@=KEtSHsi4w?03(*-sDM*@dSV8i74gIhJrA=-Z1E=y6W-RdoZxctV;Erj9g`{ z)T!zcrbT7bFFsgBcT|OJZd_h?EV_ zd=!U^aVv1vsH_@>LkycwpU1YF&o=;{h7xVXCT#8P)oriY(7k}>U3E-dneMxfg!UyFB z6xr2B&VCHn>~B2aXq(8ixhj3*XIEZYfClgIr8DIWsD)zXif{Fw@=@h4OLEB}`@j3? z`h$B-2RGNwtH?ZDvOJT#^G-RXQFMPVkG84Ro!W4FxuKCuXKsGX_e;Lt4zo)+< z+&-Jj@|i}}i#s|TdT)pA9cVfaE4}$sCw$l9*r&j^cV?LG@~gL3(B(NgBt7Ugd;Yb* zpQB@Pes3R4JZ(*w@LoeLwDn5w-;7$io=u4OLI9Bnf#&<=uo2h9u=Z+*9xnD+lE)Ok z8YN9_y@{0^npm>ZJKNXR_?y6d4#T*H0WJK+vZf=YTkA}qVHEVcOFA!%nl^+t#mTU; z+hcBx!Bg4FM^Iq|qqPx(0gyq&rjN2fAAZn1s8}#i@YtBWxUwH01Uhn@ktn4whe7#| zvYk55?G!^xXXj%Ya7WP5LW;(D0GzfujOalI1S=P#XvDpNiHfU<=B2*2_G22aiLH^X zG3U}gZTF-O%l&fbTC@$kkDq>r=m4ipgvStJfjy*1kQG$34vfcAVaOiJQub1Lno)qt_e&saY^=qUj#d_Ky`%A>J(V(!4zEE1~}Kb%WN=! zjwCxT?*k^HJ2gu4%CE2%LC#EZaj^&d63d#r@jc~(gLu2z022*(=xi*ssh|`VGs%EY0P78~p;`hd3x5G#j5OZ? z4&g2_5QqR34y)?QD`5*9>$ZnsfL39BRpyo>`reQfiqt$$TaUPXIyVl|(^u?;F~@`x zjU4)V{1dE~4R`}3d?+p!o|~VKhA*Y37t>HS8dN4|N)Rv*T+4g%q<{45{;7wyVmIyV zg#4>2rUD@|CnJ@E{3S|xD#`D#-;=UR`tdYjmIpEoVRsHyKIrAJoPOn2aI1Sj>F={> zN7);j?^WXdyQfo>$$QPkS|?*MZE^gFUPOkrH_cDS-@-~ggRBSKW_8O~?`-Ybb7NUh|ZiCmq+{PY^HzAdhq$XlqM(TG!KsUI&R@FH__x@8;_P_P1-tp!T(V6&C;-N zCBUDeil`Q{pQLR+`Zk&+<1S&6zD{9m4vGiq9SN6_5E}N!3BfQcT?M3fgsA*MRe`KO#xS@G8g+AA4@NtxHSaiXp zsNUU=57)OYUS9e5?$7;$eDA)CqJ&wTTe*&JIFSlU?}@*8AKNE?JdO*Fj>D)aW+3f z;He!`klF3q{xrHD?S^rr4Ga;TBf`Goh}d<}4wp16*O2OW{wg`0ywcUHezp6sLED#| z*Ur0x^aQyQW-OUa;U^7PFr}a|-o;46T?1l~q|4G>ge!$9?!F^W$&5k+ZxP%iUIBq; zACkd&c-&SB{n2LvA`;-)NR(1(XpqicBa*?tgMtc>>6WSKbtnxE!zvzN%76<{B~lWj zt&P=PdniH4uEv$YZtz^0dzU5NmI9exp)GupA#uS>3}y~|ziP_J-Tonbv>W8)``CQI z@It`cs=q~z_KQDuACdL;s4o|eE_D|$DTZIIIuD(;Bk{4I+&XsU6{$z*;`bY93A~GY zFxZBm5ndlC*0d$TGP|_<4ABlmf?TeMX~voBppRLLgp&H#38##rp8FB4i1nnTw& z%G?ls816B)u=t2cJJcCF8}Xn)A4WB9tG{-hneryV(|FMY6M<67DuVoEQb&=%N7E76 z1LqYJ$r~(vOwV{_GwlpL@t*#H(WA<=VP1!>+4LWh;BWsAL_v$gnubPDj zob7xt`T17z$yf;&tF?*Un(5z1B2M3Lu#72~^cCG2;?EuTJe*P5RY&M!mSQa%v#kFw zf3iiqcT&p!Is3QWw5_ZBgTZb4~w3 z=xGJzXmRQ{$p?h?<`h5Q=OFq>>HST|Be^#}6gw^!JuL7`HYV$Kj}oEyE}&glZQ9+( zrCF3$>ro<4u;D(HNS1JZ>NaoJm)sumwi+dw&F19HSxKAm%8O+zXE~1-)7ja$kyI8J zsgxbcIf#6U>1ekFLU5Ycmmi)6$HTs$E~R7=Cfj%yn}Fz6K^kO{NFrVIW(85-*2hG@ z=rYmc*zYznHb&m{I38rnD%U~cu5+rauivXloGBF-2}51!1L}F@FDGC)dAb=95eGjL zz+*yu230)?u2DbUb?{DhD!_BIFrGL|yiqV!vfSq#JPK|AOw5psrSKVbG0rN3*`17w zS_?ntd1uOAiQ1sN(nhE}O(?_ z*tSwqN1jH)a}|ncOr`eA;mjnwe~*k`NbR>*YGr_ijM_^yE-D1GxFPKW;1vY3umf;y zd2=B|-3CYD?b}+A12Y8T_)jSI?<$T^cncFc;JRqI@v(s(pxvN=Xd&<*iUb@_zH&Qd z6^X&+JalM$3}@}3N5(cg5k@Y{2KDd~u!*=>IlyQDY#X0CgHxcrgHbbodN=)%Ba^>> z3nL$4BFzdja!{)HI1oCw(`)@SWO5Z7THG&oQUlI=g9OR17>v0rq07$fIu+dnGgBnD z$J0{zN26nd#H7Mc3RyZtx0TJWRuv?+Yv8_vf{r_3PaL6iO>z4zoTe$I7e$D(?_(=r z8YeP7eAx4Xm)v)bG7T)ofy<*{fI>5%&G!}74!OCnrQIaHSrpDsfSv)@S9mF6R?zbn zzzU2((A`2|4pa}}dB~JIbH~1dU1npZ3M`U`M7VT^A;!!waeT%qLq+Y4naTw{5|u=> z*RrbtfU;5OBN>?wcktS?=;*^i3NIRlDfd*NItQZ|rIx1`;w5h0xIwYuit`QUJ2U_= zgU9i*JB|yM>s<5*?Ly=N*8!qp7M~str??T}D^zaAyaK$e6Ni`?L^rmBa$u*EAJ`kT zkycQrI;$nvi#(cw^~H5HkDjTvWeQ!l(^2e__y009_8eC}q~7j6QYyeTKblIB=k>kL zhnXz2=m_)Wj5}%cxoVMi!GY45{x6w1GCutMK~Y38`&9fL^-vD_y?XI#>aE4lN!|bQ z<@GKZ6S@bC%Pel3@+G!)Tw~>#+IqRRPB$~+&lJ4>ZWdiwqz8)Wai2B~*->a( z`-oK`sEXE{FF@iqRUA7o}Tk0 zEj~M;SE08poaqBOIY*(3^R@9;PcFT_{vg^t+H2p2WoGS#ILYOqtFK%1KK7gy2#v~i z<)CF3zW>ZZ)#)Iv< zoy>u=W3US&$)i3P*u@TLVTdyDiy~AP32ca(z~%3Oxd)u6O_+*`i@}b>{0Q6lWLbMq zI3^KN6=hPe2*EXs`X2e8gq62JYva!6S2|cNQ7iz8$kfr-XL(VtK_syk@C-1Z|({5uiXV{16NDxlD^oNMMJQmzO*CJ-iL*9y4@(d??V>5QBgTBZxZQ z6*~;=W(H(ug4jbZfrmK}W2?LuV_2bHInhYoPO%rRAD`_ympzaD9w0LG~HBh{ObB&ZA&Ge*Cfw z_vFXhF9D~>C4lJ*aZ)pg)VTOTHu9N%gu#u85JO-d!WuC>Ge=hhcy4iS4vh9ExDsB< z`}nKV5D*)~&RhdqRQ!*FgM{NoCNs|tOr42G9sriv#YNLBZJ{VWNl6zFB+$=e z39*C1prks5Aj^xB&M5MXx+EEr+epe10ah`1`S z${+7P`c66O029-zurQDzUdP6wmb}r;l`;(j$9>mQK~q9r9>qN$wt67qfY`u6{_A!9 zbFlwYIqnw^(FXf`YcoBr?}s@(j-ibwt!H{!wl+u4jK_$4oa)M&?wsTJLMZ+RrL}iCGAoT6(V8Pw6Lf{qFE;dQtU; zk|;^4!xiOw7t!&3Wtsmn$)tZ&z5VO+l#9XZ_x>=ehZnwmqj`XiB_j5sz+3YBWVwkG zLXTh1mK?63$-9>r)4en*AW-x;Vv5(>{I^!`n~_*vC_+swi==cNRk&O7r(-owx)Ho~UTKDaWxY|n-w4;}Kr zHdNdZ=|x)bOglVI;qB!sS9Zr;&(Ne;y@)ACh%r%pg`2+L+Bi$f^DzBg$_-0gDxJEa zmvSvT(r}C45oW5>!(6#?B}^^#u8oaRGFQpixnBSd=>oYF(2}*(g}2TjICMa(VY?|C zXRvcAtDt~Y%2^oJhRw~asMyh8WvViNyy-sOCv}Mu9Th$^fU5P$U+5yGVX=&x&}$bv z;|ySL^kiUhJ!YmNBij*?3MqShd)X2V^gwAxEqd##_`s&Au`x(nAf>Hr)Bg!L z0`@!|Id~UHAZ7hEi;m+KQTlt3j&6s`26TX!kE4cjrANaBn*|veR?2R8Y*6{12B9uD z_xJqj%=Nq7`TOK1;6q2{kK5A*PWgiku2a*4d;c|DZL8j3&a&s4x4gl(oZP*O?g~74 zQFx$p1nsu`367kXXmVp!{n8S^y^L6^@Y==Xz1`I4J(;T_QJOWHvC_%K45Yp_ zW(R-p<>{ZcoFE0~U%ww}3V(s{xj>XO5u<)M~pc3%!UmS}4p-9gJA zUU*bON6Sv&(8b^NdFt<)4~cB}dy$JQP~<1hFU5e=qg}7I5i3)tW32ED+bv~qr@FdUVP>@E zJ_Pe{cH|V~>RN%1s1aH7z4G*tMmCBSFpofin#lxTMQQu!fVIxs7Gi!}tnv7KIw*#P zwkmMxY;3%X_5zeYVsr|pDS|N~_Zyl@V^EAF_Z~xg$ugm|f>E3wu#9l|<&dPdS0$uE zRX{?5iG$N|@+VPvFc`{MMq<80rKqZ^%7{Ne{*{pF7i=^Gmlwisw~8q12yFS9GF#+c z?y$zi_#37YGMfu+U}4=lObnkN_T76t^F8YLlRDYeU%yg)5CT4h@#t88nT*&?+_msX z%)=6eP!E%-U&2dMy%aZD#uywd zy&`)H%xxiNL%3du5?mrYn__?&1CA1eSImw-bpOm;^gwp*Uq$I_hM z^$7xBYp}#n67G4*@{bgpyxDCk)D^;ho;c+Jz#e~jO$O0Ng$1P2EN9XW0G)ZgEK z>m=1j7es)ex)Btd$tdU}r>3q|T}An@g{a?ir>j=3%gJGobrXmch>mc`3+`Ybb>IDv z@GTFgH&n71sG&?;*9J(P*{Yjm?3ej9$F^r-Y(8A<>Y}HfD%Kfo7VsJF({iZZ?1|hCg%dEd|e$sI`JMfNe zjmCUZk?O-|%IdmDZS6XOa&Ogc7mja-w}M*4AL?De?QSL?6A}G7}2u6eWL40;za8zMa1X<(xRV& z>h1cf6?S&LQWM>S5N4q1@pD^tb7nat?R9*rt}gkU*{|_XnamNE! ze4)rw0AUzaH_zvtBt)^|li74osW6q@AZKYHDf6ncvMsD6-D1(r5MgKEIi#g3cOg{+ zlt~1LS%5DDiah2`-}Q6>5`YCVU?kA9F+Oz0Qx^&j;`1?RKiojb)ty8waty9;Si0A* zpMfhMUV~>6!A%I6URb5iH0POzH47L&@GQ&5o$P_dhKYwqN#eU}gc2M9#BRHSTD2y& z#uyVciVrtkrOQ%M%t2%TYFK`=8iqF&zT7}a4z#%!KlD_EteXHol51}j_Cdq}suKns zh8h@HV^#sk8(em>SuN}i&tUWfGrVXY__n_E^|7Q^BL`q$2#k!KlM>?M$YnkM!9YW! z9cKgtW&~9pPgy|iPjNqW`^vFv9|b}%7AHokSMGWm*zk$4xmDLqa&AnRR1>Bzyn8Va ze%hw*tQd=vUVpotaC8fP2fO-llw87N7}Pi^0o#&4_=~`nI1eef=;_n2 zmP84GAsdJhKx}Y4%xwST;_=XsLBnccfq-4!l?K^T9P$9_p?ye6A(LZeJ?nHXNi87| zM>S&8DOt|01Ptz0Bc|;7?0#8kA?Gs!0hHkYk*qFw=&8Xy`(_2OB_htppW60##d5xL z>AzX6n;oK6ev*X~JgM)M>iD9>YwZ^zXYHlj9cG=s)owd)JGygOp8W8>ZA6PX)eN`c z=|7R#Tw}Rs^-6x;*K)m2)}GBIyHAClDM^WGj;fGaTu1d)NMi_?!2i47OV^tnsG@aZ&~I4FL->4dRLXPn9vnF zs(UR5ViI_=(gzH+s;mpAl9i;CDkAO||00yKtntfVcVK7R7t;AWO@dtM*=+hs<8yJn z-BfITA;&G7*}rLaS*=;jz078+=&LH?EF3RoM{Qysl;ZRH)R4Qg)e5~rINKEGX?2eR6fLl>F>{`-0DknE- ziDW=}>s<$EX@~7TYMk)>S&c!!fF04S023Twf@rt9Z)0-OJZmEgE_U9C7L?lo?Rv_v zTo*jLXC^R=5pj>vf<`3rp5?qya0|Ni<=hTdY9FH!#;BJIje>LtIOzPQknFpDg(q(;4zRNf;x;?r96w zex%+NA@wFS9%n#nj_i@0X?}M_!le-%%^vSl&?(z~OlUDz)x|HkmZXML4*Dt;>DB7* z-@~zGYpg+k!OR@D_30M72M;hG!ezz%3A8Iv^6+0dq7pWuRC%heGJkDA*iU6Mj4+V@ zn0(Ub8=4rP8~VS2VJdk7L$cdV;gOb-;`!JdN>u5%X0sgJ|CpMm8hqc!#;&1DNllGW zLCB*#SaO}~kLge0YTyrli8cXHx{;C1%+G^62BT@fO^Qj#drAj=V29*fT1AY@32sMDYaoFLoy8$MKQWFdf3hv$Gi z?eWfn5F};g{67b=fmI40b}&aF7nJqh1B~Jt4;>`vlPAQ)v=K*^1j%d>Haqk{>4?!K zQgWFMrPsE!aQ=!hf576&&u3V3!3`c<>z$}|DRcKLQYsh_>Em5OXN*;XzsTx3BMS>3 zQbmYlv$`hm9uRo7WrI2bMunK2K_K8SsSSAZlp&xp6#Z#wa2cEl$HfBxTH{28Z8NCR z@80bREc?>fNJ{kN8XyY{3=G=RQZN#tD@z+pbuaD=TEAx}uMFw8yRns-?C`-j4{3+B zyROG$cz?{%={i#DHA}cnN!(~X6s;*{^?f6?&Flxc?f`%8+MmPjMtTb8_`oUD+ZZdF zp5A!EHMQdYyEE#3Q|AE(wfwK&xmRR4&Ub!QH>bEf;n?qdqae$`JR&Lo<a6ojrTN zzK{M8Tm2ao-z03!hEM(s5+`L+>IuDQQf}4Vvzu61;P+fMJyMu@-{e($&yx@4>75cu z;m3Y(4?Xtwcb!LTx7HVwrf#V|ORrS4(XI8tVc<*N%_xS(L9O@M&BugJZ7=oy*9#&| zjN0LtmlQn#U(`d357_;^DDoab~1rxUp2jz2oOkOJid%8t4TM;>jHZ^?%S}4*zd_Yz#2qpvVDU zb&$B&GozYE?&Rt2@!omJwbME-RHo2l+ubJw%+k-~173>fB$E&13Lz+KMVrS8ttKEq+wzr&uPcfLMQ~$7|#~=)DWZ*fp;Wk z?MAxm!&56CcJ)UJTO5$9>*;ZYr3G06Vw$8SC2xRi{P8A_Wu{pZla|&GjFgrQw%4@K z*s?u%Tw#caLhp>%SneRQJy3%kE0prvJ?P8>=b+oEcKQV1Z}XItznJ`uPfkqyU3Hb?|WAu*fVEsd3t(q zaA#R1>liu-#@j1Ep|fnv0PtdP;@QB~a|eIKc>txux|Q=>lW70w#h;Aw|3^vl`g#?# zkDGE@${XW^tUdWSeoz`Hqtu~iER2fkg11P*PeTB^g&LsIjm|x_?jwD1A7f}!(~bfg zgF05Q)@H=$!Py8?G`tuOgE`lgLI1KVM$F92C{!XEN}LzR;HSASE^yJDk(HR}$r>a@ zNjwUds3R-{E3=V(`OmpUd%qfZ^iFT#lRB(Tj#b6>$ii;j358pLw!i3x!sHZ z+BU1p^iGDFk4xLl^$+_u=40oT#E;botG8==wU|%z8fGr{U6UoHc(VIi^&ZW0*Unv| zxtrptXyDwd_fudZJ7}^*)cNH0M4!ZMYPM5*<0Dt~??0@c$~oQqwNHJjxrZyFF}kc; zt@9wCVsgyAa+Rl8N_8@bksSUj@bGh*$U~+$Vn=qw6MjK-%lVr*XcJ;4e z^5(i}_TT-LU_Sb@7=3 zjY+%8(3HZj#%pR1442~Ex4OpmHOD3?aG6k?2~s=HJHy*H*;GFL=cN1hHF1$M2JzPM z(TAO14G~VL*`~%6P!KeH^csqqxMROh=bU_h`{cQkYTo|3 zSxMJz_LPzQ^zLKaHeuNC0NPFWCFfQeVk!Zwk$Ii_DNP9xfc%dgO};G-BwKQ;^z!Fr zGZ}s>dcr|N;VI+JgX18Nz)FE9g<`Qg!G6aKZ;3v2j0YrGdrx)DJU!e8)zQp`8>nkD zo_iO5*V8^VFmk?JGP{um_BK3oAgmf+30F()$+@ip>DaeF=rW)Sd;r)JQvqca71)vj zc%q`D^tp9RSa<<4AM@&WYN?PBWGSFFtR~g00n*5(>s9m0ddEb2~q=38>-Owcc)(2IUyzhiXZSwoi6au z?I9(7E#sk|s`14n8^XPw5|@MI4Uo}6wSeE+)!E6Xn@gnGVt}M7#q#7LF_#SyHSm}0 zLDnFC4~B+daJXYmA`+dUmWrbTdU498}x|L`7<(d8wm(z zO|J}MhVg5B{QaTn5YYd$(tLZf+%X_H7zFy)m@Hyhk(!o57mIwZ7O`}o7iij%6`j}3 z93W4mWw(beEiV`3}N> z{A5xE5*nO75CEfms!V4~XzlKH!Pf`p7%)4!@OXevY&ZUx)?h3fqaA}SG!(chuf?)( zbbz7Th*}CEb0~w5oM`^v&J=FwQ{V)<0h)@Km>BdZ<$p=a;hE$HXVIsyi9>w`=XL1e z!xS-B#%%kzNHlU;_qJ%FiS0!u7v%QYcEgJQDlx*3@iRXR$Iu8c39 zJv(R-{pLWnXr~0-DU%)hM&+t%cq_6}S3@kgT1ASPrT@BdX{T8HK0Y4%Yvzt!}=iuwblB7SdUqWSx(Au(+E&2xs6@TL4cdg%Lo zQELa~FUQ4gf4^Lu@cmK6F`rV&-|ZM2C*0(o-0-P}H@W$BiS>)kn8&2g-s{F&hcM|T zE5|Li)NU@ZN{ibJ&c%5|6<7_-36z$ym(T!IzsmR?%g*V2NJtpq}KO$`?M zTjIboR@#keUW`sykObILFef&>EBxI&Z~E~U6QR(UU&bFiFs%7pT@4`3=*Ficto zLGuFJo2WetQt-CU0vETpmq@^XgF{wcp6|#fU?JLDZ0{o44>uDAXrOkOTye&bm(J$D zLzI4y)FF99kTG60R+~?u_$8*A@fW%3vV~Zd9|ZpRJ8KOj zLmxLZi-8j&mwI~)|KR*3C6VQ!BeC%>KvfxYRRwHq&_Q13U~>O^HPc<}%wAHGg>{%Z zeY`0UdWmmjS90N0Q@LBHpnHc_d(g&{Np4MZ~E1d9qIZu zr*rl~%&=j2q8Il!@2ai4n`zrj<#b{9O*&>gVzcufANP12LK~;AU8%YEOc3AY-KCKi zhb?3GF=!?3H&?z_lkTO(Smw;NR1(i@$2yf zyF5?J`Az;asK%Uqsr)zF`pAxk&hY<@J|Pt=sgO&+qeG zpcG$TEq1*wbwj@?{Cb6^;zNn=9yd;^->e9~PI2anIOo?_`)-_PtsZ{<(Z^U?UBg|9 zZ?1P)CbGbMx0X)#o$~yHEA*EHB8Fz2I6}#E9vwkP0ga(tavQ&1Z+|+%c6e&>WkFAj z@3r>syMvIn2Gkg1KKL@sAzKvuD%iH7;%o26QUQiwYR*_z*`BvWzClj=PG8+|mX}W3 zp<1)-PN$96v#VK=rk{HnY_2YoIGh>~J?6&{HfEpZp*G6RSI}x*-tayl(6N~DyLEH? z{gtL8>vjfH%X^2k+~o9Tc$Fj{hDvS!neEOCE%yXD3FSNBbjr!m<>zKyH9bX{Gave$ zTh=5?`xbenjy*M4q<1)4Cg-;O;lFsLAFLB%?_OBxPznxTkCJXBi?b{UyDIv#f#UxT z$&%cdxA{J=a+4;{ke>0Z3w}&XlO{Y z+4Kk<(y=`!R9t3KMnh0qZW78WDBl_!1h`8n{DpTtl9Luo#pB|zOvSDP|)_QG_luR_tcnUS}Nja&m` zqjI~VxQ^w}DPMay9lea#126Qc!Vqs4=&phe>?kfP+k?g)xOaT0v$fH*{yRRFaHJ!P zd+9LSzt`p77`u|a5zFwIu=b?rk5){0(wN(il_qT*0qbL41`fK^`zDWn(^L9!{YOy& zcX!~EhQ+UQe`eFQxbU0DE+9tr}OO-tAxsag1(0^Im%6-u8XW53f8h>Z(bvpa_4zQRdjEQ)>3MJK%nj zt6rR-O~SiV5!K?(bH+@eOJ9!dQktIO4Uo6nTwA=lzUJ^2AyOc`pulxQi3)=QXwq3( z4RBB(C7vU6%agVjUSDY`YKcN}IjvLUE6tUEJhIS+_SN~%PQ1YJ#jD|O@&rWeg-92~zyyEL4g z*BT5zXmQ0{Jw`)DLmDONhzzcG!{c~H=#j|0c8I!P7EWyY3$+;_b}u&Q@ZPqZJJWz1 z6xe^k`%MB6hmOvEG9Q_Vp>cZ&h)kY8SJTzC6l8Qd+Xr7y8d9Q)ysXkB6+2NP1{lJo zAw$g7g4{uv9irvpAhSd5G?jP0M8DZkkcJdZ48nOn4-p%orJ0%0D~8>7aDN-L1g_9u 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 z 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 literal 0 HcmV?d00001 diff --git a/doc/module-usage.md b/doc/module-usage.md new file mode 100644 index 0000000..68a8282 --- /dev/null +++ b/doc/module-usage.md @@ -0,0 +1,82 @@ +# Module Usage + +The client exposes Python functions for creating distributions and datasets and deploying them programmatically. + + +### Deploy + +#### Step 1: Create lists of distributions for the dataset + +```python +from databusclient import create_distribution + +# create a list +distributions = [] + +# minimal requirements +# compression and filetype will be inferred from the path +# this will trigger the download of the file to evaluate the shasum and content length +distributions.append( + create_distribution(url="https://raw.githubusercontent.com/dbpedia/databus/master/server/app/api/swagger.yml", cvs={"type": "swagger"}) +) + +# full parameters +# will just place parameters correctly, nothing will be downloaded or inferred +distributions.append( + create_distribution( + url="https://example.org/some/random/file.csv.bz2", + cvs={"type": "example", "realfile": "false"}, + file_format="csv", + compression="bz2", + sha256_length_tuple=("7a751b6dd5eb8d73d97793c3c564c71ab7b565fa4ba619e4a8fd05a6f80ff653", 367116) + ) +) +``` + +A few notes: + +* The dict for content variants can be empty ONLY IF there is just one distribution +* There can be no compression if there is no file format + +#### Step 2: Create dataset + +```python +from databusclient import create_dataset + +# minimal way +dataset = create_dataset( + version_id="https://dev.databus.dbpedia.org/denis/group1/artifact1/2022-05-18", + title="Client Testing", + abstract="Testing the client....", + description="Testing the client....", + license_url="http://dalicc.net/licenselibrary/AdaptivePublicLicense10", + distributions=distributions, +) + +# with group metadata +dataset = create_dataset( + version_id="https://dev.databus.dbpedia.org/denis/group1/artifact1/2022-05-18", + title="Client Testing", + abstract="Testing the client....", + description="Testing the client....", + license_url="http://dalicc.net/licenselibrary/AdaptivePublicLicense10", + distributions=distributions, + group_title="Title of group1", + group_abstract="Abstract of group1", + group_description="Description of group1" +) +``` + +NOTE: Group metadata is applied only if all group parameters are set. + +#### Step 3: Deploy to Databus + +```python +from databusclient import deploy + +# to deploy something you just need the dataset from the previous step and an API key +# API key can be found (or generated) at https://$$DATABUS_BASE$$/$$USER$$#settings +deploy(dataset, "mysterious API key") +``` + +The API key can be found or generated in the Databus account settings. diff --git a/run_all_conversion_tests.py b/tests/manual/run_all_conversion_tests.py similarity index 94% rename from run_all_conversion_tests.py rename to tests/manual/run_all_conversion_tests.py index 98f9bbb..bdc60a9 100644 --- a/run_all_conversion_tests.py +++ b/tests/manual/run_all_conversion_tests.py @@ -1,6 +1,7 @@ """ Layer 2 Conversion Testing Script Tests every conversion combination systematically. +Base fixture files live under tests/resources/ (base.ttl, base.nq, base.csv). Outputs go to test_outputs/ folder. Test file for testing with real datasets from databus. """ @@ -70,7 +71,7 @@ def run_test(test_id, description, func, input_file, output_file, *args): print("\n=== GROUP 1: RDF TRIPLE FORMAT CONVERSIONS ===\n") -BASE_TTL = "test_outputs/base/base.ttl" +BASE_TTL = "tests/resources/base.ttl" # T1: turtle -> ntriples (from base turtle file) t1_out = "test_outputs/triples/T1_turtle_to_ntriples/output.nt" @@ -142,7 +143,7 @@ def run_test(test_id, description, func, input_file, output_file, *args): print("\n=== GROUP 2: RDF QUAD FORMAT CONVERSIONS ===\n") -BASE_NQ = "test_outputs/base/base.nq" +BASE_NQ = "tests/resources/base.nq" # Q1: nquads -> trig q1_out = "test_outputs/quads/Q1_nquads_to_trig/output.trig" @@ -275,8 +276,8 @@ def run_test(test_id, description, func, input_file, output_file, *args): print("\n=== GROUP 3: TABULAR FORMAT CONVERSIONS ===\n") -BASE_CSV = "test_outputs/base/base.csv" -BASE_TSV = "test_outputs/base/base.tsv" +BASE_CSV = "tests/resources/base.csv" +BASE_TSV = "tests/resources/base.tsv" # TAB1: csv -> tsv tab1_out = "test_outputs/tabular/TAB1_csv_to_tsv/output.tsv" @@ -306,19 +307,19 @@ def run_test(test_id, description, func, input_file, output_file, *args): print("\n=== GROUP 4: CLI END-TO-END (run these manually) ===\n") cli_tests = [ "CLI1: turtle->ntriples from compressed Databus file", - " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --convert-format ntriples --localdir ./test_outputs/cli/CLI1", + " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --format ntriples --localdir ./test_outputs/cli/CLI1", "", "CLI2: turtle->rdf-xml from compressed Databus file", - " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --convert-format rdf-xml --localdir ./test_outputs/cli/CLI2", + " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --format rdf-xml --localdir ./test_outputs/cli/CLI2", "", "CLI3: turtle->ntriples + compression bz2->gz", - " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --convert-format ntriples --convert-to gz --localdir ./test_outputs/cli/CLI3", + " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --format ntriples --compression gz --localdir ./test_outputs/cli/CLI3", "", "CLI4: turtle->ntriples + compression bz2->xz", - " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --convert-format ntriples --convert-to xz --localdir ./test_outputs/cli/CLI4", + " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --format ntriples --compression xz --localdir ./test_outputs/cli/CLI4", "", "CLI5: unsupported cross-class error (expect ValueError)", - " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --convert-format nquads --localdir ./test_outputs/cli/CLI5", + " poetry run databusclient download \"https://databus.dbpedia.org/dbpedia/mappings/mappingbased-literals/2022.12.01/mappingbased-literals_lang=cy.ttl.bz2\" --format nquads --localdir ./test_outputs/cli/CLI5", ] for line in cli_tests: print(line) diff --git a/tests/resources/base.csv b/tests/resources/base.csv new file mode 100644 index 0000000..70b6b15 --- /dev/null +++ b/tests/resources/base.csv @@ -0,0 +1,6 @@ +@' +id,name,country +1,Leipzig,Germany +2,Berlin,Germany +3,Paris,France +'@ | Out-File -FilePath tests\resources\base.csv -Encoding ascii \ No newline at end of file diff --git a/tests/resources/base.nq b/tests/resources/base.nq new file mode 100644 index 0000000..043e7dd --- /dev/null +++ b/tests/resources/base.nq @@ -0,0 +1,3 @@ + "Leipzig"@en . + . + "Germany"@en . diff --git a/tests/resources/base.ttl b/tests/resources/base.ttl new file mode 100644 index 0000000..b480825 --- /dev/null +++ b/tests/resources/base.ttl @@ -0,0 +1,8 @@ +@prefix dbo: . +@prefix dbr: . +@prefix rdfs: . + +dbr:Leipzig rdfs:label "Leipzig"@en . +dbr:Leipzig dbo:country dbr:Germany . +dbr:Leipzig dbo:populationTotal "593145"^^ . +dbr:Germany rdfs:label "Germany"@en . From b8177d8a2cfac15667cb2f5ce19d8e5a21bfcc1b Mon Sep 17 00:00:00 2001 From: Integer-Ctrl Date: Tue, 1 Sep 2026 10:40:50 +0200 Subject: [PATCH 9/9] chore: version 1.0.0 --- .github/workflows/docker-publish.yml | 9 --------- README.md | 4 ++-- databusclient/__init__.py | 24 ++---------------------- databusclient/manifest/writer.py | 6 +++--- databusclient/version.py | 22 ++++++++++++++++++++++ pyproject.toml | 2 +- 6 files changed, 30 insertions(+), 37 deletions(-) create mode 100644 databusclient/version.py diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 9b947de..467b55c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -40,12 +40,3 @@ jobs: tags: | dbpedia/databus-python-client:latest dbpedia/databus-python-client:${{ steps.package.outputs.version }} - - - name: Update Docker Hub overview - uses: peter-evans/dockerhub-description@v5 - with: - username: ${{ secrets.DBP_DOCKERHUB_CREDENTIAL_USERNAME }} - password: ${{ secrets.DBP_DOCKERHUB_CREDENTIAL_TOKEN_PUSHIMAGES }} - repository: dbpedia/databus-python-client - short-description: Command-line and Python client for downloading, deploying and deleting datasets on DBpedia Databus. - readme-filepath: ./doc/docker/README.md diff --git a/README.md b/README.md index 421eed8..e6ed0b7 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,10 @@ Before using the client, install it via pip: python3 -m pip install databusclient ``` -Note: the PyPI release was updated and this repository prepares version `0.15`. If you previously installed `databusclient` via `pip` and observe different CLI behavior, upgrade to the latest release: +Note: this repository prepares version `1.0.0`. If you previously installed `databusclient` via `pip` and observe different CLI behavior, upgrade to the latest release: ```bash -python3 -m pip install --upgrade databusclient==0.15 +python3 -m pip install --upgrade databusclient==1.0.0 ``` You can then use the client in the command line: diff --git a/databusclient/__init__.py b/databusclient/__init__.py index 5066924..37ef670 100644 --- a/databusclient/__init__.py +++ b/databusclient/__init__.py @@ -5,31 +5,11 @@ ``python -m databusclient``. """ -from importlib.metadata import PackageNotFoundError, version -from pathlib import Path -import tomllib - from databusclient import cli from databusclient.api.deploy import create_dataset, create_distribution, deploy +from databusclient.version import __version__ - -# Source checkouts do not always have current package metadata installed, so -# prefer pyproject.toml locally and fall back to installed metadata for wheels -def _get_version() -> str: - pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" - if pyproject.exists(): - with pyproject.open("rb") as f: - return tomllib.load(f)["tool"]["poetry"]["version"] - - try: - return version("databusclient") - except PackageNotFoundError: - return "0.0.0" - - -__version__ = _get_version() - -__all__ = ["create_dataset", "deploy", "create_distribution"] +__all__ = ["__version__", "create_dataset", "deploy", "create_distribution"] def run(): diff --git a/databusclient/manifest/writer.py b/databusclient/manifest/writer.py index 3c0fd59..7e88e67 100644 --- a/databusclient/manifest/writer.py +++ b/databusclient/manifest/writer.py @@ -11,6 +11,7 @@ import os from databusclient.manifest.context import ManifestContext +from databusclient.version import __version__ # JSON-LD context using DataID vocabulary — same vocabulary used # by the Databus platform itself for semantic interoperability. @@ -23,7 +24,6 @@ } _SCHEMA_VERSION = "1.0" -_CLIENT_VERSION = "0.15" class ManifestWriter: @@ -70,7 +70,7 @@ def build_manifest_dict(context: ManifestContext) -> dict: "@context": _JSONLD_CONTEXT, "@type": "dbus:OperationManifest", "dbus:schemaVersion": _SCHEMA_VERSION, - "dbus:clientVersion": _CLIENT_VERSION, + "dbus:clientVersion": __version__, "dbus:command": context.command, "dcterms:issued": { "@value": context.issued, @@ -170,4 +170,4 @@ def _resolve_available_path(path: str) -> str: candidate = f"{base}_{counter}{ext}" if not os.path.exists(candidate): return candidate - counter += 1 \ No newline at end of file + counter += 1 diff --git a/databusclient/version.py b/databusclient/version.py new file mode 100644 index 0000000..49beada --- /dev/null +++ b/databusclient/version.py @@ -0,0 +1,22 @@ +"""Version helpers for databusclient.""" + +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +import tomllib + + +# Source checkouts do not always have current package metadata installed, so +# prefer pyproject.toml locally and fall back to installed metadata for wheels. +def get_version() -> str: + pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" + if pyproject.exists(): + with pyproject.open("rb") as f: + return tomllib.load(f)["tool"]["poetry"]["version"] + + try: + return version("databusclient") + except PackageNotFoundError: + return "0.0.0" + + +__version__ = get_version() diff --git a/pyproject.toml b/pyproject.toml index 3a91cff..137195e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "databusclient" -version = "0.15.1" +version = "1.0.0" description = "A simple client for submitting, downloading, and deleting data on the DBpedia Databus" authors = ["DBpedia Association"] license = "Apache-2.0 License"

D27v*RK4z} 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®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