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

Issue:
Add `fractal job download-logs` command
Ref #369


Discussion / hints:

Fix patch:
diff --git a/fractal/cmd/_job.py b/fractal/cmd/_job.py
index 0c4f5cb2d..c8c289083 100644
--- a/fractal/cmd/_job.py
+++ b/fractal/cmd/_job.py
@@ -1,10 +1,16 @@
+import logging
+import os
 from pathlib import Path
+from zipfile import ZipFile
+
+from rich.table import Table
 
 from ..authclient import AuthClient
 from ..common.schemas import ApplyWorkflowRead
 from ..config import settings
 from ..interface import BaseInterface
 from ..interface import PrintInterface
+from ..interface import RichConsoleInterface
 from ..interface import RichJsonInterface
 from ..response import check_response
 
@@ -45,14 +51,39 @@ async def job_list(
     **kwargs,
 ) -> BaseInterface:
 
-    raise NotImplementedError("job_list not implemented")
+    res = await client.get(f"{settings.BASE_URL}/project/{project_id}/jobs/")
+    jobs = check_response(res, expected_status_code=200)
+
+    # Coerce to a list of ApplyWorkflowRead objects
+    jobs = [ApplyWorkflowRead(**job) for job in jobs]
+
+    if batch:
+        job_ids = " ".join(str(job.id) for job in jobs)
+        return PrintInterface(retcode=0, data=job_ids)
+    else:
+        table = Table(title=f"Job list for project {project_id}")
+        kwargs = dict(style="white", justify="center")
+        table.add_column("id", **kwargs)
+        table.add_column("start_timestamp", **kwargs)
+        table.add_column("status", **kwargs)
+        table.add_column("workflow_id", **kwargs)
+        table.add_column("working_dir", **kwargs)
+
+        for j in jobs:
+            table.add_row(
+                str(j.id),
+                j.sanitised_dict()["start_timestamp"],
+                j.status,
+                str(j.workflow_id),
+                j.working_dir,
+            )
+        return RichConsoleInterface(retcode=0, data=table)
 
 
 async def job_download_logs(
     client: AuthClient,
     job_id: int,
     output: str,
-    batch: bool = False,
     **kwargs,
 ) -> BaseInterface:
 
@@ -62,4 +93,53 @@ async def job_download_logs(
             retcode=1, data=f"ERROR: {output} already exists"
         )
 
-    raise NotImplementedError("job_download_logs not implemented")
+    # Send request to server
+    res = await client.get(f"{settings.BASE_URL}/job/download/{job_id}")
+
+    # NOTE: We cannot use our default check_response here, because res._content
+    # is binary. Therefore we check the status code by hand
+    if res.status_code != 200:
+        logging.error(f"Server returned {res.status_code}")
+        logging.error(
+            f"Original request: {res._request.method} {res._request.url}"
+        )
+        logging.error(
+            f"Original payload: {res._request._content.decode('utf-8')}"
+        )
+        logging.error("Terminating.\n")
+        exit(1)
+
+    # Check the content-type entry in the response headers
+    content_type = res.headers["content-type"]
+    expected_content_type = "application/x-zip-compressed"
+    if content_type != expected_content_type:
+        logging.error(
+            f"Unexpected {content_type=} in headers of server "
+            f"response, instead of {expected_content_type=}"
+        )
+        logging.error(
+            f"Original request: {res._request.method} {res._request.url}"
+        )
+        logging.error(
+            f"Original payload: {res._request._content.decode('utf-8')}"
+        )
+        logging.error("Terminating.\n")
+        exit(1)
+
+    # Write response into a temporary zipped file
+    zipped_archive_path = output + "_tmp.zip"
+    with open(zipped_archive_path, "wb") as f:
+        f.write(res.content)
+
+    # Unzip the log archive
+    unzipped_archived_path = output
+    os.mkdir(unzipped_archived_path)
+    with ZipFile(zipped_archive_path, mode="r") as zipfile:
+        zipfile.extractall(path=unzipped_archived_path)
+
+    # Remove zipped temporary file
+    os.unlink(zipped_archive_path)
+
+    return PrintInterface(
+        retcode=0, data=f"Logs downloaded to {output} folder"
+    )
diff --git a/poetry.lock b/poetry.lock
index 3f32a1bd9..cf03ac7e6 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -17,14 +17,14 @@ typing_extensions = ">=3.7.2"
 
 [[package]]
 name = "alembic"
