pstic commited on
Commit
9ecc889
·
verified ·
1 Parent(s): 14d30b1

Deploy CPU Depth Pro API for speed test

Browse files
Files changed (7) hide show
  1. .dockerignore +5 -0
  2. Dockerfile +31 -0
  3. Dockerfile.gpu +30 -0
  4. README.md +134 -6
  5. main.py +230 -0
  6. requirements.txt +9 -0
  7. smoke_test.py +61 -0
.dockerignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1 \
6
+ PORT=8000 \
7
+ DEPTH_PRO_MODEL_ID=apple/DepthPro-hf \
8
+ DEPTH_PRO_DEVICE=auto \
9
+ DEPTH_PRO_DTYPE=float32 \
10
+ DEPTH_PRO_EAGER_LOAD=false \
11
+ MAX_IMAGE_BYTES=16777216 \
12
+ HF_HUB_ENABLE_HF_TRANSFER=1
13
+
14
+ WORKDIR /app
15
+
16
+ RUN useradd -m -u 1000 appuser
17
+
18
+ COPY requirements.txt .
19
+ RUN python -m pip install --upgrade pip \
20
+ && python -m pip install --index-url https://download.pytorch.org/whl/cpu torch==2.5.1 \
21
+ && python -m pip install -r requirements.txt
22
+
23
+ COPY main.py .
24
+ COPY smoke_test.py .
25
+
26
+ RUN chown -R appuser:appuser /app
27
+ USER appuser
28
+
29
+ EXPOSE 8000
30
+
31
+ CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}"]
Dockerfile.gpu ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1 \
6
+ PORT=8000 \
7
+ DEPTH_PRO_MODEL_ID=/repository \
8
+ DEPTH_PRO_DEVICE=auto \
9
+ DEPTH_PRO_DTYPE=auto \
10
+ DEPTH_PRO_EAGER_LOAD=true \
11
+ MAX_IMAGE_BYTES=16777216 \
12
+ HF_HUB_ENABLE_HF_TRANSFER=1
13
+
14
+ WORKDIR /app
15
+
16
+ RUN useradd -m -u 1000 appuser
17
+
18
+ COPY requirements.txt .
19
+ RUN python -m pip install --upgrade pip \
20
+ && python -m pip install -r requirements.txt
21
+
22
+ COPY main.py .
23
+ COPY smoke_test.py .
24
+
25
+ RUN chown -R appuser:appuser /app
26
+ USER appuser
27
+
28
+ EXPOSE 8000
29
+
30
+ CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}"]
README.md CHANGED
@@ -1,10 +1,138 @@
1
  ---
2
- title: Spatialthings Depth Pro Free Cpu
3
- emoji: 🚀
4
- colorFrom: pink
5
- colorTo: pink
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SpatialThings Depth Pro API
 
 
 
3
  sdk: docker
4
+ app_port: 8000
5
  ---
6
 
