├── fastapi_uws ├── router │ ├── __init__.py │ └── uws_router.py ├── stores │ ├── __init__.py │ ├── base.py │ └── mem_store.py ├── workers │ ├── __init__.py │ └── base.py ├── __init__.py ├── responses.py ├── requests │ ├── __init__.py │ └── requests.py ├── models │ ├── __init__.py │ ├── types.py │ └── models.py ├── main.py ├── settings.py └── service.py ├── tests ├── __init__.py ├── conftest.py └── test_fastapi_uws.py ├── environment.yml ├── .readthedocs.yml ├── docs ├── source │ ├── index.rst │ ├── pages │ │ └── installation.rst │ └── conf.py ├── Makefile └── make.bat ├── .github └── workflows │ ├── build_and_run.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml ├── requirements.txt ├── .pylintrc └── openapi.yml /fastapi_uws/router/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit test package for fastapi_uws.""" 2 | -------------------------------------------------------------------------------- /fastapi_uws/stores/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi_uws.stores.base import BaseUWSStore 2 | -------------------------------------------------------------------------------- /fastapi_uws/workers/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi_uws.workers.base import BaseUWSWorker 2 | -------------------------------------------------------------------------------- /fastapi_uws/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for fastapi-uws.""" 2 | 3 | __author__ = """Joshua Fraustro""" 4 | __email__ = 'jfraustro@stsci.edu' 5 | __version__ = '0.1.0' 6 | -------------------------------------------------------------------------------- /fastapi_uws/responses.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class ErrorMessage(BaseModel): 5 | """A message describing an error.""" 6 | 7 | message: str = "An error occurred." -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: "fastapi-uws" 2 | channels: 3 | - defaults 4 | - conda-forge 5 | dependencies: 6 | - python>=3.11,<3.12 7 | - pip 8 | - wheel 9 | - pip: 10 | - pip-tools 11 | -------------------------------------------------------------------------------- /fastapi_uws/requests/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi_uws.requests.requests import ( 2 | CreateJobRequest, 3 | UpdateJobDestructionRequest, 4 | UpdateJobExecutionDurationRequest, 5 | UpdateJobPhaseRequest, 6 | UpdateJobRequest, 7 | ) 8 | -------------------------------------------------------------------------------- /fastapi_uws/models/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi_uws.models.models import ( 2 | ErrorSummary, 3 | Jobs, 4 | JobSummary, 5 | Parameter, 6 | Parameters, 7 | ResultReference, 8 | Results, 9 | ShortJobDescription, 10 | ) 11 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | python: 9 | install: 10 | - method: pip 11 | path: . 12 | extra_requirements: 13 | - docs 14 | 15 | formats: [] 16 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | 2 | fastapi-uws 3 | ========= 4 | 5 | ``fastapi-uws`` is an open-source project implementing the IVOA Universal Worker Service (UWS) protocol using the FastAPI web framework. 6 | 7 | The project is designed to be used by IVOA members, service implementors, and developers to help facilitate the development of IVOA-compliant services and clients. 8 | -------------------------------------------------------------------------------- /fastapi_uws/main.py: -------------------------------------------------------------------------------- 1 | """Main module.""" 2 | 3 | from fastapi import FastAPI 4 | 5 | from fastapi_uws.router.uws_router import uws_router 6 | 7 | app = FastAPI( 8 | title="Universal Worker Service (UWS)", 9 | description="The Universal Worker Service (UWS) pattern defines how to manage asynchronous execution of jobs on a service.", 10 | version="1.2", 11 | ) 12 | 13 | app.include_router(uws_router) 14 | -------------------------------------------------------------------------------- /fastapi_uws/workers/base.py: -------------------------------------------------------------------------------- 1 | """Base UWS worker class.""" 2 | 3 | from fastapi_uws.models import JobSummary 4 | 5 | 6 | class BaseUWSWorker: 7 | """Base UWS worker class.""" 8 | 9 | def run(self, job: JobSummary) -> None: 10 | """Run the worker on the given job. 11 | 12 | Args: 13 | job: The job to run the worker on. 14 | """ 15 | pass 16 | 17 | def cancel(self, job: JobSummary) -> None: 18 | """Cancel the worker on the given job. 19 | 20 | Args: 21 | job: The job to cancel the worker on. 22 | """ 23 | pass 24 | -------------------------------------------------------------------------------- /docs/source/pages/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ============= 5 | 6 | Install Using pip 7 | ------------------ 8 | 9 | To install the ``fastapi-uws`` package, you can use pip: 10 | 11 | .. code-block:: bash 12 | 13 | pip install fastapi-uws 14 | 15 | 16 | Install Using Conda 17 | ------------------- 18 | 19 | To install the project using Conda, you can use the provided environment file: 20 | 21 | .. code-block:: bash 22 | 23 | git clone https://github.com/spacetelescope/fastapi-uws.git 24 | cd fastapi-uws 25 | conda env create -f environment.yml 26 | conda activate fastapi-uws 27 | pip install -r requirements.txt 28 | pip install . 29 | 30 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.github/workflows/build_and_run.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: push 4 | 5 | jobs: 6 | build-and-test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.10", "3.11"] 11 | 12 | steps: 13 | - name: Checkout repo 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | cache: "pip" 21 | cache-dependency-path: "**/requirements.txt" 22 | 23 | - name: Install dependencies 24 | run: | 25 | pip install -r requirements.txt 26 | 27 | - name: Run pytest 28 | run: | 29 | pip install .['test'] 30 | pytest --cov=tests/ 31 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import FastAPI 3 | from fastapi.testclient import TestClient 4 | 5 | from fastapi_uws.router.uws_router import uws_router 6 | from fastapi_uws.settings import get_store_instance, get_worker_instance 7 | 8 | 9 | @pytest.fixture 10 | def app() -> FastAPI: 11 | app = FastAPI() 12 | app.include_router(uws_router) 13 | 14 | return app 15 | 16 | 17 | @pytest.fixture 18 | def store(): 19 | """Fixture to get a fresh store instance for each test.""" 20 | test_store = get_store_instance() 21 | test_store.data = {} 22 | return test_store 23 | 24 | 25 | @pytest.fixture 26 | def worker(): 27 | """Fixture to get a fresh worker instance for each test.""" 28 | return get_worker_instance() 29 | 30 | 31 | @pytest.fixture 32 | def client(app) -> TestClient: 33 | return TestClient(app) 34 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | .pytest_cache/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024, Joshua Fraustro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastapi-uws 2 | 3 | `fastapi-uws` is an open-source Python implementation of the IVOA UWS (Universal Worker Service) protocol, based on the [FastAPI](https://fastapi.tiangolo.com/) web framework. 4 | 5 | This package is based off the prototype OpenAPI UWS specification developed as part of the 2024 IVOA Protocol Transition Tiger Team (P3T) effort. The UWS specification is still in development and may change in the future. 6 | 7 | ## Features 8 | 9 | This package is intended to be customizable for a few use cases. Users need only implement handling classes for the UWS job and results stores, executor, and the package will handle the rest. 10 | 11 | The package includes a complete FastAPI application, including the UWS routes and service logic. The package also includes a basic in-memory job store and executor for testing purposes. 12 | 13 | ## Installation 14 | 15 | Until the package is published to PyPI, you can install it directly from the GitHub repository: 16 | 17 | ```bash 18 | git clone https://github.com/spacetelescope/fastapi-uws.git 19 | cd fastapi-uws 20 | conda env create -f environment.yml 21 | conda activate fastapi-uws 22 | pip install -r requirements.txt 23 | pip install . 24 | ``` 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "fastapi-uws" 7 | version = "0.1.0" 8 | authors = [ 9 | {name = "Joshua Fraustro", email="jfraustro@stsci.edu"}, 10 | ] 11 | description = "FastAPI implementation of the IVOA UWS pattern" 12 | readme = "README.md" 13 | requires-python = ">=3.10" 14 | 15 | dependencies = [ 16 | "fastapi==0.103", 17 | "pydantic<2.6", 18 | "fastapi_restful<=0.5.0", 19 | "pydantic-settings", 20 | "typing_inspect" 21 | ] 22 | 23 | classifiers = [ 24 | 25 | "Topic :: Scientific/Engineering :: Astronomy", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | 28 | "License :: OSI Approved :: MIT License", 29 | ] 30 | keywords = [ 31 | "ivoa", 32 | "fastapi", 33 | "uws", 34 | ] 35 | 36 | [project.optional-dependencies] 37 | test = ["pytest", "pytest-cov"] 38 | dev = ["pylint", "ruff", "pre-commit"] 39 | docs = ["sphinx", "sphinx_design", "furo", "sphinx-copybutton", "toml", "sphinx_autodoc_typehints"] 40 | 41 | [project.urls] 42 | Homepage = "https://github.com/spacetelescope/fastapi-uws" 43 | Issues = "https://github.com/spacetelescope/fastapi-uws/issues" 44 | 45 | [tool.ruff] 46 | line-length = 120 47 | extend-exclude = ["docs/conf.py"] 48 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: 14 | - published 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | publish: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: "3.10" 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: ${{ secrets.PYPI_USERNAME_STSCI_MAINTAINER }} 39 | password: ${{ secrets.PYPI_PASSWORD_STSCI_MAINTAINER }} 40 | -------------------------------------------------------------------------------- /fastapi_uws/requests/requests.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Literal, Optional 3 | 4 | from pydantic import BaseModel, Field 5 | 6 | from fastapi_uws.models import Parameter 7 | from fastapi_uws.models.types import PhaseAction 8 | 9 | 10 | class UpdateJobDestructionRequest(BaseModel): 11 | """Body of the request to update the destruction time of a job.""" 12 | 13 | destruction: datetime = Field(alias="DESTRUCTION") 14 | 15 | 16 | class UpdateJobExecutionDurationRequest(BaseModel): 17 | """Body of the request to update the execution duration of a job.""" 18 | 19 | executionduration: int = Field(alias="EXECUTIONDURATION") 20 | 21 | 22 | class UpdateJobPhaseRequest(BaseModel): 23 | """Body of the request to update the phase of a job.""" 24 | 25 | phase: PhaseAction = Field(alias="PHASE") 26 | 27 | 28 | class UpdateJobRequest(BaseModel): 29 | """Body of the request to update a job.""" 30 | 31 | phase: Optional[PhaseAction] = Field(default=None, alias="PHASE") 32 | destruction: Optional[datetime] = Field(default=None, alias="DESTRUCTION") 33 | action: Optional[Literal["DELETE"]] = Field(default=None, alias="ACTION") 34 | 35 | 36 | class CreateJobRequest(BaseModel): 37 | """Body of the request to create a new job.""" 38 | 39 | parameter: list[Parameter] 40 | owner_id: Optional[str] = Field(default=None, alias="ownerId") 41 | run_id: Optional[str] = Field(default=None, alias="runId") 42 | -------------------------------------------------------------------------------- /fastapi_uws/stores/base.py: -------------------------------------------------------------------------------- 1 | """Base class for storing UWS jobs / results.""" 2 | 3 | from fastapi_uws.models import JobSummary, Parameter 4 | 5 | 6 | class BaseUWSStore: 7 | """Base class for storing UWS jobs / results.""" 8 | 9 | def get_job(self, job_id: str) -> JobSummary: 10 | """Get a job by its ID. 11 | 12 | Args: 13 | job_id: The ID of the job to get. 14 | 15 | Returns: 16 | The job with the given ID. 17 | 18 | Raises: 19 | KeyError: If no job with the given ID exists. 20 | """ 21 | raise NotImplementedError 22 | 23 | def get_jobs(self) -> list[JobSummary]: 24 | """Get all jobs. 25 | 26 | Returns: 27 | A list of all jobs. 28 | """ 29 | raise NotImplementedError 30 | 31 | def add_job(self, parameters: list[Parameter], owner_id: str = None, run_id: str = None) -> str: 32 | """Add a job. 33 | 34 | Args: 35 | parameters: The service-specific parameters with which to create the job. 36 | """ 37 | raise NotImplementedError 38 | 39 | def save_job(self, job: JobSummary) -> None: 40 | """Update a job. 41 | 42 | Args: 43 | job: The job to update. 44 | """ 45 | raise NotImplementedError 46 | 47 | def delete_job(self, job_id: str) -> None: 48 | """Delete a job by its ID. 49 | 50 | Args: 51 | job_id: The ID of the job to delete. 52 | """ 53 | raise NotImplementedError 54 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | import sys 10 | from pathlib import Path 11 | 12 | import toml 13 | 14 | ROOT_PATH = Path(__file__).parent.parent.parent 15 | CONF_PATH = ROOT_PATH / "pyproject.toml" 16 | sys.path.insert(0, str(ROOT_PATH.absolute())) 17 | 18 | PYPROJECT = toml.load(CONF_PATH)["project"] 19 | 20 | project = PYPROJECT["name"] 21 | release = PYPROJECT["version"] 22 | author = f"{PYPROJECT['authors'][0]['name']} <{PYPROJECT['authors'][0]['email']}>" 23 | copyright = "2024, Joshua Fraustro" 24 | 25 | # -- General configuration --------------------------------------------------- 26 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 27 | 28 | extensions = [ 29 | "sphinx_design", 30 | "sphinx_copybutton", 31 | "sphinx.ext.intersphinx", 32 | "sphinx.ext.autosectionlabel", 33 | "sphinx.ext.viewcode", 34 | "sphinx.ext.autodoc", 35 | "sphinx.ext.napoleon", 36 | "sphinx_autodoc_typehints", 37 | ] 38 | 39 | autodoc_default_options = { 40 | "no-inherited-members": None, 41 | "exclude-members": "model_config, model_fields", 42 | } 43 | 44 | autodoc_typehints = "description" 45 | autodoc_typehints_format = "short" 46 | autodoc_member_order = "bysource" 47 | 48 | autoclass_content = "class" 49 | 50 | intersphinx_mapping = { 51 | "python": ("https://docs.python.org/3", None), 52 | "lxml": ("https://lxml.de/apidoc/", None), 53 | } 54 | 55 | autosectionlabel_prefix_document = True 56 | 57 | html_theme_options = {} 58 | html_title = PYPROJECT["name"] 59 | 60 | templates_path = ["_templates"] 61 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 62 | 63 | 64 | # -- Options for HTML output ------------------------------------------------- 65 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 66 | 67 | html_theme = "furo" 68 | html_static_path = ["_static"] 69 | -------------------------------------------------------------------------------- /fastapi_uws/stores/mem_store.py: -------------------------------------------------------------------------------- 1 | """Basic in-memory store implementation""" 2 | 3 | from datetime import datetime, timedelta, timezone 4 | from uuid import uuid4 5 | 6 | from fastapi_uws.models import JobSummary, Parameter, Parameters 7 | from fastapi_uws.models.types import ExecutionPhase 8 | from fastapi_uws.stores.base import BaseUWSStore 9 | 10 | RESULT_EXPIRATION_SEC = 24 * 60 * 60 # 1 day in seconds 11 | MAX_EXPIRATION_TIME = RESULT_EXPIRATION_SEC * 3 # the maximum time a job could be updated to 12 | 13 | 14 | class InMemoryStore(BaseUWSStore): 15 | """ 16 | Basic in-memory store implementation 17 | """ 18 | 19 | def __init__(self, **kwargs): 20 | self.data = {} 21 | self.default_expiry = kwargs.get("default_expiry", RESULT_EXPIRATION_SEC) 22 | self.max_expiry = kwargs.get("max_expiry", MAX_EXPIRATION_TIME) 23 | 24 | def get_job(self, job_id): 25 | """Get a job by its ID.""" 26 | current_time = datetime.now(timezone.utc) 27 | 28 | job: JobSummary = self.data.get(job_id) 29 | 30 | destruction = job.destruction_time 31 | if destruction and destruction < current_time: 32 | self.delete_job(job_id) 33 | 34 | return self.data.get(job_id) 35 | 36 | def get_jobs(self): 37 | """Get all jobs.""" 38 | all_jobs: list[JobSummary] = list(self.data.values()) 39 | return all_jobs 40 | 41 | def add_job(self, parameters: list[Parameter], owner_id: str = None, run_id: str = None): 42 | """Add a job to the store""" 43 | job_id = str(uuid4()) 44 | 45 | job = JobSummary( 46 | job_id=job_id, 47 | owner_id=owner_id, 48 | run_id=run_id, 49 | phase=ExecutionPhase.PENDING, 50 | creation_time=datetime.now(timezone.utc), 51 | destruction_time=datetime.now(timezone.utc) + timedelta(seconds=self.default_expiry), 52 | parameters=Parameters(parameter=parameters), 53 | ) 54 | 55 | self.data[job_id] = job 56 | 57 | return job_id 58 | 59 | def save_job(self, job: JobSummary): 60 | """Update a job in the store.""" 61 | 62 | creation_time = job.creation_time 63 | destruction_time = job.destruction_time 64 | 65 | max_destruction_time = creation_time + timedelta(seconds=self.max_expiry) 66 | 67 | job.destruction_time = min(destruction_time, max_destruction_time) 68 | 69 | self.data[job.job_id] = job 70 | 71 | def delete_job(self, job_id): 72 | """Delete a job from the store.""" 73 | if job_id in self.data: 74 | del self.data[job_id] 75 | -------------------------------------------------------------------------------- /fastapi_uws/models/types.py: -------------------------------------------------------------------------------- 1 | """UWS Simple Types""" 2 | 3 | from enum import Enum 4 | 5 | 6 | class ErrorType(str, Enum): 7 | """Enum for error types.""" 8 | 9 | TRANSIENT = "transient" 10 | """The error is transient and the job may be rerun.""" 11 | FATAL = "fatal" 12 | """The error is fatal and the job may not be rerun.""" 13 | 14 | 15 | class UWSVersion(str, Enum): 16 | """The version of the UWS standard that the server complies with.""" 17 | 18 | V1_1 = "1.1" 19 | """The server complies with UWS 1.1.""" 20 | V1_0 = "1.0" 21 | """The server complies with UWS 1.0.""" 22 | 23 | 24 | class ExecutionPhase(str, Enum): 25 | """Enumeration of possible phases of job execution.""" 26 | 27 | PENDING = "PENDING" 28 | """ 29 | The first phase a job is entered into - this is where a job is being set up but no request to run has occurred. 30 | """ 31 | QUEUED = "QUEUED" 32 | """ 33 | A job has been accepted for execution but is waiting in a queue. 34 | """ 35 | EXECUTING = "EXECUTING" 36 | """ 37 | A job is running 38 | """ 39 | COMPLETED = "COMPLETED" 40 | """ 41 | A job has completed successfully. 42 | """ 43 | ERROR = "ERROR" 44 | """ 45 | Some form of error has occurred. 46 | """ 47 | UNKNOWN = "UNKNOWN" 48 | """ 49 | The job is in an unknown state. 50 | """ 51 | HELD = "HELD" 52 | """ 53 | The job is HELD pending execution and will not automatically be executed. 54 | Can occur after a PHASE=RUN request has been made (cf PENDING). 55 | """ 56 | SUSPENDED = "SUSPENDED" 57 | """ 58 | The job has been suspended by the system during execution. 59 | """ 60 | ABORTED = "ABORTED" 61 | """ 62 | The job has been aborted, either by user request or by the server because of lack or overuse of resources. 63 | """ 64 | ARCHIVED = "ARCHIVED" 65 | """ 66 | The job has been archived by the server at destruction time. An archived job may have deleted the results to reclaim 67 | resources, but must have job metadata preserved. This is an alternative that the server may choose in contrast to 68 | completely destroying all record of the job. 69 | """ 70 | 71 | 72 | class PhaseAction(str, Enum): 73 | """Enumeration of parameters when updating the phase of a job.""" 74 | 75 | RUN = "RUN" 76 | ABORT = "ABORT" 77 | SUSPEND = "SUSPEND" 78 | ARCHIVE = "ARCHIVE" 79 | 80 | 81 | class ErrorTypes(str, Enum): 82 | """Enumeration of possible error types.""" 83 | 84 | TRANSIENT = "transient" 85 | """The error is transient and the job may be rerun.""" 86 | FATAL = "fatal" 87 | """The error is fatal and the job may not be rerun.""" 88 | -------------------------------------------------------------------------------- /fastapi_uws/settings.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from typing import Annotated 3 | 4 | from pydantic import Field 5 | from pydantic_settings import BaseSettings, SettingsConfigDict 6 | 7 | from fastapi_uws.stores import BaseUWSStore 8 | from fastapi_uws.workers import BaseUWSWorker 9 | 10 | EXPIRY_DAY = 86400 # 1 day in seconds 11 | 12 | 13 | class StoreSettings(BaseSettings): 14 | """Settings for the UWS store.""" 15 | 16 | default_expiry: int = Field( 17 | EXPIRY_DAY * 3, description="The default expiry time for jobs and results in the store in seconds." 18 | ) 19 | max_expiry: int = Field( 20 | EXPIRY_DAY * 7, description="The maximum expiry time for jobs and results in the store in seconds." 21 | ) 22 | 23 | model_config = SettingsConfigDict( 24 | description="The configuration for the UWS store.", 25 | env_prefix="UWS_STORE_", 26 | ) 27 | 28 | 29 | class WorkerSettings(BaseSettings): 30 | """Settings for the UWS worker.""" 31 | 32 | model_config = SettingsConfigDict( 33 | description="The configuration for the UWS worker.", 34 | env_prefix="UWS_WORKER_", 35 | ) 36 | 37 | 38 | class Settings(BaseSettings): 39 | """Settings for the application.""" 40 | 41 | store_class: str = Field( 42 | "fastapi_uws.stores.mem_store.InMemoryStore", description="The class to use for the UWS store." 43 | ) 44 | worker_class: str = Field("fastapi_uws.workers.BaseUWSWorker", description="The class to use for the UWS worker.") 45 | 46 | worker_settings: Annotated[WorkerSettings, Field(default_factory=WorkerSettings)] 47 | store_settings: Annotated[StoreSettings, Field(default_factory=StoreSettings)] 48 | 49 | model_config = SettingsConfigDict( 50 | description="The configuration for the application.", 51 | env_prefix="UWS_", 52 | cli_parse_args=True, 53 | ) 54 | 55 | 56 | def import_string(dotted_path: str): 57 | """Import a class or function from a dotted path string. 58 | 59 | Args: 60 | dotted_path: The dotted path to the class or function to import. 61 | """ 62 | module_path, class_name = dotted_path.rsplit(".", 1) 63 | module = import_module(module_path) 64 | return getattr(module, class_name) 65 | 66 | 67 | # TODO: making these a singleton for now for testing purposes 68 | # this should be refactored to use dependency injection, maybe? 69 | _store_instance = None 70 | _worker_instance = None 71 | 72 | 73 | def get_store_instance() -> BaseUWSStore: 74 | """Get an instance of the configured UWS store.""" 75 | global _store_instance 76 | if _store_instance is None: 77 | store_class = import_string(app_settings.store_class) 78 | store_settings = app_settings.store_settings.model_dump() 79 | _store_instance = store_class(**store_settings) 80 | return _store_instance 81 | 82 | 83 | def get_worker_instance() -> BaseUWSWorker: 84 | """Get an instance of the configured UWS worker.""" 85 | global _worker_instance 86 | if _worker_instance is None: 87 | worker_class = import_string(app_settings.worker_class) 88 | _worker_instance = worker_class() 89 | return _worker_instance 90 | 91 | 92 | app_settings = Settings() 93 | -------------------------------------------------------------------------------- /fastapi_uws/models/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel 5 | from pydantic.fields import Field 6 | 7 | from fastapi_uws.models.types import ErrorType, ExecutionPhase, UWSVersion 8 | 9 | 10 | class BaseUWSModel(BaseModel): 11 | """Base class for UWS models.""" 12 | 13 | model_config = { 14 | "populate_by_name": True, 15 | } 16 | 17 | 18 | class Parameter(BaseUWSModel): 19 | """A parameter for a UWS job.""" 20 | 21 | id: str = Field(description="The identifier for the parameter.") 22 | by_reference: Optional[bool] = Field( 23 | default=False, 24 | description="Whether the value of the parameter represents a URL to retrieve the actual parameter value.", 25 | ) 26 | value: Optional[str] = Field(default=None, description="The value of the parameter.") 27 | 28 | 29 | class Parameters(BaseUWSModel): 30 | """The parameters of a job.""" 31 | 32 | parameter: list[Parameter] = Field(description="The list of parameters.") 33 | 34 | 35 | class ResultReference(BaseUWSModel): 36 | """A reference to a result.""" 37 | 38 | id: str = Field(description="The identifier for the result.") 39 | href: Optional[str] = Field(default=None, description="The URL of the result.") 40 | mime_type: Optional[str] = Field(default=None, alias="mimeType", description="The MIME type of the result.") 41 | size: Optional[int] = Field(default=None, description="The size of the result in bytes.") 42 | 43 | 44 | class Results(BaseUWSModel): 45 | """The results of a job.""" 46 | 47 | result: Optional[list[ResultReference]] = Field(default=[], description="The list of results.") 48 | 49 | 50 | class ErrorSummary(BaseUWSModel): 51 | """A short summary of an error.""" 52 | 53 | has_detail: bool = Field(default=False, alias="hasDetail", description="Whether a detailed error is available.") 54 | message: Optional[str] = Field(default=None, description="A human-readable message describing the error.") 55 | type: ErrorType = Field(description="Characterization of the type of the error.") 56 | 57 | 58 | class ShortJobDescription(BaseUWSModel): 59 | """A short description of a UWS job.""" 60 | 61 | creation_time: datetime = Field(alias="creationTime", description="The instant at which the job was created.") 62 | href: Optional[str] = Field(default=None, description="The URL of the job.") 63 | job_id: str = Field(alias="jobId", description="The identifier for the job.") 64 | owner_id: Optional[str] = Field(default=None, alias="ownerId", description="The owner (creator) of the job.") 65 | phase: ExecutionPhase = Field(description="The current phase of the job.") 66 | run_id: Optional[str] = Field(default=None, alias="runId", description="A client supplied identifier for the job.") 67 | 68 | 69 | class JobSummary(BaseUWSModel): 70 | """The complete representation of the state of a job.""" 71 | 72 | job_id: str = Field(alias="jobId", description="The identifier for the job.") 73 | run_id: Optional[str] = Field(default=None, alias="runId", description="A client supplied identifier for the job.") 74 | owner_id: Optional[str] = Field(default=None, alias="ownerId", description="The owner (creator) of the job.") 75 | phase: ExecutionPhase = Field(description="The current phase of the job.") 76 | quote: Optional[datetime] = Field(default=None, description="When the job is likely to complete.") 77 | creation_time: datetime = Field(alias="creationTime", description="The instant at which the job was created.") 78 | start_time: Optional[datetime] = Field( 79 | default=None, alias="startTime", description="The instant at which the job started execution." 80 | ) 81 | end_time: Optional[datetime] = Field( 82 | default=None, alias="endTime", description="The instant at which the job finished execution." 83 | ) 84 | execution_duration: Optional[int] = Field( 85 | default=0, 86 | alias="executionDuration", 87 | description="The duration (in seconds) for which the job should be allowed to run. 0 means unlimited.", 88 | ) 89 | destruction_time: Optional[datetime] = Field( 90 | default=None, 91 | alias="destructionTime", 92 | description="The time at which the job, records, and results will be destroyed.", 93 | ) 94 | parameters: Parameters = Field(description="The parameters of the job.") 95 | results: Results = Field(default_factory=Results, description="The results of the job.") 96 | error_summary: Optional[ErrorSummary] = Field( 97 | default=None, alias="errorSummary", description="A summary of any errors that occurred." 98 | ) 99 | job_info: Optional[list[str]] = Field( 100 | default=None, alias="jobInfo", description="Additional information about the job." 101 | ) 102 | version: Optional[UWSVersion] = Field( 103 | default=None, description="The version of the UWS standard that the job complies with." 104 | ) 105 | 106 | 107 | class Jobs(BaseUWSModel): 108 | """The list of job references returned at /jobs.""" 109 | 110 | jobref: list[ShortJobDescription] = Field(description="The list of job references.") 111 | version: UWSVersion = Field(description="The version of the UWS standard that the job references comply with.") 112 | -------------------------------------------------------------------------------- /fastapi_uws/service.py: -------------------------------------------------------------------------------- 1 | """Module implementing the service layer of the application.""" 2 | 3 | import time 4 | from datetime import datetime, timezone 5 | from typing import Literal 6 | 7 | from fastapi import HTTPException 8 | 9 | from fastapi_uws.models import Jobs, Parameter, ShortJobDescription 10 | from fastapi_uws.models.types import ExecutionPhase, PhaseAction 11 | from fastapi_uws.settings import get_store_instance, get_worker_instance 12 | from fastapi_uws.stores import BaseUWSStore 13 | from fastapi_uws.workers import BaseUWSWorker 14 | 15 | 16 | class UWSService: 17 | """Service class implementing the business logic of the application.""" 18 | 19 | def __init__(self): 20 | self.store: BaseUWSStore = get_store_instance() 21 | self.worker: BaseUWSWorker = get_worker_instance() 22 | 23 | def get_job_summary(self, job_id: str, phase: ExecutionPhase = None, wait: int = None): 24 | """Get a job by its ID. 25 | 26 | Args: 27 | job_id: The ID of the job to get. 28 | phase: The phase to monitor for changes. 29 | wait: The maximum time to wait for the phase to change. 30 | 31 | Returns: 32 | The job with the given ID. 33 | 34 | Raises: 35 | KeyError: If no job with the given ID exists. 36 | """ 37 | 38 | summary = self.store.get_job(job_id) 39 | if not summary: 40 | raise HTTPException(404, "Job summary not found") 41 | 42 | if wait is None or summary.phase not in ( 43 | ExecutionPhase.PENDING, 44 | ExecutionPhase.QUEUED, 45 | ExecutionPhase.EXECUTING, 46 | ): 47 | return summary 48 | 49 | if phase is not None: 50 | # if the phase we're monitoring is not the current phase, return immediately 51 | if summary.phase != phase: 52 | return summary 53 | 54 | current_phase = summary.phase 55 | start_time = time.monotonic() 56 | 57 | while (time.monotonic() - start_time) < wait: 58 | new_summary = self.store.get_job(job_id) 59 | 60 | if new_summary is None: 61 | raise HTTPException(404, "Job summary found missing while polling for phase change") 62 | 63 | if new_summary.phase != current_phase: 64 | return new_summary 65 | 66 | time.sleep(0.1) 67 | 68 | # we've reached maximum wait time, just return the job summary 69 | return summary 70 | 71 | def get_job_list(self, phase: list[ExecutionPhase] = None, after: datetime = None, last: int = None): 72 | """Get all jobs. 73 | 74 | Args: 75 | phase: The phase or list of phases to filter by. 76 | after: The date after which to filter. 77 | last: Return the last N jobs. 78 | 79 | Returns: 80 | A list of all jobs in the store, filtered by the given parameters. 81 | """ 82 | 83 | all_jobs = self.store.get_jobs() 84 | 85 | # sort by creation time 86 | all_jobs.sort(key=lambda job: job.creation_time, reverse=True) 87 | 88 | job_list = Jobs(jobref=[], version="1.1") 89 | 90 | for job in all_jobs: 91 | # apply filters 92 | if after: 93 | if job.creation_time < after: 94 | continue 95 | if phase: 96 | job_phase = ExecutionPhase(job.phase).value 97 | if job_phase not in phase: 98 | continue 99 | else: 100 | if job.phase == ExecutionPhase.ARCHIVED: 101 | # jobs with the phase "ARCHIVED" should not be returned for backwards compatibility 102 | # they should be returned if specifically asked for 103 | continue 104 | 105 | job_desc = ShortJobDescription(**job.model_dump()) 106 | job_list.jobref.append(job_desc) 107 | 108 | # limit by last, if specified 109 | if last: 110 | job_list.jobref = job_list.jobref[:last] 111 | 112 | return job_list 113 | 114 | def get_job_detail(self, job_id: str, value: str): 115 | """Return one of the detail elements of the job summary. 116 | 117 | Args: 118 | job_id: The ID of the job to get. 119 | value: The value to return. 120 | 121 | """ 122 | 123 | job = self.store.get_job(job_id) 124 | if not job: 125 | raise HTTPException(404, "Job not found") 126 | 127 | try: 128 | return getattr(job, value) 129 | except AttributeError: 130 | raise HTTPException(400, f"Job detail {value} not found") 131 | 132 | def delete_job(self, job_id): 133 | """Delete a job by its ID. 134 | 135 | Args: 136 | job_id: The ID of the job to delete. 137 | 138 | Raises: 139 | KeyError: If no job with the given ID exists. 140 | """ 141 | 142 | job = self.store.get_job(job_id) 143 | if not job: 144 | raise HTTPException(404, "Job not found") 145 | 146 | self.store.delete_job(job_id) 147 | self.worker.cancel(job_id) 148 | 149 | def create_job(self, parameters: list[Parameter], owner_id: str = None, run_id: str = None) -> str: 150 | """Create a new job. 151 | 152 | Args: 153 | parameters: The service-specific paramters with which to create the job. 154 | 155 | Returns: 156 | The ID of the created job. 157 | """ 158 | 159 | job_id = self.store.add_job(parameters, owner_id, run_id) 160 | return job_id 161 | 162 | def post_update_job( 163 | self, 164 | job_id: str, 165 | phase: PhaseAction = None, 166 | destruction: datetime = None, 167 | action: Literal["DELETE"] = None, 168 | ): 169 | """Update a job. 170 | 171 | Args: 172 | job_id: The ID of the job to update. 173 | phase: The phase in which to put the job. 174 | destruction: The new destruction time of the job. 175 | action: The action to take on the job. Currently only "DELETE" is supported. 176 | """ 177 | 178 | job = self.store.get_job(job_id) 179 | if not job: 180 | raise HTTPException(404, "Job not found") 181 | 182 | if action: 183 | # If they delete the job, there's nothing else to update 184 | self.delete_job(job_id) 185 | return None 186 | if destruction: 187 | if destruction < datetime.now(timezone.utc): 188 | raise HTTPException(400, "Destruction time must be in the future") 189 | job.destruction_time = destruction 190 | if phase: 191 | # Finally, update the phase - we can return right away 192 | self.update_job_phase(job_id, phase) 193 | 194 | return self.get_job_summary(job_id) 195 | 196 | def update_job_phase(self, job_id: str, phase: PhaseAction): 197 | """Update the phase of a job. 198 | 199 | Args: 200 | job_id: The ID of the job to update. 201 | phase: The new phase of the job. 202 | """ 203 | 204 | job = self.store.get_job(job_id) 205 | if not job: 206 | raise HTTPException(404, "Job not found") 207 | 208 | if phase == PhaseAction.RUN: 209 | # Only run the job if PENDING or HELD 210 | if job.phase in (ExecutionPhase.PENDING, ExecutionPhase.HELD): 211 | self.worker.run(job) 212 | elif phase == PhaseAction.ABORT: 213 | self.worker.cancel(job) 214 | job.phase = ExecutionPhase.ABORTED 215 | self.store.save_job(job) 216 | else: 217 | return HTTPException(501, "Phase not supported.") 218 | 219 | def update_job_value(self, job_id: str, value: str, new_value): 220 | """Update a value of a job. 221 | 222 | Args: 223 | job_id: The ID of the job to update. 224 | value: The value to update. 225 | new_value: The new value of the job. 226 | """ 227 | 228 | job = self.store.get_job(job_id) 229 | if not job: 230 | raise HTTPException(404, "Job not found") 231 | 232 | setattr(job, value, new_value) 233 | self.store.save_job(job) 234 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile --allow-unsafe --generate-hashes --no-reuse-hashes 6 | # 7 | annotated-types==0.7.0 \ 8 | --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ 9 | --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 10 | # via pydantic 11 | anyio==4.4.0 \ 12 | --hash=sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94 \ 13 | --hash=sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7 14 | # via starlette 15 | fastapi==0.103.0 \ 16 | --hash=sha256:4166732f5ddf61c33e9fa4664f73780872511e0598d4d5434b1816dc1e6d9421 \ 17 | --hash=sha256:61ab72c6c281205dd0cbaccf503e829a37e0be108d965ac223779a8479243665 18 | # via 19 | # fastapi-restful 20 | # fastapi-uws (pyproject.toml) 21 | fastapi-restful==0.5.0 \ 22 | --hash=sha256:f4215d262aa3fb3d6024e1b45061151f3e38afa41877c6253f434c65690c1ed7 \ 23 | --hash=sha256:f768bfe383fe9ef4affe357572122c8348d6a108eef5e373101c8cde18c0d27d 24 | # via fastapi-uws (pyproject.toml) 25 | idna==3.7 \ 26 | --hash=sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc \ 27 | --hash=sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0 28 | # via anyio 29 | mypy-extensions==1.0.0 \ 30 | --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d \ 31 | --hash=sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782 32 | # via typing-inspect 33 | psutil==5.9.8 \ 34 | --hash=sha256:02615ed8c5ea222323408ceba16c60e99c3f91639b07da6373fb7e6539abc56d \ 35 | --hash=sha256:05806de88103b25903dff19bb6692bd2e714ccf9e668d050d144012055cbca73 \ 36 | --hash=sha256:26bd09967ae00920df88e0352a91cff1a78f8d69b3ecabbfe733610c0af486c8 \ 37 | --hash=sha256:27cc40c3493bb10de1be4b3f07cae4c010ce715290a5be22b98493509c6299e2 \ 38 | --hash=sha256:36f435891adb138ed3c9e58c6af3e2e6ca9ac2f365efe1f9cfef2794e6c93b4e \ 39 | --hash=sha256:50187900d73c1381ba1454cf40308c2bf6f34268518b3f36a9b663ca87e65e36 \ 40 | --hash=sha256:611052c4bc70432ec770d5d54f64206aa7203a101ec273a0cd82418c86503bb7 \ 41 | --hash=sha256:6be126e3225486dff286a8fb9a06246a5253f4c7c53b475ea5f5ac934e64194c \ 42 | --hash=sha256:7d79560ad97af658a0f6adfef8b834b53f64746d45b403f225b85c5c2c140eee \ 43 | --hash=sha256:8cb6403ce6d8e047495a701dc7c5bd788add903f8986d523e3e20b98b733e421 \ 44 | --hash=sha256:8db4c1b57507eef143a15a6884ca10f7c73876cdf5d51e713151c1236a0e68cf \ 45 | --hash=sha256:aee678c8720623dc456fa20659af736241f575d79429a0e5e9cf88ae0605cc81 \ 46 | --hash=sha256:bc56c2a1b0d15aa3eaa5a60c9f3f8e3e565303b465dbf57a1b730e7a2b9844e0 \ 47 | --hash=sha256:bd1184ceb3f87651a67b2708d4c3338e9b10c5df903f2e3776b62303b26cb631 \ 48 | --hash=sha256:d06016f7f8625a1825ba3732081d77c94589dca78b7a3fc072194851e88461a4 \ 49 | --hash=sha256:d16bbddf0693323b8c6123dd804100241da461e41d6e332fb0ba6058f630f8c8 50 | # via fastapi-restful 51 | pydantic==2.5.3 \ 52 | --hash=sha256:b3ef57c62535b0941697cce638c08900d87fcb67e29cfa99e8a68f747f393f7a \ 53 | --hash=sha256:d0caf5954bee831b6bfe7e338c32b9e30c85dfe080c843680783ac2b631673b4 54 | # via 55 | # fastapi 56 | # fastapi-restful 57 | # fastapi-uws (pyproject.toml) 58 | # pydantic-settings 59 | pydantic-core==2.14.6 \ 60 | --hash=sha256:00646784f6cd993b1e1c0e7b0fdcbccc375d539db95555477771c27555e3c556 \ 61 | --hash=sha256:00b1087dabcee0b0ffd104f9f53d7d3eaddfaa314cdd6726143af6bc713aa27e \ 62 | --hash=sha256:0348b1dc6b76041516e8a854ff95b21c55f5a411c3297d2ca52f5528e49d8411 \ 63 | --hash=sha256:036137b5ad0cb0004c75b579445a1efccd072387a36c7f217bb8efd1afbe5245 \ 64 | --hash=sha256:095b707bb287bfd534044166ab767bec70a9bba3175dcdc3371782175c14e43c \ 65 | --hash=sha256:0c08de15d50fa190d577e8591f0329a643eeaed696d7771760295998aca6bc66 \ 66 | --hash=sha256:1302a54f87b5cd8528e4d6d1bf2133b6aa7c6122ff8e9dc5220fbc1e07bffebd \ 67 | --hash=sha256:172de779e2a153d36ee690dbc49c6db568d7b33b18dc56b69a7514aecbcf380d \ 68 | --hash=sha256:1b027c86c66b8627eb90e57aee1f526df77dc6d8b354ec498be9a757d513b92b \ 69 | --hash=sha256:1ce830e480f6774608dedfd4a90c42aac4a7af0a711f1b52f807130c2e434c06 \ 70 | --hash=sha256:1fd0c1d395372843fba13a51c28e3bb9d59bd7aebfeb17358ffaaa1e4dbbe948 \ 71 | --hash=sha256:23598acb8ccaa3d1d875ef3b35cb6376535095e9405d91a3d57a8c7db5d29341 \ 72 | --hash=sha256:24368e31be2c88bd69340fbfe741b405302993242ccb476c5c3ff48aeee1afe0 \ 73 | --hash=sha256:26a92ae76f75d1915806b77cf459811e772d8f71fd1e4339c99750f0e7f6324f \ 74 | --hash=sha256:27e524624eace5c59af499cd97dc18bb201dc6a7a2da24bfc66ef151c69a5f2a \ 75 | --hash=sha256:2b8719037e570639e6b665a4050add43134d80b687288ba3ade18b22bbb29dd2 \ 76 | --hash=sha256:2c5bcf3414367e29f83fd66f7de64509a8fd2368b1edf4351e862910727d3e51 \ 77 | --hash=sha256:2dbe357bc4ddda078f79d2a36fc1dd0494a7f2fad83a0a684465b6f24b46fe80 \ 78 | --hash=sha256:2f5fa187bde8524b1e37ba894db13aadd64faa884657473b03a019f625cee9a8 \ 79 | --hash=sha256:2f6ffc6701a0eb28648c845f4945a194dc7ab3c651f535b81793251e1185ac3d \ 80 | --hash=sha256:314ccc4264ce7d854941231cf71b592e30d8d368a71e50197c905874feacc8a8 \ 81 | --hash=sha256:36026d8f99c58d7044413e1b819a67ca0e0b8ebe0f25e775e6c3d1fabb3c38fb \ 82 | --hash=sha256:36099c69f6b14fc2c49d7996cbf4f87ec4f0e66d1c74aa05228583225a07b590 \ 83 | --hash=sha256:36fa402dcdc8ea7f1b0ddcf0df4254cc6b2e08f8cd80e7010d4c4ae6e86b2a87 \ 84 | --hash=sha256:370ffecb5316ed23b667d99ce4debe53ea664b99cc37bfa2af47bc769056d534 \ 85 | --hash=sha256:3860c62057acd95cc84044e758e47b18dcd8871a328ebc8ccdefd18b0d26a21b \ 86 | --hash=sha256:399ac0891c284fa8eb998bcfa323f2234858f5d2efca3950ae58c8f88830f145 \ 87 | --hash=sha256:3a0b5db001b98e1c649dd55afa928e75aa4087e587b9524a4992316fa23c9fba \ 88 | --hash=sha256:3dcf1978be02153c6a31692d4fbcc2a3f1db9da36039ead23173bc256ee3b91b \ 89 | --hash=sha256:4241204e4b36ab5ae466ecec5c4c16527a054c69f99bba20f6f75232a6a534e2 \ 90 | --hash=sha256:438027a975cc213a47c5d70672e0d29776082155cfae540c4e225716586be75e \ 91 | --hash=sha256:43e166ad47ba900f2542a80d83f9fc65fe99eb63ceec4debec160ae729824052 \ 92 | --hash=sha256:478e9e7b360dfec451daafe286998d4a1eeaecf6d69c427b834ae771cad4b622 \ 93 | --hash=sha256:4ce8299b481bcb68e5c82002b96e411796b844d72b3e92a3fbedfe8e19813eab \ 94 | --hash=sha256:4f86f1f318e56f5cbb282fe61eb84767aee743ebe32c7c0834690ebea50c0a6b \ 95 | --hash=sha256:55a23dcd98c858c0db44fc5c04fc7ed81c4b4d33c653a7c45ddaebf6563a2f66 \ 96 | --hash=sha256:599c87d79cab2a6a2a9df4aefe0455e61e7d2aeede2f8577c1b7c0aec643ee8e \ 97 | --hash=sha256:5aa90562bc079c6c290f0512b21768967f9968e4cfea84ea4ff5af5d917016e4 \ 98 | --hash=sha256:64634ccf9d671c6be242a664a33c4acf12882670b09b3f163cd00a24cffbd74e \ 99 | --hash=sha256:667aa2eac9cd0700af1ddb38b7b1ef246d8cf94c85637cbb03d7757ca4c3fdec \ 100 | --hash=sha256:6a31d98c0d69776c2576dda4b77b8e0c69ad08e8b539c25c7d0ca0dc19a50d6c \ 101 | --hash=sha256:6af4b3f52cc65f8a0bc8b1cd9676f8c21ef3e9132f21fed250f6958bd7223bed \ 102 | --hash=sha256:6c8edaea3089bf908dd27da8f5d9e395c5b4dc092dbcce9b65e7156099b4b937 \ 103 | --hash=sha256:71d72ca5eaaa8d38c8df16b7deb1a2da4f650c41b58bb142f3fb75d5ad4a611f \ 104 | --hash=sha256:72f9a942d739f09cd42fffe5dc759928217649f070056f03c70df14f5770acf9 \ 105 | --hash=sha256:747265448cb57a9f37572a488a57d873fd96bf51e5bb7edb52cfb37124516da4 \ 106 | --hash=sha256:75ec284328b60a4e91010c1acade0c30584f28a1f345bc8f72fe8b9e46ec6a96 \ 107 | --hash=sha256:78d0768ee59baa3de0f4adac9e3748b4b1fffc52143caebddfd5ea2961595277 \ 108 | --hash=sha256:78ee52ecc088c61cce32b2d30a826f929e1708f7b9247dc3b921aec367dc1b23 \ 109 | --hash=sha256:7be719e4d2ae6c314f72844ba9d69e38dff342bc360379f7c8537c48e23034b7 \ 110 | --hash=sha256:7e1f4744eea1501404b20b0ac059ff7e3f96a97d3e3f48ce27a139e053bb370b \ 111 | --hash=sha256:7e90d6cc4aad2cc1f5e16ed56e46cebf4877c62403a311af20459c15da76fd91 \ 112 | --hash=sha256:7ebe3416785f65c28f4f9441e916bfc8a54179c8dea73c23023f7086fa601c5d \ 113 | --hash=sha256:7f41533d7e3cf9520065f610b41ac1c76bc2161415955fbcead4981b22c7611e \ 114 | --hash=sha256:7f5025db12fc6de7bc1104d826d5aee1d172f9ba6ca936bf6474c2148ac336c1 \ 115 | --hash=sha256:86c963186ca5e50d5c8287b1d1c9d3f8f024cbe343d048c5bd282aec2d8641f2 \ 116 | --hash=sha256:86ce5fcfc3accf3a07a729779d0b86c5d0309a4764c897d86c11089be61da160 \ 117 | --hash=sha256:8a14c192c1d724c3acbfb3f10a958c55a2638391319ce8078cb36c02283959b9 \ 118 | --hash=sha256:8b93785eadaef932e4fe9c6e12ba67beb1b3f1e5495631419c784ab87e975670 \ 119 | --hash=sha256:8ed1af8692bd8d2a29d702f1a2e6065416d76897d726e45a1775b1444f5928a7 \ 120 | --hash=sha256:92879bce89f91f4b2416eba4429c7b5ca22c45ef4a499c39f0c5c69257522c7c \ 121 | --hash=sha256:94fc0e6621e07d1e91c44e016cc0b189b48db053061cc22d6298a611de8071bb \ 122 | --hash=sha256:982487f8931067a32e72d40ab6b47b1628a9c5d344be7f1a4e668fb462d2da42 \ 123 | --hash=sha256:9862bf828112e19685b76ca499b379338fd4c5c269d897e218b2ae8fcb80139d \ 124 | --hash=sha256:99b14dbea2fdb563d8b5a57c9badfcd72083f6006caf8e126b491519c7d64ca8 \ 125 | --hash=sha256:9c6a5c79b28003543db3ba67d1df336f253a87d3112dac3a51b94f7d48e4c0e1 \ 126 | --hash=sha256:a19b794f8fe6569472ff77602437ec4430f9b2b9ec7a1105cfd2232f9ba355e6 \ 127 | --hash=sha256:a306cdd2ad3a7d795d8e617a58c3a2ed0f76c8496fb7621b6cd514eb1532cae8 \ 128 | --hash=sha256:a3dde6cac75e0b0902778978d3b1646ca9f438654395a362cb21d9ad34b24acf \ 129 | --hash=sha256:a874f21f87c485310944b2b2734cd6d318765bcbb7515eead33af9641816506e \ 130 | --hash=sha256:a983cca5ed1dd9a35e9e42ebf9f278d344603bfcb174ff99a5815f953925140a \ 131 | --hash=sha256:aca48506a9c20f68ee61c87f2008f81f8ee99f8d7f0104bff3c47e2d148f89d9 \ 132 | --hash=sha256:b2602177668f89b38b9f84b7b3435d0a72511ddef45dc14446811759b82235a1 \ 133 | --hash=sha256:b3e5fe4538001bb82e2295b8d2a39356a84694c97cb73a566dc36328b9f83b40 \ 134 | --hash=sha256:b6ca36c12a5120bad343eef193cc0122928c5c7466121da7c20f41160ba00ba2 \ 135 | --hash=sha256:b89f4477d915ea43b4ceea6756f63f0288941b6443a2b28c69004fe07fde0d0d \ 136 | --hash=sha256:b9a9d92f10772d2a181b5ca339dee066ab7d1c9a34ae2421b2a52556e719756f \ 137 | --hash=sha256:c99462ffc538717b3e60151dfaf91125f637e801f5ab008f81c402f1dff0cd0f \ 138 | --hash=sha256:cb92f9061657287eded380d7dc455bbf115430b3aa4741bdc662d02977e7d0af \ 139 | --hash=sha256:cdee837710ef6b56ebd20245b83799fce40b265b3b406e51e8ccc5b85b9099b7 \ 140 | --hash=sha256:cf10b7d58ae4a1f07fccbf4a0a956d705356fea05fb4c70608bb6fa81d103cda \ 141 | --hash=sha256:d15687d7d7f40333bd8266f3814c591c2e2cd263fa2116e314f60d82086e353a \ 142 | --hash=sha256:d5c28525c19f5bb1e09511669bb57353d22b94cf8b65f3a8d141c389a55dec95 \ 143 | --hash=sha256:d5f916acf8afbcab6bacbb376ba7dc61f845367901ecd5e328fc4d4aef2fcab0 \ 144 | --hash=sha256:dab03ed811ed1c71d700ed08bde8431cf429bbe59e423394f0f4055f1ca0ea60 \ 145 | --hash=sha256:db453f2da3f59a348f514cfbfeb042393b68720787bbef2b4c6068ea362c8149 \ 146 | --hash=sha256:de2a0645a923ba57c5527497daf8ec5df69c6eadf869e9cd46e86349146e5975 \ 147 | --hash=sha256:dea7fcd62915fb150cdc373212141a30037e11b761fbced340e9db3379b892d4 \ 148 | --hash=sha256:dfcbebdb3c4b6f739a91769aea5ed615023f3c88cb70df812849aef634c25fbe \ 149 | --hash=sha256:dfcebb950aa7e667ec226a442722134539e77c575f6cfaa423f24371bb8d2e94 \ 150 | --hash=sha256:e0641b506486f0b4cd1500a2a65740243e8670a2549bb02bc4556a83af84ae03 \ 151 | --hash=sha256:e33b0834f1cf779aa839975f9d8755a7c2420510c0fa1e9fa0497de77cd35d2c \ 152 | --hash=sha256:e4ace1e220b078c8e48e82c081e35002038657e4b37d403ce940fa679e57113b \ 153 | --hash=sha256:e4cf2d5829f6963a5483ec01578ee76d329eb5caf330ecd05b3edd697e7d768a \ 154 | --hash=sha256:e574de99d735b3fc8364cba9912c2bec2da78775eba95cbb225ef7dda6acea24 \ 155 | --hash=sha256:e646c0e282e960345314f42f2cea5e0b5f56938c093541ea6dbf11aec2862391 \ 156 | --hash=sha256:e8a5ac97ea521d7bde7621d86c30e86b798cdecd985723c4ed737a2aa9e77d0c \ 157 | --hash=sha256:eedf97be7bc3dbc8addcef4142f4b4164066df0c6f36397ae4aaed3eb187d8ab \ 158 | --hash=sha256:ef633add81832f4b56d3b4c9408b43d530dfca29e68fb1b797dcb861a2c734cd \ 159 | --hash=sha256:f27207e8ca3e5e021e2402ba942e5b4c629718e665c81b8b306f3c8b1ddbb786 \ 160 | --hash=sha256:f85f3843bdb1fe80e8c206fe6eed7a1caeae897e496542cee499c374a85c6e08 \ 161 | --hash=sha256:f8e81e4b55930e5ffab4a68db1af431629cf2e4066dbdbfef65348b8ab804ea8 \ 162 | --hash=sha256:f96ae96a060a8072ceff4cfde89d261837b4294a4f28b84a28765470d502ccc6 \ 163 | --hash=sha256:fd9e98b408384989ea4ab60206b8e100d8687da18b5c813c11e92fd8212a98e0 \ 164 | --hash=sha256:ffff855100bc066ff2cd3aa4a60bc9534661816b110f0243e59503ec2df38421 165 | # via pydantic 166 | pydantic-settings==2.2.1 \ 167 | --hash=sha256:00b9f6a5e95553590434c0fa01ead0b216c3e10bc54ae02e37f359948643c5ed \ 168 | --hash=sha256:0235391d26db4d2190cb9b31051c4b46882d28a51533f97440867f012d4da091 169 | # via fastapi-uws (pyproject.toml) 170 | python-dotenv==1.0.1 \ 171 | --hash=sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca \ 172 | --hash=sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a 173 | # via pydantic-settings 174 | sniffio==1.3.1 \ 175 | --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ 176 | --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc 177 | # via anyio 178 | starlette==0.27.0 \ 179 | --hash=sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75 \ 180 | --hash=sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91 181 | # via fastapi 182 | typing-extensions==4.12.2 \ 183 | --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ 184 | --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 185 | # via 186 | # fastapi 187 | # pydantic 188 | # pydantic-core 189 | # typing-inspect 190 | typing-inspect==0.9.0 \ 191 | --hash=sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f \ 192 | --hash=sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78 193 | # via fastapi-uws (pyproject.toml) 194 | -------------------------------------------------------------------------------- /fastapi_uws/router/uws_router.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from fastapi import APIRouter, Body, Path, Query 4 | from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse 5 | from fastapi_restful.cbv import cbv 6 | 7 | from fastapi_uws.models import ErrorSummary, Jobs, JobSummary, Parameters, Results 8 | from fastapi_uws.models.types import ExecutionPhase 9 | from fastapi_uws.requests import ( 10 | CreateJobRequest, 11 | UpdateJobDestructionRequest, 12 | UpdateJobExecutionDurationRequest, 13 | UpdateJobPhaseRequest, 14 | UpdateJobRequest, 15 | ) 16 | from fastapi_uws.responses import ErrorMessage 17 | from fastapi_uws.service import UWSService 18 | 19 | uws_router = APIRouter(tags=["UWS"]) 20 | uws_service = UWSService() 21 | 22 | 23 | @cbv(uws_router) 24 | class UWSAPIRouter: 25 | """Router for UWS endpoints.""" 26 | 27 | @uws_router.delete( 28 | "/uws/{job_id}", 29 | responses={ 30 | 303: {"model": Jobs, "description": "Any response containing the UWS job list"}, 31 | 403: {"model": object, "description": "Forbidden"}, 32 | 404: {"model": object, "description": "Job not found"}, 33 | }, 34 | tags=["UWS"], 35 | summary="Deletes the job", 36 | response_model_by_alias=True, 37 | ) 38 | def delete_job( 39 | self, 40 | job_id: str = Path(..., description="Job ID"), 41 | ) -> None: 42 | uws_service.delete_job(job_id) 43 | return RedirectResponse(status_code=303, url="/uws/") # Redirect to the job list 44 | 45 | @uws_router.get( 46 | "/uws/{job_id}/destruction", 47 | responses={ 48 | 200: {"model": datetime, "description": "Success"}, 49 | 403: {"model": object, "description": "Forbidden"}, 50 | 404: {"model": object, "description": "Job not found"}, 51 | }, 52 | tags=["UWS"], 53 | summary="Returns the job destruction time", 54 | response_model_by_alias=True, 55 | ) 56 | def get_job_destruction( 57 | self, 58 | job_id: str = Path(..., description="Job ID"), 59 | ) -> datetime: 60 | return PlainTextResponse(uws_service.get_job_detail(job_id, "destruction_time").isoformat()) 61 | 62 | @uws_router.get( 63 | "/uws/{job_id}/error", 64 | responses={ 65 | 200: {"model": ErrorSummary, "description": "Success"}, 66 | 403: {"model": object, "description": "Forbidden"}, 67 | 404: {"model": object, "description": "Job not found"}, 68 | }, 69 | tags=["UWS"], 70 | summary="Returns the job error summary", 71 | response_model_by_alias=True, 72 | ) 73 | def get_job_error_summary( 74 | self, 75 | job_id: str = Path(..., description="Job ID"), 76 | ) -> ErrorSummary: 77 | return uws_service.get_job_detail(job_id, "error_summary") 78 | 79 | @uws_router.get( 80 | "/uws/{job_id}/executionduration", 81 | responses={ 82 | 200: {"model": int, "description": "Success"}, 83 | 403: {"model": object, "description": "Forbidden"}, 84 | 404: {"model": object, "description": "Job not found"}, 85 | }, 86 | tags=["UWS"], 87 | summary="Returns the job execution duration", 88 | response_model_by_alias=True, 89 | ) 90 | def get_job_execution_duration( 91 | self, 92 | job_id: str = Path(..., description="Job ID"), 93 | ) -> int: 94 | return uws_service.get_job_detail(job_id, "execution_duration") 95 | 96 | @uws_router.get( 97 | "/uws/", 98 | responses={ 99 | 200: {"model": Jobs, "description": "Any response containing the UWS job list"}, 100 | 403: {"model": object, "description": "Forbidden"}, 101 | 404: {"model": object, "description": "Job not found"}, 102 | }, 103 | tags=["UWS"], 104 | summary="Returns the list of UWS jobs", 105 | response_model_by_alias=True, 106 | ) 107 | def get_job_list( 108 | self, 109 | phase: list[ExecutionPhase] = Query( 110 | None, description="Execution phase of the job to filter for", alias="PHASE" 111 | ), 112 | after: datetime = Query(None, description="Return jobs submitted after this date", alias="AFTER"), 113 | last: int = Query(None, description="Return only the last N jobs", alias="LAST", ge=1), 114 | ) -> Jobs: 115 | return uws_service.get_job_list(phase, after, last) 116 | 117 | @uws_router.get( 118 | "/uws/{job_id}/owner", 119 | responses={ 120 | 200: {"model": str, "description": "Success"}, 121 | 403: {"model": object, "description": "Forbidden"}, 122 | 404: {"model": object, "description": "Job not found"}, 123 | }, 124 | tags=["UWS"], 125 | summary="Returns the job owner", 126 | response_model_by_alias=True, 127 | ) 128 | def get_job_owner( 129 | self, 130 | job_id: str = Path(..., description="Job ID"), 131 | ) -> str: 132 | return PlainTextResponse(uws_service.get_job_detail(job_id, "owner_id")) 133 | 134 | @uws_router.get( 135 | "/uws/{job_id}/parameters", 136 | responses={ 137 | 200: {"model": Parameters, "description": "Success"}, 138 | 403: {"model": object, "description": "Forbidden"}, 139 | 404: {"model": object, "description": "Job not found"}, 140 | }, 141 | tags=["UWS"], 142 | summary="Returns the job parameters", 143 | response_model_by_alias=True, 144 | ) 145 | def get_job_parameters( 146 | self, 147 | job_id: str = Path(..., description="Job ID"), 148 | ) -> Parameters: 149 | return uws_service.get_job_detail(job_id, "parameters") 150 | 151 | @uws_router.get( 152 | "/uws/{job_id}/phase", 153 | responses={ 154 | 200: {"model": ExecutionPhase, "description": "Success"}, 155 | 403: {"model": object, "description": "Forbidden"}, 156 | 404: {"model": object, "description": "Job not found"}, 157 | }, 158 | tags=["UWS"], 159 | summary="Returns the job phase", 160 | response_model_by_alias=True, 161 | ) 162 | def get_job_phase( 163 | self, 164 | job_id: str = Path(..., description="Job ID"), 165 | ) -> ExecutionPhase: 166 | return PlainTextResponse(uws_service.get_job_detail(job_id, "phase")) 167 | 168 | @uws_router.get( 169 | "/uws/{job_id}/quote", 170 | responses={ 171 | 200: {"model": datetime, "description": "Success"}, 172 | 403: {"model": object, "description": "Forbidden"}, 173 | 404: {"model": object, "description": "Job not found"}, 174 | }, 175 | tags=["UWS"], 176 | summary="Returns the job quote", 177 | response_model_by_alias=True, 178 | ) 179 | def get_job_quote( 180 | self, 181 | job_id: str = Path(..., description="Job ID"), 182 | ) -> datetime: 183 | return PlainTextResponse(uws_service.get_job_detail(job_id, "quote")) 184 | 185 | @uws_router.get( 186 | "/uws/{job_id}/results", 187 | responses={ 188 | 200: {"model": Results, "description": "Success"}, 189 | 403: {"model": object, "description": "Forbidden"}, 190 | 404: {"model": object, "description": "Job not found"}, 191 | }, 192 | tags=["UWS"], 193 | summary="Returns the job results", 194 | response_model_by_alias=True, 195 | ) 196 | def get_job_results( 197 | self, 198 | job_id: str = Path(..., description="Job ID"), 199 | ) -> Results: 200 | return uws_service.get_job_detail(job_id, "results") 201 | 202 | @uws_router.get( 203 | "/uws/{job_id}", 204 | responses={ 205 | 200: {"model": JobSummary, "description": "Any response containing the job summary"}, 206 | 403: {"model": object, "description": "Forbidden"}, 207 | 404: {"model": object, "description": "Job not found"}, 208 | }, 209 | tags=["UWS"], 210 | summary="Returns the job summary", 211 | response_model_by_alias=True, 212 | ) 213 | def get_job_summary( 214 | self, 215 | job_id: str = Path(..., description="Job ID"), 216 | phase: ExecutionPhase = Query(None, description="Phase of the job to poll for", alias="PHASE"), 217 | wait: int = Query(None, description="Maximum time to wait for the job to change phases.", alias="WAIT", ge=-1), 218 | ) -> JobSummary: 219 | return uws_service.get_job_summary(job_id, phase, wait) 220 | 221 | @uws_router.post( 222 | "/uws/", 223 | responses={ 224 | 303: {"model": JobSummary, "description": "Any response containing the job summary"}, 225 | 403: {"model": object, "description": "Forbidden"}, 226 | 404: {"model": object, "description": "Job not found"}, 227 | }, 228 | tags=["UWS"], 229 | summary="Submits a job", 230 | response_model_by_alias=True, 231 | ) 232 | def post_create_job( 233 | self, 234 | create_job_request: CreateJobRequest = Body(None, description="Initial job values"), 235 | ) -> None: 236 | parameters = create_job_request.parameter 237 | owner_id = create_job_request.owner_id 238 | run_id = create_job_request.run_id 239 | job_id = uws_service.create_job(parameters, owner_id, run_id) 240 | return RedirectResponse(status_code=303, url=f"/uws/{job_id}") 241 | 242 | @uws_router.post( 243 | "/uws/{job_id}", 244 | responses={ 245 | 303: {"model": JobSummary, "description": "Success"}, 246 | 403: {"model": object, "description": "Forbidden"}, 247 | 404: {"model": object, "description": "Job not found"}, 248 | }, 249 | tags=["UWS"], 250 | summary="Update job values", 251 | response_model_by_alias=True, 252 | ) 253 | def post_update_job( 254 | self, 255 | job_id: str = Path(..., description="Job ID"), 256 | update_job_request: UpdateJobRequest = Body(None, description="Values to update"), 257 | ) -> None: 258 | phase = update_job_request.phase 259 | destruction = update_job_request.destruction 260 | action = update_job_request.action 261 | 262 | uws_service.post_update_job(job_id, phase, destruction, action) 263 | 264 | if action == "DELETE": 265 | return RedirectResponse(status_code=303, url="/uws/") 266 | return RedirectResponse(status_code=303, url=f"/uws/{job_id}") 267 | 268 | @uws_router.post( 269 | "/uws/{job_id}/destruction", 270 | responses={ 271 | 303: {"model": JobSummary, "description": "Success"}, 272 | 403: {"model": object, "description": "Forbidden"}, 273 | 404: {"model": object, "description": "Job not found"}, 274 | }, 275 | tags=["UWS"], 276 | summary="Updates the job destruction time", 277 | response_model_by_alias=True, 278 | ) 279 | def post_update_job_destruction( 280 | self, 281 | job_id: str = Path(..., description="Job ID"), 282 | post_update_job_destruction_request: UpdateJobDestructionRequest = Body( 283 | None, description="Destruction time to update" 284 | ), 285 | ) -> None: 286 | uws_service.update_job_value(job_id, "destruction_time", post_update_job_destruction_request.destruction) 287 | return RedirectResponse(status_code=303, url=f"/uws/{job_id}") 288 | 289 | @uws_router.post( 290 | "/uws/{job_id}/executionduration", 291 | responses={ 292 | 303: {"model": JobSummary, "description": "Any response containing the job summary"}, 293 | 403: {"model": object, "description": "Forbidden"}, 294 | 404: {"model": object, "description": "Job not found"}, 295 | }, 296 | tags=["UWS"], 297 | summary="Updates the job execution duration", 298 | response_model_by_alias=True, 299 | ) 300 | def post_update_job_execution_duration( 301 | self, 302 | job_id: str = Path(..., description="Job ID"), 303 | post_update_job_execution_duration_request: UpdateJobExecutionDurationRequest = Body( 304 | None, description="Execution duration to update" 305 | ), 306 | ) -> None: 307 | uws_service.update_job_value( 308 | job_id, "execution_duration", post_update_job_execution_duration_request.executionduration 309 | ) 310 | return RedirectResponse(status_code=303, url=f"/uws/{job_id}") 311 | 312 | @uws_router.post( 313 | "/uws/{job_id}/parameters", 314 | responses={ 315 | 303: {"model": JobSummary, "description": "Success"}, 316 | 403: {"model": object, "description": "Forbidden"}, 317 | 404: {"model": object, "description": "Job not found"}, 318 | }, 319 | tags=["UWS"], 320 | summary="Update job parameters", 321 | response_model_by_alias=True, 322 | ) 323 | def post_update_job_parameters( 324 | self, 325 | job_id: str = Path(..., description="Job ID"), 326 | parameters: Parameters = Body(None, description="Parameters to update"), 327 | ) -> None: 328 | uws_service.update_job_value(job_id, "parameters", parameters) 329 | return RedirectResponse(status_code=303, url=f"/uws/{job_id}") 330 | 331 | @uws_router.post( 332 | "/uws/{job_id}/phase", 333 | responses={ 334 | 303: {"model": JobSummary, "description": "Any response containing the job summary"}, 335 | 403: {"model": object, "description": "Forbidden"}, 336 | 404: {"model": object, "description": "Job not found"}, 337 | }, 338 | tags=["UWS"], 339 | summary="Updates the job phase", 340 | response_model_by_alias=True, 341 | ) 342 | def post_update_job_phase( 343 | self, 344 | job_id: str = Path(..., description="Job ID"), 345 | post_update_job_phase_request: UpdateJobPhaseRequest = Body(None, description="Phase to update"), 346 | ) -> None: 347 | uws_service.update_job_value(job_id, "phase", post_update_job_phase_request.phase) 348 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code 6 | extension-pkg-allow-list=pyodbc,pydantic,lxml 7 | 8 | # Files or directories to be skipped. They should be base names, not paths. 9 | ignore=CVS 10 | 11 | # Files or directories matching the regular expression patterns are skipped. 12 | # The regex matches against base names, not paths. The default value ignores 13 | # Emacs file locks 14 | ignore-patterns=.git/.*, 15 | .idea/.*, 16 | conda 17 | 18 | # Python code to execute, usually for sys.path manipulation such as 19 | # pygtk.require(). 20 | #init-hook= 21 | 22 | # Use multiple processes to speed up Pylint. 23 | jobs=1 24 | 25 | # List of plugins (as comma separated values of python modules names) to load, 26 | # usually to register additional checkers. 27 | load-plugins= 28 | 29 | # Pickle collected data for later comparisons. 30 | persistent=yes 31 | 32 | # Specify a configuration file. 33 | #rcfile= 34 | 35 | # When enabled, pylint would attempt to guess common misconfiguration and emit 36 | # user-friendly hints instead of false-positive error messages 37 | suggestion-mode=yes 38 | 39 | # Allow loading of arbitrary C extensions. Extensions are imported into the 40 | # active Python interpreter and may run arbitrary code. 41 | unsafe-load-any-extension=no 42 | 43 | 44 | [MESSAGES CONTROL] 45 | 46 | # Only show warnings with the listed confidence levels. Leave empty to show 47 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 48 | confidence= 49 | 50 | # Disable the message, report, category or checker with the given id(s). You 51 | # can either give multiple identifiers separated by comma (,) or put this 52 | # option multiple times (only on the command line, not in the configuration 53 | # file where it should appear only once).You can also use "--disable=all" to 54 | # disable everything first and then reenable specific checks. For example, if 55 | # you want to run only the similarities checker, you can use "--disable=all 56 | # --enable=similarities". If you want to run only the classes checker, but have 57 | # no Warning level messages displayed, use"--disable=all --enable=classes 58 | # --disable=W" 59 | disable=unknown-option-value, 60 | unspecified-encoding, # will error if using open() without encoding https://pylint.pycqa.org/en/latest/user_guide/messages/warning/unspecified-encoding.html 61 | consider-using-with, 62 | raw-checker-failed, 63 | bad-inline-option, 64 | locally-disabled, 65 | file-ignored, 66 | suppressed-message, 67 | useless-suppression, 68 | deprecated-pragma, 69 | fixme, 70 | too-few-public-methods, 71 | len-as-condition, 72 | wrong-import-order, 73 | raise-missing-from, # avoid getting this warning for raising Exceptions in FastAPI 74 | assignment-from-none, # avoid warning from auth because of none implementation 75 | 76 | # Duplicate code checker has a bug https://github.com/PyCQA/pylint/issues/4118 77 | # that is keeping it from recognizing the min-similarity-lines settings 78 | # also, it can't be disabled by name! 79 | R0801, 80 | 81 | too-many-lines, # don't care about too many lines in module error 82 | 83 | 84 | # Enable the message, report, category or checker with the given id(s). You can 85 | # either give multiple identifier separated by comma (,) or put this option 86 | # multiple time (only on the command line, not in the configuration file where 87 | # it should appear only once). See also the "--disable" option for examples. 88 | enable=c-extension-no-member 89 | 90 | 91 | [REPORTS] 92 | 93 | # Python expression which should return a note less than 10 (10 is the highest 94 | # note). You have access to the variables errors warning, statement which 95 | # respectively contain the number of errors / warnings messages and the total 96 | # number of statements analyzed. This is used by the global evaluation report 97 | # (RP0004). 98 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 99 | 100 | # Template used to display messages. This is a python new-style format string 101 | # used to format the message information. See doc for all details 102 | #msg-template= 103 | 104 | # Set the output format. Available formats are text, parseable, colorized, json 105 | # and msvs (visual studio).You can also give a reporter class, eg 106 | # mypackage.mymodule.MyReporterClass. 107 | output-format=colorized 108 | 109 | # Tells whether to display a full report or only the messages 110 | reports=no 111 | 112 | # Activate the evaluation score. 113 | score=yes 114 | 115 | 116 | [REFACTORING] 117 | 118 | # Maximum number of nested blocks for function / method body 119 | max-nested-blocks=5 120 | 121 | # Complete name of functions that never returns. When checking for 122 | # inconsistent-return-statements if a never returning function is called then 123 | # it will be considered as an explicit return statement and no message will be 124 | # printed. 125 | never-returning-functions=optparse.Values,sys.exit 126 | 127 | 128 | [BASIC] 129 | 130 | # Naming style matching correct argument names 131 | argument-naming-style=snake_case 132 | 133 | # Regular expression matching correct argument names. Overrides argument- 134 | # naming-style 135 | #argument-rgx= 136 | 137 | # Naming style matching correct attribute names 138 | attr-naming-style=snake_case 139 | 140 | # Regular expression matching correct attribute names. Overrides attr-naming- 141 | # style 142 | #attr-rgx= 143 | 144 | # Bad variable names which should always be refused, separated by a comma 145 | bad-names=foo, 146 | bar, 147 | baz, 148 | toto, 149 | tutu, 150 | tata 151 | 152 | # Naming style matching correct class attribute names 153 | class-attribute-naming-style=any 154 | 155 | # Regular expression matching correct class attribute names. Overrides class- 156 | # attribute-naming-style 157 | #class-attribute-rgx= 158 | 159 | # Naming style matching correct class names 160 | class-naming-style=PascalCase 161 | 162 | # Regular expression matching correct class names. Overrides class-naming-style 163 | #class-rgx= 164 | 165 | # Naming style matching correct constant names 166 | const-naming-style=snake_case 167 | 168 | # Regular expression matching correct constant names. Overrides const-naming- 169 | # style 170 | # Combination of snake-case and upper-case 171 | const-rgx=^((([a-z_][a-z0-9_]*)|(__.*__))|(([A-Z_][A-Z0-9_]*)|(__.*__)))$ 172 | 173 | # Minimum line length for functions/classes that require docstrings, shorter 174 | # ones are exempt. 175 | docstring-min-length=-1 176 | 177 | # Naming style matching correct function names 178 | function-naming-style=snake_case 179 | 180 | # Regular expression matching correct function names. Overrides function- 181 | # naming-style 182 | #function-rgx= 183 | 184 | # Good variable names which should always be accepted, separated by a comma 185 | good-names=i, 186 | j, 187 | k, 188 | ex, 189 | Run, 190 | _, 191 | ra, 192 | db, 193 | ip, 194 | tb, 195 | mf, 196 | 197 | # Include a hint for the correct naming format with invalid-name 198 | include-naming-hint=no 199 | 200 | # Naming style matching correct inline iteration names 201 | inlinevar-naming-style=any 202 | 203 | # Regular expression matching correct inline iteration names. Overrides 204 | # inlinevar-naming-style 205 | #inlinevar-rgx= 206 | 207 | # Naming style matching correct method names 208 | method-naming-style=snake_case 209 | 210 | # Regular expression matching correct method names. Overrides method-naming- 211 | # style 212 | #method-rgx= 213 | 214 | # Naming style matching correct module names 215 | module-naming-style=snake_case 216 | 217 | # Regular expression matching correct module names. Overrides module-naming- 218 | # style 219 | #module-rgx= 220 | 221 | # Colon-delimited sets of names that determine each other's naming style when 222 | # the name regexes allow several styles. 223 | name-group= 224 | 225 | # Regular expression which should only match function or class names that do 226 | # not require a docstring. 227 | no-docstring-rgx=^(_|.*Handler) 228 | 229 | # List of decorators that produce properties, such as abc.abstractproperty. Add 230 | # to this list to register other decorators that produce valid properties. 231 | property-classes=abc.abstractproperty 232 | 233 | # Naming style matching correct variable names 234 | variable-naming-style=snake_case 235 | 236 | # Regular expression matching correct variable names. Overrides variable- 237 | # naming-style 238 | #variable-rgx= 239 | 240 | 241 | [FORMAT] 242 | 243 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 244 | expected-line-ending-format= 245 | 246 | # Regexp for a line that is allowed to be longer than the limit. 247 | ignore-long-lines=^\s*(# )??$ 248 | 249 | # Number of spaces of indent required inside a hanging or continued line. 250 | indent-after-paren=4 251 | 252 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 253 | # tab). 254 | indent-string=' ' 255 | 256 | # Maximum number of characters on a single line. 257 | max-line-length=120 258 | 259 | # Maximum number of lines in a module 260 | max-module-lines=1000 261 | 262 | # Allow the body of a class to be on the same line as the declaration if body 263 | # contains single statement. 264 | single-line-class-stmt=no 265 | 266 | # Allow the body of an if to be on the same line as the test if there is no 267 | # else. 268 | single-line-if-stmt=no 269 | 270 | 271 | [LOGGING] 272 | 273 | # Logging modules to check that the string format arguments are in logging 274 | # function parameter format 275 | logging-modules=logging 276 | 277 | 278 | [MISCELLANEOUS] 279 | 280 | # List of note tags to take in consideration, separated by a comma. 281 | notes=FIXME, 282 | XXX, 283 | TODO 284 | 285 | 286 | [SIMILARITIES] 287 | 288 | # Ignore comments when computing similarities. 289 | ignore-comments=yes 290 | 291 | # Ignore docstrings when computing similarities. 292 | ignore-docstrings=yes 293 | 294 | # Ignore imports when computing similarities. 295 | ignore-imports=yes 296 | 297 | # Minimum lines number of a similarity. 298 | min-similarity-lines=8 299 | 300 | 301 | [SPELLING] 302 | 303 | # Limits count of emitted suggestions for spelling mistakes 304 | max-spelling-suggestions=4 305 | 306 | # Spelling dictionary name. Available dictionaries: none. To make it working 307 | # install python-enchant package. 308 | spelling-dict= 309 | 310 | # List of comma separated words that should not be checked. 311 | spelling-ignore-words= 312 | 313 | # A path to a file that contains private dictionary; one word per line. 314 | spelling-private-dict-file= 315 | 316 | # Tells whether to store unknown words to indicated private dictionary in 317 | # --spelling-private-dict-file option instead of raising a message. 318 | spelling-store-unknown-words=no 319 | 320 | 321 | [TYPECHECK] 322 | 323 | # List of decorators that produce context managers, such as 324 | # contextlib.contextmanager. Add to this list to register other decorators that 325 | # produce valid context managers. 326 | contextmanager-decorators=contextlib.contextmanager 327 | 328 | # List of members which are set dynamically and missed by pylint inference 329 | # system, and so shouldn't trigger E1101 when accessed. Python regular 330 | # expressions are accepted. 331 | generated-members=self.whoami.* 332 | 333 | # Tells whether missing members accessed in mixin class should be ignored. A 334 | # mixin class is detected if its name ends with "mixin" (case insensitive). 335 | ignore-mixin-members=yes 336 | 337 | # This flag controls whether pylint should warn about no-member and similar 338 | # checks whenever an opaque object is returned when inferring. The inference 339 | # can return multiple potential results while evaluating a Python object, but 340 | # some branches might not be evaluated, which results in partial inference. In 341 | # that case, it might be useful to still emit no-member and other checks for 342 | # the rest of the inferred objects. 343 | ignore-on-opaque-inference=yes 344 | 345 | # List of class names for which member attributes should not be checked (useful 346 | # for classes with dynamically set attributes). This supports the use of 347 | # qualified names. 348 | ignored-classes=optparse.Values,thread._local,_thread._local 349 | 350 | # List of module names for which member attributes should not be checked 351 | # (useful for modules/projects where namespaces are manipulated during runtime 352 | # and thus existing member attributes cannot be deduced by static analysis. It 353 | # supports qualified module names, as well as Unix pattern matching. 354 | ignored-modules= 355 | 356 | # Show a hint with possible names when a member name was not found. The aspect 357 | # of finding the hint is based on edit distance. 358 | missing-member-hint=yes 359 | 360 | # The minimum edit distance a name should have in order to be considered a 361 | # similar match for a missing member name. 362 | missing-member-hint-distance=1 363 | 364 | # The total number of similar names that should be taken in consideration when 365 | # showing a hint for a missing member. 366 | missing-member-max-choices=1 367 | 368 | 369 | [VARIABLES] 370 | 371 | # List of additional names supposed to be defined in builtins. Remember that 372 | # you should avoid to define new builtins when possible. 373 | additional-builtins= 374 | 375 | # Tells whether unused global variables should be treated as a violation. 376 | allow-global-unused-variables=yes 377 | 378 | # List of strings which can identify a callback function by name. A callback 379 | # name must start or end with one of those strings. 380 | callbacks=cb_, 381 | _cb 382 | 383 | # A regular expression matching the name of dummy variables (i.e. expectedly 384 | # not used). 385 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 386 | 387 | # Argument names that match this expression will be ignored. Default to name 388 | # with leading underscore 389 | ignored-argument-names=_.*|^ignored_|^unused_ 390 | 391 | # Tells whether we should check for unused import in __init__ files. 392 | init-import=no 393 | 394 | # List of qualified module names which can have objects that can redefine 395 | # builtins. 396 | redefining-builtins-modules=six.moves,past.builtins,future.builtins 397 | 398 | 399 | [CLASSES] 400 | 401 | # List of method names used to declare (i.e. assign) instance attributes. 402 | defining-attr-methods=__init__, 403 | __new__, 404 | setUp 405 | 406 | # List of member names, which should be excluded from the protected access 407 | # warning. 408 | exclude-protected=_asdict, 409 | _fields, 410 | _replace, 411 | _source, 412 | _make 413 | 414 | # List of valid names for the first argument in a class method. 415 | valid-classmethod-first-arg=cls 416 | 417 | # List of valid names for the first argument in a metaclass class method. 418 | valid-metaclass-classmethod-first-arg=mcs 419 | 420 | 421 | [DESIGN] 422 | 423 | # Maximum number of arguments for function / method 424 | max-args=10 425 | 426 | # Maximum number of attributes for a class (see R0902). 427 | max-attributes=20 428 | 429 | # Maximum number of boolean expressions in a if statement 430 | max-bool-expr=5 431 | 432 | # Maximum number of branch for function / method body 433 | max-branches=32 434 | 435 | # Maximum number of locals for function / method body 436 | max-locals=40 437 | 438 | # Maximum number of parents for a class (see R0901). 439 | max-parents=7 440 | 441 | # Maximum number of public methods for a class (see R0904). 442 | max-public-methods=20 443 | 444 | # Maximum number of return / yield for function / method body 445 | max-returns=6 446 | 447 | # Maximum number of statements in function / method body 448 | max-statements=50 449 | 450 | # Minimum number of public methods for a class (see R0903). 451 | min-public-methods=2 452 | 453 | 454 | [IMPORTS] 455 | 456 | # Allow wildcard imports from modules that define __all__. 457 | allow-wildcard-with-all=no 458 | 459 | # Analyse import fallback blocks. This can be used to support both Python 2 and 460 | # 3 compatible code, which means that the block might have code that exists 461 | # only in one or another interpreter, leading to false positives when analysed. 462 | analyse-fallback-blocks=no 463 | 464 | # Deprecated modules which should not be used, separated by a comma 465 | deprecated-modules=optparse,tkinter.tix 466 | 467 | # Create a graph of external dependencies in the given file (report RP0402 must 468 | # not be disabled) 469 | ext-import-graph= 470 | 471 | # Create a graph of every (i.e. internal and external) dependencies in the 472 | # given file (report RP0402 must not be disabled) 473 | import-graph= 474 | 475 | # Create a graph of internal dependencies in the given file (report RP0402 must 476 | # not be disabled) 477 | int-import-graph= 478 | 479 | # Force import order to recognize a module as part of the standard 480 | # compatibility libraries. 481 | known-standard-library= 482 | 483 | # Force import order to recognize a module as part of a third party library. 484 | known-third-party=enchant 485 | 486 | 487 | [EXCEPTIONS] 488 | 489 | # Exceptions that will emit a warning when being caught. Defaults to 490 | # "Exception" 491 | overgeneral-exceptions= 492 | -------------------------------------------------------------------------------- /tests/test_fastapi_uws.py: -------------------------------------------------------------------------------- 1 | """Tests for `fastapi_uws` package.""" 2 | 3 | import os 4 | import time 5 | from datetime import datetime, timedelta, timezone 6 | 7 | from fastapi.testclient import TestClient 8 | 9 | from fastapi_uws.models import ErrorSummary, JobSummary, ResultReference, Results 10 | from fastapi_uws.stores import BaseUWSStore 11 | from fastapi_uws.workers import BaseUWSWorker 12 | 13 | SIMPLE_PARAMETERS = [ 14 | {"value": "SELECT * FROM TAP_SCHEMA.tables", "id": "QUERY", "by_reference": False}, 15 | {"value": "ADQL", "id": "LANG", "by_reference": False}, 16 | ] 17 | 18 | 19 | def build_test_job(client: TestClient, owner_id: str = None, run_id: str = None): 20 | response = client.request( 21 | "POST", 22 | "/uws", 23 | json={"parameter": SIMPLE_PARAMETERS, "ownerId": owner_id, "runId": run_id}, 24 | ) 25 | 26 | assert response.status_code == 200 27 | assert response.json()["jobId"] is not None 28 | return response.json()["jobId"] 29 | 30 | 31 | def assert_id_in_joblist(job_id: str | list[str], job_list: dict): 32 | """Check if a job ID is in a job list""" 33 | if isinstance(job_id, str): 34 | job_id = [job_id] 35 | 36 | for job in job_id: 37 | assert job in [job["jobId"] for job in job_list["jobref"]] 38 | 39 | 40 | class TestUWSAPI: 41 | """Test basic API functionality""" 42 | 43 | def test_create_job(self, client: TestClient): 44 | """Test simple job creation works""" 45 | job_id = build_test_job(client) 46 | 47 | resp = client.request("GET", f"/uws/{job_id}") 48 | 49 | job_summary = resp.json() 50 | 51 | assert resp.status_code == 200 52 | assert job_summary["jobId"] == job_id 53 | assert job_summary["phase"] == "PENDING" 54 | 55 | assert job_id is not None 56 | 57 | def test_delete_job(self, client: TestClient): 58 | """Test job deletion""" 59 | 60 | job_id = build_test_job(client) 61 | 62 | # make sure the job appears in the list 63 | resp = client.request("GET", "/uws") 64 | assert resp.status_code == 200 65 | 66 | job_list = resp.json() 67 | assert_id_in_joblist(job_id, job_list) 68 | 69 | # delete the job 70 | resp = client.request("DELETE", f"/uws/{job_id}", follow_redirects=False) 71 | assert resp.status_code == 303 72 | 73 | # make sure the job is gone 74 | resp = client.request("GET", "/uws") 75 | assert resp.status_code == 200 76 | 77 | job_list = resp.json() 78 | assert job_id not in [job["jobId"] for job in job_list["jobref"]] 79 | 80 | def test_get_destruction(self, client: TestClient): 81 | """Test job destruction time""" 82 | 83 | job_id = build_test_job(client) 84 | 85 | resp = client.request("GET", f"/uws/{job_id}/destruction") 86 | assert resp.status_code == 200 87 | 88 | destruction = resp.text 89 | 90 | destruction_time = datetime.fromisoformat(destruction) 91 | 92 | assert destruction_time > datetime.now(timezone.utc) 93 | 94 | def test_get_error_summary(self, client: TestClient, store: BaseUWSStore): 95 | """Test job error summary""" 96 | 97 | job_id = build_test_job(client) 98 | 99 | resp = client.request("GET", f"/uws/{job_id}") 100 | assert resp.status_code == 200 101 | 102 | job_summary = resp.json() 103 | 104 | assert job_summary["errorSummary"] is None 105 | 106 | # add an error summary 107 | job = JobSummary(**job_summary) 108 | job.error_summary = ErrorSummary(message="Something went wrong", type="fatal") 109 | 110 | store.save_job(job) 111 | 112 | # check the error summary 113 | resp = client.request("GET", f"/uws/{job_id}/error") 114 | assert resp.status_code == 200 115 | 116 | error_summary = resp.json() 117 | 118 | assert error_summary["message"] == "Something went wrong" 119 | assert error_summary["type"] == "fatal" 120 | 121 | def test_get_execution_duration(self, client: TestClient, store: BaseUWSStore): 122 | """Test job execution duration""" 123 | 124 | job_id = build_test_job(client) 125 | 126 | resp = client.request("GET", f"/uws/{job_id}") 127 | assert resp.status_code == 200 128 | 129 | job_summary = resp.json() 130 | 131 | assert job_summary["executionDuration"] == 0 132 | 133 | # add an execution duration 134 | job = JobSummary(**job_summary) 135 | job.execution_duration = 100 136 | 137 | store.save_job(job) 138 | 139 | # check the execution duration 140 | resp = client.request("GET", f"/uws/{job_id}/executionduration") 141 | assert resp.status_code == 200 142 | 143 | execution_duration = resp.json() 144 | 145 | assert execution_duration == 100 146 | 147 | def test_get_owner_id(self, client: TestClient): 148 | """Test job owner ID""" 149 | 150 | job_id = build_test_job(client, owner_id="anonuser") 151 | 152 | resp = client.request("GET", f"/uws/{job_id}/owner") 153 | assert resp.status_code == 200 154 | assert resp.text == "anonuser" 155 | 156 | def test_get_parameters(self, client: TestClient): 157 | """Test fetching job parameters endpoint""" 158 | 159 | job_id = build_test_job(client) 160 | 161 | resp = client.request("GET", f"/uws/{job_id}/parameters") 162 | assert resp.status_code == 200 163 | 164 | parameters = resp.json() 165 | 166 | assert parameters["parameter"] is not None 167 | 168 | param_list = parameters["parameter"] 169 | 170 | assert len(param_list) == len(SIMPLE_PARAMETERS) 171 | 172 | for param in param_list: 173 | assert param["id"] in ["QUERY", "LANG"] 174 | assert param["value"] in ["SELECT * FROM TAP_SCHEMA.tables", "ADQL"] 175 | 176 | def test_get_phase(self, client: TestClient): 177 | """Test getting the job phase""" 178 | 179 | job_id = build_test_job(client) 180 | 181 | resp = client.request("GET", f"/uws/{job_id}/phase") 182 | assert resp.status_code == 200 183 | assert resp.text == "PENDING" 184 | 185 | def test_get_results(self, client: TestClient, store: BaseUWSStore): 186 | """Test getting the job results""" 187 | 188 | job_id = build_test_job(client) 189 | 190 | resp = client.request("GET", f"/uws/{job_id}") 191 | assert resp.status_code == 200 192 | 193 | job_summary = JobSummary(**resp.json()) 194 | 195 | # Add some results 196 | result_list = [ 197 | ResultReference(id="result1", href="/result1"), 198 | ResultReference(id="result2", href="/result2"), 199 | ] 200 | job_summary.results = Results(result=result_list) 201 | store.save_job(job_summary) 202 | 203 | # fetch the results 204 | resp = client.request("GET", f"/uws/{job_id}/results") 205 | assert resp.status_code == 200 206 | 207 | results = resp.json() 208 | 209 | assert results["result"] is not None 210 | assert len(results["result"]) == 2 211 | 212 | assert results["result"][0]["id"] == "result1" 213 | assert results["result"][0]["href"] == "/result1" 214 | assert results["result"][1]["id"] == "result2" 215 | assert results["result"][1]["href"] == "/result2" 216 | 217 | def test_get_quote(self, client: TestClient, store: BaseUWSStore): 218 | """Test get the job quote""" 219 | 220 | job_id = build_test_job(client) 221 | 222 | resp = client.request("GET", f"/uws/{job_id}") 223 | assert resp.status_code == 200 224 | 225 | job_summary = resp.json() 226 | assert job_summary["quote"] is None 227 | 228 | # add a quote 229 | quote_time = datetime.now(timezone.utc) + timedelta(minutes=5) 230 | quote_time = quote_time.isoformat() 231 | 232 | job_summary = JobSummary(**job_summary) 233 | job_summary.quote = quote_time 234 | store.save_job(job_summary) 235 | 236 | # fetch the quote 237 | resp = client.request("GET", f"/uws/{job_id}/quote") 238 | assert resp.status_code == 200 239 | 240 | quote = resp.text 241 | assert quote == quote_time 242 | 243 | 244 | class TestJobList: 245 | """Test fetching and filtering the job list""" 246 | 247 | def test_get_job_list(self, client: TestClient, store: BaseUWSStore): 248 | """Basic job list get""" 249 | 250 | job_ids = [] 251 | 252 | for _ in range(10): 253 | job_ids.append(build_test_job(client)) 254 | 255 | resp = client.request("GET", "/uws") 256 | assert resp.status_code == 200 257 | 258 | job_list = resp.json() 259 | assert len(job_list["jobref"]) == 10 260 | assert_id_in_joblist(job_ids, job_list) 261 | 262 | def test_single_phase_filter(self, client: TestClient, store: BaseUWSStore): 263 | """Test filtering by a single phase""" 264 | 265 | pending_job_id = build_test_job(client) 266 | running_job_id = build_test_job(client) 267 | 268 | # check both jobs are in the list 269 | resp = client.request("GET", "/uws") 270 | assert resp.status_code == 200 271 | 272 | job_list = resp.json() 273 | assert len(job_list["jobref"]) == 2 274 | assert_id_in_joblist([pending_job_id, running_job_id], job_list) 275 | 276 | # move the running job to the running phase 277 | running_job = store.get_job(running_job_id) 278 | running_job.phase = "EXECUTING" 279 | store.save_job(running_job) 280 | 281 | resp = client.request("GET", "/uws", params={"PHASE": "EXECUTING"}) 282 | assert resp.status_code == 200 283 | 284 | job_list = resp.json() 285 | assert len(job_list["jobref"]) == 1 286 | assert_id_in_joblist(running_job_id, job_list) 287 | 288 | def test_multiple_phase_filter(self, client: TestClient, store: BaseUWSStore): 289 | """Test filtering by multiple phases""" 290 | 291 | pending_job_id = build_test_job(client) 292 | running_job_id = build_test_job(client) 293 | completed_job_id = build_test_job(client) 294 | 295 | # check all jobs are in the list 296 | resp = client.request("GET", "/uws") 297 | assert resp.status_code == 200 298 | 299 | job_list = resp.json() 300 | assert len(job_list["jobref"]) == 3 301 | assert_id_in_joblist([pending_job_id, running_job_id, completed_job_id], job_list) 302 | 303 | # move the running job to the running phase and the completed job to the completed phase 304 | running_job = store.get_job(running_job_id) 305 | running_job.phase = "EXECUTING" 306 | store.save_job(running_job) 307 | completed_job = store.get_job(completed_job_id) 308 | completed_job.phase = "COMPLETED" 309 | store.save_job(completed_job) 310 | 311 | # try querying by executing and completed 312 | resp = client.request("GET", "/uws", params={"PHASE": ["EXECUTING", "COMPLETED"]}) 313 | assert resp.status_code == 200 314 | 315 | job_list = resp.json() 316 | 317 | assert len(job_list["jobref"]) == 2 318 | assert_id_in_joblist([running_job_id, completed_job_id], job_list) 319 | 320 | # try by pending and completed 321 | resp = client.request("GET", "/uws", params={"PHASE": ["PENDING", "COMPLETED"]}) 322 | assert resp.status_code == 200 323 | 324 | job_list = resp.json() 325 | 326 | assert len(job_list["jobref"]) == 2 327 | assert_id_in_joblist([pending_job_id, completed_job_id], job_list) 328 | 329 | def test_last_filter(self, client: TestClient): 330 | """Test filtering by last N jobs""" 331 | 332 | job_ids = [] 333 | 334 | for _ in range(10): 335 | job_ids.append(build_test_job(client)) 336 | 337 | resp = client.request("GET", "/uws", params={"LAST": 5}) 338 | assert resp.status_code == 200 339 | 340 | job_list = resp.json() 341 | assert len(job_list["jobref"]) == 5 342 | for job in job_list["jobref"]: 343 | assert job["jobId"] in job_ids[-5:] 344 | 345 | # check that the creationTime order is correct 346 | creation_times = [job["creationTime"] for job in job_list["jobref"]] 347 | assert creation_times == sorted(creation_times, reverse=True) 348 | 349 | def test_after_filter(self, client: TestClient, store: BaseUWSStore): 350 | """Test filtering by jobs after a certain time""" 351 | 352 | before_filter_id = build_test_job(client) 353 | 354 | time.sleep(1) 355 | filter_time = datetime.now(timezone.utc) 356 | time.sleep(1) 357 | 358 | after_filter_id = build_test_job(client) 359 | 360 | # check both jobs are in the list 361 | resp = client.request("GET", "/uws") 362 | assert resp.status_code == 200 363 | 364 | job_list = resp.json() 365 | assert len(job_list["jobref"]) == 2 366 | assert_id_in_joblist([before_filter_id, after_filter_id], job_list) 367 | 368 | resp = client.request("GET", "/uws", params={"AFTER": filter_time.isoformat()}) 369 | assert resp.status_code == 200 370 | 371 | job_list = resp.json() 372 | assert len(job_list["jobref"]) == 1 373 | assert_id_in_joblist(after_filter_id, job_list) 374 | 375 | def test_phase_after_filter(self, client: TestClient, store: BaseUWSStore): 376 | """Test filtering by phase and after time. 377 | 378 | Per UWS spec, PHASE/AFTER should be combined with AND logic. 379 | """ 380 | 381 | pending_job_id = build_test_job(client) 382 | before_filter_running_id = build_test_job(client) 383 | 384 | time.sleep(1) 385 | filter_time = datetime.now(timezone.utc) 386 | time.sleep(1) 387 | 388 | after_filter_running_id = build_test_job(client) 389 | 390 | # check all jobs are in the list 391 | resp = client.request("GET", "/uws") 392 | assert resp.status_code == 200 393 | 394 | job_list = resp.json() 395 | 396 | assert len(job_list["jobref"]) == 3 397 | assert_id_in_joblist([pending_job_id, before_filter_running_id, after_filter_running_id], job_list) 398 | 399 | # move the running jobs to the running phase 400 | before_filter_running_job = store.get_job(before_filter_running_id) 401 | before_filter_running_job.phase = "EXECUTING" 402 | store.save_job(before_filter_running_job) 403 | 404 | after_filter_running_job = store.get_job(after_filter_running_id) 405 | after_filter_running_job.phase = "EXECUTING" 406 | store.save_job(after_filter_running_job) 407 | 408 | # try querying by executing and after filter time 409 | resp = client.request( 410 | "GET", 411 | "/uws", 412 | params={"PHASE": "EXECUTING", "AFTER": filter_time.isoformat()}, 413 | ) 414 | assert resp.status_code == 200 415 | 416 | job_list = resp.json() 417 | 418 | assert len(job_list["jobref"]) == 1 419 | assert_id_in_joblist(after_filter_running_id, job_list) 420 | 421 | def test_phase_last_filter(self, client: TestClient, store: BaseUWSStore): 422 | """Test filtering by phase and last N jobs 423 | 424 | Per UWS spec, LAST should be applied after PHASE/AFTER. 425 | """ 426 | 427 | # 3 running jobs, 1 pending job 428 | # if filtered correctly, we should get the last 2 running jobs, job2 and job3 429 | # if LAST is incorrectly applied before PHASE, we would only get job3 430 | running_job1 = build_test_job(client) 431 | running_job2 = build_test_job(client) 432 | pending_job = build_test_job(client) 433 | running_job3 = build_test_job(client) 434 | 435 | # move the running jobs to the running phase 436 | for job_id in [running_job1, running_job2, running_job3]: 437 | job = store.get_job(job_id) 438 | job.phase = "EXECUTING" 439 | store.save_job(job) 440 | 441 | # check all jobs are in the list 442 | resp = client.request("GET", "/uws") 443 | assert resp.status_code == 200 444 | 445 | job_list = resp.json() 446 | assert len(job_list["jobref"]) == 4 447 | assert_id_in_joblist([running_job1, running_job2, pending_job, running_job3], job_list) 448 | 449 | resp = client.request("GET", "/uws", params={"PHASE": "EXECUTING", "LAST": 2}) 450 | assert resp.status_code == 200 451 | 452 | job_list = resp.json() 453 | 454 | assert len(job_list["jobref"]) == 2 455 | assert_id_in_joblist([running_job2, running_job3], job_list) 456 | 457 | 458 | class TestUpdateJob: 459 | """Tests updating job properties""" 460 | 461 | def test_post_update_job(self, client: TestClient): 462 | """Test updating the /{job} endpoint via POST 463 | 464 | Clients may update PHASE, DESTRUCTION, and submit an ACTION. 465 | """ 466 | 467 | # TODO: figure out test worker config 468 | 469 | job_id = build_test_job(client) 470 | 471 | # move the job to the running phase 472 | resp = client.request("POST", f"/uws/{job_id}", json={"PHASE": "RUN"}, follow_redirects=False) 473 | assert resp.status_code == 303 474 | 475 | # check the phase 476 | resp = client.request("GET", f"/uws/{job_id}/phase") 477 | assert resp.status_code == 200 478 | assert resp.text == "EXECUTING" 479 | 480 | def test_update_job_destruction(self, client: TestClient): 481 | """Test updating the job destruction time""" 482 | 483 | job_id = build_test_job(client) 484 | 485 | # get the current destruction time 486 | resp = client.request("GET", f"/uws/{job_id}/destruction") 487 | assert resp.status_code == 200 488 | 489 | destruction_time = resp.text 490 | 491 | # update the destruction time 492 | new_destruction_time = datetime.now(timezone.utc) + timedelta(minutes=5) 493 | 494 | resp = client.request( 495 | "POST", 496 | f"/uws/{job_id}/destruction", 497 | json={"DESTRUCTION": new_destruction_time.isoformat()}, 498 | follow_redirects=False, 499 | ) 500 | assert resp.status_code == 303 501 | 502 | # check the new destruction time 503 | resp = client.request("GET", f"/uws/{job_id}/destruction") 504 | assert resp.status_code == 200 505 | 506 | destruction_time = resp.text 507 | assert destruction_time == new_destruction_time.isoformat() 508 | 509 | def test_update_job_execution_duration(self, client: TestClient): 510 | """Test updating the job execution duration""" 511 | 512 | job_id = build_test_job(client) 513 | 514 | # get the current execution duration 515 | resp = client.request("GET", f"/uws/{job_id}/executionduration") 516 | assert resp.status_code == 200 517 | 518 | execution_duration = resp.text 519 | 520 | # update the execution duration 521 | new_execution_duration = "100" 522 | 523 | resp = client.request( 524 | "POST", 525 | f"/uws/{job_id}/executionduration", 526 | json={"EXECUTIONDURATION": new_execution_duration}, 527 | follow_redirects=False, 528 | ) 529 | assert resp.status_code == 303 530 | 531 | # check the new execution duration 532 | resp = client.request("GET", f"/uws/{job_id}/executionduration") 533 | assert resp.status_code == 200 534 | 535 | execution_duration = resp.text 536 | assert execution_duration == new_execution_duration 537 | 538 | def test_update_job_phase(self, client: TestClient): 539 | """Test updating the job phase""" 540 | pass 541 | 542 | def test_update_job_parameters(self, client: TestClient): 543 | """Test updating the job parameters""" 544 | pass 545 | 546 | 547 | class Test404Responses: 548 | """Test accessing non-existent resources""" 549 | -------------------------------------------------------------------------------- /openapi.yml: -------------------------------------------------------------------------------- 1 | # Universal Worker Service (UWS) API Pattern 2 | 3 | openapi: '3.0.3' 4 | 5 | info: 6 | title: Universal Worker Service (UWS) 7 | version: '1.2' 8 | description: "The Universal Worker Service (UWS) pattern defines how to manage asynchronous execution of jobs on a service." 9 | contact: 10 | name: 'IVOA Grid and Web Services Working Group' 11 | email: 'grid@ivoa.net' 12 | license: 13 | name: MIT 14 | url: https://opensource.org/licenses/MIT 15 | 16 | servers: 17 | - url: / 18 | 19 | tags: 20 | - name: UWS 21 | description: 'Universal Worker Service' 22 | 23 | paths: 24 | /: 25 | get: 26 | operationId: getJobList 27 | tags: [UWS] 28 | summary: Returns the list of UWS jobs 29 | parameters: 30 | - name: PHASE 31 | in: query 32 | description: 'Execution phase of the job to filter for' 33 | schema: 34 | type: array 35 | items: 36 | $ref: '#/components/schemas/ExecutionPhase' 37 | - name: AFTER 38 | in: query 39 | description: 'Return jobs submitted after this date' 40 | schema: 41 | type: string 42 | format: date-time 43 | - name: LAST 44 | in: query 45 | description: 'Return only the last N jobs' 46 | schema: 47 | type: integer 48 | minimum: 1 49 | responses: 50 | '200': 51 | description: 'Success' 52 | $ref: '#/components/responses/JobListResponse' 53 | '403': 54 | $ref: '#/components/responses/Forbidden' 55 | '404': 56 | $ref: '#/components/responses/JobNotFound' 57 | post: 58 | operationId: postCreateJob 59 | tags: [UWS] 60 | summary: 'Submits a job' 61 | requestBody: 62 | description: 'Job parameters' 63 | required: true 64 | content: 65 | application/json: 66 | schema: 67 | $ref: '#/components/schemas/Parameters' 68 | responses: 69 | '303': 70 | description: "Success" 71 | $ref: '#/components/responses/JobSummaryResponse' 72 | '403': 73 | $ref: '#/components/responses/Forbidden' 74 | '404': 75 | $ref: '#/components/responses/JobNotFound' 76 | /{job_id}: 77 | parameters: 78 | - $ref: '#/components/parameters/job_id' 79 | get: 80 | operationId: getJobSummary 81 | tags: [UWS] 82 | summary: 'Returns the job summary' 83 | parameters: 84 | - name: PHASE 85 | in: query 86 | description: 'Phase of the job to poll for' 87 | schema: 88 | type: string 89 | enum: 90 | - "PENDING" 91 | - "QUEUED" 92 | - "EXECUTING" 93 | example: "PENDING" 94 | - name: WAIT 95 | in: query 96 | description: 'Maximum time to wait for the job to change phases.' 97 | schema: 98 | type: integer 99 | minimum: -1 100 | responses: 101 | '200': 102 | description: Success 103 | $ref: '#/components/responses/JobSummaryResponse' 104 | '403': 105 | $ref: '#/components/responses/Forbidden' 106 | '404': 107 | $ref: '#/components/responses/JobNotFound' 108 | post: 109 | operationId: postUpdateJob 110 | tags: [UWS] 111 | summary: Update job values 112 | parameters: 113 | - $ref: '#/components/parameters/job_id' 114 | requestBody: 115 | description: 'Values to update' 116 | required: true 117 | content: 118 | application/json: 119 | schema: 120 | type: object 121 | properties: 122 | PHASE: 123 | type: string 124 | enum: 125 | - "RUN" 126 | - "ABORT" 127 | - "SUSPEND" 128 | - "ARCHIVE" 129 | DESTRUCTION: 130 | type: string 131 | format: date-time 132 | ACTION: 133 | type: string 134 | enum: 135 | - "DELETE" 136 | responses: 137 | '303': 138 | description: Success 139 | content: 140 | application/json: 141 | schema: 142 | oneOf: 143 | - $ref: '#/components/schemas/Jobs' 144 | - $ref: '#/components/schemas/JobSummary' 145 | '403': 146 | $ref: '#/components/responses/Forbidden' 147 | '404': 148 | $ref: '#/components/responses/JobNotFound' 149 | delete: 150 | operationId: deleteJob 151 | tags: [UWS] 152 | summary: 'Deletes the job' 153 | parameters: 154 | - $ref: '#/components/parameters/job_id' 155 | responses: 156 | '303': 157 | description: Success 158 | $ref: '#/components/responses/JobListResponse' 159 | '403': 160 | $ref: '#/components/responses/Forbidden' 161 | '404': 162 | $ref: '#/components/responses/JobNotFound' 163 | /{job_id}/phase: 164 | parameters: 165 | - $ref: '#/components/parameters/job_id' 166 | get: 167 | operationId: getJobPhase 168 | tags: [UWS] 169 | summary: 'Returns the job phase' 170 | responses: 171 | '200': 172 | description: Success 173 | content: 174 | text/plain: 175 | schema: 176 | $ref: '#/components/schemas/ExecutionPhase' 177 | '403': 178 | $ref: '#/components/responses/Forbidden' 179 | '404': 180 | $ref: '#/components/responses/JobNotFound' 181 | post: 182 | operationId: postUpdateJobPhase 183 | tags: [UWS] 184 | summary: 'Updates the job phase' 185 | requestBody: 186 | description: 'Phase to update' 187 | required: true 188 | content: 189 | application/json: 190 | schema: 191 | type: object 192 | properties: 193 | PHASE: 194 | type: string 195 | enum: 196 | - "RUN" 197 | - "ABORT" 198 | - "SUSPEND" 199 | - "ARCHIVE" 200 | responses: 201 | '303': 202 | description: "Success" 203 | $ref: '#/components/responses/JobSummaryResponse' 204 | '403': 205 | $ref: '#/components/responses/Forbidden' 206 | '404': 207 | $ref: '#/components/responses/JobNotFound' 208 | /{job_id}/executionduration: 209 | parameters: 210 | - $ref: '#/components/parameters/job_id' 211 | get: 212 | operationId: getJobExecutionDuration 213 | tags: [UWS] 214 | summary: 'Returns the job execution duration' 215 | responses: 216 | '200': 217 | description: Success 218 | content: 219 | text/plain: 220 | schema: 221 | type: integer 222 | '403': 223 | $ref: '#/components/responses/Forbidden' 224 | '404': 225 | $ref: '#/components/responses/JobNotFound' 226 | post: 227 | operationId: postUpdateJobExecutionDuration 228 | tags: [UWS] 229 | summary: 'Updates the job execution duration' 230 | requestBody: 231 | description: 'Execution duration to update' 232 | required: true 233 | content: 234 | application/json: 235 | schema: 236 | type: object 237 | properties: 238 | EXECUTIONDURATION: 239 | type: integer 240 | responses: 241 | '303': 242 | description: Success 243 | $ref: '#/components/responses/JobSummaryResponse' 244 | '403': 245 | $ref: '#/components/responses/Forbidden' 246 | '404': 247 | $ref: '#/components/responses/JobNotFound' 248 | /{job_id}/destruction: 249 | parameters: 250 | - $ref: '#/components/parameters/job_id' 251 | get: 252 | operationId: getJobDestruction 253 | tags: [UWS] 254 | summary: 'Returns the job destruction time' 255 | responses: 256 | '200': 257 | description: Success 258 | content: 259 | text/plain: 260 | schema: 261 | type: string 262 | format: date-time 263 | '403': 264 | $ref: '#/components/responses/Forbidden' 265 | '404': 266 | $ref: '#/components/responses/JobNotFound' 267 | post: 268 | operationId: postUpdateJobDestruction 269 | tags: [UWS] 270 | summary: 'Updates the job destruction time' 271 | requestBody: 272 | description: 'Destruction time to update' 273 | required: true 274 | content: 275 | application/json: 276 | schema: 277 | type: object 278 | properties: 279 | DESTRUCTION: 280 | type: string 281 | format: date-time 282 | responses: 283 | '303': 284 | description: Success 285 | content: 286 | application/json: 287 | schema: 288 | $ref: '#/components/schemas/JobSummary' 289 | '403': 290 | $ref: '#/components/responses/Forbidden' 291 | '404': 292 | $ref: '#/components/responses/JobNotFound' 293 | /{job_id}/error: 294 | parameters: 295 | - $ref: '#/components/parameters/job_id' 296 | get: 297 | operationId: getJobErrorSummary 298 | tags: [UWS] 299 | summary: 'Returns the job error summary' 300 | responses: 301 | '200': 302 | description: Success 303 | content: 304 | application/json: 305 | schema: 306 | $ref: '#/components/schemas/ErrorSummary' 307 | '403': 308 | $ref: '#/components/responses/Forbidden' 309 | '404': 310 | $ref: '#/components/responses/JobNotFound' 311 | /{job_id}/quote: 312 | parameters: 313 | - $ref: '#/components/parameters/job_id' 314 | get: 315 | operationId: getJobQuote 316 | tags: [UWS] 317 | summary: 'Returns the job quote' 318 | responses: 319 | '200': 320 | description: Success 321 | content: 322 | text/plain: 323 | schema: 324 | type: string 325 | format: date-time 326 | '403': 327 | $ref: '#/components/responses/Forbidden' 328 | '404': 329 | $ref: '#/components/responses/JobNotFound' 330 | /{job_id}/parameters: 331 | parameters: 332 | - $ref: '#/components/parameters/job_id' 333 | get: 334 | operationId: getJobParameters 335 | tags: [UWS] 336 | summary: 'Returns the job parameters' 337 | responses: 338 | '200': 339 | description: Success 340 | content: 341 | application/json: 342 | schema: 343 | $ref: '#/components/schemas/Parameters' 344 | '403': 345 | $ref: '#/components/responses/Forbidden' 346 | '404': 347 | $ref: '#/components/responses/JobNotFound' 348 | post: 349 | operationId: postUpdateJobParameters 350 | tags: [UWS] 351 | summary: 'Update job parameters' 352 | requestBody: 353 | description: 'Parameters to update' 354 | required: true 355 | content: 356 | application/json: 357 | schema: 358 | $ref: "#/components/schemas/Parameters" 359 | responses: 360 | '303': 361 | description: Success 362 | content: 363 | application/json: 364 | schema: 365 | $ref: '#/components/schemas/JobSummary' 366 | '403': 367 | $ref: '#/components/responses/Forbidden' 368 | '404': 369 | $ref: '#/components/responses/JobNotFound' 370 | /{job_id}/results: 371 | parameters: 372 | - $ref: '#/components/parameters/job_id' 373 | get: 374 | operationId: getJobResults 375 | tags: [UWS] 376 | summary: 'Returns the job results' 377 | responses: 378 | '200': 379 | description: Success 380 | content: 381 | application/json: 382 | schema: 383 | $ref: '#/components/schemas/Results' 384 | '403': 385 | $ref: '#/components/responses/Forbidden' 386 | '404': 387 | $ref: '#/components/responses/JobNotFound' 388 | /{job_id}/owner: 389 | parameters: 390 | - $ref: '#/components/parameters/job_id' 391 | get: 392 | operationId: getJobOwner 393 | tags: [UWS] 394 | summary: 'Returns the job owner' 395 | responses: 396 | '200': 397 | description: Success 398 | content: 399 | text/plain: 400 | schema: 401 | type: string 402 | '403': 403 | $ref: '#/components/responses/Forbidden' 404 | '404': 405 | $ref: '#/components/responses/JobNotFound' 406 | components: 407 | responses: 408 | JobListResponse: 409 | description: 'Any response containing the UWS job list' 410 | content: 411 | application/json: 412 | schema: 413 | $ref: '#/components/schemas/Jobs' 414 | JobSummaryResponse: 415 | description: 'Any response containing the job summary' 416 | content: 417 | application/json: 418 | schema: 419 | $ref: '#/components/schemas/JobSummary' 420 | JobNotFound: 421 | description: 'Job not found' 422 | content: 423 | application/xml: 424 | schema: 425 | $ref: '#/components/schemas/VOTableErrorMessage' 426 | Forbidden: 427 | description: 'Forbidden' 428 | content: 429 | application/xml: 430 | schema: 431 | $ref: '#/components/schemas/VOTableErrorMessage' 432 | parameters: 433 | job_id: 434 | name: job_id 435 | in: path 436 | description: 'Job ID' 437 | required: true 438 | schema: 439 | type: string 440 | schemas: 441 | VOTableErrorMessage: 442 | type: object 443 | title: 'VOTABLE' 444 | description: "An error message in VOTable format" 445 | ShortJobDescription: 446 | type: object 447 | title: shortJobDescription 448 | required: [jobId] 449 | properties: 450 | phase: 451 | $ref: '#/components/schemas/ExecutionPhase' 452 | runId: 453 | type: string 454 | description: | 455 | This is a client supplied identifier - the UWS system 456 | does nothing other than to return it as part of the 457 | description of the job 458 | example: 'JWST-1234' 459 | ownerId: 460 | type: string 461 | nullable: true 462 | description: | 463 | The owner (creator) of the job - this should be 464 | expressed as a string that can be parsed in accordance 465 | with IVOA security standards. If there was no 466 | authenticated job creator then this should be set to 467 | NULL. 468 | example: 'Noirlab/John.Smith' 469 | creationTime: 470 | type: string 471 | format: date-time 472 | nullable: false 473 | description: | 474 | The instant at which the job was created. 475 | id: 476 | type: string 477 | description: | 478 | The identifier for the job 479 | example: 'HSC_XYZ_123' 480 | xml: 481 | attribute: true 482 | type: 483 | type: string 484 | description: | 485 | xlink type 486 | default: simple 487 | xml: 488 | prefix: 'xlink' 489 | namespace: 'http://www.w3.org/1999/xlink' 490 | attribute: true 491 | href: 492 | type: string 493 | description: | 494 | xlink href 495 | example: '.../jobs/HSC_XYZ_123' 496 | xml: 497 | prefix: 'xlink' 498 | namespace: 'http://www.w3.org/1999/xlink' 499 | attribute: true 500 | ExecutionPhase: 501 | type: string 502 | title: executionPhase 503 | description: | 504 | Enumeration of possible phases of job execution 505 | 506 | PENDING: The first phase a job is entered into - this is where 507 | a job is being set up but no request to run has 508 | occurred. 509 | 510 | QUEUED: A job has been accepted for execution but is waiting 511 | in a queue 512 | 513 | EXECUTING: A job is running 514 | 515 | COMPLETED: A job has completed successfully 516 | 517 | ERROR: Some form of error has occurred 518 | 519 | UNKNOWN: The job is in an unknown state. 520 | 521 | HELD: The job is HELD pending execution and will not 522 | automatically be executed - can occur after a 523 | PHASE=RUN request has been made (cf PENDING). 524 | 525 | SUSPENDED: The job has been suspended by the system during 526 | execution 527 | 528 | ABORTED: The job has been aborted, either by user request or by 529 | the server because of lack or overuse of resources. 530 | 531 | ARCHIVED: The job has been archived by the server at destruction time. An archived job 532 | may have deleted the results to reclaim resources, but must have job metadata preserved. 533 | This is an alternative that the server may choose in contrast to completely destroying all record of the job. 534 | enum: 535 | - PENDING 536 | - QUEUED 537 | - EXECUTING 538 | - COMPLETED 539 | - ERROR 540 | - UNKNOWN 541 | - HELD 542 | - SUSPENDED 543 | - ABORTED 544 | - ARCHIVED 545 | JobSummary: 546 | type: object 547 | description: | 548 | The complete representation of the state of a job 549 | title: jobSummary 550 | required: [jobId] 551 | properties: 552 | jobId: 553 | type: string 554 | description: | 555 | The identifier for the job 556 | example: 'HSC_XYZ_123' 557 | runId: 558 | type: string 559 | description: | 560 | this is a client supplied identifier - the UWS system 561 | does nothing other than to return it as part of the 562 | description of the job 563 | example: 'JWST-1234' 564 | ownerId: 565 | type: string 566 | nullable: true 567 | description: | 568 | The owner (creator) of the job - this should be 569 | expressed as a string that can be parsed in accordance 570 | with IVOA security standards. If there was no 571 | authenticated job creator then this should be set to 572 | NULL. 573 | example: 'Noirlab/John.Smith' 574 | phase: 575 | $ref: '#/components/schemas/ExecutionPhase' 576 | quote: 577 | type: string 578 | format: date-time 579 | nullable: true 580 | description: | 581 | A Quote predicts when the job is likely to complete - 582 | returned at /{jobs}/{job_id}/quote "don't know" is 583 | encoded by setting to the XML null value 584 | xsi:nil="true" 585 | creationTime: 586 | type: string 587 | format: date-time 588 | description: | 589 | The instant at which the job was created. 590 | 591 | Note that the version 1.1 of the specification requires that this element be present. 592 | It is optional only in versions 1.x of the schema for backwards compatibility. 593 | 2.0+ versions of the schema will make this formally mandatory in an XML sense. 594 | startTime: 595 | type: string 596 | format: date-time 597 | nullable: true 598 | description: | 599 | The instant at which the job started execution. 600 | endTime: 601 | type: string 602 | format: date-time 603 | nullable: true 604 | description: | 605 | The instant at which the job finished execution. 606 | executionDuration: 607 | type: integer 608 | nullable: false 609 | description: | 610 | The duration (in seconds) for which the job should be 611 | allowed to run - a value of 0 is intended to mean 612 | unlimited - returned at 613 | /{jobs}/{job_id}/executionduration 614 | destruction: 615 | type: string 616 | format: date-time 617 | nullable: true 618 | description: | 619 | The time at which the whole job + records + results 620 | will be destroyed. Returned at /{jobs}/{job_id}/destruction 621 | parameters: 622 | $ref: '#/components/schemas/Parameters' 623 | results: 624 | $ref: '#/components/schemas/Results' 625 | errorSummary: 626 | $ref: '#/components/schemas/ErrorSummary' 627 | jobInfo: 628 | type: string 629 | description: | 630 | This is arbitrary information that can be added to the 631 | job description by the UWS implementation. 632 | version: 633 | $ref: '#/components/schemas/UWSVersion' 634 | UWSVersion: 635 | type: string 636 | title: UWSVersion 637 | description: | 638 | The version of the UWS standard that the server complies with. 639 | enum: 640 | - "1.0" 641 | - "1.1" 642 | xml: 643 | prefix: 'uws' 644 | attribute: true 645 | Job: 646 | type: object 647 | allOf: [ 648 | $ref: '#/components/schemas/JobSummary'] 649 | title: job 650 | description: | 651 | This is the information that is returned 652 | when a GET is made for a single job resource - i.e. 653 | /{jobs}/{job_id} 654 | xml: 655 | name: job 656 | Jobs: 657 | type: object 658 | title: jobs 659 | description: | 660 | The list of job references returned at /(jobs) 661 | 662 | The list presented may be affected by the current security context and may be filtered 663 | properties: 664 | jobref: 665 | type: array 666 | items: 667 | $ref: '#/components/schemas/ShortJobDescription' 668 | version: 669 | $ref: '#/components/schemas/UWSVersion' 670 | xml: 671 | name: jobs 672 | ResultReference: 673 | type: object 674 | title: resultReference 675 | description: | 676 | A reference to a UWS result 677 | required: 678 | - id 679 | properties: 680 | id: 681 | type: string 682 | xml: 683 | attribute: true 684 | reference: 685 | type: string 686 | description: | 687 | The URL that can be used to retrieve the result 688 | xml: 689 | attribute: true 690 | prefix: uws 691 | size: 692 | type: number 693 | xml: 694 | attribute: true 695 | mime-type: 696 | type: string 697 | xml: 698 | attribute: true 699 | Results: 700 | type: object 701 | title: results 702 | description: | 703 | The element returned for /{jobs}/{job_id}/results 704 | properties: 705 | result: 706 | type: array 707 | items: 708 | $ref: '#/components/schemas/ResultReference' 709 | xml: 710 | name: results 711 | ErrorSummary: 712 | type: object 713 | title: errorSummary 714 | description: | 715 | A short summary of an error - a fuller representation of the 716 | error may be retrieved from /{jobs}/{job_id}/error 717 | required: 718 | - type 719 | - hasDetail 720 | properties: 721 | message: 722 | type: string 723 | description: | 724 | A short message describing the error 725 | example: 'Error Message' 726 | hasDetail: 727 | type: boolean 728 | xml: 729 | attribute: true 730 | type: 731 | type: string 732 | description: | 733 | characterization of the type of the error 734 | enum: 735 | - transient 736 | - fatal 737 | xml: 738 | attribute: true 739 | xml: 740 | name: errorSummary 741 | Parameter: 742 | type: object 743 | title: parameter 744 | required: 745 | - id 746 | properties: 747 | value: 748 | type: string 749 | description: | 750 | The value of the parameter 751 | byReference: 752 | type: boolean 753 | default: false 754 | description: | 755 | If this attribute is true then the 756 | content of the parameter represents a URL to retrieve the 757 | actual parameter value. 758 | 759 | It is up to the implementation to decide 760 | if a parameter value cannot be returned directly as the 761 | content - the basic rule is that the representation of 762 | the parameter must allow the whole job element to be 763 | valid XML. If this cannot be achieved then the parameter 764 | value must be returned by reference. 765 | xml: 766 | attribute: true 767 | id: 768 | type: string 769 | description: | 770 | The identifier for the parameter 771 | xml: 772 | attribute: true 773 | isPost: 774 | type: boolean 775 | xml: 776 | attribute: true 777 | xml: 778 | name: parameter 779 | Parameters: 780 | type: object 781 | title: parameters 782 | description: | 783 | The list of input parameters to the job - if 784 | the job description language does not naturally have 785 | parameters, then this list should contain one element which 786 | is the content of the original POST that created the job. 787 | properties: 788 | parameter: 789 | type: array 790 | items: 791 | $ref: '#/components/schemas/Parameter' --------------------------------------------------------------------------------