-
Notifications
You must be signed in to change notification settings - Fork 73
Expand file tree
/
Copy pathpython.py
More file actions
344 lines (294 loc) · 12.9 KB
/
Copy pathpython.py
File metadata and controls
344 lines (294 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
from __future__ import annotations
import shlex
import subprocess
import sys
from contextlib import suppress
from pathlib import Path
from shutil import rmtree
from typing import TYPE_CHECKING, Literal
from overrides import overrides
from rich import print # noqa: A004 # Allow shadowing the built-in
from airbyte import exceptions as exc
from airbyte._executors.base import Executor
from airbyte._util.meta import is_windows
from airbyte._util.telemetry import EventState, log_install_state
from airbyte._util.venv_util import get_bin_dir
from airbyte.constants import DEFAULT_INSTALL_DIR, NO_UV
if TYPE_CHECKING:
from airbyte.registry import ConnectorMetadata
class VenvExecutor(Executor):
def __init__(
self,
name: str | None = None,
*,
metadata: ConnectorMetadata | None = None,
target_version: str | None = None,
pip_url: str | None = None,
install_root: Path | None = None,
use_python: bool | Path | str | None = None,
) -> None:
"""Initialize a connector executor that runs a connector in a virtual environment.
Args:
name: The name of the connector.
metadata: (Optional.) The metadata of the connector.
target_version: (Optional.) The version of the connector to install.
pip_url: (Optional.) The pip URL of the connector to install.
install_root: (Optional.) The root directory where the virtual environment will be
created. If not provided, the current working directory will be used.
use_python: (Optional.) Python interpreter specification:
- True: Use current Python interpreter
- False: Use Docker instead (handled by factory)
- Path: Use interpreter at this path or interpreter name/command
- str: Use uv-managed Python version (semver patterns like "3.12", "3.11.5")
""" # noqa: DOC501
super().__init__(name=name, metadata=metadata, target_version=target_version)
if not pip_url and metadata and not metadata.pypi_package_name:
raise exc.AirbyteConnectorNotPyPiPublishedError(
connector_name=self.name,
context={
"metadata": metadata,
},
)
self.pip_url = pip_url or (
metadata.pypi_package_name
if metadata and metadata.pypi_package_name
else f"airbyte-{self.name}"
)
self.install_root = install_root or DEFAULT_INSTALL_DIR or Path.cwd()
with suppress(Exception):
self.install_root.mkdir(parents=True, exist_ok=True)
self.use_python = use_python
def _get_venv_name(self) -> str:
return f".venv-{self.name}"
def _get_venv_path(self) -> Path:
return self.install_root / self._get_venv_name()
def _get_connector_path(self) -> Path:
suffix: Literal[".exe", ""] = ".exe" if is_windows() else ""
return get_bin_dir(self._get_venv_path()) / (self.name + suffix)
@property
def interpreter_path(self) -> Path:
suffix: Literal[".exe", ""] = ".exe" if is_windows() else ""
return get_bin_dir(self._get_venv_path()) / ("python" + suffix)
def _run_subprocess_and_raise_on_failure(self, args: list[str]) -> None:
result = subprocess.run(
args,
check=False,
stderr=subprocess.PIPE,
)
if result.returncode != 0:
raise exc.AirbyteSubprocessFailedError(
run_args=args,
exit_code=result.returncode,
log_text=result.stderr.decode("utf-8"),
)
def uninstall(self) -> None:
if self._get_venv_path().exists():
rmtree(str(self._get_venv_path()))
self.reported_version = None # Reset the reported version from the previous installation
@property
def docs_url(self) -> str:
"""Get the URL to the connector's documentation."""
return "https://docs.airbyte.com/integrations/sources/" + self.name.lower().replace(
"source-", ""
)
def install(self) -> None:
"""Install the connector in a virtual environment.
After installation, the installed version will be stored in self.reported_version.
""" # noqa: DOC501
if not (
self.use_python is None
or self.use_python is True
or self.use_python is False
or isinstance(self.use_python, (str, Path))
):
raise exc.PyAirbyteInputError(
message="Invalid use_python parameter type",
input_value=str(self.use_python),
)
python_override: str | None = None
if not NO_UV and isinstance(self.use_python, Path):
python_override = str(self.use_python.absolute())
elif not NO_UV and isinstance(self.use_python, str):
python_override = self.use_python
uv_cmd_prefix = ["uv"] if not NO_UV else []
python_clause: list[str] = ["--python", python_override] if python_override else []
venv_cmd: list[str] = [
*(uv_cmd_prefix or [sys.executable, "-m"]),
"venv",
str(self._get_venv_path()),
*python_clause,
]
print(
f"Creating '{self.name}' virtual environment with command '{' '.join(venv_cmd)}'",
file=sys.stderr,
)
self._run_subprocess_and_raise_on_failure(venv_cmd)
install_cmd = (
[
"uv",
"pip",
"install",
"--python", # uv requires --python after the subcommand
str(self.interpreter_path),
]
if not NO_UV
else [
"pip",
"--python", # pip requires --python before the subcommand
str(self.interpreter_path),
"install",
]
) + shlex.split(self.pip_url)
print(
f"Installing '{self.name}' into virtual environment '{self._get_venv_path()!s}' with "
f"command '{' '.join(install_cmd)}'...\n",
file=sys.stderr,
)
try:
self._run_subprocess_and_raise_on_failure(install_cmd)
except exc.AirbyteSubprocessFailedError as ex:
# If the installation failed, remove the virtual environment
# Otherwise, the connector will be considered as installed and the user may not be able
# to retry the installation.
with suppress(exc.AirbyteSubprocessFailedError):
self.uninstall()
raise exc.AirbyteConnectorInstallationError from ex
# Assuming the installation succeeded, store the installed version
self.reported_version = self.get_installed_version(raise_on_error=False, recheck=True)
log_install_state(self.name, state=EventState.SUCCEEDED)
print(
f"Connector '{self.name}' installed successfully!\n"
f"For more information, see the {self.name} documentation:\n"
f"{self.docs_url}#reference\n",
file=sys.stderr,
)
@overrides
def get_installed_version(
self,
*,
raise_on_error: bool = False,
recheck: bool = False,
) -> str | None:
"""Detect the version of the connector installed.
Returns the version string if it can be detected, otherwise None.
If raise_on_error is True, raise an exception if the version cannot be detected.
If recheck if False and the version has already been detected, return the cached value.
In the venv, we run the following:
> python -c "from importlib.metadata import version; print(version('<connector-name>'))"
""" # noqa: DOC501
if not recheck and self.reported_version:
return self.reported_version
connector_name = self.name
if not self.interpreter_path.exists():
# No point in trying to detect the version if the interpreter does not exist
if raise_on_error:
raise exc.PyAirbyteInternalError(
message="Connector's virtual environment interpreter could not be found.",
context={
"interpreter_path": self.interpreter_path,
},
)
return None
try:
package_name = (
self.metadata.pypi_package_name
if self.metadata and self.metadata.pypi_package_name
else f"airbyte-{connector_name}"
)
return subprocess.check_output(
[
self.interpreter_path,
"-c",
f"from importlib.metadata import version; print(version('{package_name}'))",
],
universal_newlines=True,
stderr=subprocess.PIPE, # Don't print to stderr
).strip()
except Exception:
if raise_on_error:
raise
return None
def ensure_installation(
self,
*,
auto_fix: bool = True,
) -> None:
"""Ensure that the connector is installed in a virtual environment.
If not yet installed and if install_if_missing is True, then install.
Optionally, verify that the installed version matches the target version.
Note: Version verification is not supported for connectors installed from a
local path.
""" # noqa: DOC501
# Store the installed version (or None if not installed)
if not self.reported_version:
self.reported_version = self.get_installed_version()
original_installed_version = self.reported_version
reinstalled = False
venv_name = f".venv-{self.name}"
if not self._get_venv_path().exists():
if not auto_fix:
raise exc.AirbyteConnectorInstallationError(
message="Virtual environment does not exist.",
connector_name=self.name,
context={
"venv_path": self._get_venv_path(),
},
)
# If the venv path does not exist, install.
self.install()
reinstalled = True
elif not self._get_connector_path().exists():
if not auto_fix:
raise exc.AirbyteConnectorInstallationError(
message="Could not locate connector executable within the virtual environment.",
connector_name=self.name,
context={
"connector_path": self._get_connector_path(),
},
)
# If the connector path does not exist, uninstall and re-install.
# This is sometimes caused by a failed or partial installation.
print(
"Connector executable not found within the virtual environment "
f"at {self._get_connector_path()!s}.\nReinstalling...",
file=sys.stderr,
)
self.uninstall()
self.install()
reinstalled = True
# By now, everything should be installed. Raise an exception if not.
connector_path = self._get_connector_path()
if not connector_path.exists():
raise exc.AirbyteConnectorInstallationError(
message="Connector's executable could not be found within the virtual environment.",
connector_name=self.name,
context={
"connector_path": self._get_connector_path(),
},
) from FileNotFoundError(connector_path)
if self.enforce_version:
version_after_reinstall: str | None = None
if self.reported_version != self.target_version:
if auto_fix and not reinstalled:
# If we haven't already reinstalled above, reinstall now.
self.install()
reinstalled = True
if reinstalled:
version_after_reinstall = self.reported_version
# Check the version again
if self.reported_version != self.target_version:
raise exc.AirbyteConnectorInstallationError(
message="Connector's reported version does not match the target version.",
connector_name=self.name,
context={
"venv_name": venv_name,
"target_version": self.target_version,
"original_installed_version": original_installed_version,
"version_after_reinstall": version_after_reinstall,
},
)
@property
def _cli(self) -> list[str]:
"""Get the base args of the CLI executable."""
return [str(self._get_connector_path())]