Source code for exasol.nb_connector.slc.script_language_container

from __future__ import annotations

import re
from collections import namedtuple
from pathlib import (
    Path,
)

import requests
from exasol.slc import api as exaslct_api
from exasol.slc.models.compression_strategy import CompressionStrategy
from exasol_integration_test_docker_environment.lib.docker import (
    ContextDockerClient,
)

from exasol.nb_connector.ai_lab_config import AILabConfig as CKey
from exasol.nb_connector.secret_store import Secrets
from exasol.nb_connector.slc import constants
from exasol.nb_connector.slc.slc_compression_strategy import SlcCompressionStrategy
from exasol.nb_connector.slc.slc_flavor import (
    SlcError,
    SlcFlavor,
)
from exasol.nb_connector.slc.workspace import (
    Workspace,
    current_directory,
)

PipPackageDefinition = namedtuple("PipPackageDefinition", ["pkg", "version"])
CondaPackageDefinition = namedtuple("CondaPackageDefinition", ["pkg", "version"])

NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$", flags=re.IGNORECASE)


def _verify_name(slc_name: str) -> None:
    if not NAME_PATTERN.match(slc_name):
        raise SlcError(
            f'SLC name "{slc_name}" doesn\'t match'
            f' regular expression "{NAME_PATTERN}".'
        )


def _append_packages(
    file_path: Path, packages: list[PipPackageDefinition] | list[CondaPackageDefinition]
):
    """
    Appends packages to the custom packages file.
    """
    with open(file_path, "a") as f:
        for p in packages:
            print(f"{p.pkg}|{p.version}", file=f)


