diff --git a/CHANGES.rst b/CHANGES.rst index a3a71106d..2f5893c5f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -12,7 +12,11 @@ Changes Changes: -------- -- No change. +- Implement the `CLI` ``replace`` operation to update an existing `Process` definition + (resolves `#906 `_). +- Add support for `multibase `_-encoded + `multihash `_ file digests for resource integrity verification + following `W3C VC Data Integrity `_ specification. Fixes: ------ diff --git a/tests/functional/test_cli.py b/tests/functional/test_cli.py index 73430cd54..3a8412e09 100644 --- a/tests/functional/test_cli.py +++ b/tests/functional/test_cli.py @@ -463,6 +463,223 @@ def test_undeploy(self): resp = mocked_sub_requests(self.app, "get", path, expect_errors=True) assert resp.status_code == 404 + @pytest.mark.oap_part2 + def test_replace_with_simple_metadata(self): + """ + Test replace operation with simple metadata fields (PATCH-level update). + """ + test_id = f"{self.test_process_prefix}replace-simple-metadata" + payload = copy.deepcopy(self.test_payload["Echo"]) + result = mocked_sub_requests(self.app, self.client.deploy, test_id, payload) + assert result.success + assert result.body["processSummary"]["version"] == "1.0" + + # Update title and description + result = mocked_sub_requests( + self.app, self.client.replace, test_id, + metadata={"title": "Updated Title", "description": "Updated description"} + ) + assert result.success + assert result.body["version"] == "1.0.1", "PATCH-level change should bump patch version" + + # Verify the changes were applied + result = mocked_sub_requests(self.app, self.client.describe, test_id) + assert result.success + assert result.body["title"] == "Updated Title" + assert result.body["description"] == "Updated description" + + @pytest.mark.oap_part2 + def test_replace_with_keywords(self): + """ + Test replace operation with keywords (PATCH-level update, appended). + """ + test_id = f"{self.test_process_prefix}replace-keywords" + payload = copy.deepcopy(self.test_payload["Echo"]) + result = mocked_sub_requests(self.app, self.client.deploy, test_id, payload) + assert result.success + original_keywords = result.body["processSummary"]["keywords"] + + # Add new keywords + result = mocked_sub_requests( + self.app, self.client.replace, test_id, + metadata={"keywords": ["climate", "weather"]} + ) + assert result.success + assert result.body["version"] == "1.0.1" + + # Verify keywords were appended + result = mocked_sub_requests(self.app, self.client.describe, test_id) + assert result.success + assert "climate" in result.body["keywords"] + assert "weather" in result.body["keywords"] + for keyword in original_keywords: + assert keyword in result.body["keywords"], "Original keywords should be preserved" + + @pytest.mark.oap_part2 + def test_replace_with_metadata_field_links(self): + """ + Test replace operation with process metadata field containing link entries. + """ + test_id = f"{self.test_process_prefix}replace-metadata-links" + payload = copy.deepcopy(self.test_payload["Echo"]) + result = mocked_sub_requests(self.app, self.client.deploy, test_id, payload) + assert result.success + + # Add metadata entries (link-based) + metadata_updates = { + "metadata": [ + {"role": "https://schema.org/author", "rel": "author", + "href": "https://orcid.org/0000-0000-0000-0000", "title": "Author ORCID"}, + {"role": "https://schema.org/codeRepository", + "rel": "repository", "href": "https://github.com/example/repo"} + ] + } + result = mocked_sub_requests(self.app, self.client.replace, test_id, metadata=metadata_updates) + assert result.success + assert result.body["version"] == "1.0.1" + + # Verify metadata was added + result = mocked_sub_requests(self.app, self.client.describe, test_id) + assert result.success + assert "metadata" in result.body + metadata_roles = [m.get("role") for m in result.body["metadata"]] + assert "https://schema.org/author" in metadata_roles + assert "https://schema.org/codeRepository" in metadata_roles + + @pytest.mark.oap_part2 + def test_replace_with_metadata_field_values(self): + """ + Test replace operation with process metadata field containing value entries. + """ + test_id = f"{self.test_process_prefix}replace-metadata-values" + payload = copy.deepcopy(self.test_payload["Echo"]) + result = mocked_sub_requests(self.app, self.client.deploy, test_id, payload) + assert result.success + + # Add metadata entries (value-based) + metadata_updates = { + "metadata": [ + {"role": "https://schema.org/name", "value": "John Doe"}, + {"role": "https://schema.org/license", "value": "Apache-2.0"} + ] + } + result = mocked_sub_requests(self.app, self.client.replace, test_id, metadata=metadata_updates) + assert result.success + assert result.body["version"] == "1.0.1" + + # Verify metadata was added + result = mocked_sub_requests(self.app, self.client.describe, test_id) + assert result.success + assert "metadata" in result.body + # Find the value entries + name_entry = next( + (m for m in result.body["metadata"] if m.get("role") == "https://schema.org/name"), + None + ) + license_entry = next( + (m for m in result.body["metadata"] if m.get("role") == "https://schema.org/license"), + None + ) + assert name_entry is not None + assert name_entry["value"] == "John Doe" + assert license_entry is not None + assert license_entry["value"] == "Apache-2.0" + + @pytest.mark.oap_part2 + def test_replace_with_visibility(self): + """ + Test replace operation with visibility (MINOR-level update). + """ + test_id = f"{self.test_process_prefix}replace-visibility" + payload = copy.deepcopy(self.test_payload["Echo"]) + result = mocked_sub_requests(self.app, self.client.deploy, test_id, payload) + assert result.success + assert result.body["processSummary"]["version"] == "1.0.0" + + # Change visibility to private + result = mocked_sub_requests( + self.app, self.client.replace, test_id, + metadata={"visibility": Visibility.PRIVATE} + ) + assert result.success + assert result.body["version"] == "1.1.0", "MINOR-level change should bump minor version" + + # Verify visibility was changed (describe should now require auth or fail) + result = mocked_sub_requests(self.app, self.client.describe, test_id) + assert not result.success + assert result.code == 403 + + @pytest.mark.oap_part2 + def test_replace_additive_body_and_metadata(self): + """ + Test that body/cwl and metadata parameters work additively. + """ + test_id = f"{self.test_process_prefix}replace-additive" + payload = copy.deepcopy(self.test_payload["Echo"]) + original_title = payload["processDescription"]["process"]["title"] + result = mocked_sub_requests(self.app, self.client.deploy, test_id, payload) + assert result.success + + # Replace with original body but override title + new_title = "Overridden Title" + result = mocked_sub_requests( + self.app, self.client.replace, test_id, + body=payload, metadata={"title": new_title, "keywords": ["override-test"]} + ) + assert result.success + + # Verify title was overridden + result = mocked_sub_requests(self.app, self.client.describe, test_id) + assert result.success + assert result.body["title"] == new_title, "Metadata should override body field" + assert result.body["title"] != original_title + assert "override-test" in result.body["keywords"] + + @pytest.mark.oap_part2 + def test_replace_with_version_explicit(self): + """ + Test replace operation with explicit version number. + """ + test_id = f"{self.test_process_prefix}replace-explicit-version" + payload = copy.deepcopy(self.test_payload["Echo"]) + result = mocked_sub_requests(self.app, self.client.deploy, test_id, payload) + assert result.success + + # Update with explicit version + result = mocked_sub_requests( + self.app, self.client.replace, test_id, + metadata={"title": "Version Test"}, version="2.5.0" + ) + assert result.success + assert result.body["version"] == "2.5.0", "Explicit version should be used" + + # Verify version + result = mocked_sub_requests(self.app, self.client.describe, test_id) + assert result.success + assert result.body["version"] == "2.5.0" + + @pytest.mark.oap_part2 + def test_replace_with_http_method_patch(self): + """ + Test replace operation with PATCH method explicitly. + """ + test_id = f"{self.test_process_prefix}replace-patch-method" + payload = copy.deepcopy(self.test_payload["Echo"]) + result = mocked_sub_requests(self.app, self.client.deploy, test_id, payload) + assert result.success + + # Update using PATCH method + result = mocked_sub_requests( + self.app, self.client.replace, test_id, + metadata={"title": "PATCH Test"}, http_method="PATCH" + ) + assert result.success + assert result.body["version"] == "1.0.1" + + result = mocked_sub_requests(self.app, self.client.describe, test_id) + assert result.success + assert result.body["title"] == "PATCH Test" + def test_describe(self): result = mocked_sub_requests(self.app, self.client.describe, self.test_process["Echo"]) assert self.test_payload["Echo"]["processDescription"]["process"]["version"] == "1.0", ( diff --git a/tests/test_cli.py b/tests/test_cli.py index 4ad0b33f7..2ad138bed 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,6 +4,7 @@ import argparse import base64 import contextlib +import copy import inspect import itertools import json @@ -11,6 +12,7 @@ import tempfile import uuid from contextlib import ExitStack +from typing import TYPE_CHECKING from urllib.parse import quote import mock @@ -31,6 +33,11 @@ from weaver.exceptions import AuthenticationError from weaver.formats import ContentEncoding, ContentType, get_cwl_file_format +if TYPE_CHECKING: + from typing import List, Optional, Tuple, Union + + from weaver.typedefs import ExecutionResults + @pytest.mark.cli def test_operation_result_repr(): @@ -1244,7 +1251,7 @@ def test_auth_request_handler_no_url_or_token_init(): BearerAuthHandler(token=str(uuid.uuid4())) # OK BearerAuthHandler(url="https://example.com") # OK except Exception as exc: - pytest.fail(msg=f"Expected no init error from valid combinations. Got [{exc}]") + pytest.fail(f"Expected no init error from valid combinations. Got [{exc}]") @pytest.mark.cli @@ -1486,3 +1493,682 @@ def test_cli_version_non_weaver(): result = WeaverClient(url="https://fake.domain.com").version() assert result.code == 404 assert "Failed to obtain server version." in result.message + + +@pytest.mark.cli +def test_cli_replace_with_body(): + """ + Test replace operation with full body parameter. + """ + test_body = {"processDescription": {"id": "test-process", "version": "2.0.0"}} + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process", "version": "2.0.0"}) + + with mock.patch("weaver.cli.request_extra", return_value=mock_response): + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process", body=test_body) + + assert result.success + assert result.code == 200 + + +@pytest.mark.cli +def test_cli_replace_with_metadata_kvp(): + """ + Test replace operation with metadata as key=value pairs. + """ + metadata_list = ["title=Updated Title", "description=Updated description"] + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process", "version": "1.0.1"}) + + with mock.patch("weaver.cli.request_extra", return_value=mock_response): + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process", metadata=metadata_list) + + assert result.success + assert result.code == 200 + + +@pytest.mark.cli +@pytest.mark.oap_part2 +def test_cli_replace_with_inputs_outputs(): + """ + Test replace operation with inputs and outputs. + """ + test_inputs = {"input1": {"title": "Input 1"}} + test_outputs = {"output1": {"title": "Output 1"}} + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process", "version": "1.1.0"}) + + with mock.patch("weaver.cli.request_extra", return_value=mock_response): + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process", inputs=test_inputs, outputs=test_outputs) + + assert result.success + assert result.code == 200 + + +@pytest.mark.cli +@pytest.mark.oap_part2 +def test_cli_replace_with_version(): + """ + Test replace operation with explicit version. + """ + test_metadata = {"title": "Updated Title"} + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process", "version": "3.0.0"}) + + with mock.patch("weaver.cli.request_extra", return_value=mock_response): + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process", metadata=test_metadata, version="3.0.0") + + assert result.success + assert result.code == 200 + + +@pytest.mark.cli +@pytest.mark.oap_part2 +def test_cli_replace_with_http_method(): + """ + Test replace operation with explicit HTTP method selection. + """ + test_metadata = {"title": "Updated"} + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process"}) + + with mock.patch("weaver.cli.request_extra", return_value=mock_response) as mock_req: + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process", metadata=test_metadata, http_method="PATCH") + + assert result.success + assert mock_req.call_args[0][0] == "PATCH" + + +@pytest.mark.cli +@pytest.mark.oap_part2 +def test_cli_replace_no_parameters_error(): + """ + Test that replace operation fails when no update parameters are provided. + """ + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process") + + assert not result.success + assert "At least one field" in result.message + + +@pytest.mark.cli +@pytest.mark.oap_part2 +def test_cli_replace_invalid_metadata_kvp(): + """ + Test that replace operation handles metadata KVP gracefully. + + Note: parse_kvp treats 'key' without '=' as valid (empty list value), + so this just verifies the parsing succeeds but sends an empty array. + """ + metadata_list = ["invalid_format"] # Missing '=' - becomes key with empty list + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process", "version": "1.0.1"}) + + with mock.patch("weaver.cli.request_extra", return_value=mock_response) as mock_req: + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process", metadata=metadata_list) + + assert result.success + # Verify parse_kvp treated it as key with empty value + call_kwargs = mock_req.call_args[1] + payload = call_kwargs["json"] + assert "invalid_format" in payload + assert payload["invalid_format"] == [] # Empty list for key without value + + +@pytest.mark.cli +@pytest.mark.oap_part2 +def test_cli_replace_with_complex_metadata_field(): + """ + Test replace operation with complex metadata field (list of metadata objects). + Tests both link format (rel+href) and value format to showcase the distinction. + """ + metadata_updates = { + "metadata": [ + # Link-based metadata (href describes where to find information) + {"role": "https://schema.org/author", "rel": "author", + "href": "https://orcid.org/0000-0000-0000-0000", "title": "Author ORCID"}, + # Value-based metadata (actual value/data) + {"role": "https://schema.org/name", "value": "John Doe"}, + {"role": "https://schema.org/codeRepository", + "rel": "repository", "href": "https://github.com/org/repo"} + ] + } + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process", "version": "1.0.1"}) + + with mock.patch("weaver.cli.request_extra", return_value=mock_response) as mock_req: + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process", metadata=metadata_updates) + + assert result.success + # Verify the request payload contains the metadata field + call_kwargs = mock_req.call_args[1] + assert "json" in call_kwargs + payload = call_kwargs["json"] + assert "metadata" in payload + assert len(payload["metadata"]) == 3 + # Verify link format entries + assert payload["metadata"][0]["role"] == "https://schema.org/author" + assert payload["metadata"][0]["rel"] == "author" + assert payload["metadata"][0]["href"] == "https://orcid.org/0000-0000-0000-0000" + assert payload["metadata"][0]["title"] == "Author ORCID" + # Verify value format entry + assert payload["metadata"][1]["role"] == "https://schema.org/name" + assert payload["metadata"][1]["value"] == "John Doe" + # Verify another link + assert payload["metadata"][2]["role"] == "https://schema.org/codeRepository" + assert payload["metadata"][2]["rel"] == "repository" + + +@pytest.mark.cli +@pytest.mark.oap_part2 +def test_cli_replace_with_links(): + """ + Test replace operation with links field. + """ + metadata_updates = { + "links": [ + {"rel": "service-doc", "href": "https://example.com/docs", "type": "text/html"}, + {"rel": "license", "href": "https://example.com/license"} + ] + } + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process", "version": "1.0.1"}) + + with mock.patch("weaver.cli.request_extra", return_value=mock_response) as mock_req: + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process", metadata=metadata_updates) + + assert result.success + call_kwargs = mock_req.call_args[1] + payload = call_kwargs["json"] + assert "links" in payload + assert len(payload["links"]) == 2 + assert payload["links"][0]["rel"] == "service-doc" + assert payload["links"][1]["rel"] == "license" + + +@pytest.mark.cli +@pytest.mark.oap_part2 +def test_cli_replace_with_keywords(): + """ + Test replace operation with keywords field. + """ + metadata_updates = {"keywords": ["climate", "weather", "temperature"]} + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process", "version": "1.0.1"}) + + with mock.patch("weaver.cli.request_extra", return_value=mock_response) as mock_req: + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process", metadata=metadata_updates) + + assert result.success + call_kwargs = mock_req.call_args[1] + payload = call_kwargs["json"] + assert "keywords" in payload + assert payload["keywords"] == ["climate", "weather", "temperature"] + + +@pytest.mark.cli +@pytest.mark.oap_part2 +def test_cli_replace_with_job_control_options(): + """ + Test replace operation with jobControlOptions (MINOR-level change). + """ + metadata_updates = {"jobControlOptions": ["async-execute", "sync-execute"]} + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process", "version": "1.1.0"}) + + with mock.patch("weaver.cli.request_extra", return_value=mock_response) as mock_req: + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process", metadata=metadata_updates) + + assert result.success + call_kwargs = mock_req.call_args[1] + payload = call_kwargs["json"] + assert "jobControlOptions" in payload + assert payload["jobControlOptions"] == ["async-execute", "sync-execute"] + + +@pytest.mark.cli +@pytest.mark.oap_part2 +def test_cli_replace_with_visibility(): + """ + Test replace operation with visibility field (MINOR-level change). + """ + metadata_updates = {"visibility": "public"} + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process", "version": "1.1.0"}) + + with mock.patch("weaver.cli.request_extra", return_value=mock_response) as mock_req: + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process", metadata=metadata_updates) + + assert result.success + call_kwargs = mock_req.call_args[1] + payload = call_kwargs["json"] + assert "visibility" in payload + assert payload["visibility"] == "public" + + +@pytest.mark.cli +@pytest.mark.oap_part2 +def test_cli_replace_additive_body_and_metadata(): + """ + Test that body and metadata parameters are additive (metadata overlays body). + """ + test_body = {"processDescription": {"id": "test-process", "title": "Original Title", "version": "2.0.0"}} + metadata_updates = {"title": "Override Title", "keywords": ["new-tag"]} + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process", "version": "2.0.0"}) + + with mock.patch("weaver.cli.request_extra", return_value=mock_response) as mock_req: + client = WeaverClient(url="https://fake.domain.com") + # Mock the body parsing to avoid needing full deployment logic + with mock.patch.object(client, "_parse_deploy_body", return_value=OperationResult(True, body=test_body)): + with mock.patch.object(client, "_parse_deploy_package", return_value=OperationResult(True, body=test_body)): + result = client.replace(process_id="test-process", body=test_body, metadata=metadata_updates) + + assert result.success + call_kwargs = mock_req.call_args[1] + payload = call_kwargs["json"] + # Verify metadata fields override body fields + assert payload["title"] == "Override Title" + assert payload["keywords"] == ["new-tag"] + + +@pytest.mark.cli +@pytest.mark.oap_part2 +def test_cli_replace_with_metadata_json_string(): + """ + Test replace operation with metadata provided as JSON string. + """ + metadata_json = '{"title": "JSON Title", "description": "JSON Description", "keywords": ["json", "test"]}' + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process", "version": "1.1.0"}) + + with mock.patch("weaver.cli.request_extra", return_value=mock_response) as mock_req: + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process", metadata=metadata_json) + + assert result.success + call_kwargs = mock_req.call_args[1] + payload = call_kwargs["json"] + assert payload["title"] == "JSON Title" + assert payload["description"] == "JSON Description" + assert payload["keywords"] == ["json", "test"] + + +@pytest.mark.cli +def test_cli_replace_with_metadata_file(): + """ + Test replace operation with metadata loaded from file. + """ + metadata_dict = { + "title": "File Title", + "metadata": [ + {"role": "https://schema.org/license", "rel": "license", "href": "https://example.com/license"} + ] + } + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process", "version": "1.0.1"}) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp_file: + json.dump(metadata_dict, tmp_file) + tmp_file.flush() + tmp_path = tmp_file.name + + try: + with mock.patch("weaver.cli.request_extra", return_value=mock_response) as mock_req: + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process", metadata=tmp_path) + + assert result.success + call_kwargs = mock_req.call_args[1] + payload = call_kwargs["json"] + assert payload["title"] == "File Title" + assert "metadata" in payload + assert len(payload["metadata"]) == 1 + finally: + os.unlink(tmp_path) + + +@pytest.mark.cli +@pytest.mark.oap_part2 +def test_cli_replace_multiple_fields_combined(): + """ + Test replace operation combining multiple field types. + """ + metadata_updates = { + "title": "Combined Title", + "keywords": ["tag1", "tag2"], + "metadata": [{"role": "https://schema.org/author", "rel": "author", + "href": "https://orcid.org/0000-0000-0000-0000", "title": "Author ORCID"}], + "links": [{"rel": "about", "href": "https://example.com/about"}], + "visibility": "public" + } + mock_response = MockedResponse(status_code=200, json_body={"id": "test-process", "version": "1.1.0"}) + + with mock.patch("weaver.cli.request_extra", return_value=mock_response) as mock_req: + client = WeaverClient(url="https://fake.domain.com") + result = client.replace(process_id="test-process", metadata=metadata_updates) + + assert result.success + call_kwargs = mock_req.call_args[1] + payload = call_kwargs["json"] + assert payload["title"] == "Combined Title" + assert payload["keywords"] == ["tag1", "tag2"] + assert len(payload["metadata"]) == 1 + assert len(payload["links"]) == 1 + assert payload["visibility"] == "public" + + +@pytest.mark.cli +@pytest.mark.parametrize( + ["outputs", "output_ids", "expect_success", "expect_result", "expect_error_msg"], + [ + # No filtering - return unchanged + ( + { + "output1": {"href": "http://example.com/data1"}, + "output2": [{"href": "http://example.com/data2"}, {"href": "http://example.com/data3"}] + }, + None, + True, + { + "output1": {"href": "http://example.com/data1"}, + "output2": [{"href": "http://example.com/data2"}, {"href": "http://example.com/data3"}] + }, + None + ), + # Simple ID filtering + ( + { + "output1": {"href": "http://example.com/data1"}, + "output2": {"href": "http://example.com/data2"}, + "output3": {"href": "http://example.com/data3"} + }, + ["output1", "output3"], + True, + { + "output1": {"href": "http://example.com/data1"}, + "output3": {"href": "http://example.com/data3"} + }, + None + ), + # Array with specific indices (with None placeholders) + ( + { + "output1": [ + {"href": "http://example.com/data0"}, + {"href": "http://example.com/data1"}, + {"href": "http://example.com/data2"}, + {"href": "http://example.com/data3"} + ] + }, + [("output1", 1), ("output1", 3)], + True, + { + "output1": [ + None, + {"href": "http://example.com/data1"}, + None, + {"href": "http://example.com/data3"} + ] + }, + None + ), + # Array preserve length with None placeholders + ( + { + "output1": [ + {"href": "http://example.com/data0"}, + {"href": "http://example.com/data1"}, + {"href": "http://example.com/data2"} + ] + }, + [("output1", 0), ("output1", 2)], + True, + { + "output1": [ + {"href": "http://example.com/data0"}, + None, + {"href": "http://example.com/data2"} + ] + }, + None + ), + # Single value with index 0 (allowed) + ( + {"output1": {"href": "http://example.com/data"}}, + [("output1", 0)], + True, + {"output1": {"href": "http://example.com/data"}}, + None + ), + # Single value with invalid index (error) + ( + {"output1": {"href": "http://example.com/data"}}, + [("output1", 1)], + False, + None, + "not an array" + ), + # Array index out of range (error) + ( + { + "output1": [ + {"href": "http://example.com/data0"}, + {"href": "http://example.com/data1"} + ] + }, + [("output1", 5)], + False, + None, + "out of range" + ), + # Negative array index (error) + ( + { + "output1": [ + {"href": "http://example.com/data0"}, + {"href": "http://example.com/data1"} + ] + }, + [("output1", -1)], + False, + None, + "out of range" + ), + # Mixed simple and indexed IDs + ( + { + "output1": {"href": "http://example.com/data1"}, + "output2": [ + {"href": "http://example.com/data2-0"}, + {"href": "http://example.com/data2-1"}, + {"href": "http://example.com/data2-2"} + ], + "output3": {"href": "http://example.com/data3"} + }, + ["output1", ("output2", 1)], + True, + { + "output1": {"href": "http://example.com/data1"}, + "output2": [ + None, + {"href": "http://example.com/data2-1"}, + None + ] + }, + None + ), + # Multiple indices for same output + ( + { + "output1": [ + {"href": "http://example.com/data0"}, + {"href": "http://example.com/data1"}, + {"href": "http://example.com/data2"}, + {"href": "http://example.com/data3"}, + {"href": "http://example.com/data4"} + ] + }, + [("output1", 0), ("output1", 2), ("output1", 4)], + True, + { + "output1": [ + {"href": "http://example.com/data0"}, + None, + {"href": "http://example.com/data2"}, + None, + {"href": "http://example.com/data4"} + ] + }, + None + ), + # Non-existent output ID (filtered out) + ( + { + "output1": {"href": "http://example.com/data1"}, + "output2": {"href": "http://example.com/data2"} + }, + ["output1", "output999"], + True, + {"output1": {"href": "http://example.com/data1"}}, + None + ), + # Empty array with index (error) + ( + {"output1": []}, + [("output1", 0)], + False, + None, + "out of range" + ), + # Single element array + ( + {"output1": [{"href": "http://example.com/data0"}]}, + [("output1", 0)], + True, + {"output1": [{"href": "http://example.com/data0"}]}, + None + ), + # Simple values (string literals) + ( + {"out1": "val1", "out2": "val2", "out3": "val3"}, + ["out1", "out3"], + True, + {"out1": "val1", "out3": "val3"}, + None + ), + # Array with string values + ( + {"out1": ["a", "b", "c"]}, + [("out1", 0), ("out1", 2)], + True, + {"out1": ["a", None, "c"]}, + None + ), + # Array without index - returns full array, filters other outputs + ( + { + "output1": [ + {"href": "http://example.com/data0"}, + {"href": "http://example.com/data1"}, + {"href": "http://example.com/data2"} + ], + "output2": {"href": "http://example.com/other"}, + "output3": ["a", "b", "c"] + }, + ["output1"], + True, + { + "output1": [ + {"href": "http://example.com/data0"}, + {"href": "http://example.com/data1"}, + {"href": "http://example.com/data2"} + ] + }, + None + ), + # Single value without index - returns as-is, filters other outputs + ( + { + "output1": {"href": "http://example.com/data"}, + "output2": ["a", "b"], + "output3": {"href": "http://example.com/other"} + }, + ["output1"], + True, + {"output1": {"href": "http://example.com/data"}}, + None + ), + # Array with index 0 - returns only that element with None placeholders, filters other outputs + ( + { + "output1": [ + {"href": "http://example.com/data0"}, + {"href": "http://example.com/data1"}, + {"href": "http://example.com/data2"} + ], + "output2": {"href": "http://example.com/other"}, + "output3": ["x", "y", "z"] + }, + [("output1", 0)], + True, + { + "output1": [ + {"href": "http://example.com/data0"}, + None, + None + ] + }, + None + ), + # Mix of array without index and single value without index + ( + { + "arr1": ["a", "b", "c"], + "single1": "value1", + "arr2": [1, 2, 3, 4], + "single2": "value2", + "arr3": ["x", "y"] + }, + ["arr1", "single1", "arr2"], + True, + { + "arr1": ["a", "b", "c"], + "single1": "value1", + "arr2": [1, 2, 3, 4] + }, + None + ), + # Mix of array with index and array without index + ( + { + "arr1": ["a", "b", "c"], + "arr2": [1, 2, 3, 4], + "arr3": ["x", "y", "z"] + }, + ["arr1", ("arr2", 1), ("arr2", 3)], + True, + { + "arr1": ["a", "b", "c"], + "arr2": [None, 2, None, 4] + }, + None + ), + ] +) +def test_filter_outputs( + outputs, # type: ExecutionResults + output_ids, # type: Optional[List[Union[str, Tuple[str, int]]]] + expect_success, # type: bool + expect_result, # type: Optional[ExecutionResults] + expect_error_msg, # type: Optional[str] +): # type: (...) -> None + outputs_copy = copy.deepcopy(outputs) + result = WeaverClient._filter_outputs(outputs_copy, output_ids) + + assert isinstance(result, OperationResult) + if expect_success: + assert result.success + assert result.body == expect_result + else: + assert not result.success + if expect_error_msg: + assert expect_error_msg in result.message.lower() diff --git a/weaver/cli.py b/weaver/cli.py index d63a49ebc..00161deb4 100644 --- a/weaver/cli.py +++ b/weaver/cli.py @@ -22,13 +22,14 @@ from yaml.scanner import ScannerError from weaver import __meta__ +from weaver.base import Constants from weaver.datatype import AutoBase from weaver.exceptions import AuthenticationError, PackageRegistrationError from weaver.execute import ( ExecuteResponse, ExecuteReturnPreference, ExecuteTransmissionMode, - resolve_execution_parameters + resolve_execution_parameters, ExecuteControlOption ) from weaver.formats import ( ContentEncoding, @@ -66,10 +67,12 @@ import_target, load_file, null, + parse_kvp, parse_link_header, request_extra, setup_loggers ) +from weaver.visibility import Visibility from weaver.wps_restapi import swagger_definitions as sd from weaver.wps_restapi.constants import ConformanceCategory @@ -1046,7 +1049,7 @@ def deploy( LOGGER.debug("Performing requested undeploy of process: [%s]", p_id) result = self.undeploy(process_id=p_id, url=base) if result.code not in [200, 204, 404]: - return OperationResult(False, "Failed requested undeployment prior deployment.", + return OperationResult(False, "Failed requested undeployment prior to deployment.", body=result.body, text=result.text, code=result.code, headers=result.headers) LOGGER.debug("Deployment Body:\n%s", OutputFormat.convert(data, OutputFormat.JSON_STR)) path = f"{base}/processes" @@ -1093,6 +1096,237 @@ def undeploy( request_timeout=request_timeout, request_retries=request_retries) return self._parse_result(resp, with_links=with_links, with_headers=with_headers, output_format=output_format) + def replace( + self, + process_id, # type: str + body=None, # type: Optional[Union[JSON, str]] + cwl=None, # type: Optional[Union[CWL, str]] + inputs=None, # type: Optional[Union[JSON, str]] + outputs=None, # type: Optional[Union[JSON, str]] + version=None, # type: Optional[str] + metadata=None, # type: Optional[Union[JSON, str, List[str]]] + http_method=None, # type: Optional[str] + url=None, # type: Optional[URL] + auth=None, # type: Optional[AuthBase] + headers=None, # type: Optional[AnyHeadersContainer] + with_links=True, # type: bool + with_headers=False, # type: bool + request_timeout=None, # type: Optional[int] + request_retries=None, # type: Optional[int] + output_format=None, # type: Optional[AnyOutputFormat] + ): # type: (...) -> OperationResult + """ + Update an existing :term:`Process`. + + All parameters are additive, meaning they can be combined. For example, providing ``body`` with + additional ``metadata`` will use the body as base and merge/override with the metadata fields. + + :param process_id: Identifier or :term:`URI` of the process to update. + :param body: Full process body for replacement. Can be JSON string or file path. + :param cwl: Application Package CWL. Can be JSON string or file path. + :param inputs: Updated input definitions. Can be JSON string or file path. + :param outputs: Updated output definitions. Can be JSON string or file path. + :param version: Explicit version to assign. If not provided, automatically bumped based on changes. + :param metadata: + Process update fields (note: not just the process ``metadata`` field). + Can update simple fields (title, description) and complex fields (keywords, metadata, links). + + **Supported Fields by Update Level:** + + - **PATCH-level** (metadata only): ``title``, ``description``, ``keywords``, ``metadata``, ``links`` + - **MINOR-level** (capabilities): ``jobControlOptions``, ``outputTransmission``, ``visibility`` + - **MAJOR-level** (full process): Use ``body``/``cwl`` parameters instead + + **Input Formats:** + + 1. **Key=Value pairs** (simple string fields only): + + .. code-block:: python + + client.replace("my-process", metadata=["title=New Title", "description=Updated"]) + + 2. **JSON string**: + + .. code-block:: python + + client.replace("my-process", metadata='{"title": "New Title", "keywords": ["tag1"]}') + + 3. **File path** (JSON/YAML file): + + .. code-block:: python + + client.replace("my-process", metadata="/path/to/updates.json") + + 4. **Dictionary** (supports all field types and behaviors): + + .. code-block:: python + + # Comprehensive example combining multiple update fields + client.replace("my-process", metadata={ + "title": "New Process Title", + "description": "Updated process description", + "keywords": ["climate", "weather", "analysis"], + "metadata": [ + {"role": "https://schema.org/author", + "rel": "https://schema.org/author", + "href": "https://orcid.org/0000-0001-2345-6789", + "title": "Author ORCID"}, + {"role": "https://schema.org/name", + "value": "John Doe", + "title": "Author Name"} + ], + "links": [ + {"rel": "documentation", + "href": "https://example.com/docs", + "title": "Documentation"} + ], + "jobControlOptions": ["async-execute"], + "visibility": "public" + }) + + :param http_method: + HTTP method to use (PUT or PATCH). + If not specified, automatically determined based on operation: + - PUT when ``body``/``cwl``/``inputs``/``outputs`` provided (full process replacement or MAJOR changes) + - PATCH when only ``metadata``/``version`` provided (granular metadata updates) + :param url: Instance URL if not already provided during client creation. + :param auth: + Instance authentication handler if not already created during client creation. + Should perform required adjustments to request to allow access control of protected contents. + :param headers: + Additional headers to employ when sending request. + Note that this can break functionalities if expected headers are overridden. Use with care. + :param with_links: Indicate if ``links`` section should be preserved in returned result body. + :param with_headers: Indicate if response headers should be returned in result output. + :param request_timeout: Maximum timeout duration (seconds) to wait for a response when performing HTTP requests. + :param request_retries: Amount of attempt to retry HTTP requests in case of failure. + :param output_format: Select an alternate output representation of the result body contents. + :returns: Results of the operation. + """ + base = self._get_url(url) + path = f"{base}/processes/{process_id}" + + # Start with body/cwl if provided, then merge additional parameters + data = {} + has_body_or_cwl = body is not None or cwl is not None + + if has_body_or_cwl: + result = self._parse_deploy_body(body, process_id) + if not result.success: + return result + req_headers = copy.deepcopy(self._headers) + settings = copy.deepcopy(self._settings) + settings["weaver.wps_restapi_url"] = base + data = result.body + result = self._parse_deploy_package(data, cwl, None, process_id, req_headers, settings) + if not result.success: + return result + data = result.body + + # Merge additional parameters additively + if inputs is not None: + parsed_inputs = self._parse_file_or_json(inputs, "inputs") + if isinstance(parsed_inputs, OperationResult): + return parsed_inputs + data["inputs"] = parsed_inputs + + if outputs is not None: + parsed_outputs = self._parse_file_or_json(outputs, "outputs") + if isinstance(parsed_outputs, OperationResult): + return parsed_outputs + data["outputs"] = parsed_outputs + + if metadata is not None: + parsed_metadata = self._parse_metadata_updates(metadata) + if isinstance(parsed_metadata, OperationResult): + return parsed_metadata + data.update(parsed_metadata) + + if version is not None: + data["version"] = version + + if not data: + return OperationResult( + False, + "At least one field (body, cwl, inputs, outputs, version, or metadata) must be provided.", + None + ) + + # Determine HTTP method based on operation type + # PUT: full process replacement (body/cwl/inputs/outputs = MAJOR changes) + # PATCH: metadata/capability updates only (MINOR/PATCH changes) + # Inputs/outputs changes require MAJOR update since they redefine process signature + if http_method: + method = http_method.upper() + elif has_body_or_cwl or inputs is not None or outputs is not None: + method = "PUT" # MAJOR changes: full replacement or signature changes + else: + method = "PATCH" # MINOR/PATCH changes: metadata/capability updates only + + LOGGER.info("Replacement Body:\n%s", OutputFormat.convert(data, OutputFormat.JSON_STR)) + resp = self._request(method, path, json=data, + headers=self._headers, x_headers=headers, settings=self._settings, auth=auth, + request_timeout=request_timeout, request_retries=request_retries) + return self._parse_result(resp, with_links=with_links, with_headers=with_headers, output_format=output_format) + + def _parse_metadata_updates(self, metadata): + # type: (Union[JSON, str, List[str]]) -> Union[JSON, OperationResult] + """ + Parse process update fields from various input formats. + + Supports key=value pairs (for simple fields), JSON strings, file paths, or dictionaries. + See :meth:`replace` for detailed field descriptions and usage examples. + + :param metadata: Update fields to parse (list of key=value, JSON string, file path, or dict). + :returns: Parsed dictionary with process update fields or OperationResult on error. + """ + if isinstance(metadata, list): + # Parse key=value pairs using parse_kvp + kvp_string = ";".join(metadata) + parsed_metadata = parse_kvp(kvp_string, pair_sep=";") + # parse_kvp returns lists for values, extract single values for simple fields + result = {} + for key, value in parsed_metadata.items(): + result[key] = value[0] if isinstance(value, list) and len(value) == 1 else value + return result + else: + parsed_metadata = self._parse_file_or_json(metadata, "metadata") + if isinstance(parsed_metadata, OperationResult): + return parsed_metadata + if isinstance(parsed_metadata, dict): + return parsed_metadata + else: + return OperationResult( + False, + "Metadata must be a dictionary/object with process metadata fields.", + None + ) + + + def _parse_file_or_json(self, param, param_name): + # type: (Union[JSON, str], str) -> Union[JSON, OperationResult] + """ + Parse a parameter that can be a dictionary, JSON string, or file path. + + :param param: Parameter value to parse. + :param param_name: Name of the parameter for error messages. + :returns: Parsed JSON data or OperationResult on error. + """ + if isinstance(param, dict): + return param + if isinstance(param, str): + try: + if param.startswith(("{", "[")): + data = yaml.safe_load(param) + else: + data = load_file(param, text=False) + if isinstance(data, str): + data = yaml.safe_load(data) + return data + except (ValueError, TypeError, ScannerError) as exc: + return OperationResult(False, f"Failed to parse {param_name}: {exc}", None) + return param + def capabilities( self, url=None, # type: Optional[URL] @@ -2332,7 +2566,8 @@ def monitor( with_headers=with_headers, output_format=output_format) return OperationResult(False, msg) - def _download_references(self, outputs, out_links, out_dir, job_id, auth=None): + @staticmethod + def _download_references(outputs, out_links, out_dir, job_id, auth=None): # type: (ExecutionResults, AnyHeadersContainer, str, str, Optional[AuthBase]) -> ExecutionResults """ Download file references from results response contents and link headers. @@ -2400,6 +2635,78 @@ def _download_references(self, outputs, out_links, out_dir, job_id, auth=None): outputs[output] = [link] if is_array else link return outputs + @staticmethod + def _filter_outputs(outputs, output_ids=None): + # type: (ExecutionResults, Optional[Sequence[Union[str, Tuple[str, int]]]]) -> OperationResult + """ + Filter outputs IDs and N indices in case the API did not support pre-filtering. + + The ``../{N}`` indices cannot be indicated in the results request queries, + so these must be filtered after the fact regardless of filtered output IDs. + Fill the omitted indices by :class`None` to preverse the original amount. + + .. seealso:: + - ``/req/core/job-results-param-outputs`` + - https://docs.ogc.org/DRAFTS/18-062r3.html#req_core_job-results-param-outputs + """ + if not output_ids: + # nothing to parse, but no error since 'no outputs' *body* might be because all is requested by header Link + return OperationResult(True, body=outputs) + + # Collect requested IDs and their indices in a single pass + requested_ids = {} # type: Dict[str, Optional[Set[int]]] + for out_spec in output_ids: + if isinstance(out_spec, tuple): + out_id, out_idx = out_spec + if out_id not in requested_ids: + requested_ids[out_id] = set() + requested_ids[out_id].add(out_idx) + else: + # Simple ID without index - mark with None to keep as-is + if out_spec not in requested_ids: + requested_ids[out_spec] = None + + # Filter and modify outputs in-place + for out_id in list(outputs.keys()): + if out_id not in requested_ids: + # Remove unrequested outputs + del outputs[out_id] + continue + + indices = requested_ids[out_id] + if indices is None: + # Simple ID - keep output as-is + continue + + # Specific indices requested - validate and filter + out_val = outputs[out_id] + if isinstance(out_val, list): + # Validate indices are within range + for idx in indices: + if idx < 0 or idx >= len(out_val): + return OperationResult( + False, + f"Output '{out_id}' index {idx} is out of range [0, {len(out_val) - 1}].", + outputs + ) + # Create array with None placeholders, keeping only requested indices + max_idx = max(max(indices), len(out_val) - 1) + filtered_array = [None] * (max_idx + 1) + for idx in indices: + filtered_array[idx] = out_val[idx] + outputs[out_id] = filtered_array + else: + # Single value - allow index 0 to "slip through" + if 0 not in indices: + invalid_indices = ", ".join(str(idx) for idx in sorted(indices)) + return OperationResult( + False, + f"Output '{out_id}' is not an array but indices [{invalid_indices}] were requested. " + f"Only index 0 is allowed for single values.", + outputs + ) + return OperationResult(True, body=outputs) + def results( self, job_reference, # type: Union[URL, AnyUUID] @@ -2415,6 +2722,7 @@ def results( results_profile=null, # type: Union[Type[null], Optional[str]] output_format=None, # type: Optional[AnyOutputFormat] output_links=None, # type: Optional[Sequence[str]] + output_ids=None, # type: Optional[Sequence[Union[str, Tuple[str, int]]]] ): # type: (...) -> OperationResult """ Obtain the results of a successful :term:`Job` execution. @@ -2439,6 +2747,13 @@ def results( Output IDs that are expected in ``Link`` headers, and that should be retrieved (or downloaded) as results. This is not performed automatically since there can be a lot of ``Links`` in responses, and output IDs could have conflicting ``rel`` names with other indicative links. + :param output_ids: + Output IDs that should be collected from the results. + If omitted, all available outputs from the :term:`Job` will be retrieved. + If an output happens to be an array of data/file results, an optional index can also be provided to + extract only these entries. In such case, the resulting array will only contain the requested elements. + To ensure index consistency, other outputs will be represented by :class:`None` instead of the data/file. + If combined with the download option, only the requested outputs will be retrieved and saved locally. :returns: Result details and local paths if downloaded. """ job_id, job_url = self._parse_job_ref(job_reference, url) @@ -2453,22 +2768,31 @@ def results( headers = CaseInsensitiveDict(headers or {}) headers.update({ "Accept": ContentType.APP_JSON, - "Prefer": f"return={ExecuteReturnPreference.MINIMAL}", + "Prefer": f"return={ExecuteReturnPreference.MINIMAL}", # limit data transfer, prefer href if possible }) - # if profile was omitted but outputs were explicitly requested as links, + # If profile was omitted but outputs were explicitly requested as links, # consider that the user intends to retrieve them as Link headers, and therefore - # the Results JSON response profile embedding outputs is not expected behaviour - if results_profile is null and not output_links: + # the Results JSON response profile embedding outputs is not expected behaviour. + # If specific output ID/index are requested, we prefer the results profile to filter. + if (results_profile is null and not output_links) or output_ids: headers["Accept-Profile"] = sd.OGC_API_PROC_PROFILE_RESULTS_URI elif results_profile: headers["Accept-Profile"] = results_profile + params = {} + if output_ids: + # preemptyively filter outputs IDs if possible/supported by the API to reduce data transfer + params["outputs"] = ",".join(out if isinstance(out, str) else out[0] for out in output_ids) resp = self._request("GET", result_url, - headers=self._headers, x_headers=headers, settings=self._settings, auth=auth, + params=params, headers=self._headers, x_headers=headers, + settings=self._settings, auth=auth, request_timeout=request_timeout, request_retries=request_retries) res_out = self._parse_result(resp, output_format=output_format, with_links=with_links, with_headers=with_headers) - - outputs = res_out.body + # parse job results + res_outputs = self._filter_outputs(res_out.body, output_ids) + if not res_outputs.success: + return res_outputs + outputs = res_outputs.body headers = res_out.headers out_links = res_out.links(["Link"]) out_links_meta = [(link, parse_link_header(link[-1])) for link in list(out_links.items())] @@ -3618,6 +3942,89 @@ def make_parser(): add_shared_options(op_undeploy) add_process_param(op_undeploy) + op_replace = WeaverArgumentParser( + "replace", + description="Update an existing process.", + formatter_class=ParagraphFormatter, + ) + set_parser_sections(op_replace) + add_url_param(op_replace) + add_shared_options(op_replace) + add_process_param(op_replace) + op_replace.add_argument( + "-b", "--body", dest="body", + help="Full process body for replacement. Allows both JSON and YAML format when using file reference. " + "Can be provided either with a local file, an URL or literal string contents formatted as JSON." + ) + op_replace.add_argument( + "--cwl", dest="cwl", + help="Application Package CWL for process replacement. Can be provided as JSON/YAML file reference or literal." + ) + op_replace.add_argument( + "-i", "--inputs", dest="inputs", + help="Updated input definitions for the process. " + "Can be provided as JSON string, or file path containing JSON/YAML." + ) + op_replace.add_argument( + "-o", "--outputs", dest="outputs", + help="Updated output definitions for the process. " + "Can be provided as JSON string, or file path containing JSON/YAML." + ) + op_replace.add_argument( + "-v", "--version", dest="version", + help="Explicit version to assign to the updated process (e.g., '2.0.0'). " + "If not provided, version will be automatically bumped based on changes." + ) + op_replace.add_argument( + "-m", "--metadata", dest="metadata", action="append", + help=inspect.cleandoc(f""" + Process metadata fields to update based on semantic versining. + + FORMATS: + 1. Key=value pairs (simple fields): -m title='New Title' -m description='Updated' + 2. JSON string (any fields): -m '{{"title": "New", "keywords": ["tag1"]}}' + 3. File path: -m /path/to/updates.json + + PATCH-LEVEL FIELDS (metadata only): + - title, description: Simple strings + Example: -m title='New Process Title' + + - keywords: List appended to existing (empty list resets) + Example: -m '{{"keywords": ["climate", "weather"]}}' + + - metadata: Process-level metadata entries are appended. + Metatat entires can be in Link format (rel+href) or value format (role+value). + Links require 'rel' field (IANA relation or URL), and it is recommented to provide an + additional 'role' using https://schema.org definitions for semantic meaning of these concepts. + Example: + {{ + "metadata": [ + {{"role": "https://schema.org/author", "rel": "author", + "href": "https://orcid.org/0000-0000-0000-0000", "title": "Author ORCID"}}, + {{"role": "https://schema.org/name", "value": "John Doe"}}, + {{"role": "https://schema.org/codeRepository", "rel": "repository", + "href": "https://github.com/org/repo"}} + ] + }} + + - links: Additional links (appended). Each has rel and href. + Example: -m '{{"links": [{{"rel": "service-doc", "href": "https://docs", "type": "text/html"}}]}}' + + MINOR-LEVEL FIELDS (capabilities, full override): + - jobControlOptions: {ExecuteControlOption.values()} + - outputTransmission: {ExecuteTransmissionMode.values()} + - visibility: {Visibility.values()} + + MAJOR-LEVEL (full process): + Use --body/--cwl instead for complete process replacement. + """) + ) + op_replace.add_argument( + "-M", "--http-method", dest="http_method", choices=["PUT", "PATCH"], type=str.upper, + help="HTTP method for replacement. Auto-selected if not specified: " + "PUT when --body/--cwl provided (full replacement), PATCH for metadata updates only." + ) + op_register = WeaverArgumentParser( "register", description="Register a remote provider.", @@ -3883,8 +4290,19 @@ def make_parser(): help="Output directory where to store downloaded files from job results if requested " "(default: ``${CURDIR}/{JobID}/``)." ) - # FIXME: support filtering outputs on 'jobs/{jobId}/results/{id}' (https://github.com/crim-ca/weaver/issues/18) - # reuse same '-oF' parameter as for 'outputs' submitted during 'execute' operation + parser.add_argument( + "-oI", "--output-ids", metavar="OUTPUT", dest="output_ids", + nargs=1, action="append", # collect max 1 item per '-oI' + help=( + "Output IDs that should be collected from the results." + "\n\n" + "If omitted, all available outputs from the Job will be retrieved. " + "If an output happens to be an array of data/file results, an optional index can also be provided to " + "extract only these entries. In such case, the resulting array will only contain the requested elements. " + "To ensure index consistency, other outputs will be represented by 'null' instead of the data/file. " + "If combined with the download option, only the requested outputs will be retrieved and saved locally." + ) + ) op_results.add_argument( "-oL", "--output-link", dest="output_links", nargs=1, help="Output IDs in 'Link' headers to retrieve as results for matching relationship ('rel') links." @@ -3919,6 +4337,7 @@ def make_parser(): op_conformance, op_deploy, op_undeploy, + op_replace, op_register, op_unregister, op_capabilities,