Skip to content

Commit

Permalink
Merge pull request #749 from fractal-analytics-platform/test-enum-arg…
Browse files Browse the repository at this point in the history
…ument

Support `Enum` arguments and improve testing of schema-generating tools
  • Loading branch information
tcompa authored Jun 7, 2024
2 parents 8d72bb0 + 243b3f4 commit 9e4ada7
Show file tree
Hide file tree
Showing 12 changed files with 357 additions and 49 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci_pip.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
run: python -m pip install pytest devtools jsonschema requests wget pooch

- name: Test core library with pytest
run: python -m pytest tests --ignore tests/tasks
run: python -m pytest tests --ignore tests/tasks --ignore tests/dev

tests_tasks:
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
Expand Down Expand Up @@ -83,4 +83,4 @@ jobs:
key: pooch-cache

- name: Test tasks with pytest
run: python -m pytest tests tests/tasks -s --log-cli-level info
run: python -m pytest tests/dev tests/tasks -s --log-cli-level info
4 changes: 2 additions & 2 deletions .github/workflows/ci_poetry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
run: poetry install --with dev --without docs --no-interaction

- name: Test core library with pytest
run: poetry run coverage run -m pytest tests --ignore tests/tasks
run: poetry run coverage run -m pytest tests --ignore tests/tasks --ignore tests/dev

- name: Upload coverage data
uses: actions/upload-artifact@v3
Expand Down Expand Up @@ -96,7 +96,7 @@ jobs:
key: pooch-cache

- name: Test tasks with pytest
run: poetry run coverage run -m pytest tests/tasks -s --log-cli-level info
run: poetry run coverage run -m pytest tests/dev tests/tasks -s --log-cli-level info

