wraptile 0.2.1.dev1__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/.gitignore +6 -2
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/PKG-INFO +4 -4
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/pyproject.toml +3 -3
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/app.py +11 -1
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/cli.py +2 -2
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/exceptions.py +1 -1
- wraptile-0.4.0/wraptile/main.py +13 -0
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/provider.py +1 -1
- wraptile-0.4.0/wraptile/routes/__init__.py +5 -0
- wraptile-0.2.1.dev1/wraptile/routes.py → wraptile-0.4.0/wraptile/routes/core.py +7 -5
- wraptile-0.4.0/wraptile/routes/dru.py +167 -0
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/airflow/airflow_service.py +8 -2
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/base/service_base.py +13 -2
- wraptile-0.4.0/wraptile/services/local/_testing_stac.py +239 -0
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/local/local_service.py +6 -1
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/local/testing.py +101 -4
- wraptile-0.2.1.dev1/wraptile/main.py +0 -15
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/LICENSE +0 -0
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/README.md +0 -0
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/__init__.py +0 -0
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/constants.py +0 -0
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/logging.py +0 -0
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/py.typed +0 -0
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/__init__.py +0 -0
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/airflow/__init__.py +0 -0
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/airflow/tokens.py +0 -0
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/base/__init__.py +0 -0
- {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/local/__init__.py +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
*.zarr/
|
|
3
3
|
.cov-report
|
|
4
|
+
notebooks/auth-test.ipynb
|
|
4
5
|
|
|
5
|
-
#
|
|
6
|
-
docs/notebooks
|
|
6
|
+
# Legacy generated documentation (excluded from the site)
|
|
7
|
+
docs/notebooks/
|
|
7
8
|
|
|
8
9
|
# Byte-compiled / optimized / DLL files
|
|
9
10
|
__pycache__/
|
|
@@ -202,3 +203,6 @@ cython_debug/
|
|
|
202
203
|
# refer to https://docs.cursor.com/context/ignore-files
|
|
203
204
|
.cursorignore
|
|
204
205
|
.cursorindexingignore
|
|
206
|
+
|
|
207
|
+
# Downloaded font assets
|
|
208
|
+
docs/assets/downloaded/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: wraptile
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: FastAPI server that implements the OGC API - Processes
|
|
5
5
|
Project-URL: Documentation, https://eo-tools.github.io/eozilla
|
|
6
6
|
Project-URL: Repository, https://github.com/eo-tools/eozilla
|
|
@@ -28,8 +28,8 @@ Classifier: Topic :: Software Development
|
|
|
28
28
|
Classifier: Typing :: Typed
|
|
29
29
|
Requires-Python: >=3.11
|
|
30
30
|
Requires-Dist: fastapi
|
|
31
|
-
Requires-Dist: gavicore>=0.
|
|
32
|
-
Requires-Dist: procodile>=0.
|
|
31
|
+
Requires-Dist: gavicore>=0.4.0
|
|
32
|
+
Requires-Dist: procodile>=0.4.0
|
|
33
33
|
Requires-Dist: pydantic
|
|
34
34
|
Requires-Dist: pyyaml
|
|
35
35
|
Requires-Dist: typer<1,>=0.26.0
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "wraptile"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "FastAPI server that implements the OGC API - Processes"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -46,8 +46,8 @@ dependencies = [
|
|
|
46
46
|
"typer >=0.26.0,<1",
|
|
47
47
|
"uvicorn",
|
|
48
48
|
# local dependency
|
|
49
|
-
"gavicore >=0.
|
|
50
|
-
"procodile >=0.
|
|
49
|
+
"gavicore >=0.4.0",
|
|
50
|
+
"procodile >=0.4.0",
|
|
51
51
|
]
|
|
52
52
|
|
|
53
53
|
[project.scripts]
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
6
|
import time
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
7
8
|
from typing import Awaitable, Callable
|
|
8
9
|
|
|
9
10
|
from fastapi import FastAPI, Request, Response
|
|
@@ -11,8 +12,17 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
|
11
12
|
from fastapi.responses import JSONResponse
|
|
12
13
|
|
|
13
14
|
from .exceptions import ServiceException
|
|
15
|
+
from .provider import get_service
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
|
|
18
|
+
@asynccontextmanager
|
|
19
|
+
async def load_app_eagerly(app: FastAPI):
|
|
20
|
+
get_service() # startup ...
|
|
21
|
+
yield # running ...
|
|
22
|
+
# shutdown ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
app = FastAPI(lifespan=load_app_eagerly)
|
|
16
26
|
app.add_middleware(
|
|
17
27
|
CORSMiddleware,
|
|
18
28
|
allow_credentials=False, # we disallow Cookie-Auth (FastAPI default)
|
|
@@ -83,7 +83,7 @@ def new_cli(
|
|
|
83
83
|
`wraptile` help text will be used
|
|
84
84
|
summary: A one-sentence human-readable description of the tool that
|
|
85
85
|
will be used by the default help text. Hence, used only,
|
|
86
|
-
if `help`is not provided. Should end with a dot '.'.
|
|
86
|
+
if `help` is not provided. Should end with a dot '.'.
|
|
87
87
|
version: Optional version string. If not provided, the
|
|
88
88
|
`wraptile` version will be used.
|
|
89
89
|
Return:
|
|
@@ -114,7 +114,7 @@ def new_cli(
|
|
|
114
114
|
from wraptile import __version__ as default_version
|
|
115
115
|
|
|
116
116
|
if version:
|
|
117
|
-
typer.echo(f"{version} ({DEFAULT_NAME} {default_version})")
|
|
117
|
+
typer.echo(f"{name} {version} ({DEFAULT_NAME} {default_version})")
|
|
118
118
|
else:
|
|
119
119
|
typer.echo(default_version)
|
|
120
120
|
return
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 by the Eozilla team and contributors
|
|
2
|
+
# Permissions are hereby granted under the terms of the Apache 2.0 License:
|
|
3
|
+
# https://opensource.org/license/apache-2-0.
|
|
4
|
+
|
|
5
|
+
"""Export the FastAPI application with its Core routes registered.
|
|
6
|
+
|
|
7
|
+
DRU routes are registered separately when a DRU-capable service is loaded.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .app import app
|
|
11
|
+
from .routes import core
|
|
12
|
+
|
|
13
|
+
__all__ = ["app", "core"]
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# generated by gen_server.py:
|
|
2
|
-
# filename:
|
|
2
|
+
# filename: core.py:
|
|
3
3
|
# timestamp: 2026-05-27T07:55:14.099104
|
|
4
4
|
|
|
5
|
+
"""HTTP routes for OGC API - Processes Part 1: Core."""
|
|
6
|
+
|
|
5
7
|
import fastapi
|
|
6
8
|
|
|
7
|
-
from gavicore.models import (
|
|
9
|
+
from gavicore.models.core import (
|
|
8
10
|
Capabilities,
|
|
9
11
|
ConformanceDeclaration,
|
|
10
12
|
JobInfo,
|
|
@@ -14,10 +16,10 @@ from gavicore.models import (
|
|
|
14
16
|
ProcessList,
|
|
15
17
|
ProcessRequest,
|
|
16
18
|
)
|
|
17
|
-
from gavicore.service import Service
|
|
19
|
+
from gavicore.service.core import Service
|
|
18
20
|
|
|
19
|
-
from
|
|
20
|
-
from
|
|
21
|
+
from ..app import app
|
|
22
|
+
from ..provider import get_service
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
# noinspection PyPep8Naming
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Copyright (c) 2026 by the Eozilla team and contributors
|
|
2
|
+
# Permissions are hereby granted under the terms of the Apache 2.0 License:
|
|
3
|
+
# https://opensource.org/license/apache-2-0.
|
|
4
|
+
|
|
5
|
+
"""HTTP routes and application package responses for OGC Processes Part 2: DRU."""
|
|
6
|
+
|
|
7
|
+
import fastapi
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
|
|
10
|
+
from gavicore.models.core import ApiError, ProcessSummary
|
|
11
|
+
from gavicore.models.dru import OgcApplicationPackage
|
|
12
|
+
from gavicore.service.dru import DruService
|
|
13
|
+
|
|
14
|
+
from ..provider import get_service
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OgcApplicationPackageResponse(JSONResponse):
|
|
18
|
+
"""Custom response class to correctly incorporate content type in response."""
|
|
19
|
+
|
|
20
|
+
media_type = "application/ogcapppkg+json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
dru_router = fastapi.APIRouter()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# noinspection PyPep8Naming
|
|
27
|
+
@dru_router.post(
|
|
28
|
+
"/processes",
|
|
29
|
+
response_model=ProcessSummary,
|
|
30
|
+
status_code=201,
|
|
31
|
+
responses={
|
|
32
|
+
"202": {},
|
|
33
|
+
"403": {"model": ApiError},
|
|
34
|
+
"409": {"model": ApiError},
|
|
35
|
+
"415": {"model": ApiError},
|
|
36
|
+
"501": {"model": ApiError},
|
|
37
|
+
},
|
|
38
|
+
response_model_exclude_none=True,
|
|
39
|
+
response_model_exclude_unset=True,
|
|
40
|
+
openapi_extra={
|
|
41
|
+
"requestBody": {
|
|
42
|
+
# NOTE: schemas of the request body below are kept abstract since swagger-ui resolves `$ref`,
|
|
43
|
+
# thus slowing down its web interface considerably.
|
|
44
|
+
# Querying the `openapi.json` directly does not result in slow downs.
|
|
45
|
+
"content": {
|
|
46
|
+
"application/cwl": {
|
|
47
|
+
"schema": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"additionalProperties": True,
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"application/cwl+json": {
|
|
53
|
+
"schema": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"additionalProperties": True,
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"application/cwl+yaml": {
|
|
59
|
+
"schema": {
|
|
60
|
+
"type": "object",
|
|
61
|
+
"additionalProperties": True,
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
"required": True,
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
async def deploy_process(
|
|
70
|
+
request: fastapi.Request,
|
|
71
|
+
response: fastapi.Response,
|
|
72
|
+
w: str | None = None,
|
|
73
|
+
service: DruService = fastapi.Depends(get_service), # noqa B008
|
|
74
|
+
):
|
|
75
|
+
return await service.deploy_process(
|
|
76
|
+
w=w,
|
|
77
|
+
request=request,
|
|
78
|
+
response=response,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# noinspection PyPep8Naming
|
|
83
|
+
@dru_router.put(
|
|
84
|
+
"/processes/{processId}",
|
|
85
|
+
response_model=ProcessSummary,
|
|
86
|
+
responses={
|
|
87
|
+
"201": {"model": ProcessSummary},
|
|
88
|
+
"202": {"model": ProcessSummary},
|
|
89
|
+
"204": {},
|
|
90
|
+
"403": {"model": ApiError},
|
|
91
|
+
"404": {"model": ApiError},
|
|
92
|
+
"415": {"model": ApiError},
|
|
93
|
+
"501": {"model": ApiError},
|
|
94
|
+
},
|
|
95
|
+
response_model_exclude_none=True,
|
|
96
|
+
response_model_exclude_unset=True,
|
|
97
|
+
openapi_extra={
|
|
98
|
+
"requestBody": {
|
|
99
|
+
"content": {
|
|
100
|
+
"application/cwl": {"schema": ProcessSummary.model_json_schema()},
|
|
101
|
+
"application/cwl+json": {"schema": ProcessSummary.model_json_schema()},
|
|
102
|
+
"application/cwl+yaml": {"schema": ProcessSummary.model_json_schema()},
|
|
103
|
+
},
|
|
104
|
+
"required": True,
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
async def replace_process(
|
|
109
|
+
processId: str,
|
|
110
|
+
request: fastapi.Request,
|
|
111
|
+
response: fastapi.Response,
|
|
112
|
+
w: str | None = None,
|
|
113
|
+
service: DruService = fastapi.Depends(get_service), # noqa B008
|
|
114
|
+
):
|
|
115
|
+
return await service.replace_process(
|
|
116
|
+
process_id=processId,
|
|
117
|
+
w=w,
|
|
118
|
+
request=request,
|
|
119
|
+
response=response,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# noinspection PyPep8Naming
|
|
124
|
+
@dru_router.delete(
|
|
125
|
+
"/processes/{processId}",
|
|
126
|
+
status_code=204,
|
|
127
|
+
responses={
|
|
128
|
+
"403": {"model": ApiError},
|
|
129
|
+
"404": {"model": ApiError},
|
|
130
|
+
"501": {"model": ApiError},
|
|
131
|
+
},
|
|
132
|
+
response_model_exclude_none=True,
|
|
133
|
+
response_model_exclude_unset=True,
|
|
134
|
+
)
|
|
135
|
+
async def undeploy_process(
|
|
136
|
+
processId: str,
|
|
137
|
+
request: fastapi.Request,
|
|
138
|
+
response: fastapi.Response,
|
|
139
|
+
service: DruService = fastapi.Depends(get_service), # noqa B008
|
|
140
|
+
):
|
|
141
|
+
return await service.undeploy_process(
|
|
142
|
+
process_id=processId, request=request, response=response
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# noinspection PyPep8Naming
|
|
147
|
+
@dru_router.get(
|
|
148
|
+
"/processes/{processId}/package",
|
|
149
|
+
response_model=OgcApplicationPackage,
|
|
150
|
+
response_class=OgcApplicationPackageResponse,
|
|
151
|
+
responses={
|
|
152
|
+
"403": {"model": ApiError},
|
|
153
|
+
"404": {"model": ApiError},
|
|
154
|
+
"501": {"model": ApiError},
|
|
155
|
+
},
|
|
156
|
+
response_model_exclude_none=True,
|
|
157
|
+
response_model_exclude_unset=True,
|
|
158
|
+
)
|
|
159
|
+
async def get_formal_description(
|
|
160
|
+
processId: str,
|
|
161
|
+
request: fastapi.Request,
|
|
162
|
+
response: fastapi.Response,
|
|
163
|
+
service: DruService = fastapi.Depends(get_service), # noqa B008
|
|
164
|
+
):
|
|
165
|
+
return await service.get_formal_description(
|
|
166
|
+
process_id=processId, request=request, response=response
|
|
167
|
+
)
|
|
@@ -22,7 +22,7 @@ from airflow_client.client.api import DAGApi
|
|
|
22
22
|
from airflow_client.client.api import DagRunApi as DAGRunApi
|
|
23
23
|
from airflow_client.client.api import XComApi
|
|
24
24
|
|
|
25
|
-
from gavicore.models import (
|
|
25
|
+
from gavicore.models.core import (
|
|
26
26
|
InputDescription,
|
|
27
27
|
JobInfo,
|
|
28
28
|
JobList,
|
|
@@ -228,7 +228,13 @@ class AirflowService(ServiceBase):
|
|
|
228
228
|
raise ServiceException(
|
|
229
229
|
e.status, e.reason, exception=e, is_job_problem=True
|
|
230
230
|
) from e
|
|
231
|
-
|
|
231
|
+
job_info: JobInfo = self.dag_run_to_job_info(dag_run)
|
|
232
|
+
# The OGC Api - Processes specifies that the dismiss operation (`DELETE /jobs/{jobId}`)
|
|
233
|
+
# must return a job info with state `"dismissed"`, no matter whether the job was successfully
|
|
234
|
+
# terminated or really already ended (which may take a while). So our interpretation of this
|
|
235
|
+
# status is a weaker "cancellation requested".
|
|
236
|
+
job_info.status = JobStatus.dismissed
|
|
237
|
+
return job_info
|
|
232
238
|
|
|
233
239
|
async def get_job_results(self, job_id: str, *args, **kwargs) -> JobResults:
|
|
234
240
|
dag_id = self.get_dag_id_from_job_id(job_id)
|
|
@@ -12,12 +12,13 @@ import fastapi
|
|
|
12
12
|
import yaml
|
|
13
13
|
from starlette.routing import Route
|
|
14
14
|
|
|
15
|
-
from gavicore.models import (
|
|
15
|
+
from gavicore.models.core import (
|
|
16
16
|
Capabilities,
|
|
17
17
|
ConformanceDeclaration,
|
|
18
18
|
Link,
|
|
19
19
|
)
|
|
20
|
-
from gavicore.service import Service
|
|
20
|
+
from gavicore.service.core import Service
|
|
21
|
+
from gavicore.service.dru import DruService
|
|
21
22
|
from gavicore.util.dynimp import import_value
|
|
22
23
|
from wraptile.constants import ENV_VAR_SERVICE
|
|
23
24
|
from wraptile.exceptions import ServiceConfigException
|
|
@@ -92,6 +93,16 @@ class ServiceBase(Service, ABC):
|
|
|
92
93
|
name="service",
|
|
93
94
|
example="path.to.module:service",
|
|
94
95
|
)
|
|
96
|
+
|
|
97
|
+
if issubclass(service.__class__, DruService):
|
|
98
|
+
from wraptile.app import app as app_module
|
|
99
|
+
from wraptile.routes.dru import dru_router
|
|
100
|
+
|
|
101
|
+
app_module.include_router(dru_router)
|
|
102
|
+
|
|
103
|
+
logging.getLogger("uvicorn").info(
|
|
104
|
+
"Loaded additional routes to support Deploy, Redeploy, Undeploy"
|
|
105
|
+
)
|
|
95
106
|
except (ValueError, TypeError) as e:
|
|
96
107
|
raise ServiceConfigException(f"{e}") from e
|
|
97
108
|
logger = logging.getLogger("uvicorn")
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# Copyright (c) 2026 by the Eozilla team and contributors
|
|
2
|
+
# Permissions are hereby granted under the terms of the Apache 2.0 License.
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
from starlette.staticfiles import StaticFiles
|
|
14
|
+
|
|
15
|
+
from gavicore.models.core import Link
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def output_settings() -> tuple[Path, str]:
|
|
19
|
+
"""Return the testing artifact directory and its advertised HTTP base."""
|
|
20
|
+
directory = Path(os.environ.get("EOZILLA_TESTING_STAC_DIR", ".pixi/testing-stac"))
|
|
21
|
+
base = os.environ.get(
|
|
22
|
+
"EOZILLA_TESTING_STAC_URL", "http://localhost:8008/testing-stac"
|
|
23
|
+
)
|
|
24
|
+
parsed = urlsplit(base)
|
|
25
|
+
if (
|
|
26
|
+
parsed.scheme not in ("http", "https")
|
|
27
|
+
or not parsed.netloc
|
|
28
|
+
or parsed.username
|
|
29
|
+
or parsed.password
|
|
30
|
+
or parsed.query
|
|
31
|
+
or parsed.fragment
|
|
32
|
+
):
|
|
33
|
+
raise ValueError(
|
|
34
|
+
"Testing STAC base must be an absolute HTTP URL without credentials"
|
|
35
|
+
)
|
|
36
|
+
return directory.resolve(), base.rstrip("/")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def mount_artifacts(app: FastAPI) -> None:
|
|
40
|
+
"""Mount only the configured testing artifact directory, without enumeration."""
|
|
41
|
+
directory, _ = output_settings()
|
|
42
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
app.routes[:] = [
|
|
44
|
+
route for route in app.routes if getattr(route, "name", None) != "testing-stac"
|
|
45
|
+
]
|
|
46
|
+
app.mount("/testing-stac", StaticFiles(directory=directory), name="testing-stac")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_outputs(item_count: int, inline: bool) -> tuple[Any, Any, Link, int, None]:
|
|
50
|
+
"""Generate small owned products and equivalent inline/linked STAC outputs."""
|
|
51
|
+
import numpy as np
|
|
52
|
+
import xarray as xr
|
|
53
|
+
|
|
54
|
+
directory, public_base = output_settings()
|
|
55
|
+
run_id = uuid4().hex
|
|
56
|
+
run_dir = directory / run_id
|
|
57
|
+
run_dir.mkdir(parents=True)
|
|
58
|
+
base = f"{public_base}/{run_id}"
|
|
59
|
+
items = []
|
|
60
|
+
for index in range(item_count):
|
|
61
|
+
scene_id = f"scene-{index + 1}"
|
|
62
|
+
west, south = 10 + index * 1.25, 50
|
|
63
|
+
products = run_dir / scene_id / "products"
|
|
64
|
+
products.mkdir(parents=True)
|
|
65
|
+
dataset = xr.Dataset(
|
|
66
|
+
{
|
|
67
|
+
"ndvi": (
|
|
68
|
+
("lat", "lon"),
|
|
69
|
+
np.arange(4, dtype="float32").reshape(2, 2) + index,
|
|
70
|
+
)
|
|
71
|
+
},
|
|
72
|
+
coords={
|
|
73
|
+
"lat": [south + 0.25, south + 0.75],
|
|
74
|
+
"lon": [west + 0.25, west + 0.75],
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
dataset.to_zarr(str(products / "data.zarr"), mode="w", zarr_format=2)
|
|
79
|
+
finally:
|
|
80
|
+
dataset.close()
|
|
81
|
+
(products / "summary.csv").write_text(
|
|
82
|
+
f"scene,mean_ndvi\n{scene_id},{1.5 + index}\n",
|
|
83
|
+
encoding="utf-8",
|
|
84
|
+
newline="\n",
|
|
85
|
+
)
|
|
86
|
+
item = _item(scene_id, base, products, west, south, index)
|
|
87
|
+
_write_preview(
|
|
88
|
+
products / "preview.png", item["geometry"]["coordinates"][0], index
|
|
89
|
+
)
|
|
90
|
+
items.append(item)
|
|
91
|
+
_write_json(run_dir / f"{scene_id}.json", item)
|
|
92
|
+
collection = {
|
|
93
|
+
"type": "FeatureCollection",
|
|
94
|
+
"features": deepcopy(items),
|
|
95
|
+
"links": [
|
|
96
|
+
{
|
|
97
|
+
"rel": "self",
|
|
98
|
+
"href": f"{base}/items.json",
|
|
99
|
+
"type": "application/geo+json",
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
"testing:complete": True,
|
|
103
|
+
}
|
|
104
|
+
_write_json(run_dir / "items.json", collection)
|
|
105
|
+
report = Link(href=f"{base}/scene-1/products/summary.csv", type="text/csv")
|
|
106
|
+
if inline:
|
|
107
|
+
return items[0], collection, report, item_count, None
|
|
108
|
+
return (
|
|
109
|
+
Link(href=f"{base}/scene-1.json", type="application/geo+json"),
|
|
110
|
+
Link(href=f"{base}/items.json", type="application/geo+json"),
|
|
111
|
+
report,
|
|
112
|
+
item_count,
|
|
113
|
+
None,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _write_json(path: Path, document: dict[str, Any]) -> None:
|
|
118
|
+
path.write_text(json.dumps(document, indent=2), encoding="utf-8")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _footprint(west: float, south: float, index: int) -> list[list[float]]:
|
|
122
|
+
"""Return a closed, counterclockwise footprint resembling a clipped scene."""
|
|
123
|
+
outlines = (
|
|
124
|
+
((0, 0.12), (0.18, 0), (0.84, 0.06), (1, 0.82), (0.78, 1), (0.06, 0.88)),
|
|
125
|
+
((0, 0.22), (0.26, 0), (0.93, 0.16), (1, 0.73), (0.68, 1), (0.12, 0.91)),
|
|
126
|
+
)
|
|
127
|
+
ring = [[west + x, south + y] for x, y in outlines[index % len(outlines)]]
|
|
128
|
+
return ring + [ring[0].copy()]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _write_preview(path: Path, ring: list[list[float]], index: int) -> None:
|
|
132
|
+
"""Draw deterministic, north-up illustrative vegetation and water scenery.
|
|
133
|
+
|
|
134
|
+
This is independent of the tiny 2x2 example array, not satellite imagery
|
|
135
|
+
or a scientific NDVI rendering. Transparent margins match the STAC
|
|
136
|
+
footprint.
|
|
137
|
+
"""
|
|
138
|
+
import numpy as np
|
|
139
|
+
from PIL import Image, ImageDraw
|
|
140
|
+
|
|
141
|
+
size = 384
|
|
142
|
+
rng = np.random.default_rng(2026 + index)
|
|
143
|
+
terrain = np.zeros((size, size), dtype="float64")
|
|
144
|
+
for resolution, weight in ((5, 0.5), (12, 0.25), (32, 0.15), (96, 0.1)):
|
|
145
|
+
noise = (rng.random((resolution, resolution)) * 255).astype("uint8")
|
|
146
|
+
with Image.fromarray(noise) as image:
|
|
147
|
+
with image.resize((size, size), Image.Resampling.BICUBIC) as smooth:
|
|
148
|
+
terrain += np.asarray(smooth) / 255 * weight
|
|
149
|
+
|
|
150
|
+
y, x = np.mgrid[0:size, 0:size] / (size - 1)
|
|
151
|
+
# Small agricultural parcels soften into forested hills at higher values.
|
|
152
|
+
parcels = rng.uniform(0.12, 0.7, (24, 24))
|
|
153
|
+
field_x = (0.94 * x + 0.34 * y + 0.015 * np.sin(15 * y)) % 1
|
|
154
|
+
field_y = (0.94 * y - 0.34 * x + 0.015 * np.sin(12 * x)) % 1
|
|
155
|
+
field = parcels[(field_y * 24).astype(int), (field_x * 24).astype(int)]
|
|
156
|
+
forest = np.clip((terrain - 0.46) * 15, 0, 1)
|
|
157
|
+
vegetation = np.clip((1 - forest) * field + forest * (0.65 + 0.35 * terrain), 0, 1)
|
|
158
|
+
palette = np.array([[219, 208, 145], [168, 190, 104], [83, 139, 64], [26, 79, 43]])
|
|
159
|
+
colours = np.stack(
|
|
160
|
+
[
|
|
161
|
+
np.interp(vegetation, [0, 0.35, 0.6, 1], palette[:, channel])
|
|
162
|
+
for channel in range(3)
|
|
163
|
+
],
|
|
164
|
+
axis=-1,
|
|
165
|
+
)
|
|
166
|
+
# A meandering river and a lake provide recognisable landscape structure.
|
|
167
|
+
river_x = 0.48 + 0.13 * np.sin(8 * y + index) + 0.035 * np.sin(23 * y)
|
|
168
|
+
lake_edge = 1 + 0.16 * np.sin(35 * x) * np.cos(42 * y)
|
|
169
|
+
water = (abs(x - river_x) < 0.009) | (
|
|
170
|
+
((x - 0.64) / 0.10) ** 2 + ((y - 0.28) / 0.065) ** 2 < lake_edge
|
|
171
|
+
)
|
|
172
|
+
shade = 0.85 + 0.3 * terrain + rng.normal(0, 0.045, (size, size))
|
|
173
|
+
colours = np.clip(colours * shade[..., None], 0, 255).astype("uint8")
|
|
174
|
+
colours[water] = np.clip(
|
|
175
|
+
np.array([55, 105, 125]) * shade[water, None], 0, 255
|
|
176
|
+
).astype("uint8")
|
|
177
|
+
west, south = min(p[0] for p in ring), min(p[1] for p in ring)
|
|
178
|
+
east, north = max(p[0] for p in ring), max(p[1] for p in ring)
|
|
179
|
+
pixels = [
|
|
180
|
+
(
|
|
181
|
+
(lon - west) / (east - west) * (size - 1),
|
|
182
|
+
(north - lat) / (north - south) * (size - 1),
|
|
183
|
+
)
|
|
184
|
+
for lon, lat in ring
|
|
185
|
+
]
|
|
186
|
+
with Image.new("L", (size, size)) as mask:
|
|
187
|
+
ImageDraw.Draw(mask).polygon(pixels, fill=255)
|
|
188
|
+
with Image.fromarray(colours) as image:
|
|
189
|
+
image.putalpha(mask)
|
|
190
|
+
image.save(path)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _item(
|
|
194
|
+
scene_id: str, base: str, directory: Path, west: float, south: float, index: int
|
|
195
|
+
) -> dict[str, Any]:
|
|
196
|
+
products = f"{scene_id}/products"
|
|
197
|
+
return {
|
|
198
|
+
"type": "Feature",
|
|
199
|
+
"stac_version": "1.1.0",
|
|
200
|
+
"stac_extensions": [
|
|
201
|
+
"https://stac-extensions.github.io/alternate-assets/v1.2.0/schema.json"
|
|
202
|
+
],
|
|
203
|
+
"id": scene_id,
|
|
204
|
+
"geometry": {
|
|
205
|
+
"type": "Polygon",
|
|
206
|
+
"coordinates": [_footprint(west, south, index)],
|
|
207
|
+
},
|
|
208
|
+
"bbox": [west, south, west + 1, south + 1],
|
|
209
|
+
"properties": {"datetime": "2026-01-01T00:00:00Z", "testing:scene": scene_id},
|
|
210
|
+
"links": [
|
|
211
|
+
{
|
|
212
|
+
"rel": "self",
|
|
213
|
+
"href": f"{base}/{scene_id}.json",
|
|
214
|
+
"type": "application/geo+json",
|
|
215
|
+
}
|
|
216
|
+
],
|
|
217
|
+
"assets": {
|
|
218
|
+
"data": {
|
|
219
|
+
"href": f"{products}/data.zarr",
|
|
220
|
+
"type": "application/zarr",
|
|
221
|
+
"title": "NDVI grid",
|
|
222
|
+
"roles": ["data"],
|
|
223
|
+
"alternate": {"local": {"href": (directory / "data.zarr").as_uri()}},
|
|
224
|
+
},
|
|
225
|
+
"preview": {
|
|
226
|
+
"href": f"{products}/preview.png",
|
|
227
|
+
"type": "image/png",
|
|
228
|
+
"title": "Synthetic scene preview",
|
|
229
|
+
"roles": ["thumbnail"],
|
|
230
|
+
"alternate": {"local": {"href": (directory / "preview.png").as_uri()}},
|
|
231
|
+
},
|
|
232
|
+
"report": {
|
|
233
|
+
"href": f"{products}/summary.csv",
|
|
234
|
+
"type": "text/csv",
|
|
235
|
+
"title": "Scene summary",
|
|
236
|
+
"roles": ["metadata"],
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
}
|
|
@@ -11,7 +11,7 @@ from typing import Optional
|
|
|
11
11
|
import fastapi
|
|
12
12
|
from pydantic import ValidationError
|
|
13
13
|
|
|
14
|
-
from gavicore.models import (
|
|
14
|
+
from gavicore.models.core import (
|
|
15
15
|
JobInfo,
|
|
16
16
|
JobList,
|
|
17
17
|
JobResults,
|
|
@@ -162,6 +162,11 @@ class LocalService(ServiceBase):
|
|
|
162
162
|
del self.jobs[job_id]
|
|
163
163
|
self.job_results.pop(job_id, None)
|
|
164
164
|
self.job_uses_processes.pop(job_id, None)
|
|
165
|
+
# The OGC Api - Processes specifies that the dismiss operation (`DELETE /jobs/{jobId}`)
|
|
166
|
+
# must return a job info with state `"dismissed"`, no matter whether the job was successfully
|
|
167
|
+
# terminated or really already ended (which may take a while). So our interpretation of this
|
|
168
|
+
# status is a weaker "cancellation requested".
|
|
169
|
+
job.job_info.status = JobStatus.dismissed
|
|
165
170
|
return job.job_info
|
|
166
171
|
|
|
167
172
|
async def get_job_results(self, job_id: str, *args, **_kwargs) -> JobResults:
|
|
@@ -5,16 +5,31 @@
|
|
|
5
5
|
import datetime
|
|
6
6
|
import time
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Annotated, Optional
|
|
8
|
+
from typing import Annotated, Any, Optional
|
|
9
9
|
|
|
10
10
|
import pydantic
|
|
11
11
|
from pydantic import Field
|
|
12
12
|
|
|
13
|
-
from gavicore.models import InputDescription, Link, OutputDescription, Schema
|
|
13
|
+
from gavicore.models.core import InputDescription, Link, OutputDescription, Schema
|
|
14
14
|
from procodile import FromMain, FromStep, JobContext
|
|
15
|
-
from wraptile.services.local import LocalService
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
from .local_service import LocalService
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _TestingService(LocalService):
|
|
20
|
+
"""Local testing service with HTTP access to its generated STAC products."""
|
|
21
|
+
|
|
22
|
+
def configure(self, processes: bool | None = None, max_workers: int | None = None):
|
|
23
|
+
"""Configure execution and mount the testing artifact directory."""
|
|
24
|
+
super().configure(processes=processes, max_workers=max_workers)
|
|
25
|
+
from wraptile.app import app
|
|
26
|
+
|
|
27
|
+
from ._testing_stac import mount_artifacts
|
|
28
|
+
|
|
29
|
+
mount_artifacts(app)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
service = _TestingService(
|
|
18
33
|
title="Eozilla API Server (local dummy for testing)",
|
|
19
34
|
description="Local test server implementing the OGC API - Processes 1.0 Standard",
|
|
20
35
|
)
|
|
@@ -22,6 +37,88 @@ service = LocalService(
|
|
|
22
37
|
registry = service.process_registry
|
|
23
38
|
|
|
24
39
|
|
|
40
|
+
@registry.process(
|
|
41
|
+
id="create_inline_stac",
|
|
42
|
+
title="Create inline STAC outputs",
|
|
43
|
+
outputs={
|
|
44
|
+
"item": OutputDescription(
|
|
45
|
+
title="STAC Item",
|
|
46
|
+
schema=Schema(
|
|
47
|
+
**{
|
|
48
|
+
"$ref": "https://schemas.stacspec.org/v1.1.0/item-spec/json-schema/item.json"
|
|
49
|
+
}
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
"item_collection": OutputDescription(
|
|
53
|
+
title="STAC ItemCollection",
|
|
54
|
+
schema=Schema(
|
|
55
|
+
type="object",
|
|
56
|
+
properties={
|
|
57
|
+
"features": {
|
|
58
|
+
"type": "array",
|
|
59
|
+
"items": {"properties": {"stac_version": {"type": "string"}}},
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
"report": OutputDescription(
|
|
65
|
+
title="Ordinary CSV link", schema=Schema(type="object")
|
|
66
|
+
),
|
|
67
|
+
"item_count": OutputDescription(
|
|
68
|
+
title="Number of embedded Items", schema=Schema(type="integer")
|
|
69
|
+
),
|
|
70
|
+
"optional": OutputDescription(
|
|
71
|
+
title="Explicit null output", schema=Schema(nullable=True)
|
|
72
|
+
),
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
def create_inline_stac(
|
|
76
|
+
item_count: Annotated[int, Field(ge=1, le=4)] = 2,
|
|
77
|
+
) -> tuple[dict[str, Any], dict[str, Any], Link, int, None]:
|
|
78
|
+
"""Create small real Zarr/CSV products and return inline STAC JSON.
|
|
79
|
+
|
|
80
|
+
Output lives under EOZILLA_TESTING_STAC_DIR and is advertised beneath
|
|
81
|
+
EOZILLA_TESTING_STAC_URL. Each run has its own directory retained for review.
|
|
82
|
+
Requires the optional xarray, NumPy, and Zarr testing dependencies.
|
|
83
|
+
"""
|
|
84
|
+
from ._testing_stac import create_outputs
|
|
85
|
+
|
|
86
|
+
return create_outputs(item_count, inline=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@registry.process(
|
|
90
|
+
id="create_linked_stac",
|
|
91
|
+
title="Create linked STAC outputs",
|
|
92
|
+
outputs={
|
|
93
|
+
"item": OutputDescription(title="STAC Item link", schema=Schema(type="object")),
|
|
94
|
+
"item_collection": OutputDescription(
|
|
95
|
+
title="STAC ItemCollection link", schema=Schema(type="object")
|
|
96
|
+
),
|
|
97
|
+
"report": OutputDescription(
|
|
98
|
+
title="Ordinary CSV link", schema=Schema(type="object")
|
|
99
|
+
),
|
|
100
|
+
"item_count": OutputDescription(
|
|
101
|
+
title="Number of embedded Items", schema=Schema(type="integer")
|
|
102
|
+
),
|
|
103
|
+
"optional": OutputDescription(
|
|
104
|
+
title="Explicit null output", schema=Schema(nullable=True)
|
|
105
|
+
),
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
def create_linked_stac(
|
|
109
|
+
item_count: Annotated[int, Field(ge=1, le=4)] = 2,
|
|
110
|
+
) -> tuple[Link, Link, Link, int, None]:
|
|
111
|
+
"""Write the inline demo's equivalent metadata and return ordinary Links.
|
|
112
|
+
|
|
113
|
+
The testing service serves metadata and products at /testing-stac; set
|
|
114
|
+
EOZILLA_TESTING_STAC_URL to its public URL when using another host or port.
|
|
115
|
+
Generated run directories remain available until explicitly cleaned up.
|
|
116
|
+
"""
|
|
117
|
+
from ._testing_stac import create_outputs
|
|
118
|
+
|
|
119
|
+
return create_outputs(item_count, inline=False)
|
|
120
|
+
|
|
121
|
+
|
|
25
122
|
@registry.process(
|
|
26
123
|
id="sleep_a_while",
|
|
27
124
|
title="Sleep Processor",
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# Copyright (c) 2025-2026 by the Eozilla team and contributors
|
|
2
|
-
# Permissions are hereby granted under the terms of the Apache 2.0 License:
|
|
3
|
-
# https://opensource.org/license/apache-2-0.
|
|
4
|
-
|
|
5
|
-
from . import routes
|
|
6
|
-
from .app import app
|
|
7
|
-
|
|
8
|
-
"""
|
|
9
|
-
This module imports both, the FastAPI `app` instance and the application's
|
|
10
|
-
path functions from the `routes` module.
|
|
11
|
-
It also sets the server's service instance and exports the application as
|
|
12
|
-
the `app` module attribute.
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
__all__ = ["app", "routes"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|