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.
Files changed (28) hide show
  1. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/.gitignore +6 -2
  2. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/PKG-INFO +4 -4
  3. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/pyproject.toml +3 -3
  4. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/app.py +11 -1
  5. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/cli.py +2 -2
  6. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/exceptions.py +1 -1
  7. wraptile-0.4.0/wraptile/main.py +13 -0
  8. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/provider.py +1 -1
  9. wraptile-0.4.0/wraptile/routes/__init__.py +5 -0
  10. wraptile-0.2.1.dev1/wraptile/routes.py → wraptile-0.4.0/wraptile/routes/core.py +7 -5
  11. wraptile-0.4.0/wraptile/routes/dru.py +167 -0
  12. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/airflow/airflow_service.py +8 -2
  13. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/base/service_base.py +13 -2
  14. wraptile-0.4.0/wraptile/services/local/_testing_stac.py +239 -0
  15. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/local/local_service.py +6 -1
  16. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/local/testing.py +101 -4
  17. wraptile-0.2.1.dev1/wraptile/main.py +0 -15
  18. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/LICENSE +0 -0
  19. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/README.md +0 -0
  20. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/__init__.py +0 -0
  21. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/constants.py +0 -0
  22. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/logging.py +0 -0
  23. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/py.typed +0 -0
  24. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/__init__.py +0 -0
  25. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/airflow/__init__.py +0 -0
  26. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/airflow/tokens.py +0 -0
  27. {wraptile-0.2.1.dev1 → wraptile-0.4.0}/wraptile/services/base/__init__.py +0 -0
  28. {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
- # Documentation generation
6
- docs/notebooks/*.ipynb
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: wraptile
3
- Version: 0.2.1.dev1
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.2.1.dev1
32
- Requires-Dist: procodile>=0.2.1.dev1
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.2.1.dev1"
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.2.1.dev1",
50
- "procodile >=0.2.1.dev1",
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
- app = FastAPI()
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
@@ -7,7 +7,7 @@ from typing import Optional
7
7
 
8
8
  from fastapi import HTTPException
9
9
 
10
- from gavicore.models import ApiError
10
+ from gavicore.models.core import ApiError
11
11
  from gavicore.service.errors import (
12
12
  ErrorTypeId,
13
13
  create_api_error,
@@ -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"]
@@ -4,7 +4,7 @@
4
4
 
5
5
  import logging
6
6
 
7
- from gavicore.service import Service
7
+ from gavicore.service.core import Service
8
8
 
9
9
  from .services.base import ServiceBase
10
10
 
@@ -0,0 +1,5 @@
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 for OGC API - Processes Core and DRU."""
@@ -1,10 +1,12 @@
1
1
  # generated by gen_server.py:
2
- # filename: routes.py:
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 .app import app
20
- from .provider import get_service
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
- return self.dag_run_to_job_info(dag_run)
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
- service = LocalService(
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