- name: Upload coverage data
uses: actions/upload-artifact@v3
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
**Note**: Numbers like (\#123) point to closed Pull Requests on the fractal-tasks-core repository.

# 1.0.3 (unreleased)

* Support JSON-Schema generation for `Enum` task arguments (\#749).
* Make JSON-Schema generation tools more flexible, to simplify testing (\#749).

# 1.0.2

* Fix bug in plate metadata in MIP task (in the copy_ome_zarr_hcs_plate init function) (\#736).

# 1.0.1
Expand Down
79 changes: 63 additions & 16 deletions fractal_tasks_core/dev/lib_args_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
Helper functions to handle JSON schemas for task arguments.
"""
import logging
import os
from collections import Counter
from pathlib import Path
from typing import Any
from typing import Callable
from typing import Optional

from docstring_parser import parse as docparse
Expand Down Expand Up @@ -156,28 +158,71 @@ def _remove_attributes_from_descriptions(old_schema: _Schema) -> _Schema:

def create_schema_for_single_task(
executable: str,
package: str = "fractal_tasks_core",
package: Optional[str] = "fractal_tasks_core",
custom_pydantic_models: Optional[list[tuple[str, str, str]]] = None,
task_function: Optional[Callable] = None,
verbose: bool = False,
) -> _Schema:
"""
Main function to create a JSON Schema of task arguments
This function can be used in two ways:
1. `task_function` argument is `None`, `package` is set, and `executable`
is a path relative to that package.
2. `task_function` argument is provided, `executable` is an absolute path
to the function module, and `package` is `None. This is useful for
testing.
"""

logging.info("[create_schema_for_single_task] START")

# Extract the function name. Note: this could be made more general, but for
# the moment we assume the function has the same name as the module)
function_name = Path(executable).with_suffix("").name
logging.info(f"[create_schema_for_single_task] {function_name=}")
if task_function is None:
usage = "1"
# Usage 1 (standard)
if package is None:
raise ValueError(
"Cannot call `create_schema_for_single_task with "
f"{task_function=} and {package=}. Exit."
)
if os.path.isabs(executable):
raise ValueError(
"Cannot call `create_schema_for_single_task with "
f"{task_function=} and absolute {executable=}. Exit."
)
else:
usage = "2"
# Usage 2 (testing)
if package is not None:
raise ValueError(
"Cannot call `create_schema_for_single_task with "
f"{task_function=} and non-None {package=}. Exit."
)
if not os.path.isabs(executable):
raise ValueError(
"Cannot call `create_schema_for_single_task with "
f"{task_function=} and non-absolute {executable=}. Exit."
)

# Extract function from module
task_function = _extract_function(
package_name=package,
module_relative_path=executable,
function_name=function_name,
)
if usage == "1":
# Extract the function name (for the moment we assume the function has
# the same name as the module)
function_name = Path(executable).with_suffix("").name
# Extract the function object
task_function = _extract_function(
package_name=package,
module_relative_path=executable,
function_name=function_name,
verbose=verbose,
)
else:
# The function object is already available, extract its name
function_name = task_function.__name__

logging.info(f"[create_schema_for_single_task] {task_function=}")
if verbose:
logging.info(f"[create_schema_for_single_task] {function_name=}")
logging.info(f"[create_schema_for_single_task] {task_function=}")

# Validate function signature against some custom constraints
_validate_function_signature(task_function)
Expand All @@ -190,16 +235,18 @@ def create_schema_for_single_task(
schema = _remove_attributes_from_descriptions(schema)

# Include titles for custom-model-typed arguments
schema = _include_titles(schema)
schema = _include_titles(schema, verbose=verbose)

# Include descriptions of function arguments
# Include descriptions of function. Note: this function works both
# for usages 1 or 2 (see docstring).
function_args_descriptions = _get_function_args_descriptions(
package_name=package,
module_relative_path=executable,
module_path=executable,
function_name=function_name,
verbose=verbose,
)
schema = _insert_function_args_descriptions(
schema=schema, descriptions=function_args_descriptions
schema=schema, descriptions=function_args_descriptions, verbose=verbose
)

# Merge lists of fractal-tasks-core and user-provided Pydantic models
Expand Down
67 changes: 57 additions & 10 deletions fractal_tasks_core/dev/lib_descriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
# Zurich.
import ast
import logging
import os
from importlib import import_module
from pathlib import Path
from typing import Optional

from docstring_parser import parse as docparse

Expand All @@ -37,23 +39,49 @@ def _sanitize_description(string: str) -> str:


def _get_function_docstring(
package_name: str, module_relative_path: str, function_name: str
*,
package_name: Optional[str],
module_path: str,
function_name: str,
verbose: bool = False,
) -> str:
"""
Extract docstring from a function.
Args:
package_name: Example `fractal_tasks_core`.
module_relative_path: Example `tasks/create_ome_zarr.py`.
module_path:
This must be an absolute path like `/some/module.py` (if
`package_name` is `None`) or a relative path like `something.py`
(if `package_name` is not `None`).
function_name: Example `create_ome_zarr`.
"""

if not module_relative_path.endswith(".py"):
raise ValueError(f"Module {module_relative_path} must end with '.py'")
if not module_path.endswith(".py"):
raise ValueError(f"Module {module_path} must end with '.py'")

# Get the function ast.FunctionDef object
package_path = Path(import_module(package_name).__file__).parent
module_path = package_path / module_relative_path
if package_name is not None:
if os.path.isabs(module_path):
raise ValueError(
"Error in _get_function_docstring: `package_name` is not "
"None but `module_path` is absolute."
)
package_path = Path(import_module(package_name).__file__).parent
module_path = package_path / module_path
else:
if not os.path.isabs(module_path):
raise ValueError(
"Error in _get_function_docstring: `package_name` is None "
"but `module_path` is not absolute."
)
module_path = Path(module_path)

if verbose:
logging.info(f"[_get_function_docstring] {function_name=}")
logging.info(f"[_get_function_docstring] {module_path=}")

tree = ast.parse(module_path.read_text())
_function = next(
f
Expand All @@ -66,21 +94,33 @@ def _get_function_docstring(


def _get_function_args_descriptions(
package_name: str, module_relative_path: str, function_name: str
*,
package_name: Optional[str],
module_path: str,
function_name: str,
verbose: bool = False,
) -> dict[str, str]:
"""
Extract argument descriptions from a function.
Args:
package_name: Example `fractal_tasks_core`.
module_relative_path: Example `tasks/create_ome_zarr.py`.
module_path:
This must be an absolute path like `/some/module.py` (if
`package_name` is `None`) or a relative path like `something.py`
(if `package_name` is not `None`).
function_name: Example `create_ome_zarr`.
"""

# Extract docstring from ast.FunctionDef
docstring = _get_function_docstring(
package_name, module_relative_path, function_name
package_name=package_name,
module_path=module_path,
function_name=function_name,
verbose=verbose,
)
if verbose:
logging.info(f"[_get_function_args_descriptions] {docstring}")

# Parse docstring (via docstring_parser) and prepare output
parsed_docstring = docparse(docstring)
Expand Down Expand Up @@ -134,7 +174,9 @@ def _get_class_attrs_descriptions(
return descriptions


def _insert_function_args_descriptions(*, schema: dict, descriptions: dict):
def _insert_function_args_descriptions(
*, schema: dict, descriptions: dict, verbose: bool = False
):
"""
Merge the descriptions obtained via `_get_args_descriptions` into the
properties of an existing JSON Schema.
Expand All @@ -154,6 +196,11 @@ def _insert_function_args_descriptions(*, schema: dict, descriptions: dict):
else:
value["description"] = "Missing description"
new_properties[key] = value
if verbose:
logging.info(
"[_insert_function_args_descriptions] "
f"Add {key=}, {value=}"
)
new_schema["properties"] = new_properties
logging.info("[_insert_function_args_descriptions] END")
return new_schema
Expand Down
18 changes: 16 additions & 2 deletions fractal_tasks_core/dev/lib_signature_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def _extract_function(
module_relative_path: str,
function_name: str,
package_name: str = "fractal_tasks_core",
verbose: bool = False,
) -> Callable:
"""
Extract function from a module with the same name.
Expand All @@ -42,15 +43,28 @@ def _extract_function(
package_name: Example `fractal_tasks_core`.
module_relative_path: Example `tasks/create_ome_zarr.py`.
function_name: Example `create_ome_zarr`.
verbose:
"""
if not module_relative_path.endswith(".py"):
raise ValueError(f"{module_relative_path=} must end with '.py'")
module_relative_path_no_py = str(
Path(module_relative_path).with_suffix("")
)
module_relative_path_dots = module_relative_path_no_py.replace("/", ".")
module = import_module(f"{package_name}.{module_relative_path_dots}")
task_function = getattr(module, function_name)
if verbose:
logging.info(
f"Now calling `import_module` for "
f"{package_name}.{module_relative_path_dots}"
)
imported_module = import_module(
f"{package_name}.{module_relative_path_dots}"
)
if verbose:
logging.info(
f"Now getting attribute {function_name} from "
f"imported module {imported_module}."
)
task_function = getattr(imported_module, function_name)
return task_function


Expand Down
10 changes: 6 additions & 4 deletions fractal_tasks_core/dev/lib_task_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,21 @@


def _get_function_description(
package_name: str, module_relative_path: str, function_name: str
package_name: str, module_path: str, function_name: str
) -> str:
"""
Extract function description from its docstring.
Args:
package_name: Example `fractal_tasks_core`.
module_relative_path: Example `tasks/create_ome_zarr.py`.
module_path: Example `tasks/create_ome_zarr.py`.
function_name: Example `create_ome_zarr`.
"""
# Extract docstring from ast.FunctionDef
docstring = _get_function_docstring(
package_name, module_relative_path, function_name
package_name=package_name,
module_path=module_path,
function_name=function_name,
)
# Parse docstring (via docstring_parser)
parsed_docstring = docparse(docstring)
Expand Down Expand Up @@ -72,7 +74,7 @@ def create_docs_info(
# Get function description
description = _get_function_description(
package_name=package,
module_relative_path=executable,
module_path=executable,
function_name=function_name,
)
docs_info.append(f"## {function_name}\n{description}\n")
Expand Down
Loading

0 comments on commit 9e4ada7

Please sign in to comment.