Files

1 line
18 KiB
JSON

{"repo": "bis-med-it/pysdmx", "files": {"src/pysdmx/api/fmr/maintenance.py": {"b12a380a2673843d7f1d65e071dc16682c5dc2c0": {"t": 1785768918.0714185, "n": 8156, "h": "5ee99d5aa41e7309", "c": "\"\"\"Upload metadata to an FMR instance.\"\"\"\n\nfrom enum import Enum\nfrom typing import Optional, Sequence, Union\n\nimport httpx\nimport msgspec\n\nfrom pysdmx.errors import Unauthorized\nfrom pysdmx.io.json.sdmxjson2.writer import serializers\nfrom pysdmx.model import MetadataReport\nfrom pysdmx.model.__base import MaintainableArtefact\nfrom pysdmx.model.message import (\n Header,\n MetadataMessage,\n StructureMessage,\n)\nfrom pysdmx.util._net_utils import BearerAuth, map_httpx_errors\n\n\nclass StructureAction(Enum):\n \"\"\"Enumeration that defines the action when updating metadata in the FMR.\n\n Arguments:\n Append: Metadata uploaded with action 'Append' may only add new\n metadata and may not overwrite any existing metadata, i.e. any\n attempt to update existing metadata will be rejected.\n Merge: Metadata uploaded with action 'Merge' may add new metadata and\n replace existing metadata. However, for Item Schemes (codelists,\n concept schemes, etc.), the items submitted will be added to the\n existing scheme. For example, if a codelist exists with codes A, B,\n and C, and the same codelist is submitted with codes B and X, then\n the resulting codelist will have codes A, B, C, X, i.e. code B has\n been replaced while code X has been added.\n Replace: Metadata uploaded with action 'Replace' may add new metadata,\n and can also replace existing metadata with new ones. This is the\n default.\n \"\"\"\n\n Append = \"Append\"\n Merge = \"Merge\"\n Replace = \"Replace\"\n\n\nclass RegistryMaintenanceClient:\n \"\"\"EXPERIMENTAL: A client to update metadata in the FMR.\n\n The client supports two authentication modes:\n\n - Bearer token authentication via ``access_token``\n - HTTP Basic authentication via ``user`` and ``password``\n\n If ``access_token`` is provided, it takes precedence over ``user`` and\n ``password``.\n\n The client does not obtain or refresh OIDC/OAuth2 tokens itself. It is the\n responsibility of the caller to acquire a valid access token from their\n authentication provider and pass it to this client.\n \"\"\"\n\n def __init__(\n self,\n api_endpoint: str,\n user: Optional[str] = None,\n password: Optional[str] = None,\n access_token: Optional[str] = None,\n pem: Optional[str] = None,\n timeout: float = 60.0,\n ):\n \"\"\"Instantiate a new client to update metadata in the target endpoint.\n\n Args:\n api_endpoint: The endpoint of the targeted service.\n user: Username for HTTP Basic authentication. Optional if\n ``access_token`` is provided.\n password: Password for HTTP Basic authentication. Optional if\n ``access_token`` is provided.\n access_token: Bearer access token to use for authentication. If\n provided, it takes precedence over ``user`` and ``password``.\n The caller is responsible for obtaining this token.\n pem: In case the service exposed a certificate created by an\n unknown certificate authority, you can pass a pem file for\n this authority using this parameter.\n timeout: The maximum number of seconds to wait before considering\n that a request timed out. Defaults to 60 seconds.\n\n Raises:\n Unauthorized: If neither ``access_token`` nor both ``user`` and\n ``password`` are provided.\n \"\"\"\n self._api_endpoint = self.__sanitize_endpoint(api_endpoint)\n self._user = user\n self._password = password\n self._access_token = access_token\n self._timeout = timeout\n\n if self._access_token is None and not (self._user and self._password):\n raise Unauthorized(\n \"Missing authentication\",\n (\n \"Authentication requires either access_token or both \"\n \"user and password, but none were provided.\"\n ),\n )\n\n self._ssl_context = (\n httpx.create_ssl_context(\n verify=pem,\n )\n if pem\n else httpx.create_ssl_context()\n )\n self._encoder = msgspec.json.Encoder()\n\n def __build_auth(self) -> httpx.Auth:\n \"\"\"Build the authentication strategy for outgoing requests.\n\n Returns:\n An ``httpx.Auth`` instance.\n\n Notes:\n If ``access_token`` is set, bearer-token authentication is used\n and takes precedence over ``user`` and ``password``.\n \"\"\"\n if self._access_token is not None:\n return BearerAuth(self._access_token)\n\n return httpx.BasicAuth(self._user, self._password) # type: ignore[arg-type]\n\n def __post(\n self,\n message: Union[MetadataMessage, StructureMessage],\n action: StructureAction,\n endpoint: str,\n ) -> None:\n with httpx.Client(verify=self._ssl_context) as client:\n try:\n auth = self.__build_auth()\n headers = {\n \"Content-Type\": \"application/text\",\n \"Action\": action.value,\n }\n if isinstance(message, MetadataMessage):\n serializer = serializers.metadata_message\n else:\n serializer = serializers.structure_message\n bodyjs = self._encoder.encode(serializer.from_model(message))\n r = client.post(\n endpoint,\n headers=headers,\n content=bodyjs,\n timeout=self._timeout,\n auth=auth,\n )\n r.raise_for_status()\n except (httpx.RequestError, httpx.HTTPStatusError) as e:\n map_httpx_errors(e)\n\n def put_structures(\n self,\n artefacts: Sequence[MaintainableArtefact],\n header: Optional[Header] = None,\n action: StructureAction = StructureAction.Replace,\n ) -> None:\n \"\"\"EXPERIMENTAL: Upload SDMX structures to the FMR.\n\n This method is experimental and its interface or behavior may change\n without notice.\n\n Args:\n artefacts: The sequence of SDMX maintainable artefacts to upload.\n header: Optional SDMX Header to include in the message. If not\n supplied, pysdmx will generate one for you.\n action: How to apply the changes in case of already existing\n structures.\n \"\"\"\n if not header:\n header = Header()\n message = StructureMessage(header=header, structures=artefacts)\n endpoint = f\"{self._api_endpoint}/ws/secure/sdmxapi/rest\"\n return self.__post(message, action, endpoint)\n\n def put_metadata_reports(\n self,\n reports: Sequence[MetadataReport],\n header: Optional[Header] = None,\n action: StructureAction = StructureAction.Replace,\n ) -> None:\n \"\"\"EXPERIMENTAL: Upload SDMX metadata reports to the FMR.\n\n This method is experimental and its interface or behavior may change\n without notice.\n\n Args:\n reports: A sequence of metadata reports to upload.\n header: Optional SDMX Header to include in the message. If not\n supplied, pysdmx will generate one for you.\n action: How to apply the changes in case of already existing\n structures.\n \"\"\"\n if not header:\n header = Header()\n message = MetadataMessage(header=header, reports=reports)\n endpoint = f\"{self._api_endpoint}/ws/secure/sdmx/v2/metadata\"\n return self.__post(message, action, endpoint)\n\n def __sanitize_endpoint(self, endpoint: str) -> str:\n if endpoint.endswith(\"/\"):\n endpoint = endpoint[0:-1]\n endpoint = endpoint.replace(\"/ws/secure/sdmx/v2/metadata\", \"\")\n endpoint = endpoint.replace(\"/sdmx/v2\", \"\")\n endpoint = endpoint.replace(\"/ws/secure/sdmxapi/rest\", \"\")\n return endpoint\n"}}, "tests/api/fmr/maintenance/test_fmr_maintenance.py": {"b12a380a2673843d7f1d65e071dc16682c5dc2c0": {"t": 1785768920.1780396, "n": 8889, "h": "710980628857c43d", "c": "import base64\n\nimport httpx\nimport msgspec\nimport pytest\n\nfrom pysdmx import errors\nfrom pysdmx.api.fmr.maintenance import RegistryMaintenanceClient\nfrom pysdmx.io.json.sdmxjson2.messages import (\n JsonMetadataMessage,\n JsonStructureMessage,\n)\nfrom pysdmx.model import (\n Code,\n Codelist,\n MetadataAttribute,\n MetadataReport,\n Organisation,\n)\nfrom pysdmx.model.message import Header\n\n\n@pytest.fixture\ndef end_point_in() -> str:\n return \"https://registry.sdmx.org/\"\n\n\n@pytest.fixture\ndef end_point_in_2() -> str:\n return \"https://registry.sdmx.org\"\n\n\n@pytest.fixture\ndef end_point_in_3() -> str:\n return \"https://registry.sdmx.org/sdmx/v2\"\n\n\n@pytest.fixture\ndef end_point_out_structure() -> str:\n return \"https://registry.sdmx.org/ws/secure/sdmxapi/rest\"\n\n\n@pytest.fixture\ndef end_point_out_report() -> str:\n return \"https://registry.sdmx.org/ws/secure/sdmx/v2/metadata\"\n\n\n@pytest.fixture\ndef structure():\n cd = Code(\"A\", name=\"Code A\")\n return Codelist(\"CL_TEST\", agency=\"TEST\", name=\"Test CL\", items=(cd,))\n\n\n@pytest.fixture\ndef report():\n a = MetadataAttribute(\"A\", \"Code A\")\n return MetadataReport(\n \"CL_TEST\",\n agency=\"TEST\",\n name=\"Test CL\",\n attributes=tuple(a),\n targets=tuple(\n \"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=TS:T1(1.0)\"\n ),\n )\n\n\n@pytest.fixture\ndef user():\n return \"ad000042\"\n\n\n@pytest.fixture\ndef pwd():\n return \"mypwd\"\n\n\n@pytest.fixture\ndef access_token():\n return \"my-token\"\n\n\n@pytest.fixture\ndef header():\n return Header(test=True, sender=Organisation(\"5B0\"))\n\n\ndef test_structure_maintenance(\n respx_mock, structure, end_point_in, end_point_out_structure, user, pwd\n):\n respx_mock.post(end_point_out_structure).mock(\n return_value=httpx.Response(200)\n )\n\n client = RegistryMaintenanceClient(end_point_in, user, pwd)\n\n client.put_structures([structure])\n\n assert respx_mock.calls.call_count == 1\n request = respx_mock.calls[0].request\n\n # Check header\n assert \"Action\" in request.headers\n assert request.headers[\"Action\"] == \"Replace\"\n assert \"Authorization\" in request.headers\n expected = __compute_pwd(user, pwd)\n assert request.headers[\"Authorization\"] == expected\n\n # Check content\n msg = (\n msgspec.json.Decoder(JsonStructureMessage)\n .decode(request.content)\n .to_model()\n )\n assert len(msg.structures) == 1\n assert msg.structures[0] == structure\n assert msg.header.id is not None\n assert msg.header.test is False\n assert msg.header.prepared is not None\n assert msg.header.sender.id == \"ZZZ\"\n\n\ndef test_structure_maintenance_header(\n respx_mock,\n structure,\n end_point_in,\n end_point_out_structure,\n user,\n pwd,\n header,\n):\n respx_mock.post(end_point_out_structure).mock(\n return_value=httpx.Response(200)\n )\n\n client = RegistryMaintenanceClient(end_point_in, user, pwd)\n\n client.put_structures([structure], header)\n\n # Check header\n request = respx_mock.calls[0].request\n msg = (\n msgspec.json.Decoder(JsonStructureMessage)\n .decode(request.content)\n .to_model()\n )\n assert msg.header.id is not None\n assert msg.header.test is True\n assert msg.header.prepared is not None\n assert msg.header.sender.id == \"5B0\"\n\n\ndef test_report_maintenance(\n respx_mock, report, end_point_in, end_point_out_report, user, pwd\n):\n respx_mock.post(end_point_out_report).mock(\n return_value=httpx.Response(200)\n )\n\n client = RegistryMaintenanceClient(end_point_in, user, pwd)\n\n client.put_metadata_reports([report])\n\n assert respx_mock.calls.call_count == 1\n request = respx_mock.calls[0].request\n\n # Check header\n assert \"Action\" in request.headers\n assert request.headers[\"Action\"] == \"Replace\"\n assert \"Authorization\" in request.headers\n expected = __compute_pwd(user, pwd)\n assert request.headers[\"Authorization\"] == expected\n\n # Check content\n msg = (\n msgspec.json.Decoder(JsonMetadataMessage)\n .decode(request.content)\n .to_model()\n )\n assert len(msg.reports) == 1\n assert msg.reports[0] == report\n assert msg.header.id is not None\n assert msg.header.test is False\n assert msg.header.prepared is not None\n assert msg.header.sender.id == \"ZZZ\"\n\n\ndef test_report_maintenance_header(\n respx_mock, report, end_point_in, end_point_out_report, user, pwd, header\n):\n respx_mock.post(end_point_out_report).mock(\n return_value=httpx.Response(200)\n )\n\n client = RegistryMaintenanceClient(end_point_in, user, pwd)\n\n client.put_metadata_reports([report], header)\n\n # Check header\n request = respx_mock.calls[0].request\n msg = (\n msgspec.json.Decoder(JsonMetadataMessage)\n .decode(request.content)\n .to_model()\n )\n assert msg.header.id is not None\n assert msg.header.test is True\n assert msg.header.prepared is not None\n assert msg.header.sender.id == \"5B0\"\n\n\ndef test_report_maintenance_access_token(\n respx_mock, report, end_point_in, end_point_out_report, access_token\n):\n respx_mock.post(end_point_out_report).mock(\n return_value=httpx.Response(200)\n )\n\n client = RegistryMaintenanceClient(end_point_in, access_token=access_token)\n\n client.put_metadata_reports([report])\n\n assert respx_mock.calls.call_count == 1\n request = respx_mock.calls[0].request\n\n # Check header\n assert \"Action\" in request.headers\n assert request.headers[\"Action\"] == \"Replace\"\n assert \"Authorization\" in request.headers\n assert request.headers[\"Authorization\"] == f\"Bearer {access_token}\"\n\n # Check content\n msg = (\n msgspec.json.Decoder(JsonMetadataMessage)\n .decode(request.content)\n .to_model()\n )\n assert len(msg.reports) == 1\n assert msg.reports[0] == report\n assert msg.header.id is not None\n assert msg.header.test is False\n assert msg.header.prepared is not None\n assert msg.header.sender.id == \"ZZZ\"\n\n\ndef test_report_maintenance_access_token_has_priority(\n respx_mock,\n report,\n end_point_in,\n end_point_out_report,\n user,\n pwd,\n access_token,\n):\n respx_mock.post(end_point_out_report).mock(\n return_value=httpx.Response(200)\n )\n\n client = RegistryMaintenanceClient(\n end_point_in, user, pwd, access_token=access_token\n )\n\n client.put_metadata_reports([report])\n\n assert respx_mock.calls.call_count == 1\n request = respx_mock.calls[0].request\n\n # Check header\n assert \"Authorization\" in request.headers\n assert request.headers[\"Authorization\"] == f\"Bearer {access_token}\"\n\n\ndef test_invalid_auth_configuration(end_point_in):\n with pytest.raises(errors.Unauthorized):\n RegistryMaintenanceClient(end_point_in)\n\n\ndef test_endpoint_ending(\n respx_mock, structure, end_point_in_2, end_point_out_structure, user, pwd\n):\n respx_mock.post(end_point_out_structure).mock(\n return_value=httpx.Response(200)\n )\n\n client = RegistryMaintenanceClient(end_point_in_2, user, pwd)\n\n client.put_structures([structure])\n\n assert respx_mock.calls.call_count == 1\n\n\ndef test_endpoint_with_new_ep(\n respx_mock, structure, end_point_in_3, end_point_out_structure, user, pwd\n):\n respx_mock.post(end_point_out_structure).mock(\n return_value=httpx.Response(200)\n )\n\n client = RegistryMaintenanceClient(end_point_in_3, user, pwd)\n\n client.put_structures([structure])\n\n assert respx_mock.calls.call_count == 1\n\n\ndef test_endpoint_with_old_ep(\n respx_mock, structure, end_point_out_structure, user, pwd\n):\n respx_mock.post(end_point_out_structure).mock(\n return_value=httpx.Response(200)\n )\n\n client = RegistryMaintenanceClient(end_point_out_structure, user, pwd)\n\n client.put_structures([structure])\n\n assert respx_mock.calls.call_count == 1\n\n\ndef test_endpoint_with_refmeta_ep(\n respx_mock, report, end_point_out_report, user, pwd\n):\n respx_mock.post(end_point_out_report).mock(\n return_value=httpx.Response(200)\n )\n\n client = RegistryMaintenanceClient(end_point_out_report, user, pwd)\n\n client.put_metadata_reports([report])\n\n assert respx_mock.calls.call_count == 1\n\n\ndef test_client_error(\n respx_mock, structure, end_point_in, end_point_out_structure, user, pwd\n):\n respx_mock.post(end_point_out_structure).mock(\n return_value=httpx.Response(409)\n )\n client = RegistryMaintenanceClient(end_point_in, user, pwd)\n\n with pytest.raises(errors.Invalid) as e:\n client.put_structures([structure])\n assert e.value.title is not None\n assert e.value.description is not None\n assert end_point_in in e.value.description\n\n\ndef __compute_pwd(user, pwd):\n encoded = base64.b64encode(f\"{user}:{pwd}\".encode(\"ascii\")).decode(\"ascii\")\n return f\"Basic {encoded}\"\n"}}}}