From 1e3e2904fbc581427893074dd437b8b144f4c4ea Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:21:28 +0000 Subject: [PATCH] feat: add support for private PyPI repositories Add configuration options for private PyPI repository (Artifactory, Nexus, etc.) to install packages from. The private repository is used as an additional index alongside the public PyPI. Configuration includes: - enabled: boolean to enable/disable the feature - url: the private PyPI repository URL (PEP 503 compliant) - username: optional username for authentication - password: encrypted password/token for authentication The implementation uses uv's UV_INDEX environment variable to configure the additional index, and UV_INDEX_PRIVATE_USERNAME/PASSWORD for authentication. Co-Authored-By: Vojta Tuma --- component_config/configSchema.json | 51 +++++++++++++++++++++++++++++- src/component.py | 4 +-- src/configuration.py | 9 ++++++ src/package_installer.py | 51 ++++++++++++++++++++++++++---- 4 files changed, 106 insertions(+), 9 deletions(-) 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...")