-version = "1.8.1"
+version = "1.9.0"
 description = "A database migration tool for SQLAlchemy."
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "alembic-1.8.1-py3-none-any.whl", hash = "sha256:0a024d7f2de88d738d7395ff866997314c837be6104e90c5724350313dee4da4"},
-    {file = "alembic-1.8.1.tar.gz", hash = "sha256:cd0b5e45b14b706426b833f06369b9a6d5ee03f826ec3238723ce8caaf6e5ffa"},
+    {file = "alembic-1.9.0-py3-none-any.whl", hash = "sha256:e8ce855f731d92cfdbf9a71d148401fcc420399927817c6e2c0b6a5f31db745d"},
+    {file = "alembic-1.9.0.tar.gz", hash = "sha256:6af6792fe699730b27480382701b16028ebbaac6bc5cd4f06daf5fa3e4690364"},
 ]
 
 [package.dependencies]
@@ -77,21 +77,22 @@ test = ["astroid", "pytest"]
 
 [[package]]
 name = "attrs"
-version = "22.1.0"
+version = "22.2.0"
 description = "Classes Without Boilerplate"
 category = "dev"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.6"
 files = [
-    {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
-    {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
+    {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"},
+    {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"},
 ]
 
 [package.extras]
-dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"]
-docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"]
-tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"]
-tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"]
+cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"]
+dev = ["attrs[docs,tests]"]
+docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"]
+tests = ["attrs[tests-no-zope]", "zope.interface"]
+tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"]
 
 [[package]]
 name = "bcrypt"
@@ -542,14 +543,14 @@ sqlmodel = "*"
 
 [[package]]
 name = "fractal-server"
-version = "1.0.0b4"
+version = "1.0.0b9"
 description = "Server component of the Fractal analytics platform"
 category = "dev"
 optional = false
 python-versions = ">=3.8,<4.0"
 files = [
-    {file = "fractal_server-1.0.0b4-py3-none-any.whl", hash = "sha256:9d314f257fcc977d24314a1825c27177217aef4d91efd0048b14832a7adb0ea3"},
-    {file = "fractal_server-1.0.0b4.tar.gz", hash = "sha256:c31a65dbe72cea645c22f323fb5a5f525fd8bdb69a8ba0ffde9eae5efa2503f5"},
+    {file = "fractal_server-1.0.0b9-py3-none-any.whl", hash = "sha256:28773ba3ab2dd1d6dcd626b6219f8df3b1b160e9348acaabe9a1d1dd9fb41e4c"},
+    {file = "fractal_server-1.0.0b9.tar.gz", hash = "sha256:238713625a73fa8de314f357c4bea3439522354ceaac29e114dd86bc466748ed"},
 ]
 
 [package.dependencies]
@@ -564,6 +565,7 @@ sqlmodel = ">=0.0.8,<0.0.9"
 uvicorn = ">=0.18.2,<0.19.0"
 
 [package.extras]
+gunicorn = ["gunicorn (>=20.1.0,<21.0.0)"]
 postgres = ["asyncpg (>=0.27.0,<0.28.0)", "psycopg2 (>=2.9.5,<3.0.0)"]
 slurm = ["clusterfutures (>=0.4,<0.5)"]
 
@@ -655,14 +657,14 @@ files = [
 
 [[package]]
 name = "httpcore"
-version = "0.16.2"
+version = "0.16.3"
 description = "A minimal low-level HTTP client."
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "httpcore-0.16.2-py3-none-any.whl", hash = "sha256:52c79095197178856724541e845f2db86d5f1527640d9254b5b8f6f6cebfdee6"},
-    {file = "httpcore-0.16.2.tar.gz", hash = "sha256:c35c5176dc82db732acfd90b581a3062c999a72305df30c0fc8fafd8e4aca068"},
+    {file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"},
+    {file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"},
 ]
 
 [package.dependencies]
@@ -728,21 +730,21 @@ files = [
 
 [[package]]
 name = "importlib-metadata"
-version = "5.1.0"
+version = "5.2.0"
 description = "Read metadata from Python packages"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "importlib_metadata-5.1.0-py3-none-any.whl", hash = "sha256:d84d17e21670ec07990e1044a99efe8d615d860fd176fc29ef5c306068fda313"},
-    {file = "importlib_metadata-5.1.0.tar.gz", hash = "sha256:d5059f9f1e8e41f80e9c56c2ee58811450c31984dfa625329ffd7c0dad88a73b"},
+    {file = "importlib_metadata-5.2.0-py3-none-any.whl", hash = "sha256:0eafa39ba42bf225fc00e67f701d71f85aead9f878569caf13c3724f704b970f"},
+    {file = "importlib_metadata-5.2.0.tar.gz", hash = "sha256:404d48d62bba0b7a77ff9d405efd91501bef2e67ff4ace0bed40a0cf28c3c7cd"},
 ]
 
 [package.dependencies]
 zipp = ">=0.5"
 
 [package.extras]
-docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
 perf = ["ipython"]
 testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
 
@@ -1237,7 +1239,7 @@ greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platfo
 
 [package.extras]
 aiomysql = ["aiomysql", "greenlet (!=0.4.17)"]
-aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"]
+aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing-extensions (!=3.10.0.1)"]
 asyncio = ["greenlet (!=0.4.17)"]
 asyncmy = ["asyncmy (>=0.2.3,!=0.2.4)", "greenlet (!=0.4.17)"]
 mariadb-connector = ["mariadb (>=1.0.1)"]
@@ -1247,14 +1249,14 @@ mssql-pyodbc = ["pyodbc"]
 mypy = ["mypy (>=0.910)", "sqlalchemy2-stubs"]
 mysql = ["mysqlclient (>=1.4.0)", "mysqlclient (>=1.4.0,<2)"]
 mysql-connector = ["mysql-connector-python"]
-oracle = ["cx_oracle (>=7)", "cx_oracle (>=7,<8)"]
+oracle = ["cx-oracle (>=7)", "cx-oracle (>=7,<8)"]
 postgresql = ["psycopg2 (>=2.7)"]
 postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"]
 postgresql-pg8000 = ["pg8000 (>=1.16.6)"]
 postgresql-psycopg2binary = ["psycopg2-binary"]
 postgresql-psycopg2cffi = ["psycopg2cffi"]
 pymysql = ["pymysql", "pymysql (<1)"]
-sqlcipher = ["sqlcipher3_binary"]
+sqlcipher = ["sqlcipher3-binary"]
 
 [[package]]
 name = "sqlalchemy-utils"
@@ -1287,14 +1289,14 @@ url = ["furl (>=0.4.1)"]
 
 [[package]]
 name = "sqlalchemy2-stubs"
-version = "0.0.2a29"
+version = "0.0.2a30"
 description = "Typing Stubs for SQLAlchemy 1.4"
 category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "sqlalchemy2-stubs-0.0.2a29.tar.gz", hash = "sha256:1bbc6aebd76db7c0351a9f45cc1c4e8ac335ba150094c2af091e8b87b9118419"},
-    {file = "sqlalchemy2_stubs-0.0.2a29-py3-none-any.whl", hash = "sha256:ece266cdabf3797b13ddddba27561b67ae7dedc038942bf66e045e978a5e3a66"},
+    {file = "sqlalchemy2-stubs-0.0.2a30.tar.gz", hash = "sha256:a6e7606741cbe471215ff5121ceb3b46e52eca829efad1e12c316d1c269a2562"},
+    {file = "sqlalchemy2_stubs-0.0.2a30-py3-none-any.whl", hash = "sha256:44fa604e4bf8108e931a629fc72df2a95dbe0b2c1bd16f3f4f735c7aabc2f9e3"},
 ]
 
 [package.dependencies]
@@ -1410,4 +1412,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.8"
-content-hash = "af663b74de7938cf97ef7555a1141e460cdab463c45ae0f3f79d2b6bb8088af2"
+content-hash = "9c92f68da1cf33fb3e4491580de2b299e2057a930c15253cbaa4ffa54a2d9810"
diff --git a/pyproject.toml b/pyproject.toml
index 2809a3e42..cd154622d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,7 +29,7 @@ pytest = "^7.1.2"
 bumpver = "^2022.1118"
 pytest-asyncio = "^0.19.0"
 # fractal-server = "^1.0.0"
-fractal-server = ">=1.0.0b4"
+fractal-server = ">=1.0.0b9"
 coverage = {extras = ["toml"], version = "^6.5.0"}
 
 [build-system]


Test patch:
diff --git a/tests/test_job.py b/tests/test_job.py
index b236d5103..6ffe86849 100644
--- a/tests/test_job.py
+++ b/tests/test_job.py
@@ -67,7 +67,6 @@ async def test_job_status(
         assert res.data["log"] == LOG
 
 
-@pytest.mark.xfail(reason="Missing endpoint server side")
 async def test_job_list(
     register_user,
     invoke,
@@ -83,8 +82,8 @@ async def test_job_list(
     """
 
     # Create mock Project/Workflow/ApplyWorkflow objects
-    pj = await project_factory()
-    project_id = pj.id
+    res = await invoke("project new prj0 prj_path0")
+    project_id = res.data["id"]
     wf_1 = await workflow_factory(project_id=project_id)
     wf_2 = await workflow_factory(project_id=project_id)
     wd_1 = tmp_path / f"workflow_{wf_1.id}"
@@ -102,18 +101,27 @@ async def test_job_list(
     debug(job1)
     debug(job2)
 
+    # Check `job status` output with --batch option
+    cmd = f"--batch job list {project_id}"
+    debug(cmd)
+    res = await invoke(cmd)
+    assert res.retcode == 0
+    debug(res.data)
+    job_ids = [int(i) for i in res.data.split()]
+    assert job1.id in job_ids
+    assert job2.id in job_ids
+
     # Check `job status` output
     cmd = f"job list {project_id}"
     debug(cmd)
     res = await invoke(cmd)
     assert res.retcode == 0
     debug(res.data)
-
-    # FIXME add assertions
-    # FIXME also test --batch option
+    # There is not much to assert here, apart from successful invocation of the
+    # command. We add a res.show() for when pytest is run with the -s flag
+    res.show()
 
 
-@pytest.mark.xfail(reason="Missing endpoint server side")
 async def test_job_download_logs(
     register_user,
     invoke,
@@ -122,13 +130,13 @@ async def test_job_download_logs(
     job_factory,
 ):
     """
-    GIVEN a job entry in the database
-    WHEN calling the `job download-logs` command
-    THEN FIXME
+    Test the `job download-logs` command
     """
 
     # Create mock Workflow/ApplyWorkflow objects
-    wf = await workflow_factory()
+    res = await invoke("project new prj0 prj_path0")
+    project_id = res.data["id"]
+    wf = await workflow_factory(project_id=project_id)
     wd = tmp_path / f"workflow_{wf.id}"
     job = await job_factory(
         working_dir=wd.as_posix(),
@@ -163,5 +171,4 @@ async def test_job_download_logs(
     assert logfile.exists()
     with logfile.open("r") as f:
         contents = f.read()
-    # FIXME: review the following assertion
     assert contents == LOG