rsconnect-python 1.30.0__py3-none-any.whl

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 (63) hide show
  1. rsconnect/__init__.py +13 -0
  2. rsconnect/actions.py +565 -0
  3. rsconnect/actions_content.py +508 -0
  4. rsconnect/actions_environment.py +160 -0
  5. rsconnect/actions_integration.py +118 -0
  6. rsconnect/api.py +2582 -0
  7. rsconnect/bundle.py +2481 -0
  8. rsconnect/certificates.py +39 -0
  9. rsconnect/environment.py +390 -0
  10. rsconnect/environment_node.py +115 -0
  11. rsconnect/environment_r.py +300 -0
  12. rsconnect/exception.py +15 -0
  13. rsconnect/git_metadata.py +180 -0
  14. rsconnect/http_support.py +595 -0
  15. rsconnect/json_web_token.py +178 -0
  16. rsconnect/log.py +253 -0
  17. rsconnect/main.py +5889 -0
  18. rsconnect/metadata.py +879 -0
  19. rsconnect/models.py +835 -0
  20. rsconnect/oauth.py +623 -0
  21. rsconnect/py.typed +0 -0
  22. rsconnect/pyproject.py +283 -0
  23. rsconnect/quickstart/__init__.py +16 -0
  24. rsconnect/quickstart/quickstart.py +486 -0
  25. rsconnect/quickstart/templates/__init__.py +16 -0
  26. rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
  27. rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
  28. rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
  29. rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
  30. rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
  31. rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
  32. rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
  33. rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
  34. rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
  35. rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
  36. rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
  37. rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
  38. rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
  39. rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
  40. rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
  41. rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
  42. rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
  43. rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
  44. rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
  45. rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
  46. rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
  47. rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
  48. rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
  49. rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
  50. rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
  51. rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
  52. rsconnect/shiny_express.py +136 -0
  53. rsconnect/snowflake.py +93 -0
  54. rsconnect/subprocesses/__init__.py +0 -0
  55. rsconnect/subprocesses/inspect_environment.py +362 -0
  56. rsconnect/timeouts.py +89 -0
  57. rsconnect/utils_package.py +261 -0
  58. rsconnect/validation.py +156 -0
  59. rsconnect/version_check.py +154 -0
  60. rsconnect_python-1.30.0.dist-info/METADATA +89 -0
  61. rsconnect_python-1.30.0.dist-info/RECORD +63 -0
  62. rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
  63. rsconnect_python-1.30.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ BINARY_ENCODED_FILETYPES = [".cer", ".der"]
