diff --git a/docs/tutorials/command_line_client.md b/docs/tutorials/command_line_client.md index 8c572df2d..5cc7da890 100644 --- a/docs/tutorials/command_line_client.md +++ b/docs/tutorials/command_line_client.md @@ -569,12 +569,13 @@ Register a JSON Schema to a Synapse organization for later binding to entities. 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 | +| 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 | +| `--fix-schema-name` | Named | Replaces dashes and underscores with periods in the schema name ('my-schema_name' -> 'my.schema.name') | False | ### `bind-json-schema` diff --git a/synapseclient/__main__.py b/synapseclient/__main__.py index e68ca4c19..c86ecf75d 100644 --- a/synapseclient/__main__.py +++ b/synapseclient/__main__.py @@ -824,6 +824,7 @@ def register_json_schema(args, syn): schema_path=args.schema_path, organization_name=args.organization_name, schema_name=args.schema_name, + fix_schema_name=args.fix_schema_name, schema_version=args.schema_version, synapse_client=syn, ) @@ -1892,6 +1893,12 @@ def build_parser(): type=str, help="The name of the JSON schema", ) + parser_register_json_schema.add_argument( + "--fix-schema-name", + action="store_true", + default=False, + help="Replaces dashes and underscores with periods in the schema name (e.g., 'my-schema_name' becomes 'my.schema.name')", + ) parser_register_json_schema.add_argument( "--schema-version", dest="schema_version", diff --git a/synapseclient/extensions/curator/schema_management.py b/synapseclient/extensions/curator/schema_management.py index 02e37fc0b..e5b9bed58 100644 --- a/synapseclient/extensions/curator/schema_management.py +++ b/synapseclient/extensions/curator/schema_management.py @@ -6,6 +6,7 @@ """ import json +import re from typing import TYPE_CHECKING, Optional from synapseclient.core.async_utils import wrap_async_to_sync @@ -20,6 +21,7 @@ def register_jsonschema( schema_path: str, organization_name: str, schema_name: str, + fix_schema_name: bool = False, schema_version: Optional[str] = None, synapse_client: Optional["Synapse"] = None, ) -> "JSONSchema": @@ -33,6 +35,8 @@ def register_jsonschema( 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 + fix_schema_name: If True, fixes the schema name to meet Synapse requirements by replacing + dashes and underscores with periods. Defaults to False. 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 @@ -54,6 +58,7 @@ def register_jsonschema( schema_path="/path/to/schema.json", organization_name="my.org", schema_name="my.schema", + fix_schema_name=True, schema_version="0.0.1", synapse_client=syn ) @@ -65,6 +70,7 @@ def register_jsonschema( coroutine=register_jsonschema_async( schema_path=schema_path, organization_name=organization_name, + fix_schema_name=fix_schema_name, schema_name=schema_name, schema_version=schema_version, synapse_client=synapse_client, @@ -76,6 +82,7 @@ async def register_jsonschema_async( schema_path: str, organization_name: str, schema_name: str, + fix_schema_name: bool = False, schema_version: Optional[str] = None, synapse_client: Optional["Synapse"] = None, ) -> "JSONSchema": @@ -89,6 +96,8 @@ async def register_jsonschema_async( 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 + fix_schema_name: If True, fixes the schema name to meet Synapse requirements by replacing + dashes and underscores with periods. Defaults to False. 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 @@ -111,6 +120,7 @@ async def register_jsonschema_async( schema_path="/path/to/schema.json", organization_name="my.org", schema_name="my.schema", + fix_schema_name=True, schema_version="0.0.1", synapse_client=syn )) @@ -123,6 +133,11 @@ async def register_jsonschema_async( syn = Synapse.get_client(synapse_client=synapse_client) + if fix_schema_name: + old_name = schema_name + schema_name = fix_name(schema_name) + syn.logger.info(f"Changed schema name from '{old_name}' to '{schema_name}' ") + with open(schema_path, "r") as f: schema_body = json.load(f) @@ -142,6 +157,24 @@ async def register_jsonschema_async( return json_schema +def fix_name(name: str) -> str: + """ + Fixes a schema name to meet Synapse requirements by: + - replacing dashes and underscores with periods. + - collapsing multiple consecutive periods into a single period. + + Arguments: + name: The original schema name + + Returns: + The fixed schema name + + """ + name = name.replace("-", ".").replace("_", ".") + name = re.sub(r"\.+", ".", name) + return name + + def bind_jsonschema( entity_id: str, json_schema_uri: str, diff --git a/tests/integration/synapseclient/test_command_line_client.py b/tests/integration/synapseclient/test_command_line_client.py index a6062620d..803f08cdf 100644 --- a/tests/integration/synapseclient/test_command_line_client.py +++ b/tests/integration/synapseclient/test_command_line_client.py @@ -1334,6 +1334,30 @@ def test_register_json_schema(self, test_state, schema_organization, schema_file assert schema_name in output assert schema_organization.name in output + def test_register_json_schema_fix_schema_name( + self, test_state, schema_organization, schema_file + ): + """Test register-json-schema CLI command""" + schema_name = f"test-schema_id{str(uuid.uuid4())[:8]}" + fixed_schema_name = schema_name.replace("-", ".").replace("_", ".") + + output = run( + test_state, + "synapse", + "--skip-checks", + "register-json-schema", + schema_file, + schema_organization.name, + schema_name, + "--schema-version", + "1.0.0", + "--fix-schema-name", + ) + + assert "Successfully registered schema" in output + assert fixed_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 diff --git a/tests/unit/synapseclient/extensions/test_schema_management.py b/tests/unit/synapseclient/extensions/test_schema_management.py new file mode 100644 index 000000000..101b94736 --- /dev/null +++ b/tests/unit/synapseclient/extensions/test_schema_management.py @@ -0,0 +1,122 @@ +import json +from unittest.mock import AsyncMock, MagicMock, mock_open, patch + +import pytest + +from synapseclient.extensions.curator import register_jsonschema_async +from synapseclient.extensions.curator.schema_management import fix_name + + +@pytest.fixture +def mock_synapse_client(): + mock_client = MagicMock() + mock_client.logger = MagicMock() + return mock_client + + +@pytest.fixture +def mock_jsonschema(): + with patch("synapseclient.models.schema_organization.JSONSchema") as MockSchema: + instance = MockSchema.return_value + instance.store_async = AsyncMock() + instance.uri = "syn123.456" + yield MockSchema + + +@pytest.mark.asyncio +async def test_register_jsonschema_async(mock_synapse_client, mock_jsonschema): + schema_path = "mock_path.json" + schema_content = {"$id": "test_schema", "type": "object"} + org_name = "test.org" + schema_name = "my-schema_name" + version = "1.0.0" + + m_open = mock_open(read_data=json.dumps(schema_content)) + + with patch("builtins.open", m_open), patch( + "synapseclient.Synapse.get_client", return_value=mock_synapse_client + ), patch("json.load", return_value=schema_content): + result = await register_jsonschema_async( + schema_path=schema_path, + organization_name=org_name, + schema_name=schema_name, + schema_version=version, + synapse_client=mock_synapse_client, + ) + + # Verify the name was used as-is + mock_jsonschema.assert_called_once_with( + name=schema_name, organization_name=org_name + ) + + result.store_async.assert_awaited_once_with( + schema_body=schema_content, + version=version, + synapse_client=mock_synapse_client, + ) + + assert result.uri == "syn123.456" + + +@pytest.mark.asyncio +async def test_register_jsonschema_async_fix_schema_name( + mock_synapse_client, mock_jsonschema +): + schema_path = "mock_path.json" + schema_content = {"$id": "test_schema", "type": "object"} + org_name = "test.org" + schema_name = "my-schema_name" + fixed_schema_name = "my.schema.name" + version = "1.0.0" + + m_open = mock_open(read_data=json.dumps(schema_content)) + + with patch("builtins.open", m_open), patch( + "synapseclient.Synapse.get_client", return_value=mock_synapse_client + ), patch("json.load", return_value=schema_content): + result = await register_jsonschema_async( + schema_path=schema_path, + organization_name=org_name, + schema_name=schema_name, + fix_schema_name=True, + schema_version=version, + synapse_client=mock_synapse_client, + ) + + # Verify the name was fixed (dashes/underscores to dots) + mock_jsonschema.assert_called_once_with( + name=fixed_schema_name, organization_name=org_name + ) + + result.store_async.assert_awaited_once_with( + schema_body=schema_content, + version=version, + synapse_client=mock_synapse_client, + ) + + assert result.uri == "syn123.456" + + +@pytest.mark.parametrize( + "name, expected_fixed_name", + [ + ("name", "name"), + ("name.name", "name.name"), + ("name..name", "name.name"), + ("name-name", "name.name"), + ("name--name", "name.name"), + ("name_name", "name.name"), + ("name-_name", "name.name"), + ], + ids=[ + "No special characters", + "One period", + "Multiple periods", + "One dash", + "Multiple dashes", + "Underscore", + "Mixed special characters", + ], +) +def test_fix_name(name, expected_fixed_name): + assert fix_name(name) == expected_fixed_name