rs-dpr-service 0.2__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.
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.3
2
+ Name: rs-dpr-service
3
+ Version: 0.2
4
+ Summary: Copernicus Sentinel rs-server, DPR as a service.
5
+ License: Apache-2.0
6
+ Keywords: stac,copernicus,sentinel,processing
7
+ Author: ESA under contract with Airbus and CS
8
+ Author-email: ops_coprs@airbus.com
9
+ Requires-Python: >=3.11,<4.0
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: FastAPI
12
+ Classifier: Framework :: OpenTelemetry
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: GIS
21
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
22
+ Requires-Dist: SQLAlchemy (>=2.0.37,<3.0.0)
23
+ Requires-Dist: bandit (>=1.8.3,<2.0.0)
24
+ Requires-Dist: dask-gateway (==2024.1.0)
25
+ Requires-Dist: dask[distributed] (==2024.5.2)
26
+ Requires-Dist: fastapi (>=0.116.1,<0.118.0)
27
+ Requires-Dist: flake8-pyproject (>=1.2.3,<2.0.0)
28
+ Requires-Dist: flask (>=3.1.1,<4.0.0)
29
+ Requires-Dist: geoalchemy2 (>=0.17.1,<0.18.0)
30
+ Requires-Dist: httpx (>=0.28.1,<0.29.0)
31
+ Requires-Dist: jsonpatch (>=1.33,<2.0)
32
+ Requires-Dist: mypy (>=1.15.0,<2.0.0)
33
+ Requires-Dist: openapi-core (>=0.19.5,<0.20.0)
34
+ Requires-Dist: opentelemetry-distro (>=0.57b0,<0.58)
35
+ Requires-Dist: opentelemetry-exporter-otlp (>=1.36.0,<2.0.0)
36
+ Requires-Dist: opentelemetry-instrumentation-aws-lambda (>=0.57b0,<0.58)
37
+ Requires-Dist: opentelemetry-test-utils (>=0.57b0,<0.58)
38
+ Requires-Dist: psycopg2 (>=2.9.10,<3.0.0)
39
+ Requires-Dist: pygeoapi (>=0.21.dev0)
40
+ Requires-Dist: pylint (>=3.3.6,<4.0.0)
41
+ Requires-Dist: python-logging-loki (>=0.3.1,<0.4.0)
42
+ Requires-Dist: s3fs (>=2025.7.0,<2026.0.0)
43
+ Requires-Dist: tornado (>=6.5,<7.0)
44
+ Requires-Dist: uvicorn (>=0.34.1,<0.35.0)
45
+ Project-URL: Documentation, https://home.rs-python.eu/rs-documentation/rs-server/docs/doc/
46
+ Project-URL: Homepage, https://home.rs-python.eu/
47
+ Project-URL: Repository, https://github.com/RS-PYTHON/rs-dpr-service
48
+ Description-Content-Type: text/markdown
49
+
50
+ # rs-dpr-service
51
+ A service responsible for triggering the DPR processor execution within the Dask cluster.
52
+
@@ -0,0 +1,2 @@
1
+ # rs-dpr-service
2
+ A service responsible for triggering the DPR processor execution within the Dask cluster.
@@ -0,0 +1,138 @@
1
+ [project]
2
+ name = "rs-dpr-service"
3
+ dynamic = ["version"] # see: https://github.com/mtkennerly/poetry-dynamic-versioning
4
+
5
+ [tool.poetry]
6
+ name = "rs-dpr-service"
7
+ version = "0.2"
8
+ description = "Copernicus Sentinel rs-server, DPR as a service."
9
+ license = "Apache-2.0"
10
+ authors = ["ESA under contract with Airbus and CS <ops_coprs@airbus.com>"]
11
+ readme = "README.md"
12
+ homepage = "https://home.rs-python.eu/"
13
+ repository = "https://github.com/RS-PYTHON/rs-dpr-service"
14
+ documentation = "https://home.rs-python.eu/rs-documentation/rs-server/docs/doc/"
15
+ keywords = ["stac", "copernicus", "sentinel", "processing"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Framework :: FastAPI",
19
+ "Framework :: OpenTelemetry",
20
+ "Intended Audience :: Developers",
21
+ "Intended Audience :: Science/Research",
22
+ "License :: OSI Approved :: Apache Software License",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Topic :: Scientific/Engineering :: GIS",
25
+ "Topic :: Scientific/Engineering :: Image Processing"
26
+ ]
27
+
28
+ packages = [
29
+ { include = "rs_dpr_service", from = "."},
30
+ ]
31
+
32
+ # Add configuration files to the .whl package
33
+ include = [
34
+ { path = "config", format = ["wheel"] }
35
+ ]
36
+
37
+ [tool.poetry.dependencies]
38
+ python = "^3.11"
39
+ pygeoapi = ">=0.21.dev0"
40
+ flask = "^3.1.1" # for pygeoapi
41
+ uvicorn = ">=0.34.1,<0.35.0"
42
+ fastapi = ">=0.116.1,<0.118.0"
43
+ geoalchemy2 = ">=0.17.1,<0.18.0"
44
+ SQLAlchemy = "^2.0.37"
45
+ jsonpatch = "^1.33"
46
+ dask-gateway = "2024.1.0"
47
+ # same dask version as in the cluster i.e. same as eopf
48
+ dask = {extras = ["distributed"], version = "2024.5.2"}
49
+ openapi-core = "^0.19.5"
50
+ bandit = "^1.8.3"
51
+ mypy = "^1.15.0"
52
+ pylint = "^3.3.6"
53
+ flake8-pyproject = "^1.2.3"
54
+ httpx = "^0.28.1"
55
+ psycopg2 = "^2.9.10"
56
+ opentelemetry-distro = "^0.57b0" # then run 'poetry run opentelemetry-bootstrap -a install'
57
+ opentelemetry-instrumentation-aws-lambda = "^0.57b0"
58
+ opentelemetry-test-utils = "^0.57b0"
59
+ opentelemetry-exporter-otlp = "^1.36.0"
60
+ tornado = "^6.5" # fix trivy vulnerability
61
+ python-logging-loki = "^0.3.1" # to avoid otl error: No module named 'logging_loki'
62
+ s3fs = "^2025.7.0"
63
+
64
+ [tool.poetry.group.dev]
65
+ optional = true
66
+
67
+ [tool.poetry.group.dev.dependencies]
68
+ python = "^3.11"
69
+ pygeoapi = { git = "https://github.com/geopython/pygeoapi.git", branch = "master" }
70
+ pre-commit = ">=3.7.1,<5.0.0"
71
+ pylint = "^3.3.2"
72
+ pytest = "^8.3.3"
73
+ pytest-asyncio = "^1.1.0"
74
+ pytest-cov = ">=5,<7"
75
+ pytest-docker = "^3.1.0"
76
+ pytest-error-for-skips = "^2.0.2"
77
+ pytest-mock = "^3.14.0"
78
+ responses = "^0.25.3"
79
+ safety = "^3.5.1"
80
+
81
+ [tool.black]
82
+ line-length = 120
83
+
84
+ [tool.pytest.ini_options]
85
+ markers = [
86
+ "unit", # mark a test as unittest
87
+ "integration", # mark a test as integration test
88
+ ]
89
+ asyncio_mode = "auto"
90
+
91
+ [tool.mypy]
92
+ warn_redundant_casts = true
93
+ # Force analyze of partially annotate function signatures
94
+ check_untyped_defs = true
95
+ ignore_missing_imports = true
96
+
97
+ [tool.flake8]
98
+ max-line-length = 120
99
+ # per-file-ignores = [
100
+ # '__init__.py:F401',
101
+ # ]
102
+
103
+ [tool.pylint]
104
+ max-line-length = 120
105
+ disable = [
106
+ "logging-fstring-interpolation", # Logging f-string interpolation
107
+ "R0917", # Too many positional arguments
108
+ "E0401", # import-error
109
+ "R0902", # too-many-instance-attributes
110
+ "R0913", # too-many-arguments
111
+ "too-many-statements",
112
+ "not-context-manager",
113
+ "too-many-branches",
114
+ ]
115
+ ignore-paths = '^docs/.*$'
116
+
117
+ # Only add notes as comments that are not checked for errors.
118
+ notes = ["XXX"] # No errors on TODO and FIXME
119
+
120
+ min-similarity-lines = 10 # Minimum lines number of a similarity
121
+
122
+ [tool.bandit.assert_used]
123
+ skips = ['*/*.py']
124
+
125
+ [tool.bandit]
126
+ skips = ['B603', 'B607', 'B404']
127
+
128
+
129
+ [tool.poetry.requires-plugins]
130
+ poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = ["plugin"] }
131
+
132
+ [tool.poetry-dynamic-versioning]
133
+ enable = false
134
+ metadata = false
135
+
136
+ [build-system]
137
+ requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
138
+ build-backend = "poetry_dynamic_versioning.backend"
@@ -0,0 +1,13 @@
1
+ # Copyright 2024 CS Group
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
@@ -0,0 +1,358 @@
1
+ # Copyright 2025 CS Group
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ This module contains the code that is related to dask and/or sent to the dask workers.
17
+ Avoid import unnecessary dependencies here.
18
+ """
19
+ import ast
20
+ import importlib
21
+ import json
22
+ import logging
23
+ import os
24
+ import os.path as osp
25
+ import re
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ import tempfile
30
+ import time
31
+ import zipfile
32
+ from pathlib import Path
33
+
34
+ import yaml
35
+ from distributed.client import Client as DaskClient
36
+ from opentelemetry.trace.span import SpanContext
37
+
38
+ from rs_dpr_service.utils import init_opentelemetry
39
+
40
+ SERVICE_NAME = "rs.dpr.dask"
41
+
42
+ # Don't use rs_dpr_service.utils.logging, it's not forwarded to the client
43
+ logger = logging.getLogger(__name__)
44
+ logger.setLevel(logging.DEBUG)
45
+
46
+
47
+ def upload_this_module(dask_client: DaskClient):
48
+ """
49
+ Upload this current module from the caller environment to the dask client.
50
+
51
+ WARNING: These modules should not import other modules that are not installed in the dask
52
+ environment or you'll have import errors.
53
+
54
+ Args:
55
+ clients: list of dask clients to which upload the modules.
56
+ """
57
+ # Root of the current project
58
+ root = Path(__file__).parent
59
+
60
+ # Files and dirs to upload and associated name in the zip archive
61
+ files = {
62
+ root / "__init__.py": "rs_dpr_service/__init__.py",
63
+ root / "call_dask.py": "rs_dpr_service/call_dask.py",
64
+ root / "safe_to_zarr.py": "rs_dpr_service/safe_to_zarr.py",
65
+ root / "utils/__init__.py": "rs_dpr_service/utils/__init__.py",
66
+ root / "utils/init_opentelemetry.py": "rs_dpr_service/utils/init_opentelemetry.py",
67
+ root / "utils/logging.py": "rs_dpr_service/utils/logging.py",
68
+ root / "utils/utils.py": "rs_dpr_service/utils/utils.py",
69
+ }
70
+
71
+ # From a temp dir
72
+ with tempfile.TemporaryDirectory() as tmpdir:
73
+
74
+ # Create a zip with our files
75
+ zip_path = f"{tmpdir}/{root.name}.zip"
76
+ with zipfile.ZipFile(zip_path, "w") as zipped:
77
+
78
+ # Zip all files
79
+ for key, value in files.items():
80
+ zipped.write(str(key), str(value))
81
+
82
+ # Upload zip file to dask clients.
83
+ # This also installs the zipped modules inside the dask python interpreter.
84
+ try:
85
+ dask_client.upload_file(zip_path)
86
+
87
+ # We have this error if we scale up the number of workers.
88
+ # But it's OK, the zip file is automatically uploaded to them anyway.
89
+ except KeyError as e:
90
+ logger.debug(f"Ignoring error {e}")
91
+
92
+
93
+ def copy_caller_env(caller_env: dict[str, str]):
94
+ """
95
+ Copy environment variables from the calling service environment to the dask client.
96
+
97
+ Args:
98
+ caller_env: os.environ coming from caller
99
+ """
100
+ local_mode = caller_env.get("RSPY_LOCAL_MODE") == "1"
101
+
102
+ # Copy env vars from the caller
103
+ keys = [
104
+ "RSPY_LOCAL_MODE",
105
+ "S3_ACCESSKEY",
106
+ "S3_SECRETKEY",
107
+ "S3_ENDPOINT",
108
+ "S3_REGION",
109
+ "PREFECT_BUCKET_NAME",
110
+ "PREFECT_BUCKET_FOLDER",
111
+ "DASK_GATEWAY_EOPF_ADDRESS",
112
+ "DASK_CLUSTER_EOPF_NAME",
113
+ "AWS_REQUEST_CHECKSUM_CALCULATION",
114
+ "AWS_RESPONSE_CHECKSUM_VALIDATION",
115
+ "TEMPO_ENDPOINT",
116
+ "OTEL_PYTHON_REQUESTS_TRACE_HEADERS",
117
+ "OTEL_PYTHON_REQUESTS_TRACE_BODY",
118
+ ]
119
+ if local_mode:
120
+ keys.extend(
121
+ [
122
+ "LOCAL_DASK_USERNAME",
123
+ "LOCAL_DASK_PASSWORD",
124
+ "access_key",
125
+ "bucket_location",
126
+ "host_base",
127
+ "host_bucket",
128
+ "secret_key",
129
+ ],
130
+ )
131
+ else:
132
+ keys.extend(["JUPYTERHUB_API_TOKEN"])
133
+ for key in keys:
134
+ if value := caller_env.get(key):
135
+ os.environ[key] = value
136
+
137
+
138
+ def dpr_tasktable_task(
139
+ caller_env: dict[str, str],
140
+ flow_span_context: SpanContext,
141
+ use_mockup: bool,
142
+ module_name: str,
143
+ class_name: str,
144
+ ):
145
+ """
146
+ Return the DPR tasktable. This function is run from inside the dask pod.
147
+ """
148
+ # Copy env vars from the caller
149
+ copy_caller_env(caller_env)
150
+
151
+ # Init opentelemetry and record all task in an Opentelemetry span
152
+ init_opentelemetry.init_traces(None, SERVICE_NAME, logger)
153
+ with init_opentelemetry.start_span(__name__, "main_dask_flow", flow_span_context):
154
+
155
+ if use_mockup:
156
+ time.sleep(1)
157
+ return {}
158
+
159
+ # Load the python class
160
+ class_ = getattr(importlib.import_module(module_name), class_name)
161
+
162
+ # Get the tasktable for default mode. See:
163
+ # https://cpm.pages.eopf.copernicus.eu/eopf-cpm/main/processor-orchestration-guide/tasktables.html#tasktables
164
+ logger.debug(f"Available modes for {class_}: {class_.get_available_modes()}")
165
+ default_mode = class_.get_default_mode()
166
+ tasktable = class_.get_tasktable_description(default_mode)
167
+ return tasktable
168
+
169
+
170
+ def dpr_processor_task( # pylint: disable=R0914, R0917
171
+ caller_env: dict[str, str],
172
+ data: dict,
173
+ use_mockup: bool,
174
+ ):
175
+ """
176
+ Run the DPR processor. This function is run from inside the dask pod.
177
+
178
+ Args:
179
+ caller_env: env variables coming from the caller
180
+ data: data to send to the processor
181
+ use_mockup: use the mockup or real processor
182
+ """
183
+ # Copy env vars from the caller
184
+ copy_caller_env(caller_env)
185
+
186
+ # Mockup processor
187
+ if use_mockup:
188
+ try:
189
+ payload_abs_path = osp.join("/", os.getcwd(), "payload.cfg")
190
+ with open(payload_abs_path, "w+", encoding="utf-8") as payload:
191
+ payload.write(yaml.safe_dump(data))
192
+ except Exception as e:
193
+ logger.exception("Exception during payload file creation: %s", e)
194
+ raise
195
+ command = ["python3.11", "DPR_processor_mock.py", "-p", payload_abs_path]
196
+ working_dir = "/src/DPR"
197
+ log_path = "./mockup.log" # not used
198
+ logger.debug(f"Working directory for subprocess: {working_dir} (type: {type(working_dir)})")
199
+
200
+ # Real processor
201
+ else:
202
+ # Read arguments
203
+ s3_config_dir = data["s3_config_dir"]
204
+ payload_subpath = data["payload_subpath"]
205
+ s3_report_dir = data["s3_report_dir"]
206
+
207
+ # Get S3 file handler.
208
+ # NOTE: eopf exists in the dask worker environment, not in the rs-dpr-service env,
209
+ # so we cannot import it from the top of this module.
210
+ from eopf.common.file_utils import ( # pylint: disable=import-outside-toplevel
211
+ AnyPath,
212
+ )
213
+
214
+ s3 = AnyPath(
215
+ s3_config_dir,
216
+ key=os.environ["S3_ACCESSKEY"],
217
+ secret=os.environ["S3_SECRETKEY"],
218
+ client_kwargs={
219
+ "endpoint_url": os.environ["S3_ENDPOINT"],
220
+ "region_name": os.environ["S3_REGION"],
221
+ },
222
+ )
223
+
224
+ logger.info("The dpr processing task started")
225
+
226
+ # Download the configuration folder from the S3 bucket into a local temp folder
227
+ local_config_dir = s3.get(recursive=True)
228
+
229
+ # Payload path and parent dir
230
+ payload_file = osp.realpath(osp.join(local_config_dir, payload_subpath))
231
+ payload_dir = osp.dirname(payload_file)
232
+
233
+ with open(payload_file, encoding="utf-8") as opened:
234
+ payload_contents = yaml.safe_load(opened)
235
+ logger.debug(f"Payload file contents: {payload_file!r}\n{json.dumps(payload_contents, indent=2)}")
236
+
237
+ command = ["eopf", "trigger", "local", payload_file]
238
+
239
+ # Change working directory
240
+ working_dir = osp.join(local_config_dir, payload_dir)
241
+ os.chdir(working_dir)
242
+
243
+ # Create the reports dir
244
+ # WARNING: fields from the payload file: dask__export_graphs, performance_report_file, ... should
245
+ # also use this directory: ./reports
246
+ local_report_dir = osp.realpath("./reports")
247
+ log_path = osp.join(local_report_dir, Path(payload_file).with_suffix(".processor.log").name)
248
+ shutil.rmtree(local_report_dir, ignore_errors=True)
249
+ os.makedirs(local_report_dir, exist_ok=True)
250
+
251
+ # Trigger EOPF processing, catch output
252
+ with subprocess.Popen(
253
+ command,
254
+ stdout=subprocess.PIPE,
255
+ stderr=subprocess.STDOUT,
256
+ text=True,
257
+ cwd=working_dir,
258
+ ) as proc:
259
+
260
+ # Log contents
261
+ log_str = ""
262
+
263
+ # Write output to a log file and string + redirect to the prefect logger
264
+ with open(log_path, "w+", encoding="utf-8") as log_file:
265
+ while proc.stdout and (line := proc.stdout.readline()) != "":
266
+
267
+ # The log prints password in clear e.g 'key': '<my-secret>'... hide them with a regex
268
+ for key in (
269
+ "key",
270
+ "secret",
271
+ "endpoint_url",
272
+ "region_name",
273
+ "api_token",
274
+ "password",
275
+ ):
276
+ line = re.sub(rf"(\W{key}\W)[^,}}]*", r"\1: ***", line)
277
+
278
+ # Write to log file and string
279
+ log_file.write(line)
280
+ log_str += line
281
+
282
+ # Write to logger if not empty
283
+ line = line.rstrip()
284
+ if line:
285
+ logger.info(line)
286
+
287
+ try:
288
+ # Wait for the execution to finish
289
+ status_code = proc.wait()
290
+
291
+ # Raise exception if the status code is != 0
292
+ if status_code:
293
+ raise RuntimeError(f"EOPF error, status code {status_code!r}, please see the log.")
294
+ logger.info(f"EOPF finished successfully with status code {status_code!r}")
295
+
296
+ # search for the JSON-like part, parse it, and ignore the rest.
297
+ if use_mockup:
298
+ match = re.search(r"(\[\s*\{.*\}\s*\])", log_str, re.DOTALL)
299
+ if not match:
300
+ raise ValueError(f"No valid dpr_payload structure found in the output:\n{log_str}")
301
+
302
+ payload_str = match.group(1)
303
+
304
+ # Use `ast.literal_eval` to safely evaluate the structure
305
+ try:
306
+ # payload_str is a string that looks like a JSON, extracted from the dpr mockup's raw output.
307
+ # ast.literal_eval() parses that string and returns the actual Python object (not just the string).
308
+ return ast.literal_eval(payload_str)
309
+ except Exception as e:
310
+ raise ValueError(f"Failed to parse dpr_payload structure: {e}") from e
311
+
312
+ # NOTE: with the real processor, what should we return ?
313
+ return {}
314
+
315
+ # In all cases, upload the reports dir to the s3 bucket.
316
+ finally:
317
+ try:
318
+ if not use_mockup:
319
+ logger.info(f"Upload reports {local_report_dir!r} to {s3_report_dir!r}")
320
+ s3._fs.put(local_report_dir, s3_report_dir, recursive=True) # pylint: disable=protected-access
321
+ except Exception as exception: # pylint: disable=broad-exception-caught
322
+ logger.error(exception)
323
+
324
+
325
+ def convert_safe_to_zarr(cfg):
326
+ """
327
+ Convert from legacy product (safe format) into Zarr format using EOPF in a subprocess.
328
+
329
+ This runs the rs_dpr_service.safe_to_zarr module as a subprocess, passing config as JSON string.
330
+ """
331
+
332
+ # Serialize the config
333
+ cfg_str = json.dumps(cfg)
334
+
335
+ # Find the ZIP that this code lives in
336
+ module_path = Path(__file__).resolve()
337
+ zip_path = Path(str(module_path).split(".zip", maxsplit=1)[0] + ".zip")
338
+ if not zip_path.is_file():
339
+ raise RuntimeError(f"Cannot locate rs_dpr_service.zip at {zip_path}")
340
+
341
+ # Prepare an env that lets Python import from inside the ZIP
342
+ env = os.environ.copy()
343
+ # prepend the zip onto PYTHONPATH (so zipimport will kick in)
344
+ env["PYTHONPATH"] = str(zip_path) + os.pathsep + env.get("PYTHONPATH", "")
345
+
346
+ # Run the converter as a module
347
+ cmd = [sys.executable, "-m", "rs_dpr_service.safe_to_zarr", cfg_str]
348
+ result = subprocess.run(
349
+ cmd,
350
+ env=env,
351
+ capture_output=True,
352
+ text=True,
353
+ check=False,
354
+ )
355
+
356
+ if result.returncode != 0:
357
+ raise RuntimeError(f"Conversion failed: {result.stderr.strip()}")
358
+ return result.stdout.strip()