diff --git a/docs/tutorials/command_line_client.md b/docs/tutorials/command_line_client.md index 2b7763e7a..8c572df2d 100644 --- a/docs/tutorials/command_line_client.md +++ b/docs/tutorials/command_line_client.md @@ -82,6 +82,8 @@ synapse [-h] [--version] [-u SYNAPSEUSER] [-p SYNAPSE_AUTH_TOKEN] [-c CONFIGPATH - [get-sts-token](#get-sts-token): Get an STS token for access to AWS S3 storage underlying Synapse - [migrate](#migrate): Migrate Synapse entities to a different storage location - [generate-json-schema](#generate-json-schema): Generate JSON Schema(s) from a data model +- [register-json-schema](#register-json-schema): Register a JSON Schema to a Synapse organization +- [bind-json-schema](#bind-json-schema): Bind a JSON Schema to a Synapse entity ### `get` @@ -558,3 +560,32 @@ synapse generate-json-schema [-h] [--data-types data_type1, data_type2] [--outpu | `--data-types` | Named | Optional list of data types to create JSON Schema for | | `--output` | Named | Optional. Either a file path ending in '.json', or a directory path | | `--data-model-labels` | Named | Either 'class_label', or 'display_label' | + +### `register-json-schema` + +Register a JSON Schema to a Synapse organization for later binding to entities. + +```bash +synapse register-json-schema [-h] [--schema-version VERSION] schema_path organization_name schema_name +``` + +| Name | Type | Description | Default | +|-----------------------|------------|-------------------------------------------------------------------------------------|---------| +| `schema_path` | Positional | Path to the JSON schema file to register | | +| `organization_name` | Positional | Name of the organization to register the schema under | | +| `schema_name` | Positional | The name of the JSON schema | | +| `--schema-version` | Named | Version of the schema to register (e.g., '0.0.1'). If not specified, auto-generated | None | + +### `bind-json-schema` + +Bind a registered JSON Schema to a Synapse entity for metadata validation. + +```bash +synapse bind-json-schema [-h] [--enable-derived-annotations] id json_schema_uri +``` + +| Name | Type | Description | Default | +|-------------------------------|------------|------------------------------------------------------------------------------------|---------| +| `id` | Positional | The Synapse ID of the entity to bind the schema to (e.g., syn12345678) | | +| `json_schema_uri` | Positional | The URI of the JSON Schema to bind (e.g., 'my.org-schema.name-1.0.0') | | +| `--enable-derived-annotations`| Named | Enable derived annotations to auto-populate annotations from schema | False | diff --git a/docs/tutorials/python/schema_operations.md b/docs/tutorials/python/schema_operations.md index 8dc68574d..fc3c8e3b0 100644 --- a/docs/tutorials/python/schema_operations.md +++ b/docs/tutorials/python/schema_operations.md @@ -2,27 +2,26 @@ JSON Schema is a tool used to validate data. In Synapse, JSON Schemas can be use Synapse supports a subset of features from [json-schema-draft-07](https://json-schema.org/draft-07). To see the list of features currently supported, see the [JSON Schema object definition](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/schema/JsonSchema.html) from Synapse's REST API Documentation. -In this tutorial, you will learn how to create these JSON Schema using an existing data model. +In this tutorial, you will learn how to create, register, and bind JSON Schemas using an existing data model. ## Tutorial Purpose -You will create a JSON schema from your data model using the Python client as a library. To use the CLI tool, see the [documentation](../command_line_client.md). +You will learn the complete JSON Schema workflow: +1. **Generate** JSON schemas from your data model +2. **Register** schemas to a Synapse organization +3. **Bind** schemas to Synapse entities for metadata validation + +This tutorial uses the Python client as a library. To use the CLI tool, see the [command line documentation](../command_line_client.md). ## Prerequisites * You have a working [installation](../installation.md) of the Synapse Python Client. * You have a data model, see this [data model_documentation](../../explanations/curator_data_model.md). -## 1. Imports - -```python -{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=1-2} -``` - -## 2. Set up your variables +## 1. Initial set up ```python -{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=4-11} +{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=1-18} ``` To create a JSON Schema you need a data model, and the data types you want to create. @@ -31,74 +30,96 @@ The data model must be in either CSV or JSON-LD form. The data model may be a lo The data types must exist in your data model. This can be a list of data types, or `None` to create all data types in the data model. -## 3. Log into Synapse - -```python -{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=13-14} -``` - -## 4. Create a JSON Schema +## 2. Create a JSON Schema Create a JSON Schema ```python -{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=16-23} +{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=20-27} ``` You should see the first JSON Schema for the datatype you selected printed. It will look like [this schema](https://repo-prod.prod.sagebase.org/repo/v1/schema/type/registered/dpetest-test.schematic.Patient). By setting the `output` parameter as path to a "temp" directory, the file will be created as "temp/Patient.json". -## 5. Create multiple JSON Schema +## 3. Create multiple JSON Schema Create multiple JSON Schema ```python -{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=26-32} +{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=30-36} ``` The `data_types` parameter is a list and can have multiple data types. -## 6. Create every JSON Schema +## 4. Create every JSON Schema Create every JSON Schema ```python -{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=34-39} +{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=38-43} ``` If you don't set a `data_types` parameter a JSON Schema will be created for every data type in the data model. -## 7. Create a JSON Schema with a certain path +## 5. Create a JSON Schema with a certain path Create a JSON Schema ```python -{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=41-47} +{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=45-51} ``` If you have only one data type and set the `output` parameter to a file path(ending in.json), the JSON Schema file will have that path. -## 8. Create a JSON Schema in the current working directory +## 6. Create a JSON Schema in the current working directory Create a JSON Schema ```python -{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=49-54} +{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=53-58} ``` If you don't set `output` parameter the JSON Schema file will be created in the current working directory. -## 9. Create a JSON Schema using display names +## 7. Create a JSON Schema using display names Create a JSON Schema ```python -{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=56-62} +{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=60-66} ``` You can have Curator format the property names and valid values in the JSON Schema. This will remove whitespace and special characters. +## 8. Register a JSON Schema to Synapse + +Once you've created a JSON Schema file, you can register it to a Synapse organization. + +```python +{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=68-76} +``` + +The `register_jsonschema` function: +- Takes a path to your generated JSON Schema file +- Registers it with the specified organization in Synapse +- Returns the schema URI and a success message +- You can optionally specify a version (e.g., "0.0.1") or let it auto-generate + +## 9. Bind a JSON Schema to a Synapse Entity + +After registering a schema, you can bind it to Synapse entities (files, folders, etc.) for metadata validation. + +```python +{!docs/tutorials/python/tutorial_scripts/schema_operations.py!lines=78-85} +``` + +The `bind_jsonschema` function: +- Takes a Synapse entity ID (e.g., "syn12345678") +- Binds the registered schema URI to that entity +- Optionally enables derived annotations to auto-populate metadata +- Returns binding details + ## Source Code for this Tutorial
diff --git a/docs/tutorials/python/tutorial_scripts/schema_operations.py b/docs/tutorials/python/tutorial_scripts/schema_operations.py index 60ec6675e..419ae08a4 100644 --- a/docs/tutorials/python/tutorial_scripts/schema_operations.py +++ b/docs/tutorials/python/tutorial_scripts/schema_operations.py @@ -1,5 +1,9 @@ from synapseclient import Synapse -from synapseclient.extensions.curator import generate_jsonschema +from synapseclient.extensions.curator import ( + bind_jsonschema, + generate_jsonschema, + register_jsonschema, +) # Path or URL to your data model (CSV or JSONLD format) # Example: "path/to/my_data_model.csv" or "https://raw.githubusercontent.com/example.csv" @@ -9,6 +13,16 @@ DATA_TYPE = ["Patient"] # Directory where JSON Schema files will be saved OUTPUT_DIRECTORY = "temp" +# Path to a generated JSON Schema file for registration +SCHEMA_PATH = "temp/Patient.json" +# Your Synapse organization name for schema registration +ORGANIZATION_NAME = "my.organization" +# Name for the schema +SCHEMA_NAME = "patient.schema" +# Version number for the schema +SCHEMA_VERSION = "0.0.1" +# Synapse entity ID to bind the schema to (file, folder, etc.) +ENTITY_ID = "syn12345678" syn = Synapse() syn.login() @@ -53,10 +67,29 @@ synapse_client=syn, ) -# Create JSON Schema in using display names for both properties names and valid values +# Create JSON Schema using display names for both properties names and valid values schemas, file_paths = generate_jsonschema( data_model_source=DATA_MODEL_SOURCE, data_types=DATA_TYPE, data_model_labels="display_label", synapse_client=syn, ) + +# Register a JSON Schema to Synapse +json_schema = register_jsonschema( + schema_path=SCHEMA_PATH, + organization_name=ORGANIZATION_NAME, + schema_name=SCHEMA_NAME, + schema_version=SCHEMA_VERSION, + synapse_client=syn, +) +print(f"Registered schema URI: {json_schema.uri}") + +# Bind a JSON Schema to a Synapse entity +result = bind_jsonschema( + entity_id=ENTITY_ID, + json_schema_uri=json_schema.uri, + enable_derived_annotations=True, + synapse_client=syn, +) +print(f"Successfully bound schema to entity: {result}") diff --git a/synapseclient/__main__.py b/synapseclient/__main__.py index 50b12b82f..e68ca4c19 100644 --- a/synapseclient/__main__.py +++ b/synapseclient/__main__.py @@ -36,6 +36,10 @@ SynapseNoCredentialsError, ) from synapseclient.extensions.curator.schema_generation import generate_jsonschema +from synapseclient.extensions.curator.schema_management import ( + bind_jsonschema, + register_jsonschema, +) from synapseclient.wiki import Wiki tracer = trace.get_tracer("synapseclient") @@ -814,6 +818,31 @@ def generate_json_schema(args, syn): logging.info(f"Created JSON Schema files: [{paths}]") +def register_json_schema(args, syn): + """Register a JSON schema to a Synapse organization.""" + register_jsonschema( + schema_path=args.schema_path, + organization_name=args.organization_name, + schema_name=args.schema_name, + schema_version=args.schema_version, + synapse_client=syn, + ) + + +def bind_json_schema(args, syn): + """Bind a JSON schema to a Synapse entity.""" + result = bind_jsonschema( + entity_id=args.id, + json_schema_uri=args.json_schema_uri, + enable_derived_annotations=args.enable_derived_annotations, + synapse_client=syn, + ) + syn.logger.info( + f"Successfully bound schema '{args.json_schema_uri}' to entity '{args.id}'" + ) + return result + + def build_parser(): """Builds the argument parser and returns the result.""" @@ -1845,6 +1874,54 @@ def build_parser(): ) parser_generate_json_schema.set_defaults(func=generate_json_schema) + parser_register_json_schema = subparsers.add_parser( + "register-json-schema", help="Register a JSON Schema to a Synapse organization." + ) + parser_register_json_schema.add_argument( + "schema_path", + type=str, + help="Path to the JSON schema file to register", + ) + parser_register_json_schema.add_argument( + "organization_name", + type=str, + help="Name of the organization to register the schema under", + ) + parser_register_json_schema.add_argument( + "schema_name", + type=str, + help="The name of the JSON schema", + ) + parser_register_json_schema.add_argument( + "--schema-version", + dest="schema_version", + type=str, + default=None, + help="Version of the schema to register (e.g., '0.0.1'). If not specified, a version will be auto-generated.", + ) + parser_register_json_schema.set_defaults(func=register_json_schema) + + parser_bind_json_schema = subparsers.add_parser( + "bind-json-schema", help="Bind a JSON Schema to a Synapse entity." + ) + parser_bind_json_schema.add_argument( + "id", + type=str, + help="The Synapse ID of the entity to bind the schema to (e.g., syn12345678).", + ) + parser_bind_json_schema.add_argument( + "json_schema_uri", + type=str, + help="The URI of the JSON Schema to bind (e.g., 'my.org-schema.name-1.0.0').", + ) + parser_bind_json_schema.add_argument( + "--enable-derived-annotations", + action="store_true", + default=False, + help="Enable derived annotations to auto-populate annotations from schema. Defaults to False.", + ) + parser_bind_json_schema.set_defaults(func=bind_json_schema) + parser_migrate.set_defaults(func=migrate) return parser diff --git a/synapseclient/extensions/curator/__init__.py b/synapseclient/extensions/curator/__init__.py index 78aa25456..ba6bdfee9 100644 --- a/synapseclient/extensions/curator/__init__.py +++ b/synapseclient/extensions/curator/__init__.py @@ -7,6 +7,12 @@ from .file_based_metadata_task import create_file_based_metadata_task from .record_based_metadata_task import create_record_based_metadata_task from .schema_generation import generate_jsonld, generate_jsonschema +from .schema_management import ( + bind_jsonschema, + bind_jsonschema_async, + register_jsonschema, + register_jsonschema_async, +) from .schema_registry import query_schema_registry __all__ = [ @@ -15,4 +21,8 @@ "query_schema_registry", "generate_jsonld", "generate_jsonschema", + "register_jsonschema", + "register_jsonschema_async", + "bind_jsonschema", + "bind_jsonschema_async", ] diff --git a/synapseclient/extensions/curator/schema_management.py b/synapseclient/extensions/curator/schema_management.py new file mode 100644 index 000000000..02e37fc0b --- /dev/null +++ b/synapseclient/extensions/curator/schema_management.py @@ -0,0 +1,253 @@ +""" +Wrapper functions for JSON Schema registration and binding operations. + +This module provides convenience functions for CLI commands that interact with +the Synapse JSON Schema OOP models. +""" + +import json +from typing import TYPE_CHECKING, Optional + +from synapseclient.core.async_utils import wrap_async_to_sync + +if TYPE_CHECKING: + from synapseclient import Synapse + from synapseclient.models.mixins.json_schema import JSONSchemaBinding + from synapseclient.models.schema_organization import JSONSchema + + +def register_jsonschema( + schema_path: str, + organization_name: str, + schema_name: str, + schema_version: Optional[str] = None, + synapse_client: Optional["Synapse"] = None, +) -> "JSONSchema": + """ + Register a JSON schema to a Synapse organization. + + This function loads a JSON schema from a file and registers it with a specified + organization in Synapse using the JSONSchema OOP model. + + Arguments: + schema_path: Path to the JSON schema file to register + organization_name: Name of the organization to register the schema under + schema_name: Name of the JSON schema + schema_version: Optional version of the schema (e.g., '0.0.1'). + If not specified, a version will be auto-generated. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor + + Returns: + The registered JSONSchema object + + Example: Register a JSON schema + ```python + from synapseclient import Synapse + from synapseclient.extensions.curator import register_jsonschema + + syn = Synapse() + syn.login() + + json_schema = register_jsonschema( + schema_path="/path/to/schema.json", + organization_name="my.org", + schema_name="my.schema", + schema_version="0.0.1", + synapse_client=syn + ) + print(f"Registered schema URI: {json_schema.uri}") + print(f"Schema version: {json_schema.version}") + ``` + """ + return wrap_async_to_sync( + coroutine=register_jsonschema_async( + schema_path=schema_path, + organization_name=organization_name, + schema_name=schema_name, + schema_version=schema_version, + synapse_client=synapse_client, + ) + ) + + +async def register_jsonschema_async( + schema_path: str, + organization_name: str, + schema_name: str, + schema_version: Optional[str] = None, + synapse_client: Optional["Synapse"] = None, +) -> "JSONSchema": + """ + Register a JSON schema to a Synapse organization asynchronously. + + This function loads a JSON schema from a file and registers it with a specified + organization in Synapse using the JSONSchema OOP model. + + Arguments: + schema_path: Path to the JSON schema file to register + organization_name: Name of the organization to register the schema under + schema_name: The name of the JSON schema + schema_version: Optional version of the schema (e.g., '0.0.1'). + If not specified, a version will be auto-generated. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor + + Returns: + The registered JSONSchema object + + Example: Register a JSON schema + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.extensions.curator import register_jsonschema_async + + syn = Synapse() + syn.login() + + json_schema = asyncio.run(register_jsonschema_async( + schema_path="/path/to/schema.json", + organization_name="my.org", + schema_name="my.schema", + schema_version="0.0.1", + synapse_client=syn + )) + print(f"Registered schema URI: {json_schema.uri}") + print(f"Schema version: {json_schema.version}") + ``` + """ + from synapseclient import Synapse + from synapseclient.models.schema_organization import JSONSchema + + syn = Synapse.get_client(synapse_client=synapse_client) + + with open(schema_path, "r") as f: + schema_body = json.load(f) + + json_schema = JSONSchema(name=schema_name, organization_name=organization_name) + + await json_schema.store_async( + schema_body=schema_body, + version=schema_version, + synapse_client=syn, + ) + + syn.logger.info( + f"Successfully registered schema '{schema_name}' to organization '{organization_name}'" + ) + syn.logger.info(f"Schema URI: {json_schema.uri}") + + return json_schema + + +def bind_jsonschema( + entity_id: str, + json_schema_uri: str, + enable_derived_annotations: bool = False, + synapse_client: Optional["Synapse"] = None, +) -> "JSONSchemaBinding": + """ + Bind a JSON schema to a Synapse entity. + + This function binds a JSON schema to a Synapse entity using the Entity OOP model's + bind_schema method. + + Arguments: + entity_id: The Synapse ID of the entity to bind the schema to (e.g., syn12345678) + json_schema_uri: The URI of the JSON Schema to bind (e.g., 'my.org-schema.name-1.0.0') + enable_derived_annotations: If true, enable derived annotations. Defaults to False. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor + + Returns: + The JSONSchemaBinding object containing the binding details + + Example: Bind a JSON schema to an entity + ```python + from synapseclient import Synapse + from synapseclient.extensions.curator import bind_jsonschema + + syn = Synapse() + syn.login() + + result = bind_jsonschema( + entity_id="syn12345678", + json_schema_uri="my.org-my.schema-0.0.1", + enable_derived_annotations=True, + synapse_client=syn + ) + print(f"Successfully bound schema: {result}") + ``` + """ + return wrap_async_to_sync( + coroutine=bind_jsonschema_async( + entity_id=entity_id, + json_schema_uri=json_schema_uri, + enable_derived_annotations=enable_derived_annotations, + synapse_client=synapse_client, + ) + ) + + +async def bind_jsonschema_async( + entity_id: str, + json_schema_uri: str, + enable_derived_annotations: bool = False, + synapse_client: Optional["Synapse"] = None, +) -> "JSONSchemaBinding": + """ + Bind a JSON schema to a Synapse entity asynchronously. + + This function binds a JSON schema to a Synapse entity using the Entity OOP model's + bind_schema method. + + Arguments: + entity_id: The Synapse ID of the entity to bind the schema to (e.g., syn12345678) + json_schema_uri: The URI of the JSON Schema to bind (e.g., 'my.org-schema.name-1.0.0') + enable_derived_annotations: If true, enable derived annotations. Defaults to False. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor + + Returns: + The JSONSchemaBinding object containing the binding details + + Example: Bind a JSON schema to an entity + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.extensions.curator import bind_jsonschema_async + + syn = Synapse() + syn.login() + + result = asyncio.run(bind_jsonschema_async( + entity_id="syn12345678", + json_schema_uri="my.org-my.schema-0.0.1", + enable_derived_annotations=True, + synapse_client=syn + )) + print(f"Successfully bound schema: {result}") + ``` + """ + from synapseclient import Synapse + from synapseclient.operations import FileOptions, get_async + + syn = Synapse.get_client(synapse_client=synapse_client) + + entity = await get_async( + file_options=FileOptions(download_file=False), + synapse_id=entity_id, + synapse_client=syn, + ) + + result = await entity.bind_schema_async( + json_schema_uri=json_schema_uri, + enable_derived_annotations=enable_derived_annotations, + synapse_client=syn, + ) + + return result diff --git a/tests/integration/synapseclient/test_command_line_client.py b/tests/integration/synapseclient/test_command_line_client.py index 64e3afcfb..a6062620d 100644 --- a/tests/integration/synapseclient/test_command_line_client.py +++ b/tests/integration/synapseclient/test_command_line_client.py @@ -1268,3 +1268,155 @@ def test_jsonld_url(self): finally: if os.path.isfile(schema_path): os.remove(schema_path) + + +class TestSchemaManagementCommands: + """Integration tests for register-json-schema and bind-json-schema CLI commands""" + + @pytest.fixture(scope="class") + def schema_organization(self, syn: Synapse, request): + """Create a test organization for schema registration.""" + from synapseclient.models import SchemaOrganization + + # Prefix with 'id' so the name part starts with a letter (required by schema validation) + org_name = f"test.org.id{str(uuid.uuid4())[:8]}" + organization = SchemaOrganization(org_name) + organization.store(synapse_client=syn) + + def cleanup(): + for schema in organization.get_json_schemas(synapse_client=syn): + schema.delete(synapse_client=syn) + organization.delete(synapse_client=syn) + + request.addfinalizer(cleanup) + return organization + + @pytest.fixture(scope="function") + def schema_file(self, request): + """Create a temporary JSON schema file for testing""" + schema_definition = { + "$id": "test.schema", + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + }, + "required": ["name"], + } + temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) + json.dump(schema_definition, temp_file) + temp_file.close() + + def cleanup(): + if os.path.exists(temp_file.name): + os.remove(temp_file.name) + + request.addfinalizer(cleanup) + return temp_file.name + + def test_register_json_schema(self, test_state, schema_organization, schema_file): + """Test register-json-schema CLI command""" + schema_name = f"test.schema.id{str(uuid.uuid4())[:8]}" + + output = run( + test_state, + "synapse", + "--skip-checks", + "register-json-schema", + schema_file, + schema_organization.name, + schema_name, + "--schema-version", + "1.0.0", + ) + + assert "Successfully registered schema" in output + assert schema_name in output + assert schema_organization.name in output + + def test_bind_json_schema(self, test_state, schema_organization, schema_file): + """Test bind-json-schema CLI command""" + from synapseclient.models import Folder + + schema_name = f"test.schema.id{str(uuid.uuid4())[:8]}" + run( + test_state, + "synapse", + "--skip-checks", + "register-json-schema", + schema_file, + schema_organization.name, + schema_name, + "--schema-version", + "1.0.0", + ) + + schema_uri = f"{schema_organization.name}-{schema_name}-1.0.0" + folder = Folder( + name=f"test.folder.{str(uuid.uuid4())[:8]}", + parent_id=test_state.project.id, + ) + folder.store(synapse_client=test_state.syn) + + try: + output = run( + test_state, + "synapse", + "--skip-checks", + "bind-json-schema", + folder.id, + schema_uri, + "--enable-derived-annotations", + ) + + assert "Successfully bound schema" in output + assert schema_uri in output + assert folder.id in output + finally: + folder.unbind_schema(synapse_client=test_state.syn) + test_state.syn.delete(folder.id) + + def test_register_and_bind_workflow( + self, test_state, schema_organization, schema_file + ): + """Test complete workflow: register schema and bind to entity""" + from synapseclient.models import Folder + + schema_name = f"test.schema.id{str(uuid.uuid4())[:8]}" + + output = run( + test_state, + "synapse", + "--skip-checks", + "register-json-schema", + schema_file, + schema_organization.name, + schema_name, + "--schema-version", + "2.0.0", + ) + assert "Successfully registered schema" in output + + folder = Folder( + name=f"test.folder.{str(uuid.uuid4())[:8]}", + parent_id=test_state.project.id, + ) + folder.store(synapse_client=test_state.syn) + + try: + schema_uri = f"{schema_organization.name}-{schema_name}-2.0.0" + output = run( + test_state, + "synapse", + "--skip-checks", + "bind-json-schema", + folder.id, + schema_uri, + ) + assert "Successfully bound schema" in output + + bound_schema = folder.get_schema(synapse_client=test_state.syn) + assert bound_schema is not None + finally: + folder.unbind_schema(synapse_client=test_state.syn) + test_state.syn.delete(folder.id) diff --git a/tests/integration/synapseclient/test_schema_management.py b/tests/integration/synapseclient/test_schema_management.py new file mode 100644 index 000000000..34a9f7d94 --- /dev/null +++ b/tests/integration/synapseclient/test_schema_management.py @@ -0,0 +1,261 @@ +"""Integration tests for schema management wrapper functions (register and bind)""" +import json +import os +import tempfile +import uuid + +import pytest + +from synapseclient import Synapse +from synapseclient.extensions.curator import bind_jsonschema, register_jsonschema +from synapseclient.models import File, Folder, Project, SchemaOrganization + + +def create_test_name(): + """Creates a random string for naming test entities""" + random_string = "".join(i for i in str(uuid.uuid4()) if i.isalpha()) + return f"SYNPY.TEST.{random_string}" + + +@pytest.fixture(name="test_organization", scope="module") +def fixture_test_organization(syn: Synapse, request) -> SchemaOrganization: + """ + Returns a created organization for testing schema registration + """ + org = SchemaOrganization(create_test_name()) + org.store(synapse_client=syn) + + def delete_org(): + # Delete all schemas in the organization + for schema in org.get_json_schemas(synapse_client=syn): + schema.delete(synapse_client=syn) + org.delete(synapse_client=syn) + + request.addfinalizer(delete_org) + return org + + +@pytest.fixture(name="test_project", scope="module") +def fixture_test_project(syn: Synapse, request) -> Project: + """ + Returns a test project for binding schemas + """ + project = Project(name=create_test_name()) + project.store(synapse_client=syn) + + def delete_project(): + project.delete(synapse_client=syn) + + request.addfinalizer(delete_project) + return project + + +@pytest.fixture(name="test_schema_file", scope="function") +def fixture_test_schema_file(request): + """ + Creates a temporary JSON schema file for testing + """ + schema_definition = { + "$id": "test.schema", + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + }, + "required": ["name"], + } + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) as temp_file: + json.dump(schema_definition, temp_file) + temp_path = temp_file.name + + def cleanup(): + if os.path.exists(temp_path): + os.remove(temp_path) + + request.addfinalizer(cleanup) + return temp_path + + +class TestRegisterJsonSchema: + """Integration tests for register_jsonschema wrapper function""" + + def test_register_jsonschema_with_version( + self, syn: Synapse, test_organization: SchemaOrganization, test_schema_file: str + ): + """Test registering a JSON schema with a specific version""" + schema_name = create_test_name() + version = "1.0.0" + + # Register the schema + json_schema = register_jsonschema( + schema_path=test_schema_file, + organization_name=test_organization.name, + schema_name=schema_name, + schema_version=version, + synapse_client=syn, + ) + + # Verify the schema was registered + assert json_schema is not None + assert json_schema.uri is not None + assert json_schema.name == schema_name + assert test_organization.name in json_schema.uri + + def test_register_jsonschema_without_version( + self, syn: Synapse, test_organization: SchemaOrganization, test_schema_file: str + ): + """Test registering a JSON schema without specifying a version""" + schema_name = create_test_name() + + # Register the schema + json_schema = register_jsonschema( + schema_path=test_schema_file, + organization_name=test_organization.name, + schema_name=schema_name, + synapse_client=syn, + ) + + # Verify the schema was registered + assert json_schema is not None + assert json_schema.uri is not None + assert json_schema.name == schema_name + + +class TestBindJsonSchema: + """Integration tests for bind_jsonschema wrapper function""" + + def test_bind_jsonschema_to_folder( + self, + syn: Synapse, + test_organization: SchemaOrganization, + test_project: Project, + test_schema_file: str, + ): + """Test binding a JSON schema to a folder""" + # First register a schema + schema_name = create_test_name() + json_schema = register_jsonschema( + schema_path=test_schema_file, + organization_name=test_organization.name, + schema_name=schema_name, + schema_version="1.0.0", + synapse_client=syn, + ) + + # Create a test folder + folder = Folder(name=create_test_name(), parent_id=test_project.id) + folder.store(synapse_client=syn) + + try: + # Bind the schema to the folder + result = bind_jsonschema( + entity_id=folder.id, + json_schema_uri=json_schema.uri, + enable_derived_annotations=False, + synapse_client=syn, + ) + + # Verify the binding + assert result is not None + finally: + # Cleanup: unbind schema before deleting folder + folder.unbind_schema(synapse_client=syn) + syn.delete(folder.id) + + def test_bind_jsonschema_with_derived_annotations( + self, + syn: Synapse, + test_organization: SchemaOrganization, + test_project: Project, + test_schema_file: str, + ): + """Test binding a JSON schema with derived annotations enabled""" + # Register a schema + schema_name = create_test_name() + json_schema = register_jsonschema( + schema_path=test_schema_file, + organization_name=test_organization.name, + schema_name=schema_name, + schema_version="1.0.0", + synapse_client=syn, + ) + + # Create a test folder + folder = Folder(name=create_test_name(), parent_id=test_project.id) + folder.store(synapse_client=syn) + + try: + # Bind the schema with derived annotations enabled + result = bind_jsonschema( + entity_id=folder.id, + json_schema_uri=json_schema.uri, + enable_derived_annotations=True, + synapse_client=syn, + ) + + # Verify the binding + assert result is not None + finally: + # Cleanup: unbind schema before deleting folder + folder.unbind_schema(synapse_client=syn) + syn.delete(folder.id) + + +class TestRegisterAndBindWorkflow: + """Integration tests for the complete register + bind workflow""" + + def test_complete_workflow( + self, + syn: Synapse, + test_organization: SchemaOrganization, + test_project: Project, + test_schema_file: str, + ): + """Test the complete workflow: register a schema and bind it to an entity""" + schema_name = create_test_name() + + # Step 1: Register the schema + json_schema = register_jsonschema( + schema_path=test_schema_file, + organization_name=test_organization.name, + schema_name=schema_name, + schema_version="1.0.0", + synapse_client=syn, + ) + + assert json_schema is not None + assert json_schema.uri is not None + + # Step 2: Create a folder + folder = Folder(name=create_test_name(), parent_id=test_project.id) + folder.store(synapse_client=syn) + + try: + # Step 3: Bind the schema to the folder + result = bind_jsonschema( + entity_id=folder.id, + json_schema_uri=json_schema.uri, + enable_derived_annotations=True, + synapse_client=syn, + ) + + # Verify the workflow completed successfully + assert result is not None + + # Verify the schema is actually bound by retrieving it + from synapseclient.operations import FileOptions, get + + retrieved_folder = get( + file_options=FileOptions(download_file=False), + synapse_id=folder.id, + synapse_client=syn, + ) + bound_schema = retrieved_folder.get_schema(synapse_client=syn) + assert bound_schema is not None + finally: + # Cleanup: unbind schema before deleting folder + folder.unbind_schema(synapse_client=syn) + syn.delete(folder.id) diff --git a/tests/unit/synapseclient/unit_test_commandline.py b/tests/unit/synapseclient/unit_test_commandline.py index 442e5a76a..7f22a85e2 100644 --- a/tests/unit/synapseclient/unit_test_commandline.py +++ b/tests/unit/synapseclient/unit_test_commandline.py @@ -976,3 +976,63 @@ def test_jsonld_path(self): finally: if os.path.isfile(schema_path): os.remove(schema_path) + + +class TestBindJSONSchemaFunction: + @pytest.fixture(scope="function", autouse=True) + @patch("synapseclient.client.Synapse") + def setup(self, mock_syn): + self.syn = mock_syn + + def test_bind_json_schema_parses_arguments(self): + """Test that the parser correctly parses bind-json-schema arguments.""" + # GIVEN a parser + parser = cmdline.build_parser() + # WHEN I parse bind-json-schema arguments + args = parser.parse_args( + ["bind-json-schema", "syn12345678", "my.org-schema.name-1.0.0"] + ) + # THEN the arguments should be parsed correctly + assert args.id == "syn12345678" + assert args.json_schema_uri == "my.org-schema.name-1.0.0" + assert args.enable_derived_annotations is False + + def test_bind_json_schema_with_derived_annotations(self): + """Test parsing with --enable-derived-annotations flag.""" + # GIVEN a parser + parser = cmdline.build_parser() + # WHEN I parse bind-json-schema arguments with --enable-derived-annotations + args = parser.parse_args( + [ + "bind-json-schema", + "syn12345678", + "my.org-schema.name-1.0.0", + "--enable-derived-annotations", + ] + ) + # THEN enable_derived_annotations should be True + assert args.enable_derived_annotations is True + + @patch("synapseclient.__main__.bind_jsonschema") + def test_bind_json_schema_calls_wrapper(self, mock_bind_jsonschema): + """Test that bind_json_schema calls the wrapper function correctly.""" + # GIVEN a mocked wrapper response + mock_bind_jsonschema.return_value = {"entityId": "syn12345678"} + parser = cmdline.build_parser() + args = parser.parse_args( + [ + "bind-json-schema", + "syn12345678", + "my.org-schema.name-1.0.0", + "--enable-derived-annotations", + ] + ) + # WHEN I call bind_json_schema + cmdline.bind_json_schema(args, self.syn) + # THEN bind_jsonschema should be called with correct arguments + mock_bind_jsonschema.assert_called_once_with( + entity_id="syn12345678", + json_schema_uri="my.org-schema.name-1.0.0", + enable_derived_annotations=True, + synapse_client=self.syn, + )