Repository: fractal-analytics-platform/fractal-client (language: python)

Issue:
Implement POST/PATCH/DELETE for task commands
At the moment we raise a `NotImplementedError`. To do after the MVP for v2 is ready.


Discussion / hints:

Fix patch:
diff --git a/fractal_client/cmd/__init__.py b/fractal_client/cmd/__init__.py
index 6d0daf1de..ddfc271ff 100644
--- a/fractal_client/cmd/__init__.py
+++ b/fractal_client/cmd/__init__.py
@@ -141,13 +141,14 @@ def task(
     elif subcmd == "new":
         parameters = [
             "name",
-            "command",
             "source",
-            "input_type",
-            "output_type",
             "version",
-            "meta_file",
-            "args_schema",
+            "command_non_parallel",
+            "command_parallel",
+            "meta_non_parallel",
+            "meta_parallel",
+            "args_schema_non_parallel",
+            "args_schema_parallel",
             "args_schema_version",
         ]
         function_kwargs = get_kwargs(parameters, kwargs)
@@ -158,13 +159,11 @@ def task(
             "name",
             "version",
             "new_name",
-            "new_command",
-            "new_input_type",
-            "new_output_type",
             "new_version",
-            "meta_file",
-            "new_args_schema",
-            "new_args_schema_version",
+            "command_non_parallel",
+            "command_parallel",
+            "input_types",
+            "output_types",
         ]
         function_kwargs = get_kwargs(parameters, kwargs)
         iface = patch_task(client, **function_kwargs)
diff --git a/fractal_client/cmd/_task.py b/fractal_client/cmd/_task.py
index 328d69342..9b0558f4d 100644
--- a/fractal_client/cmd/_task.py
+++ b/fractal_client/cmd/_task.py
@@ -1,3 +1,4 @@
+import json
 import logging
 import sys
 from typing import Optional
@@ -6,6 +7,8 @@
 from ..config import settings
 from ..interface import Interface
 from ..response import check_response
+from ._aux_task_caching import FractalCacheError
+from ._aux_task_caching import get_task_id_from_cache
 from ._aux_task_caching import refresh_task_cache
 
 
@@ -76,13 +79,127 @@ def task_collection_check(
     return Interface(retcode=0, data=state)
 
 
-def post_task() -> None:
-    raise NotImplementedError
+def post_task(
+    client: AuthClient,
+    *,
+    name: str,
+    source: str,
+    batch: bool = False,
+    command_non_parallel: Optional[str] = None,
+    command_parallel: Optional[str] = None,
+    version: Optional[str] = None,
+    meta_non_parallel: Optional[str] = None,
+    meta_parallel: Optional[str] = None,
+    args_schema_non_parallel: Optional[str] = None,
+    args_schema_parallel: Optional[str] = None,
+    args_schema_version: Optional[str] = None,
+) -> Interface:
+    task = dict(
+        name=name,
+        source=source,
+    )
+    if command_non_parallel:
+        task["command_non_parallel"] = command_non_parallel
+    if command_parallel:
+        task["command_parallel"] = command_parallel
+    if version:
+        task["version"] = version
+    if meta_non_parallel:
+        with open(meta_non_parallel, "r") as f:
+            task["meta_non_parallel"] = json.load(f)
+    if meta_parallel:
+        with open(meta_parallel, "r") as f:
+            task["meta_parallel"] = json.load(f)
+    if args_schema_parallel:
+        with open(args_schema_parallel, "r") as f:
+            task["args_schema_parallel"] = json.load(f)
+    if args_schema_non_parallel:
+        with open(args_schema_non_parallel, "r") as f:
+            task["args_schema_non_parallel"] = json.load(f)
+    if args_schema_version:
+        task["args_schema_version"] = args_schema_version
+
+    res = client.post(f"{settings.BASE_URL}/task/", json=task)
+    new_task = check_response(res, expected_status_code=201)
+
+    if batch:
+        return Interface(retcode=0, data=str(new_task["id"]))
+    else:
+        return Interface(retcode=0, data=new_task)
 
 
-def patch_task() -> None:
-    raise NotImplementedError
+def patch_task(
+    client: AuthClient,
+    *,
+    id: Optional[int] = None,
+    name: Optional[str] = None,
+    version: Optional[str] = None,
+    new_name: Optional[str] = None,
+    new_version: Optional[str] = None,
+    command_non_parallel: Optional[str] = None,
+    command_parallel: Optional[str] = None,
+    input_types: Optional[str] = None,
+    output_types: Optional[str] = None,
+) -> Interface:
 
+    if id:
+        if version:
+            logging.error(
+                "Too many arguments: cannot provide both `id` and `version`."
+            )
+            sys.exit(1)
+    else:
+        try:
+            id = get_task_id_from_cache(
+                client=client, task_name=name, version=version
+            )
+        except FractalCacheError as e:
+            print(e)
+            sys.exit(1)
+
+    task_update = {}
+    if new_name:
+        task_update["name"] = new_name
+    if new_version:
+        task_update["version"] = new_version
+    if command_non_parallel:
+        task_update["command_non_parallel"] = command_non_parallel
+    if command_parallel:
+        task_update["command_parallel"] = command_parallel
+    if input_types:
+        with open(input_types, "r") as f:
+            task_update["input_types"] = json.load(f)
+    if output_types:
+        with open(output_types, "r") as f:
+            task_update["output_types"] = json.load(f)
+
+    res = client.patch(f"{settings.BASE_URL}/task/{id}/", json=task_update)
+    new_task = check_response(res, expected_status_code=200)
+    return Interface(retcode=0, data=new_task)
+
+
+def delete_task(
+    client: AuthClient,
+    *,
+    id: Optional[int] = None,
+    name: Optional[str] = None,
+    version: Optional[str] = None,
+) -> Interface:
 
-def delete_task() -> None:
-    raise NotImplementedError
+    if id:
+        if version:
+            logging.error(
+                "Too many arguments: cannot provide both `id` and `version`."
+            )
+            sys.exit(1)
+    else:
+        try:
+            id = get_task_id_from_cache(
+                client=client, task_name=name, version=version
+            )
+        except FractalCacheError as e:
+            print(e)
+            sys.exit(1)
+    res = client.delete(f"{settings.BASE_URL}/task/{id}/")
+    check_response(res, expected_status_code=204)
+    return Interface(retcode=0, data="")
diff --git a/fractal_client/parser.py b/fractal_client/parser.py
index b25f25dae..83fa4290b 100644
--- a/fractal_client/parser.py
+++ b/fractal_client/parser.py
@@ -254,6 +254,119 @@
     help="Also include task-collection logs.",
 )
 
+# task new
+task_new_parser = task_subparsers.add_parser(
+    "new",
+    description="Create new task.",
+    argument_default=ap.SUPPRESS,
+    allow_abbrev=False,
+)
+task_new_parser.add_argument(
+    "name", help="A human readable name for the task."
+)
+task_new_parser.add_argument(
+    "--command-non-parallel",
+    help="The non parallel command that executes the task.",
+)
+task_new_parser.add_argument(
+    "--command-parallel", help="The  parallel command that executes the task."
+)
+task_new_parser.add_argument("source", help="The source of the task")
+task_new_parser.add_argument(
+    "--version",
+    help="Task version.",
+)
+task_new_parser.add_argument(
+    "--meta-non-parallel",
+    help="Path to JSON file with meta non parallel arguments.",
+)
+task_new_parser.add_argument(
+    "--meta-parallel",
+    help="Path to JSON file with meta parallel arguments.",
+)
+task_new_parser.add_argument(
+    "--args-schema-non-parallel",
+    help="Path to JSON file with args non parallel arguments.",
+)
+task_new_parser.add_argument(
+    "--args-schema-parallel",
+    help="Path to JSON file with arg parallel arguments.",
+)
+task_new_parser.add_argument(
+    "--args-schema-version",
+    help=(
+        "Label encoding how the task-arguments JSON Schema was generated "
+        "(e.g. `pydantic_v1`)."
+    ),
+)
+
+# task edit
+task_edit_parser = task_subparsers.add_parser(
+    "edit",
+    description="Edit task.",
+    argument_default=ap.SUPPRESS,
+    allow_abbrev=False,
+)
+
+task_edit_id_or_name_group = task_edit_parser.add_mutually_exclusive_group(
+    required=True
+)
+task_edit_id_or_name_group.add_argument(
+    "--id", help="ID of the task to edit.", type=int
+)
+task_edit_id_or_name_group.add_argument(
+    "--name", help="Name of the task to edit."
+)
+
+task_edit_parser.add_argument(
+    "--version",
+    help=(
+        "Version of the task to edit "
+        "(only accepted in combination with `--name`)."
+    ),
+)
+task_edit_parser.add_argument("--new-name", help="New task name.")
+task_edit_parser.add_argument("--new-version", help="New task version.")
+task_edit_parser.add_argument(
+    "--command-non-parallel", help="New task non parallel command."
+)
+task_edit_parser.add_argument(
+    "--command-parallel",
+    help="New task parallel command.",
+)
+task_edit_parser.add_argument(
+    "--input-types",
+    help=("Path to JSON file with new input types."),
+)
+task_edit_parser.add_argument(
+    "--output-types",
+    help=("Path to JSON file with new output types."),
+)
+
+# task delete
+task_delete_parser = task_subparsers.add_parser(
+    "delete",
+    description="Delete task.",
+    argument_default=ap.SUPPRESS,
+    allow_abbrev=False,
+)
+task_delete_id_or_name_group = task_delete_parser.add_mutually_exclusive_group(
+    required=True
+)
+task_delete_id_or_name_group.add_argument(
+    "--id", help="ID of the task to delete.", type=int
+)
+task_delete_id_or_name_group.add_argument(
+    "--name", help="Name of the task to delete."
+)
+task_delete_parser.add_argument(
+    "--version",
+    help=(
+        "Version of the task to delete "
+        "(only accepted in combination with `--name`)."
+    ),
+)
+
 
 # WORKFLOW GROUP
 
diff --git a/poetry.lock b/poetry.lock
index 7dc5557c4..b233852ae 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -637,7 +637,7 @@ typing = ["typing-extensions (>=4.8)"]
 
 [[package]]
 name = "fractal-server"
-version = "2.0.0a6"
+version = "2.0.0a8"
 description = "Server component of the Fractal analytics platform"
 optional = false
 python-versions = "^3.9"
@@ -667,7 +667,7 @@ postgres = ["asyncpg (>=0.29.0,<0.30.0)", "psycopg2 (>=2.9.5,<3.0.0)"]
 type = "git"
 url = "https://github.com/fractal-analytics-platform/fractal-server.git"
 reference = "main"
-resolved_reference = "b6aaf4052bf2567b52938d503472de95642826a0"
+resolved_reference = "bbfa8df756838942bf4964a6fec72d36a535a16a"
 
 [[package]]
 name = "ghp-import"


Test patch:
diff --git a/tests/test_task.py b/tests/test_task.py
index 5906bea4d..b0dc7b55a 100644
--- a/tests/test_task.py
+++ b/tests/test_task.py
@@ -1,10 +1,14 @@
 import json
 import time
+from pathlib import Path
 from urllib.request import urlretrieve
 
 import pytest
 from devtools import debug
 
+from fractal_client.cmd._aux_task_caching import TASKS_CACHE_FILENAME
+from fractal_client.config import settings
+
 
 COLLECTION_TIMEOUT = 15.0
 
@@ -125,3 +129,292 @@ def test_repeated_task_collection(register_user, invoke, testdata_path):
     res0 = invoke(f"task collect {PACKAGE_PATH}")
     debug(res0.data)
     assert res0.data["data"]["info"] == "Already installed"
+
+
+def test_task_new(register_user, invoke, tmp_path):
+
+    # create a new task with just positional required args
+    args_path = str(tmp_path / "args.json")
+    args = {"image_dir": "/asdasd"}
+    with open(args_path, "w") as f:
+        json.dump(args, f)
+
+    meta_path = str(tmp_path / "meta.json")
+    meta = {"a": "b"}
+    with open(meta_path, "w") as f:
+        json.dump(meta, f)
+
+    res = invoke(
+        "task new _name  _source --command-parallel _command "
+        f"--version _version --meta-parallel {meta_path} "
+        f"--args-schema-parallel {args_path} "
+        f"--args-schema-version 1.0.0"
+    )
+    debug(res.data)
+    assert res.retcode == 0
+    assert res.data["name"] == "_name"
+    assert res.data["command_parallel"] == "_command"
+    assert res.data["source"] == f"{register_user['username']}:_source"
+    assert res.data["version"] == "_version"
+    assert res.data["meta_parallel"] == meta
+    assert res.data["args_schema_version"] == "1.0.0"
+
+    assert "owner" in res.data.keys()
+    first_task_id = int(res.data["id"])
+
+    # create a new task with batch option
+    res = invoke(
+        "--batch task new _name2 _source2 --command-parallel _command2"
+    )
+    res.show()
+    assert res.retcode == 0
+    assert res.data == str(first_task_id + 1)
+
+    # create a new task with same source as before. Note that in check_response
+    # we have sys.exit(1) when status code is not the expecte one
+    with pytest.raises(SystemExit) as e:
+        invoke("task new _name2 _source --command-parallel _command2")
+    assert e.value.code == 1
+
+    # create a new task passing not existing file
+    res = invoke(
+        (
+            "task new _name _source --command-parallel _command "
+            "--meta-parallel ./foo.pdf"
+        )
+    )
+    assert res.retcode == 1
+
+    metanp_path = str(tmp_path / "meta.json")
+    metanp = {"a": "b"}
+    with open(metanp_path, "w") as f:
+        json.dump(metanp, f)
+    res = invoke(
+        (
+            f"task new _name_np _source_np --command-non-parallel _command_np "
+            f"--meta-non-parallel {metanp_path} "
+            f"--args-schema-non-parallel {args_path} "
+        )
+    )
+    assert res.data["args_schema_non_parallel"] == args
+
+
+def test_task_edit(
+    caplog,
+    register_user,
+    invoke,
+    invoke_as_superuser,
+    tmp_path,
+    testdata_path: Path,
+):
+
+    args_path = str(tmp_path / "args.json")
+    args = {"image_dir": "/asdasd"}
+    with open(args_path, "w") as f:
+        json.dump(args, f)
+
+    meta_path = str(tmp_path / "meta.json")
+    meta = {"a": "b"}
+    with open(meta_path, "w") as f:
+        json.dump(meta, f)
+
+    task = invoke(
+        "task new _name  _source --command-parallel _command "
+        f"--version _version --meta-parallel {meta_path} "
+        f"--args-schema-parallel {args_path} "
+        f"--args-schema-version 1.0.0"
+    )
+
+    task.show()
+    assert task.retcode == 0
+    task_id = task.data["id"]
+    NEW_NAME = "1234"
+
+    # Test that regular user is not authorized
+    with pytest.raises(SystemExit):
+        res = invoke(f"task edit {task_id} --new-name {NEW_NAME}")
+
+    # Test successful edit of string attributes
+    res = invoke_as_superuser(
+        f"task edit --id {task_id} --new-name {NEW_NAME}"
+    )
+    assert res.data["name"] == NEW_NAME
+    assert res.retcode == 0
+
+    NEW_COMMAND_PARALLEL = "run_parallel"
+    res = invoke_as_superuser(
+        (
+            f"task edit --id {task_id} "
+            f"--command-parallel {NEW_COMMAND_PARALLEL}"
+        )
+    )
+    assert res.data["command_parallel"] == NEW_COMMAND_PARALLEL
+    assert res.retcode == 0
+
+    # Add non-parallel task and test command-non-parallel
+
+    meta_path = str(tmp_path / "meta.json")
+    meta = {"a": "b"}
+    with open(meta_path, "w") as f:
+        json.dump(meta, f)
+
+    task_np = invoke(
+        (
+            f"task new _name_np _source_np "
+            f"--command-non-parallel _command_np "
+            f"--version 1.0.1 --meta-non-parallel {meta_path}"
+        )
+    )
+
+    NEW_COMMAND_NON_PARALLEL = "run_non_parallel"
+    res = invoke_as_superuser(
+        (
+            f"task edit --id {task_np.data['id']} "
+            f"--command-non-parallel {NEW_COMMAND_NON_PARALLEL}"
+        )
+    )
+    assert res.data["command_non_parallel"] == NEW_COMMAND_NON_PARALLEL
+    assert res.retcode == 0
+
+    # Test fail with no task_id nor task_name
+    with pytest.raises(SystemExit):
+        res = invoke_as_superuser("task edit")
+    # Test fail with both task_id and task_name
+    with pytest.raises(SystemExit):
+        res = invoke_as_superuser(
+            f"task edit --id {task_id} --name {task.data['name']}"
+        )
+    # Test fail with both task_id and task_version
+    with pytest.raises(SystemExit):
+        res = invoke_as_superuser(
+            f"task edit --id {task_id} --version 1.2.3.4.5.6"
+        )
+    assert caplog.records[-1].msg == (
+        "Too many arguments: cannot provide both `id` and `version`."
+    )
+    # Test fail "name and wrong version"
+    with pytest.raises(SystemExit):
+        invoke("task delete --name INVALID_NAME --version INVALID_VERSION")
+
+    input_types = {"input": True, "output": False}
+
+    i_types_path = str(tmp_path / "itypes.json")
+    with open(i_types_path, "w") as f:
+        json.dump(input_types, f)
+
+    output_types = {"input": False, "output": True}
+
+    o_types_path = str(tmp_path / "otypes.json")
+    with open(o_types_path, "w") as f:
+        json.dump(output_types, f)
+
+    # Test regular updates (both by id and name)
+    res = invoke_as_superuser(
+        f"task edit --id {task_id} --input-types {i_types_path}"
+    )
+    assert res.data["input_types"] == input_types
+    assert res.retcode == 0
+    res = invoke_as_superuser(
+        f"task edit --name {NEW_NAME} --output-types {o_types_path}"
+    )
+    assert res.data["output_types"] == output_types
+    assert res.retcode == 0
+
+    NEW_VERSION = "3.14"
+    res = invoke_as_superuser(
+        f"task edit --id {task_id} --new-version {NEW_VERSION}"
+    )
+    assert res.data["version"] == NEW_VERSION
+    assert res.retcode == 0
+
+    # Test regular update by name, after deleting cache
+    cache_dir = Path(settings.FRACTAL_CACHE_PATH)
+    cache_file = cache_dir / TASKS_CACHE_FILENAME
+    cache_file.unlink(missing_ok=True)
+
+    res = invoke_as_superuser(
+        f"task edit --name {NEW_NAME} --output-types {o_types_path}"
+    )
+    assert res.data["output_types"] == output_types
+    assert res.retcode == 0
+
+    # Test failing invalid name
+    fail_output_types = {"input": True, "output": False}
+
+    f_o_types_path = str(tmp_path / "fotypes.json")
+    with open(f_o_types_path, "w") as f:
+        json.dump(fail_output_types, f)
+
+    with pytest.raises(SystemExit):
+        res = invoke_as_superuser(
+            f"task edit --name INVALID_NAME --output-types {f_o_types_path}"
+        )
+
+    # Test regular update by name, after creating an invalid cache
+    with cache_file.open("w") as f:
+        json.dump([], f)
+
+    new_output_types = {"input": False, "output": True}
+
+    n_o_types_path = str(tmp_path / "notypes.json")
+    with open(n_o_types_path, "w") as f:
+        json.dump(new_output_types, f)
+
+    res = invoke_as_superuser(
+        f"task edit --name {NEW_NAME} --output-types {n_o_types_path}"
+    )
+    assert res.data["output_types"] == new_output_types
+    assert res.retcode == 0
+
+
+def test_task_delete(
+    register_user,
+    user_factory,
+    invoke,
+    invoke_as_superuser,
+    tmp_path,
+):
+    """
+    Test task delete
+    """
+    NAME = "_name"
+    VERSION = "1.0.0"
+
+    meta_path = str(tmp_path / "meta.json")
+    meta = {"a": "b"}
+    with open(meta_path, "w") as f:
+        json.dump(meta, f)
+
+    task = invoke(
+        (
+            f"task new {NAME}  _source --command-parallel _command "
+            f"--version {VERSION} --meta-parallel {meta_path}"
+        )
+    )
+
+    task.show()
+    assert task.retcode == 0
+    task_id = task.data["id"]
+
+    # Test access control
+    with pytest.raises(SystemExit):
+        EMAIL = "someone@example.org"
+        PASSWORD = "123123"
+        user_factory(email=EMAIL, password=PASSWORD)
+        res = invoke(f"-u {EMAIL} -p {PASSWORD} task delete --id {task_id}")
+    # Test fail "id and version"
+    with pytest.raises(SystemExit):
+        invoke(f"task delete --id {task_id} --version {VERSION}")
+    # Test fail "name and wrong version"
+    with pytest.raises(SystemExit):
+        invoke(f"task delete --name {NAME} --version INVALID_VERSION")
+
+    # Test success
+    res = invoke("task list")
+    task_list = res.data
+    assert len(task_list) == 1
+    res = invoke(f"task delete --name {NAME} --version {VERSION}")
+    assert res.retcode == 0
+    res = invoke("task list")
+    task_list = res.data
+    assert len(task_list) == 0