[docs] class ScriptLanguageContainer: """ Support building different flavors of Exasol Script Language Containers (SLCs) using the SLCT. Parameter ``name`` serves as base of the language alias and a key for the related flavor stored in the Secure Configuration Storage (SCS / secrets / conf). The flavor is used as a template for building the SLC. If the flavor is missing in the SCS or the SLC Git repository has not been checked out (i.e. cloned) into the checkout_dir, then the constructor will raise an SlcError. Additionally, the caller needs to ensure, that a flavor with this name is contained in the SLC release specified in variable constants.SLC_RELEASE_TAG. """ GITHUB_URL = f"https://github.com/exasol/script-languages-release/releases/tag/{constants.SLC_RELEASE_TAG}" """ Hyperlink to the GitHub tag which is being used. """ def __init__( self, secrets: Secrets, name: str, ): self.secrets = secrets self.name = name _verify_name(name) self.flavor = SlcFlavor(name).verify(secrets) self.compression_strategy = SlcCompressionStrategy(name).verify(secrets) self.workspace = Workspace.for_slc(name) if not self.checkout_dir.is_dir(): raise SlcError( f"SLC Git repository not checked out to {self.checkout_dir}." ) if not self.flavor_path.is_dir(): raise SlcError( f"Given flavor {self.flavor} not found in version {constants.SLC_RELEASE_TAG} of " "Script-Languages-Release. " "Check out available flavors at https://github.com/exasol/script-languages-release/releases/tag/{constants.SLC_RELEASE_TAG}." )
[docs] @classmethod def create( cls, secrets: Secrets, name: str, flavor: str, compression_strategy: CompressionStrategy = CompressionStrategy.GZIP, ) -> ScriptLanguageContainer: _verify_name(name) slc_flavor = SlcFlavor(name) if slc_flavor.exists(secrets): raise SlcError( "Secure Configuration Storage already contains a" f" flavor for SLC name {name}." ) slc_compression_strategy = SlcCompressionStrategy(slc_name=name) if slc_compression_strategy.exists(secrets): raise SlcError( "Secure Configuration Storage already contains a" f" compression strategy for SLC name {name}." ) slc_flavor.save(secrets, flavor) slc_compression_strategy.save(secrets, compression_strategy) workspace = Workspace.for_slc(name) workspace.clone_slc_repo() return cls(secrets=secrets, name=name)
@property def language_alias(self) -> str: """ Is case-insensitive. """ return f"custom_slc_{self.name}" @property def checkout_dir(self) -> Path: return self.workspace.git_clone_path @property def _flavor_path_rel(self) -> str: return str(self.flavor_path.relative_to(self.checkout_dir)) @property def flavor_path(self) -> Path: return self.checkout_dir / constants.FLAVORS_PATH_IN_SLC_REPO / self.flavor @property def custom_packages_dir(self): """ Returns the path to the custom packages directory of the flavor """ return self.flavor_path / "flavor_customization" / "packages" @property def custom_pip_file(self) -> Path: """ Returns the path to the custom pip packages file of the flavor """ return self.custom_packages_dir / "python3_pip_packages" @property def custom_conda_file(self) -> Path: """ Returns the path to the custom conda packages file of the flavor """ return self.custom_packages_dir / "conda_packages"
[docs] def export(self) -> None: """ Exports the current SLC to the export directory. """ with current_directory(self.checkout_dir): exaslct_api.export( flavor_path=(str(self._flavor_path_rel),), export_path=str(self.workspace.export_path), output_directory=str(self.workspace.output_path), release_name=self.language_alias, compression_strategy=self.compression_strategy, )
[docs] def export_no_copy(self) -> None: """ Exports the current SLC to the internal output directory only, without copying to the export directory. """ with current_directory(self.checkout_dir): exaslct_api.export( flavor_path=(str(self._flavor_path_rel),), output_directory=str(self.workspace.output_path), release_name=self.language_alias, compression_strategy=self.compression_strategy, )
[docs] def deploy(self): """ Deploys the current script-languages-container to the database and stores the activation string in the Secure Configuration Storage. """ bfs_params = { k: self.secrets.get(v) for k, v in [ ("bucketfs_host", CKey.bfs_host_name), ("bucketfs_port", CKey.bfs_port), ("bucketfs_user", CKey.bfs_user), ("bucketfs_password", CKey.bfs_password), ("bucketfs_name", CKey.bfs_service), ("bucket", CKey.bfs_bucket), ] } with current_directory(self.checkout_dir): result = exaslct_api.deploy( flavor_path=(str(self._flavor_path_rel),), **bfs_params, path_in_bucket=constants.PATH_IN_BUCKET, release_name=self.language_alias, output_directory=str(self.workspace.output_path), compression_strategy=self.compression_strategy, ) deploy_result = result[self._flavor_path_rel]["release"] builder = deploy_result.language_definition_builder components = builder.generate_definition_components() builder.add_custom_alias(components[0].alias, self.language_alias) lang_def = builder.generate_definition() self.secrets.save(self._alias_key, lang_def)
@property def _alias_key(self): return constants.SLC_ACTIVATION_KEY_PREFIX + self.language_alias @property def activation_key(self) -> str: """ Returns the language activation string for the uploaded script-language-container. Can be used in `ALTER SESSION` or `ALTER_SYSTEM` SQL commands to activate the language of the uploaded script-language-container. """ try: return self.secrets[self._alias_key] except AttributeError as ex: raise SlcError( "Secure Configuration Storage does not contains an activation key." ) from ex
[docs] def append_custom_pip_packages(self, pip_packages: list[PipPackageDefinition]): """ Appends packages to the custom pip packages file. Note: This method is not idempotent: Multiple calls with the same package definitions will result in duplicated entries. """ _append_packages(self.custom_pip_file, pip_packages)
[docs] def append_custom_conda_packages( self, conda_packages: list[CondaPackageDefinition] ): """ Appends packages to the custom conda packages file. Note: This method is not idempotent: Multiple calls with the same package definitions will result in duplicated entries. """ _append_packages(self.custom_conda_file, conda_packages)
@property def docker_image_tags(self) -> list[str]: """ Return list of Docker image tags related to the current SLC. """ image_name = constants.SLC_DOCKER_IMG_NAME prefix = f"{image_name}:{self.flavor}" with ContextDockerClient() as docker_client: images = docker_client.images.list(name=image_name) return [tag for img in images if (tag := img.tags[0]).startswith(prefix)]
[docs] @classmethod def clean_docker_images(cls, output_path: Path = Path.cwd()) -> None: """ Deletes all SLC related local docker images. :param output_path: Defines the path where the log files are stored. """ exaslct_api.clean_all_images( output_directory=str(output_path), )
[docs] @classmethod def list_available_flavors(cls) -> list[str]: owner = "exasol" repo = "script-languages-release" path = "flavors" url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}" params = {"ref": constants.SLC_RELEASE_TAG} try: response = requests.get(url, params=params, timeout=30) data = response.json() return [ (item["name"]) for item in data if item["type"] in ("dir", "symlink") and item["name"].startswith("template") ] except requests.exceptions.RequestException as ex: raise SlcError("Unable to fetch flavor definitions.") from ex