From 73c580cdd65b29d8387dfbc7742ad8bc2623d3e3 Mon Sep 17 00:00:00 2001 From: soustruh Date: Tue, 3 Jun 2025 11:58:00 +0200 Subject: [PATCH 01/31] =?UTF-8?q?support=20for=20cloning=20repositories=20?= =?UTF-8?q?using=20git=20=F0=9F=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ssh/known_hosts | 12 +++++ Dockerfile | 4 ++ pyproject.toml | 1 + src/component.py | 38 ++++++++-------- src/configuration.py | 31 +++++++++++++ src/source_file.py | 12 +++++ src/source_git.py | 102 +++++++++++++++++++++++++++++++++++++++++++ uv.lock | 49 ++++++++++++--------- 8 files changed, 208 insertions(+), 41 deletions(-) create mode 100644 .ssh/known_hosts create mode 100644 src/configuration.py create mode 100644 src/source_file.py create mode 100644 src/source_git.py diff --git a/.ssh/known_hosts b/.ssh/known_hosts new file mode 100644 index 0000000..0ec72b2 --- /dev/null +++ b/.ssh/known_hosts @@ -0,0 +1,12 @@ +# In case public repositories update their keys, the component will start failing with: +# Host key verification failed. +# fatal: Could not read from remote repository. +# +# In such case, retrieve the new keys using the following command: +# ssh-keyscan -t ed25519 github.com + +# github.com:22 SSH-2.0-f892a94b +github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl + +# bitbucket.org:22 SSH-2.0-conker_74c0242eb7-dirty 787a0a0e3e79 +bitbucket.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIazEu89wgQZ4bqs3d63QSMzYVa0MuJ2e2gKTKqu+UUO diff --git a/Dockerfile b/Dockerfile index 0a6352a..c797269 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,10 @@ ENV UV_PROJECT_ENVIRONMENT="/home/default/" # Run uv sync as uid/gid 1000 so we don't have to chown the /home/default directory with 100k files =-O USER 1000:1000 +# Add Github SSH host key to known_hosts file +RUN mkdir /home/${USERNAME}/.ssh +COPY .ssh/known_hosts /home/${USERNAME}/.ssh/known_hosts + WORKDIR /code/ COPY pyproject.toml . COPY uv.lock . diff --git a/pyproject.toml b/pyproject.toml index 7bc67b6..9992262 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.10" dependencies = [ + "dacite>=1.9.2", "keboola-component>=1.6.10", ] diff --git a/src/component.py b/src/component.py index 8727a07..4cc7812 100644 --- a/src/component.py +++ b/src/component.py @@ -11,17 +11,13 @@ import traceback from traceback import TracebackException +import dacite from keboola.component.base import ComponentBase from keboola.component.exceptions import UserException -# configuration variables -KEY_API_TOKEN = "#api_token" -KEY_PRINT_HELLO = "print_hello" - -# list of mandatory parameters => if some is missing, -# component will fail with readable message on initialization. -REQUIRED_PARAMETERS = [KEY_PRINT_HELLO] -REQUIRED_IMAGE_PARS = [] +import source_file +import source_git +from configuration import Configuration, SourceEnum, encrypted_keys class Component(ComponentBase): @@ -37,26 +33,26 @@ class Component(ComponentBase): def __init__(self): super().__init__() + self._set_init_logging_handler() + self.parameters = dacite.from_dict( + Configuration, + self.configuration.parameters, + config=dacite.Config(cast=[SourceEnum], convert_key=encrypted_keys), + ) def run(self): - parameters = self.configuration.parameters - - self._set_init_logging_handler() - script_path = os.path.join(self.data_folder_path, "script.py") - self.prepare_script_file(script_path) + if self.parameters.source == SourceEnum.CODE: + script_path = source_file.FileHandler.prepare_script_file(self.data_folder_path, self.parameters.code) + else: + git_handler = source_git.GitHandler(self.parameters.git) + script_path = git_handler.clone_repository() self._merge_user_parameters() - # install packages - self.install_packages(parameters.get("packages", [])) + self.install_packages(self.parameters.packages) self.execute_script_file(script_path) - def prepare_script_file(self, destination_path: str): - script = self.configuration.parameters["code"] - with open(destination_path, "w+") as file: - file.write(script) - def execute_script_file(self, file_path): # Change current working directory so that relative paths work os.chdir(self.data_folder_path) @@ -122,7 +118,7 @@ def _merge_user_parameters(self): config_data = self.configuration.config_data.copy() # build config data and overwrite for the user script - config_data["parameters"] = self.configuration.parameters.get("user_properties", {}) + config_data["parameters"] = self.parameters.user_properties with open(os.path.join(self.data_folder_path, "config.json"), "w+") as inp: json.dump(config_data, inp) diff --git a/src/configuration.py b/src/configuration.py new file mode 100644 index 0000000..657a0a4 --- /dev/null +++ b/src/configuration.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass, field +from enum import Enum + + +# the encrypted keys (prefixed with # in Keboola) have to be prefixed with "encrypted_" here +def encrypted_keys(key: str) -> str: + return key.replace("encrypted_", "#") if key.startswith("encrypted_") else key + + +class SourceEnum(Enum): + CODE = "code" + GIT = "git" + + +@dataclass +class GitConfiguration: + url: str = "" + branch: str = "main" + filename: str = "main.py" + username: str | None = None # not used at all, could be removed from the configuration + encrypted_token: str | None = None + encrypted_ssh_key: str | None = None + + +@dataclass +class Configuration: + source: SourceEnum = SourceEnum.CODE + packages: list[str] = field(default_factory=list) + user_properties: dict[str, object] = field(default_factory=dict) + code: str = "" + git: GitConfiguration = field(default_factory=GitConfiguration) diff --git a/src/source_file.py b/src/source_file.py new file mode 100644 index 0000000..f88a2ec --- /dev/null +++ b/src/source_file.py @@ -0,0 +1,12 @@ +import os + + +class FileHandler: + @staticmethod + def prepare_script_file(destination_path: str, script: str) -> str: + script_filename = os.path.join(destination_path, "script.py") + + with open(script_filename, "w") as file: + file.write(script) + + return script_filename diff --git a/src/source_git.py b/src/source_git.py new file mode 100644 index 0000000..e9ff911 --- /dev/null +++ b/src/source_git.py @@ -0,0 +1,102 @@ +import logging +import os +import subprocess + +from keboola.component.exceptions import UserException + +from configuration import GitConfiguration + + +REPO_PATH = "repo_clone" + + +class GitHandler: + def __init__(self, cfg: GitConfiguration): + self.cfg = cfg + + def clone_repository(self): + """ + Clone a git repository and return the path to the cloned code. + + Returns: + Path to the main script file to execute + """ + repo_url = self.cfg.url + if not repo_url: + raise UserException("Git repository URL is required") + + branch = self.cfg.branch or "main" + + logging.info("Cloning git repository: %s", repo_url) + + try: + clone_args = ["git", "clone"] + if branch: + clone_args.extend(["--branch", branch]) + + if self.cfg.encrypted_ssh_key and self.cfg.encrypted_token: + self.cfg.encrypted_token = None + + if self.cfg.encrypted_token: + repo_url = repo_url.replace("https://", f"https://x-token-auth:{self.cfg.encrypted_token}@") + clone_args.extend([repo_url, REPO_PATH]) + + env = os.environ.copy() + + ssh_command = [ + "ssh", + # the following lines could be used to disable strict host key checking, but it is better + # for security reasons to use the known_hosts file prepared in Dockerfile + # "-o", + # "StrictHostKeyChecking=no", + "-o", + "BatchMode=yes", # do not ask for credentials when SSH auth fails + "-o", + "ConnectTimeout=30", + "-o", + "ServerAliveInterval=60", + ] + + if self.cfg.encrypted_ssh_key: + ssh_key_path = os.path.expanduser("~/.ssh/github_private_key") + with open(ssh_key_path, "wb") as f: + for line in self.cfg.encrypted_ssh_key.splitlines(): + f.write(line.encode() + b"\n") + # ensure SSH key has correct permissions + os.chmod(ssh_key_path, 0o600) + ssh_command.extend(["-i", ssh_key_path]) + elif repo_url.startswith("git@") or repo_url.startswith("ssh://"): + logging.warning("SSH URL detected but no ssh_key_path provided. Trying default SSH configuration.") + + env["GIT_SSH_COMMAND"] = " ".join(ssh_command) + + process = subprocess.Popen( + clone_args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + _, stderr = process.communicate() + + if process.returncode != 0: + error_msg = stderr.decode() if stderr else "Unknown git clone error" + if "Permission denied" in error_msg or "publickey" in error_msg: + error_msg += ". Please check SSH key configuration or use HTTPS URL." + raise UserException(f"Failed to clone git repository: {error_msg}") + + logging.info("Successfully cloned repository") + + source_dir = os.path.join(os.getcwd(), REPO_PATH) + main_script_path = os.path.join(source_dir, self.cfg.filename) + if not os.path.exists(main_script_path): + raise UserException(f"Main script file '{self.cfg.filename}' not found in repository") + + return main_script_path + + except Exception as e: + raise UserException(f"Error processing git repository: {str(e)}") from e + + @staticmethod + def prepare_script_file(script: str, destination_path: str): + with open(destination_path, "w+") as file: + file.write(script) diff --git a/uv.lock b/uv.lock index 1c20df0..b8d51bf 100644 --- a/uv.lock +++ b/uv.lock @@ -1,16 +1,13 @@ version = 1 revision = 2 requires-python = ">=3.10" -resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version < '3.13'", -] [[package]] name = "custom-python" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "dacite" }, { name = "keboola-component" }, ] @@ -22,7 +19,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "keboola-component", specifier = ">=1.6.10" }] +requires-dist = [ + { name = "dacite", specifier = ">=1.9.2" }, + { name = "keboola-component", specifier = ">=1.6.10" }, +] [package.metadata.requires-dev] dev = [ @@ -31,6 +31,15 @@ dev = [ { name = "mock", specifier = ">=5.2.0" }, ] +[[package]] +name = "dacite" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/a0/7ca79796e799a3e782045d29bf052b5cde7439a2bbb17f15ff44f7aacc63/dacite-1.9.2.tar.gz", hash = "sha256:6ccc3b299727c7aa17582f0021f6ae14d5de47c7227932c47fec4cdfefd26f09", size = 22420, upload_time = "2025-02-05T09:27:29.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload_time = "2025-02-05T09:27:24.345Z" }, +] + [[package]] name = "deprecated" version = "1.2.18" @@ -45,28 +54,28 @@ wheels = [ [[package]] name = "flake8" -version = "7.1.2" +version = "7.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mccabe" }, { name = "pycodestyle" }, { name = "pyflakes" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/16/3f2a0bb700ad65ac9663262905a025917c020a3f92f014d2ba8964b4602c/flake8-7.1.2.tar.gz", hash = "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd", size = 48119, upload_time = "2025-02-16T18:45:44.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/c4/5842fc9fc94584c455543540af62fd9900faade32511fab650e9891ec225/flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426", size = 48177, upload_time = "2025-03-29T20:08:39.329Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/f8/08d37b2cd89da306e3520bd27f8a85692122b42b56c0c2c3784ff09c022f/flake8-7.1.2-py2.py3-none-any.whl", hash = "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a", size = 57745, upload_time = "2025-02-16T18:45:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/83/5c/0627be4c9976d56b1217cb5187b7504e7fd7d3503f8bfd312a04077bd4f7/flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343", size = 57786, upload_time = "2025-03-29T20:08:37.902Z" }, ] [[package]] name = "freezegun" -version = "1.5.1" +version = "1.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/ef/722b8d71ddf4d48f25f6d78aa2533d505bf3eec000a7cacb8ccc8de61f2f/freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9", size = 33697, upload_time = "2024-05-11T17:32:53.911Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/75/0455fa5029507a2150da59db4f165fbc458ff8bb1c4f4d7e8037a14ad421/freezegun-1.5.2.tar.gz", hash = "sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181", size = 34855, upload_time = "2025-05-24T12:38:47.051Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/0b/0d7fee5919bccc1fdc1c2a7528b98f65c6f69b223a3fd8f809918c142c36/freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1", size = 17569, upload_time = "2024-05-11T17:32:51.715Z" }, + { url = "https://files.pythonhosted.org/packages/b5/b2/68d4c9b6431121b6b6aa5e04a153cac41dcacc79600ed6e2e7c3382156f5/freezegun-1.5.2-py3-none-any.whl", hash = "sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b", size = 18715, upload_time = "2025-05-24T12:38:45.274Z" }, ] [[package]] @@ -103,20 +112,20 @@ wheels = [ [[package]] name = "pycodestyle" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/aa/210b2c9aedd8c1cbeea31a50e42050ad56187754b34eb214c46709445801/pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521", size = 39232, upload_time = "2024-08-04T20:26:54.576Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/6e/1f4a62078e4d95d82367f24e685aef3a672abfd27d1a868068fed4ed2254/pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae", size = 39312, upload_time = "2025-03-29T17:33:30.669Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/d8/a211b3f85e99a0daa2ddec96c949cac6824bd305b040571b82a03dd62636/pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", size = 31284, upload_time = "2024-08-04T20:26:53.173Z" }, + { url = "https://files.pythonhosted.org/packages/07/be/b00116df1bfb3e0bb5b45e29d604799f7b91dd861637e4d448b4e09e6a3e/pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9", size = 31424, upload_time = "2025-03-29T17:33:29.405Z" }, ] [[package]] name = "pyflakes" -version = "3.2.0" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/f9/669d8c9c86613c9d568757c7f5824bd3197d7b1c6c27553bc5618a27cce2/pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", size = 63788, upload_time = "2024-01-05T00:28:47.703Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/cc/1df338bd7ed1fa7c317081dcf29bf2f01266603b301e6858856d346a12b3/pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b", size = 64175, upload_time = "2025-03-31T13:21:20.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725, upload_time = "2024-01-05T00:28:45.903Z" }, + { url = "https://files.pythonhosted.org/packages/15/40/b293a4fa769f3b02ab9e387c707c4cbdc34f073f945de0386107d4e669e6/pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a", size = 63164, upload_time = "2025-03-31T13:21:18.503Z" }, ] [[package]] @@ -142,11 +151,11 @@ wheels = [ [[package]] name = "pytz" -version = "2025.1" +version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617, upload_time = "2025-01-31T01:54:48.615Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload_time = "2025-03-25T02:25:00.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930, upload_time = "2025-01-31T01:54:45.634Z" }, + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload_time = "2025-03-25T02:24:58.468Z" }, ] [[package]] From 3a32dc9e09445530dc777bf29536a1a770763f56 Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 4 Jun 2025 01:25:39 +0200 Subject: [PATCH 02/31] fix script excerpt & log it again --- src/component.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/component.py b/src/component.py index 4cc7812..099032b 100644 --- a/src/component.py +++ b/src/component.py @@ -61,9 +61,9 @@ def execute_script_file(self, file_path): try: with open(file_path) as file: script = file.read() - logging.debug('Executing script "%s"', self.script_excerpt(script)) + logging.info("Executing script:\n%s", self.script_excerpt(script)) runpy.run_path(file_path) - logging.info("Script finished") + logging.info("Script finished successfully.") except Exception as err: _, _, tb = sys.exc_info() stack_len = len(traceback.extract_tb(tb)[4:]) @@ -81,8 +81,8 @@ def _get_stack_trace_records(etype, value, tb, limit=None, chain=True): @staticmethod def script_excerpt(script): - if len(script) > 1000: - return script[0:500] + "\n...\n" + script[-500] + if len(script) > 640: + return script[:256] + "\n...\n" + script[-256:] else: return script From e81f2f1193dd11d53fd6f70b02f8de3f8f9b60e6 Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 4 Jun 2025 01:26:02 +0200 Subject: [PATCH 03/31] =?UTF-8?q?adding=20cloned=20repository=20root=20to?= =?UTF-8?q?=20sys.path=20for=20predictable=20imports=20=F0=9F=92=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/source_git.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/source_git.py b/src/source_git.py index e9ff911..9dc2acc 100644 --- a/src/source_git.py +++ b/src/source_git.py @@ -1,17 +1,21 @@ import logging import os +import pathlib import subprocess +import sys from keboola.component.exceptions import UserException from configuration import GitConfiguration -REPO_PATH = "repo_clone" - - class GitHandler: def __init__(self, cfg: GitConfiguration): + self.REPO_PATH = "repo_clone" + + # add path for absolute imports to start at the cloned repository root level + sys.path.append(os.path.join(pathlib.Path(__file__).parent.parent, self.REPO_PATH)) + self.cfg = cfg def clone_repository(self): @@ -31,6 +35,7 @@ def clone_repository(self): try: clone_args = ["git", "clone"] + if branch: clone_args.extend(["--branch", branch]) @@ -39,7 +44,8 @@ def clone_repository(self): if self.cfg.encrypted_token: repo_url = repo_url.replace("https://", f"https://x-token-auth:{self.cfg.encrypted_token}@") - clone_args.extend([repo_url, REPO_PATH]) + + clone_args.extend([repo_url, self.REPO_PATH]) env = os.environ.copy() @@ -86,7 +92,7 @@ def clone_repository(self): logging.info("Successfully cloned repository") - source_dir = os.path.join(os.getcwd(), REPO_PATH) + source_dir = os.path.join(os.getcwd(), self.REPO_PATH) main_script_path = os.path.join(source_dir, self.cfg.filename) if not os.path.exists(main_script_path): raise UserException(f"Main script file '{self.cfg.filename}' not found in repository") From 2134795cbf4c95a13968d807973aab93d9bf174a Mon Sep 17 00:00:00 2001 From: soustruh Date: Tue, 17 Jun 2025 17:23:17 +0200 Subject: [PATCH 04/31] UI for configuring git repository --- component_config/configSchema.json | 132 +++++++++++++++++++++++++---- src/configuration.py | 22 ++++- src/source_git.py | 66 +++++++++++---- 3 files changed, 184 insertions(+), 36 deletions(-) diff --git a/component_config/configSchema.json b/component_config/configSchema.json index 8c11e72..8ce2504 100644 --- a/component_config/configSchema.json +++ b/component_config/configSchema.json @@ -7,19 +7,41 @@ "packages" ], "properties": { - "code": { - "type": "string", - "title": "Python code", + "user_properties": { + "type": "object", + "title": "User Parameters", "format": "editor", - "default": "from keboola.component import CommonInterface\n\nci = CommonInterface()\n# access user parameters\nprint(ci.configuration.parameters)", + "propertyOrder": 10, + "default": { + "debug": false + }, "options": { + "tooltip": "User parameters will be inserted in the `/data/config.json` file. They can be accessed in the code via `keboola.component.CommonInterface`, see an example in the documentation or when creating a new configuration.", "editor": { - "mode": "text/x-python", + "lint": true, + "mode": "application/json", "lineNumbers": true, "input_height": "100px" } + } + }, + "source": { + "type": "radio", + "title": "Source code & dependencies", + "propertyOrder": 20, + "enum": [ + "code", + "git" + ], + "options": { + "tooltip": "If you choose to provide the code to be ran via Git repository, any custom packages to be installed have to be specified:\n\n- in a **pyproject.toml** file accompanied with its corresponding **uv.lock file** (the modern way), or\n- in a **requirements.txt** file (the old way).\n\nThese files need to be present in the root folder of your repository. When all the aforementioned files are present, the modern way takes precedence.", + "enum_titles": [ + "Enter manually into text areas below", + "Get from Git repository" + ] }, - "propertyOrder": 10 + "default": "code", + "required": false }, "packages": { "type": "array", @@ -28,30 +50,104 @@ }, "title": "Python packages", "format": "select", + "propertyOrder": 30, "options": { + "dependencies": { + "source": "code" + }, "tags": true }, "description": "Learn more about package installation, usage, and the list of pre-installed packages in our documentation.", - "uniqueItems": true, - "propertyOrder": 1 + "uniqueItems": true }, - "user_properties": { - "type": "object", - "title": "User Parameters", + "code": { + "type": "string", + "title": "Python code", "format": "editor", - "default": { - "debug": false - }, + "propertyOrder": 40, + "default": "from keboola.component import CommonInterface\n\nci = CommonInterface()\n# access user parameters\nprint(ci.configuration.parameters)", "options": { + "dependencies": { + "source": "code" + }, "editor": { - "lint": true, - "mode": "application/json", + "mode": "text/x-python", "lineNumbers": true, "input_height": "100px" } + } + }, + "git": { + "type": "object", + "title": "Git repository source settings", + "propertyOrder": 50, + "options": { + "dependencies": { + "source": "git" + } }, - "description": "User parameters are accessible, and the result will be injected into the standard data/config.json parameters property, as in any other component.", - "propertyOrder": 1 + "required": [ + "url", + "branch", + "filename" + ], + "properties": { + "url": { + "type": "string", + "title": "Repository URL", + "propertyOrder": 60 + }, + "branch": { + "type": "string", + "title": "Branch name", + "propertyOrder": 70 + }, + "filename": { + "type": "string", + "title": "Script filename", + "propertyOrder": 80 + }, + "auth": { + "type": "radio", + "title": "Repository visibility & authorization", + "propertyOrder": 90, + "enum": [ + "none", + "pat", + "ssh" + ], + "options": { + "enum_titles": [ + "Public – None", + "Private – PAT token", + "Private – SSH key" + ] + }, + "default": "none", + "required": false + }, + "#token": { + "type": "string", + "title": "Personal access token", + "propertyOrder": 100, + "options": { + "dependencies": { + "auth": "pat" + } + } + }, + "ssh_keys": { + "type": "object", + "format": "ssh-editor", + "propertyOrder": 110, + "options": { + "only_keys": true, + "dependencies": { + "auth": "ssh" + } + } + } + } } } } \ No newline at end of file diff --git a/src/configuration.py b/src/configuration.py index 657a0a4..a359e1a 100644 --- a/src/configuration.py +++ b/src/configuration.py @@ -12,14 +12,32 @@ class SourceEnum(Enum): GIT = "git" +class AuthEnum(Enum): + NONE = "none" + PAT = "pat" + SSH = "ssh" + + +# the ssh_keys.keys.[#private,public] structure is based on Keboola's standard SSH keys UI element output structure +@dataclass +class KeysConfiguration: + public: str | None = None + encrypted_private: str | None = None + + +@dataclass +class SSHKeysConfiguration: + keys: KeysConfiguration = field(default_factory=KeysConfiguration) + + @dataclass class GitConfiguration: url: str = "" branch: str = "main" filename: str = "main.py" - username: str | None = None # not used at all, could be removed from the configuration + auth: AuthEnum = AuthEnum.NONE encrypted_token: str | None = None - encrypted_ssh_key: str | None = None + ssh_keys: SSHKeysConfiguration = field(default_factory=SSHKeysConfiguration) @dataclass diff --git a/src/source_git.py b/src/source_git.py index 9dc2acc..9e02273 100644 --- a/src/source_git.py +++ b/src/source_git.py @@ -10,13 +10,13 @@ class GitHandler: - def __init__(self, cfg: GitConfiguration): + def __init__(self, git_cfg: GitConfiguration): self.REPO_PATH = "repo_clone" # add path for absolute imports to start at the cloned repository root level sys.path.append(os.path.join(pathlib.Path(__file__).parent.parent, self.REPO_PATH)) - self.cfg = cfg + self.git_cfg = git_cfg def clone_repository(self): """ @@ -25,11 +25,11 @@ def clone_repository(self): Returns: Path to the main script file to execute """ - repo_url = self.cfg.url + repo_url = self.git_cfg.url if not repo_url: raise UserException("Git repository URL is required") - branch = self.cfg.branch or "main" + branch = self.git_cfg.branch or "main" logging.info("Cloning git repository: %s", repo_url) @@ -39,11 +39,11 @@ def clone_repository(self): if branch: clone_args.extend(["--branch", branch]) - if self.cfg.encrypted_ssh_key and self.cfg.encrypted_token: - self.cfg.encrypted_token = None + if self.git_cfg.ssh_keys.keys.encrypted_private and self.git_cfg.encrypted_token: + self.git_cfg.encrypted_token = None - if self.cfg.encrypted_token: - repo_url = repo_url.replace("https://", f"https://x-token-auth:{self.cfg.encrypted_token}@") + if self.git_cfg.encrypted_token and repo_url.startswith("https://"): + repo_url = repo_url.replace("https://", f"https://x-token-auth:{self.git_cfg.encrypted_token}@") clone_args.extend([repo_url, self.REPO_PATH]) @@ -63,10 +63,10 @@ def clone_repository(self): "ServerAliveInterval=60", ] - if self.cfg.encrypted_ssh_key: + if self.git_cfg.ssh_keys.keys.encrypted_private: ssh_key_path = os.path.expanduser("~/.ssh/github_private_key") with open(ssh_key_path, "wb") as f: - for line in self.cfg.encrypted_ssh_key.splitlines(): + for line in self.git_cfg.ssh_keys.keys.encrypted_private.splitlines(): f.write(line.encode() + b"\n") # ensure SSH key has correct permissions os.chmod(ssh_key_path, 0o600) @@ -93,16 +93,50 @@ def clone_repository(self): logging.info("Successfully cloned repository") source_dir = os.path.join(os.getcwd(), self.REPO_PATH) - main_script_path = os.path.join(source_dir, self.cfg.filename) + main_script_path = os.path.join(source_dir, self.git_cfg.filename) if not os.path.exists(main_script_path): - raise UserException(f"Main script file '{self.cfg.filename}' not found in repository") + raise UserException(f"Main script file '{self.git_cfg.filename}' not found in repository") return main_script_path except Exception as e: raise UserException(f"Error processing git repository: {str(e)}") from e - @staticmethod - def prepare_script_file(script: str, destination_path: str): - with open(destination_path, "w+") as file: - file.write(script) + def get_repository_branches(self): + """ + Get a list of branches in the git repository. + + Returns: + List of branch names + """ + try: + repo_url = self.git_cfg.url + if not repo_url: + raise UserException("Git repository URL is required") + + branches_args = ["git", "ls-remote", "--heads"] + + if self.git_cfg.ssh_keys.keys.encrypted_private and self.git_cfg.encrypted_token: + self.git_cfg.encrypted_token = None + + if self.git_cfg.encrypted_token and repo_url.startswith("https://"): + repo_url = repo_url.replace("https://", f"https://x-token-auth:{self.git_cfg.encrypted_token}@") + + branches_args.append(repo_url) + + process = subprocess.Popen( + branches_args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=self.REPO_PATH, + ) + stdout, stderr = process.communicate() + + if process.returncode != 0: + raise UserException(f"Failed to get branches: {stderr.decode()}") + + branches = [line.strip().split("refs/heads")[-1] for line in stdout.decode().splitlines() if line.strip()] + return branches + + except Exception as e: + raise UserException(f"Error getting repository branches: {str(e)}") from e From d38f02cedc5a4155b0246c5c53c9657c6dcf6be9 Mon Sep 17 00:00:00 2001 From: soustruh Date: Tue, 17 Jun 2025 17:23:29 +0200 Subject: [PATCH 05/31] deploy in a test component --- .github/workflows/push.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 3f8a800..cdd2cd4 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -11,7 +11,7 @@ on: concurrency: ci-${{ github.ref }} # to avoid tag collisions in the ECR env: # repository variables: - KBC_DEVELOPERPORTAL_APP: "kds-team.app-custom-python" # replace with your component id + KBC_DEVELOPERPORTAL_APP: "kds-team.app-custom-python-test" # replace with your component id KBC_DEVELOPERPORTAL_VENDOR: "kds-team" # replace with your vendor DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} KBC_DEVELOPERPORTAL_USERNAME: "kds-team+github" @@ -201,7 +201,7 @@ jobs: - push_event_info - build - push - if: needs.push_event_info.outputs.is_deploy_ready == 'true' + # if: needs.push_event_info.outputs.is_deploy_ready == 'true' runs-on: ubuntu-latest steps: - name: Set Developer Portal Tag @@ -222,7 +222,7 @@ jobs: - build - push runs-on: ubuntu-latest - if: needs.push_event_info.outputs.is_deploy_ready == 'true' + # if: needs.push_event_info.outputs.is_deploy_ready == 'true' steps: - name: Checkout Repository uses: actions/checkout@v4 @@ -230,4 +230,4 @@ jobs: - name: Update developer portal properties run: | chmod +x scripts/developer_portal/*.sh - scripts/developer_portal/update_properties.sh \ No newline at end of file + scripts/developer_portal/update_properties.sh From e79f2785a9b7abf75ddf73c776f9e3ec30813ec1 Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 18 Jun 2025 02:17:18 +0200 Subject: [PATCH 06/31] letter case in UI --- component_config/configSchema.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/component_config/configSchema.json b/component_config/configSchema.json index 8ce2504..1a3e883 100644 --- a/component_config/configSchema.json +++ b/component_config/configSchema.json @@ -27,7 +27,7 @@ }, "source": { "type": "radio", - "title": "Source code & dependencies", + "title": "Source Code & Dependencies", "propertyOrder": 20, "enum": [ "code", @@ -48,7 +48,7 @@ "items": { "type": "string" }, - "title": "Python packages", + "title": "Python Packages", "format": "select", "propertyOrder": 30, "options": { @@ -62,7 +62,7 @@ }, "code": { "type": "string", - "title": "Python code", + "title": "Python Code", "format": "editor", "propertyOrder": 40, "default": "from keboola.component import CommonInterface\n\nci = CommonInterface()\n# access user parameters\nprint(ci.configuration.parameters)", @@ -79,7 +79,7 @@ }, "git": { "type": "object", - "title": "Git repository source settings", + "title": "Git Repository Source Settings", "propertyOrder": 50, "options": { "dependencies": { @@ -99,17 +99,17 @@ }, "branch": { "type": "string", - "title": "Branch name", + "title": "Branch Name", "propertyOrder": 70 }, "filename": { "type": "string", - "title": "Script filename", + "title": "Script Filename", "propertyOrder": 80 }, "auth": { "type": "radio", - "title": "Repository visibility & authorization", + "title": "Repository Visibility & Authentication", "propertyOrder": 90, "enum": [ "none", @@ -119,8 +119,8 @@ "options": { "enum_titles": [ "Public – None", - "Private – PAT token", - "Private – SSH key" + "Private – Personal Access Token", + "Private – SSH Key" ] }, "default": "none", @@ -128,7 +128,7 @@ }, "#token": { "type": "string", - "title": "Personal access token", + "title": "Personal Access Token", "propertyOrder": 100, "options": { "dependencies": { From 2e8649409247b341fc0b29d44582a784da321319 Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 18 Jun 2025 02:17:36 +0200 Subject: [PATCH 07/31] list branches sync action --- component_config/configSchema.json | 11 ++- src/component.py | 15 +++- src/source_git.py | 122 ++++++++++++++++------------- 3 files changed, 88 insertions(+), 60 deletions(-) diff --git a/component_config/configSchema.json b/component_config/configSchema.json index 1a3e883..5b9f371 100644 --- a/component_config/configSchema.json +++ b/component_config/configSchema.json @@ -99,8 +99,17 @@ }, "branch": { "type": "string", + "enum": [], "title": "Branch Name", - "propertyOrder": 70 + "propertyOrder": 70, + "options": { + "async": { + "cache": false, + "label": "List Branches", + "action": "listBranches", + "autoload": [] + } + } }, "filename": { "type": "string", diff --git a/src/component.py b/src/component.py index 099032b..daebb7d 100644 --- a/src/component.py +++ b/src/component.py @@ -12,12 +12,12 @@ from traceback import TracebackException import dacite -from keboola.component.base import ComponentBase +from keboola.component.base import ComponentBase, sync_action from keboola.component.exceptions import UserException import source_file import source_git -from configuration import Configuration, SourceEnum, encrypted_keys +from configuration import AuthEnum, Configuration, SourceEnum, encrypted_keys class Component(ComponentBase): @@ -37,7 +37,7 @@ def __init__(self): self.parameters = dacite.from_dict( Configuration, self.configuration.parameters, - config=dacite.Config(cast=[SourceEnum], convert_key=encrypted_keys), + config=dacite.Config(cast=[AuthEnum, SourceEnum], convert_key=encrypted_keys), ) def run(self): @@ -122,6 +122,15 @@ def _merge_user_parameters(self): with open(os.path.join(self.data_folder_path, "config.json"), "w+") as inp: json.dump(config_data, inp) + @sync_action("listBranches") + def get_repository_branches(self): + """ + Returns a list of branches in the git repository. + This method is used to populate the branches dropdown in the UI. + """ + git_handler = source_git.GitHandler(self.parameters.git) + return git_handler.get_repository_branches() + """ Main entrypoint diff --git a/src/source_git.py b/src/source_git.py index 9e02273..3d37aea 100644 --- a/src/source_git.py +++ b/src/source_git.py @@ -6,7 +6,7 @@ from keboola.component.exceptions import UserException -from configuration import GitConfiguration +from configuration import AuthEnum, GitConfiguration class GitHandler: @@ -16,7 +16,65 @@ def __init__(self, git_cfg: GitConfiguration): # add path for absolute imports to start at the cloned repository root level sys.path.append(os.path.join(pathlib.Path(__file__).parent.parent, self.REPO_PATH)) + self.env = os.environ.copy() self.git_cfg = git_cfg + self.repo_auth_url = None # ‼️ NEVER EVER INCLUDE THIS VARIABLE IN LOGGING OUTPUT ‼️ + + if not self.git_cfg.url: + raise UserException("Git repository URL is required") + + if self.git_cfg.auth == AuthEnum.PAT: + self._set_up_token_auth() + else: + self._set_up_ssh_command() + + # do not ask for credentials when git authentication fails + self.env["GIT_TERMINAL_PROMPT"] = "0" + + def _set_up_token_auth(self) -> None: + if not self.git_cfg.encrypted_token: + raise UserException("No personal access token provided") + + if not self.git_cfg.url.startswith("https://"): + raise UserException("PAT authentication is only supported for HTTPS URLs") + + self.repo_auth_url = self.git_cfg.url.replace( + "https://", f"https://x-token-auth:{self.git_cfg.encrypted_token}@" + ) + logging.info("Git token authentication set up for HTTPS URL.") + + def _set_up_ssh_command(self) -> None: + if self.git_cfg.auth == AuthEnum.SSH and not self.git_cfg.ssh_keys.keys.encrypted_private: + raise UserException("SSH key is required for SSH authentication") + + repo_url = self.git_cfg.url + if repo_url.startswith("git@") or repo_url.startswith("ssh://"): + logging.warning("SSH URL detected but no ssh_key_path provided. Trying default SSH configuration.") + + ssh_command = [ + "ssh", + # the following lines could be used to disable strict host key checking, but it is better + # for security reasons to use the known_hosts file prepared in Dockerfile + # "-o", + # "StrictHostKeyChecking=no", + "-o", + "BatchMode=yes", # do not ask for credentials when SSH auth fails + "-o", + "ConnectTimeout=30", + "-o", + "ServerAliveInterval=60", + ] + + if self.git_cfg.ssh_keys.keys.encrypted_private: + ssh_key_path = os.path.expanduser("~/.ssh/github_private_key") + with open(ssh_key_path, "wb") as f: + for line in self.git_cfg.ssh_keys.keys.encrypted_private.splitlines(): + f.write(line.encode() + b"\n") + # ensure SSH key has correct permissions + os.chmod(ssh_key_path, 0o600) + ssh_command.extend(["-i", ssh_key_path]) + + self.env["GIT_SSH_COMMAND"] = " ".join(ssh_command) def clone_repository(self): """ @@ -25,13 +83,10 @@ def clone_repository(self): Returns: Path to the main script file to execute """ - repo_url = self.git_cfg.url - if not repo_url: - raise UserException("Git repository URL is required") branch = self.git_cfg.branch or "main" - logging.info("Cloning git repository: %s", repo_url) + logging.info("Cloning git repository: %s", self.git_cfg.url) try: clone_args = ["git", "clone"] @@ -39,48 +94,13 @@ def clone_repository(self): if branch: clone_args.extend(["--branch", branch]) - if self.git_cfg.ssh_keys.keys.encrypted_private and self.git_cfg.encrypted_token: - self.git_cfg.encrypted_token = None - - if self.git_cfg.encrypted_token and repo_url.startswith("https://"): - repo_url = repo_url.replace("https://", f"https://x-token-auth:{self.git_cfg.encrypted_token}@") - - clone_args.extend([repo_url, self.REPO_PATH]) - - env = os.environ.copy() - - ssh_command = [ - "ssh", - # the following lines could be used to disable strict host key checking, but it is better - # for security reasons to use the known_hosts file prepared in Dockerfile - # "-o", - # "StrictHostKeyChecking=no", - "-o", - "BatchMode=yes", # do not ask for credentials when SSH auth fails - "-o", - "ConnectTimeout=30", - "-o", - "ServerAliveInterval=60", - ] - - if self.git_cfg.ssh_keys.keys.encrypted_private: - ssh_key_path = os.path.expanduser("~/.ssh/github_private_key") - with open(ssh_key_path, "wb") as f: - for line in self.git_cfg.ssh_keys.keys.encrypted_private.splitlines(): - f.write(line.encode() + b"\n") - # ensure SSH key has correct permissions - os.chmod(ssh_key_path, 0o600) - ssh_command.extend(["-i", ssh_key_path]) - elif repo_url.startswith("git@") or repo_url.startswith("ssh://"): - logging.warning("SSH URL detected but no ssh_key_path provided. Trying default SSH configuration.") - - env["GIT_SSH_COMMAND"] = " ".join(ssh_command) + clone_args.extend([self.repo_auth_url or self.git_cfg.url, self.REPO_PATH]) process = subprocess.Popen( clone_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - env=env, + env=self.env, ) _, stderr = process.communicate() @@ -110,33 +130,23 @@ def get_repository_branches(self): List of branch names """ try: - repo_url = self.git_cfg.url - if not repo_url: - raise UserException("Git repository URL is required") - branches_args = ["git", "ls-remote", "--heads"] - if self.git_cfg.ssh_keys.keys.encrypted_private and self.git_cfg.encrypted_token: - self.git_cfg.encrypted_token = None - - if self.git_cfg.encrypted_token and repo_url.startswith("https://"): - repo_url = repo_url.replace("https://", f"https://x-token-auth:{self.git_cfg.encrypted_token}@") - - branches_args.append(repo_url) + branches_args.append(self.repo_auth_url or self.git_cfg.url) process = subprocess.Popen( branches_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - cwd=self.REPO_PATH, + env=self.env, ) stdout, stderr = process.communicate() if process.returncode != 0: raise UserException(f"Failed to get branches: {stderr.decode()}") - branches = [line.strip().split("refs/heads")[-1] for line in stdout.decode().splitlines() if line.strip()] - return branches + branches = [line.strip().split("refs/heads/")[-1] for line in stdout.decode().splitlines() if line.strip()] + return [{"value": b, "label": b} for b in branches] except Exception as e: raise UserException(f"Error getting repository branches: {str(e)}") from e From 7cf4556bfb10572a956b9326f442f0c018acb189 Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 18 Jun 2025 14:10:05 +0200 Subject: [PATCH 08/31] create root's .ssh directory for running sync actions --- Dockerfile | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index c797269..0c69615 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,13 +16,18 @@ ENV UV_CACHE_DIR="/.cache/uv" # Using the same path as venv defined in the base image so we can use all the preinstalled packages ENV UV_PROJECT_ENVIRONMENT="/home/default/" -# Run uv sync as uid/gid 1000 so we don't have to chown the /home/default directory with 100k files =-O -USER 1000:1000 - # Add Github SSH host key to known_hosts file +USER 1000:1000 RUN mkdir /home/${USERNAME}/.ssh COPY .ssh/known_hosts /home/${USERNAME}/.ssh/known_hosts +# Create root's .ssh directory for storing SSH keys when running sync actions +USER root +RUN mkdir /root/.ssh +COPY .ssh/known_hosts /root/.ssh/known_hosts + +# Run uv sync as uid/gid 1000 so we don't have to chown the /home/default directory with 100k files =-O +USER 1000:1000 WORKDIR /code/ COPY pyproject.toml . COPY uv.lock . From ff2312d73a1db1e69875478dc97910cb70fca208 Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 18 Jun 2025 14:10:47 +0200 Subject: [PATCH 09/31] include workflow run number in image tag --- .github/workflows/push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index cdd2cd4..0643d6e 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -65,7 +65,7 @@ jobs: - name: Set image tag id: tag run: | - TAG="${GITHUB_REF##*/}" + TAG="${GITHUB_REF##*/}-${{ github.run_number }}" IS_SEMANTIC_TAG=$(echo "$TAG" | grep -q '^v\?[0-9]\+\.[0-9]\+\.[0-9]\+$' && echo true || echo false) echo "is_semantic_tag=$IS_SEMANTIC_TAG" | tee -a $GITHUB_OUTPUT echo "app_image_tag=$TAG" | tee -a $GITHUB_OUTPUT From 00307791bfd42550fb17791487af542e87657ea4 Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 18 Jun 2025 14:25:21 +0200 Subject: [PATCH 10/31] list files sync action --- component_config/configSchema.json | 15 ++++++++++++--- src/component.py | 9 +++++++++ src/source_git.py | 16 ++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/component_config/configSchema.json b/component_config/configSchema.json index 5b9f371..94d48e5 100644 --- a/component_config/configSchema.json +++ b/component_config/configSchema.json @@ -104,17 +104,26 @@ "propertyOrder": 70, "options": { "async": { - "cache": false, "label": "List Branches", "action": "listBranches", - "autoload": [] + "autoload": ["git.url"], + "cache": false } } }, "filename": { "type": "string", + "enum": [], "title": "Script Filename", - "propertyOrder": 80 + "propertyOrder": 80, + "options": { + "async": { + "label": "List Files", + "action": "listFiles", + "autoload": ["git.branch"], + "cache": false + } + } }, "auth": { "type": "radio", diff --git a/src/component.py b/src/component.py index daebb7d..001ed07 100644 --- a/src/component.py +++ b/src/component.py @@ -131,6 +131,15 @@ def get_repository_branches(self): git_handler = source_git.GitHandler(self.parameters.git) return git_handler.get_repository_branches() + @sync_action("listFiles") + def get_repository_files(self): + """ + Returns a list of branches in the git repository. + This method is used to populate the branches dropdown in the UI. + """ + git_handler = source_git.GitHandler(self.parameters.git) + return git_handler.get_repository_files() + """ Main entrypoint diff --git a/src/source_git.py b/src/source_git.py index 3d37aea..0252331 100644 --- a/src/source_git.py +++ b/src/source_git.py @@ -150,3 +150,19 @@ def get_repository_branches(self): except Exception as e: raise UserException(f"Error getting repository branches: {str(e)}") from e + + def get_repository_files(self): + self.clone_repository() + + files = [] + for dirpath, _, filenames in os.walk(self.REPO_PATH): + if dirpath.startswith(f"{self.REPO_PATH}/.git"): + continue + for filename in filenames: + if not filename.endswith(".py"): + continue + path = os.path.join(dirpath, filename) + # strip the repository path prefix + files.append(path[len(self.REPO_PATH) + 1 :]) + + return [{"value": f, "label": f} for f in files] From 822ced4209b1dd8c073ffc40e57aa5cb8d6885d0 Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 18 Jun 2025 14:39:31 +0200 Subject: [PATCH 11/31] ignore flake8's false positives caused by ruff --- flake8.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/flake8.cfg b/flake8.cfg index 4b7a645..de9858b 100644 --- a/flake8.cfg +++ b/flake8.cfg @@ -6,6 +6,7 @@ exclude = tests, example venv +ignore = E203,W503 max-line-length = 120 # F812: list comprehension redefines ... From 4f11ef43013baee217941eae2da94736c44411d4 Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 18 Jun 2025 15:28:14 +0200 Subject: [PATCH 12/31] =?UTF-8?q?cleanup=20=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/source_git.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/source_git.py b/src/source_git.py index 0252331..5465392 100644 --- a/src/source_git.py +++ b/src/source_git.py @@ -25,7 +25,9 @@ def __init__(self, git_cfg: GitConfiguration): if self.git_cfg.auth == AuthEnum.PAT: self._set_up_token_auth() - else: + + repo_url = self.git_cfg.url + if repo_url.startswith("git@") or repo_url.startswith("ssh://"): self._set_up_ssh_command() # do not ask for credentials when git authentication fails @@ -44,12 +46,11 @@ def _set_up_token_auth(self) -> None: logging.info("Git token authentication set up for HTTPS URL.") def _set_up_ssh_command(self) -> None: - if self.git_cfg.auth == AuthEnum.SSH and not self.git_cfg.ssh_keys.keys.encrypted_private: - raise UserException("SSH key is required for SSH authentication") - - repo_url = self.git_cfg.url - if repo_url.startswith("git@") or repo_url.startswith("ssh://"): - logging.warning("SSH URL detected but no ssh_key_path provided. Trying default SSH configuration.") + if not self.git_cfg.ssh_keys.keys.encrypted_private: + if self.git_cfg.auth == AuthEnum.SSH: + raise UserException("SSH key is required for SSH authentication") + elif self.git_cfg.auth == AuthEnum.NONE: + logging.warning("SSH URL detected but no SSH private key provided. Trying default SSH configuration.") ssh_command = [ "ssh", @@ -85,7 +86,6 @@ def clone_repository(self): """ branch = self.git_cfg.branch or "main" - logging.info("Cloning git repository: %s", self.git_cfg.url) try: From ff02d70cfa82b1c9c243a1255df15f89c4a1d803 Mon Sep 17 00:00:00 2001 From: soustruh Date: Mon, 23 Jun 2025 09:27:03 +0200 Subject: [PATCH 13/31] description of the updated configuration related to git support --- README.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2ab86d0..79be339 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ +- [Custom Python Component](#custom-python-component) + - [Configuration](#configuration) + - [Git configuration](#git-configuration) + - [SSH configuration](#ssh-configuration) + - [Example: Running code saved in custom repository](#example-running-code-saved-in-custom-repository) + - [Example: Listing preinstalled packages](#example-listing-preinstalled-packages) + - [Example: Accessing custom configuration parameters](#example-accessing-custom-configuration-parameters) + - [Development](#development) + - [Integration](#integration) + + # Custom Python Component This component lets you run your own Python code directly within Keboola, with support for custom dependencies configured via the UI. @@ -5,11 +16,58 @@ This component lets you run your own Python code directly within Keboola, with s ## Configuration -- `code`: JSON encoded Python code to run. -- `packages`: Array of extra packages to be installed. +- `source`: Source of the code to run. + - `code`: Custom code entered in a text field (default). + - `git`: Custom repository. - `user_properties`: Object containing custom configuration parameters. The key names prefixed with `#` will be encrypted upon saving. +- `git`: Object containing configuration of the git repository, which shall be cloned and run (`"source": "git"` only). +- `code`: JSON encoded Python code to run (`"source": "code"` only). +- `packages`: Array of extra packages to be installed (`"source": "code"` only). *If you're not sure whether you need to install certain package or not, you can run the command `uv pip list` via subprocess (see the example below).* + + +### Git configuration + +The git configuration object supports the following parameters: + +- `url`: Repository URL – supports both HTTPS and SSH formats +- `branch`: Branch name to checkout – UI provides branch selection +- `filename`: Python script filename to execute – UI lists available files +- `auth`: Authentication method + - `none`: Public repository, no authentication (default) + - `pat`: Private repository using Personal Access Token + - `ssh`: Private repository using SSH key +- `#token`: Personal Access Token (`"auth": "pat"` only). This value will be encrypted in Keboola Storage. +- `ssh_keys`: SSH keys configuration object (`"auth": "ssh"` only). + + +### SSH configuration + +- `keys`: Object containing both public and private keys. + - `public`: Public key saved in your Git project. This value is not passed by the component and is saved just for future reference. + - `#private`: Private key used for authentication. This value will be encrypted in Keboola Storage. + + +### Example: Running code saved in custom repository + +Contents of the `config.json` file: + +```json +{ + "parameters": { + "source": "git", + "git": { + "url": "https://github.com/keboola/component-custom-python-example-repo-1.git", + "branch": "main", + "filename": "main.py", + "auth": "none", + }, + "user_properties": { + "debug": true + } + } +} +``` -If you're not sure whether you need to install certain package or not, you can run the command `uv pip list` via subprocess (see the example below). ### Example: Listing preinstalled packages @@ -29,10 +87,10 @@ The above code in the `config.json` file format for local testing: ```json { - "parameters": { - "code": "import datetime\nimport subprocess\n\nprint(\"Hello world!\")\nprint(\"Current date and time:\", datetime.datetime.now())\nprint(\"See the full list of preinstalled packages:\")\n\nsubprocess.check_call([\"uv\", \"pip\", \"list\"])\n", - "packages": [] - } + "parameters": { + "code": "import datetime\nimport subprocess\n\nprint(\"Hello world!\")\nprint(\"Current date and time:\", datetime.datetime.now())\nprint(\"See the full list of preinstalled packages:\")\n\nsubprocess.check_call([\"uv\", \"pip\", \"list\"])\n", + "packages": [] + } } ``` @@ -53,14 +111,14 @@ The above code in the `config.json` file format for local testing: ```json { - "parameters": { - "code": "from keboola.component import CommonInterface\n\nci = CommonInterface()\n# access user parameters\nprint(ci.configuration.parameters)", - "packages": [], - "user_properties": { - "debug": false - "#secretCredentials": "theStrongestPasswordEver" - } + "parameters": { + "code": "from keboola.component import CommonInterface\n\nci = CommonInterface()\n# access user parameters\nprint(ci.configuration.parameters)", + "packages": [], + "user_properties": { + "debug": false + "#secretCredentials": "theStrongestPasswordEver" } + } } ``` From 8c6f87d02a39fd3da772b65aa456d6140738748b Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 25 Jun 2025 12:22:58 +0200 Subject: [PATCH 14/31] minor changes in wording --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 79be339..c2bc9e9 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ The git configuration object supports the following parameters: - `url`: Repository URL – supports both HTTPS and SSH formats - `branch`: Branch name to checkout – UI provides branch selection - `filename`: Python script filename to execute – UI lists available files -- `auth`: Authentication method +- `auth`: Repository visibility & authentication method - `none`: Public repository, no authentication (default) - - `pat`: Private repository using Personal Access Token - - `ssh`: Private repository using SSH key + - `pat`: Private repository, Personal Access Token + - `ssh`: Private repository, SSH key - `#token`: Personal Access Token (`"auth": "pat"` only). This value will be encrypted in Keboola Storage. - `ssh_keys`: SSH keys configuration object (`"auth": "ssh"` only). From 726cd7a5d9e7a4e46dcbe86a1f8ece18035f0e62 Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 25 Jun 2025 12:23:40 +0200 Subject: [PATCH 15/31] REPO_PATH is a class variable now --- src/source_git.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/source_git.py b/src/source_git.py index 5465392..271b927 100644 --- a/src/source_git.py +++ b/src/source_git.py @@ -10,11 +10,11 @@ class GitHandler: - def __init__(self, git_cfg: GitConfiguration): - self.REPO_PATH = "repo_clone" + REPO_PATH = "repo_clone" + def __init__(self, git_cfg: GitConfiguration): # add path for absolute imports to start at the cloned repository root level - sys.path.append(os.path.join(pathlib.Path(__file__).parent.parent, self.REPO_PATH)) + sys.path.append(os.path.join(pathlib.Path(__file__).parent.parent, GitHandler.REPO_PATH)) self.env = os.environ.copy() self.git_cfg = git_cfg @@ -94,7 +94,7 @@ def clone_repository(self): if branch: clone_args.extend(["--branch", branch]) - clone_args.extend([self.repo_auth_url or self.git_cfg.url, self.REPO_PATH]) + clone_args.extend([self.repo_auth_url or self.git_cfg.url, GitHandler.REPO_PATH]) process = subprocess.Popen( clone_args, @@ -112,7 +112,7 @@ def clone_repository(self): logging.info("Successfully cloned repository") - source_dir = os.path.join(os.getcwd(), self.REPO_PATH) + source_dir = os.path.join(os.getcwd(), GitHandler.REPO_PATH) main_script_path = os.path.join(source_dir, self.git_cfg.filename) if not os.path.exists(main_script_path): raise UserException(f"Main script file '{self.git_cfg.filename}' not found in repository") @@ -155,14 +155,14 @@ def get_repository_files(self): self.clone_repository() files = [] - for dirpath, _, filenames in os.walk(self.REPO_PATH): - if dirpath.startswith(f"{self.REPO_PATH}/.git"): + for dirpath, _, filenames in os.walk(GitHandler.REPO_PATH): + if dirpath.startswith(f"{GitHandler.REPO_PATH}/.git"): continue for filename in filenames: if not filename.endswith(".py"): continue path = os.path.join(dirpath, filename) # strip the repository path prefix - files.append(path[len(self.REPO_PATH) + 1 :]) + files.append(path[len(GitHandler.REPO_PATH) + 1 :]) return [{"value": f, "label": f} for f in files] From e242418d0028834f784a042963ce61894d17b2d7 Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 25 Jun 2025 12:29:40 +0200 Subject: [PATCH 16/31] package installation refactoring --- src/component.py | 36 ++++++---------------- src/package_installer.py | 66 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 27 deletions(-) create mode 100644 src/package_installer.py diff --git a/src/component.py b/src/component.py index 001ed07..e6d6f3b 100644 --- a/src/component.py +++ b/src/component.py @@ -6,7 +6,6 @@ import logging import os import runpy -import subprocess import sys import traceback from traceback import TracebackException @@ -15,9 +14,10 @@ from keboola.component.base import ComponentBase, sync_action from keboola.component.exceptions import UserException -import source_file -import source_git from configuration import AuthEnum, Configuration, SourceEnum, encrypted_keys +from package_installer import PackageInstaller +from source_file import FileHandler +from source_git import GitHandler class Component(ComponentBase): @@ -42,15 +42,15 @@ def __init__(self): def run(self): if self.parameters.source == SourceEnum.CODE: - script_path = source_file.FileHandler.prepare_script_file(self.data_folder_path, self.parameters.code) + script_path = FileHandler.prepare_script_file(self.data_folder_path, self.parameters.code) + PackageInstaller.install_packages(self.parameters.packages) else: - git_handler = source_git.GitHandler(self.parameters.git) + git_handler = GitHandler(self.parameters.git) script_path = git_handler.clone_repository() + PackageInstaller.install_packages_for_repository(GitHandler.REPO_PATH) self._merge_user_parameters() - self.install_packages(self.parameters.packages) - self.execute_script_file(script_path) def execute_script_file(self, file_path): @@ -86,24 +86,6 @@ def script_excerpt(script): else: return script - @staticmethod - def install_packages(packages): - for package in packages: - logging.info("Installing package: %s...", package) - args = [ - "uv", - "add", - package, - ] - process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - stdout, stderr = process.communicate() - process.poll() - logging.info("Installation finished: %s. Full log in detail.", package, extra={"full_message": stdout}) - if process.poll() != 0: - raise UserException(f"Failed to install package: {package}. Log in event detail.", stderr) - elif stderr: - logging.warning(stderr) - def _set_init_logging_handler(self): for h in logging.getLogger().handlers: h.setFormatter(logging.Formatter("[Non-script message]: %(message)s")) @@ -128,7 +110,7 @@ def get_repository_branches(self): Returns a list of branches in the git repository. This method is used to populate the branches dropdown in the UI. """ - git_handler = source_git.GitHandler(self.parameters.git) + git_handler = GitHandler(self.parameters.git) return git_handler.get_repository_branches() @sync_action("listFiles") @@ -137,7 +119,7 @@ def get_repository_files(self): Returns a list of branches in the git repository. This method is used to populate the branches dropdown in the UI. """ - git_handler = source_git.GitHandler(self.parameters.git) + git_handler = GitHandler(self.parameters.git) return git_handler.get_repository_files() diff --git a/src/package_installer.py b/src/package_installer.py new file mode 100644 index 0000000..5359ba5 --- /dev/null +++ b/src/package_installer.py @@ -0,0 +1,66 @@ +import logging +import os +import subprocess + +from keboola.component.exceptions import UserException + + +class PackageInstaller: + @staticmethod + def install_packages(packages: list[str]): + for package in packages: + logging.info("Installing package: %s...", package) + args = [ + "uv", + "add", + package, + ] + process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = process.communicate() + process.poll() + logging.info("Installation finished: %s. Full log in detail.", package, extra={"full_message": stdout}) + if process.poll() != 0: + raise UserException(f"Failed to install package: {package}. Log in event detail.", stderr) + elif stderr: + logging.warning(stderr) + + @staticmethod + def install_packages_for_repository(repository_path: str): + """ + 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. + """ + pyproject_file = os.path.join(repository_path, "pyproject.toml") + uv_lock_file = f"{repository_path}/uv.lock" + requirements_file = f"{repository_path}/requirements.txt" + + if os.path.exists(pyproject_file) and os.path.exists(uv_lock_file): + logging.info("Running uv sync") + args = ["uv", "sync", "--inexact"] + process = subprocess.Popen(args, cwd=repository_path, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = process.communicate() + process.poll() + logging.info("uv sync finished. Full log in detail.", extra={"full_message": stdout}) + if process.poll() != 0: + raise UserException("Failed to perform uv sync. Log in event detail.", stderr) + elif stderr: + logging.warning(stderr) + elif os.path.exists(requirements_file): + logging.info("Installing packages from requirements.txt") + args = ["uv", "pip", "install", "-r", requirements_file] + process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = process.communicate() + process.poll() + logging.info("Installation finished. Full log in detail.", extra={"full_message": stdout}) + if process.poll() != 0: + raise UserException("Failed to install packages. Log in event detail.", stderr) + elif stderr: + logging.warning(stderr) + else: + logging.info("No dependencies file found") + + logging.info("Package installation completed for repository: %s", repository_path) From f8bf79ad5756ad2598bf325a9239160cba9afa38 Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 25 Jun 2025 13:32:12 +0200 Subject: [PATCH 17/31] fix unexpected sync action failure & reduce code redundancy --- src/package_installer.py | 55 +++++++++++++++++----------------------- src/source_git.py | 9 +++++-- 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/src/package_installer.py b/src/package_installer.py index 5359ba5..15bdae4 100644 --- a/src/package_installer.py +++ b/src/package_installer.py @@ -10,19 +10,8 @@ class PackageInstaller: def install_packages(packages: list[str]): for package in packages: logging.info("Installing package: %s...", package) - args = [ - "uv", - "add", - package, - ] - process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - stdout, stderr = process.communicate() - process.poll() - logging.info("Installation finished: %s. Full log in detail.", package, extra={"full_message": stdout}) - if process.poll() != 0: - raise UserException(f"Failed to install package: {package}. Log in event detail.", stderr) - elif stderr: - logging.warning(stderr) + args = ["uv", "add", package] + PackageInstaller._run_installation_in_subprocess(args) @staticmethod def install_packages_for_repository(repository_path: str): @@ -38,29 +27,31 @@ def install_packages_for_repository(repository_path: str): uv_lock_file = f"{repository_path}/uv.lock" requirements_file = f"{repository_path}/requirements.txt" + args = None if os.path.exists(pyproject_file) and os.path.exists(uv_lock_file): - logging.info("Running uv sync") + logging.info("Running uv sync...") args = ["uv", "sync", "--inexact"] - process = subprocess.Popen(args, cwd=repository_path, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - stdout, stderr = process.communicate() - process.poll() - logging.info("uv sync finished. Full log in detail.", extra={"full_message": stdout}) - if process.poll() != 0: - raise UserException("Failed to perform uv sync. Log in event detail.", stderr) - elif stderr: - logging.warning(stderr) elif os.path.exists(requirements_file): - logging.info("Installing packages from requirements.txt") + logging.info("Installing packages from requirements.txt...") args = ["uv", "pip", "install", "-r", requirements_file] - process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - stdout, stderr = process.communicate() - process.poll() - logging.info("Installation finished. Full log in detail.", extra={"full_message": stdout}) - if process.poll() != 0: - raise UserException("Failed to install packages. Log in event detail.", stderr) - elif stderr: - logging.warning(stderr) - else: + + if not args: logging.info("No dependencies file found") + return + + PackageInstaller._run_installation_in_subprocess(args) logging.info("Package installation completed for repository: %s", repository_path) + + @staticmethod + def _run_installation_in_subprocess(args: list[str]): + logging.debug("Running command: %s", " ".join(args)) + process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + _, stderr = process.communicate() + process.poll() + if process.poll() != 0: + message = stderr.decode() if stderr else "Unknown installation error" + raise UserException("Installation failed. Log in event detail.", message) + elif stderr: + message = stderr.decode() if stderr else "uv output empty." + logging.info("Installation finished. Full log in detail.", extra={"full_message": message}) diff --git a/src/source_git.py b/src/source_git.py index 271b927..7b6e573 100644 --- a/src/source_git.py +++ b/src/source_git.py @@ -77,7 +77,7 @@ def _set_up_ssh_command(self) -> None: self.env["GIT_SSH_COMMAND"] = " ".join(ssh_command) - def clone_repository(self): + def clone_repository(self, sync_action=False): """ Clone a git repository and return the path to the cloned code. @@ -112,6 +112,11 @@ def clone_repository(self): logging.info("Successfully cloned repository") + # when cloning for the "list files" sync action, checking for the script file presence doesn't make sense + # and could cause problems in cases the repository changed for any reason + if sync_action: + return None + source_dir = os.path.join(os.getcwd(), GitHandler.REPO_PATH) main_script_path = os.path.join(source_dir, self.git_cfg.filename) if not os.path.exists(main_script_path): @@ -152,7 +157,7 @@ def get_repository_branches(self): raise UserException(f"Error getting repository branches: {str(e)}") from e def get_repository_files(self): - self.clone_repository() + _ = self.clone_repository(sync_action=True) files = [] for dirpath, _, filenames in os.walk(GitHandler.REPO_PATH): From 54f8e59735fe9edc727f082b9143df009b285d09 Mon Sep 17 00:00:00 2001 From: soustruh Date: Fri, 4 Jul 2025 09:14:27 +0200 Subject: [PATCH 18/31] =?UTF-8?q?cleanup=20=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TEMPLATE_README.md | 223 ------------------ component_config/sample-config/config.json | 53 ----- .../sample-config/in/files/order1.xml | 18 -- component_config/sample-config/in/state.json | 1 - .../sample-config/in/tables/test.csv | 22 -- .../sample-config/in/tables/test.csv.manifest | 76 ------ .../sample-config/out/files/order1.xml | 18 -- .../sample-config/out/tables/test.csv | 22 -- docs/imgs/architecture.png | Bin 80577 -> 0 bytes docs/imgs/ci_variable.png | Bin 65524 -> 0 bytes scripts/build_n_run.ps1 | 27 --- scripts/run.bat | 4 - scripts/run_kbc_tests.ps1 | 32 --- scripts/update_dev_portal_properties.sh | 111 --------- tests/test_component.py | 8 +- 15 files changed, 2 insertions(+), 613 deletions(-) delete mode 100644 TEMPLATE_README.md delete mode 100644 component_config/sample-config/config.json delete mode 100644 component_config/sample-config/in/files/order1.xml delete mode 100644 component_config/sample-config/in/state.json delete mode 100644 component_config/sample-config/in/tables/test.csv delete mode 100644 component_config/sample-config/in/tables/test.csv.manifest delete mode 100644 component_config/sample-config/out/files/order1.xml delete mode 100644 component_config/sample-config/out/tables/test.csv delete mode 100644 docs/imgs/architecture.png delete mode 100644 docs/imgs/ci_variable.png delete mode 100644 scripts/build_n_run.ps1 delete mode 100644 scripts/run.bat delete mode 100644 scripts/run_kbc_tests.ps1 delete mode 100644 scripts/update_dev_portal_properties.sh diff --git a/TEMPLATE_README.md b/TEMPLATE_README.md deleted file mode 100644 index 8a62c2e..0000000 --- a/TEMPLATE_README.md +++ /dev/null @@ -1,223 +0,0 @@ -# KBC Component Python template - -Python template for KBC Component creation. Defines the default structure and all Bitbucket pipeline CI scripts for automatic deployment. - -Use as a starting point when creating a new component. - -Example uses [keboola.component](https://pypi.org/project/keboola.component) library providing useful methods for KBC related tasks -and boilerplate methods often needed by components, for more details see [documentation](https://github.com/keboola/python-component/blob/main/README.md) - -*NOTE: Previously the template was based on top of the deprecated [keboola-python-util-lib library](https://bitbucket.org/kds_consulting_team/keboola-python-util-lib/src/master/)* - -**Table of contents:** - -[TOC] - -# Recommended component architecture -It is recommended to use the [keboola.component library](https://pypi.org/project/keboola.component), -for each component. Major advantage is that it reduces the boilerplate code replication, the developer can focus on core component logic -and not on boilerplate tasks. If anything is missing in the library, please fork and create a pull request with additional changes, -so we can all benefit from it - -**Base components on [CommonInterface](https://htmlpreview.github.io/?https://raw.githubusercontent.com/keboola/python-component/main/docs/api-html/component/interface.html#keboola.component.interface.CommonInterface)** - -- No need to write configuration processing and validation code each time -- No need to setup logging environment manually -- No need to write code to store manifests, write statefile, retrieve dates based on relative period, and many more. -- The main focus can be the core component logic, which increases the code readability for new comers. - -**Base Client on [HtttpClient](https://pypi.org/project/keboola.http-client/)** - -- No need to write HTTP request handling over and over again -- Covers basic authentication, retry strategy, headers, default parameters - - -## Architecture using the template - -![picture](docs/imgs/architecture.png) - -## Example component -This template contains functional example of an [hello-world component](https://bitbucket.org/kds_consulting_team/kbc-python-template/src/master/src/component.py), -it can be run with [sample configuration](https://bitbucket.org/kds_consulting_team/kbc-python-template/src/master/data/) and it produces valid results. -It is advisable to use this structure as a base for new components. Especially the `component.py` module, which should only -contain the base logic necessary for communication with KBC interface, processing parameters, collecting results - and calling targeted API service methods. - - -# Creating a new component -Clone this repository into new folder and remove git history -```bash -git clone https://bitbucket.org/kds_consulting_team/kbc-python-template.git my-new-component -cd my-new-component -rm -rf .git -git init -git remote add origin PATH_TO_YOUR_BB_REPO -git add . -git commit -m 'initial' -git push -u origin master -``` - -**Method #2:** - -Copy the contents of the template folder into your clone empty repository - -```bash -git clone PATH_TO_YOUR_BB_REPO my-new-component -# now copy the contents of the template into the my-new-component dir -cd my-new-component -git add . -git commit -m 'initial' -git push -u origin master -``` - -# Setting up the CI - - Bitbucket: Enable [pipelines](https://confluence.atlassian.com/bitbucket/get-started-with-bitbucket-pipelines-792298921.html) in the repository. - - For Github: Check that the [workflows are enabled](https://docs.github.com/en/actions/managing-workflow-runs/disabling-and-enabling-a-workflow). - The actions are present in `.github/workflows/` folder. - - Set `KBC_DEVELOPERPORTAL_APP` env variable (dev portal app id) - - In case it is not set on the account level, set also other required dev portal env variables: - - - `KBC_DEVELOPERPORTAL_PASSWORD` - service account password - - `KBC_DEVELOPERPORTAL_USERNAME` - service account username - - `KBC_DEVELOPERPORTAL_VENDOR` - dev portal vendor - - `KBC_STORAGE_TOKEN` - (optional) in case you wish to run KBC automated tests - - - ![picture](docs/imgs/ci_variable.png) - -The script execution is defined in three stages: - -## Default stage -This script is executed on push to any branch except the master branch. It executes basic build and code quality steps. Following steps are performed: -Build docker image -Execute flake8 lint tests -Execute python unittest -(Optional) Push image with tag :test into the AWS repository for manual testing in KBC -If any of the above steps results in non 0 status, the build will fail. It is impossible to merge branches that fail to build into the master branch. - -## Master stage -This script is executed on any push or change in the master branch. It performs every step as the default stage. Additionally, -the `./scripts/update_dev_portal_properties.sh` script is executed. -This script propagates all changes in the Component configuration files (component_config folder) to the Developer portal. -Currently these Dev Portal configuration parameters are supported: - - - `configSchema.json` - - `configRowSchema.json` - - `component_short_description.md` - - `component_long_description.md` - -The choice to include this script directly in the master branch was made to simplify ad-hoc changes of the component configuration parameters. For instance if you wish to slightly modify the configuration schema without affecting the code itself, it is possible to simply push the changes directly into the master and these will be automatically propagated to the production without rebuilding the image itself. Solely Developer Portal configuration metadata is deployed at this stage. - -## Tagged commit stage -Whenever a tagged commit is added, or tag created this script gets executed. This is a deployment phase, so a successful build results in new code being deployed in KBC production. -At this stage all steps present in the default and master stage are executed. Additionally, -`deploy.sh` script that pushes the newly built image / tag into the ECR repository and KBC production is executed. -The deploy script is executed only after all tests and proper build steps passed. -Moreover, the `deploy.sh` script will be executed **only in the master branch**. In other words if you create a tagged commit in another branch, the pipeline gets triggered but deployment script will fail, because it is not triggered within a master branch. This is to prevent accidental deployment from a feature branch. - - -# GELF logging - -The template automatically chooses between STDOUT and GELF logger based on the Developer Portal configuration. - -To fully leverage the benefits such as outputting the `Stack Trace` into the log event detail (available by clicking on the log event) -log exceptions using `logger.exception(ex)`. - -**TIP:** When the logger verbosity is set to `verbose` you may leverage `extra` fields to log the detailed message in the detail of the log event by adding extra fields to you messages: - -```python -logging.error(f'{error}. See log detail for full query. ', - extra={"failed_query": json.dumps(query)}) -``` - -Recommended [GELF logger setup](https://developers.keboola.com/extend/common-interface/logging/#setting-up) (Developer Portal) to allow debug mode logging: - -```json -{ - "verbosity": { - "100": "normal", - "200": "normal", - "250": "normal", - "300": "verbose", - "400": "verbose", - "500": "camouflage", - "550": "camouflage", - "600": "camouflage" - }, - "gelf_server_type": "tcp" -} -``` - -# Development - -This example contains runnable container with simple unittest. For local testing it is useful to include `data` folder in the root -and use docker-compose commands to run the container or execute tests. - -If required, change local data folder (the `CUSTOM_FOLDER` placeholder) path to your custom path: -```yaml - volumes: - - ./:/code - - ./CUSTOM_FOLDER:/data -``` - -Clone this repository, init the workspace and run the component with following command: - -``` -git clone https://bitbucket.org:kds_consulting_team/kbc-python-template.git my-new-component -cd my-new-component -docker-compose build -docker-compose run --rm dev -``` - -Run the test suite and lint check using this command: - -``` -docker-compose run --rm test -``` - -## Testing - -The preset pipeline scripts contain sections allowing pushing testing image into the ECR repository and automatic -testing in a dedicated project. These sections are by default commented out. - -**Running KBC tests on deploy step, before deployment** - -Uncomment following section in the deployment step in `bitbucket-pipelines.yml` file: - -```yaml - # push test image to ECR - uncomment when initialised - # - export REPOSITORY=`docker run --rm -e KBC_DEVELOPERPORTAL_USERNAME -e KBC_DEVELOPERPORTAL_PASSWORD -e KBC_DEVELOPERPORTAL_URL quay.io/keboola/developer-portal-cli-v2:latest ecr:get-repository $KBC_DEVELOPERPORTAL_VENDOR $KBC_DEVELOPERPORTAL_APP` - # - docker tag $APP_IMAGE:latest $REPOSITORY:test - # - eval $(docker run --rm -e KBC_DEVELOPERPORTAL_USERNAME -e KBC_DEVELOPERPORTAL_PASSWORD -e KBC_DEVELOPERPORTAL_URL quay.io/keboola/developer-portal-cli-v2:latest ecr:get-login $KBC_DEVELOPERPORTAL_VENDOR $KBC_DEVELOPERPORTAL_APP) - # - docker push $REPOSITORY:test - # - docker run --rm -e KBC_STORAGE_TOKEN quay.io/keboola/syrup-cli:latest run-job $KBC_DEVELOPERPORTAL_APP BASE_KBC_CONFIG test - # - docker run --rm -e KBC_STORAGE_TOKEN quay.io/keboola/syrup-cli:latest run-job $KBC_DEVELOPERPORTAL_APP KBC_CONFIG_1 test - - ./scripts/update_dev_portal_properties.sh - - ./deploy.sh -``` - -Make sure that you have `KBC_STORAGE_TOKEN` env. variable set, containing appropriate storage token with access -to your KBC project. Also make sure to create a functional testing configuration and replace the `BASE_KBC_CONFIG` placeholder with its id. - -**Pushing testing image for manual KBC tests** - -In some cases you may wish to execute a testing version of your component manually prior to publishing. For instance to test various -configurations on it. For that it may be convenient to push the `test` image on every push either to master, or any branch. - -To achieve that simply uncomment appropriate sections in `bitbucket-pipelines.yml` file, either in master branch step or in `default` step. - -```yaml - # push test image to ecr - uncomment for testing before deployment -# - echo 'Pushing test image to repo. [tag=test]' -# - export REPOSITORY=`docker run --rm -e KBC_DEVELOPERPORTAL_USERNAME -e KBC_DEVELOPERPORTAL_PASSWORD -e KBC_DEVELOPERPORTAL_URL quay.io/keboola/developer-portal-cli-v2:latest ecr:get-repository $KBC_DEVELOPERPORTAL_VENDOR $KBC_DEVELOPERPORTAL_APP` -# - docker tag $APP_IMAGE:latest $REPOSITORY:test -# - eval $(docker run --rm -e KBC_DEVELOPERPORTAL_USERNAME -e KBC_DEVELOPERPORTAL_PASSWORD -e KBC_DEVELOPERPORTAL_URL quay.io/keboola/developer-portal-cli-v2:latest ecr:get-login $KBC_DEVELOPERPORTAL_VENDOR $KBC_DEVELOPERPORTAL_APP) -# - docker push $REPOSITORY:test -``` - - Once the build is finished, you may run such configuration in any KBC project as many times as you want by using [run-job](https://kebooladocker.docs.apiary.io/#reference/run/create-a-job-with-image/run-job) API call, using the `test` image tag. - -# Integration - -For information about deployment and integration with KBC, please refer to the [deployment section of developers documentation](https://developers.keboola.com/extend/component/deployment/) \ No newline at end of file diff --git a/component_config/sample-config/config.json b/component_config/sample-config/config.json deleted file mode 100644 index abf4e98..0000000 --- a/component_config/sample-config/config.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "storage": { - "input": { - "files": [], - "tables": [ - { - "source": "in.c-test.test", - "destination": "test.csv", - "limit": 50, - "columns": [], - "where_values": [], - "where_operator": "eq" - } - ] - }, - "output": { - "files": [], - "tables": [] - } - }, - "parameters": { - "#api_token": "demo", - "period_from": "yesterday", - "endpoints": [ - "deals", - "companies" - ], - "company_properties": "", - "deal_properties": "", - "debug": true - }, - "image_parameters": { - "syrup_url": "https://syrup.keboola.com/" - }, - "authorization": { - "oauth_api": { - "id": "OAUTH_API_ID", - "credentials": { - "id": "main", - "authorizedFor": "Myself", - "creator": { - "id": "1234", - "description": "me@keboola.com" - }, - "created": "2016-01-31 00:13:30", - "#data": "{\"refresh_token\":\"MCWBkfdK9m5YK*Oqahwm6XN6elMAEwcH5kYcK8Ku!bpiOgSDZN9MQIzunpMsh6LyKH0i!7OcwwwajuxPfvm2PrrWYSs*HerDr2ZSJ39pqHJcvwUNIvHdtcgFFr3Em*yhn3GKBwM2p9UrjtgdAriSDny5YgUYGuI3gYJY1ypD*wBaAOzzeeXZx6CdgjruJ7gboTAngbWk3CzO9rORIwXAAlGUH6ZgBQJL3AwkYVMRFV4BvIvDAMF*0DcGDyrcyYDw9X3vYn*Wy!OqgrenKCGowdJk0C0136SUv4PJI383y76UMim6Q7KGDj7Lf!K2N2FDbxsz2iZKZTBr2vHx8pEC1oBc$\"}", - "oauthVersion": "2.0", - "appKey": "000000004C184A49", - "#appSecret": "vBAYak49pVK1zghHAgDH4tCSCNlT-CiN" - } - } - } -} diff --git a/component_config/sample-config/in/files/order1.xml b/component_config/sample-config/in/files/order1.xml deleted file mode 100644 index 2567c87..0000000 --- a/component_config/sample-config/in/files/order1.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - 1 - 2018-01-01 - David - - 100 - Umbrella - - - 200 - Rain Coat - - - - \ No newline at end of file diff --git a/component_config/sample-config/in/state.json b/component_config/sample-config/in/state.json deleted file mode 100644 index a0a9cd1..0000000 --- a/component_config/sample-config/in/state.json +++ /dev/null @@ -1 +0,0 @@ -{"data_delta": "10222018"} \ No newline at end of file diff --git a/component_config/sample-config/in/tables/test.csv b/component_config/sample-config/in/tables/test.csv deleted file mode 100644 index 30eb73e..0000000 --- a/component_config/sample-config/in/tables/test.csv +++ /dev/null @@ -1,22 +0,0 @@ -"Type","Campaign_Name","Status","Start_Date","End_Date","Location","Eventbrite_link" -"Event","How to become data driven startup","Complete","2015-10-13","2015-10-13","United Kingdom","https://www.eventbrite.co.uk/e/how-to-become-data-driven-startup-registration-18711425377" -"Event","How to become data driven startup","Complete","2015-11-04","2015-11-04","United Kingdom","https://www.eventbrite.co.uk/e/how-to-become-data-driven-startup-registration-18711426380" -"Event","How to become data driven startup","Complete","2015-10-13","2015-10-13","United Kingdom","https://www.eventbrite.co.uk/e/how-to-become-data-driven-startup-registration-18711425377" -"Event","How to become data driven startup","Complete","2015-11-04","2015-11-04","United Kingdom","https://www.eventbrite.co.uk/e/how-to-become-data-driven-startup-registration-18711426380" -"Event","DATAGIRLS PRESENT: HOW TO BECOME DATA-DRIVEN","Complete","2016-01-14","2016-01-14","United Kingdom","https://www.eventbrite.co.uk/e/datagirls-present-how-to-become-data-driven-tickets-20152992142" -"Event","DATAGIRLS PRESENT: HOW TO BECOME DATA-DRIVEN","Complete","2016-02-25","2016-02-25","United Kingdom","https://www.eventbrite.co.uk/e/datagirls-present-how-to-become-data-driven-tickets-20967439175" -"Event","Data Tools for Startups","Complete","2016-03-17","2016-03-17","United Kingdom","https://www.eventbrite.co.uk/e/data-tools-for-startups-tickets-21257426535" -"Event","Data Festival London 2016","Complete","2016-06-24","2016-06-26","United Kingdom","https://www.eventbrite.co.uk/e/data-festival-london-2016-tickets-25192608771" -"Event","Becoming data driven in the high street fashion","Complete","2016-10-12","2016-10-12","United Kingdom","https://www.eventbrite.co.uk/e/becoming-data-driven-in-the-high-street-fashion-tickets-27481268213" -"Event","The Data Foundry present: DATAGIRLS Weekend","Complete","2016-10-14","2016-10-16","United Kingdom","https://www.eventbrite.co.uk/e/the-data-foundry-present-datagirls-weekend-tickets-27350069795" -"Event","[NLP] How to analyse text data for knowledge discovery","Complete","2017-04-10","2017-04-10","United Kingdom","https://www.eventbrite.co.uk/e/nlp-how-to-analyse-text-data-for-knowledge-discovery-tickets-32320274812" -"Event","Keboola DataBrunch - Amazon Go a ako s ním v maloobchode “bojovať”","Complete","2017-03-09","2017-03-09","Slovakia","https://www.eventbrite.co.uk/e/keboola-databrunch-amazon-go-a-ako-s-nim-v-maloobchode-bojovat-tickets-31827553068" -"Event","Keboola DataBrunch - Amazon Go a jak s nim v maloobchodě “bojovat”","Complete","2017-03-29","2017-03-29","Czech Republic","https://www.eventbrite.co.uk/e/keboola-databrunch-amazon-go-a-jak-s-nim-v-maloobchode-bojovat-tickets-32182393405" -"Event","The Data Foundry present: DATAGIRLS Weekend","Complete","2016-10-14","2016-10-16","United Kingdom","https://www.eventbrite.co.uk/e/the-data-foundry-present-datagirls-weekend-tickets-27350069795" -"Event","[NLP] How to analyse text data for knowledge discovery","Complete","2017-04-10","2017-04-10","United Kingdom","https://www.eventbrite.co.uk/e/nlp-how-to-analyse-text-data-for-knowledge-discovery-tickets-32320274812" -"Event","Keboola Data Brunch - KPIs and AmazonGo, budoucnost retailu? ","Complete","2017-06-27","2017-06-27","Czech Republic","https://www.eventbrite.co.uk/e/keboola-data-brunch-kpis-amazongo-budoucnost-retailu-tickets-35257195220" -"Event","Learn how to #DoMoreWithData with DataGirls","Complete","2017-10-01","2017-10-01","United Kingdom","https://www.eventbrite.co.uk/e/learn-how-to-domorewithdata-with-datagirls-tickets-36777944823" -"Event","Are You Using Data to Understand Your Customers? ","Complete","2018-02-27","2018-02-27","United Kingdom","https://www.eventbrite.co.uk/e/are-you-using-data-to-understand-your-customers-tickets-42000160611" -"Event","Conversion Rate Optimisation in Travel Industry","Complete","2018-01-30","2018-01-30","United Kingdom","https://www.eventbrite.co.uk/e/conversion-rate-optimisation-in-travel-industry-tickets-38951076719" -"Event","Learn how to #DoMoreWithData with DataGirls","Complete","2017-10-01","2017-10-01","United Kingdom","https://www.eventbrite.co.uk/e/learn-how-to-domorewithdata-with-datagirls-tickets-36777944823" -"Event","Are You Using Data to Understand Your Customers? ","Complete","2018-02-27","2018-02-27","United Kingdom","https://www.eventbrite.co.uk/e/are-you-using-data-to-understand-your-customers-tickets-42000160611" diff --git a/component_config/sample-config/in/tables/test.csv.manifest b/component_config/sample-config/in/tables/test.csv.manifest deleted file mode 100644 index 5ea9f67..0000000 --- a/component_config/sample-config/in/tables/test.csv.manifest +++ /dev/null @@ -1,76 +0,0 @@ -{ - "id": "in.c-test.test", - "uri": "https:\/\/connection.keboola.com\/v2\/storage\/tables\/in.c-test.test", - "name": "test", - "primary_key": [], - "indexed_columns": [], - "created": "2018-03-02T15:36:50+0100", - "last_change_date": "2018-03-02T15:36:54+0100", - "last_import_date": "2018-03-02T15:36:54+0100", - "rows_count": 0, - "data_size_bytes": 0, - "is_alias": false, - "attributes": [], - "columns": [ - "Type", - "Campaign_Name", - "Status", - "Start_Date", - "End_Date", - "Location", - "Eventbrite_link" - ], - "metadata": [ - { - "id": "18271581", - "key": "KBC.createdBy.component.id", - "value": "transformation", - "provider": "system", - "timestamp": "2018-03-02T15:37:02+0100" - }, - { - "id": "18271582", - "key": "KBC.createdBy.configuration.id", - "value": "361585608", - "provider": "system", - "timestamp": "2018-03-02T15:37:02+0100" - }, - { - "id": "18271583", - "key": "KBC.createdBy.configurationRow.id", - "value": "361585762", - "provider": "system", - "timestamp": "2018-03-02T15:37:02+0100" - }, - { - "id": "18271584", - "key": "KBC.lastUpdatedBy.component.id", - "value": "transformation", - "provider": "system", - "timestamp": "2018-03-02T15:37:02+0100" - }, - { - "id": "18271585", - "key": "KBC.lastUpdatedBy.configuration.id", - "value": "361585608", - "provider": "system", - "timestamp": "2018-03-02T15:37:02+0100" - }, - { - "id": "18271586", - "key": "KBC.lastUpdatedBy.configurationRow.id", - "value": "361585762", - "provider": "system", - "timestamp": "2018-03-02T15:37:02+0100" - } - ], - "column_metadata": { - "Type": [], - "Campaign_Name": [], - "Status": [], - "Start_Date": [], - "End_Date": [], - "Location": [], - "Eventbrite_link": [] - } -} \ No newline at end of file diff --git a/component_config/sample-config/out/files/order1.xml b/component_config/sample-config/out/files/order1.xml deleted file mode 100644 index 2567c87..0000000 --- a/component_config/sample-config/out/files/order1.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - 1 - 2018-01-01 - David - - 100 - Umbrella - - - 200 - Rain Coat - - - - \ No newline at end of file diff --git a/component_config/sample-config/out/tables/test.csv b/component_config/sample-config/out/tables/test.csv deleted file mode 100644 index 30eb73e..0000000 --- a/component_config/sample-config/out/tables/test.csv +++ /dev/null @@ -1,22 +0,0 @@ -"Type","Campaign_Name","Status","Start_Date","End_Date","Location","Eventbrite_link" -"Event","How to become data driven startup","Complete","2015-10-13","2015-10-13","United Kingdom","https://www.eventbrite.co.uk/e/how-to-become-data-driven-startup-registration-18711425377" -"Event","How to become data driven startup","Complete","2015-11-04","2015-11-04","United Kingdom","https://www.eventbrite.co.uk/e/how-to-become-data-driven-startup-registration-18711426380" -"Event","How to become data driven startup","Complete","2015-10-13","2015-10-13","United Kingdom","https://www.eventbrite.co.uk/e/how-to-become-data-driven-startup-registration-18711425377" -"Event","How to become data driven startup","Complete","2015-11-04","2015-11-04","United Kingdom","https://www.eventbrite.co.uk/e/how-to-become-data-driven-startup-registration-18711426380" -"Event","DATAGIRLS PRESENT: HOW TO BECOME DATA-DRIVEN","Complete","2016-01-14","2016-01-14","United Kingdom","https://www.eventbrite.co.uk/e/datagirls-present-how-to-become-data-driven-tickets-20152992142" -"Event","DATAGIRLS PRESENT: HOW TO BECOME DATA-DRIVEN","Complete","2016-02-25","2016-02-25","United Kingdom","https://www.eventbrite.co.uk/e/datagirls-present-how-to-become-data-driven-tickets-20967439175" -"Event","Data Tools for Startups","Complete","2016-03-17","2016-03-17","United Kingdom","https://www.eventbrite.co.uk/e/data-tools-for-startups-tickets-21257426535" -"Event","Data Festival London 2016","Complete","2016-06-24","2016-06-26","United Kingdom","https://www.eventbrite.co.uk/e/data-festival-london-2016-tickets-25192608771" -"Event","Becoming data driven in the high street fashion","Complete","2016-10-12","2016-10-12","United Kingdom","https://www.eventbrite.co.uk/e/becoming-data-driven-in-the-high-street-fashion-tickets-27481268213" -"Event","The Data Foundry present: DATAGIRLS Weekend","Complete","2016-10-14","2016-10-16","United Kingdom","https://www.eventbrite.co.uk/e/the-data-foundry-present-datagirls-weekend-tickets-27350069795" -"Event","[NLP] How to analyse text data for knowledge discovery","Complete","2017-04-10","2017-04-10","United Kingdom","https://www.eventbrite.co.uk/e/nlp-how-to-analyse-text-data-for-knowledge-discovery-tickets-32320274812" -"Event","Keboola DataBrunch - Amazon Go a ako s ním v maloobchode “bojovať”","Complete","2017-03-09","2017-03-09","Slovakia","https://www.eventbrite.co.uk/e/keboola-databrunch-amazon-go-a-ako-s-nim-v-maloobchode-bojovat-tickets-31827553068" -"Event","Keboola DataBrunch - Amazon Go a jak s nim v maloobchodě “bojovat”","Complete","2017-03-29","2017-03-29","Czech Republic","https://www.eventbrite.co.uk/e/keboola-databrunch-amazon-go-a-jak-s-nim-v-maloobchode-bojovat-tickets-32182393405" -"Event","The Data Foundry present: DATAGIRLS Weekend","Complete","2016-10-14","2016-10-16","United Kingdom","https://www.eventbrite.co.uk/e/the-data-foundry-present-datagirls-weekend-tickets-27350069795" -"Event","[NLP] How to analyse text data for knowledge discovery","Complete","2017-04-10","2017-04-10","United Kingdom","https://www.eventbrite.co.uk/e/nlp-how-to-analyse-text-data-for-knowledge-discovery-tickets-32320274812" -"Event","Keboola Data Brunch - KPIs and AmazonGo, budoucnost retailu? ","Complete","2017-06-27","2017-06-27","Czech Republic","https://www.eventbrite.co.uk/e/keboola-data-brunch-kpis-amazongo-budoucnost-retailu-tickets-35257195220" -"Event","Learn how to #DoMoreWithData with DataGirls","Complete","2017-10-01","2017-10-01","United Kingdom","https://www.eventbrite.co.uk/e/learn-how-to-domorewithdata-with-datagirls-tickets-36777944823" -"Event","Are You Using Data to Understand Your Customers? ","Complete","2018-02-27","2018-02-27","United Kingdom","https://www.eventbrite.co.uk/e/are-you-using-data-to-understand-your-customers-tickets-42000160611" -"Event","Conversion Rate Optimisation in Travel Industry","Complete","2018-01-30","2018-01-30","United Kingdom","https://www.eventbrite.co.uk/e/conversion-rate-optimisation-in-travel-industry-tickets-38951076719" -"Event","Learn how to #DoMoreWithData with DataGirls","Complete","2017-10-01","2017-10-01","United Kingdom","https://www.eventbrite.co.uk/e/learn-how-to-domorewithdata-with-datagirls-tickets-36777944823" -"Event","Are You Using Data to Understand Your Customers? ","Complete","2018-02-27","2018-02-27","United Kingdom","https://www.eventbrite.co.uk/e/are-you-using-data-to-understand-your-customers-tickets-42000160611" diff --git a/docs/imgs/architecture.png b/docs/imgs/architecture.png deleted file mode 100644 index b830dd200c040a78ca823a23b73ec756a8c3aa29..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 80577 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 U#)wx3a5LTwqy{2i(dfzl0wqJ`6951J diff --git a/docs/imgs/ci_variable.png b/docs/imgs/ci_variable.png deleted file mode 100644 index 3918d7b3170508e70596f91637fecd9fe830b685..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65524 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 UN3*KDH_(@)sNB~|Vf}#r0U^0U0{{R3 diff --git a/scripts/build_n_run.ps1 b/scripts/build_n_run.ps1 deleted file mode 100644 index f279269..0000000 --- a/scripts/build_n_run.ps1 +++ /dev/null @@ -1,27 +0,0 @@ -echo Building component... -$COMP_TAG = Read-Host -Prompt 'Input Docker tag name:' -docker build -rm -t $COMP_TAG ../ - -echo Running component... -Write-host "Would you like to use default data folder? (../data)" -ForegroundColor Yellow - $Readhost = Read-Host " ( y / n ) " - Switch ($ReadHost) - { - Y {Write-host "Yes use: " (join-path (Split-Path -Path (Get-Location).Path) "data"); $DATA_PATH = (join-path (Split-Path -Path (Get-Location).Path) "data") } - N {Write-Host "No, I'll specify myself"; $DATA_PATH = Read-Host -Prompt 'Input data folder path:'} - Default {Write-Host "Default, run app"; docker run -v $DATA_PATH`:/data -e KBC_DATADIR=/data $COMP_TAG} - } - -Write-host "Would you like to execute the container to Bash, skipping the execution?" -ForegroundColor Yellow - $Readhost = Read-Host " ( y / n ) " - Switch ($ReadHost) - { - Y {Write-host "Yes, get me to the bash"; docker run -ti -v $DATA_PATH`:/data --entrypoint=//bin//bash $COMP_TAG} - N {Write-Host "No, execute the app normally"; - echo $DATA_PATH - docker run -v $DATA_PATH`:/data -e KBC_DATADIR=/data $COMP_TAG - } - Default {Write-Host "Default, run app"; docker run -v $DATA_PATH`:/data -e KBC_DATADIR=/data $COMP_TAG} - } - - diff --git a/scripts/run.bat b/scripts/run.bat deleted file mode 100644 index 64da15d..0000000 --- a/scripts/run.bat +++ /dev/null @@ -1,4 +0,0 @@ -@echo off - -echo Running component... -docker run -v %cd%:/data -e KBC_DATADIR=/data comp-tag \ No newline at end of file diff --git a/scripts/run_kbc_tests.ps1 b/scripts/run_kbc_tests.ps1 deleted file mode 100644 index 430e1a3..0000000 --- a/scripts/run_kbc_tests.ps1 +++ /dev/null @@ -1,32 +0,0 @@ -echo "Preparing KBC test image" -# set env vars -$KBC_DEVELOPERPORTAL_USERNAME = Read-Host -Prompt 'Input your service account user name' -$KBC_DEVELOPERPORTAL_PASSWORD = Read-Host -Prompt 'Input your service account pass' -$KBC_DEVELOPERPORTAL_VENDOR = 'esnerda' -$KBC_DEVELOPERPORTAL_APP = 'esnerda.ex-gusto-export' -$BASE_KBC_CONFIG = '455568423' -$KBC_STORAGE_TOKEN = Read-Host -Prompt 'Input your storage token' - - -#build app -$APP_IMAGE='keboola-comp-test' -docker build ..\ --tag=$APP_IMAGE -docker images -docker -v -#docker run $APP_IMAGE flake8 --config=./deployment/flake8.cfg -echo "Running unit-tests..." -docker run $APP_IMAGE python -m unittest discover - -docker pull quay.io/keboola/developer-portal-cli-v2:latest -$REPOSITORY= docker run --rm -e KBC_DEVELOPERPORTAL_USERNAME=$KBC_DEVELOPERPORTAL_USERNAME -e KBC_DEVELOPERPORTAL_PASSWORD=$KBC_DEVELOPERPORTAL_PASSWORD quay.io/keboola/developer-portal-cli-v2:latest ecr:get-repository $KBC_DEVELOPERPORTAL_VENDOR $KBC_DEVELOPERPORTAL_APP - -docker tag $APP_IMAGE`:latest $REPOSITORY`:test - -echo 'running login' -$(docker run --rm -e KBC_DEVELOPERPORTAL_USERNAME=$KBC_DEVELOPERPORTAL_USERNAME -e KBC_DEVELOPERPORTAL_PASSWORD=$KBC_DEVELOPERPORTAL_PASSWORD -e KBC_DEVELOPERPORTAL_URL quay.io/keboola/developer-portal-cli-v2:latest ecr:get-login $KBC_DEVELOPERPORTAL_VENDOR $KBC_DEVELOPERPORTAL_APP) - -echo 'pushing test image' -docker push $REPOSITORY`:test - -echo 'running test config in KBC' -docker run --rm -e KBC_STORAGE_TOKEN=$KBC_STORAGE_TOKEN quay.io/keboola/syrup-cli:latest run-job $KBC_DEVELOPERPORTAL_APP $BASE_KBC_CONFIG test diff --git a/scripts/update_dev_portal_properties.sh b/scripts/update_dev_portal_properties.sh deleted file mode 100644 index c1e4186..0000000 --- a/scripts/update_dev_portal_properties.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash - -set -e -# Obtain the component repository and log in -docker pull quay.io/keboola/developer-portal-cli-v2:latest - - -# Update properties in Keboola Developer Portal -echo "Updating long description" -value=`cat component_config/component_long_description.md` -echo "$value" -if [ ! -z "$value" ] -then - docker run --rm \ - -e KBC_DEVELOPERPORTAL_USERNAME \ - -e KBC_DEVELOPERPORTAL_PASSWORD \ - quay.io/keboola/developer-portal-cli-v2:latest \ - update-app-property ${KBC_DEVELOPERPORTAL_VENDOR} ${KBC_DEVELOPERPORTAL_APP} longDescription --value="$value" -else - echo "longDescription is empty!" - exit 1 -fi - -echo "Updating config schema" -value=`cat component_config/configSchema.json` -echo "$value" -if [ ! -z "$value" ] -then - docker run --rm \ - -e KBC_DEVELOPERPORTAL_USERNAME \ - -e KBC_DEVELOPERPORTAL_PASSWORD \ - quay.io/keboola/developer-portal-cli-v2:latest \ - update-app-property ${KBC_DEVELOPERPORTAL_VENDOR} ${KBC_DEVELOPERPORTAL_APP} configurationSchema --value="$value" -else - echo "configurationSchema is empty!" -fi - -echo "Updating row config schema" -value=`cat component_config/configRowSchema.json` -echo "$value" -if [ ! -z "$value" ] -then - docker run --rm \ - -e KBC_DEVELOPERPORTAL_USERNAME \ - -e KBC_DEVELOPERPORTAL_PASSWORD \ - quay.io/keboola/developer-portal-cli-v2:latest \ - update-app-property ${KBC_DEVELOPERPORTAL_VENDOR} ${KBC_DEVELOPERPORTAL_APP} configurationRowSchema --value="$value" -else - echo "configurationRowSchema is empty!" -fi - - -echo "Updating config description" - -value=`cat component_config/configuration_description.md` -echo "$value" -if [ ! -z "$value" ] -then - docker run --rm \ - -e KBC_DEVELOPERPORTAL_USERNAME \ - -e KBC_DEVELOPERPORTAL_PASSWORD \ - quay.io/keboola/developer-portal-cli-v2:latest \ - update-app-property ${KBC_DEVELOPERPORTAL_VENDOR} ${KBC_DEVELOPERPORTAL_APP} configurationDescription --value="$value" -else - echo "configurationDescription is empty!" -fi - - -echo "Updating short description" - -value=`cat component_config/component_short_description.md` -echo "$value" -if [ ! -z "$value" ] -then - docker run --rm \ - -e KBC_DEVELOPERPORTAL_USERNAME \ - -e KBC_DEVELOPERPORTAL_PASSWORD \ - quay.io/keboola/developer-portal-cli-v2:latest \ - update-app-property ${KBC_DEVELOPERPORTAL_VENDOR} ${KBC_DEVELOPERPORTAL_APP} shortDescription --value="$value" -else - echo "shortDescription is empty!" -fi - -echo "Updating logger settings" - -value=`cat component_config/logger` -echo "$value" -if [ ! -z "$value" ] -then - docker run --rm \ - -e KBC_DEVELOPERPORTAL_USERNAME \ - -e KBC_DEVELOPERPORTAL_PASSWORD \ - quay.io/keboola/developer-portal-cli-v2:latest \ - update-app-property ${KBC_DEVELOPERPORTAL_VENDOR} ${KBC_DEVELOPERPORTAL_APP} logger --value="$value" -else - echo "logger type is empty!" -fi - -echo "Updating logger configuration" -value=`cat component_config/loggerConfiguration.json` -echo "$value" -if [ ! -z "$value" ] -then - docker run --rm \ - -e KBC_DEVELOPERPORTAL_USERNAME \ - -e KBC_DEVELOPERPORTAL_PASSWORD \ - quay.io/keboola/developer-portal-cli-v2:latest \ - update-app-property ${KBC_DEVELOPERPORTAL_VENDOR} ${KBC_DEVELOPERPORTAL_APP} loggerConfiguration --value="$value" -else - echo "loggerConfiguration is empty!" -fi \ No newline at end of file diff --git a/tests/test_component.py b/tests/test_component.py index 0962f37..d14c92b 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -1,11 +1,7 @@ -''' -Created on 12. 11. 2018 - -@author: esner -''' +import os import unittest + import mock -import os from freezegun import freeze_time from component import Component From 5d285a1a5534c31161467c749ced50528e243d98 Mon Sep 17 00:00:00 2001 From: soustruh Date: Fri, 4 Jul 2025 09:16:20 +0200 Subject: [PATCH 19/31] dynamic version (specified elsewhere) --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9992262..3a997a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,6 @@ [project] name = "custom-python" -version = "0.1.0" -description = "Add your description here" +dynamic = ["version"] readme = "README.md" requires-python = ">=3.10" dependencies = [ From 3d322942820f839e346705359c12c6176fa5a598 Mon Sep 17 00:00:00 2001 From: soustruh Date: Fri, 4 Jul 2025 09:16:37 +0200 Subject: [PATCH 20/31] =?UTF-8?q?package=20update=20=F0=9F=93=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- uv.lock | 174 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 87 insertions(+), 87 deletions(-) diff --git a/uv.lock b/uv.lock index b8d51bf..4aaeb9a 100644 --- a/uv.lock +++ b/uv.lock @@ -35,9 +35,9 @@ dev = [ name = "dacite" version = "1.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/55/a0/7ca79796e799a3e782045d29bf052b5cde7439a2bbb17f15ff44f7aacc63/dacite-1.9.2.tar.gz", hash = "sha256:6ccc3b299727c7aa17582f0021f6ae14d5de47c7227932c47fec4cdfefd26f09", size = 22420, upload_time = "2025-02-05T09:27:29.757Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/a0/7ca79796e799a3e782045d29bf052b5cde7439a2bbb17f15ff44f7aacc63/dacite-1.9.2.tar.gz", hash = "sha256:6ccc3b299727c7aa17582f0021f6ae14d5de47c7227932c47fec4cdfefd26f09", size = 22420, upload-time = "2025-02-05T09:27:29.757Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload_time = "2025-02-05T09:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" }, ] [[package]] @@ -47,23 +47,23 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744, upload_time = "2025-01-27T10:46:25.7Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744, upload-time = "2025-01-27T10:46:25.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload_time = "2025-01-27T10:46:09.186Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" }, ] [[package]] name = "flake8" -version = "7.2.0" +version = "7.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mccabe" }, { name = "pycodestyle" }, { name = "pyflakes" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/c4/5842fc9fc94584c455543540af62fd9900faade32511fab650e9891ec225/flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426", size = 48177, upload_time = "2025-03-29T20:08:39.329Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/5c/0627be4c9976d56b1217cb5187b7504e7fd7d3503f8bfd312a04077bd4f7/flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343", size = 57786, upload_time = "2025-03-29T20:08:37.902Z" }, + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, ] [[package]] @@ -73,9 +73,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/75/0455fa5029507a2150da59db4f165fbc458ff8bb1c4f4d7e8037a14ad421/freezegun-1.5.2.tar.gz", hash = "sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181", size = 34855, upload_time = "2025-05-24T12:38:47.051Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/75/0455fa5029507a2150da59db4f165fbc458ff8bb1c4f4d7e8037a14ad421/freezegun-1.5.2.tar.gz", hash = "sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181", size = 34855, upload-time = "2025-05-24T12:38:47.051Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/b2/68d4c9b6431121b6b6aa5e04a153cac41dcacc79600ed6e2e7c3382156f5/freezegun-1.5.2-py3-none-any.whl", hash = "sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b", size = 18715, upload_time = "2025-05-24T12:38:45.274Z" }, + { url = "https://files.pythonhosted.org/packages/b5/b2/68d4c9b6431121b6b6aa5e04a153cac41dcacc79600ed6e2e7c3382156f5/freezegun-1.5.2-py3-none-any.whl", hash = "sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b", size = 18715, upload-time = "2025-05-24T12:38:45.274Z" }, ] [[package]] @@ -87,54 +87,54 @@ dependencies = [ { name = "pygelf" }, { name = "pytz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/dd/391f1e6eaae5e925f56f30788c40e38c8bce3a80ee898512e79ced2eabab/keboola.component-1.6.10.tar.gz", hash = "sha256:5f4c347e8e96bb4dff1fe1254217e88754a4edea8a1b147815552335dcfba941", size = 47336, upload_time = "2025-01-17T11:56:05.419Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/dd/391f1e6eaae5e925f56f30788c40e38c8bce3a80ee898512e79ced2eabab/keboola.component-1.6.10.tar.gz", hash = "sha256:5f4c347e8e96bb4dff1fe1254217e88754a4edea8a1b147815552335dcfba941", size = 47336, upload-time = "2025-01-17T11:56:05.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/34/301082c106bff256e2871a5c5db54d691fdad77a9d8a89a52eef30cd18ed/keboola.component-1.6.10-py3-none-any.whl", hash = "sha256:9a13b73beb71373d9a2b456eb44f902cfcfc07747c084bfbfad761b5eaaa4d93", size = 42243, upload_time = "2025-01-17T11:56:04.215Z" }, + { url = "https://files.pythonhosted.org/packages/4c/34/301082c106bff256e2871a5c5db54d691fdad77a9d8a89a52eef30cd18ed/keboola.component-1.6.10-py3-none-any.whl", hash = "sha256:9a13b73beb71373d9a2b456eb44f902cfcfc07747c084bfbfad761b5eaaa4d93", size = 42243, upload-time = "2025-01-17T11:56:04.215Z" }, ] [[package]] name = "mccabe" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload_time = "2022-01-24T01:14:51.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload_time = "2022-01-24T01:14:49.62Z" }, + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, ] [[package]] name = "mock" version = "5.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/07/8c/14c2ae915e5f9dca5a22edd68b35be94400719ccfa068a03e0fb63d0f6f6/mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0", size = 92796, upload_time = "2025-03-03T12:31:42.911Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/8c/14c2ae915e5f9dca5a22edd68b35be94400719ccfa068a03e0fb63d0f6f6/mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0", size = 92796, upload-time = "2025-03-03T12:31:42.911Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/d9/617e6af809bf3a1d468e0d58c3997b1dc219a9a9202e650d30c2fc85d481/mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f", size = 31617, upload_time = "2025-03-03T12:31:41.518Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d9/617e6af809bf3a1d468e0d58c3997b1dc219a9a9202e650d30c2fc85d481/mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f", size = 31617, upload-time = "2025-03-03T12:31:41.518Z" }, ] [[package]] name = "pycodestyle" -version = "2.13.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/6e/1f4a62078e4d95d82367f24e685aef3a672abfd27d1a868068fed4ed2254/pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae", size = 39312, upload_time = "2025-03-29T17:33:30.669Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/be/b00116df1bfb3e0bb5b45e29d604799f7b91dd861637e4d448b4e09e6a3e/pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9", size = 31424, upload_time = "2025-03-29T17:33:29.405Z" }, + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, ] [[package]] name = "pyflakes" -version = "3.3.2" +version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/cc/1df338bd7ed1fa7c317081dcf29bf2f01266603b301e6858856d346a12b3/pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b", size = 64175, upload_time = "2025-03-31T13:21:20.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/40/b293a4fa769f3b02ab9e387c707c4cbdc34f073f945de0386107d4e669e6/pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a", size = 63164, upload_time = "2025-03-31T13:21:18.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, ] [[package]] name = "pygelf" -version = "0.4.2" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/d3/73d1fe74a156f9a0e519bedc87815ed309e64af19c73b94352e4c0959ddb/pygelf-0.4.2.tar.gz", hash = "sha256:d0bb8f45ff648a9a187713f4a05c09f685fcb8add7b04bb7471f20071bd11aad", size = 11991, upload_time = "2021-10-06T23:32:05.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/91/ac1605bb40092ae41fbb833ee55447f72e19ce5459efa6bd3beecc67e971/pygelf-0.4.3.tar.gz", hash = "sha256:8ed972563be3c8f168483f01dbf522b6bc697959c97a3f4881324b3f79638911", size = 11017, upload-time = "2025-06-14T19:21:19.832Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/cd/4afdddbc73f54ddf31d16137ef81c3d47192d75754b3115d925926081fd6/pygelf-0.4.2-py3-none-any.whl", hash = "sha256:ab57d1b26bffa014e29ae645ee51d2aa2f0c0cb419c522f2d24a237090b894a1", size = 8714, upload_time = "2021-10-06T23:32:03.156Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ee/ebac3de919431912e0be380fafd01059a091a489f6b5d7896c2a04548895/pygelf-0.4.3-py3-none-any.whl", hash = "sha256:0876c99a77f9f021834982c9808205b3239fabf5886788d701f31b495b65c8ae", size = 8750, upload-time = "2025-06-14T19:21:16.953Z" }, ] [[package]] @@ -144,89 +144,89 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload_time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload_time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "pytz" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload_time = "2025-03-25T02:25:00.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload_time = "2025-03-25T02:24:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload_time = "2024-12-04T17:35:28.174Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload_time = "2024-12-04T17:35:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "wrapt" version = "1.17.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531, upload_time = "2025-01-14T10:35:45.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531, upload-time = "2025-01-14T10:35:45.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/d1/1daec934997e8b160040c78d7b31789f19b122110a75eca3d4e8da0049e1/wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984", size = 53307, upload_time = "2025-01-14T10:33:13.616Z" }, - { url = "https://files.pythonhosted.org/packages/1b/7b/13369d42651b809389c1a7153baa01d9700430576c81a2f5c5e460df0ed9/wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22", size = 38486, upload_time = "2025-01-14T10:33:15.947Z" }, - { url = "https://files.pythonhosted.org/packages/62/bf/e0105016f907c30b4bd9e377867c48c34dc9c6c0c104556c9c9126bd89ed/wrapt-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7", size = 38777, upload_time = "2025-01-14T10:33:17.462Z" }, - { url = "https://files.pythonhosted.org/packages/27/70/0f6e0679845cbf8b165e027d43402a55494779295c4b08414097b258ac87/wrapt-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c", size = 83314, upload_time = "2025-01-14T10:33:21.282Z" }, - { url = "https://files.pythonhosted.org/packages/0f/77/0576d841bf84af8579124a93d216f55d6f74374e4445264cb378a6ed33eb/wrapt-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72", size = 74947, upload_time = "2025-01-14T10:33:24.414Z" }, - { url = "https://files.pythonhosted.org/packages/90/ec/00759565518f268ed707dcc40f7eeec38637d46b098a1f5143bff488fe97/wrapt-1.17.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061", size = 82778, upload_time = "2025-01-14T10:33:26.152Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5a/7cffd26b1c607b0b0c8a9ca9d75757ad7620c9c0a9b4a25d3f8a1480fafc/wrapt-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2", size = 81716, upload_time = "2025-01-14T10:33:27.372Z" }, - { url = "https://files.pythonhosted.org/packages/7e/09/dccf68fa98e862df7e6a60a61d43d644b7d095a5fc36dbb591bbd4a1c7b2/wrapt-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c", size = 74548, upload_time = "2025-01-14T10:33:28.52Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8e/067021fa3c8814952c5e228d916963c1115b983e21393289de15128e867e/wrapt-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62", size = 81334, upload_time = "2025-01-14T10:33:29.643Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0d/9d4b5219ae4393f718699ca1c05f5ebc0c40d076f7e65fd48f5f693294fb/wrapt-1.17.2-cp310-cp310-win32.whl", hash = "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563", size = 36427, upload_time = "2025-01-14T10:33:30.832Z" }, - { url = "https://files.pythonhosted.org/packages/72/6a/c5a83e8f61aec1e1aeef939807602fb880e5872371e95df2137142f5c58e/wrapt-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f", size = 38774, upload_time = "2025-01-14T10:33:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/a2aab2cbc7a665efab072344a8949a71081eed1d2f451f7f7d2b966594a2/wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58", size = 53308, upload_time = "2025-01-14T10:33:33.992Z" }, - { url = "https://files.pythonhosted.org/packages/50/ff/149aba8365fdacef52b31a258c4dc1c57c79759c335eff0b3316a2664a64/wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda", size = 38488, upload_time = "2025-01-14T10:33:35.264Z" }, - { url = "https://files.pythonhosted.org/packages/65/46/5a917ce85b5c3b490d35c02bf71aedaa9f2f63f2d15d9949cc4ba56e8ba9/wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438", size = 38776, upload_time = "2025-01-14T10:33:38.28Z" }, - { url = "https://files.pythonhosted.org/packages/ca/74/336c918d2915a4943501c77566db41d1bd6e9f4dbc317f356b9a244dfe83/wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a", size = 83776, upload_time = "2025-01-14T10:33:40.678Z" }, - { url = "https://files.pythonhosted.org/packages/09/99/c0c844a5ccde0fe5761d4305485297f91d67cf2a1a824c5f282e661ec7ff/wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000", size = 75420, upload_time = "2025-01-14T10:33:41.868Z" }, - { url = "https://files.pythonhosted.org/packages/b4/b0/9fc566b0fe08b282c850063591a756057c3247b2362b9286429ec5bf1721/wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6", size = 83199, upload_time = "2025-01-14T10:33:43.598Z" }, - { url = "https://files.pythonhosted.org/packages/9d/4b/71996e62d543b0a0bd95dda485219856def3347e3e9380cc0d6cf10cfb2f/wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b", size = 82307, upload_time = "2025-01-14T10:33:48.499Z" }, - { url = "https://files.pythonhosted.org/packages/39/35/0282c0d8789c0dc9bcc738911776c762a701f95cfe113fb8f0b40e45c2b9/wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662", size = 75025, upload_time = "2025-01-14T10:33:51.191Z" }, - { url = "https://files.pythonhosted.org/packages/4f/6d/90c9fd2c3c6fee181feecb620d95105370198b6b98a0770cba090441a828/wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72", size = 81879, upload_time = "2025-01-14T10:33:52.328Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fa/9fb6e594f2ce03ef03eddbdb5f4f90acb1452221a5351116c7c4708ac865/wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317", size = 36419, upload_time = "2025-01-14T10:33:53.551Z" }, - { url = "https://files.pythonhosted.org/packages/47/f8/fb1773491a253cbc123c5d5dc15c86041f746ed30416535f2a8df1f4a392/wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3", size = 38773, upload_time = "2025-01-14T10:33:56.323Z" }, - { url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799, upload_time = "2025-01-14T10:33:57.4Z" }, - { url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821, upload_time = "2025-01-14T10:33:59.334Z" }, - { url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919, upload_time = "2025-01-14T10:34:04.093Z" }, - { url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721, upload_time = "2025-01-14T10:34:07.163Z" }, - { url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899, upload_time = "2025-01-14T10:34:09.82Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222, upload_time = "2025-01-14T10:34:11.258Z" }, - { url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707, upload_time = "2025-01-14T10:34:12.49Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685, upload_time = "2025-01-14T10:34:15.043Z" }, - { url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567, upload_time = "2025-01-14T10:34:16.563Z" }, - { url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672, upload_time = "2025-01-14T10:34:17.727Z" }, - { url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865, upload_time = "2025-01-14T10:34:19.577Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800, upload_time = "2025-01-14T10:34:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824, upload_time = "2025-01-14T10:34:22.999Z" }, - { url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920, upload_time = "2025-01-14T10:34:25.386Z" }, - { url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690, upload_time = "2025-01-14T10:34:28.058Z" }, - { url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861, upload_time = "2025-01-14T10:34:29.167Z" }, - { url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174, upload_time = "2025-01-14T10:34:31.702Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721, upload_time = "2025-01-14T10:34:32.91Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763, upload_time = "2025-01-14T10:34:34.903Z" }, - { url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585, upload_time = "2025-01-14T10:34:36.13Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676, upload_time = "2025-01-14T10:34:37.962Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871, upload_time = "2025-01-14T10:34:39.13Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312, upload_time = "2025-01-14T10:34:40.604Z" }, - { url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062, upload_time = "2025-01-14T10:34:45.011Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155, upload_time = "2025-01-14T10:34:47.25Z" }, - { url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471, upload_time = "2025-01-14T10:34:50.934Z" }, - { url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208, upload_time = "2025-01-14T10:34:52.297Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339, upload_time = "2025-01-14T10:34:53.489Z" }, - { url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232, upload_time = "2025-01-14T10:34:55.327Z" }, - { url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476, upload_time = "2025-01-14T10:34:58.055Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377, upload_time = "2025-01-14T10:34:59.3Z" }, - { url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986, upload_time = "2025-01-14T10:35:00.498Z" }, - { url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750, upload_time = "2025-01-14T10:35:03.378Z" }, - { url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594, upload_time = "2025-01-14T10:35:44.018Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d1/1daec934997e8b160040c78d7b31789f19b122110a75eca3d4e8da0049e1/wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984", size = 53307, upload-time = "2025-01-14T10:33:13.616Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7b/13369d42651b809389c1a7153baa01d9700430576c81a2f5c5e460df0ed9/wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22", size = 38486, upload-time = "2025-01-14T10:33:15.947Z" }, + { url = "https://files.pythonhosted.org/packages/62/bf/e0105016f907c30b4bd9e377867c48c34dc9c6c0c104556c9c9126bd89ed/wrapt-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7", size = 38777, upload-time = "2025-01-14T10:33:17.462Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/0f6e0679845cbf8b165e027d43402a55494779295c4b08414097b258ac87/wrapt-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c", size = 83314, upload-time = "2025-01-14T10:33:21.282Z" }, + { url = "https://files.pythonhosted.org/packages/0f/77/0576d841bf84af8579124a93d216f55d6f74374e4445264cb378a6ed33eb/wrapt-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72", size = 74947, upload-time = "2025-01-14T10:33:24.414Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/00759565518f268ed707dcc40f7eeec38637d46b098a1f5143bff488fe97/wrapt-1.17.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061", size = 82778, upload-time = "2025-01-14T10:33:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/7cffd26b1c607b0b0c8a9ca9d75757ad7620c9c0a9b4a25d3f8a1480fafc/wrapt-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2", size = 81716, upload-time = "2025-01-14T10:33:27.372Z" }, + { url = "https://files.pythonhosted.org/packages/7e/09/dccf68fa98e862df7e6a60a61d43d644b7d095a5fc36dbb591bbd4a1c7b2/wrapt-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c", size = 74548, upload-time = "2025-01-14T10:33:28.52Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/067021fa3c8814952c5e228d916963c1115b983e21393289de15128e867e/wrapt-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62", size = 81334, upload-time = "2025-01-14T10:33:29.643Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0d/9d4b5219ae4393f718699ca1c05f5ebc0c40d076f7e65fd48f5f693294fb/wrapt-1.17.2-cp310-cp310-win32.whl", hash = "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563", size = 36427, upload-time = "2025-01-14T10:33:30.832Z" }, + { url = "https://files.pythonhosted.org/packages/72/6a/c5a83e8f61aec1e1aeef939807602fb880e5872371e95df2137142f5c58e/wrapt-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f", size = 38774, upload-time = "2025-01-14T10:33:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/a2aab2cbc7a665efab072344a8949a71081eed1d2f451f7f7d2b966594a2/wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58", size = 53308, upload-time = "2025-01-14T10:33:33.992Z" }, + { url = "https://files.pythonhosted.org/packages/50/ff/149aba8365fdacef52b31a258c4dc1c57c79759c335eff0b3316a2664a64/wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda", size = 38488, upload-time = "2025-01-14T10:33:35.264Z" }, + { url = "https://files.pythonhosted.org/packages/65/46/5a917ce85b5c3b490d35c02bf71aedaa9f2f63f2d15d9949cc4ba56e8ba9/wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438", size = 38776, upload-time = "2025-01-14T10:33:38.28Z" }, + { url = "https://files.pythonhosted.org/packages/ca/74/336c918d2915a4943501c77566db41d1bd6e9f4dbc317f356b9a244dfe83/wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a", size = 83776, upload-time = "2025-01-14T10:33:40.678Z" }, + { url = "https://files.pythonhosted.org/packages/09/99/c0c844a5ccde0fe5761d4305485297f91d67cf2a1a824c5f282e661ec7ff/wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000", size = 75420, upload-time = "2025-01-14T10:33:41.868Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/9fc566b0fe08b282c850063591a756057c3247b2362b9286429ec5bf1721/wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6", size = 83199, upload-time = "2025-01-14T10:33:43.598Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4b/71996e62d543b0a0bd95dda485219856def3347e3e9380cc0d6cf10cfb2f/wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b", size = 82307, upload-time = "2025-01-14T10:33:48.499Z" }, + { url = "https://files.pythonhosted.org/packages/39/35/0282c0d8789c0dc9bcc738911776c762a701f95cfe113fb8f0b40e45c2b9/wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662", size = 75025, upload-time = "2025-01-14T10:33:51.191Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6d/90c9fd2c3c6fee181feecb620d95105370198b6b98a0770cba090441a828/wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72", size = 81879, upload-time = "2025-01-14T10:33:52.328Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fa/9fb6e594f2ce03ef03eddbdb5f4f90acb1452221a5351116c7c4708ac865/wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317", size = 36419, upload-time = "2025-01-14T10:33:53.551Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/fb1773491a253cbc123c5d5dc15c86041f746ed30416535f2a8df1f4a392/wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3", size = 38773, upload-time = "2025-01-14T10:33:56.323Z" }, + { url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799, upload-time = "2025-01-14T10:33:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821, upload-time = "2025-01-14T10:33:59.334Z" }, + { url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919, upload-time = "2025-01-14T10:34:04.093Z" }, + { url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721, upload-time = "2025-01-14T10:34:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899, upload-time = "2025-01-14T10:34:09.82Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222, upload-time = "2025-01-14T10:34:11.258Z" }, + { url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707, upload-time = "2025-01-14T10:34:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685, upload-time = "2025-01-14T10:34:15.043Z" }, + { url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567, upload-time = "2025-01-14T10:34:16.563Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672, upload-time = "2025-01-14T10:34:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865, upload-time = "2025-01-14T10:34:19.577Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800, upload-time = "2025-01-14T10:34:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824, upload-time = "2025-01-14T10:34:22.999Z" }, + { url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920, upload-time = "2025-01-14T10:34:25.386Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690, upload-time = "2025-01-14T10:34:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861, upload-time = "2025-01-14T10:34:29.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174, upload-time = "2025-01-14T10:34:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721, upload-time = "2025-01-14T10:34:32.91Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763, upload-time = "2025-01-14T10:34:34.903Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585, upload-time = "2025-01-14T10:34:36.13Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676, upload-time = "2025-01-14T10:34:37.962Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871, upload-time = "2025-01-14T10:34:39.13Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312, upload-time = "2025-01-14T10:34:40.604Z" }, + { url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062, upload-time = "2025-01-14T10:34:45.011Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155, upload-time = "2025-01-14T10:34:47.25Z" }, + { url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471, upload-time = "2025-01-14T10:34:50.934Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208, upload-time = "2025-01-14T10:34:52.297Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339, upload-time = "2025-01-14T10:34:53.489Z" }, + { url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232, upload-time = "2025-01-14T10:34:55.327Z" }, + { url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476, upload-time = "2025-01-14T10:34:58.055Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377, upload-time = "2025-01-14T10:34:59.3Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986, upload-time = "2025-01-14T10:35:00.498Z" }, + { url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750, upload-time = "2025-01-14T10:35:03.378Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594, upload-time = "2025-01-14T10:35:44.018Z" }, ] From 348316b777c17ff85890eb35b41c44d8132499f8 Mon Sep 17 00:00:00 2001 From: soustruh Date: Fri, 4 Jul 2025 09:19:53 +0200 Subject: [PATCH 21/31] =?UTF-8?q?clickable=20link=20to=20example/template?= =?UTF-8?q?=20repository=20=F0=9F=A7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c2bc9e9..857e2ba 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ - [Configuration](#configuration) - [Git configuration](#git-configuration) - [SSH configuration](#ssh-configuration) - - [Example: Running code saved in custom repository](#example-running-code-saved-in-custom-repository) + - [Example: Running code saved in custom repository + template 🧩](#example-running-code-saved-in-custom-repository--template-) - [Example: Listing preinstalled packages](#example-listing-preinstalled-packages) - [Example: Accessing custom configuration parameters](#example-accessing-custom-configuration-parameters) - [Development](#development) @@ -29,13 +29,13 @@ This component lets you run your own Python code directly within Keboola, with s The git configuration object supports the following parameters: -- `url`: Repository URL – supports both HTTPS and SSH formats -- `branch`: Branch name to checkout – UI provides branch selection -- `filename`: Python script filename to execute – UI lists available files -- `auth`: Repository visibility & authentication method - - `none`: Public repository, no authentication (default) - - `pat`: Private repository, Personal Access Token - - `ssh`: Private repository, SSH key +- `url`: Repository URL – supports both HTTPS and SSH formats. +- `branch`: Branch name to checkout – UI provides branch selection. +- `filename`: Python script filename to execute – UI lists available files. +- `auth`: Repository visibility & authentication method. + - `none`: Public repository, no authentication (default). + - `pat`: Private repository, Personal Access Token. + - `ssh`: Private repository, SSH key. - `#token`: Personal Access Token (`"auth": "pat"` only). This value will be encrypted in Keboola Storage. - `ssh_keys`: SSH keys configuration object (`"auth": "ssh"` only). @@ -47,7 +47,10 @@ The git configuration object supports the following parameters: - `#private`: Private key used for authentication. This value will be encrypted in Keboola Storage. -### Example: Running code saved in custom repository +### Example: Running code saved in custom repository + template 🧩 + +As this might become a preferred way of running custom Python code in Keboola for many, we prepared a [simple example project](https://github.com/keboola/component-custom-python-example-repo-1), which help you with your first steps (and can also server you as a template for any of your future projects). + Contents of the `config.json` file: From 0839b9bdad83feeeb349248777a0d33c76015d23 Mon Sep 17 00:00:00 2001 From: soustruh Date: Tue, 8 Jul 2025 03:23:46 +0200 Subject: [PATCH 22/31] =?UTF-8?q?isolated=20python=20environments=20?= =?UTF-8?q?=E2=98=83=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- component_config/configSchema.json | 78 +++++++++++++++++++++++++----- src/component.py | 40 ++++++++++----- src/configuration.py | 15 +++++- src/package_installer.py | 36 ++++---------- src/source_file.py | 6 +-- src/source_git.py | 20 ++++---- src/subprocess_runner.py | 34 +++++++++++++ src/venv_manager.py | 22 +++++++++ 8 files changed, 188 insertions(+), 63 deletions(-) create mode 100644 src/subprocess_runner.py create mode 100644 src/venv_manager.py diff --git a/component_config/configSchema.json b/component_config/configSchema.json index 94d48e5..5b3c17f 100644 --- a/component_config/configSchema.json +++ b/component_config/configSchema.json @@ -25,10 +25,60 @@ } } }, + "venv": { + "type": "object", + "title": "Runtime Configuration", + "propertyOrder": 20, + "required": [ + "isolated", + "python" + ], + "properties": { + "clean": { + "type": "radio", + "title": "Isolated Python Environment", + "propertyOrder": 30, + "enum": [ + false, + true + ], + "options": { + "tooltip": "Isolated environment takes a couple of seconds to start, but gives you the opportuninty to pick one of the latest versions of Python. It's also a safer choice as it prevents package collisions.\n\n.Non-isolated environment (used to be the default choice) might start a bit faster, but can lead to issues mentioned above.", + "enum_titles": [ + "No (Python 3.10, contains all the pre-installed packages)", + "Yes (Python version based on your choice, just the packages)" + ] + }, + "default": true, + "required": false + }, + "python": { + "type": "radio", + "title": "Python Version", + "propertyOrder": 30, + "enum": [ + "3.12", + "3.13", + "3.14" + ], + "options": { + "tooltip": "3.12 (Oct 2023, security fixes until Oct 2028)\n3.13 (Oct 2024, security fixes until Oct 2029)\n3.14 (beta, Oct 2025, security fixes until Oct 2030)", + "enum_titles": [ + "3.12", + "3.13", + "3.14 (beta)" + ] + }, + "default": "3.13", + "required": false + } + + } + }, "source": { "type": "radio", "title": "Source Code & Dependencies", - "propertyOrder": 20, + "propertyOrder": 30, "enum": [ "code", "git" @@ -50,7 +100,7 @@ }, "title": "Python Packages", "format": "select", - "propertyOrder": 30, + "propertyOrder": 40, "options": { "dependencies": { "source": "code" @@ -64,7 +114,7 @@ "type": "string", "title": "Python Code", "format": "editor", - "propertyOrder": 40, + "propertyOrder": 50, "default": "from keboola.component import CommonInterface\n\nci = CommonInterface()\n# access user parameters\nprint(ci.configuration.parameters)", "options": { "dependencies": { @@ -80,7 +130,7 @@ "git": { "type": "object", "title": "Git Repository Source Settings", - "propertyOrder": 50, + "propertyOrder": 60, "options": { "dependencies": { "source": "git" @@ -95,18 +145,20 @@ "url": { "type": "string", "title": "Repository URL", - "propertyOrder": 60 + "propertyOrder": 70 }, "branch": { "type": "string", "enum": [], "title": "Branch Name", - "propertyOrder": 70, + "propertyOrder": 80, "options": { "async": { "label": "List Branches", "action": "listBranches", - "autoload": ["git.url"], + "autoload": [ + "git.url" + ], "cache": false } } @@ -115,12 +167,14 @@ "type": "string", "enum": [], "title": "Script Filename", - "propertyOrder": 80, + "propertyOrder": 90, "options": { "async": { "label": "List Files", "action": "listFiles", - "autoload": ["git.branch"], + "autoload": [ + "git.branch" + ], "cache": false } } @@ -128,7 +182,7 @@ "auth": { "type": "radio", "title": "Repository Visibility & Authentication", - "propertyOrder": 90, + "propertyOrder": 100, "enum": [ "none", "pat", @@ -147,7 +201,7 @@ "#token": { "type": "string", "title": "Personal Access Token", - "propertyOrder": 100, + "propertyOrder": 110, "options": { "dependencies": { "auth": "pat" @@ -157,7 +211,7 @@ "ssh_keys": { "type": "object", "format": "ssh-editor", - "propertyOrder": 110, + "propertyOrder": 120, "options": { "only_keys": true, "dependencies": { diff --git a/src/component.py b/src/component.py index e6d6f3b..ea2cb3e 100644 --- a/src/component.py +++ b/src/component.py @@ -5,19 +5,21 @@ import json import logging import os -import runpy import sys import traceback +from pathlib import Path from traceback import TracebackException import dacite from keboola.component.base import ComponentBase, sync_action from keboola.component.exceptions import UserException -from configuration import AuthEnum, Configuration, SourceEnum, encrypted_keys +from configuration import AuthEnum, Configuration, PyEnum, SourceEnum, encrypted_keys from package_installer import PackageInstaller from source_file import FileHandler from source_git import GitHandler +from subprocess_runner import SubprocessRunner +from venv_manager import VenvManager class Component(ComponentBase): @@ -37,23 +39,39 @@ def __init__(self): self.parameters = dacite.from_dict( Configuration, self.configuration.parameters, - config=dacite.Config(cast=[AuthEnum, SourceEnum], convert_key=encrypted_keys), + config=dacite.Config( + cast=[AuthEnum, PyEnum, SourceEnum], + convert_key=encrypted_keys, + ), ) def run(self): if self.parameters.source == SourceEnum.CODE: - script_path = FileHandler.prepare_script_file(self.data_folder_path, self.parameters.code) - PackageInstaller.install_packages(self.parameters.packages) + base_path = Path(self.data_folder_path) + script_filename = FileHandler.prepare_script_file(self.data_folder_path, self.parameters.code) else: + base_path = Path(GitHandler.REPO_PATH) git_handler = GitHandler(self.parameters.git) - script_path = git_handler.clone_repository() + script_filename = git_handler.clone_repository() + + if not self.parameters.venv.isolated: + logging.info("Using base image environment") + else: + logging.info("Creating new Python %s virtual environment", self.parameters.venv.python.value) + venv_path = VenvManager.prepare_venv(self.parameters.venv.python, base_path) + os.environ["UV_PROJECT_ENVIRONMENT"] = str(venv_path) + os.environ["VIRTUAL_ENV"] = str(venv_path) + + if self.parameters.source == SourceEnum.CODE: + PackageInstaller.install_packages(self.parameters.packages) + else: PackageInstaller.install_packages_for_repository(GitHandler.REPO_PATH) self._merge_user_parameters() - self.execute_script_file(script_path) + self.execute_script_file(script_filename) - def execute_script_file(self, file_path): + def execute_script_file(self, file_path: Path): # Change current working directory so that relative paths work os.chdir(self.data_folder_path) sys.path.append(self.data_folder_path) @@ -62,8 +80,8 @@ def execute_script_file(self, file_path): with open(file_path) as file: script = file.read() logging.info("Executing script:\n%s", self.script_excerpt(script)) - runpy.run_path(file_path) - logging.info("Script finished successfully.") + args = ["uv", "run", file_path.name] + SubprocessRunner.run(args, "Script executed successfully.", "Script execution failed.") except Exception as err: _, _, tb = sys.exc_info() stack_len = len(traceback.extract_tb(tb)[4:]) @@ -101,7 +119,7 @@ def _merge_user_parameters(self): # build config data and overwrite for the user script config_data["parameters"] = self.parameters.user_properties - with open(os.path.join(self.data_folder_path, "config.json"), "w+") as inp: + with open(Path(self.data_folder_path) / "config.json", "w+") as inp: json.dump(config_data, inp) @sync_action("listBranches") diff --git a/src/configuration.py b/src/configuration.py index a359e1a..6bf9017 100644 --- a/src/configuration.py +++ b/src/configuration.py @@ -12,6 +12,12 @@ class SourceEnum(Enum): GIT = "git" +class PyEnum(Enum): + PY_3_12 = "3.12" + PY_3_13 = "3.13" + PY_3_14 = "3.14" + + class AuthEnum(Enum): NONE = "none" PAT = "pat" @@ -30,6 +36,12 @@ class SSHKeysConfiguration: keys: KeysConfiguration = field(default_factory=KeysConfiguration) +@dataclass +class VenvConfiguration: + isolated: bool = False + python: PyEnum = PyEnum.PY_3_13 # only takes effect if clean is True + + @dataclass class GitConfiguration: url: str = "" @@ -43,7 +55,8 @@ class GitConfiguration: @dataclass class Configuration: source: SourceEnum = SourceEnum.CODE - packages: list[str] = field(default_factory=list) user_properties: dict[str, object] = field(default_factory=dict) + venv: VenvConfiguration = field(default_factory=VenvConfiguration) + packages: list[str] = field(default_factory=list) code: str = "" git: GitConfiguration = field(default_factory=GitConfiguration) diff --git a/src/package_installer.py b/src/package_installer.py index 15bdae4..50d4aca 100644 --- a/src/package_installer.py +++ b/src/package_installer.py @@ -1,8 +1,7 @@ import logging -import os -import subprocess +from pathlib import Path -from keboola.component.exceptions import UserException +from subprocess_runner import SubprocessRunner class PackageInstaller: @@ -11,7 +10,7 @@ def install_packages(packages: list[str]): for package in packages: logging.info("Installing package: %s...", package) args = ["uv", "add", package] - PackageInstaller._run_installation_in_subprocess(args) + SubprocessRunner.run(args, "Installation successful.", "Installation failed.") @staticmethod def install_packages_for_repository(repository_path: str): @@ -23,35 +22,20 @@ def install_packages_for_repository(repository_path: str): Args: repository_path (str): Path to the repository containing requirements.txt. """ - pyproject_file = os.path.join(repository_path, "pyproject.toml") - uv_lock_file = f"{repository_path}/uv.lock" - requirements_file = f"{repository_path}/requirements.txt" + pyproject_file = Path(repository_path) / "pyproject.toml" + uv_lock_file = Path(repository_path) / "uv.lock" + requirements_file = Path(repository_path) / "requirements.txt" args = None - if os.path.exists(pyproject_file) and os.path.exists(uv_lock_file): + if pyproject_file.exists() and uv_lock_file.exists(): logging.info("Running uv sync...") args = ["uv", "sync", "--inexact"] - elif os.path.exists(requirements_file): + elif requirements_file.exists(): logging.info("Installing packages from requirements.txt...") - args = ["uv", "pip", "install", "-r", requirements_file] + args = ["uv", "pip", "install", "-r", str(requirements_file)] if not args: logging.info("No dependencies file found") return - PackageInstaller._run_installation_in_subprocess(args) - - logging.info("Package installation completed for repository: %s", repository_path) - - @staticmethod - def _run_installation_in_subprocess(args: list[str]): - logging.debug("Running command: %s", " ".join(args)) - process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - _, stderr = process.communicate() - process.poll() - if process.poll() != 0: - message = stderr.decode() if stderr else "Unknown installation error" - raise UserException("Installation failed. Log in event detail.", message) - elif stderr: - message = stderr.decode() if stderr else "uv output empty." - logging.info("Installation finished. Full log in detail.", extra={"full_message": message}) + SubprocessRunner.run(args, "Installation successful.", "Installation failed.") diff --git a/src/source_file.py b/src/source_file.py index f88a2ec..2222826 100644 --- a/src/source_file.py +++ b/src/source_file.py @@ -1,10 +1,10 @@ -import os +from pathlib import Path class FileHandler: @staticmethod - def prepare_script_file(destination_path: str, script: str) -> str: - script_filename = os.path.join(destination_path, "script.py") + def prepare_script_file(destination_path: str, script: str) -> Path: + script_filename = Path(destination_path) / "script.py" with open(script_filename, "w") as file: file.write(script) diff --git a/src/source_git.py b/src/source_git.py index 7b6e573..f60624f 100644 --- a/src/source_git.py +++ b/src/source_git.py @@ -1,8 +1,8 @@ import logging import os -import pathlib import subprocess import sys +from pathlib import Path from keboola.component.exceptions import UserException @@ -14,7 +14,7 @@ class GitHandler: def __init__(self, git_cfg: GitConfiguration): # add path for absolute imports to start at the cloned repository root level - sys.path.append(os.path.join(pathlib.Path(__file__).parent.parent, GitHandler.REPO_PATH)) + sys.path.append(str(Path(__file__).parent.parent / GitHandler.REPO_PATH)) self.env = os.environ.copy() self.git_cfg = git_cfg @@ -67,17 +67,17 @@ def _set_up_ssh_command(self) -> None: ] if self.git_cfg.ssh_keys.keys.encrypted_private: - ssh_key_path = os.path.expanduser("~/.ssh/github_private_key") + ssh_key_path = Path("~/.ssh/github_private_key").expanduser() with open(ssh_key_path, "wb") as f: for line in self.git_cfg.ssh_keys.keys.encrypted_private.splitlines(): f.write(line.encode() + b"\n") # ensure SSH key has correct permissions os.chmod(ssh_key_path, 0o600) - ssh_command.extend(["-i", ssh_key_path]) + ssh_command.extend(["-i", str(ssh_key_path)]) self.env["GIT_SSH_COMMAND"] = " ".join(ssh_command) - def clone_repository(self, sync_action=False): + def clone_repository(self, sync_action=False) -> Path: """ Clone a git repository and return the path to the cloned code. @@ -115,11 +115,11 @@ def clone_repository(self, sync_action=False): # when cloning for the "list files" sync action, checking for the script file presence doesn't make sense # and could cause problems in cases the repository changed for any reason if sync_action: - return None + return Path() - source_dir = os.path.join(os.getcwd(), GitHandler.REPO_PATH) - main_script_path = os.path.join(source_dir, self.git_cfg.filename) - if not os.path.exists(main_script_path): + source_dir = Path.cwd() / GitHandler.REPO_PATH + main_script_path = Path(source_dir) / self.git_cfg.filename + if not main_script_path.is_file(): raise UserException(f"Main script file '{self.git_cfg.filename}' not found in repository") return main_script_path @@ -166,7 +166,7 @@ def get_repository_files(self): for filename in filenames: if not filename.endswith(".py"): continue - path = os.path.join(dirpath, filename) + path = str(Path(dirpath) / filename) # strip the repository path prefix files.append(path[len(GitHandler.REPO_PATH) + 1 :]) diff --git a/src/subprocess_runner.py b/src/subprocess_runner.py new file mode 100644 index 0000000..70ef80b --- /dev/null +++ b/src/subprocess_runner.py @@ -0,0 +1,34 @@ +import logging +import subprocess + +from keboola.component.exceptions import UserException + + +class SubprocessRunner: + @staticmethod + def run( + args: list[str], + ok_message: str = "Command finished sucessfully.", + err_message: str = "Command failed.", + ): + logging.debug("Running command: %s", " ".join(args)) + process = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False, + ) + + stdout, stderr = process.communicate() + if stdout: + logging.info("Command output:\n%s", stdout.decode()) + + process.poll() + if process.poll() != 0: + message = stderr.decode() if stderr else "Unknown installation error" + raise UserException(f"{err_message} Log in event detail.", message) + elif stderr: + message = stderr.decode() if stderr else "uv output empty." + logging.info("%s Full log in detail.", ok_message, extra={"full_message": message}) + else: + logging.info(ok_message) diff --git a/src/venv_manager.py b/src/venv_manager.py new file mode 100644 index 0000000..206bd23 --- /dev/null +++ b/src/venv_manager.py @@ -0,0 +1,22 @@ +from pathlib import Path + +from configuration import PyEnum +from subprocess_runner import SubprocessRunner + + +class VenvManager: + @staticmethod + def prepare_venv(py_version: PyEnum, base_path: Path) -> Path: + """ + Prepare venv for the main script file given. The venv is always created in the same directory + as the main script file. + + Args: + main_script_file (str): Path to the main script file. + """ + venv_path = base_path / ".venv" + args = ["uv", "venv", "-p", py_version.value, str(venv_path)] + + SubprocessRunner.run(args, "Environment created successfully.", "Environment creation failed.") + + return venv_path From 9f7d04a585098ffbf514ab172f053d19f93dacbc Mon Sep 17 00:00:00 2001 From: soustruh Date: Tue, 8 Jul 2025 11:09:09 +0200 Subject: [PATCH 23/31] =?UTF-8?q?updated=20UI=20for=20python=20environment?= =?UTF-8?q?s=20=F0=9F=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 5 +++ component_config/configSchema.json | 66 +++++++++--------------------- src/component.py | 10 ++--- src/configuration.py | 11 ++--- src/venv_manager.py | 5 +-- 5 files changed, 34 insertions(+), 63 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0c69615..ba9a195 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,11 @@ RUN mkdir -p /.cache/uv RUN chown -R 1000:1000 /.cache ENV UV_CACHE_DIR="/.cache/uv" +# Preinstall Python versions +RUN uv python install 3.12 +RUN uv python install 3.13 +RUN uv python install 3.14 + # Using the same path as venv defined in the base image so we can use all the preinstalled packages ENV UV_PROJECT_ENVIRONMENT="/home/default/" diff --git a/component_config/configSchema.json b/component_config/configSchema.json index 5b3c17f..d6f6faa 100644 --- a/component_config/configSchema.json +++ b/component_config/configSchema.json @@ -26,54 +26,26 @@ } }, "venv": { - "type": "object", - "title": "Runtime Configuration", - "propertyOrder": 20, - "required": [ - "isolated", - "python" + "enum": [ + "base", + "3.12", + "3.13", + "3.14" ], - "properties": { - "clean": { - "type": "radio", - "title": "Isolated Python Environment", - "propertyOrder": 30, - "enum": [ - false, - true - ], - "options": { - "tooltip": "Isolated environment takes a couple of seconds to start, but gives you the opportuninty to pick one of the latest versions of Python. It's also a safer choice as it prevents package collisions.\n\n.Non-isolated environment (used to be the default choice) might start a bit faster, but can lead to issues mentioned above.", - "enum_titles": [ - "No (Python 3.10, contains all the pre-installed packages)", - "Yes (Python version based on your choice, just the packages)" - ] - }, - "default": true, - "required": false - }, - "python": { - "type": "radio", - "title": "Python Version", - "propertyOrder": 30, - "enum": [ - "3.12", - "3.13", - "3.14" - ], - "options": { - "tooltip": "3.12 (Oct 2023, security fixes until Oct 2028)\n3.13 (Oct 2024, security fixes until Oct 2029)\n3.14 (beta, Oct 2025, security fixes until Oct 2030)", - "enum_titles": [ - "3.12", - "3.13", - "3.14 (beta)" - ] - }, - "default": "3.13", - "required": false - } - - } + "type": "string", + "title": "Python Version & Environment Isolation", + "default": "3.13", + "options": { + "tooltip": "- Isolated environment takes a couple of seconds to start, but gives you the opportuninty to pick one of the latest versions of Python. It's also a safer choice as it prevents package collisions.\n- Non-isolated environment (used to be the default choice) might start a bit faster, but can lead to issues mentioned above.", + "enum_titles": [ + "Python 3.10 – Shared environment (contains many pre-installed packages in legacy versions)", + "Python 3.12 – Isolated environment (just the packages of your choice)", + "Python 3.13 – Isolated environment (just the packages of your choice)", + "Python 3.14 beta – Isolated environment (just the packages of your choice)" + ] + }, + "required": false, + "propertyOrder": 30 }, "source": { "type": "radio", diff --git a/src/component.py b/src/component.py index ea2cb3e..506b76d 100644 --- a/src/component.py +++ b/src/component.py @@ -14,7 +14,7 @@ from keboola.component.base import ComponentBase, sync_action from keboola.component.exceptions import UserException -from configuration import AuthEnum, Configuration, PyEnum, SourceEnum, encrypted_keys +from configuration import AuthEnum, Configuration, VenvEnum, SourceEnum, encrypted_keys from package_installer import PackageInstaller from source_file import FileHandler from source_git import GitHandler @@ -40,7 +40,7 @@ def __init__(self): Configuration, self.configuration.parameters, config=dacite.Config( - cast=[AuthEnum, PyEnum, SourceEnum], + cast=[AuthEnum, SourceEnum, VenvEnum], convert_key=encrypted_keys, ), ) @@ -54,11 +54,11 @@ def run(self): git_handler = GitHandler(self.parameters.git) script_filename = git_handler.clone_repository() - if not self.parameters.venv.isolated: + if self.parameters.venv == VenvEnum.BASE: logging.info("Using base image environment") else: - logging.info("Creating new Python %s virtual environment", self.parameters.venv.python.value) - venv_path = VenvManager.prepare_venv(self.parameters.venv.python, base_path) + logging.info("Creating new Python %s virtual environment", self.parameters.venv.value) + venv_path = VenvManager.prepare_venv(self.parameters.venv.value, base_path) os.environ["UV_PROJECT_ENVIRONMENT"] = str(venv_path) os.environ["VIRTUAL_ENV"] = str(venv_path) diff --git a/src/configuration.py b/src/configuration.py index 6bf9017..e576521 100644 --- a/src/configuration.py +++ b/src/configuration.py @@ -12,7 +12,8 @@ class SourceEnum(Enum): GIT = "git" -class PyEnum(Enum): +class VenvEnum(Enum): + BASE = "base" PY_3_12 = "3.12" PY_3_13 = "3.13" PY_3_14 = "3.14" @@ -36,12 +37,6 @@ class SSHKeysConfiguration: keys: KeysConfiguration = field(default_factory=KeysConfiguration) -@dataclass -class VenvConfiguration: - isolated: bool = False - python: PyEnum = PyEnum.PY_3_13 # only takes effect if clean is True - - @dataclass class GitConfiguration: url: str = "" @@ -56,7 +51,7 @@ class GitConfiguration: class Configuration: source: SourceEnum = SourceEnum.CODE user_properties: dict[str, object] = field(default_factory=dict) - venv: VenvConfiguration = field(default_factory=VenvConfiguration) + venv: VenvEnum = VenvEnum.PY_3_13 packages: list[str] = field(default_factory=list) code: str = "" git: GitConfiguration = field(default_factory=GitConfiguration) diff --git a/src/venv_manager.py b/src/venv_manager.py index 206bd23..9732990 100644 --- a/src/venv_manager.py +++ b/src/venv_manager.py @@ -1,12 +1,11 @@ from pathlib import Path -from configuration import PyEnum from subprocess_runner import SubprocessRunner class VenvManager: @staticmethod - def prepare_venv(py_version: PyEnum, base_path: Path) -> Path: + def prepare_venv(py_version: str, base_path: Path) -> Path: """ Prepare venv for the main script file given. The venv is always created in the same directory as the main script file. @@ -15,7 +14,7 @@ def prepare_venv(py_version: PyEnum, base_path: Path) -> Path: main_script_file (str): Path to the main script file. """ venv_path = base_path / ".venv" - args = ["uv", "venv", "-p", py_version.value, str(venv_path)] + args = ["uv", "venv", "-p", py_version, str(venv_path)] SubprocessRunner.run(args, "Environment created successfully.", "Environment creation failed.") From 5f66b05bae0d7c6372ebff0155e16fc5bd0f3740 Mon Sep 17 00:00:00 2001 From: soustruh Date: Tue, 8 Jul 2025 12:55:02 +0200 Subject: [PATCH 24/31] =?UTF-8?q?fix=20os.path=20=E2=86=92=20pathlib=20ref?= =?UTF-8?q?actor=20(forgotten=20.name)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/component.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/component.py b/src/component.py index 506b76d..db3f8dc 100644 --- a/src/component.py +++ b/src/component.py @@ -80,7 +80,7 @@ def execute_script_file(self, file_path: Path): with open(file_path) as file: script = file.read() logging.info("Executing script:\n%s", self.script_excerpt(script)) - args = ["uv", "run", file_path.name] + args = ["uv", "run", str(file_path)] SubprocessRunner.run(args, "Script executed successfully.", "Script execution failed.") except Exception as err: _, _, tb = sys.exc_info() From cbc283c4b99ee990d9cf7b9fc5891b6a365b2e90 Mon Sep 17 00:00:00 2001 From: soustruh Date: Tue, 8 Jul 2025 13:10:08 +0200 Subject: [PATCH 25/31] exclude workflow run number from image tag again (not needed anymore) --- .github/workflows/push.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 0643d6e..967b720 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -11,8 +11,8 @@ on: concurrency: ci-${{ github.ref }} # to avoid tag collisions in the ECR env: # repository variables: - KBC_DEVELOPERPORTAL_APP: "kds-team.app-custom-python-test" # replace with your component id - KBC_DEVELOPERPORTAL_VENDOR: "kds-team" # replace with your vendor + KBC_DEVELOPERPORTAL_APP: "kds-team.app-custom-python-test" + KBC_DEVELOPERPORTAL_VENDOR: "kds-team" DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} KBC_DEVELOPERPORTAL_USERNAME: "kds-team+github" @@ -65,7 +65,7 @@ jobs: - name: Set image tag id: tag run: | - TAG="${GITHUB_REF##*/}-${{ github.run_number }}" + TAG="${GITHUB_REF##*/}" IS_SEMANTIC_TAG=$(echo "$TAG" | grep -q '^v\?[0-9]\+\.[0-9]\+\.[0-9]\+$' && echo true || echo false) echo "is_semantic_tag=$IS_SEMANTIC_TAG" | tee -a $GITHUB_OUTPUT echo "app_image_tag=$TAG" | tee -a $GITHUB_OUTPUT From 128ed92fda0b9efb10c7b78c00ef78bb6dba602e Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 9 Jul 2025 02:49:03 +0200 Subject: [PATCH 26/31] =?UTF-8?q?fix=20package=20installation=20for=20cert?= =?UTF-8?q?ain=20scenarios=20=F0=9F=93=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/component.py | 7 ++++--- src/package_installer.py | 29 +++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/component.py b/src/component.py index db3f8dc..00511e4 100644 --- a/src/component.py +++ b/src/component.py @@ -14,7 +14,7 @@ from keboola.component.base import ComponentBase, sync_action from keboola.component.exceptions import UserException -from configuration import AuthEnum, Configuration, VenvEnum, SourceEnum, encrypted_keys +from configuration import AuthEnum, Configuration, SourceEnum, VenvEnum, encrypted_keys from package_installer import PackageInstaller from source_file import FileHandler from source_git import GitHandler @@ -50,7 +50,7 @@ def run(self): base_path = Path(self.data_folder_path) script_filename = FileHandler.prepare_script_file(self.data_folder_path, self.parameters.code) else: - base_path = Path(GitHandler.REPO_PATH) + base_path = Path(GitHandler.REPO_PATH).absolute() git_handler = GitHandler(self.parameters.git) script_filename = git_handler.clone_repository() @@ -59,13 +59,14 @@ def run(self): else: logging.info("Creating new Python %s virtual environment", self.parameters.venv.value) venv_path = VenvManager.prepare_venv(self.parameters.venv.value, base_path) + logging.info("Virtual environment created at %s", venv_path) os.environ["UV_PROJECT_ENVIRONMENT"] = str(venv_path) os.environ["VIRTUAL_ENV"] = str(venv_path) if self.parameters.source == SourceEnum.CODE: PackageInstaller.install_packages(self.parameters.packages) else: - PackageInstaller.install_packages_for_repository(GitHandler.REPO_PATH) + PackageInstaller.install_packages_for_repository(base_path) self._merge_user_parameters() diff --git a/src/package_installer.py b/src/package_installer.py index 50d4aca..962ecaf 100644 --- a/src/package_installer.py +++ b/src/package_installer.py @@ -1,19 +1,28 @@ import logging +import os from pathlib import Path from subprocess_runner import SubprocessRunner +MSG_OK = "Installation successful." +MSG_ERR = "Installation failed." + class PackageInstaller: @staticmethod - def install_packages(packages: list[str]): + def install_packages(packages: list[str], use_pip=False): + if use_pip: + uv_args = ["pip", "install"] + else: + uv_args = ["add"] + for package in packages: logging.info("Installing package: %s...", package) - args = ["uv", "add", package] - SubprocessRunner.run(args, "Installation successful.", "Installation failed.") + args = ["uv", *uv_args, package] + SubprocessRunner.run(args, MSG_OK, MSG_ERR) @staticmethod - def install_packages_for_repository(repository_path: str): + def install_packages_for_repository(repository_path: Path): """ Install packages based on the given repository path. - If there is a pyproject.toml and a uv.lock file, run uv sync. @@ -22,13 +31,17 @@ def install_packages_for_repository(repository_path: str): Args: repository_path (str): Path to the repository containing requirements.txt. """ - pyproject_file = Path(repository_path) / "pyproject.toml" - uv_lock_file = Path(repository_path) / "uv.lock" - requirements_file = Path(repository_path) / "requirements.txt" + 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"], use_pip=True) 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 args = ["uv", "sync", "--inexact"] elif requirements_file.exists(): logging.info("Installing packages from requirements.txt...") @@ -38,4 +51,4 @@ def install_packages_for_repository(repository_path: str): logging.info("No dependencies file found") return - SubprocessRunner.run(args, "Installation successful.", "Installation failed.") + SubprocessRunner.run(args, MSG_OK, MSG_ERR) From a30828f6904587056a0d4d77c5bbc48a206f7210 Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 9 Jul 2025 03:09:24 +0200 Subject: [PATCH 27/31] =?UTF-8?q?correct=20permissions=20for=20preinstalle?= =?UTF-8?q?d=20python=20versions=20so=20they=20can=20actually=20be=20used?= =?UTF-8?q?=20=F0=9F=98=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index ba9a195..db4a044 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,18 +13,20 @@ RUN mkdir -p /.cache/uv RUN chown -R 1000:1000 /.cache ENV UV_CACHE_DIR="/.cache/uv" -# Preinstall Python versions +# Using the same path as venv defined in the base image so we can use all the preinstalled packages +ENV UV_PROJECT_ENVIRONMENT="/home/default/" + +# Preinstall other Python versions for creating isolated virtual environments +USER 1000:1000 RUN uv python install 3.12 RUN uv python install 3.13 RUN uv python install 3.14 -# Using the same path as venv defined in the base image so we can use all the preinstalled packages -ENV UV_PROJECT_ENVIRONMENT="/home/default/" - -# Add Github SSH host key to known_hosts file +# Add Github SSH host key to known_hosts file & create .bash_aliases for convenience when debugging USER 1000:1000 RUN mkdir /home/${USERNAME}/.ssh COPY .ssh/known_hosts /home/${USERNAME}/.ssh/known_hosts +RUN echo "alias l='ls -Al --group-directories-first'" >> /home/${USERNAME}/.bash_aliases # Create root's .ssh directory for storing SSH keys when running sync actions USER root From 376ef5b12438bdff009072795964b387413d8f24 Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 9 Jul 2025 16:05:09 +0200 Subject: [PATCH 28/31] =?UTF-8?q?tests=20for=20local=20development=20?= =?UTF-8?q?=F0=9F=A7=91=E2=80=8D=F0=9F=92=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/build_n_test_docker.py | 55 ++++++++++++++++++++++++ src/component.py | 7 ++- tests/config-1_git-example-1-base.json | 18 ++++++++ tests/config-2_git-example-1-3.13.json | 18 ++++++++ tests/config-3_git-example-2-base.json | 18 ++++++++ tests/config-4_git-example-2-3.13.json | 18 ++++++++ tests/config-5_code-base-pandas.json | 17 ++++++++ tests/config-6_code-3.13-pandas.json | 17 ++++++++ tests/config-7_code-base-httpx-only.json | 16 +++++++ tests/config-8_code-3.13-httpx-only.json | 16 +++++++ 10 files changed, 199 insertions(+), 1 deletion(-) create mode 100755 scripts/build_n_test_docker.py create mode 100644 tests/config-1_git-example-1-base.json create mode 100644 tests/config-2_git-example-1-3.13.json create mode 100644 tests/config-3_git-example-2-base.json create mode 100644 tests/config-4_git-example-2-3.13.json create mode 100644 tests/config-5_code-base-pandas.json create mode 100644 tests/config-6_code-3.13-pandas.json create mode 100644 tests/config-7_code-base-httpx-only.json create mode 100644 tests/config-8_code-3.13-httpx-only.json diff --git a/scripts/build_n_test_docker.py b/scripts/build_n_test_docker.py new file mode 100755 index 0000000..99bf4c4 --- /dev/null +++ b/scripts/build_n_test_docker.py @@ -0,0 +1,55 @@ +import logging +import shutil +import subprocess +import sys +import time +from glob import glob + +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s - %(filename)s:%(lineno)d - %(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) + + +if len(sys.argv) > 1: + data_dir = sys.argv[1] +else: + data_dir = "./data" + + +subprocess.run("docker build -t cp .", shell=True, check=True) + +all_passed = True +results = [] + +for i in range(1, 9): + config_files = glob(f"tests/config-{i}*.json") + if not config_files: + continue + + filename = config_files[0] + logging.info(f"👉 Testing {filename}...") + + shutil.copy(filename, f"{data_dir}/config.json") + + start_time = time.time() + result = subprocess.run(["docker", "run", "-v", f"{data_dir}:/data", "-u", "1000:1000", "-it", "--rm", "cp:latest"]) + elapsed_ms = round((time.time() - start_time) * 1000) + + if result.returncode == 0: + results.append(f"✅ {filename}: PASSED ({elapsed_ms} ms)") + else: + all_passed = False + msg = f"❌ {filename}: FAILED ({elapsed_ms} ms)" + results.append(msg) + logging.info(msg) + + shutil.rmtree("data/.venv", ignore_errors=True) + +logging.info("Results:\n" + "\n".join(f"- {r}" for r in results)) + +if all_passed: + logging.info("✅ All tests passed! 🎉") +else: + sys.exit(1) diff --git a/src/component.py b/src/component.py index 00511e4..d909096 100644 --- a/src/component.py +++ b/src/component.py @@ -107,7 +107,12 @@ def script_excerpt(script): def _set_init_logging_handler(self): for h in logging.getLogger().handlers: - h.setFormatter(logging.Formatter("[Non-script message]: %(message)s")) + h.setFormatter( + logging.Formatter( + fmt="[%(asctime)s - %(filename)s:%(lineno)d - %(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) def _merge_user_parameters(self): """ diff --git a/tests/config-1_git-example-1-base.json b/tests/config-1_git-example-1-base.json new file mode 100644 index 0000000..db82660 --- /dev/null +++ b/tests/config-1_git-example-1-base.json @@ -0,0 +1,18 @@ +{ + "parameters": { + "source": "git", + "git": { + "url": "https://github.com/keboola/component-custom-python-example-repo-1.git", + "auth": "none", + "branch": "main", + "filename": "main.py" + }, + "user_properties": { + "debug": true, + "rectangle_a": 3.0, + "rectangle_b": 4.0, + "endpoint": "https://www.example.com" + }, + "venv": "base" + } +} diff --git a/tests/config-2_git-example-1-3.13.json b/tests/config-2_git-example-1-3.13.json new file mode 100644 index 0000000..24c6a12 --- /dev/null +++ b/tests/config-2_git-example-1-3.13.json @@ -0,0 +1,18 @@ +{ + "parameters": { + "source": "git", + "git": { + "url": "https://github.com/keboola/component-custom-python-example-repo-1.git", + "auth": "none", + "branch": "main", + "filename": "main.py" + }, + "user_properties": { + "debug": true, + "rectangle_a": 3.0, + "rectangle_b": 4.0, + "endpoint": "https://www.example.com" + }, + "venv": "3.13" + } +} diff --git a/tests/config-3_git-example-2-base.json b/tests/config-3_git-example-2-base.json new file mode 100644 index 0000000..214aae6 --- /dev/null +++ b/tests/config-3_git-example-2-base.json @@ -0,0 +1,18 @@ +{ + "parameters": { + "source": "git", + "git": { + "url": "https://github.com/keboola/component-custom-python-example-repo-2.git", + "auth": "none", + "branch": "main", + "filename": "src/main.py" + }, + "user_properties": { + "debug": true, + "rectangle_a": 3.0, + "rectangle_b": 4.0, + "endpoint": "https://www.example.com" + }, + "venv": "base" + } +} diff --git a/tests/config-4_git-example-2-3.13.json b/tests/config-4_git-example-2-3.13.json new file mode 100644 index 0000000..1e66782 --- /dev/null +++ b/tests/config-4_git-example-2-3.13.json @@ -0,0 +1,18 @@ +{ + "parameters": { + "source": "git", + "git": { + "url": "https://github.com/keboola/component-custom-python-example-repo-2.git", + "auth": "none", + "branch": "main", + "filename": "src/main.py" + }, + "user_properties": { + "debug": true, + "rectangle_a": 3.0, + "rectangle_b": 4.0, + "endpoint": "https://www.example.com" + }, + "venv": "3.13" + } +} diff --git a/tests/config-5_code-base-pandas.json b/tests/config-5_code-base-pandas.json new file mode 100644 index 0000000..58b67a4 --- /dev/null +++ b/tests/config-5_code-base-pandas.json @@ -0,0 +1,17 @@ +{ + "parameters": { + "source": "code", + "packages": [ + "httpx", + "pandas" + ], + "user_properties": { + "debug": true, + "rectangle_a": 3.0, + "rectangle_b": 4.0, + "endpoint": "https://www.example.com" + }, + "venv": "base", + "code": "import sys\n\nprint(sys.executable, sys.version)\n\nimport httpx\nimport pandas\nfrom keboola.component import CommonInterface\n\n\nci = CommonInterface()\nprint(ci.configuration.parameters)\n\nurl = \"https://api.nationalize.io/?name=john\"\n\n\ndef get_json_from_url(url):\n try:\n response = httpx.get(url)\n response.raise_for_status()\n return response.json()\n except httpx.HTTPError as e:\n print(f\"Error fetching data from {url}: {e}\")\n return None\n\n\ndata = get_json_from_url(url)\nif data:\n print(data)\n" + } +} diff --git a/tests/config-6_code-3.13-pandas.json b/tests/config-6_code-3.13-pandas.json new file mode 100644 index 0000000..49e153d --- /dev/null +++ b/tests/config-6_code-3.13-pandas.json @@ -0,0 +1,17 @@ +{ + "parameters": { + "source": "code", + "packages": [ + "httpx", + "pandas" + ], + "user_properties": { + "debug": true, + "rectangle_a": 3.0, + "rectangle_b": 4.0, + "endpoint": "https://www.example.com" + }, + "venv": "3.13", + "code": "import sys\n\nprint(sys.executable, sys.version)\n\nimport httpx\nimport pandas\nfrom keboola.component import CommonInterface\n\n\nci = CommonInterface()\nprint(ci.configuration.parameters)\n\nurl = \"https://api.nationalize.io/?name=john\"\n\n\ndef get_json_from_url(url):\n try:\n response = httpx.get(url)\n response.raise_for_status()\n return response.json()\n except httpx.HTTPError as e:\n print(f\"Error fetching data from {url}: {e}\")\n return None\n\n\ndata = get_json_from_url(url)\nif data:\n print(data)\n" + } +} \ No newline at end of file diff --git a/tests/config-7_code-base-httpx-only.json b/tests/config-7_code-base-httpx-only.json new file mode 100644 index 0000000..d931e5f --- /dev/null +++ b/tests/config-7_code-base-httpx-only.json @@ -0,0 +1,16 @@ +{ + "parameters": { + "source": "code", + "packages": [ + "httpx" + ], + "user_properties": { + "debug": true, + "rectangle_a": 3.0, + "rectangle_b": 4.0, + "endpoint": "https://www.example.com" + }, + "venv": "base", + "code": "import sys\n\nprint(sys.executable, sys.version)\n\nimport httpx\nfrom keboola.component import CommonInterface\n\n\nci = CommonInterface()\nprint(ci.configuration.parameters)\n\nurl = \"https://api.nationalize.io/?name=john\"\n\n\ndef get_json_from_url(url):\n try:\n response = httpx.get(url)\n response.raise_for_status()\n return response.json()\n except httpx.HTTPError as e:\n print(f\"Error fetching data from {url}: {e}\")\n return None\n\n\ndata = get_json_from_url(url)\nif data:\n print(data)\n" + } +} \ No newline at end of file diff --git a/tests/config-8_code-3.13-httpx-only.json b/tests/config-8_code-3.13-httpx-only.json new file mode 100644 index 0000000..4c2d819 --- /dev/null +++ b/tests/config-8_code-3.13-httpx-only.json @@ -0,0 +1,16 @@ +{ + "parameters": { + "source": "code", + "packages": [ + "httpx" + ], + "user_properties": { + "debug": true, + "rectangle_a": 3.0, + "rectangle_b": 4.0, + "endpoint": "https://www.example.com" + }, + "venv": "3.13", + "code": "import sys\n\nprint(sys.executable, sys.version)\n\nimport httpx\nfrom keboola.component import CommonInterface\n\n\nci = CommonInterface()\nprint(ci.configuration.parameters)\n\nurl = \"https://api.nationalize.io/?name=john\"\n\n\ndef get_json_from_url(url):\n try:\n response = httpx.get(url)\n response.raise_for_status()\n return response.json()\n except httpx.HTTPError as e:\n print(f\"Error fetching data from {url}: {e}\")\n return None\n\n\ndata = get_json_from_url(url)\nif data:\n print(data)\n" + } +} \ No newline at end of file From 7c29894e82743457544c78aa446775995496fb7a Mon Sep 17 00:00:00 2001 From: soustruh Date: Wed, 9 Jul 2025 16:57:40 +0200 Subject: [PATCH 29/31] use base image's python environment for old configurations with no `venv` value --- src/configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/configuration.py b/src/configuration.py index e576521..8c24cac 100644 --- a/src/configuration.py +++ b/src/configuration.py @@ -51,7 +51,7 @@ class GitConfiguration: class Configuration: source: SourceEnum = SourceEnum.CODE user_properties: dict[str, object] = field(default_factory=dict) - venv: VenvEnum = VenvEnum.PY_3_13 + venv: VenvEnum = VenvEnum.BASE packages: list[str] = field(default_factory=list) code: str = "" git: GitConfiguration = field(default_factory=GitConfiguration) From 4ffac932ec242f774ffe97a45864378abec26fc8 Mon Sep 17 00:00:00 2001 From: soustruh Date: Thu, 10 Jul 2025 08:39:37 +0200 Subject: [PATCH 30/31] fixed forgotten & misleading default error messages --- src/subprocess_runner.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/subprocess_runner.py b/src/subprocess_runner.py index 70ef80b..94c8b27 100644 --- a/src/subprocess_runner.py +++ b/src/subprocess_runner.py @@ -25,10 +25,9 @@ def run( process.poll() if process.poll() != 0: - message = stderr.decode() if stderr else "Unknown installation error" - raise UserException(f"{err_message} Log in event detail.", message) + stderr_str = stderr.decode() if stderr else "Unknown error." + raise UserException(f"{err_message} Log in event detail.", stderr_str) elif stderr: - message = stderr.decode() if stderr else "uv output empty." - logging.info("%s Full log in detail.", ok_message, extra={"full_message": message}) + logging.info("%s Full log in detail.", ok_message, extra={"full_message": stderr.decode()}) else: logging.info(ok_message) From d6ea563e7a14be5f7ad096b28f726e93590c7cce Mon Sep 17 00:00:00 2001 From: soustruh Date: Thu, 10 Jul 2025 08:48:36 +0200 Subject: [PATCH 31/31] Revert "deploy in a test component" This reverts commit d38f02cedc5a4155b0246c5c53c9657c6dcf6be9. --- .github/workflows/push.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 967b720..1f195b7 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -11,7 +11,7 @@ on: concurrency: ci-${{ github.ref }} # to avoid tag collisions in the ECR env: # repository variables: - KBC_DEVELOPERPORTAL_APP: "kds-team.app-custom-python-test" + KBC_DEVELOPERPORTAL_APP: "kds-team.app-custom-python" KBC_DEVELOPERPORTAL_VENDOR: "kds-team" DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} KBC_DEVELOPERPORTAL_USERNAME: "kds-team+github" @@ -201,7 +201,7 @@ jobs: - push_event_info - build - push - # if: needs.push_event_info.outputs.is_deploy_ready == 'true' + if: needs.push_event_info.outputs.is_deploy_ready == 'true' runs-on: ubuntu-latest steps: - name: Set Developer Portal Tag @@ -222,7 +222,7 @@ jobs: - build - push runs-on: ubuntu-latest - # if: needs.push_event_info.outputs.is_deploy_ready == 'true' + if: needs.push_event_info.outputs.is_deploy_ready == 'true' steps: - name: Checkout Repository uses: actions/checkout@v4