From b3ad1584b210f145030b121ecbe953bb018eca4c Mon Sep 17 00:00:00 2001 From: Kevin Su Date: Thu, 23 Feb 2023 05:44:22 +0800 Subject: [PATCH 01/22] Make flytekit comply with PEP-561 (#1516) * Make flytekit comply with PEP-561 Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su --- flytekit/core/map_task.py | 7 ++++++- flytekit/py.typed | 0 plugins/setup.py | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 flytekit/py.typed diff --git a/flytekit/core/map_task.py b/flytekit/core/map_task.py index 48d0f0b335..f888638965 100644 --- a/flytekit/core/map_task.py +++ b/flytekit/core/map_task.py @@ -221,7 +221,12 @@ def _raw_execute(self, **kwargs) -> Any: return outputs -def map_task(task_function: PythonFunctionTask, concurrency: int = 0, min_success_ratio: float = 1.0, **kwargs): +def map_task( + task_function: typing.Union[typing.Callable, PythonFunctionTask], + concurrency: int = 0, + min_success_ratio: float = 1.0, + **kwargs, +): """ Use a map task for parallelizable tasks that run across a list of an input type. A map task can be composed of any individual :py:class:`flytekit.PythonFunctionTask`. diff --git a/flytekit/py.typed b/flytekit/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/setup.py b/plugins/setup.py index fe5c8c200d..de44797296 100644 --- a/plugins/setup.py +++ b/plugins/setup.py @@ -81,4 +81,5 @@ def run(self): classifiers=["Private :: Do Not Upload to pypi server"], install_requires=[], cmdclass={"install": InstallCmd, "develop": DevelopCmd}, + package_data={"flytekit": ["py.typed"]}, ) From d682410c0aa3ff4182f7e34459be02e2bcecc9a6 Mon Sep 17 00:00:00 2001 From: Ketan Umare <16888709+kumare3@users.noreply.github.com> Date: Thu, 23 Feb 2023 13:44:06 -0800 Subject: [PATCH 02/22] Flytekit Auth system overhaul and pretty printing upgrade (#1458) * [wip] New authentication system - Reuse local keyring better - use grpc based auth system Signed-off-by: Ketan Umare * Better error handling and printing, better exception handling and retrying Signed-off-by: Ketan Umare * Delete legacy files Signed-off-by: Ketan Umare * add missing None Signed-off-by: Ketan Umare * keyring removed Signed-off-by: Ketan Umare * added insecure_skip_verify Signed-off-by: Ketan Umare * test fixed Signed-off-by: Ketan Umare * Test fixed Signed-off-by: Ketan Umare * Auth update Signed-off-by: Ketan Umare * updated test Signed-off-by: Ketan Umare * updated Signed-off-by: Ketan Umare * flush buffer instead of closing, was getting a weird stack trace. make the image smaller Signed-off-by: Yee Hing Tong * updated ca-cert logic Signed-off-by: Ketan Umare * Fixed unit tests Signed-off-by: Ketan Umare * updated Signed-off-by: Ketan Umare * test fix Signed-off-by: Ketan Umare * updated Signed-off-by: Ketan Umare * nest raise if exc Signed-off-by: Yee Hing Tong * added keyring.alt for tests Signed-off-by: Ketan Umare * updated Signed-off-by: Ketan Umare * updated Signed-off-by: Ketan Umare * Lint Signed-off-by: Eduardo Apolinario --------- Signed-off-by: Ketan Umare Signed-off-by: Ketan Umare Signed-off-by: Ketan Umare Signed-off-by: Ketan Umare Signed-off-by: Yee Hing Tong Signed-off-by: Eduardo Apolinario Co-authored-by: Ketan Umare Co-authored-by: Ketan Umare Co-authored-by: Ketan Umare Co-authored-by: Yee Hing Tong Co-authored-by: Eduardo Apolinario --- dev-requirements.in | 1 + flytekit/{clis => clients}/auth/__init__.py | 0 .../auth.py => clients/auth/auth_client.py} | 251 +++++++------ flytekit/clients/auth/authenticator.py | 235 ++++++++++++ flytekit/clients/auth/default_html.py | 12 + flytekit/clients/auth/exceptions.py | 14 + flytekit/clients/auth/keyring.py | 64 ++++ flytekit/clients/auth_helper.py | 193 ++++++++++ flytekit/clients/grpc_utils/__init__.py | 0 .../clients/grpc_utils/auth_interceptor.py | 80 ++++ .../grpc_utils/wrap_exception_interceptor.py | 49 +++ flytekit/clients/raw.py | 350 +----------------- flytekit/clis/auth/credentials.py | 33 -- flytekit/clis/sdk_in_container/constants.py | 1 + flytekit/clis/sdk_in_container/pyflyte.py | 67 +++- flytekit/configuration/__init__.py | 18 +- flytekit/configuration/internal.py | 3 + flytekit/exceptions/user.py | 10 + flytekit/remote/remote.py | 12 +- tests/flytekit/unit/cli/pyflyte/test_run.py | 1 + tests/flytekit/unit/clients/auth/__init__.py | 0 .../auth/test_auth_client.py} | 21 +- .../unit/clients/auth/test_authenticator.py | 95 +++++ .../unit/clients/auth/test_default_html.py | 18 + .../unit/clients/auth/test_keyring_store.py | 32 ++ .../flytekit/unit/clients/test_auth_helper.py | 154 ++++++++ tests/flytekit/unit/clients/test_raw.py | 226 +---------- .../unit/clients/testdata/rootCACert.pem | 17 + .../unit/clients/testdata/rootCAKey.pem | 27 ++ 29 files changed, 1253 insertions(+), 731 deletions(-) rename flytekit/{clis => clients}/auth/__init__.py (100%) rename flytekit/{clis/auth/auth.py => clients/auth/auth_client.py} (56%) create mode 100644 flytekit/clients/auth/authenticator.py create mode 100644 flytekit/clients/auth/default_html.py create mode 100644 flytekit/clients/auth/exceptions.py create mode 100644 flytekit/clients/auth/keyring.py create mode 100644 flytekit/clients/auth_helper.py create mode 100644 flytekit/clients/grpc_utils/__init__.py create mode 100644 flytekit/clients/grpc_utils/auth_interceptor.py create mode 100644 flytekit/clients/grpc_utils/wrap_exception_interceptor.py delete mode 100644 flytekit/clis/auth/credentials.py create mode 100644 tests/flytekit/unit/clients/auth/__init__.py rename tests/flytekit/unit/{cli/auth/test_auth.py => clients/auth/test_auth_client.py} (52%) create mode 100644 tests/flytekit/unit/clients/auth/test_authenticator.py create mode 100644 tests/flytekit/unit/clients/auth/test_default_html.py create mode 100644 tests/flytekit/unit/clients/auth/test_keyring_store.py create mode 100644 tests/flytekit/unit/clients/test_auth_helper.py create mode 100644 tests/flytekit/unit/clients/testdata/rootCACert.pem create mode 100644 tests/flytekit/unit/clients/testdata/rootCAKey.pem diff --git a/dev-requirements.in b/dev-requirements.in index 755231ed71..25e6cf51e7 100644 --- a/dev-requirements.in +++ b/dev-requirements.in @@ -12,6 +12,7 @@ codespell google-cloud-bigquery google-cloud-bigquery-storage IPython +keyrings.alt # Only install tensorflow if not running on an arm Mac. tensorflow==2.8.1; platform_machine!='arm64' or platform_system!='Darwin' diff --git a/flytekit/clis/auth/__init__.py b/flytekit/clients/auth/__init__.py similarity index 100% rename from flytekit/clis/auth/__init__.py rename to flytekit/clients/auth/__init__.py diff --git a/flytekit/clis/auth/auth.py b/flytekit/clients/auth/auth_client.py similarity index 56% rename from flytekit/clis/auth/auth.py rename to flytekit/clients/auth/auth_client.py index f54379485a..94afa13612 100644 --- a/flytekit/clis/auth/auth.py +++ b/flytekit/clients/auth/auth_client.py @@ -1,32 +1,31 @@ +from __future__ import annotations + import base64 as _base64 import hashlib as _hashlib import http.server as _BaseHTTPServer +import logging +import multiprocessing import os as _os import re as _re +import typing import urllib.parse as _urlparse import webbrowser as _webbrowser +from dataclasses import dataclass from http import HTTPStatus as _StatusCodes -from multiprocessing import get_context as _mp_get_context +from multiprocessing import get_context from urllib.parse import urlencode as _urlencode -import keyring as _keyring import requests as _requests -from flytekit.loggers import auth_logger +from .default_html import get_default_success_html +from .exceptions import AccessTokenNotFoundError +from .keyring import Credentials _code_verifier_length = 64 _random_seed_length = 40 _utf_8 = "utf-8" -# Identifies the service used for storing passwords in keyring -_keyring_service_name = "flyteauth" -# Identifies the key used for storing and fetching from keyring. In our case, instead of a username as the keyring docs -# suggest, we are storing a user's oidc. -_keyring_access_token_storage_key = "access_token" -_keyring_refresh_token_storage_key = "refresh_token" - - def _generate_code_verifier(): """ Generates a 'code_verifier' as described in https://tools.ietf.org/html/rfc7636#section-4.1 @@ -77,6 +76,17 @@ def state(self): return self._state +@dataclass +class EndpointMetadata(object): + """ + This class can be used to control the rendering of the page on login successful or failure + """ + + endpoint: str + success_html: typing.Optional[bytes] = None + failure_html: typing.Optional[bytes] = None + + class OAuthCallbackHandler(_BaseHTTPServer.BaseHTTPRequestHandler): """ A simple wrapper around BaseHTTPServer.BaseHTTPRequestHandler that handles a callback URL that accepts an @@ -87,12 +97,16 @@ def do_GET(self): url = _urlparse.urlparse(self.path) if url.path.strip("/") == self.server.redirect_path.strip("/"): self.send_response(_StatusCodes.OK) + self.send_header("Content-type", "text/html") self.end_headers() self.handle_login(dict(_urlparse.parse_qsl(url.query))) + if self.server.remote_metadata.success_html is None: + self.wfile.write(bytes(get_default_success_html(self.server.remote_metadata.endpoint), "utf-8")) + self.wfile.flush() else: self.send_response(_StatusCodes.NOT_FOUND) - def handle_login(self, data): + def handle_login(self, data: dict): self.server.handle_authorization_code(AuthorizationCode(data["code"], data["state"])) @@ -104,49 +118,97 @@ class OAuthHTTPServer(_BaseHTTPServer.HTTPServer): def __init__( self, - server_address, - RequestHandlerClass, - bind_and_activate=True, - redirect_path=None, - queue=None, + server_address: typing.Tuple[str, int], + remote_metadata: EndpointMetadata, + request_handler_class: typing.Type[_BaseHTTPServer.BaseHTTPRequestHandler], + bind_and_activate: bool = True, + redirect_path: str = None, + queue: multiprocessing.Queue = None, ): - _BaseHTTPServer.HTTPServer.__init__(self, server_address, RequestHandlerClass, bind_and_activate) + _BaseHTTPServer.HTTPServer.__init__(self, server_address, request_handler_class, bind_and_activate) self._redirect_path = redirect_path + self._remote_metadata = remote_metadata self._auth_code = None self._queue = queue @property - def redirect_path(self): + def redirect_path(self) -> str: return self._redirect_path - def handle_authorization_code(self, auth_code): + @property + def remote_metadata(self) -> EndpointMetadata: + return self._remote_metadata + + def handle_authorization_code(self, auth_code: str): self._queue.put(auth_code) self.server_close() - def handle_request(self, queue=None): + def handle_request(self, queue: multiprocessing.Queue = None) -> typing.Any: self._queue = queue return super().handle_request() -class Credentials(object): - def __init__(self, access_token=None): - self._access_token = access_token +class _SingletonPerEndpoint(type): + """ + A metaclass to create per endpoint singletons for AuthorizationClient objects + """ + + _instances: typing.Dict[str, AuthorizationClient] = {} - @property - def access_token(self): - return self._access_token + def __call__(cls, *args, **kwargs): + endpoint = "" + if args: + endpoint = args[0] + elif "auth_endpoint" in kwargs: + endpoint = kwargs["auth_endpoint"] + else: + raise ValueError("parameter auth_endpoint is required") + if endpoint not in cls._instances: + cls._instances[endpoint] = super(_SingletonPerEndpoint, cls).__call__(*args, **kwargs) + return cls._instances[endpoint] -class AuthorizationClient(object): +class AuthorizationClient(metaclass=_SingletonPerEndpoint): + """ + Authorization client that stores the credentials in keyring and uses oauth2 standard flow to retrieve the + credentials. NOTE: This will open an web browser to retreive the credentials. + """ + def __init__( self, - auth_endpoint=None, - token_endpoint=None, - scopes=None, - client_id=None, - redirect_uri=None, + endpoint: str, + auth_endpoint: str, + token_endpoint: str, + scopes: typing.Optional[typing.List[str]] = None, + client_id: typing.Optional[str] = None, + redirect_uri: typing.Optional[str] = None, + endpoint_metadata: typing.Optional[EndpointMetadata] = None, + verify: typing.Optional[typing.Union[bool, str]] = None, ): + """ + Create new AuthorizationClient + + :param endpoint: str endpoint to connect to + :param auth_endpoint: str endpoint where auth metadata can be found + :param token_endpoint: str endpoint to retrieve token from + :param scopes: list[str] oauth2 scopes + :param client_id + :param verify: (optional) Either a boolean, in which case it controls whether we verify + the server's TLS certificate, or a string, in which case it must be a path + to a CA bundle to use. Defaults to ``True``. When set to + ``False``, requests will accept any TLS certificate presented by + the server, and will ignore hostname mismatches and/or expired + certificates, which will make your application vulnerable to + man-in-the-middle (MitM) attacks. Setting verify to ``False`` + may be useful during local development or testing. + """ + self._endpoint = endpoint self._auth_endpoint = auth_endpoint + if endpoint_metadata is None: + remote_url = _urlparse.urlparse(self._auth_endpoint) + self._remote = EndpointMetadata(endpoint=remote_url.hostname) + else: + self._remote = endpoint_metadata self._token_endpoint = token_endpoint self._client_id = client_id self._scopes = scopes or [] @@ -156,10 +218,8 @@ def __init__( self._code_challenge = code_challenge state = _generate_state_parameter() self._state = state - self._credentials = None - self._refresh_token = None + self._verify = verify self._headers = {"content-type": "application/x-www-form-urlencoded"} - self._expired = False self._params = { "client_id": client_id, # This must match the Client ID of the OAuth application. @@ -174,55 +234,27 @@ def __init__( "code_challenge_method": "S256", } - # Prefer to use already-fetched token values when they've been set globally. - self.set_tokens_from_store() - def __repr__(self): return f"AuthorizationClient({self._auth_endpoint}, {self._token_endpoint}, {self._client_id}, {self._scopes}, {self._redirect_uri})" - def set_tokens_from_store(self): - self._refresh_token = _keyring.get_password(_keyring_service_name, _keyring_refresh_token_storage_key) - access_token = _keyring.get_password(_keyring_service_name, _keyring_access_token_storage_key) - if access_token: - self._credentials = Credentials(access_token=access_token) - - @property - def has_valid_credentials(self) -> bool: - return self._credentials is not None - - def start_authorization_flow(self): - # In the absence of globally-set token values, initiate the token request flow - ctx = _mp_get_context("fork") - q = ctx.Queue() - - # First prepare the callback server in the background - server = self._create_callback_server() - server_process = ctx.Process(target=server.handle_request, args=(q,)) - server_process.daemon = True - server_process.start() - - # Send the call to request the authorization code in the background - self._request_authorization_code() - - # Request the access token once the auth code has been received. - auth_code = q.get() - server_process.terminate() - self.request_access_token(auth_code) - def _create_callback_server(self): server_url = _urlparse.urlparse(self._redirect_uri) server_address = (server_url.hostname, server_url.port) - return OAuthHTTPServer(server_address, OAuthCallbackHandler, redirect_path=server_url.path) + return OAuthHTTPServer( + server_address, + self._remote, + OAuthCallbackHandler, + redirect_path=server_url.path, + ) def _request_authorization_code(self): scheme, netloc, path, _, _, _ = _urlparse.urlparse(self._auth_endpoint) query = _urlencode(self._params) endpoint = _urlparse.urlunparse((scheme, netloc, path, None, query, None)) - auth_logger.debug(f"Requesting authorization code through {endpoint}") + logging.debug(f"Requesting authorization code through {endpoint}") _webbrowser.open_new_tab(endpoint) - def _initialize_credentials(self, auth_token_resp): - + def _credentials_from_response(self, auth_token_resp) -> Credentials: """ The auth_token_resp body is of the form: { @@ -232,21 +264,16 @@ def _initialize_credentials(self, auth_token_resp): } """ response_body = auth_token_resp.json() + refresh_token = None if "access_token" not in response_body: raise ValueError('Expected "access_token" in response from oauth server') if "refresh_token" in response_body: - self._refresh_token = response_body["refresh_token"] - _keyring.set_password( - _keyring_service_name, _keyring_refresh_token_storage_key, response_body["refresh_token"] - ) - + refresh_token = response_body["refresh_token"] access_token = response_body["access_token"] - _keyring.set_password(_keyring_service_name, _keyring_access_token_storage_key, access_token) - # Once keyring credentials have been updated, get the singleton AuthorizationClient to read them again. - self.set_tokens_from_store() + return Credentials(access_token, refresh_token, self._endpoint) - def request_access_token(self, auth_code): + def _request_access_token(self, auth_code) -> Credentials: if self._state != auth_code.state: raise ValueError(f"Unexpected state parameter [{auth_code.state}] passed") self._params.update( @@ -262,6 +289,7 @@ def request_access_token(self, auth_code): data=self._params, headers=self._headers, allow_redirects=False, + verify=self._verify, ) if resp.status_code != _StatusCodes.OK: # TODO: handle expected (?) error cases: @@ -269,38 +297,53 @@ def request_access_token(self, auth_code): raise Exception( "Failed to request access token with response: [{}] {}".format(resp.status_code, resp.content) ) - self._initialize_credentials(resp) + return self._credentials_from_response(resp) + + def get_creds_from_remote(self) -> Credentials: + """ + This is the entrypoint method. It will kickoff the full authentication flow and trigger a web-browser to + retrieve credentials + """ + # In the absence of globally-set token values, initiate the token request flow + ctx = get_context("fork") + q = ctx.Queue() + + # First prepare the callback server in the background + server = self._create_callback_server() + + server_process = ctx.Process(target=server.handle_request, args=(q,)) + server_process.daemon = True - def refresh_access_token(self): - if self._refresh_token is None: + try: + server_process.start() + + # Send the call to request the authorization code in the background + self._request_authorization_code() + + # Request the access token once the auth code has been received. + auth_code = q.get() + return self._request_access_token(auth_code) + finally: + server_process.terminate() + + def refresh_access_token(self, credentials: Credentials) -> Credentials: + if credentials.refresh_token is None: raise ValueError("no refresh token available with which to refresh authorization credentials") resp = _requests.post( url=self._token_endpoint, - data={"grant_type": "refresh_token", "client_id": self._client_id, "refresh_token": self._refresh_token}, + data={ + "grant_type": "refresh_token", + "client_id": self._client_id, + "refresh_token": credentials.refresh_token, + }, headers=self._headers, allow_redirects=False, + verify=self._verify, ) if resp.status_code != _StatusCodes.OK: - self._expired = True # In the absence of a successful response, assume the refresh token is expired. This should indicate # to the caller that the AuthorizationClient is defunct and a new one needs to be re-initialized. + raise AccessTokenNotFoundError(f"Non-200 returned from refresh token endpoint {resp.status_code}") - _keyring.delete_password(_keyring_service_name, _keyring_access_token_storage_key) - _keyring.delete_password(_keyring_service_name, _keyring_refresh_token_storage_key) - raise ValueError(f"Non-200 returned from refresh token endpoint {resp.status_code}") - self._initialize_credentials(resp) - - @property - def credentials(self): - """ - :return flytekit.clis.auth.auth.Credentials: - """ - return self._credentials - - @property - def expired(self): - """ - :return bool: - """ - return self._expired + return self._credentials_from_response(resp) diff --git a/flytekit/clients/auth/authenticator.py b/flytekit/clients/auth/authenticator.py new file mode 100644 index 0000000000..183c1787cd --- /dev/null +++ b/flytekit/clients/auth/authenticator.py @@ -0,0 +1,235 @@ +import base64 +import logging +import subprocess +import typing +from abc import abstractmethod +from dataclasses import dataclass + +import requests + +from .auth_client import AuthorizationClient +from .exceptions import AccessTokenNotFoundError, AuthenticationError +from .keyring import Credentials, KeyringStore + + +@dataclass +class ClientConfig: + """ + Client Configuration that is needed by the authenticator + """ + + token_endpoint: str + authorization_endpoint: str + redirect_uri: str + client_id: str + scopes: typing.List[str] = None + header_key: str = "authorization" + + +class ClientConfigStore(object): + """ + Client Config store retrieve client config. this can be done in multiple ways + """ + + @abstractmethod + def get_client_config(self) -> ClientConfig: + ... + + +class StaticClientConfigStore(ClientConfigStore): + def __init__(self, cfg: ClientConfig): + self._cfg = cfg + + def get_client_config(self) -> ClientConfig: + return self._cfg + + +class Authenticator(object): + """ + Base authenticator for all authentication flows + """ + + def __init__(self, endpoint: str, header_key: str, credentials: Credentials = None): + self._endpoint = endpoint + self._creds = credentials + self._header_key = header_key if header_key else "authorization" + + def get_credentials(self) -> Credentials: + return self._creds + + def _set_credentials(self, creds): + self._creds = creds + + def _set_header_key(self, h: str): + self._header_key = h + + def fetch_grpc_call_auth_metadata(self) -> typing.Optional[typing.Tuple[str, str]]: + if self._creds: + return self._header_key, f"Bearer {self._creds.access_token}" + return None + + @abstractmethod + def refresh_credentials(self): + ... + + +class PKCEAuthenticator(Authenticator): + """ + This Authenticator encapsulates the entire PKCE flow and automatically opens a browser window for login + """ + + def __init__( + self, + endpoint: str, + cfg_store: ClientConfigStore, + header_key: typing.Optional[str] = None, + verify: typing.Optional[typing.Union[bool, str]] = None, + ): + """ + Initialize with default creds from KeyStore using the endpoint name + """ + super().__init__(endpoint, header_key, KeyringStore.retrieve(endpoint)) + self._cfg_store = cfg_store + self._auth_client = None + self._verify = verify + + def _initialize_auth_client(self): + if not self._auth_client: + cfg = self._cfg_store.get_client_config() + self._set_header_key(cfg.header_key) + self._auth_client = AuthorizationClient( + endpoint=self._endpoint, + redirect_uri=cfg.redirect_uri, + client_id=cfg.client_id, + scopes=cfg.scopes, + auth_endpoint=cfg.authorization_endpoint, + token_endpoint=cfg.token_endpoint, + verify=self._verify, + ) + + def refresh_credentials(self): + """ """ + self._initialize_auth_client() + if self._creds: + """We have an access token so lets try to refresh it""" + try: + self._creds = self._auth_client.refresh_access_token(self._creds) + if self._creds: + KeyringStore.store(self._creds) + return + except AccessTokenNotFoundError: + logging.warning("Failed to refresh token. Kicking off a full authorization flow.") + KeyringStore.delete(self._endpoint) + + self._creds = self._auth_client.get_creds_from_remote() + KeyringStore.store(self._creds) + + +class CommandAuthenticator(Authenticator): + """ + This Authenticator retreives access_token using the provided command + """ + + def __init__(self, command: typing.List[str], header_key: str = None): + self._cmd = command + if not self._cmd: + raise AuthenticationError("Command cannot be empty for command authenticator") + super().__init__(None, header_key) + + def refresh_credentials(self): + """ + This function is used when the configuration value for AUTH_MODE is set to 'external_process'. + It reads an id token generated by an external process started by running the 'command'. + """ + logging.debug("Starting external process to generate id token. Command {}".format(self._cmd)) + try: + output = subprocess.run(self._cmd, capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as e: + logging.error("Failed to generate token from command {}".format(self._cmd)) + raise AuthenticationError("Problems refreshing token with command: " + str(e)) + self._creds = Credentials(output.stdout.strip()) + + +class ClientCredentialsAuthenticator(Authenticator): + """ + This Authenticator uses ClientId and ClientSecret to authenticate + """ + + _utf_8 = "utf-8" + + def __init__( + self, + endpoint: str, + client_id: str, + client_secret: str, + cfg_store: ClientConfigStore, + header_key: str = None, + ): + if not client_id or not client_secret: + raise ValueError("Client ID and Client SECRET both are required.") + cfg = cfg_store.get_client_config() + self._token_endpoint = cfg.token_endpoint + self._scopes = cfg.scopes + self._client_id = client_id + self._client_secret = client_secret + super().__init__(endpoint, cfg.header_key or header_key) + + @staticmethod + def get_token(token_endpoint: str, authorization_header: str, scopes: typing.List[str]) -> typing.Tuple[str, int]: + """ + :rtype: (Text,Int) The first element is the access token retrieved from the IDP, the second is the expiration + in seconds + """ + headers = { + "Authorization": authorization_header, + "Cache-Control": "no-cache", + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + } + body = { + "grant_type": "client_credentials", + } + if scopes is not None: + body["scope"] = ",".join(scopes) + response = requests.post(token_endpoint, data=body, headers=headers) + if response.status_code != 200: + logging.error("Non-200 ({}) received from IDP: {}".format(response.status_code, response.text)) + raise AuthenticationError("Non-200 received from IDP") + + response = response.json() + return response["access_token"], response["expires_in"] + + @staticmethod + def get_basic_authorization_header(client_id: str, client_secret: str) -> str: + """ + This function transforms the client id and the client secret into a header that conforms with http basic auth. + It joins the id and the secret with a : then base64 encodes it, then adds the appropriate text + + :param client_id: str + :param client_secret: str + :rtype: str + """ + concated = "{}:{}".format(client_id, client_secret) + return "Basic {}".format( + base64.b64encode(concated.encode(ClientCredentialsAuthenticator._utf_8)).decode( + ClientCredentialsAuthenticator._utf_8 + ) + ) + + def refresh_credentials(self): + """ + This function is used by the _handle_rpc_error() decorator, depending on the AUTH_MODE config object. This handler + is meant for SDK use-cases of auth (like pyflyte, or when users call SDK functions that require access to Admin, + like when waiting for another workflow to complete from within a task). This function uses basic auth, which means + the credentials for basic auth must be present from wherever this code is running. + + """ + token_endpoint = self._token_endpoint + scopes = self._scopes + + # Note that unlike the Pkce flow, the client ID does not come from Admin. + logging.debug(f"Basic authorization flow with client id {self._client_id} scope {scopes}") + authorization_header = self.get_basic_authorization_header(self._client_id, self._client_secret) + token, expires_in = self.get_token(token_endpoint, authorization_header, scopes) + logging.info("Retrieved new token, expires in {}".format(expires_in)) + self._creds = Credentials(token) diff --git a/flytekit/clients/auth/default_html.py b/flytekit/clients/auth/default_html.py new file mode 100644 index 0000000000..cd7f3d8964 --- /dev/null +++ b/flytekit/clients/auth/default_html.py @@ -0,0 +1,12 @@ +def get_default_success_html(endpoint: str) -> str: + return f""" + + + OAuth2 Authentication Success + + +

Successfully logged into {endpoint}

+ Flyte login + + +""" # noqa diff --git a/flytekit/clients/auth/exceptions.py b/flytekit/clients/auth/exceptions.py new file mode 100644 index 0000000000..6e790e47a4 --- /dev/null +++ b/flytekit/clients/auth/exceptions.py @@ -0,0 +1,14 @@ +class AccessTokenNotFoundError(RuntimeError): + """ + This error is raised with Access token is not found or if Refreshing the token fails + """ + + pass + + +class AuthenticationError(RuntimeError): + """ + This is raised for any AuthenticationError + """ + + pass diff --git a/flytekit/clients/auth/keyring.py b/flytekit/clients/auth/keyring.py new file mode 100644 index 0000000000..c2b19c46b6 --- /dev/null +++ b/flytekit/clients/auth/keyring.py @@ -0,0 +1,64 @@ +import logging +import typing +from dataclasses import dataclass + +import keyring as _keyring +from keyring.errors import NoKeyringError + + +@dataclass +class Credentials(object): + """ + Stores the credentials together + """ + + access_token: str + refresh_token: str = "na" + for_endpoint: str = "flyte-default" + + +class KeyringStore: + """ + Methods to access Keyring Store. + """ + + _access_token_key = "access_token" + _refresh_token_key = "refresh_token" + + @staticmethod + def store(credentials: Credentials) -> Credentials: + try: + _keyring.set_password( + credentials.for_endpoint, + KeyringStore._refresh_token_key, + credentials.refresh_token, + ) + _keyring.set_password( + credentials.for_endpoint, + KeyringStore._access_token_key, + credentials.access_token, + ) + except NoKeyringError as e: + logging.warning(f"KeyRing not available, tokens will not be cached. Error: {e}") + return credentials + + @staticmethod + def retrieve(for_endpoint: str) -> typing.Optional[Credentials]: + try: + refresh_token = _keyring.get_password(for_endpoint, KeyringStore._refresh_token_key) + access_token = _keyring.get_password(for_endpoint, KeyringStore._access_token_key) + except NoKeyringError as e: + logging.warning(f"KeyRing not available, tokens will not be cached. Error: {e}") + return None + + if not access_token: + return None + return Credentials(access_token, refresh_token, for_endpoint) + + @staticmethod + def delete(for_endpoint: str): + try: + _keyring.delete_password(for_endpoint, KeyringStore._access_token_key) + _keyring.delete_password(for_endpoint, KeyringStore._refresh_token_key) + except NoKeyringError as e: + logging.warning(f"KeyRing not available, tokens will not be cached. Error: {e}") diff --git a/flytekit/clients/auth_helper.py b/flytekit/clients/auth_helper.py new file mode 100644 index 0000000000..41fc5c025f --- /dev/null +++ b/flytekit/clients/auth_helper.py @@ -0,0 +1,193 @@ +import logging +import ssl + +import grpc +from flyteidl.service.auth_pb2 import OAuth2MetadataRequest, PublicClientAuthConfigRequest +from flyteidl.service.auth_pb2_grpc import AuthMetadataServiceStub +from OpenSSL import crypto + +from flytekit.clients.auth.authenticator import ( + Authenticator, + ClientConfig, + ClientConfigStore, + ClientCredentialsAuthenticator, + CommandAuthenticator, + PKCEAuthenticator, +) +from flytekit.clients.grpc_utils.auth_interceptor import AuthUnaryInterceptor +from flytekit.clients.grpc_utils.wrap_exception_interceptor import RetryExceptionWrapperInterceptor +from flytekit.configuration import AuthType, PlatformConfig + + +class RemoteClientConfigStore(ClientConfigStore): + """ + This class implements the ClientConfigStore that is served by the Flyte Server, that implements AuthMetadataService + """ + + def __init__(self, secure_channel: grpc.Channel): + self._secure_channel = secure_channel + + def get_client_config(self) -> ClientConfig: + """ + Retrieves the ClientConfig from the given grpc.Channel assuming AuthMetadataService is available + """ + metadata_service = AuthMetadataServiceStub(self._secure_channel) + public_client_config = metadata_service.GetPublicClientConfig(PublicClientAuthConfigRequest()) + oauth2_metadata = metadata_service.GetOAuth2Metadata(OAuth2MetadataRequest()) + return ClientConfig( + token_endpoint=oauth2_metadata.token_endpoint, + authorization_endpoint=oauth2_metadata.authorization_endpoint, + redirect_uri=public_client_config.redirect_uri, + client_id=public_client_config.client_id, + scopes=public_client_config.scopes, + header_key=public_client_config.authorization_metadata_key or None, + ) + + +def get_authenticator(cfg: PlatformConfig, cfg_store: ClientConfigStore) -> Authenticator: + """ + Returns a new authenticator based on the platform config. + """ + cfg_auth = cfg.auth_mode + if type(cfg_auth) is str: + try: + cfg_auth = AuthType[cfg_auth.upper()] + except KeyError: + logging.warning(f"Authentication type {cfg_auth} does not exist, defaulting to standard") + cfg_auth = AuthType.STANDARD + + if cfg_auth == AuthType.STANDARD or cfg_auth == AuthType.PKCE: + verify = None + if cfg.insecure_skip_verify: + verify = False + elif cfg.ca_cert_file_path: + verify = cfg.ca_cert_file_path + return PKCEAuthenticator(cfg.endpoint, cfg_store, verify=verify) + elif cfg_auth == AuthType.BASIC or cfg_auth == AuthType.CLIENT_CREDENTIALS or cfg_auth == AuthType.CLIENTSECRET: + return ClientCredentialsAuthenticator( + endpoint=cfg.endpoint, + client_id=cfg.client_id, + client_secret=cfg.client_credentials_secret, + cfg_store=cfg_store, + ) + elif cfg_auth == AuthType.EXTERNAL_PROCESS or cfg_auth == AuthType.EXTERNALCOMMAND: + client_cfg = None + if cfg_store: + client_cfg = cfg_store.get_client_config() + return CommandAuthenticator( + command=cfg.command, + header_key=client_cfg.header_key if client_cfg else None, + ) + else: + raise ValueError( + f"Invalid auth mode [{cfg_auth}] specified." f"Please update the creds config to use a valid value" + ) + + +def upgrade_channel_to_authenticated(cfg: PlatformConfig, in_channel: grpc.Channel) -> grpc.Channel: + """ + Given a grpc.Channel, preferrably a secure channel, it returns a composed channel that uses Interceptor to + perform an Oauth2.0 Auth flow + :param cfg: PlatformConfig + :param in_channel: grpc.Channel Precreated channel + :return: grpc.Channel. New composite channel + """ + authenticator = get_authenticator(cfg, RemoteClientConfigStore(in_channel)) + return grpc.intercept_channel(in_channel, AuthUnaryInterceptor(authenticator)) + + +def get_authenticated_channel(cfg: PlatformConfig) -> grpc.Channel: + """ + Returns a new channel for the given config that is authenticated + """ + channel = ( + grpc.insecure_channel(cfg.endpoint) + if cfg.insecure + else grpc.secure_channel(cfg.endpoint, grpc.ssl_channel_credentials()) + ) # noqa + return upgrade_channel_to_authenticated(cfg, channel) + + +def load_cert(cert_file: str) -> crypto.X509: + """ + Given a cert-file loads the PEM certificate and returns + """ + st_cert = open(cert_file, "rt").read() + return crypto.load_certificate(crypto.FILETYPE_PEM, st_cert) + + +def bootstrap_creds_from_server(endpoint: str) -> grpc.ChannelCredentials: + """ + Retrieves the SSL cert from the remote and uses that. should be used only if insecure-skip-verify + """ + # Get port from endpoint or use 443 + endpoint_parts = endpoint.rsplit(":", 1) + if len(endpoint_parts) == 2 and endpoint_parts[1].isdigit(): + server_address = (endpoint_parts[0], endpoint_parts[1]) + else: + server_address = (endpoint, "443") + cert = ssl.get_server_certificate(server_address) # noqa + return grpc.ssl_channel_credentials(str.encode(cert)) + + +def get_channel(cfg: PlatformConfig, **kwargs) -> grpc.Channel: + """ + Creates a new grpc.Channel given a platformConfig. + It is possible to pass additional options to the underlying channel. Examples for various options are as below + + .. code-block:: python + + get_channel(cfg=PlatformConfig(...)) + + .. code-block:: python + :caption: Additional options to insecure / secure channel. Example `options` and `compression` refer to grpc guide + + get_channel(cfg=PlatformConfig(...), options=..., compression=...) + + .. code-block:: python + :caption: Create secure channel with custom `grpc.ssl_channel_credentials` + + get_channel(cfg=PlatformConfig(insecure=False,...), credentials=...) + + + :param cfg: PlatformConfig + :param kwargs: Optional arguments to be passed to channel method. Refer to usage example above + :return: grpc.Channel (secure / insecure) + """ + if cfg.insecure: + return grpc.insecure_channel(cfg.endpoint, **kwargs) + + credentials = None + if "credentials" not in kwargs: + if cfg.insecure_skip_verify: + credentials = bootstrap_creds_from_server(cfg.endpoint) + elif cfg.ca_cert_file_path: + credentials = grpc.ssl_channel_credentials(load_cert(cfg.ca_cert_file_path)) + else: + credentials = grpc.ssl_channel_credentials( + root_certificates=kwargs.get("root_certificates", None), + private_key=kwargs.get("private_key", None), + certificate_chain=kwargs.get("certificate_chain", None), + ) + else: + credentials = kwargs["credentials"] + return grpc.secure_channel( + target=cfg.endpoint, + credentials=credentials, + options=kwargs.get("options", None), + compression=kwargs.get("compression", None), + ) + + +def wrap_exceptions_channel(cfg: PlatformConfig, in_channel: grpc.Channel) -> grpc.Channel: + """ + Wraps the input channel with RetryExceptionWrapperInterceptor. This wrapper will cover all + exceptions and raise Exception from the Family flytekit.exceptions + + .. note:: This channel should be usually the outermost channel. This channel will raise a FlyteException + + :param cfg: PlatformConfig + :param in_channel: grpc.Channel + :return: grpc.Channel + """ + return grpc.intercept_channel(in_channel, RetryExceptionWrapperInterceptor(max_retries=cfg.rpc_retries)) diff --git a/flytekit/clients/grpc_utils/__init__.py b/flytekit/clients/grpc_utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/clients/grpc_utils/auth_interceptor.py b/flytekit/clients/grpc_utils/auth_interceptor.py new file mode 100644 index 0000000000..21bcc30136 --- /dev/null +++ b/flytekit/clients/grpc_utils/auth_interceptor.py @@ -0,0 +1,80 @@ +import typing +from collections import namedtuple + +import grpc + +from flytekit.clients.auth.authenticator import Authenticator + + +class _ClientCallDetails( + namedtuple("_ClientCallDetails", ("method", "timeout", "metadata", "credentials")), + grpc.ClientCallDetails, +): + """ + Wrapper class for initializing a new ClientCallDetails instance. + We cannot make this of type - NamedTuple because, NamedTuple has a metaclass of type NamedTupleMeta and both + the metaclasses conflict + """ + + pass + + +class AuthUnaryInterceptor(grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor): + """ + This Interceptor can be used to automatically add Auth Metadata for every call - lazily in case authentication + is needed. + """ + + def __init__(self, authenticator: Authenticator): + self._authenticator = authenticator + + def _call_details_with_auth_metadata(self, client_call_details: grpc.ClientCallDetails) -> grpc.ClientCallDetails: + """ + Returns new ClientCallDetails with metadata added. + """ + metadata = None + auth_metadata = self._authenticator.fetch_grpc_call_auth_metadata() + if auth_metadata: + metadata = [] + if client_call_details.metadata: + metadata.extend(list(client_call_details.metadata)) + metadata.append(auth_metadata) + + return _ClientCallDetails( + client_call_details.method, + client_call_details.timeout, + metadata, + client_call_details.credentials, + ) + + def intercept_unary_unary( + self, + continuation: typing.Callable, + client_call_details: grpc.ClientCallDetails, + request: typing.Any, + ): + """ + Intercepts unary calls and adds auth metadata if available. On Unauthenticated, resets the token and refreshes + and then retries with the new token + """ + updated_call_details = self._call_details_with_auth_metadata(client_call_details) + fut: grpc.Future = continuation(updated_call_details, request) + e = fut.exception() + if e: + if e.code() == grpc.StatusCode.UNAUTHENTICATED: + self._authenticator.refresh_credentials() + updated_call_details = self._call_details_with_auth_metadata(client_call_details) + return continuation(updated_call_details, request) + return fut + + def intercept_unary_stream(self, continuation, client_call_details, request): + """ + Handles a stream call and adds authentication metadata if needed + """ + updated_call_details = self._call_details_with_auth_metadata(client_call_details) + c: grpc.Call = continuation(updated_call_details, request) + if c.code() == grpc.StatusCode.UNAUTHENTICATED: + self._authenticator.refresh_credentials() + updated_call_details = self._call_details_with_auth_metadata(client_call_details) + return continuation(updated_call_details, request) + return c diff --git a/flytekit/clients/grpc_utils/wrap_exception_interceptor.py b/flytekit/clients/grpc_utils/wrap_exception_interceptor.py new file mode 100644 index 0000000000..ea796f464a --- /dev/null +++ b/flytekit/clients/grpc_utils/wrap_exception_interceptor.py @@ -0,0 +1,49 @@ +import typing +from typing import Union + +import grpc + +from flytekit.exceptions.base import FlyteException +from flytekit.exceptions.system import FlyteSystemException +from flytekit.exceptions.user import ( + FlyteAuthenticationException, + FlyteEntityAlreadyExistsException, + FlyteEntityNotExistException, + FlyteInvalidInputException, +) + + +class RetryExceptionWrapperInterceptor(grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor): + def __init__(self, max_retries: int = 3): + self._max_retries = 3 + + @staticmethod + def _raise_if_exc(request: typing.Any, e: Union[grpc.Call, grpc.Future]): + if isinstance(e, grpc.RpcError): + if e.code() == grpc.StatusCode.UNAUTHENTICATED: + raise FlyteAuthenticationException() from e + elif e.code() == grpc.StatusCode.ALREADY_EXISTS: + raise FlyteEntityAlreadyExistsException() from e + elif e.code() == grpc.StatusCode.NOT_FOUND: + raise FlyteEntityNotExistException() from e + elif e.code() == grpc.StatusCode.INVALID_ARGUMENT: + raise FlyteInvalidInputException(request) from e + raise FlyteSystemException() from e + + def intercept_unary_unary(self, continuation, client_call_details, request): + retries = 0 + while True: + fut: grpc.Future = continuation(client_call_details, request) + e = fut.exception() + try: + if e: + self._raise_if_exc(request, e) + return fut + except FlyteException as e: + if retries == self._max_retries: + raise e + retries = retries + 1 + + def intercept_unary_stream(self, continuation, client_call_details, request): + c: grpc.Call = continuation(client_call_details, request) + return c diff --git a/flytekit/clients/raw.py b/flytekit/clients/raw.py index 6c8f54e9ce..e71485b17c 100644 --- a/flytekit/clients/raw.py +++ b/flytekit/clients/raw.py @@ -1,91 +1,20 @@ from __future__ import annotations -import base64 as _base64 -import ssl -import subprocess -import time import typing -from typing import Optional import grpc -import requests as _requests from flyteidl.admin.project_pb2 import ProjectListRequest from flyteidl.admin.signal_pb2 import SignalList, SignalListRequest, SignalSetRequest, SignalSetResponse from flyteidl.service import admin_pb2_grpc as _admin_service -from flyteidl.service import auth_pb2 -from flyteidl.service import auth_pb2_grpc as auth_service from flyteidl.service import dataproxy_pb2 as _dataproxy_pb2 from flyteidl.service import dataproxy_pb2_grpc as dataproxy_service from flyteidl.service import signal_pb2_grpc as signal_service from flyteidl.service.dataproxy_pb2_grpc import DataProxyServiceStub -from google.protobuf.json_format import MessageToJson as _MessageToJson -from flytekit.clis.auth import credentials as _credentials_access -from flytekit.configuration import AuthType, PlatformConfig -from flytekit.exceptions import user as _user_exceptions -from flytekit.exceptions.user import FlyteAuthenticationException +from flytekit.clients.auth_helper import get_channel, upgrade_channel_to_authenticated, wrap_exceptions_channel +from flytekit.configuration import PlatformConfig from flytekit.loggers import cli_logger -_utf_8 = "utf-8" - - -def _handle_rpc_error(retry=False): - def decorator(fn): - def handler(*args, **kwargs): - """ - Wraps rpc errors as Flyte exceptions and handles authentication the client. - """ - max_retries = 3 - max_wait_time = 1000 - - for i in range(max_retries): - try: - return fn(*args, **kwargs) - except grpc.RpcError as e: - if e.code() == grpc.StatusCode.UNAUTHENTICATED: - # Always retry auth errors. - if i == (max_retries - 1): - # Exit the loop and wrap the authentication error. - raise _user_exceptions.FlyteAuthenticationException(str(e)) - cli_logger.debug(f"Unauthenticated RPC error {e}, refreshing credentials and retrying\n") - args[0].refresh_credentials() - elif e.code() == grpc.StatusCode.ALREADY_EXISTS: - # There are two cases that we should throw error immediately - # 1. Entity already exists when we register entity - # 2. Entity not found when we fetch entity - raise _user_exceptions.FlyteEntityAlreadyExistsException(e) - elif e.code() == grpc.StatusCode.NOT_FOUND: - raise _user_exceptions.FlyteEntityNotExistException(e) - else: - # No more retries if retry=False or max_retries reached. - if (retry is False) or i == (max_retries - 1): - raise - else: - # Retry: Start with 200ms wait-time and exponentially back-off up to 1 second. - wait_time = min(200 * (2**i), max_wait_time) - cli_logger.error(f"Non-auth RPC error {e}, sleeping {wait_time}ms and retrying") - time.sleep(wait_time / 1000) - - return handler - - return decorator - - -def _handle_invalid_create_request(fn): - def handler(self, create_request): - try: - fn(self, create_request) - except grpc.RpcError as e: - if e.code() == grpc.StatusCode.INVALID_ARGUMENT: - cli_logger.error("Error creating Flyte entity because of invalid arguments. Create request: ") - cli_logger.error(_MessageToJson(create_request)) - cli_logger.error("Details returned from the flyte admin: ") - cli_logger.error(e.details) - # Re-raise since we're not handling the error here and add the create_request details - raise e - - return handler - class RawSynchronousFlyteClient(object): """ @@ -112,54 +41,9 @@ def __init__(self, cfg: PlatformConfig, **kwargs): insecure: if insecure is desired """ self._cfg = cfg - if cfg.insecure: - self._channel = grpc.insecure_channel(cfg.endpoint, **kwargs) - elif cfg.insecure_skip_verify: - # Get port from endpoint or use 443 - endpoint_parts = cfg.endpoint.rsplit(":", 1) - if len(endpoint_parts) == 2 and endpoint_parts[1].isdigit(): - server_address = tuple(endpoint_parts) - else: - server_address = (cfg.endpoint, "443") - cert = ssl.get_server_certificate(server_address) - credentials = grpc.ssl_channel_credentials(str.encode(cert)) - options = kwargs.get("options", []) - self._channel = grpc.secure_channel( - target=cfg.endpoint, - credentials=credentials, - options=options, - compression=kwargs.get("compression", None), - ) - else: - if "credentials" not in kwargs: - credentials = grpc.ssl_channel_credentials( - root_certificates=kwargs.get("root_certificates", None), - private_key=kwargs.get("private_key", None), - certificate_chain=kwargs.get("certificate_chain", None), - ) - else: - credentials = kwargs["credentials"] - self._channel = grpc.secure_channel( - target=cfg.endpoint, - credentials=credentials, - options=kwargs.get("options", None), - compression=kwargs.get("compression", None), - ) + self._channel = wrap_exceptions_channel(cfg, upgrade_channel_to_authenticated(cfg, get_channel(cfg))) self._stub = _admin_service.AdminServiceStub(self._channel) - self._auth_stub = auth_service.AuthMetadataServiceStub(self._channel) self._signal = signal_service.SignalServiceStub(self._channel) - try: - resp = self._auth_stub.GetPublicClientConfig(auth_pb2.PublicClientAuthConfigRequest()) - self._public_client_config = resp - except grpc.RpcError: - cli_logger.debug("No public client auth config found, skipping.") - self._public_client_config = None - try: - resp = self._auth_stub.GetOAuth2Metadata(auth_pb2.OAuth2MetadataRequest()) - self._oauth2_metadata = resp - except grpc.RpcError: - cli_logger.debug("No OAuth2 Metadata found, skipping.") - self._oauth2_metadata = None self._dataproxy_stub = dataproxy_service.DataProxyServiceStub(self._channel) cli_logger.info( @@ -175,161 +59,16 @@ def with_root_certificate(cls, cfg: PlatformConfig, root_cert_file: str) -> RawS b = fp.read() return RawSynchronousFlyteClient(cfg, credentials=grpc.ssl_channel_credentials(root_certificates=b)) - @property - def public_client_config(self) -> Optional[auth_pb2.PublicClientAuthConfigResponse]: - return self._public_client_config - - @property - def oauth2_metadata(self) -> Optional[auth_pb2.OAuth2MetadataResponse]: - return self._oauth2_metadata - @property def url(self) -> str: return self._cfg.endpoint - def _refresh_credentials_standard(self): - """ - This function is used when the configuration value for AUTH_MODE is set to 'standard'. - This either fetches the existing access token or initiates the flow to request a valid access token and store it. - :param self: RawSynchronousFlyteClient - :return: - """ - authorization_header_key = self.public_client_config.authorization_metadata_key or None - if not self.oauth2_metadata or not self.public_client_config: - raise ValueError( - "Raw Flyte client attempting client credentials flow but no response from Admin detected. " - "Check your Admin server's .well-known endpoints to make sure they're working as expected." - ) - - client = _credentials_access.get_client( - redirect_endpoint=self.public_client_config.redirect_uri, - client_id=self.public_client_config.client_id, - scopes=self.public_client_config.scopes, - auth_endpoint=self.oauth2_metadata.authorization_endpoint, - token_endpoint=self.oauth2_metadata.token_endpoint, - ) - - if client.has_valid_credentials and not self.check_access_token(client.credentials.access_token): - # When Python starts up, if credentials have been stored in the keyring, then the AuthorizationClient - # will have read them into its _credentials field, but it won't be in the RawSynchronousFlyteClient's - # metadata field yet. Therefore, if there's a mismatch, copy it over. - self.set_access_token(client.credentials.access_token, authorization_header_key) - return - - try: - client.refresh_access_token() - except ValueError: - client.start_authorization_flow() - - self.set_access_token(client.credentials.access_token, authorization_header_key) - - def _refresh_credentials_basic(self): - """ - This function is used by the _handle_rpc_error() decorator, depending on the AUTH_MODE config object. This handler - is meant for SDK use-cases of auth (like pyflyte, or when users call SDK functions that require access to Admin, - like when waiting for another workflow to complete from within a task). This function uses basic auth, which means - the credentials for basic auth must be present from wherever this code is running. - - :param self: RawSynchronousFlyteClient - :return: - """ - if not self.oauth2_metadata or not self.public_client_config: - raise ValueError( - "Raw Flyte client attempting client credentials flow but no response from Admin detected. " - "Check your Admin server's .well-known endpoints to make sure they're working as expected." - ) - - token_endpoint = self.oauth2_metadata.token_endpoint - scopes = self._cfg.scopes or self.public_client_config.scopes - scopes = ",".join(scopes) - - # Note that unlike the Pkce flow, the client ID does not come from Admin. - client_secret = self._cfg.client_credentials_secret - if not client_secret: - raise FlyteAuthenticationException("No client credentials secret provided in the config") - cli_logger.debug(f"Basic authorization flow with client id {self._cfg.client_id} scope {scopes}") - authorization_header = get_basic_authorization_header(self._cfg.client_id, client_secret) - token, expires_in = get_token(token_endpoint, authorization_header, scopes) - cli_logger.info("Retrieved new token, expires in {}".format(expires_in)) - authorization_header_key = self.public_client_config.authorization_metadata_key or None - self.set_access_token(token, authorization_header_key) - - def _refresh_credentials_from_command(self): - """ - This function is used when the configuration value for AUTH_MODE is set to 'external_process'. - It reads an id token generated by an external process started by running the 'command'. - - :param self: RawSynchronousFlyteClient - :return: - """ - command = self._cfg.command - if not command: - raise FlyteAuthenticationException("No command specified in configuration for command authentication") - cli_logger.debug("Starting external process to generate id token. Command {}".format(command)) - try: - output = subprocess.run(command, capture_output=True, text=True, check=True) - except subprocess.CalledProcessError as e: - cli_logger.error("Failed to generate token from command {}".format(command)) - raise _user_exceptions.FlyteAuthenticationException("Problems refreshing token with command: " + str(e)) - authorization_header_key = self.public_client_config.authorization_metadata_key or None - if not authorization_header_key: - self.set_access_token(output.stdout.strip()) - self.set_access_token(output.stdout.strip(), authorization_header_key) - - def _refresh_credentials_noop(self): - pass - - def refresh_credentials(self): - cfg_auth = self._cfg.auth_mode - if type(cfg_auth) is str: - try: - cfg_auth = AuthType[cfg_auth.upper()] - except KeyError: - cli_logger.warning(f"Authentication type {cfg_auth} does not exist, defaulting to standard") - cfg_auth = AuthType.STANDARD - - if cfg_auth == AuthType.STANDARD or cfg_auth == AuthType.PKCE: - return self._refresh_credentials_standard() - elif cfg_auth == AuthType.BASIC or cfg_auth == AuthType.CLIENT_CREDENTIALS or cfg_auth == AuthType.CLIENTSECRET: - return self._refresh_credentials_basic() - elif cfg_auth == AuthType.EXTERNAL_PROCESS or cfg_auth == AuthType.EXTERNALCOMMAND: - return self._refresh_credentials_from_command() - else: - raise ValueError( - f"Invalid auth mode [{cfg_auth}] specified." f"Please update the creds config to use a valid value" - ) - - def set_access_token(self, access_token: str, authorization_header_key: Optional[str] = "authorization"): - # Always set the header to lower-case regardless of what the config is. The grpc libraries that Admin uses - # to parse the metadata don't change the metadata, but they do automatically lower the key you're looking for. - cli_logger.debug(f"Adding authorization header. Header name: {authorization_header_key}.") - self._metadata = [ - ( - authorization_header_key, - f"Bearer {access_token}", - ) - ] - - def check_access_token(self, access_token: str) -> bool: - """ - This checks to see if the given access token is the same as the one already stored in the client. The reason - this is useful is so that we can prevent unnecessary refreshing of tokens. - - :param access_token: The access token to check - :return: If no access token is stored, or if the stored token doesn't match, return False. - """ - if self._metadata is None: - return False - return access_token == self._metadata[0][1].replace("Bearer ", "") - #################################################################################################################### # # Task Endpoints # #################################################################################################################### - @_handle_rpc_error() - @_handle_invalid_create_request def create_task(self, task_create_request): """ This will create a task definition in the Admin database. Once successful, the task object can be @@ -350,7 +89,6 @@ def create_task(self, task_create_request): """ return self._stub.CreateTask(task_create_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def list_task_ids_paginated(self, identifier_list_request): """ This returns a page of identifiers for the tasks for a given project and domain. Filters can also be @@ -376,7 +114,6 @@ def list_task_ids_paginated(self, identifier_list_request): """ return self._stub.ListTaskIds(identifier_list_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def list_tasks_paginated(self, resource_list_request): """ This returns a page of task metadata for tasks in a given project and domain. Optionally, @@ -398,7 +135,6 @@ def list_tasks_paginated(self, resource_list_request): """ return self._stub.ListTasks(resource_list_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def get_task(self, get_object_request): """ This returns a single task for a given identifier. @@ -409,14 +145,12 @@ def get_task(self, get_object_request): """ return self._stub.GetTask(get_object_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def set_signal(self, signal_set_request: SignalSetRequest) -> SignalSetResponse: """ This sets a signal """ return self._signal.SetSignal(signal_set_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def list_signals(self, signal_list_request: SignalListRequest) -> SignalList: """ This lists signals @@ -429,8 +163,6 @@ def list_signals(self, signal_list_request: SignalListRequest) -> SignalList: # #################################################################################################################### - @_handle_rpc_error() - @_handle_invalid_create_request def create_workflow(self, workflow_create_request): """ This will create a workflow definition in the Admin database. Once successful, the workflow object can be @@ -451,7 +183,6 @@ def create_workflow(self, workflow_create_request): """ return self._stub.CreateWorkflow(workflow_create_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def list_workflow_ids_paginated(self, identifier_list_request): """ This returns a page of identifiers for the workflows for a given project and domain. Filters can also be @@ -477,7 +208,6 @@ def list_workflow_ids_paginated(self, identifier_list_request): """ return self._stub.ListWorkflowIds(identifier_list_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def list_workflows_paginated(self, resource_list_request): """ This returns a page of workflow meta-information for workflows in a given project and domain. Optionally, @@ -499,7 +229,6 @@ def list_workflows_paginated(self, resource_list_request): """ return self._stub.ListWorkflows(resource_list_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def get_workflow(self, get_object_request): """ This returns a single workflow for a given identifier. @@ -516,8 +245,6 @@ def get_workflow(self, get_object_request): # #################################################################################################################### - @_handle_rpc_error() - @_handle_invalid_create_request def create_launch_plan(self, launch_plan_create_request): """ This will create a launch plan definition in the Admin database. Once successful, the launch plan object can be @@ -541,7 +268,6 @@ def create_launch_plan(self, launch_plan_create_request): # TODO: List endpoints when they come in - @_handle_rpc_error(retry=True) def get_launch_plan(self, object_get_request): """ Retrieves a launch plan entity. @@ -551,7 +277,6 @@ def get_launch_plan(self, object_get_request): """ return self._stub.GetLaunchPlan(object_get_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def get_active_launch_plan(self, active_launch_plan_request): """ Retrieves a launch plan entity. @@ -561,7 +286,6 @@ def get_active_launch_plan(self, active_launch_plan_request): """ return self._stub.GetActiveLaunchPlan(active_launch_plan_request, metadata=self._metadata) - @_handle_rpc_error() def update_launch_plan(self, update_request): """ Allows updates to a launch plan at a given identifier. Currently, a launch plan may only have it's state @@ -572,7 +296,6 @@ def update_launch_plan(self, update_request): """ return self._stub.UpdateLaunchPlan(update_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def list_launch_plan_ids_paginated(self, identifier_list_request): """ Lists launch plan named identifiers for a given project and domain. @@ -582,7 +305,6 @@ def list_launch_plan_ids_paginated(self, identifier_list_request): """ return self._stub.ListLaunchPlanIds(identifier_list_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def list_launch_plans_paginated(self, resource_list_request): """ Lists Launch Plans for a given Identifier (project, domain, name) @@ -592,7 +314,6 @@ def list_launch_plans_paginated(self, resource_list_request): """ return self._stub.ListLaunchPlans(resource_list_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def list_active_launch_plans_paginated(self, active_launch_plan_list_request): """ Lists Active Launch Plans for a given (project, domain) @@ -608,7 +329,6 @@ def list_active_launch_plans_paginated(self, active_launch_plan_list_request): # #################################################################################################################### - @_handle_rpc_error() def update_named_entity(self, update_named_entity_request): """ :param flyteidl.admin.common_pb2.NamedEntityUpdateRequest update_named_entity_request: @@ -622,7 +342,6 @@ def update_named_entity(self, update_named_entity_request): # #################################################################################################################### - @_handle_rpc_error() def create_execution(self, create_execution_request): """ This will create an execution for the given execution spec. @@ -631,7 +350,6 @@ def create_execution(self, create_execution_request): """ return self._stub.CreateExecution(create_execution_request, metadata=self._metadata) - @_handle_rpc_error() def recover_execution(self, recover_execution_request): """ This will recreate an execution with the same spec as the one belonging to the given execution identifier. @@ -640,7 +358,6 @@ def recover_execution(self, recover_execution_request): """ return self._stub.RecoverExecution(recover_execution_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def get_execution(self, get_object_request): """ Returns an execution of a workflow entity. @@ -650,7 +367,6 @@ def get_execution(self, get_object_request): """ return self._stub.GetExecution(get_object_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def get_execution_data(self, get_execution_data_request): """ Returns signed URLs to LiteralMap blobs for an execution's inputs and outputs (when available). @@ -660,7 +376,6 @@ def get_execution_data(self, get_execution_data_request): """ return self._stub.GetExecutionData(get_execution_data_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def list_executions_paginated(self, resource_list_request): """ Lists the executions for a given identifier. @@ -670,7 +385,6 @@ def list_executions_paginated(self, resource_list_request): """ return self._stub.ListExecutions(resource_list_request, metadata=self._metadata) - @_handle_rpc_error() def terminate_execution(self, terminate_execution_request): """ :param flyteidl.admin.execution_pb2.TerminateExecutionRequest terminate_execution_request: @@ -678,7 +392,6 @@ def terminate_execution(self, terminate_execution_request): """ return self._stub.TerminateExecution(terminate_execution_request, metadata=self._metadata) - @_handle_rpc_error() def relaunch_execution(self, relaunch_execution_request): """ :param flyteidl.admin.execution_pb2.ExecutionRelaunchRequest relaunch_execution_request: @@ -692,7 +405,6 @@ def relaunch_execution(self, relaunch_execution_request): # #################################################################################################################### - @_handle_rpc_error(retry=True) def get_node_execution(self, node_execution_request): """ :param flyteidl.admin.node_execution_pb2.NodeExecutionGetRequest node_execution_request: @@ -700,7 +412,6 @@ def get_node_execution(self, node_execution_request): """ return self._stub.GetNodeExecution(node_execution_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def get_node_execution_data(self, get_node_execution_data_request): """ Returns signed URLs to LiteralMap blobs for a node execution's inputs and outputs (when available). @@ -710,7 +421,6 @@ def get_node_execution_data(self, get_node_execution_data_request): """ return self._stub.GetNodeExecutionData(get_node_execution_data_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def list_node_executions_paginated(self, node_execution_list_request): """ :param flyteidl.admin.node_execution_pb2.NodeExecutionListRequest node_execution_list_request: @@ -718,7 +428,6 @@ def list_node_executions_paginated(self, node_execution_list_request): """ return self._stub.ListNodeExecutions(node_execution_list_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def list_node_executions_for_task_paginated(self, node_execution_for_task_list_request): """ :param flyteidl.admin.node_execution_pb2.NodeExecutionListRequest node_execution_for_task_list_request: @@ -732,7 +441,6 @@ def list_node_executions_for_task_paginated(self, node_execution_for_task_list_r # #################################################################################################################### - @_handle_rpc_error(retry=True) def get_task_execution(self, task_execution_request): """ :param flyteidl.admin.task_execution_pb2.TaskExecutionGetRequest task_execution_request: @@ -740,7 +448,6 @@ def get_task_execution(self, task_execution_request): """ return self._stub.GetTaskExecution(task_execution_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def get_task_execution_data(self, get_task_execution_data_request): """ Returns signed URLs to LiteralMap blobs for a task execution's inputs and outputs (when available). @@ -750,7 +457,6 @@ def get_task_execution_data(self, get_task_execution_data_request): """ return self._stub.GetTaskExecutionData(get_task_execution_data_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def list_task_executions_paginated(self, task_execution_list_request): """ :param flyteidl.admin.task_execution_pb2.TaskExecutionListRequest task_execution_list_request: @@ -764,7 +470,6 @@ def list_task_executions_paginated(self, task_execution_list_request): # #################################################################################################################### - @_handle_rpc_error(retry=True) def list_projects(self, project_list_request: typing.Optional[ProjectListRequest] = None): """ This will return a list of the projects registered with the Flyte Admin Service @@ -775,7 +480,6 @@ def list_projects(self, project_list_request: typing.Optional[ProjectListRequest project_list_request = ProjectListRequest() return self._stub.ListProjects(project_list_request, metadata=self._metadata) - @_handle_rpc_error() def register_project(self, project_register_request): """ Registers a project along with a set of domains. @@ -784,7 +488,6 @@ def register_project(self, project_register_request): """ return self._stub.RegisterProject(project_register_request, metadata=self._metadata) - @_handle_rpc_error() def update_project(self, project): """ Update an existing project specified by id. @@ -798,7 +501,6 @@ def update_project(self, project): # Matching Attributes Endpoints # #################################################################################################################### - @_handle_rpc_error() def update_project_domain_attributes(self, project_domain_attributes_update_request): """ This updates the attributes for a project and domain registered with the Flyte Admin Service @@ -809,7 +511,6 @@ def update_project_domain_attributes(self, project_domain_attributes_update_requ project_domain_attributes_update_request, metadata=self._metadata ) - @_handle_rpc_error() def update_workflow_attributes(self, workflow_attributes_update_request): """ This updates the attributes for a project, domain, and workflow registered with the Flyte Admin Service @@ -818,7 +519,6 @@ def update_workflow_attributes(self, workflow_attributes_update_request): """ return self._stub.UpdateWorkflowAttributes(workflow_attributes_update_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def get_project_domain_attributes(self, project_domain_attributes_get_request): """ This fetches the attributes for a project and domain registered with the Flyte Admin Service @@ -827,7 +527,6 @@ def get_project_domain_attributes(self, project_domain_attributes_get_request): """ return self._stub.GetProjectDomainAttributes(project_domain_attributes_get_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def get_workflow_attributes(self, workflow_attributes_get_request): """ This fetches the attributes for a project, domain, and workflow registered with the Flyte Admin Service @@ -836,7 +535,6 @@ def get_workflow_attributes(self, workflow_attributes_get_request): """ return self._stub.GetWorkflowAttributes(workflow_attributes_get_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def list_matchable_attributes(self, matchable_attributes_list_request): """ This fetches the attributes for a specific resource type registered with the Flyte Admin Service @@ -859,7 +557,6 @@ def list_matchable_attributes(self, matchable_attributes_list_request): # Data proxy endpoints # #################################################################################################################### - @_handle_rpc_error(retry=True) def create_upload_location( self, create_upload_location_request: _dataproxy_pb2.CreateUploadLocationRequest ) -> _dataproxy_pb2.CreateUploadLocationResponse: @@ -870,7 +567,6 @@ def create_upload_location( """ return self._dataproxy_stub.CreateUploadLocation(create_upload_location_request, metadata=self._metadata) - @_handle_rpc_error(retry=True) def create_download_location( self, create_download_location_request: _dataproxy_pb2.CreateDownloadLocationRequest ) -> _dataproxy_pb2.CreateDownloadLocationResponse: @@ -880,43 +576,3 @@ def create_download_location( :rtype: flyteidl.service.dataproxy_pb2.CreateDownloadLocationResponse """ return self._dataproxy_stub.CreateDownloadLocation(create_download_location_request, metadata=self._metadata) - - -def get_token(token_endpoint, authorization_header, scope): - """ - :param Text token_endpoint: - :param Text authorization_header: This is the value for the "Authorization" key. (eg 'Bearer abc123') - :param Text scope: - :rtype: (Text,Int) The first element is the access token retrieved from the IDP, the second is the expiration - in seconds - """ - headers = { - "Authorization": authorization_header, - "Cache-Control": "no-cache", - "Accept": "application/json", - "Content-Type": "application/x-www-form-urlencoded", - } - body = { - "grant_type": "client_credentials", - } - if scope is not None: - body["scope"] = scope - response = _requests.post(token_endpoint, data=body, headers=headers) - if response.status_code != 200: - cli_logger.error("Non-200 ({}) received from IDP: {}".format(response.status_code, response.text)) - raise FlyteAuthenticationException("Non-200 received from IDP") - - response = response.json() - return response["access_token"], response["expires_in"] - - -def get_basic_authorization_header(client_id, client_secret): - """ - This function transforms the client id and the client secret into a header that conforms with http basic auth. - It joins the id and the secret with a : then base64 encodes it, then adds the appropriate text. - :param Text client_id: - :param Text client_secret: - :rtype: Text - """ - concated = "{}:{}".format(client_id, client_secret) - return "Basic {}".format(_base64.b64encode(concated.encode(_utf_8)).decode(_utf_8)) diff --git a/flytekit/clis/auth/credentials.py b/flytekit/clis/auth/credentials.py deleted file mode 100644 index a8475c8dfc..0000000000 --- a/flytekit/clis/auth/credentials.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import List - -from flytekit.clis.auth.auth import AuthorizationClient -from flytekit.loggers import auth_logger - -# Default, well known-URI string used for fetching JSON metadata. See https://tools.ietf.org/html/rfc8414#section-3. -discovery_endpoint_path = "./.well-known/oauth-authorization-server" - -# Lazy initialized authorization client singleton -_authorization_client = None - - -def get_client( - redirect_endpoint: str, client_id: str, scopes: List[str], auth_endpoint: str, token_endpoint: str -) -> AuthorizationClient: - global _authorization_client - if _authorization_client is not None and not _authorization_client.expired: - return _authorization_client - - _authorization_client = AuthorizationClient( - redirect_uri=redirect_endpoint, - client_id=client_id, - scopes=scopes, - auth_endpoint=auth_endpoint, - token_endpoint=token_endpoint, - ) - - auth_logger.debug(f"Created oauth client with redirect {_authorization_client}") - - if not _authorization_client.has_valid_credentials: - _authorization_client.start_authorization_flow() - - return _authorization_client diff --git a/flytekit/clis/sdk_in_container/constants.py b/flytekit/clis/sdk_in_container/constants.py index 46513553b9..d228babf43 100644 --- a/flytekit/clis/sdk_in_container/constants.py +++ b/flytekit/clis/sdk_in_container/constants.py @@ -9,6 +9,7 @@ CTX_CONFIG_FILE = "config_file" CTX_PROJECT_ROOT = "project_root" CTX_MODULE = "module" +CTX_VERBOSE = "verbose" project_option = _click.option( diff --git a/flytekit/clis/sdk_in_container/pyflyte.py b/flytekit/clis/sdk_in_container/pyflyte.py index 1f843450ed..5e1136d14c 100644 --- a/flytekit/clis/sdk_in_container/pyflyte.py +++ b/flytekit/clis/sdk_in_container/pyflyte.py @@ -1,8 +1,12 @@ +import typing + import click +import grpc +from google.protobuf.json_format import MessageToJson from flytekit import configuration from flytekit.clis.sdk_in_container.backfill import backfill -from flytekit.clis.sdk_in_container.constants import CTX_CONFIG_FILE, CTX_PACKAGES +from flytekit.clis.sdk_in_container.constants import CTX_CONFIG_FILE, CTX_PACKAGES, CTX_VERBOSE from flytekit.clis.sdk_in_container.init import init from flytekit.clis.sdk_in_container.local_cache import local_cache from flytekit.clis.sdk_in_container.package import package @@ -10,6 +14,8 @@ from flytekit.clis.sdk_in_container.run import run from flytekit.clis.sdk_in_container.serialize import serialize from flytekit.configuration.internal import LocalSDK +from flytekit.exceptions.base import FlyteException +from flytekit.exceptions.user import FlyteInvalidInputException from flytekit.loggers import cli_logger @@ -28,7 +34,60 @@ def validate_package(ctx, param, values): return pkgs -@click.group("pyflyte", invoke_without_command=True) +def pretty_print_grpc_error(e: grpc.RpcError): + if isinstance(e, grpc._channel._InactiveRpcError): # noqa + click.secho(f"RPC Failed, with Status: {e.code()}", fg="red") + click.secho(f"\tdetails: {e.details()}", fg="magenta") + click.secho(f"\tDebug string {e.debug_error_string()}", dim=True) + return + + +def pretty_print_exception(e: Exception): + if isinstance(e, click.exceptions.Exit): + raise e + + if isinstance(e, click.ClickException): + click.secho(e.message, fg="red") + raise e + + if isinstance(e, FlyteException): + if isinstance(e, FlyteInvalidInputException): + click.secho("Request rejected by the API, due to Invalid input.", fg="red") + click.secho(f"\tReason: {str(e)}", dim=True) + click.secho(f"\tInput Request: {MessageToJson(e.request)}", dim=True) + return + click.secho(f"Failed with Exception: Reason: {e._ERROR_CODE}", fg="red") # noqa + cause = e.__cause__ + if cause: + if isinstance(cause, grpc.RpcError): + pretty_print_grpc_error(cause) + else: + click.secho(f"Underlying Exception: {cause}") + return + + if isinstance(e, grpc.RpcError): + pretty_print_grpc_error(e) + return + + click.secho(f"Failed with Unknown Exception {type(e)} Reason: {e}", fg="red") # noqa + + +class ErrorHandlingCommand(click.Group): + def invoke(self, ctx: click.Context) -> typing.Any: + try: + return super().invoke(ctx) + except Exception as e: + if CTX_VERBOSE in ctx.obj and ctx.obj[CTX_VERBOSE]: + print("Verbose mode on") + raise e + pretty_print_exception(e) + raise SystemExit(e) + + +@click.group("pyflyte", invoke_without_command=True, cls=ErrorHandlingCommand) +@click.option( + "--verbose", required=False, default=False, is_flag=True, help="Show verbose messages and exception traces" +) @click.option( "-k", "--pkgs", @@ -47,7 +106,7 @@ def validate_package(ctx, param, values): help="Path to config file for use within container", ) @click.pass_context -def main(ctx, pkgs=None, config=None): +def main(ctx, pkgs: typing.List[str], config: str, verbose: bool): """ Entrypoint for all the user commands. """ @@ -63,6 +122,7 @@ def main(ctx, pkgs=None, config=None): if pkgs is None: pkgs = [] ctx.obj[CTX_PACKAGES] = pkgs + ctx.obj[CTX_VERBOSE] = verbose main.add_command(serialize) @@ -72,6 +132,7 @@ def main(ctx, pkgs=None, config=None): main.add_command(run) main.add_command(register) main.add_command(backfill) +main.epilog if __name__ == "__main__": main() diff --git a/flytekit/configuration/__init__.py b/flytekit/configuration/__init__.py index 220f9209ea..77273cc81c 100644 --- a/flytekit/configuration/__init__.py +++ b/flytekit/configuration/__init__.py @@ -298,28 +298,31 @@ class PlatformConfig(object): This object contains the settings to talk to a Flyte backend (the DNS location of your Admin server basically). :param endpoint: DNS for Flyte backend - :param insecure: Whether or not to use SSL - :param insecure_skip_verify: Wether to skip SSL certificate verification - :param console_endpoint: endpoint for console if different than Flyte backend - :param command: This command is executed to return a token using an external process. + :param insecure: Whether to use SSL + :param insecure_skip_verify: Whether to skip SSL certificate verification + :param console_endpoint: endpoint for console if different from Flyte backend + :param command: This command is executed to return a token using an external process :param client_id: This is the public identifier for the app which handles authorization for a Flyte deployment. More details here: https://www.oauth.com/oauth2-servers/client-registration/client-id-secret/. :param client_credentials_secret: Used for service auth, which is automatically called during pyflyte. This will allow the Flyte engine to read the password directly from the environment variable. Note that this is - less secure! Please only use this if mounting the secret as a file is impossible. - :param scopes: List of scopes to request. This is only applicable to the client credentials flow. - :param auth_mode: The OAuth mode to use. Defaults to pkce flow. + less secure! Please only use this if mounting the secret as a file is impossible + :param scopes: List of scopes to request. This is only applicable to the client credentials flow + :param auth_mode: The OAuth mode to use. Defaults to pkce flow + :param ca_cert_file_path: [optional] str Root Cert to be loaded and used to verify admin """ endpoint: str = "localhost:30080" insecure: bool = False insecure_skip_verify: bool = False + ca_cert_file_path: typing.Optional[str] = None console_endpoint: typing.Optional[str] = None command: typing.Optional[typing.List[str]] = None client_id: typing.Optional[str] = None client_credentials_secret: typing.Optional[str] = None scopes: List[str] = field(default_factory=list) auth_mode: AuthType = AuthType.STANDARD + rpc_retries: int = 3 @classmethod def auto(cls, config_file: typing.Optional[typing.Union[str, ConfigFile]] = None) -> PlatformConfig: @@ -334,6 +337,7 @@ def auto(cls, config_file: typing.Optional[typing.Union[str, ConfigFile]] = None kwargs = set_if_exists( kwargs, "insecure_skip_verify", _internal.Platform.INSECURE_SKIP_VERIFY.read(config_file) ) + kwargs = set_if_exists(kwargs, "ca_cert_file_path", _internal.Platform.CA_CERT_FILE_PATH.read(config_file)) kwargs = set_if_exists(kwargs, "command", _internal.Credentials.COMMAND.read(config_file)) kwargs = set_if_exists(kwargs, "client_id", _internal.Credentials.CLIENT_ID.read(config_file)) kwargs = set_if_exists( diff --git a/flytekit/configuration/internal.py b/flytekit/configuration/internal.py index 5c29045db5..5c3729e63b 100644 --- a/flytekit/configuration/internal.py +++ b/flytekit/configuration/internal.py @@ -108,6 +108,9 @@ class Platform(object): LegacyConfigEntry(SECTION, "insecure_skip_verify", bool), YamlConfigEntry("admin.insecureSkipVerify", bool) ) CONSOLE_ENDPOINT = ConfigEntry(LegacyConfigEntry(SECTION, "console_endpoint"), YamlConfigEntry("console.endpoint")) + CA_CERT_FILE_PATH = ConfigEntry( + LegacyConfigEntry(SECTION, "ca_cert_file_path"), YamlConfigEntry("admin.caCertFilePath") + ) class LocalSDK(object): diff --git a/flytekit/exceptions/user.py b/flytekit/exceptions/user.py index 6575218666..59b58a0a5a 100644 --- a/flytekit/exceptions/user.py +++ b/flytekit/exceptions/user.py @@ -1,3 +1,5 @@ +import typing + from flytekit.exceptions.base import FlyteException as _FlyteException from flytekit.exceptions.base import FlyteRecoverableException as _Recoverable @@ -84,3 +86,11 @@ class FlyteRecoverableException(FlyteUserException, _Recoverable): class FlyteAuthenticationException(FlyteAssertion): _ERROR_CODE = "USER:AuthenticationError" + + +class FlyteInvalidInputException(FlyteUserException): + _ERROR_CODE = "USER:BadInputToAPI" + + def __init__(self, request: typing.Any): + self.request = request + super(self).__init__() diff --git a/flytekit/remote/remote.py b/flytekit/remote/remote.py index 93badd5374..03cc9a66e9 100644 --- a/flytekit/remote/remote.py +++ b/flytekit/remote/remote.py @@ -596,6 +596,7 @@ def _serialize_and_register( settings: typing.Optional[SerializationSettings], version: str, options: typing.Optional[Options] = None, + create_default_launchplan: bool = True, ) -> Identifier: """ This method serializes and register the given Flyte entity @@ -630,7 +631,7 @@ def _serialize_and_register( cp_entity, settings=settings, version=version, - create_default_launchplan=True, + create_default_launchplan=create_default_launchplan, options=options, og_entity=entity, ) @@ -685,14 +686,7 @@ def register_workflow( b.domain = ident.domain b.version = ident.version serialization_settings = b.build() - ident = self._serialize_and_register(entity, serialization_settings, version, options) - if default_launch_plan: - default_lp = LaunchPlan.get_default_launch_plan(self.context, entity) - self.register_launch_plan( - default_lp, version=ident.version, project=ident.project, domain=ident.domain, options=options - ) - remote_logger.debug("Created default launch plan for Workflow") - + ident = self._serialize_and_register(entity, serialization_settings, version, options, default_launch_plan) fwf = self.fetch_workflow(ident.project, ident.domain, ident.name, ident.version) fwf._python_interface = entity.python_interface return fwf diff --git a/tests/flytekit/unit/cli/pyflyte/test_run.py b/tests/flytekit/unit/cli/pyflyte/test_run.py index b211153f44..e963f3dfc6 100644 --- a/tests/flytekit/unit/cli/pyflyte/test_run.py +++ b/tests/flytekit/unit/cli/pyflyte/test_run.py @@ -152,6 +152,7 @@ def test_union_type_with_invalid_input(): runner.invoke( pyflyte.main, [ + "--verbose", "run", os.path.join(DIR_NAME, "workflow.py"), "test_union2", diff --git a/tests/flytekit/unit/clients/auth/__init__.py b/tests/flytekit/unit/clients/auth/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/flytekit/unit/cli/auth/test_auth.py b/tests/flytekit/unit/clients/auth/test_auth_client.py similarity index 52% rename from tests/flytekit/unit/cli/auth/test_auth.py rename to tests/flytekit/unit/clients/auth/test_auth_client.py index 487ddfcd1d..5ab843da8b 100644 --- a/tests/flytekit/unit/cli/auth/test_auth.py +++ b/tests/flytekit/unit/clients/auth/test_auth_client.py @@ -2,29 +2,40 @@ import re from multiprocessing import Queue as _Queue -from flytekit.clis.auth import auth as _auth +from flytekit.clients.auth.auth_client import ( + EndpointMetadata, + OAuthHTTPServer, + _create_code_challenge, + _generate_code_verifier, + _generate_state_parameter, +) def test_generate_code_verifier(): - verifier = _auth._generate_code_verifier() + verifier = _generate_code_verifier() assert verifier is not None assert 43 < len(verifier) < 128 assert not re.search(r"[^a-zA-Z0-9_\-.~]+", verifier) def test_generate_state_parameter(): - param = _auth._generate_state_parameter() + param = _generate_state_parameter() assert not re.search(r"[^a-zA-Z0-9-_.,]+", param) def test_create_code_challenge(): test_code_verifier = "test_code_verifier" - assert _auth._create_code_challenge(test_code_verifier) == "Qq1fGD0HhxwbmeMrqaebgn1qhvKeguQPXqLdpmixaM4" + assert _create_code_challenge(test_code_verifier) == "Qq1fGD0HhxwbmeMrqaebgn1qhvKeguQPXqLdpmixaM4" def test_oauth_http_server(): queue = _Queue() - server = _auth.OAuthHTTPServer(("localhost", 9000), _BaseHTTPServer.BaseHTTPRequestHandler, queue=queue) + server = OAuthHTTPServer( + ("localhost", 9000), + remote_metadata=EndpointMetadata(endpoint="example.com"), + request_handler_class=_BaseHTTPServer.BaseHTTPRequestHandler, + queue=queue, + ) test_auth_code = "auth_code" server.handle_authorization_code(test_auth_code) auth_code = queue.get() diff --git a/tests/flytekit/unit/clients/auth/test_authenticator.py b/tests/flytekit/unit/clients/auth/test_authenticator.py new file mode 100644 index 0000000000..4c968cf0bd --- /dev/null +++ b/tests/flytekit/unit/clients/auth/test_authenticator.py @@ -0,0 +1,95 @@ +import json +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from flytekit.clients.auth.authenticator import ( + ClientConfig, + ClientCredentialsAuthenticator, + CommandAuthenticator, + PKCEAuthenticator, + StaticClientConfigStore, +) +from flytekit.clients.auth.exceptions import AuthenticationError + +ENDPOINT = "example.com" + +client_config = ClientConfig( + token_endpoint="token_endpoint", + authorization_endpoint="auth_endpoint", + redirect_uri="redirect_uri", + client_id="client", +) + +static_cfg_store = StaticClientConfigStore(client_config) + + +@patch("flytekit.clients.auth.authenticator.KeyringStore") +@patch("flytekit.clients.auth.auth_client.AuthorizationClient.get_creds_from_remote") +@patch("flytekit.clients.auth.auth_client.AuthorizationClient.refresh_access_token") +def test_pkce_authenticator(mock_refresh: MagicMock, mock_get_creds: MagicMock, mock_keyring: MagicMock): + mock_keyring.retrieve.return_value = None + authn = PKCEAuthenticator(ENDPOINT, static_cfg_store) + assert authn._verify is None + + authn = PKCEAuthenticator(ENDPOINT, static_cfg_store, verify=False) + assert authn._verify is False + + assert authn._creds is None + assert authn._auth_client is None + authn.refresh_credentials() + assert authn._auth_client + mock_get_creds.assert_called() + mock_refresh.assert_not_called() + mock_keyring.store.assert_called() + + authn.refresh_credentials() + mock_refresh.assert_called() + + +@patch("subprocess.run") +def test_command_authenticator(mock_subprocess: MagicMock): + with pytest.raises(AuthenticationError): + authn = CommandAuthenticator(None) # noqa + + authn = CommandAuthenticator(["echo"]) + + authn.refresh_credentials() + assert authn._creds + mock_subprocess.assert_called() + + mock_subprocess.side_effect = subprocess.CalledProcessError(-1, ["x"]) + + with pytest.raises(AuthenticationError): + authn.refresh_credentials() + + +def test_get_basic_authorization_header(): + header = ClientCredentialsAuthenticator.get_basic_authorization_header("client_id", "abc") + assert header == "Basic Y2xpZW50X2lkOmFiYw==" + + +@patch("flytekit.clients.auth.authenticator.requests") +def test_get_token(mock_requests): + response = MagicMock() + response.status_code = 200 + response.json.return_value = json.loads("""{"access_token": "abc", "expires_in": 60}""") + mock_requests.post.return_value = response + access, expiration = ClientCredentialsAuthenticator.get_token("https://corp.idp.net", "abc123", ["my_scope"]) + assert access == "abc" + assert expiration == 60 + + +@patch("flytekit.clients.auth.authenticator.requests") +def test_client_creds_authenticator(mock_requests): + authn = ClientCredentialsAuthenticator( + ENDPOINT, client_id="client", client_secret="secret", cfg_store=static_cfg_store + ) + + response = MagicMock() + response.status_code = 200 + response.json.return_value = json.loads("""{"access_token": "abc", "expires_in": 60}""") + mock_requests.post.return_value = response + authn.refresh_credentials() + assert authn._creds diff --git a/tests/flytekit/unit/clients/auth/test_default_html.py b/tests/flytekit/unit/clients/auth/test_default_html.py new file mode 100644 index 0000000000..391fb6a542 --- /dev/null +++ b/tests/flytekit/unit/clients/auth/test_default_html.py @@ -0,0 +1,18 @@ +from flytekit.clients.auth.default_html import get_default_success_html + + +def test_default_html(): + assert ( + get_default_success_html("flyte.org") + == """ + + + OAuth2 Authentication Success + + +

Successfully logged into flyte.org

+ Flyte login + + +""" + ) # noqa diff --git a/tests/flytekit/unit/clients/auth/test_keyring_store.py b/tests/flytekit/unit/clients/auth/test_keyring_store.py new file mode 100644 index 0000000000..d068a1f451 --- /dev/null +++ b/tests/flytekit/unit/clients/auth/test_keyring_store.py @@ -0,0 +1,32 @@ +from unittest.mock import MagicMock, patch + +from keyring.errors import NoKeyringError + +from flytekit.clients.auth.keyring import Credentials, KeyringStore + + +@patch("keyring.get_password") +def test_keyring_store_get(kr_get_password: MagicMock): + kr_get_password.return_value = "t" + assert KeyringStore.retrieve("example1.com") is not None + + kr_get_password.side_effect = NoKeyringError() + assert KeyringStore.retrieve("example2.com") is None + + +@patch("keyring.delete_password") +def test_keyring_store_delete(kr_del_password: MagicMock): + kr_del_password.return_value = None + assert KeyringStore.delete("example1.com") is None + + kr_del_password.side_effect = NoKeyringError() + assert KeyringStore.delete("example2.com") is None + + +@patch("keyring.set_password") +def test_keyring_store_set(kr_set_password: MagicMock): + kr_set_password.return_value = None + assert KeyringStore.store(Credentials(access_token="a", refresh_token="r", for_endpoint="f")) + + kr_set_password.side_effect = NoKeyringError() + assert KeyringStore.retrieve("example2.com") is None diff --git a/tests/flytekit/unit/clients/test_auth_helper.py b/tests/flytekit/unit/clients/test_auth_helper.py new file mode 100644 index 0000000000..8f14de730e --- /dev/null +++ b/tests/flytekit/unit/clients/test_auth_helper.py @@ -0,0 +1,154 @@ +import os.path +from unittest.mock import MagicMock, patch + +import pytest +from flyteidl.service.auth_pb2 import OAuth2MetadataResponse, PublicClientAuthConfigResponse + +from flytekit.clients.auth.authenticator import ( + ClientConfig, + ClientConfigStore, + ClientCredentialsAuthenticator, + CommandAuthenticator, + PKCEAuthenticator, +) +from flytekit.clients.auth.exceptions import AuthenticationError +from flytekit.clients.auth_helper import ( + RemoteClientConfigStore, + get_authenticator, + load_cert, + upgrade_channel_to_authenticated, + wrap_exceptions_channel, +) +from flytekit.clients.grpc_utils.auth_interceptor import AuthUnaryInterceptor +from flytekit.clients.grpc_utils.wrap_exception_interceptor import RetryExceptionWrapperInterceptor +from flytekit.configuration import AuthType, PlatformConfig + +REDIRECT_URI = "http://localhost:53593/callback" + +TOKEN_ENDPOINT = "https://your.domain.io/oauth2/token" + +CLIENT_ID = "flytectl" + +OAUTH_AUTHORIZE = "https://your.domain.io/oauth2/authorize" + + +def get_auth_service_mock() -> MagicMock: + auth_stub_mock = MagicMock() + auth_stub_mock.GetPublicClientConfig.return_value = PublicClientAuthConfigResponse( + client_id=CLIENT_ID, + redirect_uri=REDIRECT_URI, + scopes=["offline", "all"], + authorization_metadata_key="flyte-authorization", + ) + auth_stub_mock.GetOAuth2Metadata.return_value = OAuth2MetadataResponse( + issuer="https://your.domain.io", + authorization_endpoint=OAUTH_AUTHORIZE, + token_endpoint=TOKEN_ENDPOINT, + response_types_supported=["code", "token", "code token"], + scopes_supported=["all"], + token_endpoint_auth_methods_supported=["client_secret_basic"], + jwks_uri="https://your.domain.io/oauth2/jwks", + code_challenge_methods_supported=["S256"], + grant_types_supported=["client_credentials", "refresh_token", "authorization_code"], + ) + return auth_stub_mock + + +@patch("flytekit.clients.auth_helper.AuthMetadataServiceStub") +def test_remote_client_config_store(mock_auth_service: MagicMock): + ch = MagicMock() + cs = RemoteClientConfigStore(ch) + mock_auth_service.return_value = get_auth_service_mock() + + ccfg = cs.get_client_config() + assert ccfg is not None + assert ccfg.client_id == CLIENT_ID + assert ccfg.authorization_endpoint == OAUTH_AUTHORIZE + + +def get_client_config() -> ClientConfigStore: + cfg_store = MagicMock() + cfg_store.get_client_config.return_value = ClientConfig( + token_endpoint=TOKEN_ENDPOINT, + authorization_endpoint=OAUTH_AUTHORIZE, + redirect_uri=REDIRECT_URI, + client_id=CLIENT_ID, + ) + return cfg_store + + +def test_get_authenticator_basic(): + cfg = PlatformConfig(auth_mode=AuthType.BASIC) + + with pytest.raises(ValueError, match="Client ID and Client SECRET both are required"): + get_authenticator(cfg, None) + + cfg = PlatformConfig(auth_mode=AuthType.BASIC, client_credentials_secret="xyz", client_id="id") + authn = get_authenticator(cfg, get_client_config()) + assert authn + assert isinstance(authn, ClientCredentialsAuthenticator) + + cfg = PlatformConfig(auth_mode=AuthType.CLIENT_CREDENTIALS, client_credentials_secret="xyz", client_id="id") + authn = get_authenticator(cfg, get_client_config()) + assert authn + assert isinstance(authn, ClientCredentialsAuthenticator) + + cfg = PlatformConfig(auth_mode=AuthType.CLIENTSECRET, client_credentials_secret="xyz", client_id="id") + authn = get_authenticator(cfg, get_client_config()) + assert authn + assert isinstance(authn, ClientCredentialsAuthenticator) + + +def test_get_authenticator_pkce(): + cfg = PlatformConfig() + authn = get_authenticator(cfg, get_client_config()) + assert authn + assert isinstance(authn, PKCEAuthenticator) + + cfg = PlatformConfig(insecure_skip_verify=True) + authn = get_authenticator(cfg, get_client_config()) + assert authn + assert isinstance(authn, PKCEAuthenticator) + assert authn._verify is False + + cfg = PlatformConfig(ca_cert_file_path="/file") + authn = get_authenticator(cfg, get_client_config()) + assert authn + assert isinstance(authn, PKCEAuthenticator) + assert authn._verify == "/file" + + +def test_get_authenticator_cmd(): + cfg = PlatformConfig(auth_mode=AuthType.EXTERNAL_PROCESS) + with pytest.raises(AuthenticationError): + get_authenticator(cfg, get_client_config()) + + cfg = PlatformConfig(auth_mode=AuthType.EXTERNAL_PROCESS, command=["echo"]) + authn = get_authenticator(cfg, get_client_config()) + assert authn + assert isinstance(authn, CommandAuthenticator) + + cfg = PlatformConfig(auth_mode=AuthType.EXTERNALCOMMAND, command=["echo"]) + authn = get_authenticator(cfg, get_client_config()) + assert authn + assert isinstance(authn, CommandAuthenticator) + assert authn._cmd == ["echo"] + + +def test_wrap_exceptions_channel(): + ch = MagicMock() + out_ch = wrap_exceptions_channel(PlatformConfig(), ch) + assert isinstance(out_ch._interceptor, RetryExceptionWrapperInterceptor) # noqa + + +def test_upgrade_channel_to_auth(): + ch = MagicMock() + out_ch = upgrade_channel_to_authenticated(PlatformConfig(), ch) + assert isinstance(out_ch._interceptor, AuthUnaryInterceptor) # noqa + + +def test_load_cert(): + cert_file = os.path.join(os.path.dirname(__file__), "testdata", "rootCACert.pem") + f = load_cert(cert_file) + assert f + print(f) diff --git a/tests/flytekit/unit/clients/test_raw.py b/tests/flytekit/unit/clients/test_raw.py index 10a7e09333..ee4e516354 100644 --- a/tests/flytekit/unit/clients/test_raw.py +++ b/tests/flytekit/unit/clients/test_raw.py @@ -1,148 +1,9 @@ -import json -from subprocess import CompletedProcess +from unittest import mock -import grpc -import mock -import pytest from flyteidl.admin import project_pb2 as _project_pb2 -from flyteidl.service import auth_pb2 -from mock import MagicMock, patch -from flytekit.clients.raw import ( - RawSynchronousFlyteClient, - _handle_invalid_create_request, - get_basic_authorization_header, - get_token, -) -from flytekit.configuration import AuthType, PlatformConfig -from flytekit.configuration.internal import Credentials - - -def get_admin_stub_mock() -> mock.MagicMock: - auth_stub_mock = mock.MagicMock() - auth_stub_mock.GetPublicClientConfig.return_value = auth_pb2.PublicClientAuthConfigResponse( - client_id="flytectl", - redirect_uri="http://localhost:53593/callback", - scopes=["offline", "all"], - authorization_metadata_key="flyte-authorization", - ) - auth_stub_mock.GetOAuth2Metadata.return_value = auth_pb2.OAuth2MetadataResponse( - issuer="https://your.domain.io", - authorization_endpoint="https://your.domain.io/oauth2/authorize", - token_endpoint="https://your.domain.io/oauth2/token", - response_types_supported=["code", "token", "code token"], - scopes_supported=["all"], - token_endpoint_auth_methods_supported=["client_secret_basic"], - jwks_uri="https://your.domain.io/oauth2/jwks", - code_challenge_methods_supported=["S256"], - grant_types_supported=["client_credentials", "refresh_token", "authorization_code"], - ) - return auth_stub_mock - - -@mock.patch("flytekit.clients.raw.signal_service") -@mock.patch("flytekit.clients.raw.dataproxy_service") -@mock.patch("flytekit.clients.raw.auth_service") -@mock.patch("flytekit.clients.raw._admin_service") -@mock.patch("flytekit.clients.raw.grpc.insecure_channel") -@mock.patch("flytekit.clients.raw.grpc.secure_channel") -def test_client_set_token(mock_secure_channel, mock_channel, mock_admin, mock_admin_auth, mock_dataproxy, mock_signal): - mock_secure_channel.return_value = True - mock_channel.return_value = True - mock_admin.AdminServiceStub.return_value = True - mock_admin_auth.AuthMetadataServiceStub.return_value = get_admin_stub_mock() - client = RawSynchronousFlyteClient(PlatformConfig(endpoint="a.b.com", insecure=True)) - client.set_access_token("abc") - assert client._metadata[0][1] == "Bearer abc" - assert client.check_access_token("abc") - - -@mock.patch("flytekit.clients.raw.RawSynchronousFlyteClient.set_access_token") -@mock.patch("flytekit.clients.raw.auth_service") -@mock.patch("subprocess.run") -def test_refresh_credentials_from_command(mock_call_to_external_process, mock_admin_auth, mock_set_access_token): - token = "token" - command = ["command", "generating", "token"] - - mock_admin_auth.AuthMetadataServiceStub.return_value = get_admin_stub_mock() - client = RawSynchronousFlyteClient(PlatformConfig(command=command)) - - mock_call_to_external_process.return_value = CompletedProcess(command, 0, stdout=token) - client._refresh_credentials_from_command() - - mock_call_to_external_process.assert_called_with(command, capture_output=True, text=True, check=True) - mock_set_access_token.assert_called_with(token, client.public_client_config.authorization_metadata_key) - - -@mock.patch("flytekit.clients.raw.signal_service") -@mock.patch("flytekit.clients.raw.dataproxy_service") -@mock.patch("flytekit.clients.raw.get_basic_authorization_header") -@mock.patch("flytekit.clients.raw.get_token") -@mock.patch("flytekit.clients.raw.auth_service") -@mock.patch("flytekit.clients.raw._admin_service") -@mock.patch("flytekit.clients.raw.grpc.insecure_channel") -@mock.patch("flytekit.clients.raw.grpc.secure_channel") -def test_refresh_client_credentials_aka_basic( - mock_secure_channel, - mock_channel, - mock_admin, - mock_admin_auth, - mock_get_token, - mock_get_basic_header, - mock_dataproxy, - mock_signal, -): - mock_secure_channel.return_value = True - mock_channel.return_value = True - mock_admin.AdminServiceStub.return_value = True - mock_get_basic_header.return_value = "Basic 123" - mock_get_token.return_value = ("token1", 1234567) - - mock_admin_auth.AuthMetadataServiceStub.return_value = get_admin_stub_mock() - client = RawSynchronousFlyteClient( - PlatformConfig( - endpoint="a.b.com", insecure=True, client_credentials_secret="sosecret", scopes=["a", "b", "c", "d"] - ) - ) - client._metadata = None - assert not client.check_access_token("fdsa") - client._refresh_credentials_basic() - - # Scopes from configuration take precendence. - mock_get_token.assert_called_once_with("https://your.domain.io/oauth2/token", "Basic 123", "a,b,c,d") - - client.set_access_token("token") - assert client._metadata[0][0] == "authorization" - - -@mock.patch("flytekit.clients.raw.signal_service") -@mock.patch("flytekit.clients.raw.dataproxy_service") -@mock.patch("flytekit.clients.raw.auth_service") -@mock.patch("flytekit.clients.raw._admin_service") -@mock.patch("flytekit.clients.raw.grpc.insecure_channel") -@mock.patch("flytekit.clients.raw.grpc.secure_channel") -def test_raises(mock_secure_channel, mock_channel, mock_admin, mock_admin_auth, mock_dataproxy, mock_signal): - mock_secure_channel.return_value = True - mock_channel.return_value = True - mock_admin.AdminServiceStub.return_value = True - - # If the public client config is missing then raise an error - mocked_auth = get_admin_stub_mock() - mocked_auth.GetPublicClientConfig.return_value = None - mock_admin_auth.AuthMetadataServiceStub.return_value = mocked_auth - client = RawSynchronousFlyteClient(PlatformConfig(endpoint="a.b.com", insecure=True)) - assert client.public_client_config is None - with pytest.raises(ValueError): - client._refresh_credentials_basic() - - # If the oauth2 metadata is missing then raise an error - mocked_auth = get_admin_stub_mock() - mocked_auth.GetOAuth2Metadata.return_value = None - mock_admin_auth.AuthMetadataServiceStub.return_value = mocked_auth - client = RawSynchronousFlyteClient(PlatformConfig(endpoint="a.b.com", insecure=True)) - assert client.oauth2_metadata is None - with pytest.raises(ValueError): - client._refresh_credentials_basic() +from flytekit.clients.raw import RawSynchronousFlyteClient +from flytekit.configuration import PlatformConfig @mock.patch("flytekit.clients.raw._admin_service") @@ -161,84 +22,3 @@ def test_list_projects_paginated(mock_channel, mock_admin): project_list_request = _project_pb2.ProjectListRequest(limit=100, token="", filters=None, sort_by=None) client.list_projects(project_list_request) mock_admin.AdminServiceStub().ListProjects.assert_called_with(project_list_request, metadata=None) - - -def test_get_basic_authorization_header(): - header = get_basic_authorization_header("client_id", "abc") - assert header == "Basic Y2xpZW50X2lkOmFiYw==" - - -@patch("flytekit.clients.raw._requests") -def test_get_token(mock_requests): - response = MagicMock() - response.status_code = 200 - response.json.return_value = json.loads("""{"access_token": "abc", "expires_in": 60}""") - mock_requests.post.return_value = response - access, expiration = get_token("https://corp.idp.net", "abc123", "my_scope") - assert access == "abc" - assert expiration == 60 - - -@patch.object(RawSynchronousFlyteClient, "_refresh_credentials_standard") -def test_refresh_standard(mocked_method): - cc = RawSynchronousFlyteClient(PlatformConfig()) - cc.refresh_credentials() - assert mocked_method.called - - -@patch.object(RawSynchronousFlyteClient, "_refresh_credentials_basic") -def test_refresh_basic(mocked_method): - cc = RawSynchronousFlyteClient(PlatformConfig(auth_mode=AuthType.BASIC)) - cc.refresh_credentials() - assert mocked_method.called - - cc = RawSynchronousFlyteClient(PlatformConfig(auth_mode=AuthType.CLIENT_CREDENTIALS)) - cc.refresh_credentials() - assert mocked_method.call_count == 2 - - -@patch.object(RawSynchronousFlyteClient, "_refresh_credentials_basic") -def test_basic_strings(mocked_method): - cc = RawSynchronousFlyteClient(PlatformConfig(auth_mode="basic")) - cc.refresh_credentials() - assert mocked_method.called - - cc = RawSynchronousFlyteClient(PlatformConfig(auth_mode="client_credentials")) - cc.refresh_credentials() - assert mocked_method.call_count == 2 - - -@patch.object(RawSynchronousFlyteClient, "_refresh_credentials_from_command") -def test_refresh_command(mocked_method): - cc = RawSynchronousFlyteClient(PlatformConfig(auth_mode=AuthType.EXTERNALCOMMAND)) - cc.refresh_credentials() - assert mocked_method.called - - -@patch.object(RawSynchronousFlyteClient, "_refresh_credentials_from_command") -def test_refresh_from_environment_variable(mocked_method, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv(Credentials.AUTH_MODE.legacy.get_env_name(), AuthType.EXTERNAL_PROCESS.name, prepend=False) - cc = RawSynchronousFlyteClient(PlatformConfig(auth_mode=None).auto(None)) - cc.refresh_credentials() - assert mocked_method.called - - -def test__handle_invalid_create_request_decorator_happy(): - client = RawSynchronousFlyteClient(PlatformConfig(auth_mode=AuthType.CLIENT_CREDENTIALS)) - mocked_method = client._stub.CreateWorkflow = mock.Mock() - _handle_invalid_create_request(client.create_workflow("/flyteidl.service.AdminService/CreateWorkflow")) - mocked_method.assert_called_once() - - -@patch("flytekit.clients.raw.cli_logger") -@patch("flytekit.clients.raw._MessageToJson") -def test__handle_invalid_create_request_decorator_raises(mock_to_JSON, mock_logger): - mock_to_JSON(return_value="test") - err = grpc.RpcError() - err.details = "There is already a workflow with different structure." - err.code = lambda: grpc.StatusCode.INVALID_ARGUMENT - client = RawSynchronousFlyteClient(PlatformConfig(auth_mode=AuthType.CLIENT_CREDENTIALS)) - client._stub.CreateWorkflow = mock.Mock(side_effect=err) - with pytest.raises(grpc.RpcError): - _handle_invalid_create_request(client.create_workflow("/flyteidl.service.AdminService/CreateWorkflow")) - mock_logger.error.assert_called_with("There is already a workflow with different structure.") diff --git a/tests/flytekit/unit/clients/testdata/rootCACert.pem b/tests/flytekit/unit/clients/testdata/rootCACert.pem new file mode 100644 index 0000000000..e9117bced2 --- /dev/null +++ b/tests/flytekit/unit/clients/testdata/rootCACert.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICsDCCAZgCCQC8mxWHhdmIjDANBgkqhkiG9w0BAQsFADAaMQswCQYDVQQGEwJC +UjELMAkGA1UECAwCV0EwHhcNMjMwMjIyMDU1MTAwWhcNMzMwMjE5MDU1MTAwWjAa +MQswCQYDVQQGEwJCUjELMAkGA1UECAwCV0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDru2F+xX/Z4Q2W6h5qzwZUpCUjkgjQgSZPHI11hfLqOemXoW9o +66LtU/QOeMXSLGnhq7WSILqVz9sCmRvQmxYTFuur8eQXPEpqggOVWfJQ6vj4ssvf +a5j5KWytM9ixgwUmw9xwAAQ4FC1LQsYyD44Lkb+OPJZEK55ZOyGAPblVJW9WSvnX +DsWXbsDENMU7XlpMtVI5/tToBLaSKIMhbZlssCJtjc7omBTL14yy9L7Bj321/a0m +8SRH/XtfmTux/HHq60qTvUWiHrL+CjehZQcehGlKpqaYq5sEQCVWt/cfyvHLo0gd +X6ayAfmno8WeNbXqsuoU+xckI+8WI4vQ2cfnAgMBAAEwDQYJKoZIhvcNAQELBQAD +ggEBAB++cNraohRh91Xa1GW6IQAInyDnxsXSY0wXrpJsAN541lETdk9+L1CbtHxk +5hwvrkUItzrUjIIKq11k0NceM3ClYyO826By/DMjWtPMYp0eSXLKJJnAT5euhONy +9eBLyNFO0yUj77fEiEj6k5PAUBCgs6ZzWTVCgBiNKPAT6WxaYeIwXdQvC0KoJ0t7 +0SD7/I4i9SSjw3lCRZfMKdd7MEPTpi5hXpZPphg9HYJX5o1KSjWvMTYDUzaQtOlf +GM9zNSXug/GyYgVgUyg2dqp/ohbMtqgFH1kTbvMlLmS6BtQxyi11G6QWv6gdb8z5 +es7Wv+5ZqVjroswzEGi/h72Xo0E= +-----END CERTIFICATE----- diff --git a/tests/flytekit/unit/clients/testdata/rootCAKey.pem b/tests/flytekit/unit/clients/testdata/rootCAKey.pem new file mode 100644 index 0000000000..0f71fce5f1 --- /dev/null +++ b/tests/flytekit/unit/clients/testdata/rootCAKey.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA67thfsV/2eENluoeas8GVKQlI5II0IEmTxyNdYXy6jnpl6Fv +aOui7VP0DnjF0ixp4au1kiC6lc/bApkb0JsWExbrq/HkFzxKaoIDlVnyUOr4+LLL +32uY+SlsrTPYsYMFJsPccAAEOBQtS0LGMg+OC5G/jjyWRCueWTshgD25VSVvVkr5 +1w7Fl27AxDTFO15aTLVSOf7U6AS2kiiDIW2ZbLAibY3O6JgUy9eMsvS+wY99tf2t +JvEkR/17X5k7sfxx6utKk71Foh6y/go3oWUHHoRpSqammKubBEAlVrf3H8rxy6NI +HV+msgH5p6PFnjW16rLqFPsXJCPvFiOL0NnH5wIDAQABAoIBAA+cVRSEF7diA/he +gK0qEI1CYYM9hH/qTZMnnOaPfEqukx2Lf0k/cYat7JeYv+DvOAPNzzRiHnkVTreZ +VBI4cvnIpsq4Nhaj03nCKmKVlkpthRdTH9Un1vWJHL1LlaoLtyeeCNcR6TWdgHJf +daiTByEVAc51jK3vBYl7NPi9HazZsT8p1IxN8pXrM4yXWZ2UDhVkNfiylmg+qsnt +s5C8ENmOoAEeUxfQjPUK72UrQAZM7+N+yGvXPHTkJAEs2yx25NbTUZugXa6+Ehlx +55RuXLbDB0cn/eUp0wmLAryYekTm0AjPy4wsvwoKAkxSnIMdfj6kO0EbEYZv5yFC +aPTeJyECgYEA+h1L3dqnhykNvu6xk1T6fmJ1up8xYH7syToz/QXTlNHJKYQnmZDt +u2Y6jmOC4Tv892/CAeDJZqw0KGJb94sDzD4oE0etf79SyfJ3Gh0BBU9P4W1r2uPx +VNak8V/g/qAfP2cwZz/h9jPWkQ1UcfZa1Mpali4EEHdODAwqxXouHTsCgYEA8Udx +jzyrQuwINJMKfzws52QYsuuy2IUbhfbz9r7lqauvbuYx0PCUH/fnwxUCqjr1BS/l +jdy1SjlcvJ1lHsuJ6YaHu30FjNcDiyr0gAEXQeuIpIVD74BdTNJpKoy2AWIdIYtj +wbjm95V3XOdo/tmFYx+6Ign702Lmi+gkDtDxRUUCgYEAwaLAw6eun5OHEtTVIc1e +iU5M+wiYP67EPx4Sdcd3APZRmRS5W8i6ZKVGnEoqX5oDxMT/HFkdU6HqV4Ge1c0I +Sa2tdQ+/IPHMdJCE6PCfg67dlxcRs0tZ4Wa0GDM0i60HxBxteuIYXHXRnkcFo50o +wSlQbIh/mQfkoqsgyfZHkVUCgYAxiCcp7pyB+o6crGsFP8dAIW5onLZ0eK7zy4S9 +7Oac9F/pdlxXtmvSPERZ6iBH7h6K2BBaFSsqd6gwGGe/8Kz5QeLvfHT9Os7BbSoQ +dSjfIYlFrQ4LRuDgenmYgJaEpi2wyzrJdDoGLar5aZBGcUVO2h6OClqmRLFrm1Z7 +rC07uQKBgBqrNfLs0aBolWXhD5KcWl7Eg+lr8Zm3htYTXSZaQwpuDLQ/SA7F02fu +UWbJVNN44xeJZdFtygxqPLOXrRoNYuYwJ6A5SIjERFiwg7kxGqlWKMoVs0HFFsEb +noBtXOzyx5GEyghotTaAr/wdS7eY8ccmTsKm15td1MLUxjK1nlS/ +-----END RSA PRIVATE KEY----- From 7a77508765e7b6e1d2bd2d140f1abac84215c5b8 Mon Sep 17 00:00:00 2001 From: Eduardo Apolinario <653394+eapolinario@users.noreply.github.com> Date: Thu, 23 Feb 2023 15:12:43 -0800 Subject: [PATCH 03/22] Create non-root user after apt-get (#1519) * Create non-root user after apt-get Signed-off-by: Eduardo Apolinario * Create user after pip install Signed-off-by: Kevin Su --------- Signed-off-by: Eduardo Apolinario Signed-off-by: Kevin Su Co-authored-by: Eduardo Apolinario Co-authored-by: Kevin Su --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6c3228ad2f..9aa462781c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,10 +4,6 @@ FROM python:${PYTHON_VERSION}-slim-buster MAINTAINER Flyte Team LABEL org.opencontainers.image.source https://github.com/flyteorg/flytekit -RUN useradd -u 1000 flytekit -RUN chown flytekit: /root -USER flytekit - WORKDIR /root ENV PYTHONPATH /root @@ -24,4 +20,8 @@ RUN pip install -U flytekit==$VERSION \ flytekitplugins-data-fsspec[gcp]==$VERSION \ scikit-learn +RUN useradd -u 1000 flytekit +RUN chown flytekit: /root +USER flytekit + ENV FLYTE_INTERNAL_IMAGE "$DOCKER_IMAGE" From 4ea2ee4384f41ebddb95c340766ff124fe2a04c0 Mon Sep 17 00:00:00 2001 From: Eduardo Apolinario <653394+eapolinario@users.noreply.github.com> Date: Thu, 23 Feb 2023 15:45:26 -0800 Subject: [PATCH 04/22] Add root pyflyte reference to docs (#1520) Signed-off-by: Eduardo Apolinario Co-authored-by: Eduardo Apolinario --- docs/source/pyflyte.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/source/pyflyte.rst b/docs/source/pyflyte.rst index cbbf657bc3..46221c6ca6 100644 --- a/docs/source/pyflyte.rst +++ b/docs/source/pyflyte.rst @@ -2,6 +2,10 @@ Pyflyte CLI ########### +.. click:: flytekit.clis.sdk_in_container.pyflyte:main + :prog: pyflyte + :nested: full + .. click:: flytekit.clis.sdk_in_container.init:init :prog: pyflyte init :nested: full From 45c01101125390f5161f38c799dd8367e257e444 Mon Sep 17 00:00:00 2001 From: Eduardo Apolinario <653394+eapolinario@users.noreply.github.com> Date: Thu, 23 Feb 2023 22:11:15 -0800 Subject: [PATCH 05/22] Drop support for python 3.7 (#1521) --- .github/workflows/pythonbuild.yml | 2 +- .github/workflows/pythonpublish.yml | 2 +- flytekit/configuration/default_images.py | 2 -- plugins/README.md | 5 +++-- plugins/flytekit-aws-athena/setup.py | 3 +-- plugins/flytekit-aws-batch/setup.py | 3 +-- plugins/flytekit-aws-sagemaker/setup.py | 3 +-- plugins/flytekit-bigquery/setup.py | 3 +-- plugins/flytekit-data-fsspec/setup.py | 3 +-- plugins/flytekit-dbt/setup.py | 3 +-- plugins/flytekit-deck-standard/setup.py | 3 +-- plugins/flytekit-dolt/setup.py | 3 +-- plugins/flytekit-greatexpectations/setup.py | 3 +-- plugins/flytekit-hive/setup.py | 3 +-- plugins/flytekit-huggingface/setup.py | 3 +-- plugins/flytekit-k8s-pod/setup.py | 3 +-- plugins/flytekit-kf-pytorch/setup.py | 3 +-- plugins/flytekit-kf-tensorflow/setup.py | 3 +-- plugins/flytekit-mlflow/setup.py | 3 +-- plugins/flytekit-modin/setup.py | 3 +-- plugins/flytekit-onnx-pytorch/setup.py | 5 +++-- plugins/flytekit-onnx-scikitlearn/setup.py | 3 +-- plugins/flytekit-onnx-tensorflow/furloughed_setup.py | 3 +-- plugins/flytekit-pandera/setup.py | 3 +-- plugins/flytekit-papermill/setup.py | 3 +-- plugins/flytekit-polars/setup.py | 3 +-- plugins/flytekit-ray/setup.py | 3 +-- plugins/flytekit-snowflake/setup.py | 3 +-- plugins/flytekit-spark/setup.py | 3 +-- .../flytekit-sqlalchemy/flytekitplugins/sqlalchemy/task.py | 7 +++---- plugins/flytekit-sqlalchemy/setup.py | 3 +-- plugins/flytekit-vaex/setup.py | 3 +-- plugins/flytekit-whylogs/setup.py | 4 +++- setup.py | 4 +--- tests/flytekit/unit/configuration/test_image_config.py | 3 +-- 35 files changed, 42 insertions(+), 70 deletions(-) diff --git a/.github/workflows/pythonbuild.yml b/.github/workflows/pythonbuild.yml index 2344b15391..369a2ecd41 100644 --- a/.github/workflows/pythonbuild.yml +++ b/.github/workflows/pythonbuild.yml @@ -16,7 +16,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10"] exclude: # Ignore this test because we failed to install docker-py # docker-py will install pywin32==227, whereas pywin only added support for python 3.10 in version 301. diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 097d82323e..28febb3876 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -60,7 +60,7 @@ jobs: needs: deploy strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10"] steps: - uses: actions/checkout@v2 with: diff --git a/flytekit/configuration/default_images.py b/flytekit/configuration/default_images.py index 8c01041eed..37eadbb36c 100644 --- a/flytekit/configuration/default_images.py +++ b/flytekit/configuration/default_images.py @@ -4,7 +4,6 @@ class PythonVersion(enum.Enum): - PYTHON_3_7 = (3, 7) PYTHON_3_8 = (3, 8) PYTHON_3_9 = (3, 9) PYTHON_3_10 = (3, 10) @@ -16,7 +15,6 @@ class DefaultImages(object): """ _DEFAULT_IMAGE_PREFIXES = { - PythonVersion.PYTHON_3_7: "cr.flyte.org/flyteorg/flytekit:py3.7-", PythonVersion.PYTHON_3_8: "cr.flyte.org/flyteorg/flytekit:py3.8-", PythonVersion.PYTHON_3_9: "cr.flyte.org/flyteorg/flytekit:py3.9-", PythonVersion.PYTHON_3_10: "cr.flyte.org/flyteorg/flytekit:py3.10-", diff --git a/plugins/README.md b/plugins/README.md index 495ce91019..d583442c17 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -92,13 +92,14 @@ setup( packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Software Development", diff --git a/plugins/flytekit-aws-athena/setup.py b/plugins/flytekit-aws-athena/setup.py index 92802c17b9..5bbf0581d2 100644 --- a/plugins/flytekit-aws-athena/setup.py +++ b/plugins/flytekit-aws-athena/setup.py @@ -18,12 +18,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-aws-batch/setup.py b/plugins/flytekit-aws-batch/setup.py index 7bab047207..db75ce18b9 100644 --- a/plugins/flytekit-aws-batch/setup.py +++ b/plugins/flytekit-aws-batch/setup.py @@ -18,12 +18,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-aws-sagemaker/setup.py b/plugins/flytekit-aws-sagemaker/setup.py index b33edb366d..b54e93d533 100644 --- a/plugins/flytekit-aws-sagemaker/setup.py +++ b/plugins/flytekit-aws-sagemaker/setup.py @@ -19,12 +19,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}", f"flytekitplugins.{PLUGIN_NAME}.models"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-bigquery/setup.py b/plugins/flytekit-bigquery/setup.py index 5da946cd3f..4a12168313 100644 --- a/plugins/flytekit-bigquery/setup.py +++ b/plugins/flytekit-bigquery/setup.py @@ -18,12 +18,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-data-fsspec/setup.py b/plugins/flytekit-data-fsspec/setup.py index 5773990cf0..f622ea3d48 100644 --- a/plugins/flytekit-data-fsspec/setup.py +++ b/plugins/flytekit-data-fsspec/setup.py @@ -27,12 +27,11 @@ "gcp": ["gcsfs>=2021.7.0"], }, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-dbt/setup.py b/plugins/flytekit-dbt/setup.py index 15aea3bc1c..7a6cd2a3bf 100644 --- a/plugins/flytekit-dbt/setup.py +++ b/plugins/flytekit-dbt/setup.py @@ -24,12 +24,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-deck-standard/setup.py b/plugins/flytekit-deck-standard/setup.py index 82ac6788dd..0d90d9603e 100644 --- a/plugins/flytekit-deck-standard/setup.py +++ b/plugins/flytekit-deck-standard/setup.py @@ -21,12 +21,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-dolt/setup.py b/plugins/flytekit-dolt/setup.py index ca3445cb7f..3acc44bf48 100644 --- a/plugins/flytekit-dolt/setup.py +++ b/plugins/flytekit-dolt/setup.py @@ -34,12 +34,11 @@ def run(self): ), cmdclass=dict(develop=PostDevelopCommand), license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-greatexpectations/setup.py b/plugins/flytekit-greatexpectations/setup.py index b8781dbb88..756dcc999a 100644 --- a/plugins/flytekit-greatexpectations/setup.py +++ b/plugins/flytekit-greatexpectations/setup.py @@ -18,12 +18,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-hive/setup.py b/plugins/flytekit-hive/setup.py index fd9c2f869b..3df4213f16 100644 --- a/plugins/flytekit-hive/setup.py +++ b/plugins/flytekit-hive/setup.py @@ -18,12 +18,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-huggingface/setup.py b/plugins/flytekit-huggingface/setup.py index 22cb096ba8..9c1debaba0 100644 --- a/plugins/flytekit-huggingface/setup.py +++ b/plugins/flytekit-huggingface/setup.py @@ -23,12 +23,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-k8s-pod/setup.py b/plugins/flytekit-k8s-pod/setup.py index cbdac44a3d..9767c24ddb 100644 --- a/plugins/flytekit-k8s-pod/setup.py +++ b/plugins/flytekit-k8s-pod/setup.py @@ -21,12 +21,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-kf-pytorch/setup.py b/plugins/flytekit-kf-pytorch/setup.py index 0fa777e453..5eb1d4c43f 100644 --- a/plugins/flytekit-kf-pytorch/setup.py +++ b/plugins/flytekit-kf-pytorch/setup.py @@ -18,12 +18,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-kf-tensorflow/setup.py b/plugins/flytekit-kf-tensorflow/setup.py index 056f8221a2..4614b90497 100644 --- a/plugins/flytekit-kf-tensorflow/setup.py +++ b/plugins/flytekit-kf-tensorflow/setup.py @@ -19,12 +19,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-mlflow/setup.py b/plugins/flytekit-mlflow/setup.py index 2033ce5d27..32bf295aec 100644 --- a/plugins/flytekit-mlflow/setup.py +++ b/plugins/flytekit-mlflow/setup.py @@ -18,12 +18,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-modin/setup.py b/plugins/flytekit-modin/setup.py index bdb765e795..31ae2c3c5f 100644 --- a/plugins/flytekit-modin/setup.py +++ b/plugins/flytekit-modin/setup.py @@ -22,12 +22,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-onnx-pytorch/setup.py b/plugins/flytekit-onnx-pytorch/setup.py index e380e5a579..ddc485781c 100644 --- a/plugins/flytekit-onnx-pytorch/setup.py +++ b/plugins/flytekit-onnx-pytorch/setup.py @@ -18,13 +18,14 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Software Development", diff --git a/plugins/flytekit-onnx-scikitlearn/setup.py b/plugins/flytekit-onnx-scikitlearn/setup.py index 04b92d1b96..8579e36b55 100644 --- a/plugins/flytekit-onnx-scikitlearn/setup.py +++ b/plugins/flytekit-onnx-scikitlearn/setup.py @@ -18,12 +18,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-onnx-tensorflow/furloughed_setup.py b/plugins/flytekit-onnx-tensorflow/furloughed_setup.py index c5e12d8ea0..11ea2afb41 100644 --- a/plugins/flytekit-onnx-tensorflow/furloughed_setup.py +++ b/plugins/flytekit-onnx-tensorflow/furloughed_setup.py @@ -18,12 +18,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-pandera/setup.py b/plugins/flytekit-pandera/setup.py index 3ac46e8ea0..2da7f0bde0 100644 --- a/plugins/flytekit-pandera/setup.py +++ b/plugins/flytekit-pandera/setup.py @@ -18,12 +18,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-papermill/setup.py b/plugins/flytekit-papermill/setup.py index 454d0d49e2..33b9816081 100644 --- a/plugins/flytekit-papermill/setup.py +++ b/plugins/flytekit-papermill/setup.py @@ -23,12 +23,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-polars/setup.py b/plugins/flytekit-polars/setup.py index b57526769e..f0e6d4735d 100644 --- a/plugins/flytekit-polars/setup.py +++ b/plugins/flytekit-polars/setup.py @@ -20,12 +20,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-ray/setup.py b/plugins/flytekit-ray/setup.py index 003ab86c96..2a6edc130b 100644 --- a/plugins/flytekit-ray/setup.py +++ b/plugins/flytekit-ray/setup.py @@ -18,12 +18,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-snowflake/setup.py b/plugins/flytekit-snowflake/setup.py index 137fa21c90..219468b380 100644 --- a/plugins/flytekit-snowflake/setup.py +++ b/plugins/flytekit-snowflake/setup.py @@ -18,12 +18,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-spark/setup.py b/plugins/flytekit-spark/setup.py index 67d47cf6b1..4207a0265c 100644 --- a/plugins/flytekit-spark/setup.py +++ b/plugins/flytekit-spark/setup.py @@ -18,12 +18,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-sqlalchemy/flytekitplugins/sqlalchemy/task.py b/plugins/flytekit-sqlalchemy/flytekitplugins/sqlalchemy/task.py index 88e4ef41c0..7d55c8c144 100644 --- a/plugins/flytekit-sqlalchemy/flytekitplugins/sqlalchemy/task.py +++ b/plugins/flytekit-sqlalchemy/flytekitplugins/sqlalchemy/task.py @@ -20,10 +20,9 @@ class SQLAlchemyDefaultImages(DefaultImages): """Default images for the sqlalchemy flytekit plugin.""" _DEFAULT_IMAGE_PREFIXES = { - PythonVersion.PYTHON_3_7: "ghcr.io/flyteorg/flytekit:py3.7-sqlalchemy-", - PythonVersion.PYTHON_3_8: "ghcr.io/flyteorg/flytekit:py3.8-sqlalchemy-", - PythonVersion.PYTHON_3_9: "ghcr.io/flyteorg/flytekit:py3.9-sqlalchemy-", - PythonVersion.PYTHON_3_10: "ghcr.io/flyteorg/flytekit:py3.10-sqlalchemy-", + PythonVersion.PYTHON_3_8: "cr.flyte.org/flyteorg/flytekit:py3.8-sqlalchemy-", + PythonVersion.PYTHON_3_9: "cr.flyte.org/flyteorg/flytekit:py3.9-sqlalchemy-", + PythonVersion.PYTHON_3_10: "cr.flyte.org/flyteorg/flytekit:py3.10-sqlalchemy-", } diff --git a/plugins/flytekit-sqlalchemy/setup.py b/plugins/flytekit-sqlalchemy/setup.py index 654373b7e7..8ffd2c8f64 100644 --- a/plugins/flytekit-sqlalchemy/setup.py +++ b/plugins/flytekit-sqlalchemy/setup.py @@ -18,12 +18,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-vaex/setup.py b/plugins/flytekit-vaex/setup.py index e8886543f5..57cf07df68 100644 --- a/plugins/flytekit-vaex/setup.py +++ b/plugins/flytekit-vaex/setup.py @@ -17,12 +17,11 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/plugins/flytekit-whylogs/setup.py b/plugins/flytekit-whylogs/setup.py index 47cd0068d5..f2d671ede9 100644 --- a/plugins/flytekit-whylogs/setup.py +++ b/plugins/flytekit-whylogs/setup.py @@ -21,12 +21,14 @@ packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, license="apache2", - python_requires=">=3.7", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Software Development", diff --git a/setup.py b/setup.py index 74a466394b..3e7b886e71 100644 --- a/setup.py +++ b/setup.py @@ -67,7 +67,6 @@ "diskcache>=5.2.1", "cloudpickle>=2.0.0", "cookiecutter>=1.7.3", - "numpy<1.22.0; python_version < '3.8.0'", # TODO: We should remove mentions to the deprecated numpy # aliases. More details in https://github.com/flyteorg/flyte/issues/3166 "numpy<1.24.0", @@ -81,12 +80,11 @@ "flytekit/bin/entrypoint.py", ], license="apache2", - python_requires=">=3.7,<3.11", + python_requires=">=3.8,<3.11", classifiers=[ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/tests/flytekit/unit/configuration/test_image_config.py b/tests/flytekit/unit/configuration/test_image_config.py index 84c767f8fb..c162882357 100644 --- a/tests/flytekit/unit/configuration/test_image_config.py +++ b/tests/flytekit/unit/configuration/test_image_config.py @@ -11,7 +11,6 @@ @pytest.mark.parametrize( "python_version_enum, expected_image_string", [ - (PythonVersion.PYTHON_3_7, "cr.flyte.org/flyteorg/flytekit:py3.7-latest"), (PythonVersion.PYTHON_3_8, "cr.flyte.org/flyteorg/flytekit:py3.8-latest"), (PythonVersion.PYTHON_3_9, "cr.flyte.org/flyteorg/flytekit:py3.9-latest"), (PythonVersion.PYTHON_3_10, "cr.flyte.org/flyteorg/flytekit:py3.10-latest"), @@ -24,7 +23,7 @@ def test_defaults(python_version_enum, expected_image_string): @pytest.mark.parametrize( "python_version_enum, flytekit_version, expected_image_string", [ - (PythonVersion.PYTHON_3_7, "v0.32.0", "cr.flyte.org/flyteorg/flytekit:py3.7-0.32.0"), + (PythonVersion.PYTHON_3_9, "v0.32.0", "cr.flyte.org/flyteorg/flytekit:py3.9-0.32.0"), (PythonVersion.PYTHON_3_8, "1.31.3", "cr.flyte.org/flyteorg/flytekit:py3.8-1.31.3"), ], ) From d0b72a8ffd9b24233576e33ff043f8392e86021c Mon Sep 17 00:00:00 2001 From: Samhita Alla Date: Mon, 27 Feb 2023 21:08:00 +0530 Subject: [PATCH 06/22] DuckDB plugin (#1419) * DuckDB integration Signed-off-by: Samhita Alla * add sd test and fix import Signed-off-by: Samhita Alla Signed-off-by: Samhita Alla * fix lint error Signed-off-by: Samhita Alla * fix lint error Signed-off-by: Samhita Alla * list to List Signed-off-by: Samhita Alla * lint Signed-off-by: Samhita Alla * incorporated suggestions Signed-off-by: Samhita Alla * add duckdb to requirements and add gh action to detect doc warnings and errors Signed-off-by: Samhita Alla * gh action: python 3.9 Signed-off-by: Samhita Alla * docs python 3.8 to 3.9 Signed-off-by: Samhita Alla --------- Signed-off-by: Samhita Alla Signed-off-by: Samhita Alla Co-authored-by: Kevin Su --- .github/workflows/docs_build.yml | 26 +++ .github/workflows/pythonbuild.yml | 9 +- doc-requirements.in | 3 +- doc-requirements.txt | 109 +++++----- docs/source/plugins/duckdb.rst | 12 ++ docs/source/plugins/index.rst | 2 + plugins/README.md | 2 + plugins/flytekit-duckdb/README.md | 9 + .../flytekitplugins/duckdb/__init__.py | 11 + .../flytekitplugins/duckdb/task.py | 117 +++++++++++ plugins/flytekit-duckdb/requirements.in | 3 + plugins/flytekit-duckdb/requirements.txt | 194 ++++++++++++++++++ plugins/flytekit-duckdb/setup.py | 36 ++++ plugins/flytekit-duckdb/tests/test_task.py | 145 +++++++++++++ plugins/setup.py | 1 + 15 files changed, 616 insertions(+), 63 deletions(-) create mode 100644 .github/workflows/docs_build.yml create mode 100644 docs/source/plugins/duckdb.rst create mode 100644 plugins/flytekit-duckdb/README.md create mode 100644 plugins/flytekit-duckdb/flytekitplugins/duckdb/__init__.py create mode 100644 plugins/flytekit-duckdb/flytekitplugins/duckdb/task.py create mode 100644 plugins/flytekit-duckdb/requirements.in create mode 100644 plugins/flytekit-duckdb/requirements.txt create mode 100644 plugins/flytekit-duckdb/setup.py create mode 100644 plugins/flytekit-duckdb/tests/test_task.py diff --git a/.github/workflows/docs_build.yml b/.github/workflows/docs_build.yml new file mode 100644 index 0000000000..4fd71ce3b0 --- /dev/null +++ b/.github/workflows/docs_build.yml @@ -0,0 +1,26 @@ +name: Docs Build + +on: + push: + branches: + - master + pull_request: + branches: + - master +jobs: + docs_warnings: + name: Docs Warnings + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: "0" + - uses: actions/setup-python@v4 + with: + python-version: '3.9' + - name: Report Sphinx Warnings + id: sphinx-warnings + run: | + sudo apt-get install python3-sphinx + pip install -r doc-requirements.txt + SPHINXOPTS="-W" cd docs && make html diff --git a/.github/workflows/pythonbuild.yml b/.github/workflows/pythonbuild.yml index 369a2ecd41..442f7c01f8 100644 --- a/.github/workflows/pythonbuild.yml +++ b/.github/workflows/pythonbuild.yml @@ -65,6 +65,7 @@ jobs: - flytekit-dbt - flytekit-deck-standard - flytekit-dolt + - flytekit-duckdb - flytekit-greatexpectations - flytekit-hive - flytekit-k8s-pod @@ -169,11 +170,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Fetch the code - uses: actions/checkout@v2 - - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/checkout@v3 + - name: Set up Python 3.9 + uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip==21.2.4 setuptools wheel diff --git a/doc-requirements.in b/doc-requirements.in index a5b921481c..872201189a 100644 --- a/doc-requirements.in +++ b/doc-requirements.in @@ -47,4 +47,5 @@ ray # ray scikit-learn # scikit-learn dask[distributed] # dask vaex # vaex -mlflow # mlflow +mlflow # mlflow +duckdb # duckdb diff --git a/doc-requirements.txt b/doc-requirements.txt index 2eb0532253..98a84f41c9 100644 --- a/doc-requirements.txt +++ b/doc-requirements.txt @@ -14,9 +14,9 @@ aiosignal==1.3.1 # via ray alabaster==0.7.13 # via sphinx -alembic==1.9.2 +alembic==1.9.3 # via mlflow -altair==4.2.2 +altair==4.2.0 # via great-expectations ansiwrap==0.8.4 # via papermill @@ -27,6 +27,10 @@ anyio==3.6.2 # watchfiles aplus==0.11.0 # via vaex-core +appnope==0.1.3 + # via + # ipykernel + # ipython argon2-cffi==21.3.0 # via # jupyter-server @@ -38,7 +42,7 @@ arrow==1.2.3 # via # isoduration # jinja2-time -astroid==2.14.1 +astroid==2.14.2 # via sphinx-autoapi astropy==5.2.1 # via vaex-astro @@ -67,7 +71,7 @@ blake3==0.3.3 # via vaex-core bleach==6.0.0 # via nbconvert -botocore==1.29.61 +botocore==1.29.72 # via -r doc-requirements.in bqplot==0.12.36 # via @@ -125,17 +129,16 @@ cookiecutter==2.1.1 # via flytekit croniter==1.3.8 # via flytekit -cryptography==39.0.0 +cryptography==39.0.1 # via # -r doc-requirements.in # great-expectations # pyopenssl - # secretstorage css-html-js-minify==2.5.5 # via sphinx-material cycler==0.11.0 # via matplotlib -dask[distributed]==2023.1.1 +dask[distributed]==2023.2.0 # via # -r doc-requirements.in # distributed @@ -160,7 +163,7 @@ diskcache==5.4.0 # via flytekit distlib==0.3.6 # via virtualenv -distributed==2023.1.1 +distributed==2023.2.0 # via dask docker==6.0.1 # via @@ -177,8 +180,10 @@ docutils==0.17.1 # sphinx-panels dolt-integrations==0.1.5 # via -r doc-requirements.in -doltcli==0.1.17 +doltcli==0.1.18 # via dolt-integrations +duckdb==0.7.0 + # via -r doc-requirements.in entrypoints==0.4 # via # altair @@ -186,7 +191,7 @@ entrypoints==0.4 # papermill executing==1.2.0 # via stack-data -fastapi==0.89.1 +fastapi==0.92.0 # via vaex-server fastjsonschema==2.16.2 # via nbformat @@ -195,11 +200,11 @@ filelock==3.9.0 # ray # vaex-core # virtualenv -flask==2.2.2 +flask==2.2.3 # via mlflow flatbuffers==23.1.21 # via tensorflow -flyteidl==1.3.5 +flyteidl==1.3.7 # via flytekit fonttools==4.38.0 # via matplotlib @@ -260,7 +265,7 @@ googleapis-common-protos==1.58.0 # flytekit # google-api-core # grpcio-status -great-expectations==0.15.46 +great-expectations==0.15.48 # via -r doc-requirements.in greenlet==2.0.2 # via sqlalchemy @@ -291,7 +296,7 @@ htmlmin==0.1.12 # via ydata-profiling httptools==0.5.0 # via uvicorn -identify==2.5.17 +identify==2.5.18 # via pre-commit idna==3.4 # via @@ -315,7 +320,7 @@ importlib-metadata==5.2.0 # sphinx ipydatawidgets==4.3.2 # via pythreejs -ipykernel==6.20.2 +ipykernel==6.21.2 # via # ipywidgets # jupyter @@ -325,9 +330,9 @@ ipykernel==6.20.2 # qtconsole ipyleaflet==0.17.2 # via vaex-jupyter -ipympl==0.9.2 +ipympl==0.9.3 # via vaex-jupyter -ipython==8.9.0 +ipython==8.10.0 # via # great-expectations # ipykernel @@ -371,10 +376,6 @@ jaraco-classes==3.2.3 # via keyring jedi==0.18.2 # via ipython -jeepney==0.8.0 - # via - # keyring - # secretstorage jinja2==3.1.2 # via # altair @@ -426,11 +427,13 @@ jupyter-client==8.0.2 # nbclient # notebook # qtconsole -jupyter-console==6.4.4 +jupyter-console==6.5.1 # via jupyter jupyter-core==5.2.0 # via + # ipykernel # jupyter-client + # jupyter-console # jupyter-server # nbclassic # nbclient @@ -440,7 +443,7 @@ jupyter-core==5.2.0 # qtconsole jupyter-events==0.6.3 # via jupyter-server -jupyter-server==2.2.0 +jupyter-server==2.3.0 # via # nbclassic # notebook-shim @@ -458,7 +461,7 @@ keyring==23.13.1 # via flytekit kiwisolver==1.4.4 # via matplotlib -kubernetes==25.3.0 +kubernetes==26.1.0 # via # -r doc-requirements.in # flytekit @@ -516,7 +519,7 @@ matplotlib-inline==0.1.6 # ipython mdurl==0.1.2 # via markdown-it-py -mistune==2.0.4 +mistune==2.0.5 # via # great-expectations # nbconvert @@ -534,7 +537,7 @@ multimethod==1.9.1 # via # visions # ydata-profiling -mypy-extensions==0.4.3 +mypy-extensions==1.0.0 # via typing-inspect natsort==8.2.0 # via flytekit @@ -618,16 +621,6 @@ numpy==1.23.5 # visions # xarray # ydata-profiling -nvidia-cublas-cu11==11.10.3.66 - # via - # nvidia-cudnn-cu11 - # torch -nvidia-cuda-nvrtc-cu11==11.7.99 - # via torch -nvidia-cuda-runtime-cu11==11.7.99 - # via torch -nvidia-cudnn-cu11==8.5.0.96 - # via torch oauthlib==3.2.2 # via # databricks-cli @@ -701,13 +694,13 @@ pillow==9.4.0 # matplotlib # vaex-viz # visions -platformdirs==2.6.2 +platformdirs==3.0.0 # via # jupyter-core # virtualenv plotly==5.13.0 # via -r doc-requirements.in -pre-commit==3.0.2 +pre-commit==3.0.4 # via sphinx-tags progressbar2==4.2.0 # via vaex-core @@ -766,7 +759,7 @@ pyasn1-modules==0.2.8 # via google-auth pycparser==2.21 # via cffi -pydantic==1.10.4 +pydantic==1.10.5 # via # fastapi # great-expectations @@ -795,7 +788,7 @@ pyparsing==3.0.9 # matplotlib pyrsistent==0.19.3 # via jsonschema -pyspark==3.3.1 +pyspark==3.3.2 # via -r doc-requirements.in python-dateutil==2.8.2 # via @@ -812,7 +805,7 @@ python-dateutil==2.8.2 # whylabs-client python-dotenv==0.21.1 # via uvicorn -python-json-logger==2.0.4 +python-json-logger==2.0.6 # via # flytekit # jupyter-events @@ -820,7 +813,7 @@ python-slugify[unidecode]==8.0.0 # via # cookiecutter # sphinx-material -python-utils==3.4.5 +python-utils==3.5.2 # via progressbar2 pythreejs==2.4.1 # via ipyvolume @@ -858,6 +851,7 @@ pyzmq==25.0.0 # via # ipykernel # jupyter-client + # jupyter-console # jupyter-server # nbclassic # notebook @@ -933,8 +927,6 @@ scipy==1.9.3 # ydata-profiling seaborn==0.12.2 # via ydata-profiling -secretstorage==3.3.3 - # via keyring send2trash==1.8.0 # via # jupyter-server @@ -971,7 +963,7 @@ sortedcontainers==2.4.0 # via # distributed # flytekit -soupsieve==2.3.2.post1 +soupsieve==2.4 # via beautifulsoup4 sphinx==4.5.0 # via @@ -1034,7 +1026,7 @@ sqlparse==0.4.3 # via mlflow stack-data==0.6.2 # via ipython -starlette==0.22.0 +starlette==0.25.0 # via fastapi statsd==3.3.0 # via flytekit @@ -1048,7 +1040,7 @@ tangled-up-in-unicode==0.2.0 # via visions tblib==1.7.0 # via distributed -tenacity==8.1.0 +tenacity==8.2.1 # via # papermill # plotly @@ -1116,6 +1108,7 @@ traitlets==5.9.0 # ipyvolume # ipywidgets # jupyter-client + # jupyter-console # jupyter-core # jupyter-events # jupyter-server @@ -1135,11 +1128,13 @@ traittypes==0.2.1 # ipydatawidgets # ipyleaflet # ipyvolume +typed-ast==1.5.4 + # via doltcli typeguard==2.13.3 # via ydata-profiling -types-toml==0.10.8.1 +types-toml==0.10.8.4 # via responses -typing-extensions==4.4.0 +typing-extensions==4.5.0 # via # astroid # flytekit @@ -1204,7 +1199,7 @@ vaex-viz==0.5.4 # via # vaex # vaex-jupyter -virtualenv==20.17.1 +virtualenv==20.19.0 # via # pre-commit # ray @@ -1220,14 +1215,14 @@ webencodings==0.5.1 # via # bleach # tinycss2 -websocket-client==1.5.0 +websocket-client==1.5.1 # via # docker # jupyter-server # kubernetes websockets==10.4 # via uvicorn -werkzeug==2.2.2 +werkzeug==2.2.3 # via # flask # tensorboard @@ -1235,12 +1230,10 @@ wheel==0.38.4 # via # astunparse # flytekit - # nvidia-cublas-cu11 - # nvidia-cuda-runtime-cu11 # tensorboard -whylabs-client==0.4.3 +whylabs-client==0.4.2 # via -r doc-requirements.in -whylogs==1.1.24 +whylogs==1.1.26 # via -r doc-requirements.in whylogs-sketching==3.4.1.dev3 # via whylogs @@ -1253,7 +1246,7 @@ wrapt==1.14.1 # flytekit # pandera # tensorflow -xarray==2023.1.0 +xarray==2023.2.0 # via vaex-jupyter xyzservices==2022.9.0 # via ipyleaflet @@ -1261,7 +1254,7 @@ ydata-profiling==4.0.0 # via pandas-profiling zict==2.2.0 # via distributed -zipp==3.12.0 +zipp==3.13.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/docs/source/plugins/duckdb.rst b/docs/source/plugins/duckdb.rst new file mode 100644 index 0000000000..bde0783e3a --- /dev/null +++ b/docs/source/plugins/duckdb.rst @@ -0,0 +1,12 @@ +.. _duckdb: + +################################################### +DuckDB API reference +################################################### + +.. tags:: Integration, Data, Analytics + +.. automodule:: flytekitplugins.duckdb + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/docs/source/plugins/index.rst b/docs/source/plugins/index.rst index 693587192e..e56e9230db 100644 --- a/docs/source/plugins/index.rst +++ b/docs/source/plugins/index.rst @@ -31,6 +31,7 @@ Plugin API reference * :ref:`DBT ` - DBT API reference * :ref:`Vaex ` - Vaex API reference * :ref:`MLflow ` - MLflow API reference +* :ref:`DuckDB ` - DuckDB API reference .. toctree:: :maxdepth: 2 @@ -63,3 +64,4 @@ Plugin API reference DBT Vaex MLflow + DuckDB diff --git a/plugins/README.md b/plugins/README.md index d583442c17..abf47b9556 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -23,6 +23,8 @@ All the Flytekit plugins maintained by the core team are added here. It is not n | Snowflake | ```bash pip install flytekitplugins-snowflake``` | Use Snowflake as a 'data warehouse-as-a-service' within Flyte | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-snowflake.svg)](https://pypi.python.org/pypi/flytekitplugins-snowflake/) | Backend | | dbt | ```bash pip install flytekitplugins-dbt``` | Run dbt within Flyte | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-dbt.svg)](https://pypi.python.org/pypi/flytekitplugins-dbt/) | Flytekit-only | | Huggingface | ```bash pip install flytekitplugins-huggingface``` | Read & write Hugginface Datasets as Flyte StructuredDatasets | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-huggingface.svg)](https://pypi.python.org/pypi/flytekitplugins-huggingface/) | Flytekit-only | +| DuckDB | ```bash pip install flytekitplugins-duckdb``` | Run analytical workloads with ease using DuckDB. +| [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-duckdb.svg)](https://pypi.python.org/pypi/flytekitplugins-duckdb/) | Flytekit-only | ## Have a Plugin Idea? 💡 Please [file an issue](https://github.com/flyteorg/flyte/issues/new?assignees=&labels=untriaged%2Cplugins&template=backend-plugin-request.md&title=%5BPlugin%5D). diff --git a/plugins/flytekit-duckdb/README.md b/plugins/flytekit-duckdb/README.md new file mode 100644 index 0000000000..b914e14505 --- /dev/null +++ b/plugins/flytekit-duckdb/README.md @@ -0,0 +1,9 @@ +# Flytekit DuckDB Plugin + +Run analytical workloads with ease using DuckDB. + +To install the plugin, run the following command: + +```bash +pip install flytekitplugins-duckdb +``` diff --git a/plugins/flytekit-duckdb/flytekitplugins/duckdb/__init__.py b/plugins/flytekit-duckdb/flytekitplugins/duckdb/__init__.py new file mode 100644 index 0000000000..7f46dbf52e --- /dev/null +++ b/plugins/flytekit-duckdb/flytekitplugins/duckdb/__init__.py @@ -0,0 +1,11 @@ +""" +.. currentmodule:: flytekitplugins.duckdb + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + DuckDBQuery +""" + +from .task import DuckDBQuery diff --git a/plugins/flytekit-duckdb/flytekitplugins/duckdb/task.py b/plugins/flytekit-duckdb/flytekitplugins/duckdb/task.py new file mode 100644 index 0000000000..ff69a0663f --- /dev/null +++ b/plugins/flytekit-duckdb/flytekitplugins/duckdb/task.py @@ -0,0 +1,117 @@ +from typing import Dict, List, NamedTuple, Optional, Union + +import duckdb +import pandas as pd +import pyarrow as pa + +from flytekit import PythonInstanceTask +from flytekit.extend import Interface +from flytekit.types.structured.structured_dataset import StructuredDataset + + +class QueryOutput(NamedTuple): + counter: int = -1 + output: Optional[str] = None + + +class DuckDBQuery(PythonInstanceTask): + _TASK_TYPE = "duckdb" + + def __init__( + self, + name: str, + query: Union[str, List[str]], + inputs: Optional[Dict[str, Union[StructuredDataset, list]]] = None, + **kwargs, + ): + """ + This method initializes the DuckDBQuery. + + Args: + name: Name of the task + query: DuckDB query to execute + inputs: The query parameters to be used while executing the query + """ + self._query = query + # create an in-memory database that's non-persistent + self._con = duckdb.connect(":memory:") + + outputs = {"result": StructuredDataset} + + super(DuckDBQuery, self).__init__( + name=name, + task_type=self._TASK_TYPE, + task_config=None, + interface=Interface(inputs=inputs, outputs=outputs), + **kwargs, + ) + + def _execute_query(self, params: list, query: str, counter: int, multiple_params: bool): + """ + This method runs the DuckDBQuery. + + Args: + params: Query parameters to use while executing the query + query: DuckDB query to execute + counter: Use counter to map user-given arguments to the query parameters + multiple_params: Set flag to indicate the presence of params for multiple queries + """ + if any(x in query for x in ("$", "?")): + if multiple_params: + counter += 1 + if not counter < len(params): + raise ValueError("Parameter doesn't exist.") + if "insert" in query.lower(): + # run executemany disregarding the number of entries to store for an insert query + yield QueryOutput(output=self._con.executemany(query, params[counter]), counter=counter) + else: + yield QueryOutput(output=self._con.execute(query, params[counter]), counter=counter) + else: + if params: + yield QueryOutput(output=self._con.execute(query, params), counter=counter) + else: + raise ValueError("Parameter not specified.") + else: + yield QueryOutput(output=self._con.execute(query), counter=counter) + + def execute(self, **kwargs) -> StructuredDataset: + # TODO: Enable iterative download after adding the functionality to structured dataset code. + params = None + for key in self.python_interface.inputs.keys(): + val = kwargs.get(key) + if isinstance(val, StructuredDataset): + # register structured dataset + self._con.register(key, val.open(pa.Table).all()) + elif isinstance(val, (pd.DataFrame, pa.Table)): + # register pandas dataframe/arrow table + self._con.register(key, val) + elif isinstance(val, list): + # copy val into params + params = val + else: + raise ValueError(f"Expected inputs of type StructuredDataset, str or list, received {type(val)}") + + final_query = self._query + query_output = QueryOutput() + # set flag to indicate the presence of params for multiple queries + multiple_params = isinstance(params[0], list) if params else False + + if isinstance(self._query, list) and len(self._query) > 1: + # loop until the penultimate query + for query in self._query[:-1]: + query_output = next( + self._execute_query( + params=params, query=query, counter=query_output.counter, multiple_params=multiple_params + ) + ) + final_query = self._query[-1] + + # fetch query output from the last query + # expecting a SELECT query + dataframe = next( + self._execute_query( + params=params, query=final_query, counter=query_output.counter, multiple_params=multiple_params + ) + ).output.arrow() + + return StructuredDataset(dataframe=dataframe) diff --git a/plugins/flytekit-duckdb/requirements.in b/plugins/flytekit-duckdb/requirements.in new file mode 100644 index 0000000000..1589691aa5 --- /dev/null +++ b/plugins/flytekit-duckdb/requirements.in @@ -0,0 +1,3 @@ +. +-e file:.#egg=flytekitplugins-duckdb +duckdb diff --git a/plugins/flytekit-duckdb/requirements.txt b/plugins/flytekit-duckdb/requirements.txt new file mode 100644 index 0000000000..c69007f914 --- /dev/null +++ b/plugins/flytekit-duckdb/requirements.txt @@ -0,0 +1,194 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile requirements.in +# +-e file:.#egg=flytekitplugins-duckdb + # via -r requirements.in +arrow==1.2.3 + # via jinja2-time +binaryornot==0.4.4 + # via cookiecutter +certifi==2022.12.7 + # via requests +cffi==1.15.1 + # via cryptography +chardet==5.1.0 + # via binaryornot +charset-normalizer==2.1.1 + # via requests +click==8.1.3 + # via + # cookiecutter + # flytekit +cloudpickle==2.2.0 + # via flytekit +cookiecutter==2.1.1 + # via flytekit +croniter==1.3.8 + # via flytekit +cryptography==39.0.0 + # via pyopenssl +dataclasses-json==0.5.7 + # via flytekit +decorator==5.1.1 + # via retry +deprecated==1.2.13 + # via flytekit +diskcache==5.4.0 + # via flytekit +docker==6.0.1 + # via flytekit +docker-image-py==0.1.12 + # via flytekit +docstring-parser==0.15 + # via flytekit +duckdb==0.6.1 + # via + # -r requirements.in + # flytekitplugins-duckdb +flyteidl==1.3.2 + # via flytekit +flytekit==1.3.0b6 + # via flytekitplugins-duckdb +googleapis-common-protos==1.58.0 + # via + # flyteidl + # flytekit + # grpcio-status +grpcio==1.51.1 + # via + # flytekit + # grpcio-status +grpcio-status==1.51.1 + # via flytekit +idna==3.4 + # via requests +importlib-metadata==6.0.0 + # via + # flytekit + # keyring +jaraco-classes==3.2.3 + # via keyring +jinja2==3.1.2 + # via + # cookiecutter + # jinja2-time +jinja2-time==0.2.0 + # via cookiecutter +joblib==1.2.0 + # via flytekit +keyring==23.13.1 + # via flytekit +markupsafe==2.1.1 + # via jinja2 +marshmallow==3.19.0 + # via + # dataclasses-json + # marshmallow-enum + # marshmallow-jsonschema +marshmallow-enum==1.5.1 + # via dataclasses-json +marshmallow-jsonschema==0.13.0 + # via flytekit +more-itertools==9.0.0 + # via jaraco-classes +mypy-extensions==0.4.3 + # via typing-inspect +natsort==8.2.0 + # via flytekit +numpy==1.23.5 + # via + # duckdb + # flytekit + # pandas + # pyarrow +packaging==23.0 + # via + # docker + # marshmallow +pandas==1.5.2 + # via flytekit +protobuf==4.21.12 + # via + # flyteidl + # googleapis-common-protos + # grpcio-status + # protoc-gen-swagger +protoc-gen-swagger==0.1.0 + # via flyteidl +py==1.11.0 + # via retry +pyarrow==10.0.1 + # via flytekit +pycparser==2.21 + # via cffi +pyopenssl==23.0.0 + # via flytekit +python-dateutil==2.8.2 + # via + # arrow + # croniter + # flytekit + # pandas +python-json-logger==2.0.4 + # via flytekit +python-slugify==7.0.0 + # via cookiecutter +pytimeparse==1.1.8 + # via flytekit +pytz==2022.7 + # via + # flytekit + # pandas +pyyaml==6.0 + # via + # cookiecutter + # flytekit +regex==2022.10.31 + # via docker-image-py +requests==2.28.1 + # via + # cookiecutter + # docker + # flytekit + # responses +responses==0.22.0 + # via flytekit +retry==0.9.2 + # via flytekit +six==1.16.0 + # via python-dateutil +sortedcontainers==2.4.0 + # via flytekit +statsd==3.3.0 + # via flytekit +text-unidecode==1.3 + # via python-slugify +toml==0.10.2 + # via responses +types-toml==0.10.8.1 + # via responses +typing-extensions==4.4.0 + # via + # flytekit + # typing-inspect +typing-inspect==0.8.0 + # via dataclasses-json +urllib3==1.26.13 + # via + # docker + # flytekit + # requests + # responses +websocket-client==1.4.2 + # via docker +wheel==0.38.4 + # via flytekit +wrapt==1.14.1 + # via + # deprecated + # flytekit +zipp==3.11.0 + # via importlib-metadata diff --git a/plugins/flytekit-duckdb/setup.py b/plugins/flytekit-duckdb/setup.py new file mode 100644 index 0000000000..f2642bbdb0 --- /dev/null +++ b/plugins/flytekit-duckdb/setup.py @@ -0,0 +1,36 @@ +from setuptools import setup + +PLUGIN_NAME = "duckdb" + +microlib_name = f"flytekitplugins-{PLUGIN_NAME}" + +plugin_requires = ["flytekit>=1.3.0b2,<2.0.0", "duckdb"] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + description="DuckDB Plugin for Flytekit", + namespace_packages=["flytekitplugins"], + packages=[f"flytekitplugins.{PLUGIN_NAME}"], + install_requires=plugin_requires, + license="apache2", + python_requires=">=3.7,<3.11", + classifiers=[ + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + ], +) diff --git a/plugins/flytekit-duckdb/tests/test_task.py b/plugins/flytekit-duckdb/tests/test_task.py new file mode 100644 index 0000000000..5af73a45eb --- /dev/null +++ b/plugins/flytekit-duckdb/tests/test_task.py @@ -0,0 +1,145 @@ +from typing import List, Union + +import pandas as pd +import pyarrow as pa +from flytekitplugins.duckdb import DuckDBQuery +from typing_extensions import Annotated + +from flytekit import kwtypes, task, workflow +from flytekit.types.structured.structured_dataset import StructuredDataset + + +def test_simple(): + duckdb_task = DuckDBQuery(name="duckdb_task", query="SELECT SUM(a) FROM mydf", inputs=kwtypes(mydf=pd.DataFrame)) + + @workflow + def pandas_wf(mydf: pd.DataFrame) -> pd.DataFrame: + return duckdb_task(mydf=mydf) + + @workflow + def arrow_wf(mydf: pd.DataFrame) -> pa.Table: + return duckdb_task(mydf=mydf) + + df = pd.DataFrame({"a": [1, 2, 3]}) + assert isinstance(pandas_wf(mydf=df), pd.DataFrame) + assert isinstance(arrow_wf(mydf=df), pa.Table) + + +def test_parquet(): + duckdb_query = DuckDBQuery( + name="read_parquet", + query=[ + "INSTALL httpfs", + "LOAD httpfs", + """SELECT hour(lpep_pickup_datetime) AS hour, count(*) AS count FROM READ_PARQUET(?) GROUP BY hour""", + ], + inputs=kwtypes(params=List[str]), + ) + + @workflow + def parquet_wf(parquet_file: str) -> pd.DataFrame: + return duckdb_query(params=[parquet_file]) + + assert isinstance( + parquet_wf(parquet_file="https://d37ci6vzurychx.cloudfront.net/trip-data/green_tripdata_2022-02.parquet"), + pd.DataFrame, + ) + + +def test_arrow(): + duckdb_task = DuckDBQuery( + name="duckdb_arrow_task", query="SELECT * FROM arrow_table WHERE i = 2", inputs=kwtypes(arrow_table=pa.Table) + ) + + @task + def get_arrow_table() -> pa.Table: + return pa.Table.from_pydict({"i": [1, 2, 3, 4], "j": ["one", "two", "three", "four"]}) + + @workflow + def arrow_wf(arrow_table: pa.Table) -> pa.Table: + return duckdb_task(arrow_table=arrow_table) + + assert isinstance(arrow_wf(arrow_table=get_arrow_table()), pa.Table) + + +def test_structured_dataset_arrow_table(): + duckdb_task = DuckDBQuery( + name="duckdb_sd_table", + query="SELECT * FROM arrow_table WHERE i = 2", + inputs=kwtypes(arrow_table=StructuredDataset), + ) + + @task + def get_arrow_table() -> StructuredDataset: + return StructuredDataset( + dataframe=pa.Table.from_pydict({"i": [1, 2, 3, 4], "j": ["one", "two", "three", "four"]}) + ) + + @workflow + def arrow_wf(arrow_table: StructuredDataset) -> pa.Table: + return duckdb_task(arrow_table=arrow_table) + + assert isinstance(arrow_wf(arrow_table=get_arrow_table()), pa.Table) + + +def test_structured_dataset_pandas_df(): + duckdb_task = DuckDBQuery( + name="duckdb_sd_df", + query="SELECT * FROM pandas_df WHERE i = 2", + inputs=kwtypes(pandas_df=StructuredDataset), + ) + + @task + def get_pandas_df() -> StructuredDataset: + return StructuredDataset( + dataframe=pd.DataFrame.from_dict({"i": [1, 2, 3, 4], "j": ["one", "two", "three", "four"]}) + ) + + @workflow + def pandas_wf(pandas_df: StructuredDataset) -> pd.DataFrame: + return duckdb_task(pandas_df=pandas_df) + + assert isinstance(pandas_wf(pandas_df=get_pandas_df()), pd.DataFrame) + + +def test_distinct_params(): + duckdb_params_query = DuckDBQuery( + name="params_query", + query=[ + "CREATE TABLE items(item VARCHAR, value DECIMAL(10,2), count INTEGER)", + "INSERT INTO items VALUES (?, ?, ?)", + "SELECT $1 AS one, $1 AS two, $2 AS three", + ], + inputs=kwtypes(params=List[List[Union[str, List[Union[str, int]]]]]), + ) + + @task + def read_df(df: Annotated[StructuredDataset, kwtypes(one=str)]) -> pd.DataFrame: + return df.open(pd.DataFrame).all() + + @workflow + def params_wf(params: List[List[Union[str, List[Union[str, int]]]]]) -> pd.DataFrame: + return read_df(df=duckdb_params_query(params=params)) + + params = [[["chainsaw", 500, 10], ["iphone", 300, 2]], ["duck", "goose"]] + wf_output = params_wf(params=params) + assert isinstance(wf_output, pd.DataFrame) + assert wf_output.columns.values == ["one"] + + +def test_insert_query_with_single_params(): + duckdb_params_query = DuckDBQuery( + name="params_query", + query=[ + "CREATE TABLE items(value DECIMAL(10,2))", + "INSERT INTO items VALUES (?)", + "SELECT * FROM items", + ], + inputs=kwtypes(params=List[List[List[int]]]), + ) + + @workflow + def params_wf(params: List[List[List[int]]]) -> pa.Table: + return duckdb_params_query(params=params) + + assert isinstance(params_wf(params=[[[500], [300], [2]]]), pa.Table) diff --git a/plugins/setup.py b/plugins/setup.py index de44797296..cef41e0a0b 100644 --- a/plugins/setup.py +++ b/plugins/setup.py @@ -15,6 +15,7 @@ "flytekitplugins-fsspec": "flytekit-data-fsspec", "flytekitplugins-dbt": "flytekit-dbt", "flytekitplugins-dolt": "flytekit-dolt", + "flytekitplugins-duckdb": "flytekit-duckdb", "flytekitplugins-great_expectations": "flytekit-greatexpectations", "flytekitplugins-hive": "flytekit-hive", "flytekitplugins-pod": "flytekit-k8s-pod", From e84b9b08eacf5f08820726800ec8d0e3e192f724 Mon Sep 17 00:00:00 2001 From: Samhita Alla Date: Thu, 2 Mar 2023 01:36:25 +0530 Subject: [PATCH 07/22] add string as a valid input (#1527) * add string as a valid input Signed-off-by: Samhita Alla * isort Signed-off-by: Samhita Alla * tests Signed-off-by: Samhita Alla * Lint Signed-off-by: Eduardo Apolinario --------- Signed-off-by: Samhita Alla Signed-off-by: Eduardo Apolinario Co-authored-by: Eduardo Apolinario --- .../flytekitplugins/duckdb/task.py | 4 ++ plugins/flytekit-duckdb/tests/test_task.py | 51 ++++++++++--------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/plugins/flytekit-duckdb/flytekitplugins/duckdb/task.py b/plugins/flytekit-duckdb/flytekitplugins/duckdb/task.py index ff69a0663f..ead962da52 100644 --- a/plugins/flytekit-duckdb/flytekitplugins/duckdb/task.py +++ b/plugins/flytekit-duckdb/flytekitplugins/duckdb/task.py @@ -1,3 +1,4 @@ +import json from typing import Dict, List, NamedTuple, Optional, Union import duckdb @@ -88,6 +89,9 @@ def execute(self, **kwargs) -> StructuredDataset: elif isinstance(val, list): # copy val into params params = val + elif isinstance(val, str): + # load into a list + params = json.loads(val) else: raise ValueError(f"Expected inputs of type StructuredDataset, str or list, received {type(val)}") diff --git a/plugins/flytekit-duckdb/tests/test_task.py b/plugins/flytekit-duckdb/tests/test_task.py index 5af73a45eb..e2b4450ba6 100644 --- a/plugins/flytekit-duckdb/tests/test_task.py +++ b/plugins/flytekit-duckdb/tests/test_task.py @@ -1,4 +1,5 @@ -from typing import List, Union +import json +from typing import List import pandas as pd import pyarrow as pa @@ -10,15 +11,17 @@ def test_simple(): - duckdb_task = DuckDBQuery(name="duckdb_task", query="SELECT SUM(a) FROM mydf", inputs=kwtypes(mydf=pd.DataFrame)) + simple_duckdb_query = DuckDBQuery( + name="duckdb_task", query="SELECT SUM(a) FROM mydf", inputs=kwtypes(mydf=pd.DataFrame) + ) @workflow def pandas_wf(mydf: pd.DataFrame) -> pd.DataFrame: - return duckdb_task(mydf=mydf) + return simple_duckdb_query(mydf=mydf) @workflow def arrow_wf(mydf: pd.DataFrame) -> pa.Table: - return duckdb_task(mydf=mydf) + return simple_duckdb_query(mydf=mydf) df = pd.DataFrame({"a": [1, 2, 3]}) assert isinstance(pandas_wf(mydf=df), pd.DataFrame) @@ -26,7 +29,7 @@ def arrow_wf(mydf: pd.DataFrame) -> pa.Table: def test_parquet(): - duckdb_query = DuckDBQuery( + parquet_duckdb_query = DuckDBQuery( name="read_parquet", query=[ "INSTALL httpfs", @@ -38,7 +41,7 @@ def test_parquet(): @workflow def parquet_wf(parquet_file: str) -> pd.DataFrame: - return duckdb_query(params=[parquet_file]) + return parquet_duckdb_query(params=[parquet_file]) assert isinstance( parquet_wf(parquet_file="https://d37ci6vzurychx.cloudfront.net/trip-data/green_tripdata_2022-02.parquet"), @@ -47,7 +50,7 @@ def parquet_wf(parquet_file: str) -> pd.DataFrame: def test_arrow(): - duckdb_task = DuckDBQuery( + arrow_duckdb_query = DuckDBQuery( name="duckdb_arrow_task", query="SELECT * FROM arrow_table WHERE i = 2", inputs=kwtypes(arrow_table=pa.Table) ) @@ -56,14 +59,14 @@ def get_arrow_table() -> pa.Table: return pa.Table.from_pydict({"i": [1, 2, 3, 4], "j": ["one", "two", "three", "four"]}) @workflow - def arrow_wf(arrow_table: pa.Table) -> pa.Table: - return duckdb_task(arrow_table=arrow_table) + def arrow_wf() -> pa.Table: + return arrow_duckdb_query(arrow_table=get_arrow_table()) - assert isinstance(arrow_wf(arrow_table=get_arrow_table()), pa.Table) + assert isinstance(arrow_wf(), pa.Table) def test_structured_dataset_arrow_table(): - duckdb_task = DuckDBQuery( + sd_duckdb_query = DuckDBQuery( name="duckdb_sd_table", query="SELECT * FROM arrow_table WHERE i = 2", inputs=kwtypes(arrow_table=StructuredDataset), @@ -76,14 +79,14 @@ def get_arrow_table() -> StructuredDataset: ) @workflow - def arrow_wf(arrow_table: StructuredDataset) -> pa.Table: - return duckdb_task(arrow_table=arrow_table) + def arrow_wf() -> pa.Table: + return sd_duckdb_query(arrow_table=get_arrow_table()) - assert isinstance(arrow_wf(arrow_table=get_arrow_table()), pa.Table) + assert isinstance(arrow_wf(), pa.Table) def test_structured_dataset_pandas_df(): - duckdb_task = DuckDBQuery( + sd_pandas_duckdb_query = DuckDBQuery( name="duckdb_sd_df", query="SELECT * FROM pandas_df WHERE i = 2", inputs=kwtypes(pandas_df=StructuredDataset), @@ -96,10 +99,10 @@ def get_pandas_df() -> StructuredDataset: ) @workflow - def pandas_wf(pandas_df: StructuredDataset) -> pd.DataFrame: - return duckdb_task(pandas_df=pandas_df) + def pandas_wf() -> pd.DataFrame: + return sd_pandas_duckdb_query(pandas_df=get_pandas_df()) - assert isinstance(pandas_wf(pandas_df=get_pandas_df()), pd.DataFrame) + assert isinstance(pandas_wf(), pd.DataFrame) def test_distinct_params(): @@ -110,7 +113,7 @@ def test_distinct_params(): "INSERT INTO items VALUES (?, ?, ?)", "SELECT $1 AS one, $1 AS two, $2 AS three", ], - inputs=kwtypes(params=List[List[Union[str, List[Union[str, int]]]]]), + inputs=kwtypes(params=str), ) @task @@ -118,11 +121,11 @@ def read_df(df: Annotated[StructuredDataset, kwtypes(one=str)]) -> pd.DataFrame: return df.open(pd.DataFrame).all() @workflow - def params_wf(params: List[List[Union[str, List[Union[str, int]]]]]) -> pd.DataFrame: + def params_wf(params: str) -> pd.DataFrame: return read_df(df=duckdb_params_query(params=params)) params = [[["chainsaw", 500, 10], ["iphone", 300, 2]], ["duck", "goose"]] - wf_output = params_wf(params=params) + wf_output = params_wf(params=json.dumps(params)) assert isinstance(wf_output, pd.DataFrame) assert wf_output.columns.values == ["one"] @@ -135,11 +138,11 @@ def test_insert_query_with_single_params(): "INSERT INTO items VALUES (?)", "SELECT * FROM items", ], - inputs=kwtypes(params=List[List[List[int]]]), + inputs=kwtypes(params=str), ) @workflow - def params_wf(params: List[List[List[int]]]) -> pa.Table: + def params_wf(params: str) -> pa.Table: return duckdb_params_query(params=params) - assert isinstance(params_wf(params=[[[500], [300], [2]]]), pa.Table) + assert isinstance(params_wf(params=json.dumps([[[500], [300], [2]]])), pa.Table) From 6b56fb547cd4f9238040ea0723e6b37a2deba8b3 Mon Sep 17 00:00:00 2001 From: Yee Hing Tong Date: Wed, 1 Mar 2023 14:23:40 -0800 Subject: [PATCH 08/22] Add back attempt to use existing serialization settings when running (#1529) Signed-off-by: Yee Hing Tong --- flytekit/bin/entrypoint.py | 4 ++-- .../flytekit/unit/bin/test_python_entrypoint.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/flytekit/bin/entrypoint.py b/flytekit/bin/entrypoint.py index 4f4962309d..ca7a6cf20d 100644 --- a/flytekit/bin/entrypoint.py +++ b/flytekit/bin/entrypoint.py @@ -270,8 +270,8 @@ def setup_execution( if compressed_serialization_settings: ss = SerializationSettings.from_transport(compressed_serialization_settings) ssb = ss.new_builder() - ssb.project = exe_project - ssb.domain = exe_domain + ssb.project = ssb.project or exe_project + ssb.domain = ssb.domain or exe_domain ssb.version = tk_version if dynamic_addl_distro: ssb.fast_serialization_settings = FastSerializationSettings( diff --git a/tests/flytekit/unit/bin/test_python_entrypoint.py b/tests/flytekit/unit/bin/test_python_entrypoint.py index 6dd1785585..45d50a2fc5 100644 --- a/tests/flytekit/unit/bin/test_python_entrypoint.py +++ b/tests/flytekit/unit/bin/test_python_entrypoint.py @@ -7,6 +7,7 @@ from flyteidl.core.errors_pb2 import ErrorDocument from flytekit.bin.entrypoint import _dispatch_execute, normalize_inputs, setup_execution +from flytekit.configuration import Image, ImageConfig, SerializationSettings from flytekit.core import context_manager from flytekit.core.base_task import IgnoreOutputs from flytekit.core.data_persistence import DiskPersistence @@ -310,6 +311,22 @@ def test_dispatch_execute_system_error(mock_write_to_file, mock_upload_dir, mock assert ed.error.origin == execution_models.ExecutionError.ErrorKind.SYSTEM +def test_persist_ss(): + default_img = Image(name="default", fqn="test", tag="tag") + ss = SerializationSettings( + project="proj1", + domain="dom", + version="version123", + env=None, + image_config=ImageConfig(default_image=default_img, images=[default_img]), + ) + ss_txt = ss.serialized_context + os.environ["_F_SS_C"] = ss_txt + with setup_execution("s3://", checkpoint_path=None, prev_checkpoint=None) as ctx: + assert ctx.serialization_settings.project == "proj1" + assert ctx.serialization_settings.domain == "dom" + + def test_setup_disk_prefix(): with setup_execution("qwerty") as ctx: assert isinstance(ctx.file_access._default_remote, DiskPersistence) From 71d436ac456285a75b3ec462f34fc4524933c309 Mon Sep 17 00:00:00 2001 From: Niels Bantilan Date: Thu, 2 Mar 2023 17:51:26 -0500 Subject: [PATCH 09/22] update configuration docs, fix some docstrings (#1530) * update configuration docs, fix some docstrings Signed-off-by: Niels Bantilan * update copy Signed-off-by: Niels Bantilan * add config init command Signed-off-by: Niels Bantilan --------- Signed-off-by: Niels Bantilan --- docs/source/conf.py | 1 - docs/source/design/control_plane.rst | 2 + docs/source/extras.tensorflow.rst | 5 +- docs/source/pyflyte.rst | 24 ---- flytekit/configuration/__init__.py | 103 +++++++++++++----- flytekit/extras/tasks/shell.py | 3 +- plugins/setup.py | 4 +- .../unit/configuration/configs/images.config | 11 ++ 8 files changed, 99 insertions(+), 54 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 1745e56efe..205bcb8838 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -56,7 +56,6 @@ "sphinx.ext.graphviz", "sphinx-prompt", "sphinx_copybutton", - "sphinx_fontawesome", "sphinx_panels", "sphinxcontrib.yt", "sphinx_tags", diff --git a/docs/source/design/control_plane.rst b/docs/source/design/control_plane.rst index 5aa9d30e55..156bc46212 100644 --- a/docs/source/design/control_plane.rst +++ b/docs/source/design/control_plane.rst @@ -88,6 +88,8 @@ The ``for_endpoint`` method also accepts: * ``data_config``: can be used to configure how data is downloaded or uploaded to a specific blob storage like S3, GCS, etc. * ``config_file``: the path to the configuration file to use. +.. _general_initialization: + Generalized Initialization ========================== diff --git a/docs/source/extras.tensorflow.rst b/docs/source/extras.tensorflow.rst index 699dd44da0..b5984c0dc9 100644 --- a/docs/source/extras.tensorflow.rst +++ b/docs/source/extras.tensorflow.rst @@ -1,6 +1,7 @@ -############ +############### TensorFlow Type -############ +############### + .. automodule:: flytekit.extras.tensorflow :no-members: :no-inherited-members: diff --git a/docs/source/pyflyte.rst b/docs/source/pyflyte.rst index 46221c6ca6..707977c316 100644 --- a/docs/source/pyflyte.rst +++ b/docs/source/pyflyte.rst @@ -5,27 +5,3 @@ Pyflyte CLI .. click:: flytekit.clis.sdk_in_container.pyflyte:main :prog: pyflyte :nested: full - -.. click:: flytekit.clis.sdk_in_container.init:init - :prog: pyflyte init - :nested: full - -.. click:: flytekit.clis.sdk_in_container.local_cache:local_cache - :prog: pyflyte local-cache - :nested: full - -.. click:: flytekit.clis.sdk_in_container.package:package - :prog: pyflyte package - :nested: full - -.. click:: flytekit.clis.sdk_in_container.register:register - :prog: pyflyte register - :nested: full - -.. click:: flytekit.clis.sdk_in_container.run:run - :prog: pyflyte run - :nested: none - -.. click:: flytekit.clis.sdk_in_container.serialize:serialize - :prog: pyflyte serialize - :nested: full diff --git a/flytekit/configuration/__init__.py b/flytekit/configuration/__init__.py index 77273cc81c..afed857a26 100644 --- a/flytekit/configuration/__init__.py +++ b/flytekit/configuration/__init__.py @@ -5,28 +5,72 @@ .. currentmodule:: flytekit.configuration -Flytekit Configuration Ecosystem --------------------------------- +Flytekit Configuration Sources +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Where can configuration come from? -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +There are multiple ways to configure flytekit settings: -- Command line arguments. This is the ideal location for settings to go. (See ``pyflyte package --help`` for example.) -- Environment variables. Users can specify these at compile time, but when your task is run, Flyte Propeller will also set configuration to ensure correct interaction with the platform. -- A config file - an INI style configuration file. By default, flytekit will look for a file in two places - 1. First, a file named ``flytekit.config`` in the Python interpreter's starting directory - 2. A file in ``~/.flyte/config`` in the home directory as detected by Python. +**Command Line Arguments**: This is the recommended way of setting configuration values for many cases. +For example, see `pyflyte package `_ command. + +**Python Config Object**: A :py:class:`~flytekit.configuration.Config` object can by used directly, e.g. when +initializing a :py:class:`~flytefit.remote.remote.FlyteRemote` object. See :doc:`here ` for examples on +how to specify a ``Config`` object. + +**Environment Variables**: Users can specify these at compile time, but when your task is run, Flyte Propeller will +also set configuration to ensure correct interaction with the platform. The environment variables must be specified +with the format ``FLYTE_{SECTION}_{OPTION}``, all in upper case. For example, to specify the +:py:class:`PlatformConfig.endpoint ` setting, the environment variable would +be ``FLYTE_PLATFORM_URL``. + +.. note:: + + Environment variables won't work for image configuration, which need to be specified with the + `pyflyte package --image ... `_ option or in a configuration + file. + +**YAML Format Configuration File**: A configuration file that contains settings for both +`flytectl `__ and ``flytekit``. This is the recommended configuration +file format. Invoke the :ref:`flytectl config init ` command to create a boilerplate +``~/.flyte/config.yaml`` file, and ``flytectl --help`` to learn about all of the configuration yaml options. + +.. dropdown:: See example ``config.yaml`` file + :title: text-muted + :animate: fade-in-slide-down + + .. literalinclude:: ../../tests/flytekit/unit/configuration/configs/sample.yaml + :language: yaml + :caption: config.yaml + +**INI Format Configuration File**: A configuration file for ``flytekit``. By default, ``flytekit`` will look for a +file in two places: + +1. First, a file named ``flytekit.config`` in the Python interpreter's working directory. +2. A file in ``~/.flyte/config`` in the home directory as detected by Python. + +.. dropdown:: See example ``flytekit.config`` file + :title: text-muted + :animate: fade-in-slide-down + + .. literalinclude:: ../../tests/flytekit/unit/configuration/configs/images.config + :language: ini + :caption: flytekit.config + +.. warning:: + + The INI format configuration is considered a legacy configuration format. We recommend using the yaml format + instead if you're using a configuration file. How is configuration used? ^^^^^^^^^^^^^^^^^^^^^^^^^^ Configuration usage can roughly be bucketed into the following areas, -- Compile-time settings - things like the default image, where to look for Flyte code, etc. -- Platform settings - Where to find the Flyte backend (Admin DNS, whether to use SSL) -- Run time (registration) settings - these are things like the K8s service account to use, a specific S3/GCS bucket to write off-loaded data (dataframes and files) to, notifications, labels & annotations, etc. -- Data access settings - Is there a custom S3 endpoint in use? Backoff/retry behavior for accessing S3/GCS, key and password, etc. -- Other settings - Statsd configuration, which is a run-time applicable setting but is not necessarily relevant to the Flyte platform. +- **Compile-time settings**: these are settings like the default image and named images, where to look for Flyte code, etc. +- **Platform settings**: Where to find the Flyte backend (Admin DNS, whether to use SSL) +- **Registration Run-time settings**: these are things like the K8s service account to use, a specific S3/GCS bucket to write off-loaded data (dataframes and files) to, notifications, labels & annotations, etc. +- **Data access settings**: Is there a custom S3 endpoint in use? Backoff/retry behavior for accessing S3/GCS, key and password, etc. +- **Other settings** - Statsd configuration, which is a run-time applicable setting but is not necessarily relevant to the Flyte platform. Configuration Objects --------------------- @@ -42,8 +86,15 @@ .. _configuration-compile-time-settings: -Compilation (Serialization) Time Settings -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Serialization Time Settings +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +These are serialization/compile-time settings that are used when using commands like +`pyflyte package `_ or `pyflyte register `_. These +configuration settings are typically passed in as flags to the above CLI commands. + +The image configurations are typically either passed in via an `--image `_ flag, +or can be specified in the ``yaml`` or ``ini`` configuration files (see examples above). .. autosummary:: :template: custom.rst @@ -60,6 +111,10 @@ Execution Time Settings ^^^^^^^^^^^^^^^^^^^^^^^ +Users typically shouldn't be concerned with these configurations, as they are typically set by FlytePropeller or +FlyteAdmin. The configurations below are useful for authenticating to a Flyte backend, configuring data access +credentials, secrets, and statsd metrics. + .. autosummary:: :template: custom.rst :toctree: generated/ @@ -71,7 +126,6 @@ ~S3Config ~GCSConfig ~DataConfig - ~Config """ from __future__ import annotations @@ -190,10 +244,9 @@ def find_image(self, name) -> Optional[Image]: def validate_image(_: typing.Any, param: str, values: tuple) -> ImageConfig: """ Validates the image to match the standard format. Also validates that only one default image - is provided. a default image, is one that is specified as - default=img or just img. All other images should be provided with a name, in the format - name=img - This method can be used with the CLI + is provided. a default image, is one that is specified as ``default=`` or just ````. All + other images should be provided with a name, in the format ``name=`` This method can be used with the + CLI :param _: click argument, ignored here. :param param: the click argument, here should be "image" @@ -266,7 +319,8 @@ def from_images(cls, default_image: str, m: typing.Optional[typing.Dict[str, str { "spark": "ghcr.io/flyteorg/myspark:...", "other": "...", - }) + } + ) :return: """ @@ -557,7 +611,7 @@ def auto(cls, config_file: typing.Union[str, ConfigFile, None] = None) -> Config @classmethod def for_sandbox(cls) -> Config: """ - Constructs a new Config object specifically to connect to :std:ref:`deploy-sandbox-local`. + Constructs a new Config object specifically to connect to :std:ref:`deployment-deployment-sandbox`. If you are using a hosted Sandbox like environment, then you may need to use port-forward or ingress urls :return: Config """ @@ -619,6 +673,7 @@ class FastSerializationSettings(object): distribution_location: Optional[str] = None +# TODO: ImageConfig, python_interpreter, venv_root, fast_serialization_settings.destination_dir should be combined. @dataclass_json @dataclass() class SerializationSettings(object): @@ -626,8 +681,6 @@ class SerializationSettings(object): These settings are provided while serializing a workflow and task, before registration. This is required to get runtime information at serialization time, as well as some defaults. - TODO: ImageConfig, python_interpreter, venv_root, fast_serialization_settings.destination_dir should be combined. - Attributes: project (str): The project (if any) with which to register entities under. domain (str): The domain (if any) with which to register entities under. diff --git a/flytekit/extras/tasks/shell.py b/flytekit/extras/tasks/shell.py index be7cda0a17..12ef36af3e 100644 --- a/flytekit/extras/tasks/shell.py +++ b/flytekit/extras/tasks/shell.py @@ -120,7 +120,8 @@ def __init__( task_config: T Configuration for the task, can be either a Pod (or coming soon, BatchJob) config inputs: A Dictionary of input names to types output_locs: A list of :py:class:`OutputLocations` - **kwargs: Other arguments that can be passed to :ref:class:`PythonInstanceTask` + **kwargs: Other arguments that can be passed to + :py:class:`~flytekit.core.python_function_task.PythonInstanceTask` """ if script and script_file: raise ValueError("Only either of script or script_file can be provided") diff --git a/plugins/setup.py b/plugins/setup.py index cef41e0a0b..843e2528bb 100644 --- a/plugins/setup.py +++ b/plugins/setup.py @@ -7,6 +7,8 @@ PACKAGE_NAME = "flytekitplugins-parent" +__version__ = "0.0.0+develop" + # Please maintain an alphabetical order in the following list SOURCES = { "flytekitplugins-athena": "flytekit-aws-athena", @@ -74,7 +76,7 @@ def run(self): setup( name=PACKAGE_NAME, - version="0.1.0", + version=__version__, author="flyteorg", author_email="admin@flyte.org", description="This is a microlib package to help install all the plugins", diff --git a/tests/flytekit/unit/configuration/configs/images.config b/tests/flytekit/unit/configuration/configs/images.config index ea6f31212e..3d0565e7d9 100644 --- a/tests/flytekit/unit/configuration/configs/images.config +++ b/tests/flytekit/unit/configuration/configs/images.config @@ -1,3 +1,14 @@ +[sdk] +workflow_packages=module1,module2 + +[platform] +url=flyte.mycorp.io +insecure=true + +[auth] +kubernetes_service_account=demo +raw_output_data_prefix=s3://my-bucket + [images] xyz=docker.io/xyz:latest abc=docker.io/abc From 8ccb5dde044f9dce3e216a8cea58dd464fb3ab6b Mon Sep 17 00:00:00 2001 From: Eduardo Apolinario <653394+eapolinario@users.noreply.github.com> Date: Fri, 3 Mar 2023 11:12:56 -0800 Subject: [PATCH 10/22] Revert "Make flytekit comply with PEP-561 (#1516)" (#1532) This reverts commit b3ad1584b210f145030b121ecbe953bb018eca4c. Signed-off-by: Eduardo Apolinario Co-authored-by: Eduardo Apolinario --- flytekit/core/map_task.py | 7 +------ flytekit/py.typed | 0 plugins/setup.py | 1 - 3 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 flytekit/py.typed diff --git a/flytekit/core/map_task.py b/flytekit/core/map_task.py index f888638965..48d0f0b335 100644 --- a/flytekit/core/map_task.py +++ b/flytekit/core/map_task.py @@ -221,12 +221,7 @@ def _raw_execute(self, **kwargs) -> Any: return outputs -def map_task( - task_function: typing.Union[typing.Callable, PythonFunctionTask], - concurrency: int = 0, - min_success_ratio: float = 1.0, - **kwargs, -): +def map_task(task_function: PythonFunctionTask, concurrency: int = 0, min_success_ratio: float = 1.0, **kwargs): """ Use a map task for parallelizable tasks that run across a list of an input type. A map task can be composed of any individual :py:class:`flytekit.PythonFunctionTask`. diff --git a/flytekit/py.typed b/flytekit/py.typed deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/plugins/setup.py b/plugins/setup.py index 843e2528bb..1b47cc58e0 100644 --- a/plugins/setup.py +++ b/plugins/setup.py @@ -84,5 +84,4 @@ def run(self): classifiers=["Private :: Do Not Upload to pypi server"], install_requires=[], cmdclass={"install": InstallCmd, "develop": DevelopCmd}, - package_data={"flytekit": ["py.typed"]}, ) From d4bf2340a47d20561e6b12a8113d88e1c2461f65 Mon Sep 17 00:00:00 2001 From: Kevin Su Date: Tue, 7 Mar 2023 07:53:45 +0800 Subject: [PATCH 11/22] Failed to initialize FlyteInvalidInputException (#1534) Signed-off-by: Kevin Su --- flytekit/exceptions/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flytekit/exceptions/user.py b/flytekit/exceptions/user.py index 59b58a0a5a..0bb5e6c03b 100644 --- a/flytekit/exceptions/user.py +++ b/flytekit/exceptions/user.py @@ -93,4 +93,4 @@ class FlyteInvalidInputException(FlyteUserException): def __init__(self, request: typing.Any): self.request = request - super(self).__init__() + super().__init__() From be24c52f3fb8f7949db172f490cca95fa0f0e413 Mon Sep 17 00:00:00 2001 From: Yee Hing Tong Date: Tue, 7 Mar 2023 14:52:17 -0800 Subject: [PATCH 12/22] Pin fsspec to <=2023.1 (#1537) * pin fsspec Signed-off-by: Yee Hing Tong * lint Signed-off-by: Kevin Su --------- Signed-off-by: Yee Hing Tong Signed-off-by: Kevin Su Co-authored-by: Kevin Su --- flytekit/core/type_engine.py | 2 +- plugins/flytekit-data-fsspec/setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flytekit/core/type_engine.py b/flytekit/core/type_engine.py index 3d9b64a2bf..f21e93a774 100644 --- a/flytekit/core/type_engine.py +++ b/flytekit/core/type_engine.py @@ -289,7 +289,7 @@ def assert_type(self, expected_type: Type[DataClassJsonMixin], v: T): for f in dataclasses.fields(expected_type): expected_fields_dict[f.name] = f.type - for f in dataclasses.fields(type(v)): + for f in dataclasses.fields(type(v)): # type: ignore original_type = f.type expected_type = expected_fields_dict[f.name] diff --git a/plugins/flytekit-data-fsspec/setup.py b/plugins/flytekit-data-fsspec/setup.py index f622ea3d48..a7920d1eeb 100644 --- a/plugins/flytekit-data-fsspec/setup.py +++ b/plugins/flytekit-data-fsspec/setup.py @@ -4,7 +4,7 @@ microlib_name = f"flytekitplugins-data-{PLUGIN_NAME}" -plugin_requires = ["flytekit>=1.3.0b2,<2.0.0", "fsspec>=2021.7.0", "botocore>=1.7.48", "pandas>=1.2.0"] +plugin_requires = ["flytekit>=1.3.0b2,<2.0.0", "fsspec<=2023.1", "botocore>=1.7.48", "pandas>=1.2.0"] __version__ = "0.0.0+develop" From 7c3c255655d25e70988028d8773b034c7519d6e8 Mon Sep 17 00:00:00 2001 From: Felix Ruess Date: Fri, 10 Mar 2023 00:37:13 +0100 Subject: [PATCH 13/22] Prefer FLYTE_ prefixed AWS creds env vars (#1523) Signed-off-by: Felix Ruess --- .../flytekitplugins/fsspec/persist.py | 13 ++--- .../tests/test_persist.py | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/persist.py b/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/persist.py index 4fe1b22baa..b890b3cc6c 100644 --- a/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/persist.py +++ b/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/persist.py @@ -8,9 +8,6 @@ from flytekit.extend import DataPersistence, DataPersistencePlugins from flytekit.loggers import logger -S3_ACCESS_KEY_ID_ENV_NAME = "AWS_ACCESS_KEY_ID" -S3_SECRET_ACCESS_KEY_ENV_NAME = "AWS_SECRET_ACCESS_KEY" - # Refer to https://github.com/fsspec/s3fs/blob/50bafe4d8766c3b2a4e1fc09669cf02fb2d71454/s3fs/core.py#L198 # for key and secret _FSSPEC_S3_KEY_ID = "key" @@ -19,13 +16,11 @@ def s3_setup_args(s3_cfg: S3Config): kwargs = {} - if S3_ACCESS_KEY_ID_ENV_NAME not in os.environ: - if s3_cfg.access_key_id: - kwargs[_FSSPEC_S3_KEY_ID] = s3_cfg.access_key_id + if s3_cfg.access_key_id: + kwargs[_FSSPEC_S3_KEY_ID] = s3_cfg.access_key_id - if S3_SECRET_ACCESS_KEY_ENV_NAME not in os.environ: - if s3_cfg.secret_access_key: - kwargs[_FSSPEC_S3_SECRET] = s3_cfg.secret_access_key + if s3_cfg.secret_access_key: + kwargs[_FSSPEC_S3_SECRET] = s3_cfg.secret_access_key # S3fs takes this as a special arg if s3_cfg.endpoint is not None: diff --git a/plugins/flytekit-data-fsspec/tests/test_persist.py b/plugins/flytekit-data-fsspec/tests/test_persist.py index 691201925b..8e87c9c5eb 100644 --- a/plugins/flytekit-data-fsspec/tests/test_persist.py +++ b/plugins/flytekit-data-fsspec/tests/test_persist.py @@ -2,6 +2,7 @@ import pathlib import tempfile +import mock from flytekitplugins.fsspec.persist import FSSpecPersistence, s3_setup_args from fsspec.implementations.local import LocalFileSystem @@ -19,6 +20,54 @@ def test_s3_setup_args(): assert kwargs == {"key": "access"} +@mock.patch.dict(os.environ, {}, clear=True) +def test_s3_setup_args_env_empty(): + kwargs = s3_setup_args(S3Config.auto()) + assert kwargs == {} + + +@mock.patch.dict( + os.environ, + { + "AWS_ACCESS_KEY_ID": "ignore-user", + "AWS_SECRET_ACCESS_KEY": "ignore-secret", + "FLYTE_AWS_ACCESS_KEY_ID": "flyte", + "FLYTE_AWS_SECRET_ACCESS_KEY": "flyte-secret", + }, + clear=True, +) +def test_s3_setup_args_env_both(): + kwargs = s3_setup_args(S3Config.auto()) + assert kwargs == {"key": "flyte", "secret": "flyte-secret"} + + +@mock.patch.dict( + os.environ, + { + "FLYTE_AWS_ACCESS_KEY_ID": "flyte", + "FLYTE_AWS_SECRET_ACCESS_KEY": "flyte-secret", + }, + clear=True, +) +def test_s3_setup_args_env_flyte(): + kwargs = s3_setup_args(S3Config.auto()) + assert kwargs == {"key": "flyte", "secret": "flyte-secret"} + + +@mock.patch.dict( + os.environ, + { + "AWS_ACCESS_KEY_ID": "ignore-user", + "AWS_SECRET_ACCESS_KEY": "ignore-secret", + }, + clear=True, +) +def test_s3_setup_args_env_aws(): + kwargs = s3_setup_args(S3Config.auto()) + # not explicitly in kwargs, since fsspec/boto3 will use these env vars by default + assert kwargs == {} + + def test_get_protocol(): assert FSSpecPersistence.get_protocol("s3://abc") == "s3" assert FSSpecPersistence.get_protocol("/abc") == "file" From aee20eac7d0d65874b5f3c04dc1a5ddfed591063 Mon Sep 17 00:00:00 2001 From: Honnix Date: Fri, 10 Mar 2023 00:40:03 +0100 Subject: [PATCH 14/22] Filter out remote entity when generating pb (#1545) * Filter out remote entity when generating pb Signed-off-by: Hongxin Liang * Unit test it Signed-off-by: Hongxin Liang * Fix linting Signed-off-by: Hongxin Liang --------- Signed-off-by: Hongxin Liang --- flytekit/tools/serialize_helpers.py | 3 ++- .../flytekit/unit/cli/pyflyte/test_package.py | 20 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/flytekit/tools/serialize_helpers.py b/flytekit/tools/serialize_helpers.py index f7937443be..69af2b96b4 100644 --- a/flytekit/tools/serialize_helpers.py +++ b/flytekit/tools/serialize_helpers.py @@ -17,6 +17,7 @@ from flytekit.models.admin.workflow import WorkflowSpec from flytekit.models.core import identifier as _identifier from flytekit.models.task import TaskSpec +from flytekit.remote.remote_callable import RemoteEntity from flytekit.tools.translator import FlyteControlPlaneEntity, Options, get_serializable @@ -40,7 +41,7 @@ def _should_register_with_admin(entity) -> bool: """ return isinstance( entity, (task_models.TaskSpec, _launch_plan_models.LaunchPlan, admin_workflow_models.WorkflowSpec) - ) + ) and not isinstance(entity, RemoteEntity) def _find_duplicate_tasks(tasks: typing.List[task_models.TaskSpec]) -> typing.Set[task_models.TaskSpec]: diff --git a/tests/flytekit/unit/cli/pyflyte/test_package.py b/tests/flytekit/unit/cli/pyflyte/test_package.py index 40d63021f2..e3ccb1d803 100644 --- a/tests/flytekit/unit/cli/pyflyte/test_package.py +++ b/tests/flytekit/unit/cli/pyflyte/test_package.py @@ -7,12 +7,17 @@ import flytekit import flytekit.configuration import flytekit.tools.serialize_helpers +from flytekit import TaskMetadata from flytekit.clis.sdk_in_container import pyflyte from flytekit.core import context_manager from flytekit.exceptions.user import FlyteValidationException from flytekit.models.admin.workflow import WorkflowSpec +from flytekit.models.core.identifier import Identifier, ResourceType from flytekit.models.launch_plan import LaunchPlan from flytekit.models.task import TaskSpec +from flytekit.remote import FlyteTask +from flytekit.remote.interface import TypedInterface +from flytekit.remote.remote_callable import RemoteEntity sample_file_contents = """ from flytekit import task, workflow @@ -52,12 +57,25 @@ def test_get_registrable_entities(): ), ) ) - context_manager.FlyteEntities.entities = [foo, wf, "str"] + context_manager.FlyteEntities.entities = [ + foo, + wf, + "str", + FlyteTask( + id=Identifier(ResourceType.TASK, "p", "d", "n", "v"), + type="t", + metadata=TaskMetadata().to_taskmetadata_model(), + interface=TypedInterface(inputs={}, outputs={}), + custom=None, + ), + ] entities = flytekit.tools.serialize_helpers.get_registrable_entities(ctx) assert entities assert len(entities) == 3 for e in entities: + if isinstance(e, RemoteEntity): + assert False, "found unexpected remote entity" if isinstance(e, WorkflowSpec) or isinstance(e, TaskSpec) or isinstance(e, LaunchPlan): continue assert False, f"found unknown entity {type(e)}" From e90ee2576c87298826b1ba702cf7569f43f737ba Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Thu, 9 Mar 2023 23:54:58 -0500 Subject: [PATCH 15/22] Pyflyte docs formatting (#1538) --- flytekit/clis/sdk_in_container/backfill.py | 8 +++---- flytekit/clis/sdk_in_container/package.py | 26 ++++++++++----------- flytekit/clis/sdk_in_container/register.py | 22 ++++++++--------- flytekit/clis/sdk_in_container/run.py | 12 +++++----- flytekit/clis/sdk_in_container/serialize.py | 14 +++++------ 5 files changed, 41 insertions(+), 41 deletions(-) diff --git a/flytekit/clis/sdk_in_container/backfill.py b/flytekit/clis/sdk_in_container/backfill.py index 80a799b600..234b03499f 100644 --- a/flytekit/clis/sdk_in_container/backfill.py +++ b/flytekit/clis/sdk_in_container/backfill.py @@ -10,8 +10,8 @@ The backfill command generates and registers a new workflow based on the input launchplan to run an automated backfill. The workflow can be managed using the Flyte UI and can be canceled, relaunched, and recovered. -- launchplan refers to the name of the launchplan -- launchplan_version is optional and should be a valid version for a launchplan version. +- ``launchplan`` refers to the name of the Launchplan +- ``launchplan_version`` is optional and should be a valid version for a Launchplan version. """ @@ -90,8 +90,8 @@ def resolve_backfill_window( is_flag=True, default=False, show_default=True, - help="All backfill steps can be run in parallel (limited by max-parallelism), if using --parallel." - " Else all steps will be run sequentially [--serial].", + help="All backfill steps can be run in parallel (limited by max-parallelism), if using ``--parallel.``" + " Else all steps will be run sequentially [``--serial``].", ) @click.option( "--execute/--do-not-execute", diff --git a/flytekit/clis/sdk_in_container/package.py b/flytekit/clis/sdk_in_container/package.py index 1a849d0681..e457b3d649 100644 --- a/flytekit/clis/sdk_in_container/package.py +++ b/flytekit/clis/sdk_in_container/package.py @@ -22,11 +22,11 @@ multiple=True, type=click.UNPROCESSED, callback=ImageConfig.validate_image, - help="A fully qualified tag for an docker image, e.g. somedocker.com/myimage:someversion123. This is a " - "multi-option and can be of the form --image xyz.io/docker:latest" - " --image my_image=xyz.io/docker2:latest. Note, the `name=image_uri`. The name is optional, if not" - "provided the image will be used as the default image. All the names have to be unique, and thus" - "there can only be one --image option with no-name.", + help="A fully qualified tag for an docker image, for example ``somedocker.com/myimage:someversion123``. This is a " + "multi-option and can be of the form ``--image xyz.io/docker:latest" + " --image my_image=xyz.io/docker2:latest``. Note, the ``name=image_uri``. The name is optional, if not " + "provided the image will be used as the default image. All the names have to be unique, and thus " + "there can only be one ``--image`` option with no name.", ) @click.option( "-s", @@ -34,7 +34,7 @@ required=False, type=click.Path(exists=True, file_okay=False, readable=True, resolve_path=True, allow_dash=True), default=".", - help="local filesystem path to the root of the package.", + help="Local filesystem path to the root of the package.", ) @click.option( "-o", @@ -42,14 +42,14 @@ required=False, type=click.Path(dir_okay=False, writable=True, resolve_path=True, allow_dash=True), default="flyte-package.tgz", - help="filesystem path to the source of the python package (from where the pkgs will start).", + help="Filesystem path to the source of the Python package (from where the pkgs will start).", ) @click.option( "--fast", is_flag=True, default=False, required=False, - help="This flag enables fast packaging, that allows `no container build` deploys of flyte workflows and tasks." + help="This flag enables fast packaging, that allows `no container build` deploys of flyte workflows and tasks. " "Note this needs additional configuration, refer to the docs.", ) @click.option( @@ -59,7 +59,7 @@ default=False, required=False, help="This flag enables overriding existing output files. If not specified, package will exit with an error," - " in case an output file already exists.", + " when an output file already exists.", ) @click.option( "-p", @@ -75,7 +75,7 @@ required=False, type=str, default="/root", - help="Filesystem path to where the code is copied into within the Dockerfile. look for `COPY . /root` like command.", + help="Filesystem path to where the code is copied into within the Dockerfile. look for ``COPY . /root`` like command.", ) @click.option( "--deref-symlinks", @@ -90,9 +90,9 @@ def package( """ This command produces a Flyte backend registrable package of all entities in Flyte. For tasks, one pb file is produced for each task, representing one TaskTemplate object. - For workflows, one pb file is produced for each workflow, representing a WorkflowClosure object. The closure - object contains the WorkflowTemplate, along with the relevant tasks for that workflow. - This serialization step will set the name of the tasks to the fully qualified name of the task function. + For workflows, one pb file is produced for each workflow, representing a WorkflowClosure object. The closure + object contains the WorkflowTemplate, along with the relevant tasks for that workflow. + This serialization step will set the name of the tasks to the fully qualified name of the task function. """ if os.path.exists(output) and not force: raise click.BadParameter(click.style(f"Output file {output} already exists, specify -f to override.", fg="red")) diff --git a/flytekit/clis/sdk_in_container/register.py b/flytekit/clis/sdk_in_container/register.py index 2a167e9d0e..30c955e351 100644 --- a/flytekit/clis/sdk_in_container/register.py +++ b/flytekit/clis/sdk_in_container/register.py @@ -12,17 +12,17 @@ from flytekit.tools import repo _register_help = """ -This command is similar to package but instead of producing a zip file, all your Flyte entities are compiled, -and then sent to the backend specified by your config file. Think of this as combining the pyflyte package -and the flytectl register step in one command. This is why you see switches you'd normally use with flytectl +This command is similar to ``package`` but instead of producing a zip file, all your Flyte entities are compiled, +and then sent to the backend specified by your config file. Think of this as combining the ``pyflyte package`` +and the ``flytectl register`` steps in one command. This is why you see switches you'd normally use with flytectl like service account here. Note: This command runs "fast" register by default. -This means that a zip is created from the detected root of the packages given, and uploaded. Just like with -pyflyte run, tasks registered from this command will download and unzip that code package before running. +This means that a zip is created from the detected root of the packages given and uploaded. Just like with +``pyflyte run``, tasks registered from this command will download and unzip that code package before running. Note: This command only works on regular Python packages, not namespace packages. When determining - the root of your project, it finds the first folder that does not have an __init__.py file. +the root of your project, it finds the first folder that does not have a ``__init__.py`` file. """ @@ -52,11 +52,11 @@ type=click.UNPROCESSED, callback=ImageConfig.validate_image, default=[DefaultImages.default_image()], - help="A fully qualified tag for an docker image, e.g. somedocker.com/myimage:someversion123. This is a " - "multi-option and can be of the form --image xyz.io/docker:latest " - "--image my_image=xyz.io/docker2:latest. Note, the `name=image_uri`. The name is optional, if not " + help="A fully qualified tag for an docker image, for example ``somedocker.com/myimage:someversion123``. This is a " + "multi-option and can be of the form ``--image xyz.io/docker:latest" + " --image my_image=xyz.io/docker2:latest``. Note, the ``name=image_uri``. The name is optional, if not " "provided the image will be used as the default image. All the names have to be unique, and thus " - "there can only be one --image option with no name.", + "there can only be one ``--image`` option with no name.", ) @click.option( "-o", @@ -105,7 +105,7 @@ "--non-fast", default=False, is_flag=True, - help="Enables to skip zipping and uploading the package", + help="Skip zipping and uploading the package", ) @click.option( "--dry-run", diff --git a/flytekit/clis/sdk_in_container/run.py b/flytekit/clis/sdk_in_container/run.py index 793c15c911..136831c0bc 100644 --- a/flytekit/clis/sdk_in_container/run.py +++ b/flytekit/clis/sdk_in_container/run.py @@ -447,14 +447,14 @@ def get_workflow_command_base_params() -> typing.List[click.Option]: required=False, is_flag=True, default=False, - help="Whether wait for the execution to finish", + help="Whether to wait for the execution to finish", ), click.Option( param_decls=["--dump-snippet", "dump_snippet"], required=False, is_flag=True, default=False, - help="Whether dump a code snippet instructing how to load the workflow execution using flyteremote", + help="Whether to dump a code snippet instructing how to load the workflow execution using flyteremote", ), ] @@ -673,12 +673,12 @@ def get_command(self, ctx, filename): _run_help = """ -This command can execute either a workflow or a task from the commandline, for fully self-contained scripts. -Tasks and workflows cannot be imported from other files currently. Please use `pyflyte package` or -`pyflyte register` to handle those and then launch from the Flyte UI or `flytectl` +This command can execute either a workflow or a task from the command line, for fully self-contained scripts. +Tasks and workflows cannot be imported from other files currently. Please use ``pyflyte package`` or +``pyflyte register`` to handle those and then launch from the Flyte UI or ``flytectl``. Note: This command only works on regular Python packages, not namespace packages. When determining - the root of your project, it finds the first folder that does not have an __init__.py file. +the root of your project, it finds the first folder that does not have an ``__init__.py`` file. """ run = RunCommand( diff --git a/flytekit/clis/sdk_in_container/serialize.py b/flytekit/clis/sdk_in_container/serialize.py index 33c0b47940..eef055ad3c 100644 --- a/flytekit/clis/sdk_in_container/serialize.py +++ b/flytekit/clis/sdk_in_container/serialize.py @@ -74,21 +74,21 @@ def serialize_all( "--image", required=False, default=lambda: os.environ.get("FLYTE_INTERNAL_IMAGE", ""), - help="Text tag: e.g. somedocker.com/myimage:someversion123", + help="Text tag, for example ``somedocker.com/myimage:someversion123``", ) @click.option( "--local-source-root", required=False, default=lambda: os.getcwd(), - help="Root dir for python code containing workflow definitions to operate on when not the current working directory" - "Optional when running `pyflyte serialize` in out of container mode and your code lies outside of your working directory", + help="Root dir for Python code containing workflow definitions to operate on when not the current working directory. " + "Optional when running ``pyflyte serialize`` in out-of-container-mode and your code lies outside of your working directory.", ) @click.option( "--in-container-config-path", required=False, help="This is where the configuration for your task lives inside the container. " "The reason it needs to be a separate option is because this pyflyte utility cannot know where the Dockerfile " - "writes the config file to. Required for running `pyflyte serialize` in out of container mode", + "writes the config file to. Required for running ``pyflyte serialize`` in out-of-container-mode", ) @click.option( "--in-container-virtualenv-root", @@ -103,9 +103,9 @@ def serialize(ctx, image, local_source_root, in_container_config_path, in_contai """ This command produces protobufs for tasks and templates. For tasks, one pb file is produced for each task, representing one TaskTemplate object. - For workflows, one pb file is produced for each workflow, representing a WorkflowClosure object. The closure - object contains the WorkflowTemplate, along with the relevant tasks for that workflow. In lieu of Admin, - this serialization step will set the URN of the tasks to the fully qualified name of the task function. + For workflows, one pb file is produced for each workflow, representing a WorkflowClosure object. The closure + object contains the WorkflowTemplate, along with the relevant tasks for that workflow. In lieu of Admin, + this serialization step will set the URN of the tasks to the fully qualified name of the task function. """ ctx.obj[CTX_IMAGE] = image ctx.obj[CTX_LOCAL_SRC_ROOT] = local_source_root From 28da983bba36e243bc671f9ba1aa53a0791efd62 Mon Sep 17 00:00:00 2001 From: Yee Hing Tong Date: Thu, 9 Mar 2023 22:19:41 -0800 Subject: [PATCH 16/22] Data subsystem (#1526) Signed-off-by: Ketan Umare Signed-off-by: Yee Hing Tong --- Dockerfile.dev | 38 ++ doc-requirements.txt | 2 +- flytekit/__init__.py | 2 - flytekit/configuration/__init__.py | 2 +- flytekit/core/checkpointer.py | 4 +- flytekit/core/context_manager.py | 6 +- flytekit/core/data_persistence.py | 480 ++++++------------ flytekit/deck/deck.py | 7 +- flytekit/extend/__init__.py | 4 +- flytekit/extras/persistence/__init__.py | 26 - flytekit/extras/persistence/gcs_gsutil.py | 115 ----- flytekit/extras/persistence/http.py | 84 --- flytekit/extras/persistence/s3_awscli.py | 181 ------- flytekit/remote/remote.py | 52 +- flytekit/tools/script_mode.py | 24 - flytekit/types/structured/basic_dfs.py | 89 +++- .../types/structured/structured_dataset.py | 125 ++--- .../flytekitplugins/fsspec/__init__.py | 53 -- .../flytekitplugins/fsspec/arrow.py | 70 --- .../flytekitplugins/fsspec/pandas.py | 76 --- .../flytekitplugins/fsspec/persist.py | 144 ------ plugins/flytekit-data-fsspec/setup.py | 11 +- .../tests/test_basic_dfs.py | 44 -- .../tests/test_persist.py | 183 ------- .../tests/test_placeholder.py | 3 + .../tests/test_pyspark_transformers.py | 11 + setup.py | 4 + .../unit/bin/test_python_entrypoint.py | 34 +- tests/flytekit/unit/core/test_checkpoint.py | 12 +- tests/flytekit/unit/core/test_data.py | 215 ++++++++ .../unit/core/test_data_persistence.py | 11 +- .../unit/core/test_flyte_directory.py | 10 +- .../unit/core/test_structured_dataset.py | 17 +- .../core/test_structured_dataset_handlers.py | 2 +- tests/flytekit/unit/core/test_type_hints.py | 12 +- .../unit/core/tracker/test_arrow_data.py | 29 ++ .../unit/extras/persistence/__init__.py | 0 .../extras/persistence/test_gcs_gsutil.py | 35 -- .../unit/extras/persistence/test_http.py | 20 - .../unit/extras/persistence/test_s3_awscli.py | 80 --- tests/flytekit/unit/remote/test_remote.py | 8 - 41 files changed, 712 insertions(+), 1613 deletions(-) create mode 100644 Dockerfile.dev delete mode 100644 flytekit/extras/persistence/__init__.py delete mode 100644 flytekit/extras/persistence/gcs_gsutil.py delete mode 100644 flytekit/extras/persistence/http.py delete mode 100644 flytekit/extras/persistence/s3_awscli.py delete mode 100644 plugins/flytekit-data-fsspec/flytekitplugins/fsspec/arrow.py delete mode 100644 plugins/flytekit-data-fsspec/flytekitplugins/fsspec/pandas.py delete mode 100644 plugins/flytekit-data-fsspec/flytekitplugins/fsspec/persist.py delete mode 100644 plugins/flytekit-data-fsspec/tests/test_basic_dfs.py delete mode 100644 plugins/flytekit-data-fsspec/tests/test_persist.py create mode 100644 plugins/flytekit-data-fsspec/tests/test_placeholder.py create mode 100644 tests/flytekit/unit/core/test_data.py create mode 100644 tests/flytekit/unit/core/tracker/test_arrow_data.py delete mode 100644 tests/flytekit/unit/extras/persistence/__init__.py delete mode 100644 tests/flytekit/unit/extras/persistence/test_gcs_gsutil.py delete mode 100644 tests/flytekit/unit/extras/persistence/test_http.py delete mode 100644 tests/flytekit/unit/extras/persistence/test_s3_awscli.py diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000000..f6baf63896 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,38 @@ +# This Dockerfile is here to help with end-to-end testing +# From flytekit +# $ docker build -f Dockerfile.dev --build-arg PYTHON_VERSION=3.10 -t localhost:30000/flytekittest:someversion . +# $ docker push localhost:30000/flytekittest:someversion +# From your test user code +# $ pyflyte run --image localhost:30000/flytekittest:someversion + +ARG PYTHON_VERSION +FROM python:${PYTHON_VERSION}-slim-buster + +MAINTAINER Flyte Team +LABEL org.opencontainers.image.source https://github.com/flyteorg/flytekit + +WORKDIR /root +ENV PYTHONPATH /root + +ARG VERSION +ARG DOCKER_IMAGE + +RUN apt-get update && apt-get install build-essential vim -y + +COPY . /code/flytekit +WORKDIR /code/flytekit + +# Pod tasks should be exposed in the default image +RUN pip install -e . +RUN pip install -e plugins/flytekit-k8s-pod +RUN pip install -e plugins/flytekit-deck-standard +RUN pip install scikit-learn + +ENV PYTHONPATH "/code/flytekit:/code/flytekit/plugins/flytekit-k8s-pod:/code/flytekit/plugins/flytekit-deck-standard:" + +WORKDIR /root +RUN useradd -u 1000 flytekit +RUN chown flytekit: /root +USER flytekit + +ENV FLYTE_INTERNAL_IMAGE "$DOCKER_IMAGE" diff --git a/doc-requirements.txt b/doc-requirements.txt index 98a84f41c9..19f20af9fc 100644 --- a/doc-requirements.txt +++ b/doc-requirements.txt @@ -216,7 +216,7 @@ frozenlist==1.3.3 # via # aiosignal # ray -fsspec==2023.1.0 +fsspec==2023.3.0 # via # -r doc-requirements.in # dask diff --git a/flytekit/__init__.py b/flytekit/__init__.py index 0a992719d9..c2fc11816c 100644 --- a/flytekit/__init__.py +++ b/flytekit/__init__.py @@ -205,7 +205,6 @@ from flytekit.core.condition import conditional from flytekit.core.container_task import ContainerTask from flytekit.core.context_manager import ExecutionParameters, FlyteContext, FlyteContextManager -from flytekit.core.data_persistence import DataPersistence, DataPersistencePlugins from flytekit.core.dynamic_workflow_task import dynamic from flytekit.core.gate import approve, sleep, wait_for_input from flytekit.core.hash import HashMethod @@ -223,7 +222,6 @@ from flytekit.core.workflow import WorkflowFailurePolicy, reference_workflow, workflow from flytekit.deck import Deck from flytekit.extras import pytorch, sklearn, tensorflow -from flytekit.extras.persistence import GCSPersistence, HttpPersistence, S3Persistence from flytekit.loggers import logger from flytekit.models.common import Annotations, AuthRole, Labels from flytekit.models.core.execution import WorkflowExecutionPhase diff --git a/flytekit/configuration/__init__.py b/flytekit/configuration/__init__.py index afed857a26..6485f3a9d5 100644 --- a/flytekit/configuration/__init__.py +++ b/flytekit/configuration/__init__.py @@ -352,7 +352,7 @@ class PlatformConfig(object): This object contains the settings to talk to a Flyte backend (the DNS location of your Admin server basically). :param endpoint: DNS for Flyte backend - :param insecure: Whether to use SSL + :param insecure: Whether or not to use SSL :param insecure_skip_verify: Whether to skip SSL certificate verification :param console_endpoint: endpoint for console if different from Flyte backend :param command: This command is executed to return a token using an external process diff --git a/flytekit/core/checkpointer.py b/flytekit/core/checkpointer.py index c1eb933ec6..4b4cfd16f3 100644 --- a/flytekit/core/checkpointer.py +++ b/flytekit/core/checkpointer.py @@ -126,7 +126,7 @@ def save(self, cp: typing.Union[Path, str, io.BufferedReader]): fa.upload_directory(str(cp), self._checkpoint_dest) else: fname = cp.stem + cp.suffix - rpath = fa._default_remote.construct_path(False, False, self._checkpoint_dest, fname) + rpath = fa._default_remote.sep.join([str(self._checkpoint_dest), fname]) fa.upload(str(cp), rpath) return @@ -138,7 +138,7 @@ def save(self, cp: typing.Union[Path, str, io.BufferedReader]): with dest_cp.open("wb") as f: f.write(cp.read()) - rpath = fa._default_remote.construct_path(False, False, self._checkpoint_dest, self.TMP_DST_PATH) + rpath = fa._default_remote.sep.join([str(self._checkpoint_dest), self.TMP_DST_PATH]) fa.upload(str(dest_cp), rpath) def read(self) -> typing.Optional[bytes]: diff --git a/flytekit/core/context_manager.py b/flytekit/core/context_manager.py index fc8915e338..63914c13b2 100644 --- a/flytekit/core/context_manager.py +++ b/flytekit/core/context_manager.py @@ -84,7 +84,7 @@ class Builder(object): decks: List[Deck] raw_output_prefix: Optional[str] = None execution_id: typing.Optional[_identifier.WorkflowExecutionIdentifier] = None - working_dir: typing.Optional[utils.AutoDeletingTempDir] = None + working_dir: typing.Optional[str] = None checkpoint: typing.Optional[Checkpoint] = None execution_date: typing.Optional[datetime] = None logging: Optional[_logging.Logger] = None @@ -202,12 +202,10 @@ def raw_output_prefix(self) -> str: return self._raw_output_prefix @property - def working_directory(self) -> utils.AutoDeletingTempDir: + def working_directory(self) -> str: """ A handle to a special working directory for easily producing temporary files. - TODO: Usage examples - TODO: This does not always return a AutoDeletingTempDir """ return self._working_directory diff --git a/flytekit/core/data_persistence.py b/flytekit/core/data_persistence.py index d48ce45ce1..8fb73ebd8c 100644 --- a/flytekit/core/data_persistence.py +++ b/flytekit/core/data_persistence.py @@ -21,296 +21,46 @@ UnsupportedPersistenceOp """ - import os import pathlib -import re -import shutil -import sys import tempfile import typing -from abc import abstractmethod -from shutil import copyfile -from typing import Dict, Union +from typing import Union, cast from uuid import UUID +import fsspec +from fsspec.utils import get_protocol + +from flytekit import configuration from flytekit.configuration import DataConfig from flytekit.core.utils import PerformanceTimer -from flytekit.exceptions.user import FlyteAssertion, FlyteValueException +from flytekit.exceptions.user import FlyteAssertion from flytekit.interfaces.random import random from flytekit.loggers import logger -CURRENT_PYTHON = sys.version_info[:2] -THREE_SEVEN = (3, 7) - - -class UnsupportedPersistenceOp(Exception): - """ - This exception is raised for all methods when a method is not supported by the data persistence layer - """ - - def __init__(self, message: str): - super(UnsupportedPersistenceOp, self).__init__(message) - - -class DataPersistence(object): - """ - Base abstract type for all DataPersistence operations. This can be extended using the flytekitplugins architecture - """ - - def __init__(self, name: str = "", default_prefix: typing.Optional[str] = None, **kwargs): - self._name = name - self._default_prefix = default_prefix - - @property - def name(self) -> str: - return self._name - - @property - def default_prefix(self) -> typing.Optional[str]: - return self._default_prefix - - def listdir(self, path: str, recursive: bool = False) -> typing.Generator[str, None, None]: - """ - Returns true if the given path exists, else false - """ - raise UnsupportedPersistenceOp(f"Listing a directory is not supported by the persistence plugin {self.name}") - - @abstractmethod - def exists(self, path: str) -> bool: - """ - Returns true if the given path exists, else false - """ - pass - - @abstractmethod - def get(self, from_path: str, to_path: str, recursive: bool = False): - """ - Retrieves data from from_path and writes to the given to_path (to_path is locally accessible) - """ - pass - - @abstractmethod - def put(self, from_path: str, to_path: str, recursive: bool = False): - """ - Stores data from from_path and writes to the given to_path (from_path is locally accessible) - """ - pass - - @abstractmethod - def construct_path(self, add_protocol: bool, add_prefix: bool, *paths: str) -> str: - """ - if add_protocol is true then is prefixed else - Constructs a path in the format *args - delim is dependent on the storage medium. - each of the args is joined with the delim - """ - pass - +# Refer to https://github.com/fsspec/s3fs/blob/50bafe4d8766c3b2a4e1fc09669cf02fb2d71454/s3fs/core.py#L198 +# for key and secret +_FSSPEC_S3_KEY_ID = "key" +_FSSPEC_S3_SECRET = "secret" +_ANON = "anon" -class DataPersistencePlugins(object): - """ - DataPersistencePlugins is the core plugin registry that stores all DataPersistence plugins. To add a new plugin use - - .. code-block:: python - DataPersistencePlugins.register_plugin("s3:/", DataPersistence(), force=True|False) - - These plugins should always be registered. Follow the plugin registration guidelines to auto-discover your plugins. - """ +def s3_setup_args(s3_cfg: configuration.S3Config, anonymous: bool = False): + kwargs = {} + if s3_cfg.access_key_id: + kwargs[_FSSPEC_S3_KEY_ID] = s3_cfg.access_key_id - _PLUGINS: Dict[str, typing.Type[DataPersistence]] = {} + if s3_cfg.secret_access_key: + kwargs[_FSSPEC_S3_SECRET] = s3_cfg.secret_access_key - @classmethod - def register_plugin(cls, protocol: str, plugin: typing.Type[DataPersistence], force: bool = False): - """ - Registers the supplied plugin for the specified protocol if one does not already exist. - If one exists and force is default or False, then a TypeError is raised. - If one does not exist then it is registered - If one exists, but force == True then the existing plugin is overridden - """ - if protocol in cls._PLUGINS: - p = cls._PLUGINS[protocol] - if p == plugin: - return - if not force: - raise TypeError( - f"Cannot register plugin {plugin.name} for protocol {protocol} as plugin {p.name} is already" - f" registered for the same protocol. You can force register the new plugin by passing force=True" - ) + # S3fs takes this as a special arg + if s3_cfg.endpoint is not None: + kwargs["client_kwargs"] = {"endpoint_url": s3_cfg.endpoint} - cls._PLUGINS[protocol] = plugin + if anonymous: + kwargs[_ANON] = True - @staticmethod - def get_protocol(url: str) -> str: - # copy from fsspec https://github.com/fsspec/filesystem_spec/blob/fe09da6942ad043622212927df7442c104fe7932/fsspec/utils.py#L387-L391 - parts = re.split(r"(\:\:|\://)", url, 1) - if len(parts) > 1: - return parts[0] - logger.info("Setting protocol to file") - return "file" - - @classmethod - def find_plugin(cls, path: str) -> typing.Type[DataPersistence]: - """ - Returns a plugin for the given protocol, else raise a TypeError - """ - for k, p in cls._PLUGINS.items(): - if cls.get_protocol(path) == k.replace("://", "") or path.startswith(k): - return p - raise TypeError(f"No plugin found for matching protocol of path {path}") - - @classmethod - def print_all_plugins(cls): - """ - Prints all the plugins and their associated protocoles - """ - for k, p in cls._PLUGINS.items(): - print(f"Plugin {p.name} registered for protocol {k}") - - @classmethod - def is_supported_protocol(cls, protocol: str) -> bool: - """ - Returns true if the given protocol is has a registered plugin for it - """ - return protocol in cls._PLUGINS - - @classmethod - def supported_protocols(cls) -> typing.List[str]: - return [k for k in cls._PLUGINS.keys()] - - -class DiskPersistence(DataPersistence): - """ - The simplest form of persistence that is available with default flytekit - Disk-based persistence. - This will store all data locally and retrieve the data from local. This is helpful for local execution and simulating - runs. - """ - - PROTOCOL = "file://" - - def __init__(self, default_prefix: typing.Optional[str] = None, **kwargs): - super().__init__(name="local", default_prefix=default_prefix, **kwargs) - - @staticmethod - def _make_local_path(path): - if not os.path.exists(path): - try: - pathlib.Path(path).mkdir(parents=True, exist_ok=True) - except OSError: # Guard against race condition - if not os.path.isdir(path): - raise - - @staticmethod - def strip_file_header(path: str) -> str: - """ - Drops file:// if it exists from the file - """ - if path.startswith("file://"): - return path.replace("file://", "", 1) - return path - - def listdir(self, path: str, recursive: bool = False) -> typing.Generator[str, None, None]: - if not recursive: - files = os.listdir(self.strip_file_header(path)) - for f in files: - yield f - return - - for root, subdirs, files in os.walk(self.strip_file_header(path)): - for f in files: - yield os.path.join(root, f) - return - - def exists(self, path: str): - return os.path.exists(self.strip_file_header(path)) - - def copy_tree(self, from_path: str, to_path: str): - # TODO: Remove this code after support for 3.7 is dropped and inline this function back - # 3.7 doesn't have dirs_exist_ok - if CURRENT_PYTHON == THREE_SEVEN: - tp = pathlib.Path(self.strip_file_header(to_path)) - if tp.exists(): - if not tp.is_dir(): - raise FlyteValueException(tp, f"Target {tp} exists but is not a dir") - files = os.listdir(tp) - if len(files) != 0: - logger.debug(f"Deleting existing target dir {tp} with files {files}") - shutil.rmtree(tp) - shutil.copytree(self.strip_file_header(from_path), self.strip_file_header(to_path)) - else: - # copytree will overwrite existing files in the to_path - shutil.copytree(self.strip_file_header(from_path), self.strip_file_header(to_path), dirs_exist_ok=True) - - def get(self, from_path: str, to_path: str, recursive: bool = False): - if from_path != to_path: - if recursive: - self.copy_tree(from_path, to_path) - else: - copyfile(self.strip_file_header(from_path), self.strip_file_header(to_path)) - - def put(self, from_path: str, to_path: str, recursive: bool = False): - if from_path != to_path: - if recursive: - self.copy_tree(from_path, to_path) - else: - # Emulate s3's flat storage by automatically creating directory path - self._make_local_path(os.path.dirname(self.strip_file_header(to_path))) - # Write the object to a local file in the temp local folder - copyfile(self.strip_file_header(from_path), self.strip_file_header(to_path)) - - def construct_path(self, _: bool, add_prefix: bool, *args: str) -> str: - # Ignore add_protocol for now. Only complicates things - if add_prefix: - prefix = self.default_prefix if self.default_prefix else "" - return os.path.join(prefix, *args) - return os.path.join(*args) - - -def stringify_path(filepath): - """ - Copied from `filesystem_spec `__ - - Attempt to convert a path-like object to a string. - Parameters - ---------- - filepath: object to be converted - Returns - ------- - filepath_str: maybe a string version of the object - Notes - ----- - Objects supporting the fspath protocol (Python 3.6+) are coerced - according to its __fspath__ method. - For backwards compatibility with older Python version, pathlib.Path - objects are specially coerced. - Any other object is passed through unchanged, which includes bytes, - strings, buffers, or anything else that's not even path-like. - """ - if isinstance(filepath, str): - return filepath - elif hasattr(filepath, "__fspath__"): - return filepath.__fspath__() - elif isinstance(filepath, pathlib.Path): - return str(filepath) - elif hasattr(filepath, "path"): - return filepath.path - else: - return filepath - - -def split_protocol(urlpath): - """ - Copied from `filesystem_spec `__ - Return protocol, path pair - """ - urlpath = stringify_path(urlpath) - if "://" in urlpath: - protocol, path = urlpath.split("://", 1) - if len(protocol) > 1: - # excludes Windows paths - return protocol, path - return None, urlpath + return kwargs class FileAccessProvider(object): @@ -335,13 +85,18 @@ def __init__( local_sandbox_dir_appended = os.path.join(local_sandbox_dir, "local_flytekit") self._local_sandbox_dir = pathlib.Path(local_sandbox_dir_appended) self._local_sandbox_dir.mkdir(parents=True, exist_ok=True) - self._local = DiskPersistence(default_prefix=local_sandbox_dir_appended) + self._local = fsspec.filesystem(None) - self._default_remote = DataPersistencePlugins.find_plugin(raw_output_prefix)( - default_prefix=raw_output_prefix, data_config=data_config - ) - self._raw_output_prefix = raw_output_prefix self._data_config = data_config if data_config else DataConfig.auto() + self._default_protocol = get_protocol(raw_output_prefix) + self._default_remote = cast(fsspec.AbstractFileSystem, self.get_filesystem(self._default_protocol)) + if os.name == "nt" and raw_output_prefix.startswith("file://"): + raise FlyteAssertion("Cannot use the file:// prefix on Windows.") + self._raw_output_prefix = ( + raw_output_prefix + if raw_output_prefix.endswith(self.sep(self._default_remote)) + else raw_output_prefix + self.sep(self._default_remote) + ) @property def raw_output_prefix(self) -> str: @@ -351,38 +106,120 @@ def raw_output_prefix(self) -> str: def data_config(self) -> DataConfig: return self._data_config + def get_filesystem( + self, protocol: typing.Optional[str] = None, anonymous: bool = False + ) -> typing.Optional[fsspec.AbstractFileSystem]: + if not protocol: + return self._default_remote + kwargs = {} # type: typing.Dict[str, typing.Any] + if protocol == "file": + kwargs = {"auto_mkdir": True} + elif protocol == "s3": + kwargs = s3_setup_args(self._data_config.s3, anonymous=anonymous) + return fsspec.filesystem(protocol, **kwargs) # type: ignore + elif protocol == "gs": + if anonymous: + kwargs["token"] = _ANON + return fsspec.filesystem(protocol, **kwargs) # type: ignore + + # Preserve old behavior of returning None for file systems that don't have an explicit anonymous option. + if anonymous: + return None + + return fsspec.filesystem(protocol, **kwargs) # type: ignore + + def get_filesystem_for_path(self, path: str = "", anonymous: bool = False) -> fsspec.AbstractFileSystem: + protocol = get_protocol(path) + return self.get_filesystem(protocol, anonymous=anonymous) + @staticmethod def is_remote(path: Union[str, os.PathLike]) -> bool: """ - Deprecated. Lets find a replacement + Deprecated. Let's find a replacement """ - protocol, _ = split_protocol(path) + protocol = get_protocol(path) if protocol is None: return False return protocol != "file" @property def local_sandbox_dir(self) -> os.PathLike: + """ + This is a context based temp dir. + """ return self._local_sandbox_dir @property - def local_access(self) -> DiskPersistence: + def local_access(self) -> fsspec.AbstractFileSystem: return self._local - def construct_random_path( - self, persist: DataPersistence, file_path_or_file_name: typing.Optional[str] = None - ) -> str: + @staticmethod + def strip_file_header(path: str, trim_trailing_sep: bool = False) -> str: """ - Use file_path_or_file_name, when you want a random directory, but want to preserve the leaf file name + Drops file:// if it exists from the file """ - key = UUID(int=random.getrandbits(128)).hex - if file_path_or_file_name: - _, tail = os.path.split(file_path_or_file_name) - if tail: - return persist.construct_path(False, True, key, tail) - else: - logger.warning(f"No filename detected in {file_path_or_file_name}, generating random path") - return persist.construct_path(False, True, key) + if path.startswith("file://"): + return path.replace("file://", "", 1) + return path + + @staticmethod + def recursive_paths(f: str, t: str) -> typing.Tuple[str, str]: + f = os.path.join(f, "") + t = os.path.join(t, "") + return f, t + + def sep(self, file_system: typing.Optional[fsspec.AbstractFileSystem]) -> str: + if file_system is None or file_system.protocol == "file": + return os.sep + return file_system.sep + + def exists(self, path: str) -> bool: + try: + file_system = self.get_filesystem_for_path(path) + return file_system.exists(path) + except OSError as oe: + logger.debug(f"Error in exists checking {path} {oe}") + anon_fs = self.get_filesystem(get_protocol(path), anonymous=True) + if anon_fs is not None: + logger.debug(f"Attempting anonymous exists with {anon_fs}") + return anon_fs.exists(path) + raise oe + + def get(self, from_path: str, to_path: str, recursive: bool = False): + file_system = self.get_filesystem_for_path(from_path) + if recursive: + from_path, to_path = self.recursive_paths(from_path, to_path) + try: + if os.name == "nt" and file_system.protocol == "file" and recursive: + import shutil + + return shutil.copytree( + self.strip_file_header(from_path), self.strip_file_header(to_path), dirs_exist_ok=True + ) + return file_system.get(from_path, to_path, recursive=recursive) + except OSError as oe: + logger.debug(f"Error in getting {from_path} to {to_path} rec {recursive} {oe}") + file_system = self.get_filesystem(get_protocol(from_path), anonymous=True) + if file_system is not None: + logger.debug(f"Attempting anonymous get with {file_system}") + return file_system.get(from_path, to_path, recursive=recursive) + raise oe + + def put(self, from_path: str, to_path: str, recursive: bool = False): + file_system = self.get_filesystem_for_path(to_path) + from_path = self.strip_file_header(from_path) + if recursive: + # Only check this for the local filesystem + if file_system.protocol == "file" and not file_system.isdir(from_path): + raise FlyteAssertion(f"Source path {from_path} is not a directory") + if os.name == "nt" and file_system.protocol == "file": + import shutil + + return shutil.copytree( + self.strip_file_header(from_path), self.strip_file_header(to_path), dirs_exist_ok=True + ) + from_path, to_path = self.recursive_paths(from_path, to_path) + return file_system.put(from_path, to_path, recursive=recursive) def get_random_remote_path(self, file_path_or_file_name: typing.Optional[str] = None) -> str: """ @@ -391,7 +228,20 @@ def get_random_remote_path(self, file_path_or_file_name: typing.Optional[str] = Use file_path_or_file_name, when you want a random directory, but want to preserve the leaf file name """ - return self.construct_random_path(self._default_remote, file_path_or_file_name) + default_protocol = self._default_remote.protocol + if type(default_protocol) == list: + default_protocol = default_protocol[0] + key = UUID(int=random.getrandbits(128)).hex + tail = "" + if file_path_or_file_name: + _, tail = os.path.split(file_path_or_file_name) + sep = self.sep(self._default_remote) + tail = sep + tail if tail else tail + if default_protocol == "file": + # Special case the local case, users will not expect to see a file:// prefix + return self.strip_file_header(self.raw_output_prefix) + key + tail + + return self._default_remote.unstrip_protocol(self.raw_output_prefix + key + tail) def get_random_remote_directory(self): return self.get_random_remote_path(None) @@ -400,19 +250,19 @@ def get_random_local_path(self, file_path_or_file_name: typing.Optional[str] = N """ Use file_path_or_file_name, when you want a random directory, but want to preserve the leaf file name """ - return self.construct_random_path(self._local, file_path_or_file_name) + key = UUID(int=random.getrandbits(128)).hex + tail = "" + if file_path_or_file_name: + _, tail = os.path.split(file_path_or_file_name) + if tail: + return os.path.join(self._local_sandbox_dir, key, tail) + return os.path.join(self._local_sandbox_dir, key) def get_random_local_directory(self) -> str: _dir = self.get_random_local_path(None) pathlib.Path(_dir).mkdir(parents=True, exist_ok=True) return _dir - def exists(self, path: str) -> bool: - """ - checks if the given path exists - """ - return DataPersistencePlugins.find_plugin(path)().exists(path) - def download_directory(self, remote_path: str, local_path: str): """ Downloads directory from given remote to local path @@ -439,39 +289,34 @@ def upload_directory(self, local_path: str, remote_path: str): """ return self.put_data(local_path, remote_path, is_multipart=True) - def get_data(self, remote_path: str, local_path: str, is_multipart=False): + def get_data(self, remote_path: str, local_path: str, is_multipart: bool = False): """ - :param Text remote_path: - :param Text local_path: - :param bool is_multipart: + :param remote_path: + :param local_path: + :param is_multipart: """ try: with PerformanceTimer(f"Copying ({remote_path} -> {local_path})"): pathlib.Path(local_path).parent.mkdir(parents=True, exist_ok=True) - data_persistence_plugin = DataPersistencePlugins.find_plugin(remote_path) - data_persistence_plugin(data_config=self.data_config).get( - remote_path, local_path, recursive=is_multipart - ) + self.get(remote_path, to_path=local_path, recursive=is_multipart) except Exception as ex: raise FlyteAssertion( f"Failed to get data from {remote_path} to {local_path} (recursive={is_multipart}).\n\n" f"Original exception: {str(ex)}" ) - def put_data(self, local_path: str, remote_path: str, is_multipart=False): + def put_data(self, local_path: Union[str, os.PathLike], remote_path: str, is_multipart: bool = False): """ The implication here is that we're always going to put data to the remote location, so we .remote to ensure we don't use the true local proxy if the remote path is a file:// - :param Text local_path: - :param Text remote_path: - :param bool is_multipart: + :param local_path: + :param remote_path: + :param is_multipart: """ try: with PerformanceTimer(f"Writing ({local_path} -> {remote_path})"): - DataPersistencePlugins.find_plugin(remote_path)(data_config=self.data_config).put( - local_path, remote_path, recursive=is_multipart - ) + self.put(cast(str, local_path), remote_path, recursive=is_multipart) except Exception as ex: raise FlyteAssertion( f"Failed to put data from {local_path} to {remote_path} (recursive={is_multipart}).\n\n" @@ -479,9 +324,6 @@ def put_data(self, local_path: str, remote_path: str, is_multipart=False): ) from ex -DataPersistencePlugins.register_plugin("file://", DiskPersistence) -DataPersistencePlugins.register_plugin("/", DiskPersistence) - flyte_tmp_dir = tempfile.mkdtemp(prefix="flyte-") default_local_file_access_provider = FileAccessProvider( local_sandbox_dir=os.path.join(flyte_tmp_dir, "sandbox"), diff --git a/flytekit/deck/deck.py b/flytekit/deck/deck.py index cec59e7318..45ee4efa51 100644 --- a/flytekit/deck/deck.py +++ b/flytekit/deck/deck.py @@ -10,6 +10,11 @@ OUTPUT_DIR_JUPYTER_PREFIX = "jupyter" DECK_FILE_NAME = "deck.html" +try: + from IPython.core.display import HTML +except ImportError: + ... + class Deck: """ @@ -100,8 +105,6 @@ def _get_deck( deck_map = {deck.name: deck.html for deck in new_user_params.decks} raw_html = template.render(metadata=deck_map) if not ignore_jupyter and _ipython_check(): - from IPython.core.display import HTML - return HTML(raw_html) return raw_html diff --git a/flytekit/extend/__init__.py b/flytekit/extend/__init__.py index f6635a4a57..7223d13523 100644 --- a/flytekit/extend/__init__.py +++ b/flytekit/extend/__init__.py @@ -29,8 +29,6 @@ PythonCustomizedContainerTask ExecutableTemplateShimTask ShimTaskExecutor - DataPersistence - DataPersistencePlugins """ from flytekit.configuration import Image, ImageConfig, SerializationSettings @@ -39,7 +37,7 @@ from flytekit.core.base_task import IgnoreOutputs, PythonTask, TaskResolverMixin from flytekit.core.class_based_resolver import ClassStorageTaskResolver from flytekit.core.context_manager import ExecutionState, SecretsManager -from flytekit.core.data_persistence import DataPersistence, DataPersistencePlugins +from flytekit.core.data_persistence import FileAccessProvider from flytekit.core.interface import Interface from flytekit.core.promise import Promise from flytekit.core.python_customized_container_task import PythonCustomizedContainerTask diff --git a/flytekit/extras/persistence/__init__.py b/flytekit/extras/persistence/__init__.py deleted file mode 100644 index a677632fd8..0000000000 --- a/flytekit/extras/persistence/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -======================= -DataPersistence Extras -======================= - -.. currentmodule:: flytekit.extras.persistence - -This module provides some default implementations of :py:class:`flytekit.DataPersistence`. These implementations -use command-line clients to download and upload data. The actual binaries need to be installed for these extras to work. -The binaries are not bundled with flytekit to keep it lightweight. - -Persistence Extras -=================== - -.. autosummary:: - :template: custom.rst - :toctree: generated/ - - GCSPersistence - HttpPersistence - S3Persistence -""" - -from flytekit.extras.persistence.gcs_gsutil import GCSPersistence -from flytekit.extras.persistence.http import HttpPersistence -from flytekit.extras.persistence.s3_awscli import S3Persistence diff --git a/flytekit/extras/persistence/gcs_gsutil.py b/flytekit/extras/persistence/gcs_gsutil.py deleted file mode 100644 index 0ddb600024..0000000000 --- a/flytekit/extras/persistence/gcs_gsutil.py +++ /dev/null @@ -1,115 +0,0 @@ -import os -import posixpath -import typing -from shutil import which as shell_which - -from flytekit.configuration import DataConfig, GCSConfig -from flytekit.core.data_persistence import DataPersistence, DataPersistencePlugins -from flytekit.exceptions.user import FlyteUserException -from flytekit.tools import subprocess - - -def _update_cmd_config_and_execute(cmd): - env = os.environ.copy() - return subprocess.check_call(cmd, env=env) - - -def _amend_path(path): - return posixpath.join(path, "*") if not path.endswith("*") else path - - -class GCSPersistence(DataPersistence): - """ - This DataPersistence plugin uses a preinstalled GSUtil binary in the container to download and upload data. - - The binary can be installed in multiple ways including simply, - - .. prompt:: - - pip install gsutil - - """ - - _GS_UTIL_CLI = "gsutil" - PROTOCOL = "gs://" - - def __init__(self, default_prefix: typing.Optional[str] = None, data_config: typing.Optional[DataConfig] = None): - super(GCSPersistence, self).__init__(name="gcs-gsutil", default_prefix=default_prefix) - self.gcs_cfg = data_config.gcs if data_config else GCSConfig.auto() - - @staticmethod - def _check_binary(): - """ - Make sure that the `gsutil` cli is present - """ - if not shell_which(GCSPersistence._GS_UTIL_CLI): - raise FlyteUserException("gsutil (gcloud cli) not found! Please install using `pip install gsutil`.") - - def _maybe_with_gsutil_parallelism(self, *gsutil_args): - """ - Check if we should run `gsutil` with the `-m` flag that enables - parallelism via multiple threads/processes. Additional tweaking of - this behavior can be achieved via the .boto configuration file. See: - https://cloud.google.com/storage/docs/boto-gsutil - """ - cmd = [GCSPersistence._GS_UTIL_CLI] - if self.gcs_cfg.gsutil_parallelism: - cmd.append("-m") - cmd.extend(gsutil_args) - - return cmd - - def exists(self, remote_path): - """ - :param Text remote_path: remote gs:// path - :rtype bool: whether the gs file exists or not - """ - GCSPersistence._check_binary() - - if not remote_path.startswith("gs://"): - raise ValueError("Not an GS Key. Please use FQN (GS ARN) of the format gs://...") - - cmd = [GCSPersistence._GS_UTIL_CLI, "-q", "stat", remote_path] - try: - _update_cmd_config_and_execute(cmd) - return True - except Exception: - return False - - def get(self, from_path: str, to_path: str, recursive: bool = False): - if not from_path.startswith("gs://"): - raise ValueError("Not an GS Key. Please use FQN (GS ARN) of the format gs://...") - - GCSPersistence._check_binary() - if recursive: - cmd = self._maybe_with_gsutil_parallelism("cp", "-r", _amend_path(from_path), to_path) - else: - cmd = self._maybe_with_gsutil_parallelism("cp", from_path, to_path) - - return _update_cmd_config_and_execute(cmd) - - def put(self, from_path: str, to_path: str, recursive: bool = False): - GCSPersistence._check_binary() - - if recursive: - cmd = self._maybe_with_gsutil_parallelism( - "cp", - "-r", - _amend_path(from_path), - to_path if to_path.endswith("/") else to_path + "/", - ) - else: - cmd = self._maybe_with_gsutil_parallelism("cp", from_path, to_path) - return _update_cmd_config_and_execute(cmd) - - def construct_path(self, add_protocol: bool, add_prefix: bool, *paths) -> str: - paths = list(paths) # make type check happy - if add_prefix: - paths.insert(0, self.default_prefix) - path = "/".join(paths) - if add_protocol: - return f"{self.PROTOCOL}{path}" - return path - - -DataPersistencePlugins.register_plugin(GCSPersistence.PROTOCOL, GCSPersistence) diff --git a/flytekit/extras/persistence/http.py b/flytekit/extras/persistence/http.py deleted file mode 100644 index ce6079300d..0000000000 --- a/flytekit/extras/persistence/http.py +++ /dev/null @@ -1,84 +0,0 @@ -import base64 -import os -import pathlib - -import requests - -from flytekit.core.data_persistence import DataPersistence, DataPersistencePlugins -from flytekit.exceptions import user -from flytekit.loggers import logger -from flytekit.tools import script_mode - - -class HttpPersistence(DataPersistence): - """ - DataPersistence implementation for the HTTP protocol. only supports downloading from an http source. Uploads are - not supported currently. - """ - - PROTOCOL_HTTP = "http" - PROTOCOL_HTTPS = "https" - _HTTP_OK = 200 - _HTTP_FORBIDDEN = 403 - _HTTP_NOT_FOUND = 404 - ALLOWED_CODES = { - _HTTP_OK, - _HTTP_NOT_FOUND, - _HTTP_FORBIDDEN, - } - - def __init__(self, *args, **kwargs): - super(HttpPersistence, self).__init__(name="http/https", *args, **kwargs) - - def exists(self, path: str): - rsp = requests.head(path) - if rsp.status_code not in self.ALLOWED_CODES: - raise user.FlyteValueException( - rsp.status_code, - f"Data at {path} could not be checked for existence. Expected one of: {self.ALLOWED_CODES}", - ) - return rsp.status_code == self._HTTP_OK - - def get(self, from_path: str, to_path: str, recursive: bool = False): - if recursive: - raise user.FlyteAssertion("Reading data recursively from HTTP endpoint is not currently supported.") - rsp = requests.get(from_path) - if rsp.status_code != self._HTTP_OK: - raise user.FlyteValueException( - rsp.status_code, - "Request for data @ {} failed. Expected status code {}".format(from_path, type(self)._HTTP_OK), - ) - head, _ = os.path.split(to_path) - if head and head.startswith("/"): - logger.debug(f"HttpPersistence creating {head} so that parent dirs exist") - pathlib.Path(head).mkdir(parents=True, exist_ok=True) - with open(to_path, "wb") as writer: - writer.write(rsp.content) - - def put(self, from_path: str, to_path: str, recursive: bool = False): - if recursive: - raise user.FlyteAssertion("Recursive writing data to HTTP endpoint is not currently supported.") - - md5, _ = script_mode.hash_file(from_path) - encoded_md5 = base64.b64encode(md5) - with open(from_path, "+rb") as local_file: - content = local_file.read() - content_length = len(content) - rsp = requests.put( - to_path, data=content, headers={"Content-Length": str(content_length), "Content-MD5": encoded_md5} - ) - - if rsp.status_code != self._HTTP_OK: - raise user.FlyteValueException( - rsp.status_code, - f"Request to send data {to_path} failed.", - ) - - def construct_path(self, add_protocol: bool, add_prefix: bool, *paths) -> str: - raise user.FlyteAssertion( - "There are multiple ways of creating http links / paths, this is not supported by the persistence layer" - ) - - -DataPersistencePlugins.register_plugin("http://", HttpPersistence) -DataPersistencePlugins.register_plugin("https://", HttpPersistence) diff --git a/flytekit/extras/persistence/s3_awscli.py b/flytekit/extras/persistence/s3_awscli.py deleted file mode 100644 index 0b00227ca0..0000000000 --- a/flytekit/extras/persistence/s3_awscli.py +++ /dev/null @@ -1,181 +0,0 @@ -import os -import os as _os -import re as _re -import string as _string -import time -import typing -from shutil import which as shell_which -from typing import Dict, List, Optional - -from flytekit.configuration import DataConfig, S3Config -from flytekit.core.data_persistence import DataPersistence, DataPersistencePlugins -from flytekit.exceptions.user import FlyteUserException -from flytekit.loggers import logger -from flytekit.tools import subprocess - -S3_ANONYMOUS_FLAG = "--no-sign-request" -S3_ACCESS_KEY_ID_ENV_NAME = "AWS_ACCESS_KEY_ID" -S3_SECRET_ACCESS_KEY_ENV_NAME = "AWS_SECRET_ACCESS_KEY" - - -def _update_cmd_config_and_execute(s3_cfg: S3Config, cmd: List[str]): - env = _os.environ.copy() - - if s3_cfg.enable_debug: - cmd.insert(1, "--debug") - - if s3_cfg.endpoint is not None: - cmd.insert(1, s3_cfg.endpoint) - cmd.insert(1, "--endpoint-url") - - if S3_ACCESS_KEY_ID_ENV_NAME not in os.environ: - if s3_cfg.access_key_id: - env[S3_ACCESS_KEY_ID_ENV_NAME] = s3_cfg.access_key_id - - if S3_SECRET_ACCESS_KEY_ENV_NAME not in os.environ: - if s3_cfg.secret_access_key: - env[S3_SECRET_ACCESS_KEY_ENV_NAME] = s3_cfg.secret_access_key - - retry = 0 - while True: - try: - try: - return subprocess.check_call(cmd, env=env) - except Exception as e: - if retry > 0: - logger.info(f"AWS command failed with error {e}, command: {cmd}, retry {retry}") - - logger.debug(f"Appending anonymous flag and retrying command {cmd}") - anonymous_cmd = cmd[:] # strings only, so this is deep enough - anonymous_cmd.insert(1, S3_ANONYMOUS_FLAG) - return subprocess.check_call(anonymous_cmd, env=env) - - except Exception as e: - logger.error(f"Exception when trying to execute {cmd}, reason: {str(e)}") - retry += 1 - if retry > s3_cfg.retries: - raise - secs = s3_cfg.backoff - logger.info(f"Sleeping before retrying again, after {secs.total_seconds()} seconds") - time.sleep(secs.total_seconds()) - logger.info("Retrying again") - - -def _extra_args(extra_args: Dict[str, str]) -> List[str]: - cmd = [] - if "ContentType" in extra_args: - cmd += ["--content-type", extra_args["ContentType"]] - if "ContentEncoding" in extra_args: - cmd += ["--content-encoding", extra_args["ContentEncoding"]] - if "ACL" in extra_args: - cmd += ["--acl", extra_args["ACL"]] - return cmd - - -class S3Persistence(DataPersistence): - """ - DataPersistence plugin for AWS S3 (and Minio). Use aws cli to manage the transfer. The binary needs to be installed - separately - - .. prompt:: - - pip install awscli - - """ - - PROTOCOL = "s3://" - _AWS_CLI = "aws" - _SHARD_CHARACTERS = [str(x) for x in range(10)] + list(_string.ascii_lowercase) - - def __init__(self, default_prefix: Optional[str] = None, data_config: typing.Optional[DataConfig] = None): - super().__init__(name="awscli-s3", default_prefix=default_prefix) - self.s3_cfg = data_config.s3 if data_config else S3Config.auto() - - @staticmethod - def _check_binary(): - """ - Make sure that the AWS cli is present - """ - if not shell_which(S3Persistence._AWS_CLI): - raise FlyteUserException("AWS CLI not found! Please install it with `pip install awscli`.") - - @staticmethod - def _split_s3_path_to_bucket_and_key(path: str) -> typing.Tuple[str, str]: - """ - splits a valid s3 uri into bucket and key - """ - path = path[len("s3://") :] - first_slash = path.index("/") - return path[:first_slash], path[first_slash + 1 :] - - def exists(self, remote_path): - """ - Given a remote path of the format s3://, checks if the remote file exists - """ - S3Persistence._check_binary() - - if not remote_path.startswith("s3://"): - raise ValueError("Not an S3 ARN. Please use FQN (S3 ARN) of the format s3://...") - - bucket, file_path = self._split_s3_path_to_bucket_and_key(remote_path) - cmd = [ - S3Persistence._AWS_CLI, - "s3api", - "head-object", - "--bucket", - bucket, - "--key", - file_path, - ] - try: - _update_cmd_config_and_execute(cmd=cmd, s3_cfg=self.s3_cfg) - return True - except Exception as ex: - # The s3api command returns an error if the object does not exist. The error message contains - # the http status code: "An error occurred (404) when calling the HeadObject operation: Not Found" - # This is a best effort for returning if the object does not exist by searching - # for existence of (404) in the error message. This should not be needed when we get off the cli and use lib - if _re.search("(404)", str(ex)): - return False - else: - raise ex - - def get(self, from_path: str, to_path: str, recursive: bool = False): - S3Persistence._check_binary() - - if not from_path.startswith("s3://"): - raise ValueError("Not an S3 ARN. Please use FQN (S3 ARN) of the format s3://...") - - if recursive: - cmd = [S3Persistence._AWS_CLI, "s3", "cp", "--recursive", from_path, to_path] - else: - cmd = [S3Persistence._AWS_CLI, "s3", "cp", from_path, to_path] - return _update_cmd_config_and_execute(cmd=cmd, s3_cfg=self.s3_cfg) - - def put(self, from_path: str, to_path: str, recursive: bool = False): - extra_args = { - "ACL": "bucket-owner-full-control", - } - - if not to_path.startswith("s3://"): - raise ValueError("Not an S3 ARN. Please use FQN (S3 ARN) of the format s3://...") - - S3Persistence._check_binary() - cmd = [S3Persistence._AWS_CLI, "s3", "cp"] - if recursive: - cmd += ["--recursive"] - cmd.extend(_extra_args(extra_args)) - cmd += [from_path, to_path] - return _update_cmd_config_and_execute(cmd=cmd, s3_cfg=self.s3_cfg) - - def construct_path(self, add_protocol: bool, add_prefix: bool, *paths: str) -> str: - paths = list(paths) # make type check happy - if add_prefix: - paths.insert(0, self.default_prefix) - path = "/".join(paths) - if add_protocol: - return f"{self.PROTOCOL}{path}" - return path - - -DataPersistencePlugins.register_plugin(S3Persistence.PROTOCOL, S3Persistence) diff --git a/flytekit/remote/remote.py b/flytekit/remote/remote.py index 03cc9a66e9..37baacef70 100644 --- a/flytekit/remote/remote.py +++ b/flytekit/remote/remote.py @@ -6,17 +6,20 @@ from __future__ import annotations import base64 -import functools import hashlib +import importlib import os import pathlib +import tempfile import time import typing import uuid +from base64 import b64encode from collections import OrderedDict from dataclasses import asdict, dataclass from datetime import datetime, timedelta +import requests from flyteidl.admin.signal_pb2 import Signal, SignalListRequest, SignalSetRequest from flyteidl.core import literals_pb2 as literals_pb2 @@ -31,10 +34,15 @@ from flytekit.core.launch_plan import LaunchPlan from flytekit.core.python_auto_container import PythonAutoContainerTask from flytekit.core.reference_entity import ReferenceSpec +from flytekit.core.tracker import get_full_module_path from flytekit.core.type_engine import LiteralsResolver, TypeEngine from flytekit.core.workflow import WorkflowBase from flytekit.exceptions import user as user_exceptions -from flytekit.exceptions.user import FlyteEntityAlreadyExistsException, FlyteEntityNotExistException +from flytekit.exceptions.user import ( + FlyteEntityAlreadyExistsException, + FlyteEntityNotExistException, + FlyteValueException, +) from flytekit.loggers import remote_logger from flytekit.models import common as common_models from flytekit.models import filters as filter_models @@ -62,7 +70,7 @@ from flytekit.remote.lazy_entity import LazyEntity from flytekit.remote.remote_callable import RemoteEntity from flytekit.tools.fast_registration import fast_package -from flytekit.tools.script_mode import fast_register_single_script, hash_file +from flytekit.tools.script_mode import compress_single_script, hash_file from flytekit.tools.translator import ( FlyteControlPlaneEntity, FlyteLocalEntity, @@ -728,7 +736,23 @@ def _upload_file( content_md5=md5_bytes, filename=to_upload.name, ) - self._ctx.file_access.put_data(str(to_upload), upload_location.signed_url) + + encoded_md5 = b64encode(md5_bytes) + with open(str(to_upload), "+rb") as local_file: + content = local_file.read() + content_length = len(content) + rsp = requests.put( + upload_location.signed_url, + data=content, + headers={"Content-Length": str(content_length), "Content-MD5": encoded_md5}, + ) + + if rsp.status_code != requests.codes["OK"]: + raise FlyteValueException( + rsp.status_code, + f"Request to send data {upload_location.signed_url} failed.", + ) + remote_logger.debug( f"Uploading {to_upload} to {upload_location.signed_url} native url {upload_location.native_url}" ) @@ -795,16 +819,14 @@ def register_script( if image_config is None: image_config = ImageConfig.auto_default_image() - upload_location, md5_bytes = fast_register_single_script( - source_path, - module_name, - functools.partial( - self.client.get_upload_signed_url, - project=project or self.default_project, - domain=domain or self.default_domain, - filename="scriptmode.tar.gz", - ), - ) + with tempfile.TemporaryDirectory() as tmp_dir: + archive_fname = pathlib.Path(os.path.join(tmp_dir, "script_mode.tar.gz")) + mod = importlib.import_module(module_name) + compress_single_script(source_path, str(archive_fname), get_full_module_path(mod, mod.__name__)) + md5_bytes, upload_native_url = self._upload_file( + archive_fname, project or self.default_project, domain or self.default_domain + ) + serialization_settings = SerializationSettings( project=project, domain=domain, @@ -813,7 +835,7 @@ def register_script( fast_serialization_settings=FastSerializationSettings( enabled=True, destination_dir=destination_dir, - distribution_location=upload_location.native_url, + distribution_location=upload_native_url, ), ) diff --git a/flytekit/tools/script_mode.py b/flytekit/tools/script_mode.py index 29b617824c..1f3e31a382 100644 --- a/flytekit/tools/script_mode.py +++ b/flytekit/tools/script_mode.py @@ -1,6 +1,5 @@ import gzip import hashlib -import importlib import os import shutil import tarfile @@ -8,11 +7,6 @@ import typing from pathlib import Path -from flyteidl.service import dataproxy_pb2 as _data_proxy_pb2 - -from flytekit.core import context_manager -from flytekit.core.tracker import get_full_module_path - def compress_single_script(source_path: str, destination: str, full_module_name: str): """ @@ -96,24 +90,6 @@ def tar_strip_file_attributes(tar_info: tarfile.TarInfo) -> tarfile.TarInfo: return tar_info -def fast_register_single_script( - source_path: str, module_name: str, create_upload_location_fn: typing.Callable -) -> (_data_proxy_pb2.CreateUploadLocationResponse, bytes): - - # Open a temp directory and dump the contents of the digest. - with tempfile.TemporaryDirectory() as tmp_dir: - archive_fname = os.path.join(tmp_dir, "script_mode.tar.gz") - mod = importlib.import_module(module_name) - compress_single_script(source_path, archive_fname, get_full_module_path(mod, mod.__name__)) - - flyte_ctx = context_manager.FlyteContextManager.current_context() - md5, _ = hash_file(archive_fname) - upload_location = create_upload_location_fn(content_md5=md5) - flyte_ctx.file_access.put_data(archive_fname, upload_location.signed_url) - - return upload_location, md5 - - def hash_file(file_path: typing.Union[os.PathLike, str]) -> (bytes, str): """ Hash a file and produce a digest to be used as a version diff --git a/flytekit/types/structured/basic_dfs.py b/flytekit/types/structured/basic_dfs.py index 39f8d11e24..ae3e8a00d9 100644 --- a/flytekit/types/structured/basic_dfs.py +++ b/flytekit/types/structured/basic_dfs.py @@ -1,12 +1,18 @@ import os import typing +from pathlib import Path from typing import TypeVar import pandas as pd import pyarrow as pa import pyarrow.parquet as pq +from botocore.exceptions import NoCredentialsError +from fsspec.core import split_protocol, strip_protocol +from fsspec.utils import get_protocol -from flytekit import FlyteContext +from flytekit import FlyteContext, logger +from flytekit.configuration import DataConfig +from flytekit.core.data_persistence import s3_setup_args from flytekit.deck import TopFrameRenderer from flytekit.deck.renderer import ArrowRenderer from flytekit.models import literals @@ -23,6 +29,15 @@ T = TypeVar("T") +def get_storage_options(cfg: DataConfig, uri: str, anon: bool = False) -> typing.Optional[typing.Dict]: + protocol = get_protocol(uri) + if protocol == "s3": + kwargs = s3_setup_args(cfg.s3, anon) + if kwargs: + return kwargs + return None + + class PandasToParquetEncodingHandler(StructuredDatasetEncoder): def __init__(self): super().__init__(pd.DataFrame, None, PARQUET) @@ -33,6 +48,26 @@ def encode( structured_dataset: StructuredDataset, structured_dataset_type: StructuredDatasetType, ) -> literals.StructuredDataset: + uri = typing.cast(str, structured_dataset.uri) or ctx.file_access.get_random_remote_directory() + if not ctx.file_access.is_remote(uri): + Path(uri).mkdir(parents=True, exist_ok=True) + path = os.path.join(uri, f"{0:05}") + df = typing.cast(pd.DataFrame, structured_dataset.dataframe) + df.to_parquet( + path, + coerce_timestamps="us", + allow_truncated_timestamps=False, + storage_options=get_storage_options(ctx.file_access.data_config, path), + ) + structured_dataset_type.format = PARQUET + return literals.StructuredDataset(uri=uri, metadata=StructuredDatasetMetadata(structured_dataset_type)) + + def ddencode( + self, + ctx: FlyteContext, + structured_dataset: StructuredDataset, + structured_dataset_type: StructuredDatasetType, + ) -> literals.StructuredDataset: path = typing.cast(str, structured_dataset.uri) or ctx.file_access.get_random_remote_directory() df = typing.cast(pd.DataFrame, structured_dataset.dataframe) @@ -53,6 +88,24 @@ def decode( ctx: FlyteContext, flyte_value: literals.StructuredDataset, current_task_metadata: StructuredDatasetMetadata, + ) -> pd.DataFrame: + uri = flyte_value.uri + columns = None + kwargs = get_storage_options(ctx.file_access.data_config, uri) + if current_task_metadata.structured_dataset_type and current_task_metadata.structured_dataset_type.columns: + columns = [c.name for c in current_task_metadata.structured_dataset_type.columns] + try: + return pd.read_parquet(uri, columns=columns, storage_options=kwargs) + except NoCredentialsError: + logger.debug("S3 source detected, attempting anonymous S3 access") + kwargs = get_storage_options(ctx.file_access.data_config, uri, anon=True) + return pd.read_parquet(uri, columns=columns, storage_options=kwargs) + + def dcccecode( + self, + ctx: FlyteContext, + flyte_value: literals.StructuredDataset, + current_task_metadata: StructuredDatasetMetadata, ) -> pd.DataFrame: path = flyte_value.uri local_dir = ctx.file_access.get_random_local_directory() @@ -73,13 +126,13 @@ def encode( structured_dataset: StructuredDataset, structured_dataset_type: StructuredDatasetType, ) -> literals.StructuredDataset: - path = typing.cast(str, structured_dataset.uri) or ctx.file_access.get_random_remote_path() - df = structured_dataset.dataframe - local_dir = ctx.file_access.get_random_local_directory() - local_path = os.path.join(local_dir, f"{0:05}") - pq.write_table(df, local_path) - ctx.file_access.upload_directory(local_dir, path) - return literals.StructuredDataset(uri=path, metadata=StructuredDatasetMetadata(structured_dataset_type)) + uri = typing.cast(str, structured_dataset.uri) or ctx.file_access.get_random_remote_directory() + if not ctx.file_access.is_remote(uri): + Path(uri).mkdir(parents=True, exist_ok=True) + path = os.path.join(uri, f"{0:05}") + filesystem = ctx.file_access.get_filesystem_for_path(path) + pq.write_table(structured_dataset.dataframe, strip_protocol(path), filesystem=filesystem) + return literals.StructuredDataset(uri=uri, metadata=StructuredDatasetMetadata(structured_dataset_type)) class ParquetToArrowDecodingHandler(StructuredDatasetDecoder): @@ -92,13 +145,23 @@ def decode( flyte_value: literals.StructuredDataset, current_task_metadata: StructuredDatasetMetadata, ) -> pa.Table: - path = flyte_value.uri - local_dir = ctx.file_access.get_random_local_directory() - ctx.file_access.get_data(path, local_dir, is_multipart=True) + uri = flyte_value.uri + if not ctx.file_access.is_remote(uri): + Path(uri).parent.mkdir(parents=True, exist_ok=True) + _, path = split_protocol(uri) + + columns = None if current_task_metadata.structured_dataset_type and current_task_metadata.structured_dataset_type.columns: columns = [c.name for c in current_task_metadata.structured_dataset_type.columns] - return pq.read_table(local_dir, columns=columns) - return pq.read_table(local_dir) + try: + fs = ctx.file_access.get_filesystem_for_path(uri) + return pq.read_table(path, filesystem=fs, columns=columns) + except NoCredentialsError as e: + logger.debug("S3 source detected, attempting anonymous S3 access") + fs = ctx.file_access.get_filesystem_for_path(uri, anonymous=True) + if fs is not None: + return pq.read_table(path, filesystem=fs, columns=columns) + raise e StructuredDatasetTransformerEngine.register(PandasToParquetEncodingHandler(), default_format_for_type=True) diff --git a/flytekit/types/structured/structured_dataset.py b/flytekit/types/structured/structured_dataset.py index 90755c8cc5..9b4951e084 100644 --- a/flytekit/types/structured/structured_dataset.py +++ b/flytekit/types/structured/structured_dataset.py @@ -12,11 +12,11 @@ import pandas as pd import pyarrow as pa from dataclasses_json import config, dataclass_json +from fsspec.utils import get_protocol from marshmallow import fields from typing_extensions import Annotated, TypeAlias, get_args, get_origin from flytekit.core.context_manager import FlyteContext, FlyteContextManager -from flytekit.core.data_persistence import DataPersistencePlugins, DiskPersistence from flytekit.core.type_engine import TypeEngine, TypeTransformer from flytekit.deck.renderer import Renderable from flytekit.loggers import logger @@ -34,6 +34,7 @@ # Storage formats PARQUET: StructuredDatasetFormat = "parquet" GENERIC_FORMAT: StructuredDatasetFormat = "" +GENERIC_PROTOCOL: str = "generic protocol" @dataclass_json @@ -74,6 +75,7 @@ def __init__( self._literal_sd: Optional[literals.StructuredDataset] = None # Not meant for users to set, will be set by an open() call self._dataframe_type: Optional[DF] = None # type: ignore + self._already_uploaded = False @property def dataframe(self) -> Optional[DF]: @@ -270,11 +272,6 @@ def decode( raise NotImplementedError -def protocol_prefix(uri: str) -> str: - p = DataPersistencePlugins.get_protocol(uri) - return p - - def convert_schema_type_to_structured_dataset_type( column_type: int, ) -> int: @@ -336,42 +333,54 @@ class StructuredDatasetTransformerEngine(TypeTransformer[StructuredDataset]): @classmethod def _finder(cls, handler_map, df_type: Type, protocol: str, format: str): - # If the incoming format requested is a specific format (e.g. "avro"), then look for that specific handler - # if missing, see if there's a generic format handler. Error if missing. - # If the incoming format requested is the generic format (""), then see if it's present, - # if not, look to see if there is a default format for the df_type and a handler for that format. - # if still missing, look to see if there's only _one_ handler for that type, if so then use that. - if format != GENERIC_FORMAT: - try: - return handler_map[df_type][protocol][format] - except KeyError: - try: - return handler_map[df_type][protocol][GENERIC_FORMAT] - except KeyError: - ... - else: - try: - return handler_map[df_type][protocol][GENERIC_FORMAT] - except KeyError: - if df_type in cls.DEFAULT_FORMATS and cls.DEFAULT_FORMATS[df_type] in handler_map[df_type][protocol]: - hh = handler_map[df_type][protocol][cls.DEFAULT_FORMATS[df_type]] - logger.debug( - f"Didn't find format specific handler {type(handler_map)} for protocol {protocol}" - f" using the generic handler {hh} instead." - ) - return hh - if len(handler_map[df_type][protocol]) == 1: - hh = list(handler_map[df_type][protocol].values())[0] - logger.debug( - f"Using {hh} with format {hh.supported_format} as it's the only one available for {df_type}" - ) - return hh + # If there's an exact match, then we should use it. + try: + return handler_map[df_type][protocol][format] + except KeyError: + ... + + fsspec_handler = None + protocol_specific_handler = None + single_handler = None + default_format = cls.DEFAULT_FORMATS.get(df_type, None) + + try: + fss_handlers = handler_map[df_type]["fsspec"] + if format in fss_handlers: + fsspec_handler = fss_handlers[format] + elif GENERIC_FORMAT in fss_handlers: + fsspec_handler = fss_handlers[GENERIC_FORMAT] + else: + if default_format and default_format in fss_handlers and format == GENERIC_FORMAT: + fsspec_handler = fss_handlers[default_format] else: - logger.warning( - f"Did not automatically pick a handler for {df_type}," - f" more than one detected {handler_map[df_type][protocol].keys()}" - ) - raise ValueError(f"Failed to find a handler for {df_type}, protocol {protocol}, fmt |{format}|") + if len(fss_handlers) == 1 and format == GENERIC_FORMAT: + single_handler = list(fss_handlers.values())[0] + else: + ... + except KeyError: + ... + + try: + protocol_handlers = handler_map[df_type][protocol] + if GENERIC_FORMAT in protocol_handlers: + protocol_specific_handler = protocol_handlers[GENERIC_FORMAT] + else: + if default_format and default_format in protocol_handlers: + protocol_specific_handler = protocol_handlers[default_format] + else: + if len(protocol_handlers) == 1: + single_handler = list(protocol_handlers.values())[0] + else: + ... + + except KeyError: + ... + + if protocol_specific_handler or fsspec_handler or single_handler: + return protocol_specific_handler or fsspec_handler or single_handler + else: + raise ValueError(f"Failed to find a handler for {df_type}, protocol {protocol}, fmt |{format}|") @classmethod def get_encoder(cls, df_type: Type, protocol: str, format: str): @@ -436,18 +445,12 @@ def register( if h.protocol is None: if default_for_type: raise ValueError(f"Registering SD handler {h} with all protocols should never have default specified.") - for persistence_protocol in DataPersistencePlugins.supported_protocols(): - # TODO: Clean this up when we get to replacing the persistence layer. - # The behavior of the protocols given in the supported_protocols and is_supported_protocol - # is not actually the same as the one returned in get_protocol. - stripped = DataPersistencePlugins.get_protocol(persistence_protocol) - logger.debug(f"Automatically registering {persistence_protocol} as {stripped} with {h}") - try: - cls.register_for_protocol( - h, stripped, False, override, default_format_for_type, default_storage_for_type - ) - except DuplicateHandlerError: - logger.debug(f"Skipping {persistence_protocol}/{stripped} for {h} because duplicate") + try: + cls.register_for_protocol( + h, "fsspec", False, override, default_format_for_type, default_storage_for_type + ) + except DuplicateHandlerError: + logger.debug(f"Skipping generic fsspec protocol for handler {h} because duplicate") elif h.protocol == "": raise ValueError(f"Use None instead of empty string for registering handler {h}") @@ -470,8 +473,7 @@ def register_for_protocol( See the main register function instead. """ if protocol == "/": - # TODO: Special fix again, because get_protocol returns file, instead of file:// - protocol = DataPersistencePlugins.get_protocol(DiskPersistence.PROTOCOL) + protocol = "file" lowest_level = cls._handler_finder(h, protocol) if h.supported_format in lowest_level and override is False: raise DuplicateHandlerError( @@ -542,6 +544,8 @@ def to_literal( # def t1(dataset: Annotated[StructuredDataset, my_cols]) -> Annotated[StructuredDataset, my_cols]: # return dataset if python_val._literal_sd is not None: + if python_val._already_uploaded: + return Literal(scalar=Scalar(structured_dataset=python_val._literal_sd)) if python_val.dataframe is not None: raise ValueError( f"Shouldn't have specified both literal {python_val._literal_sd} and dataframe {python_val.dataframe}" @@ -593,7 +597,7 @@ def _protocol_from_type_or_prefix(self, ctx: FlyteContext, df_type: Type, uri: O if df_type in self.DEFAULT_PROTOCOLS: return self.DEFAULT_PROTOCOLS[df_type] else: - protocol = protocol_prefix(uri or ctx.file_access.raw_output_prefix) + protocol = get_protocol(uri or ctx.file_access.raw_output_prefix) logger.debug( f"No default protocol for type {df_type} found, using {protocol} from output prefix {ctx.file_access.raw_output_prefix}" ) @@ -622,7 +626,10 @@ def encode( # Note that this will always be the same as the incoming format except for when the fallback handler # with a format of "" is used. sd_model.metadata._structured_dataset_type.format = handler.supported_format - return Literal(scalar=Scalar(structured_dataset=sd_model)) + lit = Literal(scalar=Scalar(structured_dataset=sd_model)) + sd._literal_sd = sd_model + sd._already_uploaded = True + return lit def to_python_value( self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T] | StructuredDataset @@ -769,7 +776,7 @@ def open_as( :param updated_metadata: New metadata type, since it might be different from the metadata in the literal. :return: dataframe. It could be pandas dataframe or arrow table, etc. """ - protocol = protocol_prefix(sd.uri) + protocol = get_protocol(sd.uri) decoder = self.get_decoder(df_type, protocol, sd.metadata.structured_dataset_type.format) result = decoder.decode(ctx, sd, updated_metadata) if isinstance(result, types.GeneratorType): @@ -783,7 +790,7 @@ def iter_as( df_type: Type[DF], updated_metadata: StructuredDatasetMetadata, ) -> typing.Iterator[DF]: - protocol = protocol_prefix(sd.uri) + protocol = get_protocol(sd.uri) decoder = self.DECODERS[df_type][protocol][sd.metadata.structured_dataset_type.format] result: Union[DF, typing.Iterator[DF]] = decoder.decode(ctx, sd, updated_metadata) if not isinstance(result, types.GeneratorType): diff --git a/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/__init__.py b/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/__init__.py index 68ee456ed6..e69de29bb2 100644 --- a/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/__init__.py +++ b/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/__init__.py @@ -1,53 +0,0 @@ -""" -.. currentmodule:: flytekitplugins.fsspec - -This package contains things that are useful when extending Flytekit. - -.. autosummary:: - :template: custom.rst - :toctree: generated/ - - ArrowToParquetEncodingHandler - FSSpecPersistence - PandasToParquetEncodingHandler - ParquetToArrowDecodingHandler - ParquetToPandasDecodingHandler -""" - -__all__ = [ - "ArrowToParquetEncodingHandler", - "FSSpecPersistence", - "PandasToParquetEncodingHandler", - "ParquetToArrowDecodingHandler", - "ParquetToPandasDecodingHandler", -] - -import importlib - -from flytekit import StructuredDatasetTransformerEngine, logger - -from .arrow import ArrowToParquetEncodingHandler, ParquetToArrowDecodingHandler -from .pandas import PandasToParquetEncodingHandler, ParquetToPandasDecodingHandler -from .persist import FSSpecPersistence - -S3 = "s3" -ABFS = "abfs" -GCS = "gs" - - -def _register(protocol: str): - logger.info(f"Registering fsspec {protocol} implementations and overriding default structured encoder/decoder.") - StructuredDatasetTransformerEngine.register(PandasToParquetEncodingHandler(protocol), True, True) - StructuredDatasetTransformerEngine.register(ParquetToPandasDecodingHandler(protocol), True, True) - StructuredDatasetTransformerEngine.register(ArrowToParquetEncodingHandler(protocol), True, True) - StructuredDatasetTransformerEngine.register(ParquetToArrowDecodingHandler(protocol), True, True) - - -if importlib.util.find_spec("adlfs"): - _register(ABFS) - -if importlib.util.find_spec("s3fs"): - _register(S3) - -if importlib.util.find_spec("gcsfs"): - _register(GCS) diff --git a/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/arrow.py b/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/arrow.py deleted file mode 100644 index ec8d5f975e..0000000000 --- a/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/arrow.py +++ /dev/null @@ -1,70 +0,0 @@ -import os -import typing -from pathlib import Path - -import pyarrow as pa -import pyarrow.parquet as pq -from botocore.exceptions import NoCredentialsError -from flytekitplugins.fsspec.persist import FSSpecPersistence -from fsspec.core import split_protocol, strip_protocol - -from flytekit import FlyteContext, logger -from flytekit.models import literals -from flytekit.models.literals import StructuredDatasetMetadata -from flytekit.models.types import StructuredDatasetType -from flytekit.types.structured.structured_dataset import ( - PARQUET, - StructuredDataset, - StructuredDatasetDecoder, - StructuredDatasetEncoder, -) - - -class ArrowToParquetEncodingHandler(StructuredDatasetEncoder): - def __init__(self, protocol: str): - super().__init__(pa.Table, protocol, PARQUET) - - def encode( - self, - ctx: FlyteContext, - structured_dataset: StructuredDataset, - structured_dataset_type: StructuredDatasetType, - ) -> literals.StructuredDataset: - uri = typing.cast(str, structured_dataset.uri) or ctx.file_access.get_random_remote_directory() - if not ctx.file_access.is_remote(uri): - Path(uri).mkdir(parents=True, exist_ok=True) - path = os.path.join(uri, f"{0:05}") - fp = FSSpecPersistence(data_config=ctx.file_access.data_config) - filesystem = fp.get_filesystem(path) - pq.write_table(structured_dataset.dataframe, strip_protocol(path), filesystem=filesystem) - return literals.StructuredDataset(uri=uri, metadata=StructuredDatasetMetadata(structured_dataset_type)) - - -class ParquetToArrowDecodingHandler(StructuredDatasetDecoder): - def __init__(self, protocol: str): - super().__init__(pa.Table, protocol, PARQUET) - - def decode( - self, - ctx: FlyteContext, - flyte_value: literals.StructuredDataset, - current_task_metadata: StructuredDatasetMetadata, - ) -> pa.Table: - uri = flyte_value.uri - if not ctx.file_access.is_remote(uri): - Path(uri).parent.mkdir(parents=True, exist_ok=True) - _, path = split_protocol(uri) - - columns = None - if current_task_metadata.structured_dataset_type and current_task_metadata.structured_dataset_type.columns: - columns = [c.name for c in current_task_metadata.structured_dataset_type.columns] - try: - fp = FSSpecPersistence(data_config=ctx.file_access.data_config) - fs = fp.get_filesystem(uri) - return pq.read_table(path, filesystem=fs, columns=columns) - except NoCredentialsError as e: - logger.debug("S3 source detected, attempting anonymous S3 access") - fs = FSSpecPersistence.get_anonymous_filesystem(uri) - if fs is not None: - return pq.read_table(path, filesystem=fs, columns=columns) - raise e diff --git a/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/pandas.py b/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/pandas.py deleted file mode 100644 index e4986ed9f6..0000000000 --- a/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/pandas.py +++ /dev/null @@ -1,76 +0,0 @@ -import os -import typing -from pathlib import Path - -import pandas as pd -from botocore.exceptions import NoCredentialsError -from flytekitplugins.fsspec.persist import FSSpecPersistence, s3_setup_args - -from flytekit import FlyteContext, logger -from flytekit.configuration import DataConfig -from flytekit.models import literals -from flytekit.models.literals import StructuredDatasetMetadata -from flytekit.models.types import StructuredDatasetType -from flytekit.types.structured.structured_dataset import ( - PARQUET, - StructuredDataset, - StructuredDatasetDecoder, - StructuredDatasetEncoder, -) - - -def get_storage_options(cfg: DataConfig, uri: str) -> typing.Optional[typing.Dict]: - protocol = FSSpecPersistence.get_protocol(uri) - if protocol == "s3": - kwargs = s3_setup_args(cfg.s3) - if kwargs: - return kwargs - return None - - -class PandasToParquetEncodingHandler(StructuredDatasetEncoder): - def __init__(self, protocol: str): - super().__init__(pd.DataFrame, protocol, PARQUET) - - def encode( - self, - ctx: FlyteContext, - structured_dataset: StructuredDataset, - structured_dataset_type: StructuredDatasetType, - ) -> literals.StructuredDataset: - uri = typing.cast(str, structured_dataset.uri) or ctx.file_access.get_random_remote_directory() - if not ctx.file_access.is_remote(uri): - Path(uri).mkdir(parents=True, exist_ok=True) - path = os.path.join(uri, f"{0:05}") - df = typing.cast(pd.DataFrame, structured_dataset.dataframe) - df.to_parquet( - path, - coerce_timestamps="us", - allow_truncated_timestamps=False, - storage_options=get_storage_options(ctx.file_access.data_config, path), - ) - structured_dataset_type.format = PARQUET - return literals.StructuredDataset(uri=uri, metadata=StructuredDatasetMetadata(structured_dataset_type)) - - -class ParquetToPandasDecodingHandler(StructuredDatasetDecoder): - def __init__(self, protocol: str): - super().__init__(pd.DataFrame, protocol, PARQUET) - - def decode( - self, - ctx: FlyteContext, - flyte_value: literals.StructuredDataset, - current_task_metadata: StructuredDatasetMetadata, - ) -> pd.DataFrame: - uri = flyte_value.uri - columns = None - kwargs = get_storage_options(ctx.file_access.data_config, uri) - if current_task_metadata.structured_dataset_type and current_task_metadata.structured_dataset_type.columns: - columns = [c.name for c in current_task_metadata.structured_dataset_type.columns] - try: - return pd.read_parquet(uri, columns=columns, storage_options=kwargs) - except NoCredentialsError: - logger.debug("S3 source detected, attempting anonymous S3 access") - kwargs["anon"] = True - return pd.read_parquet(uri, columns=columns, storage_options=kwargs) diff --git a/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/persist.py b/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/persist.py deleted file mode 100644 index b890b3cc6c..0000000000 --- a/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/persist.py +++ /dev/null @@ -1,144 +0,0 @@ -import os -import typing - -import fsspec -from fsspec.registry import known_implementations - -from flytekit.configuration import DataConfig, S3Config -from flytekit.extend import DataPersistence, DataPersistencePlugins -from flytekit.loggers import logger - -# Refer to https://github.com/fsspec/s3fs/blob/50bafe4d8766c3b2a4e1fc09669cf02fb2d71454/s3fs/core.py#L198 -# for key and secret -_FSSPEC_S3_KEY_ID = "key" -_FSSPEC_S3_SECRET = "secret" - - -def s3_setup_args(s3_cfg: S3Config): - kwargs = {} - if s3_cfg.access_key_id: - kwargs[_FSSPEC_S3_KEY_ID] = s3_cfg.access_key_id - - if s3_cfg.secret_access_key: - kwargs[_FSSPEC_S3_SECRET] = s3_cfg.secret_access_key - - # S3fs takes this as a special arg - if s3_cfg.endpoint is not None: - kwargs["client_kwargs"] = {"endpoint_url": s3_cfg.endpoint} - - return kwargs - - -class FSSpecPersistence(DataPersistence): - """ - This DataPersistence plugin uses fsspec to perform the IO. - NOTE: The put is not as performant as it can be for multiple files because of - - https://github.com/intake/filesystem_spec/issues/724. Once this bug is fixed, we can remove the `HACK` in the put - method - """ - - def __init__(self, default_prefix=None, data_config: typing.Optional[DataConfig] = None): - super(FSSpecPersistence, self).__init__(name="fsspec-persistence", default_prefix=default_prefix) - self.default_protocol = self.get_protocol(default_prefix) - self._data_cfg = data_config if data_config else DataConfig.auto() - - @staticmethod - def get_protocol(path: typing.Optional[str] = None): - if path: - return DataPersistencePlugins.get_protocol(path) - logger.info("Setting protocol to file") - return "file" - - def get_filesystem(self, path: str) -> fsspec.AbstractFileSystem: - protocol = FSSpecPersistence.get_protocol(path) - kwargs = {} - if protocol == "file": - kwargs = {"auto_mkdir": True} - elif protocol == "s3": - kwargs = s3_setup_args(self._data_cfg.s3) - return fsspec.filesystem(protocol, **kwargs) # type: ignore - - def get_anonymous_filesystem(self, path: str) -> typing.Optional[fsspec.AbstractFileSystem]: - protocol = FSSpecPersistence.get_protocol(path) - if protocol == "s3": - kwargs = s3_setup_args(self._data_cfg.s3) - anonymous_fs = fsspec.filesystem(protocol, anon=True, **kwargs) # type: ignore - return anonymous_fs - return None - - @staticmethod - def recursive_paths(f: str, t: str) -> typing.Tuple[str, str]: - if not f.endswith("*"): - f = os.path.join(f, "*") - if not t.endswith("/"): - t += "/" - return f, t - - def exists(self, path: str) -> bool: - try: - fs = self.get_filesystem(path) - return fs.exists(path) - except OSError as oe: - logger.debug(f"Error in exists checking {path} {oe}") - fs = self.get_anonymous_filesystem(path) - if fs is not None: - logger.debug("S3 source detected, attempting anonymous S3 exists check") - return fs.exists(path) - raise oe - - def get(self, from_path: str, to_path: str, recursive: bool = False): - fs = self.get_filesystem(from_path) - if recursive: - from_path, to_path = self.recursive_paths(from_path, to_path) - try: - return fs.get(from_path, to_path, recursive=recursive) - except OSError as oe: - logger.debug(f"Error in getting {from_path} to {to_path} rec {recursive} {oe}") - fs = self.get_anonymous_filesystem(from_path) - if fs is not None: - logger.debug("S3 source detected, attempting anonymous S3 access") - return fs.get(from_path, to_path, recursive=recursive) - raise oe - - def put(self, from_path: str, to_path: str, recursive: bool = False): - fs = self.get_filesystem(to_path) - if recursive: - from_path, to_path = self.recursive_paths(from_path, to_path) - # BEGIN HACK! - # Once https://github.com/intake/filesystem_spec/issues/724 is fixed, delete the special recursive handling - from fsspec.implementations.local import LocalFileSystem - from fsspec.utils import other_paths - - lfs = LocalFileSystem() - try: - lpaths = lfs.expand_path(from_path, recursive=recursive) - except FileNotFoundError: - # In some cases, there is no file in the original directory, so we just skip copying the file to the remote path - logger.debug(f"there is no file in the {from_path}") - return - rpaths = other_paths(lpaths, to_path) - for l, r in zip(lpaths, rpaths): - fs.put_file(l, r) - return - # END OF HACK!! - return fs.put(from_path, to_path, recursive=recursive) - - def construct_path(self, add_protocol: bool, add_prefix: bool, *paths) -> str: - path_list = list(paths) # make type check happy - if add_prefix: - path_list.insert(0, self.default_prefix) # type: ignore - path = "/".join(path_list) - if add_protocol: - return f"{self.default_protocol}://{path}" - return typing.cast(str, path) - - -def _register(): - logger.info("Registering fsspec known implementations and overriding all default implementations for persistence.") - DataPersistencePlugins.register_plugin("/", FSSpecPersistence, force=True) - for k, v in known_implementations.items(): - DataPersistencePlugins.register_plugin(f"{k}://", FSSpecPersistence, force=True) - - -# Registering all plugins -_register() diff --git a/plugins/flytekit-data-fsspec/setup.py b/plugins/flytekit-data-fsspec/setup.py index a7920d1eeb..0ceae3ac1b 100644 --- a/plugins/flytekit-data-fsspec/setup.py +++ b/plugins/flytekit-data-fsspec/setup.py @@ -4,7 +4,7 @@ microlib_name = f"flytekitplugins-data-{PLUGIN_NAME}" -plugin_requires = ["flytekit>=1.3.0b2,<2.0.0", "fsspec<=2023.1", "botocore>=1.7.48", "pandas>=1.2.0"] +plugin_requires = [] __version__ = "0.0.0+develop" @@ -13,7 +13,7 @@ version=__version__, author="flyteorg", author_email="admin@flyte.org", - description="This package data-plugins for flytekit, that are powered by fsspec", + description="This is a deprecated plugin as of flytekit 1.5", url="https://github.com/flyteorg/flytekit/tree/master/plugins/flytekit-data-fsspec", long_description=open("README.md").read(), long_description_content_type="text/markdown", @@ -22,9 +22,9 @@ install_requires=plugin_requires, extras_require={ # https://github.com/fsspec/filesystem_spec/blob/master/setup.py#L36 - "abfs": ["adlfs>=2022.2.0"], - "aws": ["s3fs>=2021.7.0"], - "gcp": ["gcsfs>=2021.7.0"], + "abfs": [], + "aws": [], + "gcp": [], }, license="apache2", python_requires=">=3.8", @@ -41,5 +41,4 @@ "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules", ], - entry_points={"flytekit.plugins": [f"{PLUGIN_NAME}=flytekitplugins.{PLUGIN_NAME}"]}, ) diff --git a/plugins/flytekit-data-fsspec/tests/test_basic_dfs.py b/plugins/flytekit-data-fsspec/tests/test_basic_dfs.py deleted file mode 100644 index 434a763a93..0000000000 --- a/plugins/flytekit-data-fsspec/tests/test_basic_dfs.py +++ /dev/null @@ -1,44 +0,0 @@ -import pandas as pd -import pyarrow as pa -from flytekitplugins.fsspec.pandas import get_storage_options - -from flytekit import kwtypes, task -from flytekit.configuration import DataConfig, S3Config - -try: - from typing import Annotated -except ImportError: - from typing_extensions import Annotated - - -def test_get_storage_options(): - endpoint = "https://s3.amazonaws.com" - - options = get_storage_options(DataConfig(s3=S3Config(endpoint=endpoint)), "s3://bucket/somewhere") - assert options == {"client_kwargs": {"endpoint_url": endpoint}} - - options = get_storage_options(DataConfig(), "/tmp/file") - assert options is None - - -cols = kwtypes(Name=str, Age=int) -subset_cols = kwtypes(Name=str) - - -@task -def t1( - df1: Annotated[pd.DataFrame, cols], df2: Annotated[pa.Table, cols] -) -> (Annotated[pd.DataFrame, subset_cols], Annotated[pa.Table, subset_cols]): - return df1, df2 - - -def test_structured_dataset_wf(): - pd_df = pd.DataFrame({"Name": ["Tom", "Joseph"], "Age": [20, 22]}) - pa_df = pa.Table.from_pandas(pd_df) - - subset_pd_df = pd.DataFrame({"Name": ["Tom", "Joseph"]}) - subset_pa_df = pa.Table.from_pandas(subset_pd_df) - - df1, df2 = t1(df1=pd_df, df2=pa_df) - assert df1.equals(subset_pd_df) - assert df2.equals(subset_pa_df) diff --git a/plugins/flytekit-data-fsspec/tests/test_persist.py b/plugins/flytekit-data-fsspec/tests/test_persist.py deleted file mode 100644 index 8e87c9c5eb..0000000000 --- a/plugins/flytekit-data-fsspec/tests/test_persist.py +++ /dev/null @@ -1,183 +0,0 @@ -import os -import pathlib -import tempfile - -import mock -from flytekitplugins.fsspec.persist import FSSpecPersistence, s3_setup_args -from fsspec.implementations.local import LocalFileSystem - -from flytekit.configuration import S3Config - - -def test_s3_setup_args(): - kwargs = s3_setup_args(S3Config()) - assert kwargs == {} - - kwargs = s3_setup_args(S3Config(endpoint="http://localhost:30084")) - assert kwargs == {"client_kwargs": {"endpoint_url": "http://localhost:30084"}} - - kwargs = s3_setup_args(S3Config(access_key_id="access")) - assert kwargs == {"key": "access"} - - -@mock.patch.dict(os.environ, {}, clear=True) -def test_s3_setup_args_env_empty(): - kwargs = s3_setup_args(S3Config.auto()) - assert kwargs == {} - - -@mock.patch.dict( - os.environ, - { - "AWS_ACCESS_KEY_ID": "ignore-user", - "AWS_SECRET_ACCESS_KEY": "ignore-secret", - "FLYTE_AWS_ACCESS_KEY_ID": "flyte", - "FLYTE_AWS_SECRET_ACCESS_KEY": "flyte-secret", - }, - clear=True, -) -def test_s3_setup_args_env_both(): - kwargs = s3_setup_args(S3Config.auto()) - assert kwargs == {"key": "flyte", "secret": "flyte-secret"} - - -@mock.patch.dict( - os.environ, - { - "FLYTE_AWS_ACCESS_KEY_ID": "flyte", - "FLYTE_AWS_SECRET_ACCESS_KEY": "flyte-secret", - }, - clear=True, -) -def test_s3_setup_args_env_flyte(): - kwargs = s3_setup_args(S3Config.auto()) - assert kwargs == {"key": "flyte", "secret": "flyte-secret"} - - -@mock.patch.dict( - os.environ, - { - "AWS_ACCESS_KEY_ID": "ignore-user", - "AWS_SECRET_ACCESS_KEY": "ignore-secret", - }, - clear=True, -) -def test_s3_setup_args_env_aws(): - kwargs = s3_setup_args(S3Config.auto()) - # not explicitly in kwargs, since fsspec/boto3 will use these env vars by default - assert kwargs == {} - - -def test_get_protocol(): - assert FSSpecPersistence.get_protocol("s3://abc") == "s3" - assert FSSpecPersistence.get_protocol("/abc") == "file" - assert FSSpecPersistence.get_protocol("file://abc") == "file" - assert FSSpecPersistence.get_protocol("gs://abc") == "gs" - assert FSSpecPersistence.get_protocol("sftp://abc") == "sftp" - assert FSSpecPersistence.get_protocol("abfs://abc") == "abfs" - - -def test_get_anonymous_filesystem(): - fp = FSSpecPersistence() - fs = fp.get_anonymous_filesystem("/abc") - assert fs is None - fs = fp.get_anonymous_filesystem("s3://abc") - assert fs is not None - assert fs.protocol == ["s3", "s3a"] - - -def test_get_filesystem(): - fp = FSSpecPersistence() - fs = fp.get_filesystem("/abc") - assert fs is not None - assert isinstance(fs, LocalFileSystem) - - -def test_recursive_paths(): - f, t = FSSpecPersistence.recursive_paths("/tmp", "/tmp") - assert (f, t) == ("/tmp/*", "/tmp/") - f, t = FSSpecPersistence.recursive_paths("/tmp/", "/tmp/") - assert (f, t) == ("/tmp/*", "/tmp/") - f, t = FSSpecPersistence.recursive_paths("/tmp/*", "/tmp") - assert (f, t) == ("/tmp/*", "/tmp/") - - -def test_exists(): - fs = FSSpecPersistence() - assert not fs.exists("/tmp/non-existent") - - with tempfile.TemporaryDirectory() as tdir: - f = os.path.join(tdir, "f.txt") - with open(f, "w") as fp: - fp.write("hello") - - assert fs.exists(f) - - -def test_get(): - fs = FSSpecPersistence() - with tempfile.TemporaryDirectory() as tdir: - f = os.path.join(tdir, "f.txt") - with open(f, "w") as fp: - fp.write("hello") - - t = os.path.join(tdir, "t.txt") - - fs.get(f, t) - with open(t, "r") as fp: - assert fp.read() == "hello" - - -def test_get_recursive(): - fs = FSSpecPersistence() - with tempfile.TemporaryDirectory() as tdir: - p = pathlib.Path(tdir) - d = p.joinpath("d") - d.mkdir() - f = d.joinpath(d, "f.txt") - with open(f, "w") as fp: - fp.write("hello") - - o = p.joinpath("o") - - t = o.joinpath(o, "f.txt") - fs.get(str(d), str(o), recursive=True) - with open(t, "r") as fp: - assert fp.read() == "hello" - - -def test_put(): - fs = FSSpecPersistence() - with tempfile.TemporaryDirectory() as tdir: - f = os.path.join(tdir, "f.txt") - with open(f, "w") as fp: - fp.write("hello") - - t = os.path.join(tdir, "t.txt") - - fs.put(f, t) - with open(t, "r") as fp: - assert fp.read() == "hello" - - -def test_put_recursive(): - fs = FSSpecPersistence() - with tempfile.TemporaryDirectory() as tdir: - p = pathlib.Path(tdir) - d = p.joinpath("d") - d.mkdir() - f = d.joinpath(d, "f.txt") - with open(f, "w") as fp: - fp.write("hello") - - o = p.joinpath("o") - - t = o.joinpath(o, "f.txt") - fs.put(str(d), str(o), recursive=True) - with open(t, "r") as fp: - assert fp.read() == "hello" - - -def test_construct_path(): - fs = FSSpecPersistence() - assert fs.construct_path(True, False, "abc") == "file://abc" diff --git a/plugins/flytekit-data-fsspec/tests/test_placeholder.py b/plugins/flytekit-data-fsspec/tests/test_placeholder.py new file mode 100644 index 0000000000..eb6dc82a34 --- /dev/null +++ b/plugins/flytekit-data-fsspec/tests/test_placeholder.py @@ -0,0 +1,3 @@ +# This test is here to give pytest something to run, otherwise it returns a non-zero return code. +def test_dummy(): + assert 1 + 1 == 2 diff --git a/plugins/flytekit-spark/tests/test_pyspark_transformers.py b/plugins/flytekit-spark/tests/test_pyspark_transformers.py index cb527e16ef..212af454dd 100644 --- a/plugins/flytekit-spark/tests/test_pyspark_transformers.py +++ b/plugins/flytekit-spark/tests/test_pyspark_transformers.py @@ -6,13 +6,24 @@ import flytekit from flytekit import task, workflow +from flytekit.core.context_manager import FlyteContextManager from flytekit.core.type_engine import TypeEngine +from flytekit.types.structured.structured_dataset import StructuredDatasetTransformerEngine def test_type_resolution(): assert type(TypeEngine.get_transformer(PipelineModel)) == PySparkPipelineModelTransformer +def test_basic_get(): + + ctx = FlyteContextManager.current_context() + e = StructuredDatasetTransformerEngine() + prot = e._protocol_from_type_or_prefix(ctx, pyspark.sql.DataFrame, uri="/tmp/blah") + en = e.get_encoder(pyspark.sql.DataFrame, prot, "") + assert en is not None + + def test_pipeline_model_compatibility(): @task(task_config=Spark()) def my_dataset() -> pyspark.sql.DataFrame: diff --git a/setup.py b/setup.py index 3e7b886e71..11a24ccbe4 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,10 @@ "grpcio>=1.50.0,<2.0", "grpcio-status>=1.50.0,<2.0", "importlib-metadata", + "fsspec>=2023.3.0", + "adlfs", + "s3fs", + "gcsfs", "pyopenssl", "joblib", "python-json-logger>=2.0.0", diff --git a/tests/flytekit/unit/bin/test_python_entrypoint.py b/tests/flytekit/unit/bin/test_python_entrypoint.py index 45d50a2fc5..1a24cccb61 100644 --- a/tests/flytekit/unit/bin/test_python_entrypoint.py +++ b/tests/flytekit/unit/bin/test_python_entrypoint.py @@ -2,6 +2,7 @@ import typing from collections import OrderedDict +import fsspec import mock import pytest from flyteidl.core.errors_pb2 import ErrorDocument @@ -10,15 +11,12 @@ from flytekit.configuration import Image, ImageConfig, SerializationSettings from flytekit.core import context_manager from flytekit.core.base_task import IgnoreOutputs -from flytekit.core.data_persistence import DiskPersistence from flytekit.core.dynamic_workflow_task import dynamic from flytekit.core.promise import VoidPromise from flytekit.core.task import task from flytekit.core.type_engine import TypeEngine from flytekit.exceptions import user as user_exceptions from flytekit.exceptions.scopes import system_entry_point -from flytekit.extras.persistence.gcs_gsutil import GCSPersistence -from flytekit.extras.persistence.s3_awscli import S3Persistence from flytekit.models import literals as _literal_models from flytekit.models.core import errors as error_models from flytekit.models.core import execution as execution_models @@ -311,7 +309,22 @@ def test_dispatch_execute_system_error(mock_write_to_file, mock_upload_dir, mock assert ed.error.origin == execution_models.ExecutionError.ErrorKind.SYSTEM -def test_persist_ss(): +def test_setup_disk_prefix(): + with setup_execution("qwerty") as ctx: + assert isinstance(ctx.file_access._default_remote, fsspec.AbstractFileSystem) + assert ctx.file_access._default_remote.protocol == "file" + + +def test_setup_cloud_prefix(): + with setup_execution("s3://", checkpoint_path=None, prev_checkpoint=None) as ctx: + assert ctx.file_access._default_remote.protocol[0] == "s3" + + with setup_execution("gs://", checkpoint_path=None, prev_checkpoint=None) as ctx: + assert "gs" in ctx.file_access._default_remote.protocol + + +@mock.patch("google.auth.compute_engine._metadata") # to prevent network calls +def test_persist_ss(mock_gcs): default_img = Image(name="default", fqn="test", tag="tag") ss = SerializationSettings( project="proj1", @@ -327,19 +340,6 @@ def test_persist_ss(): assert ctx.serialization_settings.domain == "dom" -def test_setup_disk_prefix(): - with setup_execution("qwerty") as ctx: - assert isinstance(ctx.file_access._default_remote, DiskPersistence) - - -def test_setup_cloud_prefix(): - with setup_execution("s3://", checkpoint_path=None, prev_checkpoint=None) as ctx: - assert isinstance(ctx.file_access._default_remote, S3Persistence) - - with setup_execution("gs://", checkpoint_path=None, prev_checkpoint=None) as ctx: - assert isinstance(ctx.file_access._default_remote, GCSPersistence) - - def test_normalize_inputs(): assert normalize_inputs("{{.rawOutputDataPrefix}}", "{{.checkpointOutputPrefix}}", "{{.prevCheckpointPrefix}}") == ( None, diff --git a/tests/flytekit/unit/core/test_checkpoint.py b/tests/flytekit/unit/core/test_checkpoint.py index 2add1b9e7d..b5fa46fe54 100644 --- a/tests/flytekit/unit/core/test_checkpoint.py +++ b/tests/flytekit/unit/core/test_checkpoint.py @@ -1,3 +1,4 @@ +import os from pathlib import Path import pytest @@ -36,11 +37,14 @@ def test_sync_checkpoint_save_file(tmpdir): def test_sync_checkpoint_save_filepath(tmpdir): - td_path = Path(tmpdir) - cp = SyncCheckpoint(checkpoint_dest=tmpdir) - dst_path = td_path.joinpath("test") + src_path = Path(os.path.join(tmpdir, "src")) + src_path.mkdir(parents=True, exist_ok=True) + chkpnt_path = Path(os.path.join(tmpdir, "dest")) + chkpnt_path.mkdir() + cp = SyncCheckpoint(checkpoint_dest=str(chkpnt_path)) + dst_path = chkpnt_path.joinpath("test") assert not dst_path.exists() - inp = td_path.joinpath("test") + inp = src_path.joinpath("test") with inp.open("wb") as f: f.write(b"blah") cp.save(inp) diff --git a/tests/flytekit/unit/core/test_data.py b/tests/flytekit/unit/core/test_data.py new file mode 100644 index 0000000000..880036f636 --- /dev/null +++ b/tests/flytekit/unit/core/test_data.py @@ -0,0 +1,215 @@ +import os +import shutil +import tempfile + +import fsspec +import mock +import pytest + +from flytekit.configuration import Config, S3Config +from flytekit.core.data_persistence import FileAccessProvider, default_local_file_access_provider, s3_setup_args + +local = fsspec.filesystem("file") +root = os.path.abspath(os.sep) + + +@mock.patch("google.auth.compute_engine._metadata") # to prevent network calls +@mock.patch("flytekit.core.data_persistence.UUID") +def test_path_getting(mock_uuid_class, mock_gcs): + mock_uuid_class.return_value.hex = "abcdef123" + + # Testing with raw output prefix pointing to a local path + loc_sandbox = os.path.join(root, "tmp", "unittest") + loc_data = os.path.join(root, "tmp", "unittestdata") + local_raw_fp = FileAccessProvider(local_sandbox_dir=loc_sandbox, raw_output_prefix=loc_data) + assert local_raw_fp.get_random_remote_path() == os.path.join(root, "tmp", "unittestdata", "abcdef123") + assert local_raw_fp.get_random_remote_path("/fsa/blah.csv") == os.path.join( + root, "tmp", "unittestdata", "abcdef123", "blah.csv" + ) + assert local_raw_fp.get_random_remote_directory() == os.path.join(root, "tmp", "unittestdata", "abcdef123") + + # Test local path and directory + assert local_raw_fp.get_random_local_path() == os.path.join(root, "tmp", "unittest", "local_flytekit", "abcdef123") + assert local_raw_fp.get_random_local_path("xjiosa/blah.txt") == os.path.join( + root, "tmp", "unittest", "local_flytekit", "abcdef123", "blah.txt" + ) + assert local_raw_fp.get_random_local_directory() == os.path.join( + root, "tmp", "unittest", "local_flytekit", "abcdef123" + ) + + # Recursive paths + assert "file:///abc/happy/", "s3://my-s3-bucket/bucket1/" == local_raw_fp.recursive_paths( + "file:///abc/happy/", "s3://my-s3-bucket/bucket1/" + ) + assert "file:///abc/happy/", "s3://my-s3-bucket/bucket1/" == local_raw_fp.recursive_paths( + "file:///abc/happy", "s3://my-s3-bucket/bucket1" + ) + + # Test with remote pointed to s3. + s3_fa = FileAccessProvider(local_sandbox_dir=loc_sandbox, raw_output_prefix="s3://my-s3-bucket") + assert s3_fa.get_random_remote_path() == "s3://my-s3-bucket/abcdef123" + assert s3_fa.get_random_remote_directory() == "s3://my-s3-bucket/abcdef123" + # trailing slash should make no difference + s3_fa = FileAccessProvider(local_sandbox_dir=loc_sandbox, raw_output_prefix="s3://my-s3-bucket/") + assert s3_fa.get_random_remote_path() == "s3://my-s3-bucket/abcdef123" + assert s3_fa.get_random_remote_directory() == "s3://my-s3-bucket/abcdef123" + + # Testing with raw output prefix pointing to file:// + # Skip tests for windows + if os.name != "nt": + file_raw_fp = FileAccessProvider(local_sandbox_dir=loc_sandbox, raw_output_prefix="file:///tmp/unittestdata") + assert file_raw_fp.get_random_remote_path() == os.path.join(root, "tmp", "unittestdata", "abcdef123") + assert file_raw_fp.get_random_remote_path("/fsa/blah.csv") == os.path.join( + root, "tmp", "unittestdata", "abcdef123", "blah.csv" + ) + assert file_raw_fp.get_random_remote_directory() == os.path.join(root, "tmp", "unittestdata", "abcdef123") + + g_fa = FileAccessProvider(local_sandbox_dir=loc_sandbox, raw_output_prefix="gs://my-s3-bucket/") + assert g_fa.get_random_remote_path() == "gs://my-s3-bucket/abcdef123" + + +@mock.patch("flytekit.core.data_persistence.UUID") +def test_default_file_access_instance(mock_uuid_class): + mock_uuid_class.return_value.hex = "abcdef123" + + assert default_local_file_access_provider.get_random_local_path().endswith( + os.path.join("sandbox", "local_flytekit", "abcdef123") + ) + assert default_local_file_access_provider.get_random_local_path("bob.txt").endswith( + os.path.join("abcdef123", "bob.txt") + ) + + assert default_local_file_access_provider.get_random_local_directory().endswith( + os.path.join("sandbox", "local_flytekit", "abcdef123") + ) + + x = default_local_file_access_provider.get_random_remote_path() + assert x.endswith(os.path.join("raw", "abcdef123")) + x = default_local_file_access_provider.get_random_remote_path("eve.txt") + assert x.endswith(os.path.join("raw", "abcdef123", "eve.txt")) + x = default_local_file_access_provider.get_random_remote_directory() + assert x.endswith(os.path.join("raw", "abcdef123")) + + +@pytest.fixture +def source_folder(): + # Set up source directory for testing + parent_temp = tempfile.mkdtemp() + src_dir = os.path.join(parent_temp, "source", "") + nested_dir = os.path.join(src_dir, "nested") + local.mkdir(nested_dir) + local.touch(os.path.join(src_dir, "original.txt")) + local.touch(os.path.join(nested_dir, "more.txt")) + yield src_dir + shutil.rmtree(parent_temp) + + +def test_local_raw_fsspec(source_folder): + # Test copying using raw fsspec local filesystem, should not create a nested folder + with tempfile.TemporaryDirectory() as dest_tmpdir: + local.put(source_folder, dest_tmpdir, recursive=True) + + new_temp_dir_2 = tempfile.mkdtemp() + new_temp_dir_2 = os.path.join(new_temp_dir_2, "doesnotexist") + local.put(source_folder, new_temp_dir_2, recursive=True) + files = local.find(new_temp_dir_2) + assert len(files) == 2 + + +def test_local_provider(source_folder): + # Test that behavior putting from a local dir to a local remote dir is the same whether or not the local + # dest folder exists. + dc = Config.for_sandbox().data_config + with tempfile.TemporaryDirectory() as dest_tmpdir: + provider = FileAccessProvider(local_sandbox_dir="/tmp/unittest", raw_output_prefix=dest_tmpdir, data_config=dc) + doesnotexist = provider.get_random_remote_directory() + provider.put_data(source_folder, doesnotexist, is_multipart=True) + files = provider._default_remote.find(doesnotexist) + assert len(files) == 2 + + exists = provider.get_random_remote_directory() + provider._default_remote.mkdir(exists) + provider.put_data(source_folder, exists, is_multipart=True) + files = provider._default_remote.find(exists) + assert len(files) == 2 + + +@pytest.mark.sandbox_test +def test_s3_provider(source_folder): + # Running mkdir on s3 filesystem doesn't do anything so leaving out for now + dc = Config.for_sandbox().data_config + provider = FileAccessProvider( + local_sandbox_dir="/tmp/unittest", raw_output_prefix="s3://my-s3-bucket/testdata/", data_config=dc + ) + doesnotexist = provider.get_random_remote_directory() + provider.put_data(source_folder, doesnotexist, is_multipart=True) + fs = provider.get_filesystem_for_path(doesnotexist) + files = fs.find(doesnotexist) + assert len(files) == 2 + + +def test_local_provider_get_empty(): + dc = Config.for_sandbox().data_config + with tempfile.TemporaryDirectory() as empty_source: + with tempfile.TemporaryDirectory() as dest_folder: + provider = FileAccessProvider( + local_sandbox_dir="/tmp/unittest", raw_output_prefix=empty_source, data_config=dc + ) + provider.get_data(empty_source, dest_folder, is_multipart=True) + loc = provider.get_filesystem_for_path(dest_folder) + src_files = loc.find(empty_source) + assert len(src_files) == 0 + dest_files = loc.find(dest_folder) + assert len(dest_files) == 0 + + +@mock.patch("flytekit.configuration.get_config_file") +@mock.patch("os.environ") +def test_s3_setup_args_env_empty(mock_os, mock_get_config_file): + mock_get_config_file.return_value = None + mock_os.get.return_value = None + s3c = S3Config.auto() + kwargs = s3_setup_args(s3c) + assert kwargs == {} + + +@mock.patch("flytekit.configuration.get_config_file") +@mock.patch("os.environ") +def test_s3_setup_args_env_both(mock_os, mock_get_config_file): + mock_get_config_file.return_value = None + ee = { + "AWS_ACCESS_KEY_ID": "ignore-user", + "AWS_SECRET_ACCESS_KEY": "ignore-secret", + "FLYTE_AWS_ACCESS_KEY_ID": "flyte", + "FLYTE_AWS_SECRET_ACCESS_KEY": "flyte-secret", + } + mock_os.get.side_effect = lambda x, y: ee.get(x) + kwargs = s3_setup_args(S3Config.auto()) + assert kwargs == {"key": "flyte", "secret": "flyte-secret"} + + +@mock.patch("flytekit.configuration.get_config_file") +@mock.patch("os.environ") +def test_s3_setup_args_env_flyte(mock_os, mock_get_config_file): + mock_get_config_file.return_value = None + ee = { + "FLYTE_AWS_ACCESS_KEY_ID": "flyte", + "FLYTE_AWS_SECRET_ACCESS_KEY": "flyte-secret", + } + mock_os.get.side_effect = lambda x, y: ee.get(x) + kwargs = s3_setup_args(S3Config.auto()) + assert kwargs == {"key": "flyte", "secret": "flyte-secret"} + + +@mock.patch("flytekit.configuration.get_config_file") +@mock.patch("os.environ") +def test_s3_setup_args_env_aws(mock_os, mock_get_config_file): + mock_get_config_file.return_value = None + ee = { + "AWS_ACCESS_KEY_ID": "ignore-user", + "AWS_SECRET_ACCESS_KEY": "ignore-secret", + } + mock_os.get.side_effect = lambda x, y: ee.get(x) + kwargs = s3_setup_args(S3Config.auto()) + # not explicitly in kwargs, since fsspec/boto3 will use these env vars by default + assert kwargs == {} diff --git a/tests/flytekit/unit/core/test_data_persistence.py b/tests/flytekit/unit/core/test_data_persistence.py index af39e9e852..27b407c1ce 100644 --- a/tests/flytekit/unit/core/test_data_persistence.py +++ b/tests/flytekit/unit/core/test_data_persistence.py @@ -1,11 +1,11 @@ -from flytekit.core.data_persistence import DataPersistencePlugins, FileAccessProvider +from flytekit.core.data_persistence import FileAccessProvider def test_get_random_remote_path(): fp = FileAccessProvider("/tmp", "s3://my-bucket") path = fp.get_random_remote_path() assert path.startswith("s3://my-bucket") - assert fp.raw_output_prefix == "s3://my-bucket" + assert fp.raw_output_prefix == "s3://my-bucket/" def test_is_remote(): @@ -14,10 +14,3 @@ def test_is_remote(): assert fp.is_remote("/tmp/foo/bar") is False assert fp.is_remote("file://foo/bar") is False assert fp.is_remote("s3://my-bucket/foo/bar") is True - - -def test_lister(): - x = DataPersistencePlugins.supported_protocols() - main_protocols = {"file", "/", "gs", "http", "https", "s3"} - all_protocols = set([y.replace("://", "") for y in x]) - assert main_protocols.issubset(all_protocols) diff --git a/tests/flytekit/unit/core/test_flyte_directory.py b/tests/flytekit/unit/core/test_flyte_directory.py index 0cb4f524f9..bd20c39c53 100644 --- a/tests/flytekit/unit/core/test_flyte_directory.py +++ b/tests/flytekit/unit/core/test_flyte_directory.py @@ -49,7 +49,6 @@ def test_engine(): def test_transformer_to_literal_local(): - random_dir = context_manager.FlyteContext.current_context().file_access.get_random_local_directory() fs = FileAccessProvider(local_sandbox_dir=random_dir, raw_output_prefix=os.path.join(random_dir, "raw")) ctx = context_manager.FlyteContext.current_context() @@ -86,6 +85,15 @@ def test_transformer_to_literal_local(): with pytest.raises(TypeError, match="No automatic conversion from "): TypeEngine.to_literal(ctx, 3, FlyteDirectory, lt) + +def test_transformer_to_literal_localss(): + random_dir = context_manager.FlyteContext.current_context().file_access.get_random_local_directory() + fs = FileAccessProvider(local_sandbox_dir=random_dir, raw_output_prefix=os.path.join(random_dir, "raw")) + ctx = context_manager.FlyteContext.current_context() + with context_manager.FlyteContextManager.with_context(ctx.with_file_access(fs)) as ctx: + + tf = FlyteDirToMultipartBlobTransformer() + lt = tf.get_literal_type(FlyteDirectory) # Can't use if it's not a directory with pytest.raises(FlyteAssertion): p = "/tmp/flyte/xyz" diff --git a/tests/flytekit/unit/core/test_structured_dataset.py b/tests/flytekit/unit/core/test_structured_dataset.py index bfb41d0fef..eaba8b6343 100644 --- a/tests/flytekit/unit/core/test_structured_dataset.py +++ b/tests/flytekit/unit/core/test_structured_dataset.py @@ -1,9 +1,11 @@ +import os import tempfile import typing import pandas as pd import pyarrow as pa import pytest +from fsspec.utils import get_protocol from typing_extensions import Annotated import flytekit.configuration @@ -25,7 +27,6 @@ StructuredDatasetTransformerEngine, convert_schema_type_to_structured_dataset_type, extract_cols_and_format, - protocol_prefix, ) my_cols = kwtypes(w=typing.Dict[str, typing.Dict[str, int]], x=typing.List[typing.List[int]], y=int, z=str) @@ -44,8 +45,8 @@ def test_protocol(): - assert protocol_prefix("s3://my-s3-bucket/file") == "s3" - assert protocol_prefix("/file") == "file" + assert get_protocol("s3://my-s3-bucket/file") == "s3" + assert get_protocol("/file") == "file" def generate_pandas() -> pd.DataFrame: @@ -74,7 +75,6 @@ def t1(a: pd.DataFrame) -> pd.DataFrame: def test_setting_of_unset_formats(): - custom = Annotated[StructuredDataset, "parquet"] example = custom(dataframe=df, uri="/path") # It's okay that the annotation is not used here yet. @@ -89,7 +89,9 @@ def t2(path: str) -> StructuredDataset: def wf(path: str) -> StructuredDataset: return t2(path=path) - res = wf(path="/tmp/somewhere") + with tempfile.TemporaryDirectory() as tmp_dir: + fname = os.path.join(tmp_dir, "somewhere") + res = wf(path=fname) # Now that it's passed through an encoder however, it should be set. assert res.file_format == "parquet" @@ -281,7 +283,10 @@ def encode( # Check that registering with a / triggers the file protocol instead. StructuredDatasetTransformerEngine.register(TempEncoder("/")) - assert StructuredDatasetTransformerEngine.ENCODERS[MyDF].get("file") is not None + res = StructuredDatasetTransformerEngine.get_encoder(MyDF, "file", "/") + # Test that the one we got was registered under fsspec + assert res is StructuredDatasetTransformerEngine.ENCODERS[MyDF].get("fsspec")["/"] + assert res is not None def test_sd(): diff --git a/tests/flytekit/unit/core/test_structured_dataset_handlers.py b/tests/flytekit/unit/core/test_structured_dataset_handlers.py index c7aa5563f9..cef124ffd0 100644 --- a/tests/flytekit/unit/core/test_structured_dataset_handlers.py +++ b/tests/flytekit/unit/core/test_structured_dataset_handlers.py @@ -50,5 +50,5 @@ def test_arrow(): assert encoder.protocol is None assert decoder.protocol is None assert encoder.python_type is decoder.python_type - d = StructuredDatasetTransformerEngine.DECODERS[encoder.python_type]["s3"]["parquet"] + d = StructuredDatasetTransformerEngine.DECODERS[encoder.python_type]["fsspec"]["parquet"] assert d is not None diff --git a/tests/flytekit/unit/core/test_type_hints.py b/tests/flytekit/unit/core/test_type_hints.py index 373a536769..1913deb6bf 100644 --- a/tests/flytekit/unit/core/test_type_hints.py +++ b/tests/flytekit/unit/core/test_type_hints.py @@ -490,11 +490,13 @@ def t1(path: str) -> DatasetStruct: def wf(path: str) -> DatasetStruct: return t1(path=path) - res = wf(path="/tmp/somewhere") - assert "parquet" == res.a.file_format - assert "parquet" == res.b.a.file_format - assert_frame_equal(df, res.a.open(pd.DataFrame).all()) - assert_frame_equal(df, res.b.a.open(pd.DataFrame).all()) + with tempfile.TemporaryDirectory() as tmp_dir: + fname = os.path.join(tmp_dir, "df_file") + res = wf(path=fname) + assert "parquet" == res.a.file_format + assert "parquet" == res.b.a.file_format + assert_frame_equal(df, res.a.open(pd.DataFrame).all()) + assert_frame_equal(df, res.b.a.open(pd.DataFrame).all()) def test_wf1_with_map(): diff --git a/tests/flytekit/unit/core/tracker/test_arrow_data.py b/tests/flytekit/unit/core/tracker/test_arrow_data.py new file mode 100644 index 0000000000..747e7f1651 --- /dev/null +++ b/tests/flytekit/unit/core/tracker/test_arrow_data.py @@ -0,0 +1,29 @@ +import typing + +import pandas as pd +import pyarrow as pa +from typing_extensions import Annotated + +from flytekit import kwtypes, task + +cols = kwtypes(Name=str, Age=int) +subset_cols = kwtypes(Name=str) + + +@task +def t1( + df1: Annotated[pd.DataFrame, cols], df2: Annotated[pa.Table, cols] +) -> typing.Tuple[Annotated[pd.DataFrame, subset_cols], Annotated[pa.Table, subset_cols]]: + return df1, df2 + + +def test_structured_dataset_wf(): + pd_df = pd.DataFrame({"Name": ["Tom", "Joseph"], "Age": [20, 22]}) + pa_df = pa.Table.from_pandas(pd_df) + + subset_pd_df = pd.DataFrame({"Name": ["Tom", "Joseph"]}) + subset_pa_df = pa.Table.from_pandas(subset_pd_df) + + df1, df2 = t1(df1=pd_df, df2=pa_df) + assert df1.equals(subset_pd_df) + assert df2.equals(subset_pa_df) diff --git a/tests/flytekit/unit/extras/persistence/__init__.py b/tests/flytekit/unit/extras/persistence/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/flytekit/unit/extras/persistence/test_gcs_gsutil.py b/tests/flytekit/unit/extras/persistence/test_gcs_gsutil.py deleted file mode 100644 index d2c50cc4a9..0000000000 --- a/tests/flytekit/unit/extras/persistence/test_gcs_gsutil.py +++ /dev/null @@ -1,35 +0,0 @@ -import mock - -from flytekit import GCSPersistence - - -@mock.patch("flytekit.extras.persistence.gcs_gsutil._update_cmd_config_and_execute") -@mock.patch("flytekit.extras.persistence.gcs_gsutil.GCSPersistence._check_binary") -def test_put(mock_check, mock_exec): - proxy = GCSPersistence() - proxy.put("/test", "gs://my-bucket/k1") - mock_exec.assert_called_with(["gsutil", "cp", "/test", "gs://my-bucket/k1"]) - - -@mock.patch("flytekit.extras.persistence.gcs_gsutil._update_cmd_config_and_execute") -@mock.patch("flytekit.extras.persistence.gcs_gsutil.GCSPersistence._check_binary") -def test_put_recursive(mock_check, mock_exec): - proxy = GCSPersistence() - proxy.put("/test", "gs://my-bucket/k1", True) - mock_exec.assert_called_with(["gsutil", "cp", "-r", "/test/*", "gs://my-bucket/k1/"]) - - -@mock.patch("flytekit.extras.persistence.gcs_gsutil._update_cmd_config_and_execute") -@mock.patch("flytekit.extras.persistence.gcs_gsutil.GCSPersistence._check_binary") -def test_get(mock_check, mock_exec): - proxy = GCSPersistence() - proxy.get("gs://my-bucket/k1", "/test") - mock_exec.assert_called_with(["gsutil", "cp", "gs://my-bucket/k1", "/test"]) - - -@mock.patch("flytekit.extras.persistence.gcs_gsutil._update_cmd_config_and_execute") -@mock.patch("flytekit.extras.persistence.gcs_gsutil.GCSPersistence._check_binary") -def test_get_recursive(mock_check, mock_exec): - proxy = GCSPersistence() - proxy.get("gs://my-bucket/k1", "/test", True) - mock_exec.assert_called_with(["gsutil", "cp", "-r", "gs://my-bucket/k1/*", "/test"]) diff --git a/tests/flytekit/unit/extras/persistence/test_http.py b/tests/flytekit/unit/extras/persistence/test_http.py deleted file mode 100644 index 893b43f364..0000000000 --- a/tests/flytekit/unit/extras/persistence/test_http.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest - -from flytekit import HttpPersistence - - -def test_put(): - proxy = HttpPersistence() - with pytest.raises(AssertionError): - proxy.put("", "", recursive=True) - - -def test_construct_path(): - proxy = HttpPersistence() - with pytest.raises(AssertionError): - proxy.construct_path(True, False, "", "") - - -def test_exists(): - proxy = HttpPersistence() - assert proxy.exists("https://flyte.org") diff --git a/tests/flytekit/unit/extras/persistence/test_s3_awscli.py b/tests/flytekit/unit/extras/persistence/test_s3_awscli.py deleted file mode 100644 index a6f29f36d6..0000000000 --- a/tests/flytekit/unit/extras/persistence/test_s3_awscli.py +++ /dev/null @@ -1,80 +0,0 @@ -from datetime import timedelta - -import mock - -from flytekit import S3Persistence -from flytekit.configuration import DataConfig, S3Config -from flytekit.extras.persistence import s3_awscli - - -def test_property(): - aws = S3Persistence("s3://raw-output") - assert aws.default_prefix == "s3://raw-output" - - -def test_construct_path(): - aws = S3Persistence() - p = aws.construct_path(True, False, "xyz") - assert p == "s3://xyz" - - -@mock.patch("flytekit.extras.persistence.s3_awscli.S3Persistence._check_binary") -@mock.patch("flytekit.extras.persistence.s3_awscli.subprocess") -def test_retries(mock_subprocess, mock_check): - mock_subprocess.check_call.side_effect = Exception("test exception (404)") - mock_check.return_value = True - - proxy = S3Persistence(data_config=DataConfig(s3=S3Config(backoff=timedelta(seconds=0)))) - assert proxy.exists("s3://test/fdsa/fdsa") is False - assert mock_subprocess.check_call.call_count == 8 - - -def test_extra_args(): - assert s3_awscli._extra_args({}) == [] - assert s3_awscli._extra_args({"ContentType": "ct"}) == ["--content-type", "ct"] - assert s3_awscli._extra_args({"ContentEncoding": "ec"}) == ["--content-encoding", "ec"] - assert s3_awscli._extra_args({"ACL": "acl"}) == ["--acl", "acl"] - assert s3_awscli._extra_args({"ContentType": "ct", "ContentEncoding": "ec", "ACL": "acl"}) == [ - "--content-type", - "ct", - "--content-encoding", - "ec", - "--acl", - "acl", - ] - - -@mock.patch("flytekit.extras.persistence.s3_awscli._update_cmd_config_and_execute") -def test_put(mock_exec): - proxy = S3Persistence() - proxy.put("/test", "s3://my-bucket/k1") - mock_exec.assert_called_with( - cmd=["aws", "s3", "cp", "--acl", "bucket-owner-full-control", "/test", "s3://my-bucket/k1"], - s3_cfg=S3Config.auto(), - ) - - -@mock.patch("flytekit.extras.persistence.s3_awscli._update_cmd_config_and_execute") -def test_put_recursive(mock_exec): - proxy = S3Persistence() - proxy.put("/test", "s3://my-bucket/k1", True) - mock_exec.assert_called_with( - cmd=["aws", "s3", "cp", "--recursive", "--acl", "bucket-owner-full-control", "/test", "s3://my-bucket/k1"], - s3_cfg=S3Config.auto(), - ) - - -@mock.patch("flytekit.extras.persistence.s3_awscli._update_cmd_config_and_execute") -def test_get(mock_exec): - proxy = S3Persistence() - proxy.get("s3://my-bucket/k1", "/test") - mock_exec.assert_called_with(cmd=["aws", "s3", "cp", "s3://my-bucket/k1", "/test"], s3_cfg=S3Config.auto()) - - -@mock.patch("flytekit.extras.persistence.s3_awscli._update_cmd_config_and_execute") -def test_get_recursive(mock_exec): - proxy = S3Persistence() - proxy.get("s3://my-bucket/k1", "/test", True) - mock_exec.assert_called_with( - cmd=["aws", "s3", "cp", "--recursive", "s3://my-bucket/k1", "/test"], s3_cfg=S3Config.auto() - ) diff --git a/tests/flytekit/unit/remote/test_remote.py b/tests/flytekit/unit/remote/test_remote.py index 4b8f82fb7e..5e20eaeee3 100644 --- a/tests/flytekit/unit/remote/test_remote.py +++ b/tests/flytekit/unit/remote/test_remote.py @@ -177,14 +177,6 @@ def test_more_stuff(mock_client): with tempfile.TemporaryDirectory() as tmp_dir: r._upload_file(pathlib.Path(tmp_dir)) - # Test that this copies the file. - with tempfile.TemporaryDirectory() as tmp_dir: - mm = MagicMock() - mm.signed_url = os.path.join(tmp_dir, "tmp_file") - mock_client.return_value.get_upload_signed_url.return_value = mm - - r._upload_file(pathlib.Path(__file__)) - serialization_settings = flytekit.configuration.SerializationSettings( project="project", domain="domain", From 152080fa27c6d07edda9fcc2a602ca3ca9f4739a Mon Sep 17 00:00:00 2001 From: Josep Cugat Date: Mon, 13 Mar 2023 00:23:42 +0100 Subject: [PATCH 17/22] Less strict docker dependency versions (#1536) Signed-off-by: Josep Cugat Co-authored-by: Josep Cugat Co-authored-by: Kevin Su --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 11a24ccbe4..9c5266dfdb 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ "click>=6.6,<9.0", "croniter>=0.3.20,<4.0.0", "deprecated>=1.0,<2.0", - "docker>=5.0.3,<7.0.0", + "docker>=4.0.0,<7.0.0", "python-dateutil>=2.1", # Restrict grpcio and grpcio-status. Version 1.50.0 pulls in a version of protobuf that is not compatible # with the old protobuf library (as described in https://developers.google.com/protocol-buffers/docs/news/2022-05-06) From 34f80ba12eda64431be4c21c78df81b7afbe2758 Mon Sep 17 00:00:00 2001 From: Eduardo Apolinario <653394+eapolinario@users.noreply.github.com> Date: Tue, 14 Mar 2023 14:31:44 -0700 Subject: [PATCH 18/22] Pluck retry from flytekit and into sagemaker (#1411) * Remove retry from flytekit's setup.py and regenerate requirements Signed-off-by: Eduardo Apolinario * Add to sagemaker Signed-off-by: Eduardo Apolinario * Remove retry from sagemaker plugin requirements file Signed-off-by: Eduardo Apolinario * Restore doc-requirements.txt Signed-off-by: eduardo apolinario * Fix bad merge Signed-off-by: eduardo apolinario --------- Signed-off-by: Eduardo Apolinario Signed-off-by: eduardo apolinario Co-authored-by: Eduardo Apolinario --- .../flytekit-aws-sagemaker/requirements.txt | 9 ++++----- plugins/flytekit-aws-sagemaker/setup.py | 2 +- plugins/flytekit-duckdb/requirements.txt | 18 +++++++++++------- setup.py | 1 - 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/plugins/flytekit-aws-sagemaker/requirements.txt b/plugins/flytekit-aws-sagemaker/requirements.txt index dec5af37d2..203706448a 100644 --- a/plugins/flytekit-aws-sagemaker/requirements.txt +++ b/plugins/flytekit-aws-sagemaker/requirements.txt @@ -46,7 +46,8 @@ cryptography==38.0.4 dataclasses-json==0.5.7 # via flytekit decorator==5.1.1 - # via retry + # via + # retry2 deprecated==1.2.13 # via flytekit diskcache==5.4.0 @@ -147,8 +148,6 @@ protoc-gen-swagger==0.1.0 # via flyteidl psutil==5.9.4 # via sagemaker-training -py==1.11.0 - # via retry pyarrow==10.0.1 # via flytekit pycparser==2.21 @@ -188,8 +187,8 @@ requests==2.28.1 # responses responses==0.22.0 # via flytekit -retry==0.9.2 - # via flytekit +retry2==0.9.5 + # via flytekitplugins-awssagemaker retrying==1.3.4 # via sagemaker-training s3transfer==0.6.0 diff --git a/plugins/flytekit-aws-sagemaker/setup.py b/plugins/flytekit-aws-sagemaker/setup.py index b54e93d533..855dd32402 100644 --- a/plugins/flytekit-aws-sagemaker/setup.py +++ b/plugins/flytekit-aws-sagemaker/setup.py @@ -4,7 +4,7 @@ microlib_name = f"flytekitplugins-{PLUGIN_NAME}" -plugin_requires = ["flytekit>=1.3.0b2,<2.0.0", "sagemaker-training>=3.6.2,<4.0.0"] +plugin_requires = ["flytekit>=1.3.0b2,<2.0.0", "sagemaker-training>=3.6.2,<4.0.0", "retry2==0.9.5"] __version__ = "0.0.0+develop" diff --git a/plugins/flytekit-duckdb/requirements.txt b/plugins/flytekit-duckdb/requirements.txt index c69007f914..4bd2159b09 100644 --- a/plugins/flytekit-duckdb/requirements.txt +++ b/plugins/flytekit-duckdb/requirements.txt @@ -16,7 +16,7 @@ cffi==1.15.1 # via cryptography chardet==5.1.0 # via binaryornot -charset-normalizer==2.1.1 +charset-normalizer==3.0.1 # via requests click==8.1.3 # via @@ -32,8 +32,6 @@ cryptography==39.0.0 # via pyopenssl dataclasses-json==0.5.7 # via flytekit -decorator==5.1.1 - # via retry deprecated==1.2.13 # via flytekit diskcache==5.4.0 @@ -67,8 +65,12 @@ idna==3.4 # via requests importlib-metadata==6.0.0 # via + # click # flytekit + # jsonschema # keyring +importlib-resources==5.10.2 + # via keyring jaraco-classes==3.2.3 # via keyring jinja2==3.1.2 @@ -118,8 +120,6 @@ protobuf==4.21.12 # protoc-gen-swagger protoc-gen-swagger==0.1.0 # via flyteidl -py==1.11.0 - # via retry pyarrow==10.0.1 # via flytekit pycparser==2.21 @@ -148,7 +148,7 @@ pyyaml==6.0 # flytekit regex==2022.10.31 # via docker-image-py -requests==2.28.1 +requests==2.28.2 # via # cookiecutter # docker @@ -172,11 +172,15 @@ types-toml==0.10.8.1 # via responses typing-extensions==4.4.0 # via + # arrow # flytekit + # gitpython + # importlib-metadata + # responses # typing-inspect typing-inspect==0.8.0 # via dataclasses-json -urllib3==1.26.13 +urllib3==1.26.14 # via # docker # flytekit diff --git a/setup.py b/setup.py index 9c5266dfdb..18ffb75187 100644 --- a/setup.py +++ b/setup.py @@ -60,7 +60,6 @@ "statsd>=3.0.0,<4.0.0", "urllib3>=1.22,<2.0.0", "wrapt>=1.0.0,<2.0.0", - "retry==0.9.2", "dataclasses-json>=0.5.2", "marshmallow-jsonschema>=0.12.0", "natsort>=7.0.1", From 93995e29dcd37317e91ebcfb3019a4ffed0e82de Mon Sep 17 00:00:00 2001 From: Yee Hing Tong Date: Mon, 20 Mar 2023 11:10:45 -0700 Subject: [PATCH 19/22] Update the pypi wait (#1554) Signed-off-by: Yee Hing Tong --- .github/workflows/pythonpublish.yml | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 28febb3876..da0be3518e 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -47,11 +47,28 @@ jobs: run: | make -C plugins build_all_plugins make -C plugins publish_all_plugins - # Added sleep because PYPI take some time in publish - - name: Sleep for 180 seconds - uses: jakejarvis/wait-action@master - with: - time: '180s' + - name: Sleep until pypi is available + id: pypiwait + run: | + # from refs/tags/v1.2.3 get 1.2.3 and make sure it's not an empty string + VERSION=$(echo $GITHUB_REF | sed 's#.*/v##') + if [ -z "$VERSION" ] + then + echo "No tagged version found, exiting" + exit 1 + fi + LINK="https://pypi.org/project/flytekit/${VERSION}" + for i in {1..60}; do + if curl -L -I -s -f ${LINK} >/dev/null; then + echo "Found pypi" + exit 0 + else + echo "Did not find - Retrying in 10 seconds..." + sleep 10 + fi + done + exit 1 + shell: bash outputs: version: ${{ steps.bump.outputs.version }} From 98e74c273a61fd767ea331f5f0812d22a4f7aa57 Mon Sep 17 00:00:00 2001 From: Ketan Umare <16888709+kumare3@users.noreply.github.com> Date: Mon, 20 Mar 2023 16:52:25 -0700 Subject: [PATCH 20/22] Stream Directories and Files using Flyte (#1512) Signed-off-by: Ketan Umare Signed-off-by: Niels Bantilan Signed-off-by: Yee Hing Tong --- Dockerfile.dev | 16 +-- flytekit/clis/sdk_in_container/run.py | 2 +- flytekit/core/data_persistence.py | 14 +-- flytekit/core/type_engine.py | 1 - flytekit/types/directory/types.py | 85 ++++++++++++- flytekit/types/file/file.py | 67 +++++++++- flytekit/types/structured/basic_dfs.py | 30 ----- tests/flytekit/unit/core/test_data.py | 115 ++++++++++++++++++ tests/flytekit/unit/core/test_flyte_file.py | 79 +++++++++--- tests/flytekit/unit/core/tracker/d.py | 4 + .../unit/core/tracker/test_tracking.py | 7 ++ .../flytekit/unit/extras/sqlite3/chinook.zip | Bin 0 -> 305596 bytes .../flytekit/unit/extras/sqlite3/test_task.py | 5 +- .../flytekit/unit/types/directory/__init__.py | 0 .../unit/types/directory/test_types.py | 31 +++++ tests/flytekit/unit/types/file/__init__.py | 0 16 files changed, 380 insertions(+), 76 deletions(-) create mode 100644 tests/flytekit/unit/extras/sqlite3/chinook.zip create mode 100644 tests/flytekit/unit/types/directory/__init__.py create mode 100644 tests/flytekit/unit/types/directory/test_types.py create mode 100644 tests/flytekit/unit/types/file/__init__.py diff --git a/Dockerfile.dev b/Dockerfile.dev index f6baf63896..b7c5104bbc 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -12,27 +12,21 @@ MAINTAINER Flyte Team LABEL org.opencontainers.image.source https://github.com/flyteorg/flytekit WORKDIR /root -ENV PYTHONPATH /root ARG VERSION -ARG DOCKER_IMAGE RUN apt-get update && apt-get install build-essential vim -y -COPY . /code/flytekit -WORKDIR /code/flytekit +COPY . /flytekit # Pod tasks should be exposed in the default image -RUN pip install -e . -RUN pip install -e plugins/flytekit-k8s-pod -RUN pip install -e plugins/flytekit-deck-standard +RUN pip install -e /flytekit +RUN pip install -e /flytekit/plugins/flytekit-k8s-pod +RUN pip install -e /flytekit/plugins/flytekit-deck-standard RUN pip install scikit-learn -ENV PYTHONPATH "/code/flytekit:/code/flytekit/plugins/flytekit-k8s-pod:/code/flytekit/plugins/flytekit-deck-standard:" +ENV PYTHONPATH "/flytekit:/flytekit/plugins/flytekit-k8s-pod:/flytekit/plugins/flytekit-deck-standard:" -WORKDIR /root RUN useradd -u 1000 flytekit RUN chown flytekit: /root USER flytekit - -ENV FLYTE_INTERNAL_IMAGE "$DOCKER_IMAGE" diff --git a/flytekit/clis/sdk_in_container/run.py b/flytekit/clis/sdk_in_container/run.py index 136831c0bc..c45ec3f150 100644 --- a/flytekit/clis/sdk_in_container/run.py +++ b/flytekit/clis/sdk_in_container/run.py @@ -81,7 +81,7 @@ def convert( raise ValueError( f"Currently only directories containing one file are supported, found [{len(files)}] files found in {p.resolve()}" ) - return Directory(dir_path=value, local_file=files[0].resolve()) + return Directory(dir_path=str(p), local_file=files[0].resolve()) raise click.BadParameter(f"parameter should be a valid directory path, {value}") diff --git a/flytekit/core/data_persistence.py b/flytekit/core/data_persistence.py index 8fb73ebd8c..ea36689874 100644 --- a/flytekit/core/data_persistence.py +++ b/flytekit/core/data_persistence.py @@ -107,16 +107,16 @@ def data_config(self) -> DataConfig: return self._data_config def get_filesystem( - self, protocol: typing.Optional[str] = None, anonymous: bool = False + self, protocol: typing.Optional[str] = None, anonymous: bool = False, **kwargs ) -> typing.Optional[fsspec.AbstractFileSystem]: if not protocol: return self._default_remote - kwargs = {} # type: typing.Dict[str, typing.Any] if protocol == "file": - kwargs = {"auto_mkdir": True} + kwargs["auto_mkdir"] = True elif protocol == "s3": - kwargs = s3_setup_args(self._data_config.s3, anonymous=anonymous) - return fsspec.filesystem(protocol, **kwargs) # type: ignore + s3kwargs = s3_setup_args(self._data_config.s3, anonymous=anonymous) + s3kwargs.update(kwargs) + return fsspec.filesystem(protocol, **s3kwargs) # type: ignore elif protocol == "gs": if anonymous: kwargs["token"] = _ANON @@ -128,9 +128,9 @@ def get_filesystem( return fsspec.filesystem(protocol, **kwargs) # type: ignore - def get_filesystem_for_path(self, path: str = "", anonymous: bool = False) -> fsspec.AbstractFileSystem: + def get_filesystem_for_path(self, path: str = "", anonymous: bool = False, **kwargs) -> fsspec.AbstractFileSystem: protocol = get_protocol(path) - return self.get_filesystem(protocol, anonymous=anonymous) + return self.get_filesystem(protocol, anonymous=anonymous, **kwargs) @staticmethod def is_remote(path: Union[str, os.PathLike]) -> bool: diff --git a/flytekit/core/type_engine.py b/flytekit/core/type_engine.py index f21e93a774..306c4116ad 100644 --- a/flytekit/core/type_engine.py +++ b/flytekit/core/type_engine.py @@ -129,7 +129,6 @@ def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: f"Conversion to python value expected type {expected_python_type} from literal not implemented" ) - @abstractmethod def to_html(self, ctx: FlyteContext, python_val: T, expected_python_type: Type[T]) -> str: """ Converts any python val (dataframe, int, float) to a html string, and it will be wrapped in the HTML div diff --git a/flytekit/types/directory/types.py b/flytekit/types/directory/types.py index 7d576f9353..f4f23eb72f 100644 --- a/flytekit/types/directory/types.py +++ b/flytekit/types/directory/types.py @@ -2,20 +2,25 @@ import os import pathlib +import random import typing from dataclasses import dataclass, field from pathlib import Path +from typing import Any, Generator, Tuple +from uuid import UUID +import fsspec from dataclasses_json import config, dataclass_json +from fsspec.utils import get_protocol from marshmallow import fields -from flytekit.core.context_manager import FlyteContext +from flytekit.core.context_manager import FlyteContext, FlyteContextManager from flytekit.core.type_engine import TypeEngine, TypeTransformer from flytekit.models import types as _type_models from flytekit.models.core import types as _core_types from flytekit.models.literals import Blob, BlobMetadata, Literal, Scalar from flytekit.models.types import LiteralType -from flytekit.types.file import FileExt +from flytekit.types.file import FileExt, FlyteFile T = typing.TypeVar("T") PathType = typing.Union[str, os.PathLike] @@ -148,6 +153,18 @@ def __fspath__(self): def extension(cls) -> str: return "" + @classmethod + def new_remote(cls) -> FlyteDirectory: + """ + Create a new FlyteDirectory object using the currently configured default remote in the context (i.e. + the raw_output_prefix configured in the current FileAccessProvider object in the context). + This is used if you explicitly have a folder somewhere that you want to create files under. + If you want to write a whole folder, you can let your task return a FlyteDirectory object, + and let flytekit handle the uploading. + """ + d = FlyteContext.current_context().file_access.get_random_remote_directory() + return FlyteDirectory(path=d) + def __class_getitem__(cls, item: typing.Union[typing.Type, str]) -> typing.Type[FlyteDirectory]: if item is None: return cls @@ -176,6 +193,12 @@ def downloaded(self) -> bool: def remote_directory(self) -> typing.Optional[str]: return self._remote_directory + @property + def sep(self) -> str: + if os.name == "nt" and get_protocol(self.path or self.remote_source or self.remote_directory) == "file": + return "\\" + return "/" + @property def remote_source(self) -> str: """ @@ -184,9 +207,67 @@ def remote_source(self) -> str: """ return typing.cast(str, self._remote_source) + def new_file(self, name: typing.Optional[str] = None) -> FlyteFile: + """ + This will create a new file under the current folder. + If given a name, it will use the name given, otherwise it'll pick a random string. + Collisions are not checked. + """ + # TODO we may want to use - https://github.com/fsspec/universal_pathlib + if not name: + name = UUID(int=random.getrandbits(128)).hex + new_path = self.sep.join([str(self.path).rstrip(self.sep), name]) # trim trailing sep if any and join + return FlyteFile(path=new_path) + + def new_dir(self, name: typing.Optional[str] = None) -> FlyteDirectory: + """ + This will create a new folder under the current folder. + If given a name, it will use the name given, otherwise it'll pick a random string. + Collisions are not checked. + """ + if not name: + name = UUID(int=random.getrandbits(128)).hex + + new_path = self.sep.join([str(self.path).rstrip(self.sep), name]) # trim trailing sep if any and join + return FlyteDirectory(path=new_path) + def download(self) -> str: return self.__fspath__() + def crawl( + self, maxdepth: typing.Optional[int] = None, topdown: bool = True, **kwargs + ) -> Generator[Tuple[typing.Union[str, os.PathLike[Any]], typing.Dict[Any, Any]], None, None]: + """ + Crawl returns a generator of all files prefixed by any sub-folders under the given "FlyteDirectory". + if details=True is passed, then it will return a dictionary as specified by fsspec. + + Example: + + >>> list(fd.crawl()) + [("/base", "file1"), ("/base", "dir1/file1"), ("/base", "dir2/file1"), ("/base", "dir1/dir/file1")] + + >>> list(x.crawl(detail=True)) + [('/tmp/test', {'my-dir/ab.py': {'name': '/tmp/test/my-dir/ab.py', 'size': 0, 'type': 'file', + 'created': 1677720780.2318847, 'islink': False, 'mode': 33188, 'uid': 501, 'gid': 0, + 'mtime': 1677720780.2317934, 'ino': 1694329, 'nlink': 1}})] + """ + final_path = self.path + if self.remote_source: + final_path = self.remote_source + elif self.remote_directory: + final_path = self.remote_directory + ctx = FlyteContextManager.current_context() + fs = ctx.file_access.get_filesystem_for_path(final_path) + base_path_len = len(fsspec.core.strip_protocol(final_path)) + 1 # Add additional `/` at the end + for base, _, files in fs.walk(final_path, maxdepth, topdown, **kwargs): + current_base = base[base_path_len:] + if isinstance(files, dict): + for f, v in files.items(): + yield final_path, {os.path.join(current_base, f): v} + else: + for f in files: + yield final_path, os.path.join(current_base, f) + def __repr__(self): return self.path diff --git a/flytekit/types/file/file.py b/flytekit/types/file/file.py index 6537f85cae..23f4137344 100644 --- a/flytekit/types/file/file.py +++ b/flytekit/types/file/file.py @@ -3,12 +3,13 @@ import os import pathlib import typing +from contextlib import contextmanager from dataclasses import dataclass, field from dataclasses_json import config, dataclass_json from marshmallow import fields -from flytekit.core.context_manager import FlyteContext +from flytekit.core.context_manager import FlyteContext, FlyteContextManager from flytekit.core.type_engine import TypeEngine, TypeTransformer, TypeTransformerFailedError from flytekit.loggers import logger from flytekit.models.core.types import BlobType @@ -27,7 +28,9 @@ def noop(): @dataclass_json @dataclass class FlyteFile(os.PathLike, typing.Generic[T]): - path: typing.Union[str, os.PathLike] = field(default=None, metadata=config(mm_field=fields.String())) # type: ignore + path: typing.Union[str, os.PathLike] = field( + default=None, metadata=config(mm_field=fields.String()) + ) # type: ignore """ Since there is no native Python implementation of files and directories for the Flyte Blob type, (like how int exists for Flyte's Integer type) we need to create one so that users can express that their tasks take @@ -148,6 +151,15 @@ def t2() -> flytekit_typing.FlyteFile["csv"]: def extension(cls) -> str: return "" + @classmethod + def new_remote_file(cls, name: typing.Optional[str] = None) -> FlyteFile: + """ + Create a new FlyteFile object with a remote path. + """ + ctx = FlyteContextManager.current_context() + remote_path = ctx.file_access.get_random_remote_path(name) + return cls(path=remote_path) + def __class_getitem__(cls, item: typing.Union[str, typing.Type]) -> typing.Type[FlyteFile]: from . import FileExt @@ -226,6 +238,57 @@ def remote_source(self) -> str: def download(self) -> str: return self.__fspath__() + @contextmanager + def open( + self, + mode: str, + cache_type: typing.Optional[str] = None, + cache_options: typing.Optional[typing.Dict[str, typing.Any]] = None, + ): + """ + Returns a streaming File handle + + .. code-block:: python + + @task + def copy_file(ff: FlyteFile) -> FlyteFile: + new_file = FlyteFile.new_remote_file(ff.name) + with ff.open("rb", cache_type="readahead", cache={}) as r: + with new_file.open("wb") as w: + w.write(r.read()) + return new_file + + Alternatively + + .. code-block:: python + + @task + def copy_file(ff: FlyteFile) -> FlyteFile: + new_file = FlyteFile.new_remote_file(ff.name) + with fsspec.open(f"readahead::{ff.remote_path}", "rb", readahead={}) as r: + with new_file.open("wb") as w: + w.write(r.read()) + return new_file + + + :param mode: str Open mode like 'rb', 'rt', 'wb', ... + :param cache_type: optional str Specify if caching is to be used. Cache protocol can be ones supported by + fsspec https://filesystem-spec.readthedocs.io/en/latest/api.html#readbuffering, + especially useful for large file reads + :param cache_options: optional Dict[str, Any] Refer to fsspec caching options. This is strongly coupled to the + cache_protocol + """ + ctx = FlyteContextManager.current_context() + final_path = self.path + if self.remote_source: + final_path = self.remote_source + elif self.remote_path: + final_path = self.remote_path + fs = ctx.file_access.get_filesystem_for_path(final_path) + f = fs.open(final_path, mode, cache_type=cache_type, cache_options=cache_options) + yield f + f.close() + def __repr__(self): return self.path diff --git a/flytekit/types/structured/basic_dfs.py b/flytekit/types/structured/basic_dfs.py index ae3e8a00d9..c8f4ef3baa 100644 --- a/flytekit/types/structured/basic_dfs.py +++ b/flytekit/types/structured/basic_dfs.py @@ -62,22 +62,6 @@ def encode( structured_dataset_type.format = PARQUET return literals.StructuredDataset(uri=uri, metadata=StructuredDatasetMetadata(structured_dataset_type)) - def ddencode( - self, - ctx: FlyteContext, - structured_dataset: StructuredDataset, - structured_dataset_type: StructuredDatasetType, - ) -> literals.StructuredDataset: - - path = typing.cast(str, structured_dataset.uri) or ctx.file_access.get_random_remote_directory() - df = typing.cast(pd.DataFrame, structured_dataset.dataframe) - local_dir = ctx.file_access.get_random_local_directory() - local_path = os.path.join(local_dir, f"{0:05}") - df.to_parquet(local_path, coerce_timestamps="us", allow_truncated_timestamps=False) - ctx.file_access.upload_directory(local_dir, path) - structured_dataset_type.format = PARQUET - return literals.StructuredDataset(uri=path, metadata=StructuredDatasetMetadata(structured_dataset_type)) - class ParquetToPandasDecodingHandler(StructuredDatasetDecoder): def __init__(self): @@ -101,20 +85,6 @@ def decode( kwargs = get_storage_options(ctx.file_access.data_config, uri, anon=True) return pd.read_parquet(uri, columns=columns, storage_options=kwargs) - def dcccecode( - self, - ctx: FlyteContext, - flyte_value: literals.StructuredDataset, - current_task_metadata: StructuredDatasetMetadata, - ) -> pd.DataFrame: - path = flyte_value.uri - local_dir = ctx.file_access.get_random_local_directory() - ctx.file_access.get_data(path, local_dir, is_multipart=True) - if current_task_metadata.structured_dataset_type and current_task_metadata.structured_dataset_type.columns: - columns = [c.name for c in current_task_metadata.structured_dataset_type.columns] - return pd.read_parquet(local_dir, columns=columns) - return pd.read_parquet(local_dir) - class ArrowToParquetEncodingHandler(StructuredDatasetEncoder): def __init__(self): diff --git a/tests/flytekit/unit/core/test_data.py b/tests/flytekit/unit/core/test_data.py index 880036f636..1b33ad2923 100644 --- a/tests/flytekit/unit/core/test_data.py +++ b/tests/flytekit/unit/core/test_data.py @@ -1,13 +1,17 @@ import os +import random import shutil import tempfile +from uuid import UUID import fsspec import mock import pytest from flytekit.configuration import Config, S3Config +from flytekit.core.context_manager import FlyteContextManager from flytekit.core.data_persistence import FileAccessProvider, default_local_file_access_provider, s3_setup_args +from flytekit.types.directory.types import FlyteDirectory local = fsspec.filesystem("file") root = os.path.abspath(os.sep) @@ -99,6 +103,8 @@ def source_folder(): nested_dir = os.path.join(src_dir, "nested") local.mkdir(nested_dir) local.touch(os.path.join(src_dir, "original.txt")) + with open(os.path.join(src_dir, "original.txt"), "w") as fh: + fh.write("hello original") local.touch(os.path.join(nested_dir, "more.txt")) yield src_dir shutil.rmtree(parent_temp) @@ -213,3 +219,112 @@ def test_s3_setup_args_env_aws(mock_os, mock_get_config_file): kwargs = s3_setup_args(S3Config.auto()) # not explicitly in kwargs, since fsspec/boto3 will use these env vars by default assert kwargs == {} + + +def test_crawl_local_nt(source_folder): + """ + running this to see what it prints + """ + if os.name != "nt": # don't + return + source_folder = os.path.join(source_folder, "") # ensure there's a trailing / or \ + fd = FlyteDirectory(path=source_folder) + res = fd.crawl() + split = [(x, y) for x, y in res] + print(f"NT split {split}") + + # Test crawling a directory without trailing / or \ + source_folder = source_folder[:-1] + fd = FlyteDirectory(path=source_folder) + res = fd.crawl() + files = [os.path.join(x, y) for x, y in res] + print(f"NT files joined {files}") + + +def test_crawl_local_non_nt(source_folder): + """ + crawl on the source folder fixture should return for example + ('/var/folders/jx/54tww2ls58n8qtlp9k31nbd80000gp/T/tmpp14arygf/source/', 'original.txt') + ('/var/folders/jx/54tww2ls58n8qtlp9k31nbd80000gp/T/tmpp14arygf/source/', 'nested/more.txt') + """ + if os.name == "nt": # don't + return + source_folder = os.path.join(source_folder, "") # ensure there's a trailing / or \ + fd = FlyteDirectory(path=source_folder) + res = fd.crawl() + split = [(x, y) for x, y in res] + files = [os.path.join(x, y) for x, y in split] + assert set(split) == {(source_folder, "original.txt"), (source_folder, os.path.join("nested", "more.txt"))} + expected = {os.path.join(source_folder, "original.txt"), os.path.join(source_folder, "nested", "more.txt")} + assert set(files) == expected + + # Test crawling a directory without trailing / or \ + source_folder = source_folder[:-1] + fd = FlyteDirectory(path=source_folder) + res = fd.crawl() + files = [os.path.join(x, y) for x, y in res] + assert set(files) == expected + + # Test crawling a single file + fd = FlyteDirectory(path=os.path.join(source_folder, "original.txt")) + res = fd.crawl() + files = [os.path.join(x, y) for x, y in res] + assert len(files) == 0 + + +@pytest.mark.sandbox_test +def test_crawl_s3(source_folder): + """ + ('s3://my-s3-bucket/testdata/5b31492c032893b515650f8c76008cf7', 'original.txt') + ('s3://my-s3-bucket/testdata/5b31492c032893b515650f8c76008cf7', 'nested/more.txt') + """ + # Running mkdir on s3 filesystem doesn't do anything so leaving out for now + dc = Config.for_sandbox().data_config + provider = FileAccessProvider( + local_sandbox_dir="/tmp/unittest", raw_output_prefix="s3://my-s3-bucket/testdata/", data_config=dc + ) + s3_random_target = provider.get_random_remote_directory() + provider.put_data(source_folder, s3_random_target, is_multipart=True) + ctx = FlyteContextManager.current_context() + expected = {f"{s3_random_target}/original.txt", f"{s3_random_target}/nested/more.txt"} + + with FlyteContextManager.with_context(ctx.with_file_access(provider)): + fd = FlyteDirectory(path=s3_random_target) + res = fd.crawl() + res = [(x, y) for x, y in res] + files = [os.path.join(x, y) for x, y in res] + assert set(files) == expected + assert set(res) == {(s3_random_target, "original.txt"), (s3_random_target, os.path.join("nested", "more.txt"))} + + fd_file = FlyteDirectory(path=f"{s3_random_target}/original.txt") + res = fd_file.crawl() + files = [r for r in res] + assert len(files) == 1 + + +@pytest.mark.sandbox_test +def test_walk_local_copy_to_s3(source_folder): + dc = Config.for_sandbox().data_config + explicit_empty_folder = UUID(int=random.getrandbits(128)).hex + raw_output_path = f"s3://my-s3-bucket/testdata/{explicit_empty_folder}" + provider = FileAccessProvider(local_sandbox_dir="/tmp/unittest", raw_output_prefix=raw_output_path, data_config=dc) + + ctx = FlyteContextManager.current_context() + local_fd = FlyteDirectory(path=source_folder) + local_fd_crawl = local_fd.crawl() + local_fd_crawl = [x for x in local_fd_crawl] + with FlyteContextManager.with_context(ctx.with_file_access(provider)): + fd = FlyteDirectory.new_remote() + assert raw_output_path in fd.path + + # Write source folder files to new remote path + for root_path, suffix in local_fd_crawl: + new_file = fd.new_file(suffix) # noqa + with open(os.path.join(root_path, suffix), "rb") as r: # noqa + with new_file.open("w") as w: + print(f"Writing, t {type(w)} p {new_file.path} |{suffix}|") + w.write(str(r.read())) + + new_crawl = fd.crawl() + new_suffixes = [y for x, y in new_crawl] + assert len(new_suffixes) == 2 # should have written two files diff --git a/tests/flytekit/unit/core/test_flyte_file.py b/tests/flytekit/unit/core/test_flyte_file.py index e2123222e0..1c1593ad4c 100644 --- a/tests/flytekit/unit/core/test_flyte_file.py +++ b/tests/flytekit/unit/core/test_flyte_file.py @@ -7,9 +7,8 @@ import pytest import flytekit.configuration -from flytekit.configuration import Image, ImageConfig -from flytekit.core import context_manager -from flytekit.core.context_manager import ExecutionState +from flytekit.configuration import Config, Image, ImageConfig +from flytekit.core.context_manager import ExecutionState, FlyteContextManager from flytekit.core.data_persistence import FileAccessProvider, flyte_tmp_dir from flytekit.core.dynamic_workflow_task import dynamic from flytekit.core.launch_plan import LaunchPlan @@ -81,11 +80,10 @@ def t1() -> FlyteFile: def my_wf() -> FlyteFile: return t1() - random_dir = context_manager.FlyteContext.current_context().file_access.get_random_local_directory() - # print(f"Random: {random_dir}") + random_dir = FlyteContextManager.current_context().file_access.get_random_local_directory() fs = FileAccessProvider(local_sandbox_dir=random_dir, raw_output_prefix=os.path.join(random_dir, "mock_remote")) - ctx = context_manager.FlyteContext.current_context() - with context_manager.FlyteContextManager.with_context(ctx.with_file_access(fs)): + ctx = FlyteContextManager.current_context() + with FlyteContextManager.with_context(ctx.with_file_access(fs)): top_level_files = os.listdir(random_dir) assert len(top_level_files) == 1 # the flytekit_local folder @@ -108,10 +106,10 @@ def t1() -> FlyteFile: def my_wf() -> FlyteFile: return t1() - random_dir = context_manager.FlyteContext.current_context().file_access.get_random_local_directory() + random_dir = FlyteContextManager.current_context().file_access.get_random_local_directory() fs = FileAccessProvider(local_sandbox_dir=random_dir, raw_output_prefix=os.path.join(random_dir, "mock_remote")) - ctx = context_manager.FlyteContext.current_context() - with context_manager.FlyteContextManager.with_context(ctx.with_file_access(fs)): + ctx = FlyteContextManager.current_context() + with FlyteContextManager.with_context(ctx.with_file_access(fs)): top_level_files = os.listdir(random_dir) assert len(top_level_files) == 1 # the flytekit_local folder @@ -137,12 +135,12 @@ def my_wf() -> FlyteFile: return t1() # This creates a random directory that we know is empty. - random_dir = context_manager.FlyteContext.current_context().file_access.get_random_local_directory() + random_dir = FlyteContextManager.current_context().file_access.get_random_local_directory() # Creating a new FileAccessProvider will add two folderst to the random dir print(f"Random {random_dir}") fs = FileAccessProvider(local_sandbox_dir=random_dir, raw_output_prefix=os.path.join(random_dir, "mock_remote")) - ctx = context_manager.FlyteContext.current_context() - with context_manager.FlyteContextManager.with_context(ctx.with_file_access(fs)): + ctx = FlyteContextManager.current_context() + with FlyteContextManager.with_context(ctx.with_file_access(fs)): working_dir = os.listdir(random_dir) assert len(working_dir) == 1 # the local_flytekit folder @@ -189,11 +187,11 @@ def my_wf() -> FlyteFile: return t1() # This creates a random directory that we know is empty. - random_dir = context_manager.FlyteContext.current_context().file_access.get_random_local_directory() + random_dir = FlyteContextManager.current_context().file_access.get_random_local_directory() # Creating a new FileAccessProvider will add two folderst to the random dir fs = FileAccessProvider(local_sandbox_dir=random_dir, raw_output_prefix=os.path.join(random_dir, "mock_remote")) - ctx = context_manager.FlyteContext.current_context() - with context_manager.FlyteContextManager.with_context(ctx.with_file_access(fs)): + ctx = FlyteContextManager.current_context() + with FlyteContextManager.with_context(ctx.with_file_access(fs)): working_dir = os.listdir(random_dir) assert len(working_dir) == 1 # the local_flytekit dir @@ -243,8 +241,8 @@ def dyn(in1: FlyteFile): fd = FlyteFile("s3://anything") - with context_manager.FlyteContextManager.with_context( - context_manager.FlyteContextManager.current_context().with_serialization_settings( + with FlyteContextManager.with_context( + FlyteContextManager.current_context().with_serialization_settings( flytekit.configuration.SerializationSettings( project="test_proj", domain="test_domain", @@ -254,8 +252,8 @@ def dyn(in1: FlyteFile): ) ) ): - ctx = context_manager.FlyteContextManager.current_context() - with context_manager.FlyteContextManager.with_context( + ctx = FlyteContextManager.current_context() + with FlyteContextManager.with_context( ctx.with_execution_state(ctx.new_execution_state().with_params(mode=ExecutionState.Mode.TASK_EXECUTION)) ) as ctx: lit = TypeEngine.to_literal( @@ -433,3 +431,44 @@ def wf(path: str) -> os.PathLike: return t2(ff=n1) assert flyte_tmp_dir in wf(path="s3://somewhere").path + + +@pytest.mark.sandbox_test +def test_file_open_things(): + @task + def write_this_file_to_s3() -> FlyteFile: + ctx = FlyteContextManager.current_context() + dest = ctx.file_access.get_random_remote_path() + ctx.file_access.put(__file__, dest) + return FlyteFile(path=dest) + + @task + def copy_file(ff: FlyteFile) -> FlyteFile: + new_file = FlyteFile.new_remote_file(ff.remote_path) + with ff.open("r") as r: + with new_file.open("w") as w: + w.write(r.read()) + return new_file + + @task + def print_file(ff: FlyteFile): + with open(ff, "r") as fh: + print(len(fh.readlines())) + + dc = Config.for_sandbox().data_config + with tempfile.TemporaryDirectory() as new_sandbox: + provider = FileAccessProvider( + local_sandbox_dir=new_sandbox, raw_output_prefix="s3://my-s3-bucket/testdata/", data_config=dc + ) + ctx = FlyteContextManager.current_context() + local = ctx.file_access.get_filesystem("file") # get a local file system. + with FlyteContextManager.with_context(ctx.with_file_access(provider)): + f = write_this_file_to_s3() + copy_file(ff=f) + files = local.find(new_sandbox) + # copy_file was done via streaming so no files should have been written + assert len(files) == 0 + print_file(ff=f) + # print_file uses traditional download semantics so now a file should have been created + files = local.find(new_sandbox) + assert len(files) == 1 diff --git a/tests/flytekit/unit/core/tracker/d.py b/tests/flytekit/unit/core/tracker/d.py index 9385b0f08d..c84e36fe59 100644 --- a/tests/flytekit/unit/core/tracker/d.py +++ b/tests/flytekit/unit/core/tracker/d.py @@ -9,3 +9,7 @@ def tasks(): @task def foo(): pass + + +def inner_function(a: str) -> str: + return "hello" diff --git a/tests/flytekit/unit/core/tracker/test_tracking.py b/tests/flytekit/unit/core/tracker/test_tracking.py index 33ae18acd5..b33725436d 100644 --- a/tests/flytekit/unit/core/tracker/test_tracking.py +++ b/tests/flytekit/unit/core/tracker/test_tracking.py @@ -79,3 +79,10 @@ def test_extract_task_module(test_input, expected): except Exception: FeatureFlags.FLYTE_PYTHON_PACKAGE_ROOT = old raise + + +local_task = task(d.inner_function) + + +def test_local_task_wrap(): + assert local_task.instantiated_in == "tests.flytekit.unit.core.tracker.test_tracking" diff --git a/tests/flytekit/unit/extras/sqlite3/chinook.zip b/tests/flytekit/unit/extras/sqlite3/chinook.zip new file mode 100644 index 0000000000000000000000000000000000000000..6dd568fa6163f1383f027bd3fab5ac1833cda1e9 GIT binary patch literal 305596 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∨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

i^u}QxDsMW7n-L;sHkCW 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 zʒ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�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 Date: Tue, 21 Mar 2023 22:21:41 +0100 Subject: [PATCH 21/22] Make `FlyteFile` compatible with `Annotated[..., HashMethod]` (#1544) * fix: Make FlyteFile compatible with Annotated[..., HashMethod] See issue #3424 Signed-off-by: Adrian Rumpold * tests: Add test case for FlyteFile with HashMethod annotation Issue: #3424 Signed-off-by: Adrian Rumpold * fix: Use typing_extensions.Annotated for py3.8 compatibility Issue: #3424 Signed-off-by: Adrian Rumpold * fix: Use `get_args` and `get_origin` from typing_extensions for py3.8 compatibility Issue: #3424 Signed-off-by: Adrian Rumpold * fix(tests): Use fixture for local dummy file Signed-off-by: Adrian Rumpold --------- Signed-off-by: Adrian Rumpold --- flytekit/types/file/file.py | 5 +++++ tests/flytekit/unit/core/test_flyte_file.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/flytekit/types/file/file.py b/flytekit/types/file/file.py index 23f4137344..bb8feb3d9c 100644 --- a/flytekit/types/file/file.py +++ b/flytekit/types/file/file.py @@ -8,6 +8,7 @@ from dataclasses_json import config, dataclass_json from marshmallow import fields +from typing_extensions import Annotated, get_args, get_origin from flytekit.core.context_manager import FlyteContext, FlyteContextManager from flytekit.core.type_engine import TypeEngine, TypeTransformer, TypeTransformerFailedError @@ -335,6 +336,10 @@ def to_literal( if python_val is None: raise TypeTransformerFailedError("None value cannot be converted to a file.") + # Correctly handle `Annotated[FlyteFile, ...]` by extracting the origin type + if get_origin(python_type) is Annotated: + python_type = get_args(python_type)[0] + if not (python_type is os.PathLike or issubclass(python_type, FlyteFile)): raise ValueError(f"Incorrect type {python_type}, must be either a FlyteFile or os.PathLike") diff --git a/tests/flytekit/unit/core/test_flyte_file.py b/tests/flytekit/unit/core/test_flyte_file.py index 1c1593ad4c..b7f0a1aeee 100644 --- a/tests/flytekit/unit/core/test_flyte_file.py +++ b/tests/flytekit/unit/core/test_flyte_file.py @@ -5,12 +5,14 @@ from unittest.mock import MagicMock import pytest +from typing_extensions import Annotated import flytekit.configuration from flytekit.configuration import Config, Image, ImageConfig from flytekit.core.context_manager import ExecutionState, FlyteContextManager from flytekit.core.data_persistence import FileAccessProvider, flyte_tmp_dir from flytekit.core.dynamic_workflow_task import dynamic +from flytekit.core.hash import HashMethod from flytekit.core.launch_plan import LaunchPlan from flytekit.core.task import task from flytekit.core.type_engine import TypeEngine @@ -433,6 +435,21 @@ def wf(path: str) -> os.PathLike: assert flyte_tmp_dir in wf(path="s3://somewhere").path +def test_flyte_file_annotated_hashmethod(local_dummy_file): + def calc_hash(ff: FlyteFile) -> str: + return str(ff.path) + + @task + def t1(path: str) -> Annotated[FlyteFile, HashMethod(calc_hash)]: + return FlyteFile(path) + + @workflow + def wf(path: str) -> None: + t1(path=path) + + wf(path=local_dummy_file) + + @pytest.mark.sandbox_test def test_file_open_things(): @task From de2878903b594069f1629d7ee11bf59ab04b59f2 Mon Sep 17 00:00:00 2001 From: Niels Bantilan Date: Wed, 22 Mar 2023 20:18:57 -0400 Subject: [PATCH 22/22] move FlyteSchema deprecation warning to initialization method (#1558) * move FlyteSchema deprecation warning to initialization method Signed-off-by: Niels Bantilan * update Signed-off-by: Niels Bantilan --------- Signed-off-by: Niels Bantilan --- flytekit/types/schema/types.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flytekit/types/schema/types.py b/flytekit/types/schema/types.py index c380bcc481..ac6b71ba38 100644 --- a/flytekit/types/schema/types.py +++ b/flytekit/types/schema/types.py @@ -186,7 +186,6 @@ class FlyteSchema(object): """ This is the main schema class that users should use. """ - logger.warning("FlyteSchema is deprecated, use Structured Dataset instead.") @classmethod def columns(cls) -> typing.Dict[str, typing.Type]: @@ -203,6 +202,7 @@ def format(cls) -> SchemaFormat: def __class_getitem__( cls, columns: typing.Dict[str, typing.Type], fmt: SchemaFormat = SchemaFormat.PARQUET ) -> Type[FlyteSchema]: + logger.warning("FlyteSchema is deprecated, use Structured Dataset instead.") if columns is None: return FlyteSchema @@ -240,6 +240,7 @@ def __init__( supported_mode: SchemaOpenMode = SchemaOpenMode.WRITE, downloader: typing.Optional[typing.Callable] = None, ): + logger.warning("FlyteSchema is deprecated, use Structured Dataset instead.") if supported_mode == SchemaOpenMode.READ and remote_path is None: raise ValueError("To create a FlyteSchema in read mode, remote_path is required") if (