6
+ TEXT_ENCODED_FILETYPES = [".ca-bundle", ".crt", ".key", ".pem"]
7
+
8
+
9
+ def read_certificate_file(location: str) -> str | bytes:
10
+ """Reads a certificate file from disk.
11
+
12
+ The file type (suffix) is used to determine the file encoding.
13
+ Assumption are made based on standard SSL practices.
14
+
15
+ Files ending in '.cer' and '.der' are assumed DER (Distinguished
16
+ Encoding Rules) files encoded in binary format.
17
+
18
+ Files ending in '.ca-bundle', '.crt', '.key', and '.pem' are PEM
19
+ (Privacy Enhanced Mail) files encoded in plain-text format.
20
+ """
21
+
22
+ path = Path(location)
23
+ suffix = path.suffix
24
+
25
+ if suffix in BINARY_ENCODED_FILETYPES:
26
+ with open(path, "rb") as bFile:
27
+ return bFile.read()
28
+
29
+ if suffix in TEXT_ENCODED_FILETYPES:
30
+ with open(path, "r") as tFile:
31
+ return tFile.read()
32
+
33
+ types = BINARY_ENCODED_FILETYPES + TEXT_ENCODED_FILETYPES
34
+ types = sorted(types)
35
+ types = [f"'{_}'" for _ in types]
36
+ human_readable_string = ", ".join(types[:-1]) + ", or " + types[-1]
37
+ raise RuntimeError(
38
+ f"The certificate file type is not recognized. Expected {human_readable_string}. Found '{suffix}'."
39
+ )
@@ -0,0 +1,390 @@
1
+ """Detects the configuration of a Python environment.
2
+
3
+ Given a directory and a Python executable, this module inspects the environment
4
+ and returns information about the Python version and the environment itself.
5
+
6
+ To inspect the environment it relies on a subprocess that runs the `rsconnect.subprocesses.inspect_environment`
7
+ module. This module is responsible for gathering the environment information and returning it in a JSON format.
8
+ """
9
+
10
+ import typing
11
+ import sys
12
+ import dataclasses
13
+ import pprint
14
+ import subprocess
15
+ import json
16
+ import pathlib
17
+ import os.path
18
+ import enum
19
+
20
+ from . import pyproject
21
+ from .log import logger
22
+ from .exception import RSConnectException
23
+ from .subprocesses.inspect_environment import EnvironmentData, MakeEnvironmentData as _MakeEnvironmentData
24
+
25
+ import click
26
+
27
+ try:
28
+ from enum import StrEnum
29
+ except ImportError: # Python <3.11
30
+
31
+ class StrEnum(str, enum.Enum):
32
+ def __str__(self) -> str:
33
+ return str(self.value)
34
+
35
+
36
+ class PackageInstaller(StrEnum):
37
+ PIP = "pip"
38
+ UV = "uv"
39
+
40
+
41
+ class Environment:
42
+ """A Python project environment,
43
+
44
+ The data is loaded from a rsconnect.utils.environment json response,
45
+ the environment contains all the information provided by :class:`EnvironmentData` plus
46
+ the environment python interpreter and the python interpreter version requirement.
47
+
48
+ The goal is to capture all the information needed to replicate such environment.
49
+ """
50
+
51
+ DATA_FIELDS = {f.name for f in dataclasses.fields(EnvironmentData)}
52
+
53
+ def __init__(
54
+ self,
55
+ data: EnvironmentData,
56
+ python_interpreter: typing.Optional[str] = None,
57
+ python_version_requirement: typing.Optional[str] = None,
58
+ ):
59
+ self._data = data
60
+
61
+ # Fields that are not loaded from the environment subprocess
62
+ self.python_version_requirement = python_version_requirement
63
+ self.python_interpreter = python_interpreter
64
+ # Optional override of server install behavior. If None, server-driven
65
+ # default is used.
66
+ self.package_manager_allow_uv: typing.Optional[bool] = None
67
+
68
+ def __getattr__(self, name: str) -> typing.Any:
69
+ # We directly proxy the attributes of the EnvironmentData object
70
+ # so that schema changes can be handled in EnvironmentData exclusively.
71
+ return getattr(self._data, name)
72
+
73
+ def __setattr__(self, name: str, value: typing.Any) -> None:
74
+ if name in self.DATA_FIELDS:
75
+ # proxy the attribute to the underlying EnvironmentData object
76
+ self._data = self._data._replace(**{name: value})
77
+ else:
78
+ super().__setattr__(name, value)
79
+
80
+ def __eq__(self, other: typing.Any) -> bool:
81
+ if not isinstance(other, Environment):
82
+ return False
83
+
84
+ return (
85
+ self._data == other._data
86
+ and self.python_interpreter == other.python_interpreter
87
+ and self.python_version_requirement == other.python_version_requirement
88
+ )
89
+
90
+ def __repr__(self) -> str:
91
+ data = self._data._asdict()
92
+ data.pop("contents", None) # Remove contents as it's too long to display
93
+ return (
94
+ f"Environment({data}, "
95
+ f"python_interpreter={self.python_interpreter}, "
96
+ f"python_version_requirement={self.python_version_requirement})"
97
+ )
98
+
99
+ @classmethod
100
+ def from_dict(
101
+ cls,
102
+ data: typing.Dict[str, typing.Any],
103
+ python_interpreter: typing.Optional[str] = None,
104
+ python_version_requirement: typing.Optional[str] = None,
105
+ ) -> "Environment":
106
+ """Create an Environment instance from the dictionary representation of EnvironmentData."""
107
+ return cls(
108
+ _MakeEnvironmentData(**data),
109
+ python_interpreter=python_interpreter,
110
+ python_version_requirement=python_version_requirement,
111
+ )
112
+
113
+ @classmethod
114
+ def create_python_environment(
115
+ cls,
116
+ directory: str,
117
+ requirements_file: typing.Optional[str] = "requirements.txt",
118
+ python: typing.Optional[str] = None,
119
+ override_python_version: typing.Optional[str] = None,
120
+ app_file: typing.Optional[str] = None,
121
+ package_manager: typing.Optional[PackageInstaller] = None,
122
+ ) -> "Environment":
123
+ """Given a project directory and a Python executable, return Environment information.
124
+
125
+ If no Python executable is provided, the current system Python executable is used.
126
+
127
+ :param directory: the project directory to inspect.
128
+ :param requirements_file: requirements file name relative to the project directory. If None,
129
+ capture the environment via pip freeze.
130
+ :param python: the Python executable of the environment to use for inspection.
131
+ :param override_python_version: the Python version required by the project.
132
+ :param app_file: the main application file to use for inspection.
133
+
134
+ :return: a tuple containing the Python executable of the environment and the Environment object.
135
+ """
136
+ if app_file is None:
137
+ module_file = fake_module_file_from_directory(directory)
138
+ else:
139
+ module_file = app_file
140
+
141
+ _warn_on_ignored_manifest(directory)
142
+ _warn_if_environment_directory(directory)
143
+
144
+ python_version_requirement = pyproject.detect_python_version_requirement(directory)
145
+ _warn_on_missing_python_version(python_version_requirement)
146
+
147
+ _check_requirements_file(directory, requirements_file)
148
+
149
+ if python is not None:
150
+ # TODO: Remove the option in a future release
151
+ logger.warning(
152
+ "On modern Posit Connect versions, the --python option won't influence "
153
+ "the Python version used to deploy the application anymore. "
154
+ "Please use a .python-version file to force a specific interpreter version."
155
+ )
156
+
157
+ if override_python_version:
158
+ # TODO: Remove the option in a future release
159
+ logger.warning(
160
+ "The --override-python-version option is deprecated, "
161
+ "please use a .python-version file to force a specific interpreter version."
162
+ )
163
+ python_version_requirement = f"=={override_python_version}"
164
+
165
+ # with cli_feedback("Inspecting Python environment"):
166
+ environment = cls._get_python_env_info(module_file, python, requirements_file=requirements_file)
167
+ environment.python_version_requirement = python_version_requirement
168
+
169
+ if override_python_version:
170
+ # Retaing backward compatibility with old Connect versions
171
+ # that didn't support environment.python.requires
172
+ environment.python = override_python_version
173
+
174
+ if package_manager is not None:
175
+ try:
176
+ selected_package_manager = PackageInstaller(package_manager)
177
+ except ValueError:
178
+ raise RSConnectException("Unsupported package manager: %s" % package_manager) from None
179
+ # Override the package manager name recorded by inspector
180
+ environment.package_manager = selected_package_manager # type: ignore[attr-defined]
181
+ # Derive allow_uv from selection
182
+ environment.package_manager_allow_uv = selected_package_manager is PackageInstaller.UV
183
+
184
+ if requirements_file is None:
185
+ _warn_on_ignored_requirements(directory, environment.filename)
186
+
187
+ return environment
188
+
189
+ @classmethod
190
+ def _get_python_env_info(
191
+ cls,
192
+ file_name: str,
193
+ python: typing.Optional[str],
194
+ requirements_file: typing.Optional[str] = "requirements.txt",
195
+ ) -> "Environment":
196
+ """
197
+ Gathers the python and environment information relating to the specified file
198
+ with an eye to deploy it.
199
+
200
+ :param file_name: the primary file being deployed.
201
+ :param python: the optional name of a Python executable.
202
+ :param requirements_file: which requirements file to read. If None, generate via pip freeze.
203
+ :return: information about the version of Python in use plus some environmental
204
+ stuff.
205
+ """
206
+ python = which_python(python)
207
+ logger.debug("Python: %s" % python)
208
+ environment = cls._inspect_environment(python, os.path.dirname(file_name), requirements_file=requirements_file)
209
+ if environment.error:
210
+ raise RSConnectException(environment.error)
211
+ logger.debug("Python: %s" % python)
212
+ logger.debug("Environment: %s" % pprint.pformat(environment._asdict()))
213
+ return environment
214
+
215
+ @classmethod
216
+ def _inspect_environment(
217
+ cls,
218
+ python: str,
219
+ directory: str,
220
+ requirements_file: typing.Optional[str] = "requirements.txt",
221
+ check_output: typing.Callable[..., bytes] = subprocess.check_output,
222
+ ) -> "Environment":
223
+ """Run the environment inspector using the specified python binary.
224
+
225
+ Returns a dictionary of information about the environment,
226
+ or containing an "error" field if an error occurred.
227
+ """
228
+ args = [python, "-m", "rsconnect.subprocesses.inspect_environment"]
229
+ args.extend(["--requirements-file", requirements_file or "none"])
230
+ args.append(directory)
231
+
232
+ try:
233
+ environment_json = check_output(args, text=True)
234
+ except Exception as e:
235
+ raise RSConnectException("Error inspecting environment (subprocess failed)") from e
236
+
237
+ try:
238
+ environment_data = json.loads(environment_json)
239
+ except json.JSONDecodeError as e:
240
+ raise RSConnectException("Error parsing environment JSON") from e
241
+
242
+ if "error" in environment_data:
243
+ system_error_message = environment_data.get("error")
244
+ if system_error_message:
245
+ raise RSConnectException(f"Error creating environment: {system_error_message}")
246
+
247
+ try:
248
+ return cls.from_dict(environment_data, python_interpreter=python)
249
+ except TypeError as e:
250
+ raise RSConnectException("Error constructing environment object") from e
251
+
252
+
253
+ def which_python(python: typing.Optional[str] = None) -> str:
254
+ """Determines which Python executable to use.
255
+
256
+ If the :param python: is provided, then validation is performed to check if the path is an executable file. If
257
+ None, the invoking system Python executable location is returned.
258
+
259
+ :param python: (Optional) path to a python executable.
260
+ :return: :param python: or `sys.executable`.
261
+ """
262
+ if python is None:
263
+ return sys.executable
264
+ if not os.path.exists(python):
265
+ raise RSConnectException(f"The path '{python}' does not exist. Expected a Python executable.")
266
+ if os.path.isdir(python):
267
+ raise RSConnectException(f"The path '{python}' is a directory. Expected a Python executable.")
268
+ if not os.access(python, os.X_OK):
269
+ raise RSConnectException(f"The path '{python}' is not executable. Expected a Python executable")
270
+ return python
271
+
272
+
273
+ def fake_module_file_from_directory(directory: str) -> str:
274
+ """
275
+ Takes a directory and invents a properly named file that though possibly fake,
276
+ can be used for other name/title derivation.
277
+
278
+ :param directory: the directory to start with.
279
+ :return: the directory plus the (potentially) fake module file.
280
+ """
281
+ app_name = os.path.abspath(directory)
282
+ app_name = os.path.dirname(app_name) if app_name.endswith(os.path.sep) else os.path.basename(app_name)
283
+ return os.path.join(directory, app_name + ".py")
284
+
285
+
286
+ def is_environment_dir(directory: typing.Union[str, pathlib.Path]) -> bool:
287
+ """Detect whether `directory` is a virtualenv"""
288
+
289
+ # A virtualenv will have Python at ./bin/python
290
+ python_path = os.path.join(directory, "bin", "python")
291
+ # But on Windows, it's at Scripts\Python.exe
292
+ win_path = os.path.join(directory, "Scripts", "Python.exe")
293
+ return os.path.exists(python_path) or os.path.exists(win_path)
294
+
295
+
296
+ def list_environment_dirs(directory: typing.Union[str, pathlib.Path]) -> typing.List[str]:
297
+ """Returns a list of subdirectories in `directory` that appear to contain virtual environments."""
298
+ envs: typing.List[str] = []
299
+
300
+ for name in os.listdir(directory):
301
+ path = os.path.join(directory, name)
302
+ if is_environment_dir(path):
303
+ envs.append(name)
304
+ return envs
305
+
306
+
307
+ def _warn_on_ignored_manifest(directory: str) -> None:
308
+ """
309
+ Checks for the existence of a file called manifest.json in the given directory.
310
+ If it's there, a warning noting that it will be ignored will be printed.
311
+
312
+ :param directory: the directory to check in.
313
+ """
314
+ if os.path.exists(os.path.join(directory, "manifest.json")):
315
+ click.secho(
316
+ " Warning: the existing manifest.json file will not be used or considered.",
317
+ fg="yellow",
318
+ )
319
+
320
+
321
+ def _check_requirements_file(directory: str, requirements_file: typing.Optional[str]) -> None:
322
+ """
323
+ Verify that a requirements file exists inside the deployment directory.
324
+
325
+ :param directory: the directory to check in.
326
+ :param requirements_file: the name of the requirements file, or None to skip the check.
327
+ """
328
+ if requirements_file is None:
329
+ return
330
+
331
+ directory_path = pathlib.Path(directory)
332
+ requirements_file_path = directory_path / pathlib.Path(requirements_file)
333
+ if directory_path not in requirements_file_path.parents:
334
+ click.secho(
335
+ " Warning: The requirements file '%s' is outside of the deployment directory.\n" % requirements_file,
336
+ fg="red",
337
+ )
338
+
339
+ if not requirements_file_path.exists():
340
+ raise RSConnectException(
341
+ "The requirements file '%s' does not exist in '%s'.\n"
342
+ "Please create the file or specify a different file with --requirements-file.\n"
343
+ "To have the requirements file generated using pip freeze, pass --force-generate."
344
+ % (requirements_file, directory)
345
+ )
346
+
347
+
348
+ def _warn_if_environment_directory(directory: typing.Union[str, pathlib.Path]) -> None:
349
+ """
350
+ Issue a warning if the deployment directory is itself a virtualenv (yikes!).
351
+
352
+ :param directory: the directory to check in.
353
+ """
354
+ if is_environment_dir(directory):
355
+ click.secho(
356
+ " Warning: The deployment directory appears to be a python virtual environment.\n"
357
+ " Python libraries and binaries will be excluded from the deployment.",
358
+ fg="yellow",
359
+ )
360
+
361
+
362
+ def _warn_on_ignored_requirements(directory: str, requirements_file_name: str) -> None:
363
+ """
364
+ Checks for the existence of a file called manifest.json in the given directory.
365
+ If it's there, a warning noting that it will be ignored will be printed.
366
+
367
+ :param directory: the directory to check in.
368
+ :param requirements_file_name: the name of the requirements file.
369
+ """
370
+ if os.path.exists(os.path.join(directory, requirements_file_name)):
371
+ click.secho(
372
+ " Warning: the existing %s file will not be used or considered." % requirements_file_name,
373
+ fg="yellow",
374
+ )
375
+
376
+
377
+ def _warn_on_missing_python_version(version_constraint: typing.Optional[str]) -> None:
378
+ """
379
+ Check that the project has a Python version constraint requested.
380
+ If it doesn't warn the user that it should be specified.
381
+
382
+ :param version_constraint: the version constraint in the project.
383
+ """
384
+ if version_constraint is None:
385
+ click.secho(
386
+ " Warning: Python version constraint missing from pyproject.toml, setup.cfg or .python-version\n"
387
+ " Connect will guess the version to use based on local environment.\n"
388
+ " Consider specifying a Python version constraint.",
389
+ fg="yellow",
390
+ )
@@ -0,0 +1,115 @@
1
+ """Detects the configuration of a Node.js environment.
2
+
3
+ Given a directory containing a package.json file, this module inspects
4
+ the local Node.js/npm installation and returns information needed to
5
+ build the deployment manifest.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import locale
12
+ import os
13
+ import subprocess
14
+ from typing import Optional
15
+
16
+ from .exception import RSConnectException
17
+ from .log import logger
18
+
19
+
20
+ class NodeEnvironment:
21
+ """A Node.js project environment for deployment.
22
+
23
+ Captures Node.js version, npm version, and package.json contents
24
+ needed for the manifest.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ node_version: str,
30
+ npm_version: str,
31
+ package_file: str,
32
+ package_contents: str,
33
+ has_lock_file: bool,
34
+ locale: str,
35
+ ):
36
+ self.node_version = node_version
37
+ self.npm_version = npm_version
38
+ self.package_file = package_file
39
+ self.package_contents = package_contents
40
+ self.has_lock_file = has_lock_file
41
+ self.locale = locale
42
+
43
+ @classmethod
44
+ def create(
45
+ cls,
46
+ directory: str,
47
+ node_executable: Optional[str] = None,
48
+ ) -> NodeEnvironment:
49
+ """Detect Node.js environment from a project directory.
50
+
51
+ :param directory: path to the project directory containing package.json.
52
+ :param node_executable: optional path to the node binary. Defaults to "node" on PATH.
53
+ :return: a NodeEnvironment instance.
54
+ """
55
+ node_executable = node_executable or "node"
56
+
57
+ package_json_path = os.path.join(directory, "package.json")
58
+ if not os.path.exists(package_json_path):
59
+ raise RSConnectException(
60
+ f"No package.json found in '{directory}'. A package.json file is required to deploy Node.js content."
61
+ )
62
+
63
+ with open(package_json_path, encoding="utf-8") as f:
64
+ package_contents = f.read()
65
+
66
+ try:
67
+ json.loads(package_contents)
68
+ except json.JSONDecodeError as e:
69
+ raise RSConnectException(f"Failed to parse package.json: {e}")
70
+
71
+ node_version = _detect_version(node_executable, "--version", "Node.js")
72
+ npm_version = _detect_version("npm", "--version", "npm")
73
+
74
+ has_lock_file = os.path.exists(os.path.join(directory, "package-lock.json"))
75
+ if not has_lock_file:
76
+ raise RSConnectException(
77
+ f"No package-lock.json found in '{directory}'. "
78
+ "Connect installs Node.js dependencies with npm. "
79
+ "Both package.json and package-lock.json are required to deploy Node.js content."
80
+ )
81
+
82
+ env_locale = locale.getlocale()[0] or "en_US"
83
+
84
+ return cls(
85
+ node_version=node_version,
86
+ npm_version=npm_version,
87
+ package_file="package.json",
88
+ package_contents=package_contents,
89
+ has_lock_file=has_lock_file,
90
+ locale=env_locale,
91
+ )
92
+
93
+
94
+ def _detect_version(executable: str, flag: str, label: str) -> str:
95
+ """Run an executable with a version flag and return the version string."""
96
+ try:
97
+ result = subprocess.run(
98
+ [executable, flag],
99
+ capture_output=True,
100
+ text=True,
101
+ timeout=10,
102
+ )
103
+ if result.returncode != 0:
104
+ raise RSConnectException(f"{label} returned exit code {result.returncode}: {result.stderr.strip()}")
105
+ version = result.stdout.strip().lstrip("v")
106
+ if not version:
107
+ raise RSConnectException(f"{label} returned empty version string.")
108
+ logger.debug(f"Detected {label} version: {version}")
109
+ return version
110
+ except FileNotFoundError:
111
+ raise RSConnectException(
112
+ f"Could not find '{executable}' on PATH. Please install {label} or specify the path with --node."
113
+ )
114
+ except subprocess.TimeoutExpired:
115
+ raise RSConnectException(f"Timed out detecting {label} version.")