diff --git a/component_config/configSchema.json b/component_config/configSchema.json index ec91949..2f8b636 100644 --- a/component_config/configSchema.json +++ b/component_config/configSchema.json @@ -99,6 +99,55 @@ } } }, + "private_pypi": { + "type": "object", + "title": "Private PyPI Repository (Optional)", + "propertyOrder": 55, + "options": { + "tooltip": "Configure a private PyPI repository (e.g., Artifactory, Nexus, or any PEP 503 compliant index) to install packages from. The private repository will be used as an additional index alongside the public PyPI." + }, + "properties": { + "enabled": { + "type": "boolean", + "title": "Enable Private PyPI Repository", + "default": false, + "propertyOrder": 1 + }, + "url": { + "type": "string", + "title": "Repository URL", + "propertyOrder": 2, + "options": { + "tooltip": "The URL of the private PyPI repository (e.g., https://artifactory.example.com/api/pypi/pypi-local/simple/). Must be a PEP 503 compliant simple index URL.", + "dependencies": { + "enabled": true + } + } + }, + "username": { + "type": "string", + "title": "Username", + "propertyOrder": 3, + "options": { + "tooltip": "Username for authentication (optional for some repositories).", + "dependencies": { + "enabled": true + } + } + }, + "#password": { + "type": "string", + "title": "Password / Token", + "propertyOrder": 4, + "options": { + "tooltip": "Password or API token for authentication.", + "dependencies": { + "enabled": true + } + } + } + } + }, "git": { "type": "object", "title": "Git Repository Source Settings", @@ -195,4 +244,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/component.py b/src/component.py index 01b587a..8b0ae3e 100644 --- a/src/component.py +++ b/src/component.py @@ -76,9 +76,9 @@ def run(self): if self.parameters.source == SourceEnum.CODE: if "keboola.component" not in self.parameters.packages: self.parameters.packages.insert(0, "keboola.component") - PackageInstaller.install_packages(self.parameters.packages) + PackageInstaller.install_packages(self.parameters.packages, self.parameters.private_pypi) else: - PackageInstaller.install_packages_for_repository(base_path) + PackageInstaller.install_packages_for_repository(base_path, self.parameters.private_pypi) self._merge_user_parameters() diff --git a/src/configuration.py b/src/configuration.py index b3f9e3c..915f62e 100644 --- a/src/configuration.py +++ b/src/configuration.py @@ -49,6 +49,14 @@ class GitConfiguration: ssh_keys: SSHKeysConfiguration = field(default_factory=SSHKeysConfiguration) +@dataclass +class PrivatePyPIConfiguration: + enabled: bool = False + url: str = "" + username: str = "" + encrypted_password: str | None = None + + @dataclass class Configuration: source: SourceEnum = SourceEnum.CODE @@ -57,6 +65,7 @@ class Configuration: packages: list[str] = field(default_factory=list) code: str = "" git: GitConfiguration = field(default_factory=GitConfiguration) + private_pypi: PrivatePyPIConfiguration = field(default_factory=PrivatePyPIConfiguration) def __post_init__(self): if isinstance(self.user_properties, list): diff --git a/src/package_installer.py b/src/package_installer.py index 9e2b116..558408f 100644 --- a/src/package_installer.py +++ b/src/package_installer.py @@ -1,42 +1,81 @@ import logging import os from pathlib import Path +from typing import TYPE_CHECKING from subprocess_runner import SubprocessRunner +if TYPE_CHECKING: + from configuration import PrivatePyPIConfiguration + MSG_OK = "Installation successful." MSG_ERR = "Installation failed." +PRIVATE_PYPI_INDEX_NAME = "private" + class PackageInstaller: @staticmethod - def install_packages(packages: list[str]): + def _setup_private_pypi_env(private_pypi: "PrivatePyPIConfiguration | None") -> None: + """ + Set up environment variables for private PyPI repository authentication. + + Args: + private_pypi: Private PyPI configuration, or None if not configured. + """ + if private_pypi is None or not private_pypi.enabled or not private_pypi.url: + return + + logging.info("Configuring private PyPI repository: %s", private_pypi.url) + + os.environ["UV_INDEX"] = f"{PRIVATE_PYPI_INDEX_NAME}={private_pypi.url}" + + if private_pypi.username: + env_var_name = f"UV_INDEX_{PRIVATE_PYPI_INDEX_NAME.upper()}_USERNAME" + os.environ[env_var_name] = private_pypi.username + + if private_pypi.encrypted_password: + env_var_name = f"UV_INDEX_{PRIVATE_PYPI_INDEX_NAME.upper()}_PASSWORD" + os.environ[env_var_name] = private_pypi.encrypted_password + + @staticmethod + def install_packages( + packages: list[str], + private_pypi: "PrivatePyPIConfiguration | None" = None + ) -> None: + PackageInstaller._setup_private_pypi_env(private_pypi) + for package in packages: logging.info("Installing package: %s...", package) args = ["uv", "pip", "install", package] SubprocessRunner.run(args, MSG_OK, MSG_ERR) @staticmethod - def install_packages_for_repository(repository_path: Path): + def install_packages_for_repository( + repository_path: Path, + private_pypi: "PrivatePyPIConfiguration | None" = None + ) -> None: """ Install packages based on the given repository path. - If there is a pyproject.toml and a uv.lock file, run uv sync. - If there is a requirements.txt file, install packages from it using uv. Args: - repository_path (str): Path to the repository containing requirements.txt. + repository_path: Path to the repository containing requirements.txt. + private_pypi: Private PyPI configuration, or None if not configured. """ + PackageInstaller._setup_private_pypi_env(private_pypi) + pyproject_file = repository_path / "pyproject.toml" uv_lock_file = repository_path / "uv.lock" requirements_file = repository_path / "requirements.txt" - # Explicitly install keboola.component in case user didn't include in their dependencies file - PackageInstaller.install_packages(["keboola.component"]) + PackageInstaller.install_packages(["keboola.component"], private_pypi) args = None if pyproject_file.exists() and uv_lock_file.exists(): logging.info("Running uv sync...") - os.chdir(repository_path) # it is currently impossible to pass custom uv.lock path + os.chdir(repository_path) args = ["uv", "sync", "--inexact"] elif requirements_file.exists(): logging.info("Installing packages from requirements.txt...")