7
+ # SpatialThings Hosted Depth Pro API
8
+
9
+ This directory packages a Hugging Face hosted Depth Pro server that preserves the Android API contract used by SpatialThings.
10
+
11
+ ## Deployment choice
12
+
13
+ Use a Hugging Face Inference Endpoint custom container as the primary production path. The endpoint should select `apple/DepthPro-hf` as the model repository so Hugging Face mounts the model at `/repository`, while the Docker image contains only this FastAPI server and Python dependencies.
14
+
15
+ Use a Docker Space only as a fallback. Free Spaces sleep when idle, so they do not satisfy the always-available requirement. Paid Spaces can run indefinitely, but Inference Endpoints have the cleaner production deployment and autoscaling controls.
16
+
17
+ ## API contract
18
+
19
+ - `GET /health`
20
+ - `POST /estimate-depth`
21
+ - Request `Content-Type`: `image/jpeg`
22
+ - Response `Content-Type`: `application/octet-stream`
23
+ - Response body: contiguous `float32` little-endian depth map
24
+ - Response headers:
25
+ - `X-Depth-Width`
26
+ - `X-Depth-Height`
27
+ - `X-Depth-Scale: metric_meters`
28
+ - `X-Process-Time-Sec`
29
+
30
+ The server returns the `predicted_depth` tensor from `apple/DepthPro-hf` after `post_process_depth_estimation(..., target_sizes=[(image.height, image.width)])`. It does not normalize the output.
31
+
32
+ ## Cost and availability
33
+
34
+ For always-on production, configure the endpoint with:
35
+
36
+ - min replicas: `1`
37
+ - max replicas: `1` to start, increase only after measuring traffic
38
+ - scale-to-zero: disabled
39
+ - hardware: start with 1x Nvidia L4; T4 can be cheaper but has less GPU memory
40
+
41
+ As of 2026-07-02 from Hugging Face pricing docs:
42
+
43
+ - Inference Endpoint AWS T4 x1: `$0.50/hr`, about `$365/month` at 730 hours
44
+ - Inference Endpoint AWS L4 x1: `$0.80/hr`, about `$584/month`
45
+ - Inference Endpoint GCP L4 x1: `$0.70/hr`, about `$511/month`
46
+ - Space T4 small: `$0.40/hr`, Space T4 medium: `$0.60/hr`, Space L4 x1: `$0.80/hr`
47
+
48
+ Do not enable scale-to-zero for the Android production URL. Hugging Face documents cold starts, temporary `503` responses while a replica initializes, and multi-minute scale-up time depending on the model. That behavior conflicts with an always-available mobile backend.
49
+
50
+ ## Build and push the container
51
+
52
+ From the repository root:
53
+
54
+ ```bash
55
+ docker build --platform linux/amd64 \
56
+ -f deploy/hf_depth_pro/Dockerfile.gpu \
57
+ -t <registry-user>/spatialthings-depth-pro:0.1.0 \
58
+ deploy/hf_depth_pro
59
+
60
+ docker push <registry-user>/spatialthings-depth-pro:0.1.0
61
+ ```
62
+
63
+ `--platform linux/amd64` matters on Apple Silicon Macs because Hugging Face Endpoint infrastructure expects x86_64 container images.
64
+
65
+ ## Create the Inference Endpoint
66
+
67
+ Use the Inference Endpoints UI when deploying a custom container:
68
+
69
+ 1. Create a new endpoint.
70
+ 2. Model repository: `apple/DepthPro-hf`.
71
+ 3. Custom container image: `<registry-user>/spatialthings-depth-pro:0.1.0`.
72
+ 4. Container port: `8000`.
73
+ 5. Hardware: 1x Nvidia L4 recommended for the first production deployment.
74
+ 6. Autoscaling: `min replicas=1`, `max replicas=1`, scale-to-zero disabled.
75
+ 7. Visibility:
76
+ - Public keeps the current Android contract with no auth header, but exposes the endpoint to abuse.
77
+ - Protected requires adding `Authorization: Bearer ...` in the Android client.
78
+
79
+ After the endpoint reaches Running, set the Android Depth Pro base URL to:
80
+
81
+ ```text
82
+ https://<endpoint-id>.<region>.endpoints.huggingface.cloud
83
+ ```
84
+
85
+ ## Space fallback
86
+
87
+ For the free CPU test path, create a Docker Space without `--flavor` and without `--sleep-time -1`:
88
+
89
+ ```bash
90
+ hf repos create <user-or-org>/spatialthings-depth-pro \
91
+ --type space \
92
+ --space-sdk docker \
93
+ --public \
94
+ --exist-ok
95
+
96
+ hf upload <user-or-org>/spatialthings-depth-pro deploy/hf_depth_pro . \
97
+ --type space
98
+ ```
99
+
100
+ For Space fallback, set this runtime variable:
101
+
102
+ ```text
103
+ DEPTH_PRO_MODEL_ID=apple/DepthPro-hf
104
+ DEPTH_PRO_EAGER_LOAD=false
105
+ ```
106
+
107
+ The Space URL is:
108
+
109
+ ```text
110
+ https://<user-or-org>-spatialthings-depth-pro.hf.space
111
+ ```
112
+
113
+ This free Space uses CPU Basic. It is suitable for cold-start and rough latency checks only. It can sleep when idle, and Depth Pro CPU inference is expected to be much slower than a paid GPU endpoint.
114
+
115
+ For a paid always-on Space fallback, recreate or upgrade it with GPU hardware and `--sleep-time -1`.
116
+
117
+ ## Local development fallback
118
+
119
+ Local execution is only for development validation:
120
+
121
+ ```bash
122
+ cd deploy/hf_depth_pro
123
+ python3 -m venv .venv
124
+ source .venv/bin/activate
125
+ pip install -r requirements.txt
126
+
127
+ DEPTH_PRO_MODEL_ID=apple/DepthPro-hf \
128
+ DEPTH_PRO_DEVICE=auto \
129
+ uvicorn main:app --host 0.0.0.0 --port 8000
130
+ ```
131
+
132
+ Smoke-test the Android contract:
133
+
134
+ ```bash
135
+ python smoke_test.py \
136
+ --base-url http://127.0.0.1:8000 \
137
+ --image ../../data/tmp_inputs/cat_fallback.jpg
138
+ ```
main.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import threading
6
+ import time
7
+ from contextlib import asynccontextmanager
8
+ from io import BytesIO
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ import torch
14
+ from fastapi import Body, FastAPI, HTTPException, Request, Response
15
+ from PIL import Image, UnidentifiedImageError
16
+ from transformers import AutoImageProcessor, AutoModelForDepthEstimation
17
+
18
+ LOGGER = logging.getLogger("spatialthings.depth_pro")
19
+
20
+ DEFAULT_HF_MODEL_ID = "apple/DepthPro-hf"
21
+ ENDPOINT_MODEL_PATH = Path("/repository")
22
+
23
+
24
+ def _env_bool(name: str, default: bool | None = None) -> bool | None:
25
+ raw = os.getenv(name)
26
+ if raw is None or raw == "":
27
+ return default
28
+ return raw.strip().lower() in {"1", "true", "yes", "y", "on"}
29
+
30
+
31
+ def _select_model_id() -> str:
32
+ configured = os.getenv("DEPTH_PRO_MODEL_ID")
33
+ if configured:
34
+ return configured
35
+ if ENDPOINT_MODEL_PATH.exists():
36
+ return str(ENDPOINT_MODEL_PATH)
37
+ return DEFAULT_HF_MODEL_ID
38
+
39
+
40
+ def _select_device() -> torch.device:
41
+ configured = os.getenv("DEPTH_PRO_DEVICE", "auto").strip().lower()
42
+ if configured == "auto":
43
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
44
+ return torch.device(configured)
45
+
46
+
47
+ def _select_dtype(device: torch.device) -> torch.dtype:
48
+ configured = os.getenv("DEPTH_PRO_DTYPE", "auto").strip().lower()
49
+ if configured == "auto":
50
+ return torch.float16 if device.type == "cuda" else torch.float32
51
+ if configured in {"float16", "fp16", "half"}:
52
+ return torch.float16
53
+ if configured in {"bfloat16", "bf16"}:
54
+ return torch.bfloat16
55
+ if configured in {"float32", "fp32"}:
56
+ return torch.float32
57
+ raise ValueError(f"Unsupported DEPTH_PRO_DTYPE={configured!r}")
58
+
59
+
60
+ class ModelNotReadyError(RuntimeError):
61
+ pass
62
+
63
+
64
+ class DepthProModel:
65
+ def __init__(self) -> None:
66
+ self.model_id = _select_model_id()
67
+ self.device = _select_device()
68
+ self.dtype = _select_dtype(self.device)
69
+ self.max_image_bytes = int(os.getenv("MAX_IMAGE_BYTES", str(16 * 1024 * 1024)))
70
+ self.use_fov_model = _env_bool("DEPTH_PRO_USE_FOV_MODEL", None)
71
+ self.processor: Any | None = None
72
+ self.model: Any | None = None
73
+ self.load_lock = threading.Lock()
74
+ self.lock = threading.Lock()
75
+
76
+ def load(self) -> None:
77
+ if self.processor is not None and self.model is not None:
78
+ return
79
+
80
+ with self.load_lock:
81
+ if self.processor is not None and self.model is not None:
82
+ return
83
+
84
+ model_kwargs: dict[str, Any] = {"torch_dtype": self.dtype}
85
+ if self.use_fov_model is not None:
86
+ model_kwargs["use_fov_model"] = self.use_fov_model
87
+
88
+ started = time.perf_counter()
89
+ LOGGER.info(
90
+ "Loading Depth Pro model model_id=%s device=%s dtype=%s use_fov_model=%s",
91
+ self.model_id,
92
+ self.device,
93
+ self.dtype,
94
+ self.use_fov_model,
95
+ )
96
+ self.processor = AutoImageProcessor.from_pretrained(self.model_id, use_fast=True)
97
+ self.model = AutoModelForDepthEstimation.from_pretrained(self.model_id, **model_kwargs)
98
+ self.model.to(self.device)
99
+ self.model.eval()
100
+ LOGGER.info("Loaded Depth Pro in %.3fs", time.perf_counter() - started)
101
+
102
+ def unload(self) -> None:
103
+ if self.model is not None:
104
+ self.model.to("cpu")
105
+ self.model = None
106
+ self.processor = None
107
+ if torch.cuda.is_available():
108
+ torch.cuda.empty_cache()
109
+
110
+ def require_loaded(self) -> tuple[Any, Any]:
111
+ if self.processor is None or self.model is None:
112
+ raise ModelNotReadyError("Depth Pro model is not loaded")
113
+ return self.processor, self.model
114
+
115
+ def health(self) -> dict[str, Any]:
116
+ return {
117
+ "ok": self.processor is not None and self.model is not None,
118
+ "model_id": self.model_id,
119
+ "device": str(self.device),
120
+ "dtype": str(self.dtype).replace("torch.", ""),
121
+ "scale": "metric_meters",
122
+ "max_image_bytes": self.max_image_bytes,
123
+ }
124
+
125
+ def estimate(self, image: Image.Image) -> tuple[np.ndarray, float]:
126
+ self.load()
127
+ processor, model = self.require_loaded()
128
+ width, height = image.size
129
+
130
+ with self.lock:
131
+ started = time.perf_counter()
132
+ inputs = processor(images=image, return_tensors="pt")
133
+ inputs = {
134
+ key: value.to(device=self.device)
135
+ if not torch.is_floating_point(value)
136
+ else value.to(device=self.device, dtype=self.dtype)
137
+ for key, value in inputs.items()
138
+ }
139
+
140
+ with torch.inference_mode():
141
+ outputs = model(**inputs)
142
+
143
+ post_processed = processor.post_process_depth_estimation(
144
+ outputs,
145
+ target_sizes=[(height, width)],
146
+ )
147
+ depth = post_processed[0]["predicted_depth"]
148
+ elapsed = time.perf_counter() - started
149
+
150
+ if isinstance(depth, torch.Tensor):
151
+ depth_np = depth.detach().to(dtype=torch.float32).cpu().numpy()
152
+ else:
153
+ depth_np = np.asarray(depth, dtype=np.float32)
154
+
155
+ if depth_np.ndim != 2:
156
+ raise RuntimeError(f"Depth output must be 2D, got shape={depth_np.shape}")
157
+
158
+ return np.ascontiguousarray(depth_np.astype(np.float32, copy=False)), elapsed
159
+
160
+
161
+ depth_pro = DepthProModel()
162
+
163
+
164
+ @asynccontextmanager
165
+ async def lifespan(app: FastAPI):
166
+ if _env_bool("DEPTH_PRO_EAGER_LOAD", True):
167
+ depth_pro.load()
168
+ try:
169
+ yield
170
+ finally:
171
+ depth_pro.unload()
172
+
173
+
174
+ app = FastAPI(title="SpatialThings Depth Pro API", lifespan=lifespan)
175
+
176
+
177
+ @app.get("/")
178
+ def root() -> dict[str, Any]:
179
+ return depth_pro.health()
180
+
181
+
182
+ @app.get("/health")
183
+ def health() -> dict[str, Any]:
184
+ return depth_pro.health()
185
+
186
+
187
+ @app.post("/estimate-depth")
188
+ def estimate_depth(
189
+ request: Request,
190
+ body: bytes = Body(..., media_type="image/jpeg"),
191
+ ) -> Response:
192
+ content_type = request.headers.get("content-type", "").split(";", maxsplit=1)[0].strip().lower()
193
+ if content_type not in {"image/jpeg", "image/jpg"}:
194
+ raise HTTPException(status_code=415, detail="Content-Type must be image/jpeg")
195
+ if not body:
196
+ raise HTTPException(status_code=400, detail="Missing request body")
197
+ if len(body) > depth_pro.max_image_bytes:
198
+ raise HTTPException(status_code=413, detail=f"Request body too large: {len(body)} bytes")
199
+
200
+ try:
201
+ with Image.open(BytesIO(body)) as image:
202
+ rgb = image.convert("RGB")
203
+ except (UnidentifiedImageError, OSError) as exc:
204
+ raise HTTPException(status_code=400, detail=f"Invalid JPEG image: {exc}") from exc
205
+
206
+ try:
207
+ depth, elapsed = depth_pro.estimate(rgb)
208
+ except ModelNotReadyError as exc:
209
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
210
+ except RuntimeError as exc:
211
+ LOGGER.exception("Depth estimation failed")
212
+ raise HTTPException(status_code=500, detail=f"Depth estimation failed: {exc}") from exc
213
+
214
+ little_endian = np.ascontiguousarray(depth.astype("<f4", copy=False))
215
+ payload = little_endian.tobytes()
216
+ headers = {
217
+ "Content-Length": str(len(payload)),
218
+ "X-Depth-Width": str(depth.shape[1]),
219
+ "X-Depth-Height": str(depth.shape[0]),
220
+ "X-Depth-Scale": "metric_meters",
221
+ "X-Process-Time-Sec": f"{elapsed:.6f}",
222
+ }
223
+ return Response(content=payload, media_type="application/octet-stream", headers=headers)
224
+
225
+
226
+ if __name__ == "__main__":
227
+ import uvicorn
228
+
229
+ logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
230
+ uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "8000")))
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ accelerate>=0.34.0
2
+ fastapi>=0.115.0
3
+ hf-transfer>=0.1.8
4
+ numpy>=1.26.0
5
+ pillow>=10.4.0
6
+ safetensors>=0.4.5
7
+ torch>=2.4.0
8
+ transformers>=4.50.0
9
+ uvicorn[standard]>=0.30.0
smoke_test.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import struct
5
+ import sys
6
+ import urllib.error
7
+ import urllib.request
8
+ from pathlib import Path
9
+
10
+
11
+ def request(method: str, url: str, body: bytes | None = None, content_type: str | None = None):
12
+ headers = {}
13
+ if content_type:
14
+ headers["Content-Type"] = content_type
15
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
16
+ return urllib.request.urlopen(req, timeout=120)
17
+
18
+
19
+ def main() -> int:
20
+ parser = argparse.ArgumentParser(description="Smoke-test the SpatialThings Depth Pro API contract.")
21
+ parser.add_argument("--base-url", default="http://127.0.0.1:8000")
22
+ parser.add_argument("--image", required=True, type=Path)
23
+ args = parser.parse_args()
24
+
25
+ try:
26
+ with request("GET", f"{args.base_url.rstrip('/')}/health") as response:
27
+ print(f"health status={response.status} body={response.read().decode('utf-8')}")
28
+
29
+ image_bytes = args.image.read_bytes()
30
+ with request(
31
+ "POST",
32
+ f"{args.base_url.rstrip('/')}/estimate-depth",
33
+ body=image_bytes,
34
+ content_type="image/jpeg",
35
+ ) as response:
36
+ payload = response.read()
37
+ width = int(response.headers["X-Depth-Width"])
38
+ height = int(response.headers["X-Depth-Height"])
39
+ scale = response.headers["X-Depth-Scale"]
40
+ process_time = response.headers["X-Process-Time-Sec"]
41
+
42
+ expected_bytes = width * height * 4
43
+ if len(payload) != expected_bytes:
44
+ raise RuntimeError(f"Unexpected body length: got={len(payload)} expected={expected_bytes}")
45
+ if scale != "metric_meters":
46
+ raise RuntimeError(f"Unexpected X-Depth-Scale: {scale}")
47
+
48
+ first_depth = struct.unpack_from("<f", payload, 0)[0] if payload else float("nan")
49
+ print(
50
+ "estimate-depth ok "
51
+ f"size={width}x{height} bytes={len(payload)} "
52
+ f"scale={scale} process_time_sec={process_time} first_depth={first_depth:.6f}"
53
+ )
54
+ return 0
55
+ except (OSError, urllib.error.HTTPError, RuntimeError) as exc:
56
+ print(f"[ERROR] {exc}", file=sys.stderr)
57
+ return 1
58
+
59
+
60
+ if __name__ == "__main__":
61
+ raise SystemExit(main())