From 4fd5d813b18ef48792cd8b27e12350eff20c4ee3 Mon Sep 17 00:00:00 2001 From: Eduardo Apolinario <653394+eapolinario@users.noreply.github.com> Date: Wed, 17 May 2023 13:32:06 -0700 Subject: [PATCH] [release v1.2] Backport v1.6 (#1645) * Data subsystem (#1526) Signed-off-by: Ketan Umare Signed-off-by: Yee Hing Tong * 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 * Update the pypi wait (#1554) Signed-off-by: Yee Hing Tong * Stream Directories and Files using Flyte (#1512) Signed-off-by: Ketan Umare Signed-off-by: Niels Bantilan Signed-off-by: Yee Hing Tong * 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 * 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 * add pod_template and pod_template_name arguments for ContainerTask (#1515) * add pod_template and pod_template_name arguments for ContainerTask Signed-off-by: Felix Ruess * factor out _serialize_pod_spec into separate util function Signed-off-by: Felix Ruess * model file changes, couple other changes Signed-off-by: Yee Hing Tong * minor cleanup Signed-off-by: Felix Ruess * add unit test for container_task pod_template Signed-off-by: Felix Ruess * bump min version of flyteidl to 1.3.12 for pod template data config support Signed-off-by: Felix Ruess * require flyteidl==1.3.12 in doc-requirements.txt Signed-off-by: Felix Ruess --------- Signed-off-by: Felix Ruess Signed-off-by: Yee Hing Tong Co-authored-by: Yee Hing Tong * Pass locally defined scopes to RemoteClientConfigStore (#1553) Signed-off-by: franco-bocci * TypeTransformer for TensorFlow model (#1562) * TypeTransformer for TensorFlow model Signed-off-by: Samhita Alla * clean up Signed-off-by: Samhita Alla * clean up Signed-off-by: Samhita Alla * fix lint Signed-off-by: Samhita Alla --------- Signed-off-by: Samhita Alla * Remove fsspec flytekit plugin from main Dockerfile (#1561) Signed-off-by: Yee Hing Tong * Device auth flow / Headless auth (#1552) * Device auth flow Signed-off-by: Ketan Umare * Device AuthFlow is now available in flytekit Signed-off-by: Ketan Umare * unit tests Signed-off-by: Ketan Umare * test added Signed-off-by: Ketan Umare * updated Signed-off-by: Ketan Umare * Fixed test Signed-off-by: Ketan Umare * Fixed unit test Signed-off-by: Ketan Umare * Fixed lint errors Signed-off-by: Ketan Umare --------- Signed-off-by: Ketan Umare * url encode secret in client credentials flow (#1566) * url encode secret first Signed-off-by: Yee Hing Tong * nit Signed-off-by: Yee Hing Tong --------- Signed-off-by: Yee Hing Tong * Python run multiple files (#1559) * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * lint Signed-off-by: Kevin Su * update comment Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su * General Partial support in flytekit and multi-list support in flytekit (#1556) Signed-off-by: Ketan Umare * fix: Silence keyring warnings by changing to debug (#1568) Co-authored-by: ggydush-fn * Support GCP secrets (#1571) Signed-off-by: Yee Hing Tong * Automatically remove unused import (#1574) * Automatically remove unused import Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su * Disallow partial lists in map tasks (#1577) * Disallow partial lists in map tasks Signed-off-by: eduardo apolinario * Lint Signed-off-by: eduardo apolinario --------- Signed-off-by: eduardo apolinario Co-authored-by: eduardo apolinario * Remove duplicate reporting logic (#1578) Signed-off-by: Yee Hing Tong * [Core feature] Convert List[Any] to a single pickle file (#1535) * Convert List[Any] to a single pickle file * remove redundant code * keep batchSize only if type contain flytePickle * fix error * add batch support to translate_inputs_to_literals * add ci test Signed-off-by: Yicheng-Lu-llll * improve comment Signed-off-by: Yicheng-Lu-llll * add more ci test Signed-off-by: Yicheng-Lu-llll * improve Signed-off-by: Yicheng-Lu-llll * handle HashMethod case Signed-off-by: Yicheng-Lu-llll * improve format Signed-off-by: Yicheng-Lu-llll * improve Signed-off-by: Yicheng-Lu-llll * improve Signed-off-by: Yicheng-Lu-llll * improve Signed-off-by: Yicheng-Lu-llll * add test_is_batchable Signed-off-by: Yicheng-Lu-llll * Add BatchSize Signed-off-by: Kevin Su * test_batch_size Signed-off-by: Kevin Su --------- Signed-off-by: Yicheng-Lu-llll Signed-off-by: Kevin Su Co-authored-by: root Co-authored-by: Kevin Su * Improve authoring structure documentation (#1572) Signed-off-by: Samhita Alla * Unify sqlalchemy Dockerfiles (#1585) * Unify sqlalchemy Dockerfiles Signed-off-by: eduardo apolinario * Simplifyte sqlalchemy Dockerfile Signed-off-by: eduardo apolinario * Set pythonpath to /root Signed-off-by: eduardo apolinario --------- Signed-off-by: eduardo apolinario Co-authored-by: eduardo apolinario * Pyflyte build imageSpec (#1555) * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * pyflyte build Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * Support serialize and package Signed-off-by: Kevin Su * more tests Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * move to plugin Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * fixed tested Signed-off-by: Kevin Su * fixed tested Signed-off-by: Kevin Su * more tests Signed-off-by: Kevin Su * Add support passing yaml in pyflyte run Signed-off-by: Kevin Su * lint Signed-off-by: Kevin Su * lint Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su * Feature/3506: Improve Type Conversion errors, use rich to prettify error messages (#1582) * improve input type conversion error Signed-off-by: Niels Bantilan * fix lint Signed-off-by: Niels Bantilan * fix Signed-off-by: Niels Bantilan * add tests Signed-off-by: Niels Bantilan * add tests Signed-off-by: Niels Bantilan * add rich Signed-off-by: Niels Bantilan * fix lint Signed-off-by: Niels Bantilan * remove prototyping script, update loggers Signed-off-by: Niels Bantilan * update __init__.py Signed-off-by: Niels Bantilan * update logger Signed-off-by: Niels Bantilan * update logger Signed-off-by: Niels Bantilan * fix GE and pandera tests Signed-off-by: Niels Bantilan * fix lint Signed-off-by: Niels Bantilan --------- Signed-off-by: Niels Bantilan * Add support nested FlyteFile in pyflyte run (#1587) * Add support nested FlyteFile in pyflyte run Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su * Set cache_regions=True in the case of s3fs (#1592) * Set cache_regions=True in the case of s3fs * Linting Signed-off-by: eduardo apolinario * Fix tests Signed-off-by: eduardo apolinario * More linting Signed-off-by: eduardo apolinario --------- Signed-off-by: eduardo apolinario Co-authored-by: eduardo apolinario * Set default ComSpec when running on Windows (#1595) * set default ComSpec when running on Windows Signed-off-by: Kevin Su * lint Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su * Feature: add activate-launchplan command to pyflyte (#1588) * add activate-launchplan command to pyflyte Signed-off-by: Artem Petrov <58334441+wckdman@users.noreply.github.com> * fix linting Signed-off-by: Artem Petrov <58334441+wckdman@users.noreply.github.com> * rework launchplan command Signed-off-by: Artem Petrov <58334441+wckdman@users.noreply.github.com> * fix linting Signed-off-by: Artem Petrov <58334441+wckdman@users.noreply.github.com> --------- Signed-off-by: Artem Petrov <58334441+wckdman@users.noreply.github.com> * nit (#1596) Signed-off-by: Yee Hing Tong * Add log streaming to papermill plugin (#1129) * checkpoint Signed-off-by: Mike Zhong * Experimental implementation works well. Instead of messing with the class, we utilize the interpolation and context to dynamically generate a wrapper around the desired script. In the wrapper, we cd to the ctx.working_directory, export the env variables (handled by an additional method to convert dict to str), and then pass the arguments to the script Signed-off-by: Mike Zhong * fix spacing Signed-off-by: Mike Zhong * remove breakpoint Signed-off-by: Mike Zhong * Output ctx.working_directory as single output Signed-off-by: Mike Zhong * more doc strings Signed-off-by: Mike Zhong * more comments Signed-off-by: Mike Zhong * Added comments Signed-off-by: Mike Zhong * Add tests and test files from other branch Signed-off-by: Mike Zhong * minor fix, have function return the instance rather than create. It seems to get registered when flyte packages your project Signed-off-by: Mike Zhong * fix tests Signed-off-by: Mike Zhong * remove set flags not supported by sh Signed-off-by: Mike Zhong * fix spellcheck lint errors Signed-off-by: Mike Zhong * don't have a windows equivalent test script so bypassing those tests for now Signed-off-by: Mike Zhong * Add typing to make_export_string_from_env_dict params Signed-off-by: Mike Zhong * fixup doc string Signed-off-by: Mike Zhong * Refactored the new behavior out into a new class. Did not change implementation details at all Signed-off-by: Mike Zhong * Address linter issues and up test cov Signed-off-by: Mike Zhong * Skip test on windows, no equivalent script Signed-off-by: Mike Zhong * Run black and isort, address SC2236 Signed-off-by: Mike Zhong * Refactored class name to remove _, make utility function require name so multiple uses in a workflow don't break Signed-off-by: Mike Zhong * fix utility function Signed-off-by: Mike Zhong * fix utility function call in tests Signed-off-by: Mike Zhong * Fix isort on test_shell.py Signed-off-by: Mike Zhong * adding logging settings to papermill plugin Signed-off-by: Calvin Leather * set level to info logging * improved comments/docs --------- Signed-off-by: Mike Zhong Signed-off-by: Calvin Leather Co-authored-by: Mike Zhong Co-authored-by: Mike Zhong * Set lower bound for s3fs (#1598) * set lower bound for s3fs Signed-off-by: Kevin Su * update doc-requirements.txt Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su * Implement horovod task in mpi plugin (#1575) * Add horovod task to mpi plugin Signed-off-by: byhsu * Remove unused Signed-off-by: byhsu * add test Signed-off-by: byhsu * inherit from mpifunctiontask Signed-off-by: byhsu * fix format Signed-off-by: byhsu * fix format Signed-off-by: byhsu --------- Signed-off-by: byhsu Co-authored-by: byhsu * add ImageRender class for Flyte Decks (#1599) Signed-off-by: esad * Bump tensorflow from 2.10.0 to 2.11.1 in /plugins/flytekit-mlflow (#1586) Bumps [tensorflow](https://github.com/tensorflow/tensorflow) from 2.10.0 to 2.11.1. - [Release notes](https://github.com/tensorflow/tensorflow/releases) - [Changelog](https://github.com/tensorflow/tensorflow/blob/master/RELEASE.md) - [Commits](https://github.com/tensorflow/tensorflow/compare/v2.10.0...v2.11.1) --- updated-dependencies: - dependency-name: tensorflow dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Handle batchable lists of length 0 (#1600) Signed-off-by: eduardo apolinario Co-authored-by: eduardo apolinario * pyflyte run imperative workflow (#1597) * pyflyte run imperative workflow Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * lint Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su * Backfill should fill in the right input vars (#1593) * Backfill fix - Backfill was using incorrect arguments - backfill should use the argument that user provides or none at all Signed-off-by: Ketan Umare * Updated code Signed-off-by: Ketan Umare * fixed unit test Signed-off-by: Ketan Umare --------- Signed-off-by: Ketan Umare * Pyflyte prettified (#1602) * Pyflyte prettified Signed-off-by: Ketan Umare * added dependency Signed-off-by: Ketan Umare --------- Signed-off-by: Ketan Umare * pyflyte run now supports json/yaml files (#1606) * pyflyte run now supports json files Signed-off-by: Ketan Umare * added yaml support Signed-off-by: Ketan Umare * fixed parsing Signed-off-by: Ketan Umare * fixed windows test Signed-off-by: Ketan Umare --------- Signed-off-by: Ketan Umare * Add is_inside func to ImageSpec (#1601) * Add is_inside func to ImageSpec Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * Add is_inside func to ImageSpec Signed-off-by: Kevin Su * Add tests Signed-off-by: Kevin Su * rename Signed-off-by: Kevin Su * rename Signed-off-by: Kevin Su * check execution mode Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su * Measuring the time taken for each component when executing a task (#1581) * add time measurement framework Signed-off-by: Yicheng-Lu-llll * add time measurement framework Signed-off-by: Yicheng-Lu-llll * nit Signed-off-by: Yicheng-Lu-llll * nit Signed-off-by: Yicheng-Lu-llll * nit Signed-off-by: Yicheng-Lu-llll * nit Signed-off-by: Yicheng-Lu-llll * nit Signed-off-by: Yicheng-Lu-llll --------- Signed-off-by: Yicheng-Lu-llll * Skip the image building process if the check for its existence fails (#1614) * Fixed tests Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * lint Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * add lru cache Signed-off-by: Kevin Su * update Signed-off-by: Kevin Su * lint Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su * Enable torch elastic training (torchrun) (#1603) Signed-off-by: Ketan Umare Signed-off-by: Fabio Graetz This PR brings [torch elastic training (`torchrun`)](https://pytorch.org/docs/stable/elastic/run.html) to the pytorch plugin: ```python from flytekitplugins.kfpytorch import Elastic @task( task_config=Elastic( replicas=4, nproc_per_node=4, ... ), ... ) def train(...): ... ``` https://github.com/flyteorg/flyte/issues/3614 * update sphinx youtube plugin (#1619) * update sphinx youtube plugin Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su * Read polar dataframe without copying to local (#1618) Signed-off-by: Kevin Su * Add default spark image (#1616) * Add default spark image Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * fix tests Signed-off-by: Kevin Su * Fix tests Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su * Lazy load modules (#1590) * lazy load module Signed-off-by: Kevin Su * lazy load module Signed-off-by: Kevin Su * import Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * keep structured dataset in flytekit init Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * fixed tess Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * fixed tests Signed-off-by: Kevin Su * fixed tests Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * move import pandas to __init__ Signed-off-by: Kevin Su * use lazy import loader instead Signed-off-by: Kevin Su * Fixed tests Signed-off-by: Kevin Su * Fixed tests Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * fix tests Signed-off-by: Kevin Su * regular import Signed-off-by: Kevin Su * fixed tests Signed-off-by: Kevin Su * test Signed-off-by: Kevin Su * lint Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * lint Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su * Override node name with a name containing underscore (#1608) * override node_name with a name containing underscore Signed-off-by: Kevin Su * add comment Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su * External Plugin Service (grpc) (#1524) External Plugin Service * Include traceback in errors from admin (#1621) Signed-off-by: Yee Hing Tong * Add support for copying all the files in source root (#1622) * Add support for copying all the files in source root Signed-off-by: Kevin Su * Add tests Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su * fix PipelineModel transformer issue 3648 (#1623) Signed-off-by: esad * update GH workflow for external plugin service (#1624) Signed-off-by: Kevin Su * [Issue-3617] Enables FlyteFiles, FlyteDirectors, and StructuredDatasets inputs in papermill plugin (#1612) * Add support for using a list as an input for a subworkflow (#1605) Signed-off-by: Kevin Su * Improve task decorator type hints with overload (#1631) Without the overload, the decorated function does not have the proper type of PythonFunctionTask, leading to spurious type errors when attempting to register the task on a FlyteRemote object Signed-off-by: Matthew Hoffman * add annotation option for serialization (#1615) * add annotation option for serialization * add support for guessing type * formatting * test both directions * remove guessing for annotated types * clarify usage in test * Update tests/flytekit/unit/core/test_type_engine.py --------- Co-authored-by: Eli Bixby Co-authored-by: Yee Hing Tong * Delete removed data persistence classes from docs (#1633) Signed-off-by: Niels Bantilan * Improve workflow decorator type hints with overload (#1635) Previously, the workflow decorator is hinted as always returning a WorkflowBase, which is not true when _workflow_function is None; similar to #1631, we propose using typing.overload to differentiate the return type of workflow based on the value of _workflow_function Signed-off-by: Matthew Hoffman * Fix mypy errors (#1313) * wip Signed-off-by: Kevin Su * Fix mypy errors Signed-off-by: Kevin Su * Fix mypy errors Signed-off-by: Kevin Su * Fix tests Signed-off-by: Kevin Su * Fix tests Signed-off-by: Kevin Su * Fix tests Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * wip Signed-off-by: Kevin Su * fix tests Signed-off-by: Kevin Su * fix tests Signed-off-by: Kevin Su * fix test Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * Update type Signed-off-by: Kevin Su * Fix tests Signed-off-by: Kevin Su * Fix tests Signed-off-by: Kevin Su * Fix tests Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * update dev-requirements.txt Signed-off-by: Kevin Su * Address comment Signed-off-by: Kevin Su * upgrade torch Signed-off-by: Kevin Su * nit Signed-off-by: Kevin Su * lint Signed-off-by: Kevin Su --------- Signed-off-by: Kevin Su Signed-off-by: Kevin Su Co-authored-by: Yee Hing Tong * type-protobuf<4 Signed-off-by: eduardo apolinario * Allow for python 3.7 Signed-off-by: eduardo apolinario * Fix protobuf transformer merge error * Fix bad merge of s/_task_models/task_models/ * Add maxsize=128 to @lru_cache to cover python 3.7 Signed-off-by: eduardo apolinario * Lint Signed-off-by: eduardo apolinario * Regenerate doc-requirements.txt Signed-off-by: eduardo apolinario * Add missing call to lru_cache Signed-off-by: eduardo apolinario * Bump version and regenerate requirements in a few plugins Signed-off-by: eduardo apolinario * Revert "Fix mypy errors (#1313)" This reverts commit af491557ed615566d8c5cc70725b1a6a3c6a1b38. Signed-off-by: Eduardo Apolinario * Remove docs Signed-off-by: eduardo apolinario * More linting Signed-off-by: eduardo apolinario * Fix type of get_type_for_output_var Signed-off-by: eduardo apolinario * Remove tensorflow test_model * Replace use of shutil.copytree Signed-off-by: eduardo apolinario * Other use of copytree Signed-off-by: eduardo apolinario * Stringify paths in test_script_mode Signed-off-by: eduardo apolinario * os.path.isfile instead in imagespec Signed-off-by: eduardo apolinario * Remove tests Signed-off-by: eduardo apolinario * More linting Signed-off-by: eduardo apolinario * Remove flaky test Signed-off-by: eduardo apolinario --------- Signed-off-by: Ketan Umare Signed-off-by: Yee Hing Tong Signed-off-by: Eduardo Apolinario Signed-off-by: eduardo apolinario Signed-off-by: Niels Bantilan Signed-off-by: Adrian Rumpold Signed-off-by: Felix Ruess Signed-off-by: franco-bocci Signed-off-by: Samhita Alla Signed-off-by: Kevin Su Signed-off-by: Yicheng-Lu-llll Signed-off-by: Artem Petrov <58334441+wckdman@users.noreply.github.com> Signed-off-by: Mike Zhong Signed-off-by: Calvin Leather Signed-off-by: byhsu Signed-off-by: esad Signed-off-by: dependabot[bot] Signed-off-by: Matthew Hoffman Signed-off-by: Kevin Su Co-authored-by: Yee Hing Tong Co-authored-by: Eduardo Apolinario Co-authored-by: Ketan Umare <16888709+kumare3@users.noreply.github.com> Co-authored-by: Adrian Rumpold Co-authored-by: Niels Bantilan Co-authored-by: Felix Ruess Co-authored-by: Franco Bocci <121866694+franco-bocci@users.noreply.github.com> Co-authored-by: Samhita Alla Co-authored-by: Kevin Su Co-authored-by: ggydush <35151789+ggydush@users.noreply.github.com> Co-authored-by: ggydush-fn Co-authored-by: Yicheng-Lu-llll <51814063+Yicheng-Lu-llll@users.noreply.github.com> Co-authored-by: root Co-authored-by: Artem <58334441+wckdman@users.noreply.github.com> Co-authored-by: Calvin Leather Co-authored-by: Mike Zhong Co-authored-by: Mike Zhong Co-authored-by: ByronHsu Co-authored-by: byhsu Co-authored-by: peridotml <106936600+peridotml@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Fabio M. Graetz, Ph.D Co-authored-by: Matthew Hoffman Co-authored-by: Eli Bixby Co-authored-by: Eli Bixby --- .github/workflows/docs_build.yml | 26 - .github/workflows/pythonbuild.yml | 17 +- .github/workflows/pythonpublish.yml | 114 +++- .gitignore | 1 + .pre-commit-config.yaml | 4 + .readthedocs.yml | 2 + Dockerfile | 3 +- Dockerfile.dev | 32 ++ Dockerfile.external-plugin-service | 9 + Makefile | 1 + dev-requirements.in | 5 + doc-requirements.in | 3 +- doc-requirements.txt | 175 ++++-- docs/source/conf.py | 2 +- docs/source/design/authoring.rst | 101 ++-- flytekit/__init__.py | 14 +- flytekit/bin/entrypoint.py | 11 +- flytekit/clients/auth/auth_client.py | 4 +- flytekit/clients/auth/authenticator.py | 108 ++-- flytekit/clients/auth/exceptions.py | 8 + flytekit/clients/auth/keyring.py | 7 +- flytekit/clients/auth/token_client.py | 154 ++++++ flytekit/clients/auth_helper.py | 5 + flytekit/clients/friendly.py | 2 +- flytekit/clis/flyte_cli/main.py | 1 - flytekit/clis/sdk_in_container/backfill.py | 19 +- flytekit/clis/sdk_in_container/build.py | 127 +++++ flytekit/clis/sdk_in_container/constants.py | 3 +- flytekit/clis/sdk_in_container/helpers.py | 2 +- flytekit/clis/sdk_in_container/init.py | 2 +- flytekit/clis/sdk_in_container/launchplan.py | 74 +++ flytekit/clis/sdk_in_container/local_cache.py | 2 +- flytekit/clis/sdk_in_container/package.py | 2 +- flytekit/clis/sdk_in_container/pyflyte.py | 19 +- flytekit/clis/sdk_in_container/register.py | 2 +- flytekit/clis/sdk_in_container/run.py | 177 +++++-- flytekit/clis/sdk_in_container/serialize.py | 10 +- flytekit/clis/sdk_in_container/serve.py | 46 ++ .../clis/sdk_in_container/utils.py | 0 flytekit/configuration/__init__.py | 22 +- flytekit/configuration/default_images.py | 15 +- flytekit/core/base_task.py | 65 ++- flytekit/core/checkpointer.py | 4 +- flytekit/core/container_task.py | 55 +- flytekit/core/context_manager.py | 59 ++- flytekit/core/data_persistence.py | 498 ++++++------------ flytekit/core/interface.py | 52 +- flytekit/core/local_cache.py | 4 +- flytekit/core/map_task.py | 196 +++++-- flytekit/core/node.py | 4 +- flytekit/core/pod_template.py | 15 +- flytekit/core/promise.py | 47 +- flytekit/core/python_auto_container.py | 77 +-- flytekit/core/task.py | 70 ++- flytekit/core/tracker.py | 1 - flytekit/core/type_engine.py | 133 ++++- flytekit/core/utils.py | 154 +++++- flytekit/core/workflow.py | 107 +++- flytekit/deck/deck.py | 89 +++- flytekit/deck/html/template.html | 8 +- flytekit/deck/renderer.py | 22 +- flytekit/exceptions/scopes.py | 7 +- flytekit/extend/__init__.py | 4 +- flytekit/extend/backend/__init__.py | 0 flytekit/extend/backend/base_plugin.py | 107 ++++ .../extend/backend/external_plugin_service.py | 53 ++ 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/extras/sqlite3/task.py | 5 +- flytekit/extras/tasks/shell.py | 5 +- flytekit/extras/tensorflow/__init__.py | 3 +- flytekit/extras/tensorflow/model.py | 76 +++ flytekit/extras/tensorflow/record.py | 1 - flytekit/image_spec/__init__.py | 1 + flytekit/image_spec/image_spec.py | 170 ++++++ flytekit/lazy_import/__init__.py | 0 flytekit/lazy_import/lazy_module.py | 33 ++ flytekit/loggers.py | 29 +- flytekit/models/common.py | 4 +- flytekit/models/security.py | 6 +- flytekit/models/task.py | 16 +- flytekit/models/types.py | 2 +- flytekit/remote/backfill.py | 7 +- flytekit/remote/remote.py | 72 ++- flytekit/tools/fast_registration.py | 2 + flytekit/tools/repo.py | 4 +- flytekit/tools/script_mode.py | 106 ++-- flytekit/tools/serialize_helpers.py | 29 - flytekit/tools/translator.py | 8 +- flytekit/types/directory/types.py | 85 ++- flytekit/types/file/file.py | 72 ++- flytekit/types/pickle/__init__.py | 2 +- flytekit/types/pickle/pickle.py | 13 + flytekit/types/schema/types.py | 3 +- flytekit/types/structured/__init__.py | 59 ++- flytekit/types/structured/basic_dfs.py | 95 ++-- flytekit/types/structured/bigquery.py | 7 - .../types/structured/structured_dataset.py | 175 +++--- .../flytekit-aws-sagemaker/requirements.txt | 9 +- plugins/flytekit-aws-sagemaker/setup.py | 2 +- .../flytekitplugins/bigquery/__init__.py | 1 + .../bigquery/backend_plugin.py | 94 ++++ .../flytekitplugins/bigquery/task.py | 5 +- plugins/flytekit-bigquery/requirements.txt | 4 +- plugins/flytekit-bigquery/setup.py | 3 +- .../tests/test_backend_plugin.py | 94 ++++ .../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 + .../flytekitplugins/deck/renderer.py | 141 ++++- .../tests/test_renderer.py | 63 ++- plugins/flytekit-duckdb/requirements.txt | 18 +- plugins/flytekit-envd/README.md | 26 + .../flytekitplugins/envd/__init__.py | 13 + .../flytekitplugins/envd/image_builder.py | 73 +++ plugins/flytekit-envd/requirements.in | 2 + plugins/flytekit-envd/requirements.txt | 229 ++++++++ plugins/flytekit-envd/setup.py | 37 ++ plugins/flytekit-envd/tests/__init__.py | 0 .../flytekit-envd/tests/test_image_spec.py | 31 ++ plugins/flytekit-k8s-pod/tests/test_pod.py | 6 +- .../flytekitplugins/kfmpi/__init__.py | 2 +- .../flytekitplugins/kfmpi/task.py | 57 ++ .../flytekit-kf-mpi/tests/test_mpi_task.py | 25 +- plugins/flytekit-kf-pytorch/README.md | 3 + .../flytekitplugins/kfpytorch/__init__.py | 3 +- .../flytekitplugins/kfpytorch/models.py | 23 - .../flytekitplugins/kfpytorch/task.py | 203 ++++++- plugins/flytekit-kf-pytorch/requirements.txt | 101 ++-- plugins/flytekit-kf-pytorch/setup.py | 5 +- .../tests/test_elastic_task.py | 67 +++ plugins/flytekit-mlflow/dev-requirements.txt | 16 +- .../flytekitplugins/mlflow/tracking.py | 2 +- .../tests/test_mlflow_tracking.py | 2 +- plugins/flytekit-pandera/tests/test_plugin.py | 17 +- .../flytekitplugins/papermill/__init__.py | 2 +- .../flytekitplugins/papermill/task.py | 118 ++++- plugins/flytekit-papermill/tests/test_task.py | 42 +- .../tests/testdata/nb-simple.ipynb | 7 +- .../tests/testdata/nb-types.ipynb | 100 ++++ .../flytekitplugins/polars/sd_transformers.py | 9 +- .../tests/test_polars_plugin_sd.py | 12 + plugins/flytekit-spark/Dockerfile | 14 + .../spark/pyspark_transformers.py | 14 +- .../flytekitplugins/spark/task.py | 37 +- .../tests/test_pyspark_transformers.py | 11 + plugins/flytekit-sqlalchemy/Dockerfile | 19 + plugins/flytekit-sqlalchemy/Dockerfile.py3.10 | 25 - plugins/flytekit-sqlalchemy/Dockerfile.py3.7 | 25 - plugins/flytekit-sqlalchemy/Dockerfile.py3.8 | 25 - plugins/flytekit-sqlalchemy/Dockerfile.py3.9 | 25 - plugins/setup.py | 3 + setup.py | 10 +- .../unit/bin/test_python_entrypoint.py | 34 +- .../flytekit/unit/cli/pyflyte/imageSpec.yaml | 2 + .../unit/cli/pyflyte/image_spec_wf.py | 20 + .../unit/cli/pyflyte/test_backfill.py | 1 - tests/flytekit/unit/cli/pyflyte/test_build.py | 31 ++ .../unit/cli/pyflyte/test_launchplan.py | 34 ++ .../flytekit/unit/cli/pyflyte/test_package.py | 52 -- .../unit/cli/pyflyte/test_register.py | 2 +- tests/flytekit/unit/cli/pyflyte/test_run.py | 98 +++- tests/flytekit/unit/cli/pyflyte/test_serve.py | 9 + tests/flytekit/unit/cli/pyflyte/workflow.py | 8 +- .../unit/clients/auth/test_authenticator.py | 66 ++- .../unit/clients/auth/test_token_client.py | 81 +++ .../flytekit/unit/clients/test_auth_helper.py | 15 +- .../unit/configuration/test_image_config.py | 4 + .../core/flyte_functools/test_decorators.py | 17 +- .../flytekit/unit/core/image_spec/__init__.py | 0 .../unit/core/image_spec/test_image_spec.py | 50 ++ tests/flytekit/unit/core/test_checkpoint.py | 22 +- tests/flytekit/unit/core/test_checkpointer.py | 13 - .../flytekit/unit/core/test_container_task.py | 80 +++ .../unit/core/test_context_manager.py | 17 +- tests/flytekit/unit/core/test_data.py | 330 ++++++++++++ .../unit/core/test_data_persistence.py | 11 +- .../unit/core/test_flyte_directory.py | 10 +- tests/flytekit/unit/core/test_flyte_file.py | 96 +++- tests/flytekit/unit/core/test_flyte_pickle.py | 7 +- tests/flytekit/unit/core/test_map_task.py | 78 ++- .../flytekit/unit/core/test_node_creation.py | 3 +- tests/flytekit/unit/core/test_partials.py | 219 ++++++++ tests/flytekit/unit/core/test_promise.py | 13 +- .../unit/core/test_python_function_task.py | 11 + .../unit/core/test_structured_dataset.py | 17 +- .../core/test_structured_dataset_handlers.py | 2 +- tests/flytekit/unit/core/test_type_engine.py | 115 +++- tests/flytekit/unit/core/test_type_hints.py | 60 +-- tests/flytekit/unit/core/test_utils.py | 39 +- tests/flytekit/unit/core/test_workflows.py | 83 +++ tests/flytekit/unit/core/tracker/d.py | 4 + .../unit/core/tracker/test_arrow_data.py | 29 + .../unit/core/tracker/test_tracking.py | 7 + tests/flytekit/unit/deck/test_deck.py | 32 +- .../unit/extend/test_backend_plugin.py | 105 ++++ .../extras/persistence/test_gcs_gsutil.py | 35 -- .../unit/extras/persistence/test_http.py | 20 - .../unit/extras/persistence/test_s3_awscli.py | 80 --- .../flytekit/unit/extras/sqlite3/chinook.zip | Bin 0 -> 305596 bytes .../flytekit/unit/extras/sqlite3/test_task.py | 5 +- .../unit/extras/tensorflow/model/__init__.py | 0 .../tensorflow/model/test_transformations.py | 75 +++ .../unit/models/core/test_security.py | 13 + tests/flytekit/unit/remote/test_remote.py | 10 +- tests/flytekit/unit/tools/test_script_mode.py | 77 ++- .../flytekit/unit/types/directory/__init__.py | 0 .../unit/types/directory/test_types.py | 31 ++ tests/flytekit/unit/types/file/__init__.py | 0 216 files changed, 6599 insertions(+), 2791 deletions(-) delete mode 100644 .github/workflows/docs_build.yml create mode 100644 Dockerfile.dev create mode 100644 Dockerfile.external-plugin-service create mode 100644 flytekit/clients/auth/token_client.py create mode 100644 flytekit/clis/sdk_in_container/build.py create mode 100644 flytekit/clis/sdk_in_container/launchplan.py create mode 100644 flytekit/clis/sdk_in_container/serve.py rename tests/flytekit/unit/extras/persistence/__init__.py => flytekit/clis/sdk_in_container/utils.py (100%) create mode 100644 flytekit/extend/backend/__init__.py create mode 100644 flytekit/extend/backend/base_plugin.py create mode 100644 flytekit/extend/backend/external_plugin_service.py 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 create mode 100644 flytekit/extras/tensorflow/model.py create mode 100644 flytekit/image_spec/__init__.py create mode 100644 flytekit/image_spec/image_spec.py create mode 100644 flytekit/lazy_import/__init__.py create mode 100644 flytekit/lazy_import/lazy_module.py create mode 100644 plugins/flytekit-bigquery/flytekitplugins/bigquery/backend_plugin.py create mode 100644 plugins/flytekit-bigquery/tests/test_backend_plugin.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 plugins/flytekit-envd/README.md create mode 100644 plugins/flytekit-envd/flytekitplugins/envd/__init__.py create mode 100644 plugins/flytekit-envd/flytekitplugins/envd/image_builder.py create mode 100644 plugins/flytekit-envd/requirements.in create mode 100644 plugins/flytekit-envd/requirements.txt create mode 100644 plugins/flytekit-envd/setup.py create mode 100644 plugins/flytekit-envd/tests/__init__.py create mode 100644 plugins/flytekit-envd/tests/test_image_spec.py delete mode 100644 plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/models.py create mode 100644 plugins/flytekit-kf-pytorch/tests/test_elastic_task.py create mode 100644 plugins/flytekit-papermill/tests/testdata/nb-types.ipynb create mode 100644 plugins/flytekit-spark/Dockerfile create mode 100644 plugins/flytekit-sqlalchemy/Dockerfile delete mode 100644 plugins/flytekit-sqlalchemy/Dockerfile.py3.10 delete mode 100644 plugins/flytekit-sqlalchemy/Dockerfile.py3.7 delete mode 100644 plugins/flytekit-sqlalchemy/Dockerfile.py3.8 delete mode 100644 plugins/flytekit-sqlalchemy/Dockerfile.py3.9 create mode 100644 tests/flytekit/unit/cli/pyflyte/imageSpec.yaml create mode 100644 tests/flytekit/unit/cli/pyflyte/image_spec_wf.py create mode 100644 tests/flytekit/unit/cli/pyflyte/test_build.py create mode 100644 tests/flytekit/unit/cli/pyflyte/test_launchplan.py create mode 100644 tests/flytekit/unit/cli/pyflyte/test_serve.py create mode 100644 tests/flytekit/unit/clients/auth/test_token_client.py create mode 100644 tests/flytekit/unit/core/image_spec/__init__.py create mode 100644 tests/flytekit/unit/core/image_spec/test_image_spec.py create mode 100644 tests/flytekit/unit/core/test_container_task.py create mode 100644 tests/flytekit/unit/core/test_data.py create mode 100644 tests/flytekit/unit/core/test_partials.py create mode 100644 tests/flytekit/unit/core/tracker/test_arrow_data.py create mode 100644 tests/flytekit/unit/extend/test_backend_plugin.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 create mode 100644 tests/flytekit/unit/extras/sqlite3/chinook.zip create mode 100644 tests/flytekit/unit/extras/tensorflow/model/__init__.py create mode 100644 tests/flytekit/unit/extras/tensorflow/model/test_transformations.py create mode 100644 tests/flytekit/unit/models/core/test_security.py 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/.github/workflows/docs_build.yml b/.github/workflows/docs_build.yml deleted file mode 100644 index 4fd71ce3b0..0000000000 --- a/.github/workflows/docs_build.yml +++ /dev/null @@ -1,26 +0,0 @@ -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 75e356ab0a..e33346afe5 100644 --- a/.github/workflows/pythonbuild.yml +++ b/.github/workflows/pythonbuild.yml @@ -65,6 +65,7 @@ jobs: - flytekit-deck-standard - flytekit-dolt - flytekit-duckdb + - flytekit-envd - flytekit-greatexpectations - flytekit-hive - flytekit-k8s-pod @@ -156,19 +157,3 @@ jobs: uses: ludeeus/action-shellcheck@master with: ignore_paths: boilerplate - - docs: - runs-on: ubuntu-latest - steps: - - name: Fetch the code - uses: actions/checkout@v3 - - name: Set up Python 3.9 - uses: actions/setup-python@v4 - with: - python-version: 3.9 - - name: Install dependencies - run: | - python -m pip install --upgrade pip==21.2.4 setuptools wheel - pip install -r doc-requirements.txt - - name: Build the documentation - run: make -C docs html diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 097d82323e..169e0e58b7 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 }} @@ -120,6 +137,91 @@ jobs: tags: ${{ steps.sqlalchemy-names.outputs.tags }} build-args: | VERSION=${{ needs.deploy.outputs.version }} - file: ./plugins/flytekit-sqlalchemy/Dockerfile.py${{ matrix.python-version }} + PYTHON_VERSION=${{ matrix.python-version }} + file: ./plugins/flytekit-sqlalchemy/Dockerfile + cache-from: type=gha + cache-to: type=gha,mode=max + + build-and-push-external-plugin-service-images: + runs-on: ubuntu-latest + needs: deploy + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: "0" + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + - name: Login to GitHub Container Registry + if: ${{ github.event_name == 'release' }} + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: "${{ secrets.FLYTE_BOT_USERNAME }}" + password: "${{ secrets.FLYTE_BOT_PAT }}" + - name: Prepare External Plugin Service Image Names + id: external-plugin-service-names + uses: docker/metadata-action@v3 + with: + images: | + ghcr.io/${{ github.repository_owner }}/external-plugin-service + tags: | + latest + ${{ github.sha }} + ${{ needs.deploy.outputs.version }} + - name: Push External Plugin Service Image to GitHub Registry + uses: docker/build-push-action@v2 + with: + context: "." + platforms: linux/arm64, linux/amd64 + push: ${{ github.event_name == 'release' }} + tags: ${{ steps.external-plugin-service-names.outputs.tags }} + build-args: | + VERSION=${{ needs.deploy.outputs.version }} + file: ./Dockerfile.external-plugin-service + cache-from: type=gha + cache-to: type=gha,mode=max + + build-and-push-spark-images: + runs-on: ubuntu-latest + needs: deploy + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: "0" + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + - name: Login to GitHub Container Registry + if: ${{ github.event_name == 'release' }} + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: "${{ secrets.FLYTE_BOT_USERNAME }}" + password: "${{ secrets.FLYTE_BOT_PAT }}" + - name: Prepare Spark Image Names + id: spark-names + uses: docker/metadata-action@v3 + with: + images: | + ghcr.io/${{ github.repository_owner }}/flytekit + tags: | + spark-latest + spark-${{ github.sha }} + spark-${{ needs.deploy.outputs.version }} + - name: Push Spark Image to GitHub Registry + uses: docker/build-push-action@v2 + with: + context: "./plugins/flytekit-spark/" + platforms: linux/arm64, linux/amd64 + push: ${{ github.event_name == 'release' }} + tags: ${{ steps.spark-names.outputs.tags }} + build-args: | + VERSION=${{ needs.deploy.outputs.version }} + file: ./plugins/flytekit-spark/Dockerfile cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index a4fe02503e..b2e20249a8 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ htmlcov *.ipynb *dat docs/source/_tags/ +.hypothesis diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1fd6e6b648..3007f6e64d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,3 +22,7 @@ repos: rev: v0.8.0.4 hooks: - id: shellcheck +- repo: https://github.com/conorfalvey/check_pdb_hook + rev: 0.0.9 + hooks: + - id: check_pdb_hook diff --git a/.readthedocs.yml b/.readthedocs.yml index 19b1898e94..18f4292317 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -9,6 +9,8 @@ build: os: ubuntu-20.04 tools: python: "3.9" + apt_packages: + - graphviz # Build documentation in the docs/ directory with Sphinx sphinx: diff --git a/Dockerfile b/Dockerfile index 9aa462781c..257fcb5143 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,7 @@ RUN apt-get update && apt-get install build-essential -y RUN pip install -U flytekit==$VERSION \ flytekitplugins-pod==$VERSION \ flytekitplugins-deck-standard==$VERSION \ - flytekitplugins-data-fsspec[aws]==$VERSION \ - flytekitplugins-data-fsspec[gcp]==$VERSION \ + flytekitplugins-envd==$VERSION \ scikit-learn RUN useradd -u 1000 flytekit diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000000..b7c5104bbc --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,32 @@ +# 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 + +ARG VERSION + +RUN apt-get update && apt-get install build-essential vim -y + +COPY . /flytekit + +# Pod tasks should be exposed in the default image +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 "/flytekit:/flytekit/plugins/flytekit-k8s-pod:/flytekit/plugins/flytekit-deck-standard:" + +RUN useradd -u 1000 flytekit +RUN chown flytekit: /root +USER flytekit diff --git a/Dockerfile.external-plugin-service b/Dockerfile.external-plugin-service new file mode 100644 index 0000000000..2194f5de23 --- /dev/null +++ b/Dockerfile.external-plugin-service @@ -0,0 +1,9 @@ +FROM python:3.9-slim-buster + +MAINTAINER Flyte Team +LABEL org.opencontainers.image.source=https://github.com/flyteorg/flytekit + +ARG VERSION +RUN pip install -U flytekit==$VERSION flytekitplugins-bigquery==$VERSION + +CMD pyflyte serve --port 8000 diff --git a/Makefile b/Makefile index eb9f43cdb6..1f50a8d95d 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,7 @@ setup: install-piptools ## Install requirements .PHONY: fmt fmt: ## Format code with black and isort + autoflake --remove-all-unused-imports --ignore-init-module-imports --ignore-pass-after-docstring --in-place -r flytekit plugins tests pre-commit run black --all-files || true pre-commit run isort --all-files || true diff --git a/dev-requirements.in b/dev-requirements.in index 8655b92bae..a912d8c9d9 100644 --- a/dev-requirements.in +++ b/dev-requirements.in @@ -2,6 +2,7 @@ git+https://github.com/flyteorg/pytest-flyte@main#egg=pytest-flyte coverage[toml] +hypothesis joblib mock pytest @@ -21,3 +22,7 @@ tensorflow==2.8.1; platform_machine!='arm64' or platform_system!='Darwin' # we put this constraint while we do not have per-environment requirements files torch<=1.12.1 scikit-learn +types-protobuf<4 +types-croniter +types-mock +autoflake diff --git a/doc-requirements.in b/doc-requirements.in index e17b05ec5b..b495e7616f 100644 --- a/doc-requirements.in +++ b/doc-requirements.in @@ -1,6 +1,7 @@ . -e file:.#egg=flytekit +grpcio<=1.49.1 git+https://github.com/flyteorg/furo@main sphinx sphinx-gallery @@ -11,7 +12,7 @@ sphinx-autoapi sphinx-copybutton sphinx_fontawesome sphinx-panels -sphinxcontrib-yt +sphinxcontrib-youtube cryptography google-api-core[grpc] scikit-learn diff --git a/doc-requirements.txt b/doc-requirements.txt index 2616e043df..7da1b07b74 100644 --- a/doc-requirements.txt +++ b/doc-requirements.txt @@ -10,8 +10,24 @@ absl-py==1.4.0 # via # tensorboard # tensorflow +adal==1.2.7 + # via azure-datalake-store +adlfs==2023.1.0 + # via flytekit +aiobotocore==2.5.0 + # via s3fs +aiohttp==3.8.4 + # via + # adlfs + # aiobotocore + # gcsfs + # s3fs +aioitertools==0.11.0 + # via aiobotocore aiosignal==1.3.1 - # via ray + # via + # aiohttp + # ray alabaster==0.7.13 # via sphinx alembic==1.9.2 @@ -46,11 +62,25 @@ asttokens==2.2.1 # via stack-data astunparse==1.6.3 # via tensorflow +async-timeout==4.0.2 + # via aiohttp attrs==22.2.0 # via + # aiohttp # jsonschema # ray # visions +azure-core==1.26.4 + # via + # adlfs + # azure-identity + # azure-storage-blob +azure-datalake-store==0.0.52 + # via adlfs +azure-identity==1.12.0 + # via adlfs +azure-storage-blob==12.16.0 + # via adlfs babel==2.11.0 # via sphinx backcall==0.2.0 @@ -67,8 +97,10 @@ blake3==0.3.3 # via vaex-core bleach==6.0.0 # via nbconvert -botocore==1.29.61 - # via -r doc-requirements.in +botocore==1.29.76 + # via + # -r doc-requirements.in + # aiobotocore bqplot==0.12.36 # via # ipyvolume @@ -86,31 +118,33 @@ certifi==2022.12.7 cffi==1.15.1 # via # argon2-cffi-bindings + # azure-datalake-store # cryptography cfgv==3.3.1 # via pre-commit chardet==5.0.0 # via binaryornot charset-normalizer==3.0.1 - # via requests + # via + # aiohttp + # requests click==8.1.3 # via # cookiecutter # dask # databricks-cli - # distributed # flask # flytekit # great-expectations # mlflow # papermill # ray + # rich-click # sphinx-click # uvicorn cloudpickle==2.2.1 # via # dask - # distributed # flytekit # mlflow # shap @@ -128,18 +162,20 @@ croniter==1.3.7 cryptography==39.0.0 # via # -r doc-requirements.in + # adal + # azure-identity + # azure-storage-blob # great-expectations + # msal + # pyjwt # pyopenssl # secretstorage css-html-js-minify==2.5.5 # via sphinx-material cycler==0.11.0 # via matplotlib -dask[distributed]==2023.1.1 - # via - # -r doc-requirements.in - # distributed - # vaex-core +dask==2023.1.1 + # via vaex-core databricks-cli==0.17.4 # via mlflow dataclasses-json==0.5.7 @@ -150,8 +186,8 @@ debugpy==1.6.6 # via ipykernel decorator==5.1.1 # via + # gcsfs # ipython - # retry defusedxml==0.7.1 # via nbconvert deprecated==1.2.13 @@ -160,8 +196,6 @@ diskcache==5.4.0 # via flytekit distlib==0.3.6 # via virtualenv -distributed==2023.1.1 - # via dask docker==6.0.1 # via # flytekit @@ -201,7 +235,7 @@ flatbuffers==2.0.7 # via # tensorflow # tf2onnx -flyteidl==1.2.9 +flyteidl==1.2.10 # via flytekit fonttools==4.38.0 # via matplotlib @@ -211,19 +245,26 @@ frozendict==2.3.4 # via vaex-core frozenlist==1.3.3 # via + # aiohttp # aiosignal # ray -fsspec==2023.1.0 +fsspec==2023.4.0 # via # -r doc-requirements.in + # adlfs # dask + # flytekit + # gcsfs # modin + # s3fs furo @ git+https://github.com/flyteorg/furo@main # via -r doc-requirements.in future==0.18.3 # via vaex-core gast==0.5.3 # via tensorflow +gcsfs==2023.4.0 + # via flytekit gitdb==4.0.10 # via gitpython gitpython==3.1.30 @@ -235,30 +276,42 @@ google-api-core[grpc]==2.11.0 # -r doc-requirements.in # google-cloud-bigquery # google-cloud-core + # google-cloud-storage google-auth==2.16.0 # via + # gcsfs # google-api-core # google-auth-oauthlib # google-cloud-core + # google-cloud-storage # kubernetes # tensorboard google-auth-oauthlib==0.4.6 - # via tensorboard + # via + # gcsfs + # tensorboard google-cloud==0.34.0 # via -r doc-requirements.in google-cloud-bigquery==3.5.0 # via -r doc-requirements.in google-cloud-core==2.3.2 - # via google-cloud-bigquery + # via + # google-cloud-bigquery + # google-cloud-storage +google-cloud-storage==2.8.0 + # via gcsfs google-crc32c==1.5.0 # via google-resumable-media google-pasta==0.2.0 # via tensorflow google-resumable-media==2.4.1 - # via google-cloud-bigquery + # via + # google-cloud-bigquery + # google-cloud-storage googleapis-common-protos==1.58.0 # via # flyteidl + # flytekit # google-api-core # grpcio-status great-expectations==0.15.46 @@ -267,6 +320,7 @@ greenlet==2.0.2 # via sqlalchemy grpcio==1.48.2 # via + # -r doc-requirements.in # flytekit # google-api-core # google-cloud-bigquery @@ -286,8 +340,6 @@ h5py==3.8.0 # via # tensorflow # vaex-hdf5 -heapdict==1.0.1 - # via zict htmlmin==0.1.12 # via ydata-profiling httptools==0.5.0 @@ -299,6 +351,7 @@ idna==3.4 # anyio # jsonschema # requests + # yarl imagehash==4.3.1 # via visions imagesize==1.4.1 @@ -364,6 +417,8 @@ ipywidgets==8.0.4 # ipyvue # jupyter # pythreejs +isodate==0.6.1 + # via azure-storage-blob isoduration==20.11.0 # via jsonschema itsdangerous==2.1.2 @@ -381,7 +436,6 @@ jinja2==3.1.2 # altair # branca # cookiecutter - # distributed # flask # great-expectations # jinja2-time @@ -469,9 +523,7 @@ libclang==15.0.6.1 llvmlite==0.39.1 # via numba locket==1.0.0 - # via - # distributed - # partd + # via partd lxml==4.9.2 # via sphinx-material makefun==1.15.0 @@ -526,10 +578,18 @@ modin==0.18.1 # via -r doc-requirements.in more-itertools==9.0.0 # via jaraco-classes +msal==1.22.0 + # via + # azure-identity + # msal-extensions +msal-extensions==1.0.0 + # via azure-identity msgpack==1.0.4 + # via ray +multidict==6.0.4 # via - # distributed - # ray + # aiohttp + # yarl multimethod==1.9.1 # via # visions @@ -650,7 +710,6 @@ packaging==22.0 # via # astropy # dask - # distributed # docker # google-cloud-bigquery # great-expectations @@ -720,7 +779,9 @@ platformdirs==2.6.2 # virtualenv plotly==5.13.0 # via -r doc-requirements.in -pre-commit==3.0.2 +portalocker==2.7.0 + # via msal-extensions +pre-commit==3.0.4 # via sphinx-tags progressbar2==4.2.0 # via vaex-core @@ -757,7 +818,6 @@ protoc-gen-swagger==0.1.0 # via flyteidl psutil==5.9.4 # via - # distributed # ipykernel # modin ptyprocess==0.7.0 @@ -766,8 +826,6 @@ ptyprocess==0.7.0 # terminado pure-eval==0.2.2 # via stack-data -py==1.11.0 - # via retry py4j==0.10.9.5 # via pyspark pyarrow==6.0.1 @@ -802,8 +860,11 @@ pygments==2.14.0 # rich # sphinx # sphinx-prompt -pyjwt==2.6.0 - # via databricks-cli +pyjwt[crypto]==2.6.0 + # via + # adal + # databricks-cli + # msal pyopenssl==23.0.0 # via flytekit pyparsing==3.0.9 @@ -816,6 +877,7 @@ pyspark==3.3.1 # via -r doc-requirements.in python-dateutil==2.8.2 # via + # adal # arrow # botocore # croniter @@ -859,7 +921,6 @@ pyyaml==6.0 # astropy # cookiecutter # dask - # distributed # flytekit # jupyter-events # kubernetes @@ -891,21 +952,28 @@ regex==2022.10.31 # via docker-image-py requests==2.28.2 # via + # adal + # azure-core + # azure-datalake-store # cookiecutter # databricks-cli # docker # flytekit + # gcsfs # google-api-core # google-cloud-bigquery + # google-cloud-storage # great-expectations # ipyvolume # kubernetes # mlflow + # msal # papermill # ray # requests-oauthlib # responses # sphinx + # sphinxcontrib-youtube # tensorboard # tf2onnx # vaex-core @@ -916,8 +984,6 @@ requests-oauthlib==1.3.1 # kubernetes responses==0.22.0 # via flytekit -retry==0.9.2 - # via flytekit rfc3339-validator==0.1.4 # via # jsonschema @@ -927,14 +993,21 @@ rfc3986-validator==0.1.1 # jsonschema # jupyter-events rich==13.3.1 - # via vaex-core + # via + # flytekit + # rich-click + # vaex-core +rich-click==1.6.1 + # via flytekit rsa==4.9 # via google-auth ruamel-yaml==0.17.17 # via great-expectations ruamel-yaml-clib==0.2.7 # via ruamel-yaml -scikit-learn==1.1.1 +s3fs==2023.4.0 + # via flytekit +scikit-learn==1.2.1 # via # -r doc-requirements.in # mlflow @@ -966,11 +1039,14 @@ six==1.16.0 # via # asttokens # astunparse + # azure-core + # azure-identity # bleach # databricks-cli # google-auth # google-pasta # grpcio + # isodate # keras-preprocessing # kubernetes # patsy @@ -992,9 +1068,7 @@ sniffio==1.3.0 snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 - # via - # distributed - # flytekit + # via flytekit soupsieve==2.3.2.post1 # via beautifulsoup4 sphinx==4.5.0 @@ -1012,7 +1086,7 @@ sphinx==4.5.0 # sphinx-panels # sphinx-prompt # sphinx-tags - # sphinxcontrib-yt + # sphinxcontrib-youtube sphinx-autoapi==2.0.1 # via -r doc-requirements.in sphinx-basic-ng==1.0.0b1 @@ -1047,7 +1121,7 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx -sphinxcontrib-yt==0.2.2 +sphinxcontrib-youtube==1.2.0 # via -r doc-requirements.in sqlalchemy==1.4.46 # via @@ -1070,8 +1144,6 @@ tabulate==0.9.0 # vaex-core tangled-up-in-unicode==0.2.0 # via visions -tblib==1.7.0 - # via distributed tenacity==8.1.0 # via # papermill @@ -1112,13 +1184,11 @@ toolz==0.12.0 # via # altair # dask - # distributed # partd torch==1.13.1 # via -r doc-requirements.in tornado==6.2 # via - # distributed # ipykernel # jupyter-client # jupyter-server @@ -1167,7 +1237,10 @@ types-toml==0.10.8.1 # via responses typing-extensions==4.4.0 # via + # aioitertools # astroid + # azure-core + # azure-storage-blob # flytekit # great-expectations # onnx @@ -1194,7 +1267,6 @@ uri-template==1.2.0 urllib3==1.26.14 # via # botocore - # distributed # docker # flytekit # great-expectations @@ -1275,6 +1347,7 @@ widgetsnbextension==4.0.5 # via ipywidgets wrapt==1.14.1 # via + # aiobotocore # astroid # deprecated # flytekit @@ -1284,10 +1357,10 @@ xarray==2023.1.0 # via vaex-jupyter xyzservices==2022.9.0 # via ipyleaflet +yarl==1.8.2 + # via aiohttp ydata-profiling==4.0.0 # via pandas-profiling -zict==2.2.0 - # via distributed zipp==3.12.0 # via importlib-metadata diff --git a/docs/source/conf.py b/docs/source/conf.py index 205bcb8838..6c0663f6b5 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -57,7 +57,7 @@ "sphinx-prompt", "sphinx_copybutton", "sphinx_panels", - "sphinxcontrib.yt", + "sphinxcontrib.youtube", "sphinx_tags", "sphinx_click", ] diff --git a/docs/source/design/authoring.rst b/docs/source/design/authoring.rst index 46a094399d..3118a9c71b 100644 --- a/docs/source/design/authoring.rst +++ b/docs/source/design/authoring.rst @@ -1,40 +1,42 @@ .. _design-authoring: -####################### +################### Authoring Structure -####################### +################### .. tags:: Design, Basic -One of the core features of Flytekit is to enable users to write tasks and workflows. In this section, we will understand how it works internally. - -.. note:: - - Please refer to the `design doc `__. +Flytekit's main focus is to provide users with the ability to create their own tasks and workflows. +In this section, we'll take a closer look at how it works under the hood. ********************* Types and Type Engine ********************* -Flyte has its own type system, which is codified `in the IDL `__. Python has its own type system despite being a dynamic language, which is primarily explained in `PEP 484 `_. Flytekit needs to build a medium to bridge the gap between these two type systems. -Type Engine -============= -This primariliy happens through the :py:class:`flytekit.extend.TypeEngine`. This engine works by invoking a series of :py:class:`TypeTransformers `. Each transformer is responsible for providing the functionality that the engine requires for a given native Python type. +Flyte uses its own type system, which is defined in the `IDL `__. +Despite being a dynamic language, Python also has its own type system which is primarily explained in `PEP 484 `__. +Therefore, Flytekit needs to establish a means of bridging the gap between these two type systems. +This is primariliy accomplished through the use of :py:class:`flytekit.extend.TypeEngine`. +The ``TypeEngine`` works by invoking a series of :py:class:`TypeTransformers `. +Each transformer is responsible for providing the functionality that the engine requires for a given native Python type. ***************** Callable Entities ***************** -:ref:`Tasks `, :ref:`workflows `, and :ref:`launch plans ` form the core of the Flyte user experience. Each of these concepts is backed by one or more Python classes. These classes in turn, are instantiated by decorators (in the case of tasks and workflow) or a regular Python call (in the case of launch plans). + +The Flyte user experience is built around three main concepts: :ref:`Tasks `, :ref:`workflows `, and :ref:`launch plans `. +Each of these concepts is supported by one or more Python classes, which are instantiated by decorators (in the case of tasks and workflows) or a regular Python call (in the case of launch plans). Tasks ===== -This is the current task class hierarchy: + +Here is the existing hierarchy of task classes: .. inheritance-diagram:: flytekit.core.python_function_task.PythonFunctionTask flytekit.core.python_function_task.PythonInstanceTask flytekit.extras.sqlite3.task.SQLite3Task - :parts: 1 :top-classes: flytekit.core.base_task.Task + :parts: 1 -Please see the documentation on each of the classes for details. +For more information on each of the classes, please refer to the corresponding documentation. .. autoclass:: flytekit.core.base_task.Task :noindex: @@ -48,10 +50,10 @@ Please see the documentation on each of the classes for details. .. autoclass:: flytekit.core.python_function_task.PythonFunctionTask :noindex: - Workflows ========== -There are two workflow classes, and both inherit from the :py:class:`WorkflowBase ` class. + +There exist two workflow classes, both of which derive from the ``WorkflowBase`` class. .. autoclass:: flytekit.core.workflow.PythonFunctionWorkflow :noindex: @@ -59,10 +61,10 @@ There are two workflow classes, and both inherit from the :py:class:`WorkflowBas .. autoclass:: flytekit.core.workflow.ImperativeWorkflow :noindex: +Launch Plans +============ -Launch Plan -=========== -There is only one :py:class:`LaunchPlan ` class. +There exists one :py:class:`LaunchPlan ` class. .. autoclass:: flytekit.core.launch_plan.LaunchPlan :noindex: @@ -72,12 +74,13 @@ There is only one :py:class:`LaunchPlan ` ****************** Exception Handling ****************** -Exception handling takes place along two dimensions: -* System vs. User: We try to differentiate between user exceptions and Flytekit/system-level exceptions. For instance, if Flytekit fails to upload its outputs, that's a system exception. If the user raises a ``ValueError`` because of an unexpected input in the task code, that's a user exception. -* Recoverable vs. Non-recoverable: Recoverable errors will be retried and counted against the task's retry count. Non-recoverable errors will simply fail. System exceptions are by default recoverable (since there's a good chance it was just a blip). +Exception handling occurs along two dimensions: + +* System vs. User: We distinguish between Flytekit/system-level exceptions and user exceptions. For instance, if Flytekit encounters an issue while uploading outputs, it is considered a system exception. On the other hand, if a user raises a ``ValueError`` due to an unexpected input in the task code, it is classified as a user exception. +* Recoverable vs. Non-recoverable: Recoverable errors are retried and counted towards the task's retry count, while non-recoverable errors simply fail. System exceptions are recoverable by default since they are usually temporary. -Here's the user exception tree. Feel free to raise any of these exception classes. Note that the ``FlyteRecoverableException`` is the only recoverable exception. All others, along with all the non-Flytekit defined exceptions, are non-recoverable. +The following is the user exception tree, which users can raise as needed. It is important to note that only ``FlyteRecoverableException`` is a recoverable exception. All other exceptions, including non-Flytekit defined exceptions, are non-recoverable. .. inheritance-diagram:: flytekit.exceptions.user.FlyteValidationException flytekit.exceptions.user.FlyteEntityAlreadyExistsException flytekit.exceptions.user.FlyteValueException flytekit.exceptions.user.FlyteTimeout flytekit.exceptions.user.FlyteAuthenticationException flytekit.exceptions.user.FlyteRecoverableException :parts: 1 @@ -85,36 +88,42 @@ Here's the user exception tree. Feel free to raise any of these exception classe Implementation ============== -For those who want to dig deeper, take a look at the :py:class:`flytekit.common.exceptions.scopes.FlyteScopedException` classes. -There are two decorators that are interspersed throughout the codebase. + +If you wish to delve deeper, you can explore the ``FlyteScopedException`` classes. + +There are two decorators that are used throughout the codebase. .. autofunction:: flytekit.exceptions.scopes.system_entry_point .. autofunction:: flytekit.exceptions.scopes.user_entry_point -************** +************* Call Patterns -************** -The above-mentioned entities (tasks, workflows, and launch plan) are callable. They can be invoked to yield a unit (or units) of work in Flyte. +************* -In Pythonic terms, when you add ``()`` to the end of one of the entities, it invokes the ``__call__`` method on the object. +The entities mentioned above (tasks, workflows, and launch plans) are callable and can be invoked to generate one or more units of work in Flyte. -What happens when a callable entity is called depends on the current context, specifically the current :py:class:`flytekit.FlyteContext` +In Pythonic terminology, adding ``()`` to the end of an entity invokes the ``__call__`` method on the object. -Raw Task Execution -=================== -This is what happens when a task is just run as part of a unit test. The ``@task`` decorator actually turns the decorated function into an instance of the ``PythonFunctionTask`` object, but when a user calls the ``task()`` outside of a workflow, the original function is called without any interference by Flytekit. +The behavior that occurs when a callable entity is invoked is dependent on the current context, specifically the current :py:class:`flytekit.FlyteContext`. -Task Execution Inside Workflow -=============================== -When a workflow is run locally (say as a part of a unit test), certain changes occur in the ``task``. +Raw task execution +================== -Before going further, there is a special object that's worth mentioning, the :py:class:`flytekit.extend.Promise`. +When a task is executed as part of a unit test, the ``@task`` decorator transforms the decorated function into an instance of the ``PythonFunctionTask`` object. +However, when a user invokes the ``task()`` function outside of a workflow, the original function is called without any intervention from Flytekit. + +Task execution inside a workflow +================================ + +When a workflow is executed locally (for instance, as part of a unit test), some modifications are made to the task. + +Before proceeding, it is worth noting a special object, the :py:class:`flytekit.extend.Promise`. .. autoclass:: flytekit.core.promise.Promise :noindex: -Let's assume we have a workflow like :: +Consider the following workflow: :: @task def t1(a: int) -> Tuple[int, str]: @@ -130,19 +139,23 @@ Let's assume we have a workflow like :: d = t2(a=y, b=b) return x, d -As discussed in the Promise object's documentation, when a task is called from inside a workflow, the Python native values returned by the raw underlying functions are first converted into Flyte IDL literals and then wrapped inside ``Promise`` objects. One ``Promise`` is created for every return variable. +As stated in the documentation for the Promise object, when a task is invoked within a workflow, the Python native values returned by the underlying functions are first converted into Flyte IDL literals and then encapsulated inside Promise objects. +One Promise object is created for each return variable. -When the next task is called, the logic is triggered to unwrap these Promises. +When the next task is invoked, the values are extracted from these Promises. Compilation =========== -When a workflow is compiled, instead of producing Promise objects that wrap literal values, they wrap a :py:class:`flytekit.core.promise.NodeOutput` instead. This helps track data dependency between tasks. + +During the workflow compilation process, instead of generating Promise objects that encapsulate literal values, the workflow encapsulates a :py:class:`flytekit.core.promise.NodeOutput`. +This approach aids in tracking the data dependencies between tasks. Branch Skip =========== -If a :py:func:`flytekit.conditional` is determined to be false, then Flytekit will skip calling the task. This avoids running the unintended task. +If the condition specified in a :py:func:`flytekit.conditional` evaluates to ``False``, Flytekit will avoid invoking the corresponding task. +This prevents the unintended execution of the task. .. note:: - We discussed about a task's execution pattern above. The same pattern can be applied to workflows and launch plans too! + The execution pattern that we discussed for tasks can be applied to workflows and launch plans as well! diff --git a/flytekit/__init__.py b/flytekit/__init__.py index 20433c2ad1..4bbac570f3 100644 --- a/flytekit/__init__.py +++ b/flytekit/__init__.py @@ -26,6 +26,7 @@ map_task ~core.workflow.ImperativeWorkflow ~core.node_creation.create_node + ~core.promise.NodeOutput FlyteContextManager Running Locally @@ -195,6 +196,10 @@ import sys from typing import Generator +from rich import traceback + +from flytekit.lazy_import.lazy_module import lazy_module + if sys.version_info < (3, 10): from importlib_metadata import entry_points else: @@ -206,7 +211,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,8 +227,7 @@ from flytekit.core.workflow import ImperativeWorkflow as Workflow 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.image_spec import ImageSpec from flytekit.loggers import logger from flytekit.models.common import Annotations, AuthRole, Labels from flytekit.models.core.execution import WorkflowExecutionPhase @@ -232,7 +235,7 @@ from flytekit.models.documentation import Description, Documentation, SourceCode from flytekit.models.literals import Blob, BlobMetadata, Literal, Scalar from flytekit.models.types import LiteralType -from flytekit.types import directory, file, numpy, schema +from flytekit.types import directory, file from flytekit.types.structured.structured_dataset import ( StructuredDataset, StructuredDatasetFormat, @@ -299,3 +302,6 @@ def load_implicit_plugins(): # Load all implicit plugins load_implicit_plugins() + +# Pretty-print exception messages +traceback.install(width=None, extra_lines=0) diff --git a/flytekit/bin/entrypoint.py b/flytekit/bin/entrypoint.py index ca7a6cf20d..a9b7c313f0 100644 --- a/flytekit/bin/entrypoint.py +++ b/flytekit/bin/entrypoint.py @@ -10,7 +10,6 @@ import click as _click from flyteidl.core import literals_pb2 as _literals_pb2 -from flytekit import PythonFunctionTask from flytekit.configuration import ( SERIALIZED_CONTEXT_ENV_VAR, FastSerializationSettings, @@ -23,7 +22,7 @@ from flytekit.core.checkpointer import SyncCheckpoint from flytekit.core.context_manager import ExecutionParameters, ExecutionState, FlyteContext, FlyteContextManager from flytekit.core.data_persistence import FileAccessProvider -from flytekit.core.map_task import MapPythonTask +from flytekit.core.map_task import MapTaskResolver from flytekit.core.promise import VoidPromise from flytekit.exceptions import scopes as _scoped_exceptions from flytekit.exceptions import scopes as _scopes @@ -391,12 +390,8 @@ def _execute_map_task( with setup_execution( raw_output_data_prefix, checkpoint_path, prev_checkpoint, dynamic_addl_distro, dynamic_dest_dir ) as ctx: - resolver_obj = load_object_from_module(resolver) - # Use the resolver to load the actual task object - _task_def = resolver_obj.load_task(loader_args=resolver_args) - if not isinstance(_task_def, PythonFunctionTask): - raise Exception("Map tasks cannot be run with instance tasks.") - map_task = MapPythonTask(_task_def, max_concurrency) + mtr = MapTaskResolver() + map_task = mtr.load_task(loader_args=resolver_args, max_concurrency=max_concurrency) task_index = _compute_array_job_index() output_prefix = os.path.join(output_prefix, str(task_index)) diff --git a/flytekit/clients/auth/auth_client.py b/flytekit/clients/auth/auth_client.py index 94afa13612..ec1fd4d3e1 100644 --- a/flytekit/clients/auth/auth_client.py +++ b/flytekit/clients/auth/auth_client.py @@ -269,9 +269,11 @@ def _credentials_from_response(self, auth_token_resp) -> Credentials: raise ValueError('Expected "access_token" in response from oauth server') if "refresh_token" in response_body: refresh_token = response_body["refresh_token"] + if "expires_in" in response_body: + expires_in = response_body["expires_in"] access_token = response_body["access_token"] - return Credentials(access_token, refresh_token, self._endpoint) + return Credentials(access_token, refresh_token, self._endpoint, expires_in=expires_in) def _request_access_token(self, auth_code) -> Credentials: if self._state != auth_code.state: diff --git a/flytekit/clients/auth/authenticator.py b/flytekit/clients/auth/authenticator.py index 183c1787cd..9582c901d8 100644 --- a/flytekit/clients/auth/authenticator.py +++ b/flytekit/clients/auth/authenticator.py @@ -1,12 +1,10 @@ -import base64 import logging import subprocess import typing from abc import abstractmethod from dataclasses import dataclass -import requests - +from . import token_client from .auth_client import AuthorizationClient from .exceptions import AccessTokenNotFoundError, AuthenticationError from .keyring import Credentials, KeyringStore @@ -22,6 +20,7 @@ class ClientConfig: authorization_endpoint: str redirect_uri: str client_id: str + device_authorization_endpoint: typing.Optional[str] = None scopes: typing.List[str] = None header_key: str = "authorization" @@ -155,67 +154,25 @@ 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, + header_key: typing.Optional[str] = None, + scopes: typing.Optional[typing.List[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 + # Use scopes from `flytekit.configuration.PlatformConfig` if passed + self._scopes = scopes or 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 @@ -229,7 +186,56 @@ def refresh_credentials(self): # 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) + authorization_header = token_client.get_basic_authorization_header(self._client_id, self._client_secret) + token, expires_in = token_client.get_token(token_endpoint, scopes, authorization_header) logging.info("Retrieved new token, expires in {}".format(expires_in)) self._creds = Credentials(token) + + +class DeviceCodeAuthenticator(Authenticator): + """ + This Authenticator implements the Device Code authorization flow useful for headless user authentication. + + Examples described + - https://developer.okta.com/docs/guides/device-authorization-grant/main/ + - https://auth0.com/docs/get-started/authentication-and-authorization-flow/device-authorization-flow#device-flow + """ + + def __init__( + self, + endpoint: str, + cfg_store: ClientConfigStore, + header_key: typing.Optional[str] = None, + audience: typing.Optional[str] = None, + ): + self._audience = audience + cfg = cfg_store.get_client_config() + self._client_id = cfg.client_id + self._device_auth_endpoint = cfg.device_authorization_endpoint + self._scope = cfg.scopes + self._token_endpoint = cfg.token_endpoint + if self._device_auth_endpoint is None: + raise AuthenticationError( + "Device Authentication is not available on the Flyte backend / authentication server" + ) + super().__init__( + endpoint=endpoint, header_key=header_key or cfg.header_key, credentials=KeyringStore.retrieve(endpoint) + ) + + def refresh_credentials(self): + resp = token_client.get_device_code(self._device_auth_endpoint, self._client_id, self._audience, self._scope) + print( + f""" +To Authenticate navigate in a browser to the following URL: {resp.verification_uri} and enter code: {resp.user_code} +OR copy paste the following URL: {resp.verification_uri_complete} + """ + ) + try: + # Currently the refresh token is not retreived. We may want to add support for refreshTokens so that + # access tokens can be refreshed for once authenticated machines + token, expires_in = token_client.poll_token_endpoint(resp, self._token_endpoint, client_id=self._client_id) + self._creds = Credentials(access_token=token, expires_in=expires_in, for_endpoint=self._endpoint) + KeyringStore.store(self._creds) + except Exception: + KeyringStore.delete(self._endpoint) + raise diff --git a/flytekit/clients/auth/exceptions.py b/flytekit/clients/auth/exceptions.py index 6e790e47a4..5086c5b6e1 100644 --- a/flytekit/clients/auth/exceptions.py +++ b/flytekit/clients/auth/exceptions.py @@ -12,3 +12,11 @@ class AuthenticationError(RuntimeError): """ pass + + +class AuthenticationPending(RuntimeError): + """ + This is raised if the token endpoint returns authentication pending + """ + + pass diff --git a/flytekit/clients/auth/keyring.py b/flytekit/clients/auth/keyring.py index c2b19c46b6..79f5e86c68 100644 --- a/flytekit/clients/auth/keyring.py +++ b/flytekit/clients/auth/keyring.py @@ -15,6 +15,7 @@ class Credentials(object): access_token: str refresh_token: str = "na" for_endpoint: str = "flyte-default" + expires_in: typing.Optional[int] = None class KeyringStore: @@ -39,7 +40,7 @@ def store(credentials: Credentials) -> Credentials: credentials.access_token, ) except NoKeyringError as e: - logging.warning(f"KeyRing not available, tokens will not be cached. Error: {e}") + logging.debug(f"KeyRing not available, tokens will not be cached. Error: {e}") return credentials @staticmethod @@ -48,7 +49,7 @@ def retrieve(for_endpoint: str) -> typing.Optional[Credentials]: 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}") + logging.debug(f"KeyRing not available, tokens will not be cached. Error: {e}") return None if not access_token: @@ -61,4 +62,4 @@ def delete(for_endpoint: str): _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}") + logging.debug(f"KeyRing not available, tokens will not be cached. Error: {e}") diff --git a/flytekit/clients/auth/token_client.py b/flytekit/clients/auth/token_client.py new file mode 100644 index 0000000000..7cbb42a13e --- /dev/null +++ b/flytekit/clients/auth/token_client.py @@ -0,0 +1,154 @@ +import base64 +import enum +import logging +import time +import typing +import urllib.parse +from dataclasses import dataclass +from datetime import datetime, timedelta + +import requests + +from flytekit.clients.auth.exceptions import AuthenticationError, AuthenticationPending + +utf_8 = "utf-8" + +# Errors that Token endpoint will return +error_slow_down = "slow_down" +error_auth_pending = "authorization_pending" + + +# Grant Types +class GrantType(str, enum.Enum): + CLIENT_CREDS = "client_credentials" + DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code" + + +@dataclass +class DeviceCodeResponse: + """ + Response from device auth flow endpoint + {'device_code': 'code', + 'user_code': 'BNDJJFXL', + 'verification_uri': 'url', + 'verification_uri_complete': 'url', + 'expires_in': 600, + 'interval': 5} + """ + + device_code: str + user_code: str + verification_uri: str + verification_uri_complete: str + expires_in: int + interval: int + + @classmethod + def from_json_response(cls, j: typing.Dict) -> "DeviceCodeResponse": + return cls( + device_code=j["device_code"], + user_code=j["user_code"], + verification_uri=j["verification_uri"], + verification_uri_complete=j["verification_uri_complete"], + expires_in=j["expires_in"], + interval=j["interval"], + ) + + +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. Secrets are + first URL encoded to escape illegal characters. + + :param client_id: str + :param client_secret: str + :rtype: str + """ + encoded = urllib.parse.quote_plus(client_secret) + concatenated = "{}:{}".format(client_id, encoded) + return "Basic {}".format(base64.b64encode(concatenated.encode(utf_8)).decode(utf_8)) + + +def get_token( + token_endpoint: str, + scopes: typing.Optional[typing.List[str]] = None, + authorization_header: typing.Optional[str] = None, + client_id: typing.Optional[str] = None, + device_code: typing.Optional[str] = None, + grant_type: GrantType = GrantType.CLIENT_CREDS, +) -> 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 = { + "Cache-Control": "no-cache", + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + } + if authorization_header: + headers["Authorization"] = authorization_header + body = { + "grant_type": grant_type.value, + } + if client_id: + body["client_id"] = client_id + if device_code: + body["device_code"] = device_code + if scopes is not None: + body["scope"] = ",".join(scopes) + + response = requests.post(token_endpoint, data=body, headers=headers) + if not response.ok: + j = response.json() + if "error" in j: + err = j["error"] + if err == error_auth_pending or err == error_slow_down: + raise AuthenticationPending(f"Token not yet available, try again in some time {err}") + logging.error("Status Code ({}) received from IDP: {}".format(response.status_code, response.text)) + raise AuthenticationError("Status Code ({}) received from IDP: {}".format(response.status_code, response.text)) + + j = response.json() + return j["access_token"], j["expires_in"] + + +def get_device_code( + device_auth_endpoint: str, + client_id: str, + audience: typing.Optional[str] = None, + scope: typing.Optional[typing.List[str]] = None, +) -> DeviceCodeResponse: + """ + Retrieves the device Authentication code that can be done to authenticate the request using a browser on a + separate device + """ + payload = {"client_id": client_id, "scope": scope, "audience": audience} + resp = requests.post(device_auth_endpoint, payload) + if not resp.ok: + raise AuthenticationError(f"Unable to retrieve Device Authentication Code for {payload}, Reason {resp.reason}") + return DeviceCodeResponse.from_json_response(resp.json()) + + +def poll_token_endpoint(resp: DeviceCodeResponse, token_endpoint: str, client_id: str) -> typing.Tuple[str, int]: + tick = datetime.now() + interval = timedelta(seconds=resp.interval) + end_time = tick + timedelta(seconds=resp.expires_in) + while tick < end_time: + try: + access_token, expires_in = get_token( + token_endpoint, + grant_type=GrantType.DEVICE_CODE, + client_id=client_id, + device_code=resp.device_code, + ) + print("Authentication successful!") + return access_token, expires_in + except AuthenticationPending: + ... + except Exception: + raise + print("Authentication Pending...") + time.sleep(interval.total_seconds()) + tick = tick + interval + raise AuthenticationError("Authentication failed!") diff --git a/flytekit/clients/auth_helper.py b/flytekit/clients/auth_helper.py index 41fc5c025f..93bd883324 100644 --- a/flytekit/clients/auth_helper.py +++ b/flytekit/clients/auth_helper.py @@ -12,6 +12,7 @@ ClientConfigStore, ClientCredentialsAuthenticator, CommandAuthenticator, + DeviceCodeAuthenticator, PKCEAuthenticator, ) from flytekit.clients.grpc_utils.auth_interceptor import AuthUnaryInterceptor @@ -41,6 +42,7 @@ def get_client_config(self) -> ClientConfig: client_id=public_client_config.client_id, scopes=public_client_config.scopes, header_key=public_client_config.authorization_metadata_key or None, + device_authorization_endpoint=oauth2_metadata.device_authorization_endpoint, ) @@ -69,6 +71,7 @@ def get_authenticator(cfg: PlatformConfig, cfg_store: ClientConfigStore) -> Auth client_id=cfg.client_id, client_secret=cfg.client_credentials_secret, cfg_store=cfg_store, + scopes=cfg.scopes, ) elif cfg_auth == AuthType.EXTERNAL_PROCESS or cfg_auth == AuthType.EXTERNALCOMMAND: client_cfg = None @@ -78,6 +81,8 @@ def get_authenticator(cfg: PlatformConfig, cfg_store: ClientConfigStore) -> Auth command=cfg.command, header_key=client_cfg.header_key if client_cfg else None, ) + elif cfg_auth == AuthType.DEVICEFLOW: + return DeviceCodeAuthenticator(endpoint=cfg.endpoint, cfg_store=cfg_store, audience=cfg.audience) else: raise ValueError( f"Invalid auth mode [{cfg_auth}] specified." f"Please update the creds config to use a valid value" diff --git a/flytekit/clients/friendly.py b/flytekit/clients/friendly.py index d542af5f7e..2b15dfbd50 100644 --- a/flytekit/clients/friendly.py +++ b/flytekit/clients/friendly.py @@ -1007,7 +1007,7 @@ def get_upload_signed_url( def get_download_signed_url( self, native_url: str, expires_in: datetime.timedelta = None - ) -> _data_proxy_pb2.CreateUploadLocationResponse: + ) -> _data_proxy_pb2.CreateDownloadLocationRequest: expires_in_pb = None if expires_in: expires_in_pb = Duration() diff --git a/flytekit/clis/flyte_cli/main.py b/flytekit/clis/flyte_cli/main.py index 21aec1c4ad..47d5c8c641 100644 --- a/flytekit/clis/flyte_cli/main.py +++ b/flytekit/clis/flyte_cli/main.py @@ -1167,7 +1167,6 @@ def terminate_execution(host, insecure, cause, urn=None): raise _click.UsageError('Missing option "-u" / "--urn" or missing pipe inputs.') except KeyboardInterrupt: _sys.stdout.flush() - pass else: _terminate_one_execution(client, urn, cause) diff --git a/flytekit/clis/sdk_in_container/backfill.py b/flytekit/clis/sdk_in_container/backfill.py index 234b03499f..49c2667d5b 100644 --- a/flytekit/clis/sdk_in_container/backfill.py +++ b/flytekit/clis/sdk_in_container/backfill.py @@ -1,7 +1,7 @@ import typing from datetime import datetime, timedelta -import click +import rich_click as click from flytekit.clis.sdk_in_container.helpers import get_and_save_remote_with_click_context from flytekit.clis.sdk_in_container.run import DateTimeType, DurationParamType @@ -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. """ @@ -168,11 +168,12 @@ def backfill( execute=execute, parallel=parallel, ) - if entity: - console_url = remote.generate_console_url(entity) - if execute: - click.secho(f"\n Execution launched {console_url} to see execution in the console.", fg="green") - return - click.secho(f"\n Workflow registered at {console_url}", fg="green") + if dry_run: + return + console_url = remote.generate_console_url(entity) + if execute: + click.secho(f"\n Execution launched {console_url} to see execution in the console.", fg="green") + return + click.secho(f"\n Workflow registered at {console_url}", fg="green") except StopIteration as e: click.secho(f"{e.value}", fg="red") diff --git a/flytekit/clis/sdk_in_container/build.py b/flytekit/clis/sdk_in_container/build.py new file mode 100644 index 0000000000..3e18535268 --- /dev/null +++ b/flytekit/clis/sdk_in_container/build.py @@ -0,0 +1,127 @@ +import os +import pathlib +import typing + +import rich_click as click +from typing_extensions import OrderedDict + +from flytekit.clis.sdk_in_container.constants import CTX_MODULE, CTX_PROJECT_ROOT +from flytekit.clis.sdk_in_container.run import RUN_LEVEL_PARAMS_KEY, get_entities_in_file, load_naive_entity +from flytekit.configuration import ImageConfig, SerializationSettings +from flytekit.core.base_task import PythonTask +from flytekit.core.workflow import PythonFunctionWorkflow +from flytekit.tools.script_mode import _find_project_root +from flytekit.tools.translator import get_serializable + + +def get_workflow_command_base_params() -> typing.List[click.Option]: + """ + Return the set of base parameters added to every pyflyte build workflow subcommand. + """ + return [ + click.Option( + param_decls=["--fast"], + required=False, + is_flag=True, + default=False, + help="Use fast serialization. The image won't contain the source code. The value is false by default.", + ), + ] + + +def build_command(ctx: click.Context, entity: typing.Union[PythonFunctionWorkflow, PythonTask]): + """ + Returns a function that is used to implement WorkflowCommand and build an image for flyte workflows. + """ + + def _build(*args, **kwargs): + m = OrderedDict() + options = None + run_level_params = ctx.obj[RUN_LEVEL_PARAMS_KEY] + + project, domain = run_level_params.get("project"), run_level_params.get("domain") + serialization_settings = SerializationSettings( + project=project, + domain=domain, + image_config=ImageConfig.auto_default_image(), + ) + if not run_level_params.get("fast"): + serialization_settings.source_root = ctx.obj[RUN_LEVEL_PARAMS_KEY].get(CTX_PROJECT_ROOT) + + _ = get_serializable(m, settings=serialization_settings, entity=entity, options=options) + + return _build + + +class WorkflowCommand(click.MultiCommand): + """ + click multicommand at the python file layer, subcommands should be all the workflows in the file. + """ + + def __init__(self, filename: str, *args, **kwargs): + super().__init__(*args, **kwargs) + self._filename = pathlib.Path(filename).resolve() + + def list_commands(self, ctx): + entities = get_entities_in_file(self._filename.__str__()) + return entities.all() + + def get_command(self, ctx, exe_entity): + """ + This command uses the filename with which this command was created, and the string name of the entity passed + after the Python filename on the command line, to load the Python object, and then return the Command that + click should run. + :param ctx: The click Context object. + :param exe_entity: string of the flyte entity provided by the user. Should be the name of a workflow, or task + function. + :return: + """ + rel_path = os.path.relpath(self._filename) + if rel_path.startswith(".."): + raise ValueError( + f"You must call pyflyte from the same or parent dir, {self._filename} not under {os.getcwd()}" + ) + + project_root = _find_project_root(self._filename) + rel_path = self._filename.relative_to(project_root) + module = os.path.splitext(rel_path)[0].replace(os.path.sep, ".") + + ctx.obj[RUN_LEVEL_PARAMS_KEY][CTX_PROJECT_ROOT] = project_root + ctx.obj[RUN_LEVEL_PARAMS_KEY][CTX_MODULE] = module + + entity = load_naive_entity(module, exe_entity, project_root) + + cmd = click.Command( + name=exe_entity, + callback=build_command(ctx, entity), + help=f"Build an image for {module}.{exe_entity}.", + ) + return cmd + + +class BuildCommand(click.MultiCommand): + """ + A click command group for building a image for flyte workflows & tasks in a file. + """ + + def __init__(self, *args, **kwargs): + params = get_workflow_command_base_params() + super().__init__(*args, params=params, **kwargs) + + def list_commands(self, ctx): + return [str(p) for p in pathlib.Path(".").glob("*.py") if str(p) != "__init__.py"] + + def get_command(self, ctx, filename): + if ctx.obj: + ctx.obj[RUN_LEVEL_PARAMS_KEY] = ctx.params + return WorkflowCommand(filename, name=filename, help="Build an image for [workflow|task]") + + +_build_help = """ +This command can build an image for a workflow or a task from the command line, for fully self-contained scripts. +""" + +build = BuildCommand( + name="build", + help=_build_help, +) diff --git a/flytekit/clis/sdk_in_container/constants.py b/flytekit/clis/sdk_in_container/constants.py index d228babf43..67391abb4d 100644 --- a/flytekit/clis/sdk_in_container/constants.py +++ b/flytekit/clis/sdk_in_container/constants.py @@ -1,4 +1,4 @@ -import click as _click +import rich_click as _click CTX_PROJECT = "project" CTX_DOMAIN = "domain" @@ -10,6 +10,7 @@ CTX_PROJECT_ROOT = "project_root" CTX_MODULE = "module" CTX_VERBOSE = "verbose" +CTX_COPY_ALL = "copy_all" project_option = _click.option( diff --git a/flytekit/clis/sdk_in_container/helpers.py b/flytekit/clis/sdk_in_container/helpers.py index 6ac451be92..4c66a2046c 100644 --- a/flytekit/clis/sdk_in_container/helpers.py +++ b/flytekit/clis/sdk_in_container/helpers.py @@ -1,7 +1,7 @@ from dataclasses import replace from typing import Optional -import click +import rich_click as click from flytekit.clis.sdk_in_container.constants import CTX_CONFIG_FILE from flytekit.configuration import Config, ImageConfig, get_config_file diff --git a/flytekit/clis/sdk_in_container/init.py b/flytekit/clis/sdk_in_container/init.py index 1ec2f57c32..627b393578 100644 --- a/flytekit/clis/sdk_in_container/init.py +++ b/flytekit/clis/sdk_in_container/init.py @@ -1,4 +1,4 @@ -import click +import rich_click as click from cookiecutter.main import cookiecutter diff --git a/flytekit/clis/sdk_in_container/launchplan.py b/flytekit/clis/sdk_in_container/launchplan.py new file mode 100644 index 0000000000..2d33e2e3d7 --- /dev/null +++ b/flytekit/clis/sdk_in_container/launchplan.py @@ -0,0 +1,74 @@ +import rich_click as click + +from flytekit.clis.sdk_in_container.helpers import get_and_save_remote_with_click_context +from flytekit.models.launch_plan import LaunchPlanState + +_launchplan_help = """ +The launchplan command activates or deactivates a specified or the latest version of the launchplan. +If ``--activate`` is chosen then the previous version of the launchplan will be deactivated. + +- ``launchplan`` refers to the name of the Launchplan +- ``launchplan_version`` is optional and should be a valid version for a Launchplan version. If not specified the latest will be used. +""" + + +@click.command("launchplan", help=_launchplan_help) +@click.option( + "-p", + "--project", + required=False, + type=str, + default="flytesnacks", + help="Fecth launchplan from this project", +) +@click.option( + "-d", + "--domain", + required=False, + type=str, + default="development", + help="Fetch launchplan from this domain", +) +@click.option( + "--activate/--deactivate", + required=True, + type=bool, + is_flag=True, + help="Activate or Deactivate the launchplan", +) +@click.argument( + "launchplan", + required=True, + type=str, +) +@click.argument( + "launchplan-version", + required=False, + type=str, + default=None, +) +@click.pass_context +def launchplan( + ctx: click.Context, + project: str, + domain: str, + activate: bool, + launchplan: str, + launchplan_version: str, +): + remote = get_and_save_remote_with_click_context(ctx, project, domain) + try: + launchplan = remote.fetch_launch_plan( + project=project, + domain=domain, + name=launchplan, + version=launchplan_version, + ) + state = LaunchPlanState.ACTIVE if activate else LaunchPlanState.INACTIVE + remote.client.update_launch_plan(id=launchplan.id, state=state) + click.secho( + f"\n Launchplan was set to {LaunchPlanState.enum_to_string(state)}: {launchplan.name}:{launchplan.id.version}", + fg="green", + ) + except StopIteration as e: + click.secho(f"{e.value}", fg="red") diff --git a/flytekit/clis/sdk_in_container/local_cache.py b/flytekit/clis/sdk_in_container/local_cache.py index 0dbdc9c621..b0923b842a 100644 --- a/flytekit/clis/sdk_in_container/local_cache.py +++ b/flytekit/clis/sdk_in_container/local_cache.py @@ -1,4 +1,4 @@ -import click +import rich_click as click from flytekit.core.local_cache import LocalTaskCache diff --git a/flytekit/clis/sdk_in_container/package.py b/flytekit/clis/sdk_in_container/package.py index e457b3d649..f1c6f526e2 100644 --- a/flytekit/clis/sdk_in_container/package.py +++ b/flytekit/clis/sdk_in_container/package.py @@ -1,6 +1,6 @@ import os -import click +import rich_click as click from flytekit.clis.helpers import display_help_with_error from flytekit.clis.sdk_in_container import constants diff --git a/flytekit/clis/sdk_in_container/pyflyte.py b/flytekit/clis/sdk_in_container/pyflyte.py index 5e1136d14c..d9a8fb0c2a 100644 --- a/flytekit/clis/sdk_in_container/pyflyte.py +++ b/flytekit/clis/sdk_in_container/pyflyte.py @@ -1,18 +1,21 @@ import typing -import click import grpc +import rich_click as click 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.build import build 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.launchplan import launchplan from flytekit.clis.sdk_in_container.local_cache import local_cache from flytekit.clis.sdk_in_container.package import package from flytekit.clis.sdk_in_container.register import register from flytekit.clis.sdk_in_container.run import run from flytekit.clis.sdk_in_container.serialize import serialize +from flytekit.clis.sdk_in_container.serve import serve from flytekit.configuration.internal import LocalSDK from flytekit.exceptions.base import FlyteException from flytekit.exceptions.user import FlyteInvalidInputException @@ -36,8 +39,8 @@ def validate_package(ctx, param, values): 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"RPC Failed, with Status: {e.code()}", fg="red", bold=True) + click.secho(f"\tdetails: {e.details()}", fg="magenta", bold=True) click.secho(f"\tDebug string {e.debug_error_string()}", dim=True) return @@ -51,12 +54,11 @@ def pretty_print_exception(e: Exception): raise e if isinstance(e, FlyteException): + click.secho(f"Failed with Exception Code: {e._ERROR_CODE}", fg="red") # noqa 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): @@ -72,7 +74,7 @@ def pretty_print_exception(e: Exception): click.secho(f"Failed with Unknown Exception {type(e)} Reason: {e}", fg="red") # noqa -class ErrorHandlingCommand(click.Group): +class ErrorHandlingCommand(click.RichGroup): def invoke(self, ctx: click.Context) -> typing.Any: try: return super().invoke(ctx) @@ -132,6 +134,9 @@ def main(ctx, pkgs: typing.List[str], config: str, verbose: bool): main.add_command(run) main.add_command(register) main.add_command(backfill) +main.add_command(serve) +main.add_command(build) +main.add_command(launchplan) main.epilog if __name__ == "__main__": diff --git a/flytekit/clis/sdk_in_container/register.py b/flytekit/clis/sdk_in_container/register.py index 30c955e351..afc7aeb99e 100644 --- a/flytekit/clis/sdk_in_container/register.py +++ b/flytekit/clis/sdk_in_container/register.py @@ -1,7 +1,7 @@ import os import typing -import click +import rich_click as click from flytekit.clis.helpers import display_help_with_error from flytekit.clis.sdk_in_container import constants diff --git a/flytekit/clis/sdk_in_container/run.py b/flytekit/clis/sdk_in_container/run.py index 136831c0bc..9c7228ec46 100644 --- a/flytekit/clis/sdk_in_container/run.py +++ b/flytekit/clis/sdk_in_container/run.py @@ -9,7 +9,8 @@ from dataclasses import dataclass from typing import cast -import click +import rich_click as click +import yaml from dataclasses_json import DataClassJsonMixin from pytimeparse import parse from typing_extensions import get_args @@ -17,6 +18,7 @@ from flytekit import BlobType, Literal, Scalar from flytekit.clis.sdk_in_container.constants import ( CTX_CONFIG_FILE, + CTX_COPY_ALL, CTX_DOMAIN, CTX_MODULE, CTX_PROJECT, @@ -37,7 +39,7 @@ from flytekit.core.workflow import PythonFunctionWorkflow, WorkflowBase from flytekit.models import literals from flytekit.models.interface import Variable -from flytekit.models.literals import Blob, BlobMetadata, Primitive, Union +from flytekit.models.literals import Blob, BlobMetadata, LiteralCollection, LiteralMap, Primitive, Union from flytekit.models.types import LiteralType, SimpleType from flytekit.remote.executions import FlyteWorkflowExecution from flytekit.tools import module_loader, script_mode @@ -55,10 +57,6 @@ def remove_prefix(text, prefix): return text -class JsonParamType(click.ParamType): - name = "json object" - - @dataclass class Directory(object): dir_path: str @@ -81,7 +79,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}") @@ -134,6 +132,33 @@ def convert( return datetime.timedelta(seconds=parse(value)) +class JsonParamType(click.ParamType): + name = "json object OR json/yaml file path" + + def convert( + self, value: typing.Any, param: typing.Optional[click.Parameter], ctx: typing.Optional[click.Context] + ) -> typing.Any: + if value is None: + raise click.BadParameter("None value cannot be converted to a Json type.") + if type(value) == dict or type(value) == list: + return value + try: + return json.loads(value) + except Exception: # noqa + try: + # We failed to load the json, so we'll try to load it as a file + if os.path.exists(value): + # if the value is a yaml file, we'll try to load it as yaml + if value.endswith(".yaml") or value.endswith(".yml"): + with open(value, "r") as f: + return yaml.safe_load(f) + with open(value, "r") as f: + return json.load(f) + raise + except json.JSONDecodeError as e: + raise click.BadParameter(f"parameter {param} should be a valid json object, {value}, error: {e}") + + @dataclass class DefaultConverter(object): click_type: click.ParamType @@ -215,16 +240,18 @@ def is_bool(self) -> bool: return self._literal_type.simple == SimpleType.BOOLEAN return False - def get_uri_for_dir(self, value: Directory, remote_filename: typing.Optional[str] = None): + def get_uri_for_dir( + self, ctx: typing.Optional[click.Context], value: Directory, remote_filename: typing.Optional[str] = None + ): uri = value.dir_path if self._remote and value.local: md5, _ = script_mode.hash_file(value.local_file) if not remote_filename: remote_filename = value.local_file.name - df_remote_location = self._create_upload_fn(filename=remote_filename, content_md5=md5) - self._flyte_ctx.file_access.put_data(value.local_file, df_remote_location.signed_url) - uri = df_remote_location.native_url[: -len(remote_filename)] + remote = ctx.obj[FLYTE_REMOTE_INSTANCE_KEY] + _, native_url = remote.upload_file(value.local_file) + uri = native_url[: -len(remote_filename)] return uri @@ -232,7 +259,7 @@ def convert_to_structured_dataset( self, ctx: typing.Optional[click.Context], param: typing.Optional[click.Parameter], value: Directory ) -> Literal: - uri = self.get_uri_for_dir(value, "00000.parquet") + uri = self.get_uri_for_dir(ctx, value, "00000.parquet") lit = Literal( scalar=Scalar( @@ -254,15 +281,13 @@ def convert_to_blob( value: typing.Union[Directory, FileParam], ) -> Literal: if isinstance(value, Directory): - uri = self.get_uri_for_dir(value) + uri = self.get_uri_for_dir(ctx, value) else: uri = value.filepath if self._remote and value.local: fp = pathlib.Path(value.filepath) - md5, _ = script_mode.hash_file(value.filepath) - df_remote_location = self._create_upload_fn(filename=fp.name, content_md5=md5) - self._flyte_ctx.file_access.put_data(fp, df_remote_location.signed_url) - uri = df_remote_location.native_url + remote = ctx.obj[FLYTE_REMOTE_INSTANCE_KEY] + _, uri = remote.upload_file(fp) lit = Literal( scalar=Scalar( @@ -299,6 +324,68 @@ def convert_to_union( logging.debug(f"Failed to convert python type {python_type} to literal type {variant}", e) raise ValueError(f"Failed to convert python type {self._python_type} to literal type {lt}") + def convert_to_list( + self, ctx: typing.Optional[click.Context], param: typing.Optional[click.Parameter], value: list + ) -> Literal: + """ + Convert a python list into a Flyte Literal + """ + if not value: + raise click.BadParameter("Expected non-empty list") + if not isinstance(value, list): + raise click.BadParameter(f"Expected json list '[...]', parsed value is {type(value)}") + converter = FlyteLiteralConverter( + ctx, + self._flyte_ctx, + self._literal_type.collection_type, + type(value[0]), + self._create_upload_fn, + ) + lt = Literal(collection=LiteralCollection([])) + for v in value: + click_val = converter._click_type.convert(v, param, ctx) + lt.collection.literals.append(converter.convert_to_literal(ctx, param, click_val)) + return lt + + def convert_to_map( + self, ctx: typing.Optional[click.Context], param: typing.Optional[click.Parameter], value: dict + ) -> Literal: + """ + Convert a python dict into a Flyte Literal. + It is assumed that the click parameter type is a JsonParamType. The map is also assumed to be univariate. + """ + if not value: + raise click.BadParameter("Expected non-empty dict") + if not isinstance(value, dict): + raise click.BadParameter(f"Expected json dict '{{...}}', parsed value is {type(value)}") + converter = FlyteLiteralConverter( + ctx, + self._flyte_ctx, + self._literal_type.map_value_type, + type(value[list(value.keys())[0]]), + self._create_upload_fn, + ) + lt = Literal(map=LiteralMap({})) + for k, v in value.items(): + click_val = converter._click_type.convert(v, param, ctx) + lt.map.literals[k] = converter.convert_to_literal(ctx, param, click_val) + return lt + + def convert_to_struct( + self, + ctx: typing.Optional[click.Context], + param: typing.Optional[click.Parameter], + value: typing.Union[dict, typing.Any], + ) -> Literal: + """ + Convert the loaded json object to a Flyte Literal struct type. + """ + if type(value) != self._python_type: + o = cast(DataClassJsonMixin, self._python_type).from_json(json.dumps(value)) + else: + o = value + return TypeEngine.to_literal(self._flyte_ctx, o, self._python_type, self._literal_type) + def convert_to_literal( self, ctx: typing.Optional[click.Context], param: typing.Optional[click.Parameter], value: typing.Any ) -> Literal: @@ -308,30 +395,18 @@ def convert_to_literal( if self._literal_type.blob: return self.convert_to_blob(ctx, param, value) - if self._literal_type.collection_type or self._literal_type.map_value_type: - # TODO Does not support nested flytefile, flyteschema types - v = json.loads(value) if isinstance(value, str) else value - if self._literal_type.collection_type and not isinstance(v, list): - raise click.BadParameter(f"Expected json list '[...]', parsed value is {type(v)}") - if self._literal_type.map_value_type and not isinstance(v, dict): - raise click.BadParameter("Expected json map '{}', parsed value is {%s}" % type(v)) - return TypeEngine.to_literal(self._flyte_ctx, v, self._python_type, self._literal_type) + if self._literal_type.collection_type: + return self.convert_to_list(ctx, param, value) + + if self._literal_type.map_value_type: + return self.convert_to_map(ctx, param, value) if self._literal_type.union_type: return self.convert_to_union(ctx, param, value) if self._literal_type.simple or self._literal_type.enum_type: if self._literal_type.simple and self._literal_type.simple == SimpleType.STRUCT: - if self._python_type == dict: - if type(value) != str: - # The type of default value is dict, so we have to convert it to json string - value = json.dumps(value) - o = json.loads(value) - elif type(value) != self._python_type: - o = cast(DataClassJsonMixin, self._python_type).from_json(value) - else: - o = value - return TypeEngine.to_literal(self._flyte_ctx, o, self._python_type, self._literal_type) + return self.convert_to_struct(ctx, param, value) return Literal(scalar=self._converter.convert(value, self._python_type)) if self._literal_type.schema: @@ -342,10 +417,15 @@ def convert_to_literal( ) def convert(self, ctx, param, value) -> typing.Union[Literal, typing.Any]: - lit = self.convert_to_literal(ctx, param, value) - if not self._remote: - return TypeEngine.to_python_value(self._flyte_ctx, lit, self._python_type) - return lit + try: + lit = self.convert_to_literal(ctx, param, value) + if not self._remote: + return TypeEngine.to_python_value(self._flyte_ctx, lit, self._python_type) + return lit + except click.BadParameter: + raise + except Exception as e: + raise click.BadParameter(f"Failed to convert param {param}, {value} to {self._python_type}") from e def to_click_option( @@ -368,6 +448,13 @@ def to_click_option( if literal_converter.is_bool() and not default_val: default_val = False + if literal_var.type.simple == SimpleType.STRUCT: + if default_val: + if type(default_val) == dict or type(default_val) == list: + default_val = json.dumps(default_val) + else: + default_val = cast(DataClassJsonMixin, default_val).to_json() + return click.Option( param_decls=[f"--{input_name}"], type=literal_converter.click_type, @@ -426,6 +513,13 @@ def get_workflow_command_base_params() -> typing.List[click.Option]: default="/root", help="Directory inside the image where the tar file containing the code will be copied to", ), + click.Option( + param_decls=["--copy-all", "copy_all"], + required=False, + is_flag=True, + default=False, + help="Copy all files in the source root directory to the destination directory", + ), click.Option( param_decls=["-i", "--image", "image_config"], required=False, @@ -557,6 +651,7 @@ def _run(*args, **kwargs): destination_dir=run_level_params.get("destination_dir"), source_path=ctx.obj[RUN_LEVEL_PARAMS_KEY].get(CTX_PROJECT_ROOT), module_name=ctx.obj[RUN_LEVEL_PARAMS_KEY].get(CTX_MODULE), + copy_all=ctx.obj[RUN_LEVEL_PARAMS_KEY].get(CTX_COPY_ALL), ) options = None @@ -586,7 +681,7 @@ def _run(*args, **kwargs): return _run -class WorkflowCommand(click.MultiCommand): +class WorkflowCommand(click.RichGroup): """ click multicommand at the python file layer, subcommands should be all the workflows in the file. """ @@ -654,7 +749,7 @@ def get_command(self, ctx, exe_entity): return cmd -class RunCommand(click.MultiCommand): +class RunCommand(click.RichGroup): """ A click command group for registering and executing flyte workflows & tasks in a file. """ diff --git a/flytekit/clis/sdk_in_container/serialize.py b/flytekit/clis/sdk_in_container/serialize.py index eef055ad3c..0c328248e5 100644 --- a/flytekit/clis/sdk_in_container/serialize.py +++ b/flytekit/clis/sdk_in_container/serialize.py @@ -3,7 +3,7 @@ import typing from enum import Enum as _Enum -import click +import rich_click as click from flytekit.clis.sdk_in_container import constants from flytekit.clis.sdk_in_container.constants import CTX_PACKAGES @@ -69,7 +69,7 @@ def serialize_all( serialize_to_folder(pkgs, serialization_settings, local_source_root, folder) -@click.group("serialize") +@click.group("serialize", cls=click.RichGroup) @click.option( "--image", required=False, @@ -124,7 +124,7 @@ def serialize(ctx, image, local_source_root, in_container_config_path, in_contai ctx.obj[CTX_PYTHON_INTERPRETER] = sys.executable -@click.command("workflows") +@click.command("workflows", cls=click.RichCommand) # For now let's just assume that the directory needs to exist. If you're docker run -v'ing, docker will create the # directory for you so it shouldn't be a problem. @click.option("-f", "--folder", type=click.Path(exists=True)) @@ -148,13 +148,13 @@ def workflows(ctx, folder=None): ) -@click.group("fast") +@click.group("fast", cls=click.RichGroup) @click.pass_context def fast(ctx): pass -@click.command("workflows") +@click.command("workflows", cls=click.RichCommand) @click.option( "--deref-symlinks", default=False, diff --git a/flytekit/clis/sdk_in_container/serve.py b/flytekit/clis/sdk_in_container/serve.py new file mode 100644 index 0000000000..71b539d36c --- /dev/null +++ b/flytekit/clis/sdk_in_container/serve.py @@ -0,0 +1,46 @@ +from concurrent import futures + +import click +import grpc +from flyteidl.service.external_plugin_service_pb2_grpc import add_ExternalPluginServiceServicer_to_server + +from flytekit.extend.backend.external_plugin_service import BackendPluginServer + +_serve_help = """Start a grpc server for the external plugin service.""" + + +@click.command("serve", help=_serve_help) +@click.option( + "--port", + default="8000", + is_flag=False, + type=int, + help="Grpc port for the external plugin service", +) +@click.option( + "--worker", + default="10", + is_flag=False, + type=int, + help="Number of workers for the grpc server", +) +@click.option( + "--timeout", + default=None, + is_flag=False, + type=int, + help="It will wait for the specified number of seconds before shutting down grpc server. It should only be used " + "for testing.", +) +@click.pass_context +def serve(_: click.Context, port, worker, timeout): + """ + Start a grpc server for the external plugin service. + """ + click.secho("Starting the external plugin service...", fg="blue") + server = grpc.server(futures.ThreadPoolExecutor(max_workers=worker)) + add_ExternalPluginServiceServicer_to_server(BackendPluginServer(), server) + + server.add_insecure_port(f"[::]:{port}") + server.start() + server.wait_for_termination(timeout=timeout) diff --git a/tests/flytekit/unit/extras/persistence/__init__.py b/flytekit/clis/sdk_in_container/utils.py similarity index 100% rename from tests/flytekit/unit/extras/persistence/__init__.py rename to flytekit/clis/sdk_in_container/utils.py diff --git a/flytekit/configuration/__init__.py b/flytekit/configuration/__init__.py index afed857a26..75b80409cc 100644 --- a/flytekit/configuration/__init__.py +++ b/flytekit/configuration/__init__.py @@ -143,12 +143,14 @@ from io import BytesIO from typing import Dict, List, Optional +import yaml from dataclasses_json import dataclass_json -from docker_image import reference from flytekit.configuration import internal as _internal from flytekit.configuration.default_images import DefaultImages from flytekit.configuration.file import ConfigEntry, ConfigFile, get_config_file, read_file_if_exists, set_if_exists +from flytekit.image_spec import ImageSpec +from flytekit.image_spec.image_spec import ImageBuildEngine from flytekit.loggers import logger PROJECT_PLACEHOLDER = "{{ registration.project }}" @@ -205,6 +207,15 @@ def look_up_image_info(name: str, tag: str, optional_tag: bool = False) -> Image :param Text tag: e.g. somedocker.com/myimage:someversion123 :rtype: Text """ + from docker_image import reference + + if os.path.isfile(tag): + with open(tag, "r") as f: + image_spec_dict = yaml.safe_load(f) + image_spec = ImageSpec(**image_spec_dict) + ImageBuildEngine.build(image_spec) + tag = image_spec.image_name() + ref = reference.Reference.parse(tag) if not optional_tag and ref["tag"] is None: raise AssertionError(f"Incorrectly formatted image {tag}, missing tag value") @@ -344,6 +355,7 @@ class AuthType(enum.Enum): CLIENTSECRET = "ClientSecret" PKCE = "Pkce" EXTERNALCOMMAND = "ExternalCommand" + DEVICEFLOW = "DeviceFlow" @dataclass(init=True, repr=True, eq=True, frozen=True) @@ -352,7 +364,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 @@ -376,6 +388,7 @@ class PlatformConfig(object): client_credentials_secret: typing.Optional[str] = None scopes: List[str] = field(default_factory=list) auth_mode: AuthType = AuthType.STANDARD + audience: typing.Optional[str] = None rpc_retries: int = 3 @classmethod @@ -697,6 +710,7 @@ class SerializationSettings(object): fast_serialization_settings (Optional[FastSerializationSettings]): If the code is being serialized so that it can be fast registered (and thus omit building a Docker image) this object contains additional parameters for serialization. + source_root (Optional[str]): The root directory of the source code. """ image_config: ImageConfig @@ -708,6 +722,7 @@ class SerializationSettings(object): python_interpreter: str = DEFAULT_RUNTIME_PYTHON_INTERPRETER flytekit_virtualenv_root: Optional[str] = None fast_serialization_settings: Optional[FastSerializationSettings] = None + source_root: Optional[str] = None def __post_init__(self): if self.flytekit_virtualenv_root is None: @@ -781,6 +796,7 @@ def new_builder(self) -> Builder: flytekit_virtualenv_root=self.flytekit_virtualenv_root, python_interpreter=self.python_interpreter, fast_serialization_settings=self.fast_serialization_settings, + source_root=self.source_root, ) def should_fast_serialize(self) -> bool: @@ -831,6 +847,7 @@ class Builder(object): flytekit_virtualenv_root: Optional[str] = None python_interpreter: Optional[str] = None fast_serialization_settings: Optional[FastSerializationSettings] = None + source_root: Optional[str] = None def with_fast_serialization_settings(self, fss: fast_serialization_settings) -> SerializationSettings.Builder: self.fast_serialization_settings = fss @@ -847,4 +864,5 @@ def build(self) -> SerializationSettings: flytekit_virtualenv_root=self.flytekit_virtualenv_root, python_interpreter=self.python_interpreter, fast_serialization_settings=self.fast_serialization_settings, + source_root=self.source_root, ) diff --git a/flytekit/configuration/default_images.py b/flytekit/configuration/default_images.py index 8c01041eed..625e69d9ae 100644 --- a/flytekit/configuration/default_images.py +++ b/flytekit/configuration/default_images.py @@ -30,14 +30,19 @@ def default_image(cls) -> str: def find_image_for( cls, python_version: typing.Optional[PythonVersion] = None, flytekit_version: typing.Optional[str] = None ) -> str: + if python_version is None: + python_version = PythonVersion((sys.version_info.major, sys.version_info.minor)) + + return cls._DEFAULT_IMAGE_PREFIXES[python_version] + ( + flytekit_version.replace("v", "") if flytekit_version else cls.get_version_suffix() + ) + + @classmethod + def get_version_suffix(cls) -> str: from flytekit import __version__ if not __version__ or __version__ == "0.0.0+develop": version_suffix = "latest" else: version_suffix = __version__ - if python_version is None: - python_version = PythonVersion((sys.version_info.major, sys.version_info.minor)) - return cls._DEFAULT_IMAGE_PREFIXES[python_version] + ( - flytekit_version.replace("v", "") if flytekit_version else version_suffix - ) + return version_suffix diff --git a/flytekit/core/base_task.py b/flytekit/core/base_task.py index 2cf8032a6f..8bbe636227 100644 --- a/flytekit/core/base_task.py +++ b/flytekit/core/base_task.py @@ -37,8 +37,8 @@ translate_inputs_to_literals, ) from flytekit.core.tracker import TrackedInstance -from flytekit.core.type_engine import TypeEngine -from flytekit.deck.deck import Deck +from flytekit.core.type_engine import TypeEngine, TypeTransformerFailedError +from flytekit.core.utils import timeit from flytekit.loggers import logger from flytekit.models import dynamic_job as _dynamic_job from flytekit.models import interface as _interface_models @@ -239,12 +239,17 @@ def local_execute(self, ctx: FlyteContext, **kwargs) -> Union[Tuple[Promise], Pr # Promises as essentially inputs from previous task executions # native constants are just bound to this specific task (default values for a task input) # Also along with promises and constants, there could be dictionary or list of promises or constants - kwargs = translate_inputs_to_literals( - ctx, - incoming_values=kwargs, - flyte_interface_types=self.interface.inputs, # type: ignore - native_types=self.get_input_types(), - ) + try: + kwargs = translate_inputs_to_literals( + ctx, + incoming_values=kwargs, + flyte_interface_types=self.interface.inputs, + native_types=self.get_input_types(), # type: ignore + ) + except TypeTransformerFailedError as exc: + msg = f"Failed to convert inputs of task '{self.name}':\n {exc}" + logger.error(msg) + raise TypeError(msg) from exc input_literal_map = _literal_models.LiteralMap(literals=kwargs) # if metadata.cache is set, check memoized version @@ -503,13 +508,21 @@ def dispatch_execute( ) as exec_ctx: # TODO We could support default values here too - but not part of the plan right now # Translate the input literals to Python native - native_inputs = TypeEngine.literal_map_to_kwargs(exec_ctx, input_literal_map, self.python_interface.inputs) + try: + native_inputs = TypeEngine.literal_map_to_kwargs( + exec_ctx, input_literal_map, self.python_interface.inputs + ) + except Exception as exc: + msg = f"Failed to convert inputs of task '{self.name}':\n {exc}" + logger.error(msg) + raise type(exc)(msg) from exc # TODO: Logger should auto inject the current context information to indicate if the task is running within # a workflow or a subworkflow etc logger.info(f"Invoking {self.name} with inputs: {native_inputs}") try: - native_outputs = self.execute(**native_inputs) + with timeit("Execute user level code"): + native_outputs = self.execute(**native_inputs) except Exception as e: logger.exception(f"Exception when executing {e}") raise e @@ -546,22 +559,26 @@ def dispatch_execute( # We manually construct a LiteralMap here because task inputs and outputs actually violate the assumption # built into the IDL that all the values of a literal map are of the same type. - literals = {} - for k, v in native_outputs_as_map.items(): - literal_type = self._outputs_interface[k].type - py_type = self.get_type_for_output_var(k, v) - - if isinstance(v, tuple): - raise TypeError(f"Output({k}) in task{self.name} received a tuple {v}, instead of {py_type}") - try: - literals[k] = TypeEngine.to_literal(exec_ctx, v, py_type, literal_type) - except Exception as e: - logger.error(f"Failed to convert return value for var {k} with error {type(e)}: {e}") - raise TypeError( - f"Failed to convert return value for var {k} for function {self.name} with error {type(e)}: {e}" - ) from e + with timeit("Translate the output to literals"): + literals = {} + for i, (k, v) in enumerate(native_outputs_as_map.items()): + literal_type = self._outputs_interface[k].type + py_type = self.get_type_for_output_var(k, v) + + if isinstance(v, tuple): + raise TypeError(f"Output({k}) in task '{self.name}' received a tuple {v}, instead of {py_type}") + try: + literals[k] = TypeEngine.to_literal(exec_ctx, v, py_type, literal_type) + except Exception as e: + # only show the name of output key if it's user-defined (by default Flyte names these as "o") + key = k if k != f"o{i}" else i + msg = f"Failed to convert outputs of task '{self.name}' at position {key}:\n {e}" + logger.error(msg) + raise TypeError(msg) from e if self._disable_deck is False: + from flytekit.deck.deck import Deck + INPUT = "input" OUTPUT = "output" 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/container_task.py b/flytekit/core/container_task.py index d470fb54fe..bec3915430 100644 --- a/flytekit/core/container_task.py +++ b/flytekit/core/container_task.py @@ -4,13 +4,15 @@ from flytekit.configuration import SerializationSettings from flytekit.core.base_task import PythonTask, TaskMetadata from flytekit.core.interface import Interface +from flytekit.core.pod_template import PodTemplate from flytekit.core.resources import Resources, ResourceSpec -from flytekit.core.utils import _get_container_definition +from flytekit.core.utils import _get_container_definition, _serialize_pod_spec from flytekit.models import task as _task_model from flytekit.models.security import Secret, SecurityContext +_PRIMARY_CONTAINER_NAME_FIELD = "primary_container_name" + -# TODO: do we need pod_template here? Seems that it is a raw container not running in pods class ContainerTask(PythonTask): """ This is an intermediate class that represents Flyte Tasks that run a container at execution time. This is the vast @@ -47,6 +49,8 @@ def __init__( metadata_format: MetadataFormat = MetadataFormat.JSON, io_strategy: IOStrategy = None, secret_requests: Optional[List[Secret]] = None, + pod_template: Optional["PodTemplate"] = None, + pod_template_name: Optional[str] = None, **kwargs, ): sec_ctx = None @@ -55,6 +59,11 @@ def __init__( if not isinstance(s, Secret): raise AssertionError(f"Secret {s} should be of type flytekit.Secret, received {type(s)}") sec_ctx = SecurityContext(secrets=secret_requests) + + # pod_template_name overwrites the metadata.pod_template_name + metadata = metadata or TaskMetadata() + metadata.pod_template_name = pod_template_name + super().__init__( task_type="raw-container", name=name, @@ -74,6 +83,7 @@ def __init__( self._resources = ResourceSpec( requests=requests if requests else Resources(), limits=limits if limits else Resources() ) + self.pod_template = pod_template @property def resources(self) -> ResourceSpec: @@ -91,19 +101,29 @@ def execute(self, **kwargs) -> Any: return None def get_container(self, settings: SerializationSettings) -> _task_model.Container: + # if pod_template is specified, return None here but in get_k8s_pod, return pod_template merged with container + if self.pod_template is not None: + return None + + return self._get_container(settings) + + def _get_data_loading_config(self) -> _task_model.DataLoadingConfig: + return _task_model.DataLoadingConfig( + input_path=self._input_data_dir, + output_path=self._output_data_dir, + format=self._md_format.value, + enabled=True, + io_strategy=self._io_strategy.value if self._io_strategy else None, + ) + + def _get_container(self, settings: SerializationSettings) -> _task_model.Container: env = settings.env or {} env = {**env, **self.environment} if self.environment else env return _get_container_definition( image=self._image, command=self._cmd, args=self._args, - data_loading_config=_task_model.DataLoadingConfig( - input_path=self._input_data_dir, - output_path=self._output_data_dir, - format=self._md_format.value, - enabled=True, - io_strategy=self._io_strategy.value if self._io_strategy else None, - ), + data_loading_config=self._get_data_loading_config(), environment=env, storage_request=self.resources.requests.storage, ephemeral_storage_request=self.resources.requests.ephemeral_storage, @@ -116,3 +136,20 @@ def get_container(self, settings: SerializationSettings) -> _task_model.Containe gpu_limit=self.resources.limits.gpu, memory_limit=self.resources.limits.mem, ) + + def get_k8s_pod(self, settings: SerializationSettings) -> _task_model.K8sPod: + if self.pod_template is None: + return None + return _task_model.K8sPod( + pod_spec=_serialize_pod_spec(self.pod_template, self._get_container(settings)), + metadata=_task_model.K8sObjectMetadata( + labels=self.pod_template.labels, + annotations=self.pod_template.annotations, + ), + data_config=self._get_data_loading_config(), + ) + + def get_config(self, settings: SerializationSettings) -> Optional[Dict[str, str]]: + if self.pod_template is None: + return {} + return {_PRIMARY_CONTAINER_NAME_FIELD: self.pod_template.primary_container_name} diff --git a/flytekit/core/context_manager.py b/flytekit/core/context_manager.py index 7e4600b3bb..3bc776008d 100644 --- a/flytekit/core/context_manager.py +++ b/flytekit/core/context_manager.py @@ -27,7 +27,6 @@ from enum import Enum from typing import Generator, List, Optional, Union -from flytekit.clients import friendly as friendly_client # noqa from flytekit.configuration import Config, SecretsConfig, SerializationSettings from flytekit.core import mock_stats, utils from flytekit.core.checkpointer import Checkpoint, SyncCheckpoint @@ -39,7 +38,8 @@ from flytekit.models.core import identifier as _identifier if typing.TYPE_CHECKING: - from flytekit.deck.deck import Deck + from flytekit import Deck + from flytekit.clients import friendly as friendly_client # noqa # TODO: resolve circular import from flytekit.core.python_auto_container import TaskResolverMixin @@ -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 @@ -264,10 +262,24 @@ def decks(self) -> typing.List: @property def default_deck(self) -> Deck: - from flytekit.deck.deck import Deck + from flytekit import Deck return Deck("default") + @property + def timeline_deck(self) -> "TimeLineDeck": # type: ignore + from flytekit.deck.deck import TimeLineDeck + + time_line_deck = None + for deck in self.decks: + if isinstance(deck, TimeLineDeck): + time_line_deck = deck + break + if time_line_deck is None: + time_line_deck = TimeLineDeck("Timeline") + + return time_line_deck + def __getattr__(self, attr_name: str) -> typing.Any: """ This houses certain task specific context. For example in Spark, it houses the SparkSession, etc @@ -331,13 +343,13 @@ def __getattr__(self, item: str) -> _GroupSecrets: """ return self._GroupSecrets(item, self) - def get(self, group: str, key: str) -> str: + def get(self, group: str, key: Optional[str] = None, group_version: Optional[str] = None) -> str: """ Retrieves a secret using the resolution order -> Env followed by file. If not found raises a ValueError """ - self.check_group_key(group, key) - env_var = self.get_secrets_env_var(group, key) - fpath = self.get_secrets_file(group, key) + self.check_group_key(group) + env_var = self.get_secrets_env_var(group, key, group_version) + fpath = self.get_secrets_file(group, key, group_version) v = os.environ.get(env_var) if v is not None: return v @@ -348,26 +360,27 @@ def get(self, group: str, key: str) -> str: f"Unable to find secret for key {key} in group {group} " f"in Env Var:{env_var} and FilePath: {fpath}" ) - def get_secrets_env_var(self, group: str, key: str) -> str: + def get_secrets_env_var(self, group: str, key: Optional[str] = None, group_version: Optional[str] = None) -> str: """ Returns a string that matches the ENV Variable to look for the secrets """ - self.check_group_key(group, key) - return f"{self._env_prefix}{group.upper()}_{key.upper()}" + self.check_group_key(group) + l = [k.upper() for k in filter(None, (group, group_version, key))] + return f"{self._env_prefix}{'_'.join(l)}" - def get_secrets_file(self, group: str, key: str) -> str: + def get_secrets_file(self, group: str, key: Optional[str] = None, group_version: Optional[str] = None) -> str: """ Returns a path that matches the file to look for the secrets """ - self.check_group_key(group, key) - return os.path.join(self._base_dir, group.lower(), f"{self._file_prefix}{key.lower()}") + self.check_group_key(group) + l = [k.lower() for k in filter(None, (group, group_version, key))] + l[-1] = f"{self._file_prefix}{l[-1]}" + return os.path.join(self._base_dir, *l) @staticmethod - def check_group_key(group: str, key: str): + def check_group_key(group: str): if group is None or group == "": raise ValueError("secrets group is a mandatory field.") - if key is None or key == "": - raise ValueError("secrets key is a mandatory field.") @dataclass(frozen=True) @@ -538,7 +551,7 @@ class FlyteContext(object): file_access: FileAccessProvider level: int = 0 - flyte_client: Optional[friendly_client.SynchronousFlyteClient] = None + flyte_client: Optional["friendly_client.SynchronousFlyteClient"] = None compilation_state: Optional[CompilationState] = None execution_state: Optional[ExecutionState] = None serialization_settings: Optional[SerializationSettings] = None @@ -647,7 +660,7 @@ class Builder(object): level: int = 0 compilation_state: Optional[CompilationState] = None execution_state: Optional[ExecutionState] = None - flyte_client: Optional[friendly_client.SynchronousFlyteClient] = None + flyte_client: Optional["friendly_client.SynchronousFlyteClient"] = None serialization_settings: Optional[SerializationSettings] = None in_a_condition: bool = False @@ -726,7 +739,7 @@ class FlyteContextManager(object): FlyteContextManager manages the execution context within Flytekit. It holds global state of either compilation or Execution. It is not thread-safe and can only be run as a single threaded application currently. Context's within Flytekit is useful to manage compilation state and execution state. Refer to ``CompilationState`` - and ``ExecutionState`` for for information. FlyteContextManager provides a singleton stack to manage these contexts. + and ``ExecutionState`` for more information. FlyteContextManager provides a singleton stack to manage these contexts. Typical usage is diff --git a/flytekit/core/data_persistence.py b/flytekit/core/data_persistence.py index d407b3528b..2080f73b9f 100644 --- a/flytekit/core/data_persistence.py +++ b/flytekit/core/data_persistence.py @@ -14,303 +14,52 @@ :template: custom.rst :nosignatures: - DataPersistence - DataPersistencePlugins - DiskPersistence FileAccessProvider - 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 Any, Dict, 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.core.utils import timeit +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 - - -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. - """ - - _PLUGINS: Dict[str, typing.Type[DataPersistence]] = {} - - @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" - ) - - cls._PLUGINS[protocol] = plugin - - @staticmethod - def get_protocol(url: 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}") +# 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" - @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 +def s3_setup_args(s3_cfg: configuration.S3Config, anonymous: bool = False): + kwargs: Dict[str, Any] = { + "cache_regions": True, + } + if s3_cfg.access_key_id: + kwargs[_FSSPEC_S3_KEY_ID] = s3_cfg.access_key_id - @classmethod - def supported_protocols(cls) -> typing.List[str]: - return [k for k in cls._PLUGINS.keys()] + 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} -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. - """ + if anonymous: + kwargs[_ANON] = True - 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 +84,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 +105,112 @@ 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, **kwargs + ) -> typing.Optional[fsspec.AbstractFileSystem]: + if not protocol: + return self._default_remote + if protocol == "file": + kwargs["auto_mkdir"] = True + elif protocol == "s3": + 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 + 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, **kwargs) -> fsspec.AbstractFileSystem: + protocol = get_protocol(path) + return self.get_filesystem(protocol, anonymous=anonymous, **kwargs) + @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: + return _copytree(self.strip_file_header(from_path), self.strip_file_header(to_path)) + 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": + return _copytree(self.strip_file_header(from_path), self.strip_file_header(to_path)) + 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 +219,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 +241,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 +280,36 @@ 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): + @timeit("Download data to local from remote") + 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 - ) + pathlib.Path(local_path).parent.mkdir(parents=True, exist_ok=True) + 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: Union[str, os.PathLike], remote_path: str, is_multipart=False): + @timeit("Upload data to remote") + 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 - ) + local_path = str(local_path) + + 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,8 +317,18 @@ def put_data(self, local_path: Union[str, os.PathLike], remote_path: str, is_mul ) from ex -DataPersistencePlugins.register_plugin("file://", DiskPersistence) -DataPersistencePlugins.register_plugin("/", DiskPersistence) +def _copytree(source, destination): + if not os.path.exists(destination): + os.makedirs(destination) + for item in os.listdir(source): + s = os.path.join(source, item) + d = os.path.join(destination, item) + if os.path.isdir(s): + _copytree(s, d) + else: + shutil.copy2(s, d) + return destination + flyte_tmp_dir = tempfile.mkdtemp(prefix="flyte-") default_local_file_access_provider = FileAccessProvider( diff --git a/flytekit/core/interface.py b/flytekit/core/interface.py index 954c1ae409..b7d1ee997c 100644 --- a/flytekit/core/interface.py +++ b/flytekit/core/interface.py @@ -21,6 +21,28 @@ T = typing.TypeVar("T") +def repr_kv(k: str, v: Union[Type, Tuple[Type, Any]]) -> str: + if isinstance(v, tuple): + if v[1]: + return f"{k}: {v[0]}={v[1]}" + return f"{k}: {v[0]}" + return f"{k}: {v}" + + +def repr_type_signature(io: Union[Dict[str, Tuple[Type, Any]], Dict[str, Type]]) -> str: + """ + Converts an inputs and outputs to a type signature + """ + s = "(" + i = 0 + for k, v in io.items(): + if i > 0: + s += ", " + s += repr_kv(k, v) + i = i + 1 + return s + ")" + + class Interface(object): """ A Python native interface object, like inspect.signature but simpler. @@ -57,7 +79,9 @@ def __init__( variables = [k for k in outputs.keys()] # TODO: This class is a duplicate of the one in create_task_outputs. Over time, we should move to this one. - class Output(collections.namedtuple(output_tuple_name or "DefaultNamedTupleOutput", variables)): + class Output( # type: ignore + collections.namedtuple(output_tuple_name or "DefaultNamedTupleOutput", variables) # type: ignore + ): # type: ignore """ This class can be used in two different places. For multivariate-return entities this class is used to rewrap the outputs so that our with_overrides function can work. @@ -167,6 +191,12 @@ def with_outputs(self, extra_outputs: Dict[str, Type]) -> Interface: new_outputs[k] = v return Interface(self._inputs, new_outputs) + def __str__(self): + return f"{repr_type_signature(self._inputs)} -> {repr_type_signature(self._outputs)}" + + def __repr__(self): + return str(self) + def transform_inputs_to_parameters( ctx: context_manager.FlyteContext, interface: Interface @@ -220,7 +250,7 @@ def transform_interface_to_typed_interface( return _interface_models.TypedInterface(inputs_map, outputs_map) -def transform_types_to_list_of_type(m: Dict[str, type]) -> Dict[str, type]: +def transform_types_to_list_of_type(m: Dict[str, type], bound_inputs: typing.Set[str]) -> Dict[str, type]: """ Converts a given variables to be collections of their type. This is useful for array jobs / map style code. It will create a collection of types even if any one these types is not a collection type @@ -230,6 +260,10 @@ def transform_types_to_list_of_type(m: Dict[str, type]) -> Dict[str, type]: all_types_are_collection = True for k, v in m.items(): + if k in bound_inputs: + # Skip the inputs that are bound. If they are bound, it does not matter if they are collection or + # singletons + continue v_type = type(v) if v_type != typing.List and v_type != list: all_types_are_collection = False @@ -240,17 +274,22 @@ def transform_types_to_list_of_type(m: Dict[str, type]) -> Dict[str, type]: om = {} for k, v in m.items(): - om[k] = typing.List[v] + if k in bound_inputs: + om[k] = v + else: + om[k] = typing.List[v] # type: ignore return om # type: ignore -def transform_interface_to_list_interface(interface: Interface) -> Interface: +def transform_interface_to_list_interface(interface: Interface, bound_inputs: typing.Set[str]) -> Interface: """ Takes a single task interface and interpolates it to an array interface - to allow performing distributed python map like functions + :param interface: Interface to be upgraded toa list interface + :param bound_inputs: fixed inputs that should not upgraded to a list and will be maintained as scalars. """ - map_inputs = transform_types_to_list_of_type(interface.inputs) - map_outputs = transform_types_to_list_of_type(interface.outputs) + map_inputs = transform_types_to_list_of_type(interface.inputs, bound_inputs) + map_outputs = transform_types_to_list_of_type(interface.outputs, set()) return Interface(inputs=map_inputs, outputs=map_outputs) @@ -286,7 +325,6 @@ def transform_function_to_interface(fn: typing.Callable, docstring: Optional[Doc For now the fancy object, maybe in the future a dumb object. """ - type_hints = get_type_hints(fn, include_extras=True) signature = inspect.signature(fn) return_annotation = type_hints.get("return", None) diff --git a/flytekit/core/local_cache.py b/flytekit/core/local_cache.py index 11cb3b926c..e0b205ca5b 100644 --- a/flytekit/core/local_cache.py +++ b/flytekit/core/local_cache.py @@ -1,10 +1,12 @@ from typing import Optional -import joblib from diskcache import Cache +from flytekit import lazy_module from flytekit.models.literals import Literal, LiteralCollection, LiteralMap +joblib = lazy_module("joblib") + # Location on the filesystem where serialized objects will be stored # TODO: read from config CACHE_LOCATION = "~/.flyte/local-cache" diff --git a/flytekit/core/map_task.py b/flytekit/core/map_task.py index 3b5c0a09ca..44f18de2a3 100644 --- a/flytekit/core/map_task.py +++ b/flytekit/core/map_task.py @@ -2,71 +2,92 @@ Flytekit map tasks specify how to run a single task across a list of inputs. Map tasks themselves are constructed with a reference task as well as run-time parameters that limit execution concurrency and failure tolerations. """ - +import functools +import hashlib +import logging import os import typing from contextlib import contextmanager -from itertools import count -from typing import Any, Dict, List, Optional, Type +from typing import Any, Dict, List, Optional, Set from flytekit.configuration import SerializationSettings from flytekit.core import tracker -from flytekit.core.base_task import PythonTask +from flytekit.core.base_task import PythonTask, Task, TaskResolverMixin from flytekit.core.constants import SdkTaskType from flytekit.core.context_manager import ExecutionState, FlyteContext, FlyteContextManager from flytekit.core.interface import transform_interface_to_list_interface from flytekit.core.python_function_task import PythonFunctionTask +from flytekit.core.tracker import TrackedInstance +from flytekit.core.utils import timeit from flytekit.exceptions import scopes as exception_scopes from flytekit.models.array_job import ArrayJob from flytekit.models.interface import Variable from flytekit.models.task import Container, K8sPod, Sql +from flytekit.tools.module_loader import load_object_from_module class MapPythonTask(PythonTask): """ A MapPythonTask defines a :py:class:`flytekit.PythonTask` which specifies how to run an inner :py:class:`flytekit.PythonFunctionTask` across a range of inputs in parallel. - TODO: support lambda functions """ - # To support multiple map tasks declared around identical python function tasks, we keep a global count of - # MapPythonTask instances to uniquely differentiate map task names for each declared instance. - _ids = count(0) - def __init__( self, - python_function_task: PythonFunctionTask, - concurrency: int = None, - min_success_ratio: float = None, + python_function_task: typing.Union[PythonFunctionTask, functools.partial], + concurrency: Optional[int] = None, + min_success_ratio: Optional[float] = None, + bound_inputs: Optional[Set[str]] = None, **kwargs, ): """ + Wrapper that creates a MapPythonTask + :param python_function_task: This argument is implicitly passed and represents the repeatable function :param concurrency: If specified, this limits the number of mapped tasks than can run in parallel to the given - batch size + batch size :param min_success_ratio: If specified, this determines the minimum fraction of total jobs which can complete - successfully before terminating this task and marking it successful. + successfully before terminating this task and marking it successful + :param bound_inputs: List[str] specifies a list of variable names within the interface of python_function_task, + that are already bound and should not be considered as list inputs, but scalar values. This is mostly + useful at runtime and is passed in by MapTaskResolver. This field is not required when a `partial` method + is specified. The bound_vars will be auto-deduced from the `partial.keywords`. """ - if len(python_function_task.python_interface.inputs.keys()) > 1: - raise ValueError("Map tasks only accept python function tasks with 0 or 1 inputs") - - if len(python_function_task.python_interface.outputs.keys()) > 1: + self._partial = None + if isinstance(python_function_task, functools.partial): + # TODO: We should be able to support partial tasks with lists as inputs + for arg in python_function_task.keywords.values(): + if isinstance(arg, list): + raise ValueError("Map tasks do not support partial tasks with lists as inputs. ") + self._partial = python_function_task + actual_task = self._partial.func + else: + actual_task = python_function_task + + if not isinstance(actual_task, PythonFunctionTask): + raise ValueError("Map tasks can only compose of Python Functon Tasks currently") + + if len(actual_task.python_interface.outputs.keys()) > 1: raise ValueError("Map tasks only accept python function tasks with 0 or 1 outputs") - collection_interface = transform_interface_to_list_interface(python_function_task.python_interface) - instance = next(self._ids) - _, mod, f, _ = tracker.extract_task_module(python_function_task.task_function) - name = f"{mod}.mapper_{f}_{instance}" - - self._cmd_prefix = None - self._run_task = python_function_task - self._max_concurrency = concurrency - self._min_success_ratio = min_success_ratio - self._array_task_interface = python_function_task.python_interface - if "metadata" not in kwargs and python_function_task.metadata: - kwargs["metadata"] = python_function_task.metadata - if "security_ctx" not in kwargs and python_function_task.security_context: - kwargs["security_ctx"] = python_function_task.security_context + self._bound_inputs: typing.Set[str] = set(bound_inputs) if bound_inputs else set() + if self._partial: + self._bound_inputs = set(self._partial.keywords.keys()) + + collection_interface = transform_interface_to_list_interface(actual_task.python_interface, self._bound_inputs) + self._run_task: PythonFunctionTask = actual_task + _, mod, f, _ = tracker.extract_task_module(actual_task.task_function) + h = hashlib.md5(collection_interface.__str__().encode("utf-8")).hexdigest() + name = f"{mod}.map_{f}_{h}" + + self._cmd_prefix: typing.Optional[typing.List[str]] = None + self._max_concurrency: typing.Optional[int] = concurrency + self._min_success_ratio: typing.Optional[float] = min_success_ratio + self._array_task_interface = actual_task.python_interface + if "metadata" not in kwargs and actual_task.metadata: + kwargs["metadata"] = actual_task.metadata + if "security_ctx" not in kwargs and actual_task.security_context: + kwargs["security_ctx"] = actual_task.security_context super().__init__( name=name, interface=collection_interface, @@ -76,7 +97,15 @@ def __init__( **kwargs, ) + @property + def bound_inputs(self) -> Set[str]: + return self._bound_inputs + def get_command(self, settings: SerializationSettings) -> List[str]: + """ + TODO ADD bound variables to the resolver. Maybe we need a different resolver? + """ + mt = MapTaskResolver() container_args = [ "pyflyte-map-execute", "--inputs", @@ -90,9 +119,9 @@ def get_command(self, settings: SerializationSettings) -> List[str]: "--prev-checkpoint", "{{.prevCheckpointPrefix}}", "--resolver", - self._run_task.task_resolver.location, + mt.name(), "--", - *self._run_task.task_resolver.loader_args(settings, self._run_task), + *mt.loader_args(settings, self), ] if self._cmd_prefix: @@ -100,7 +129,7 @@ def get_command(self, settings: SerializationSettings) -> List[str]: return container_args def set_command_prefix(self, cmd: typing.Optional[typing.List[str]]): - self._cmd_prefix = cmd # type: ignore + self._cmd_prefix = cmd @contextmanager def prepare_target(self): @@ -135,6 +164,18 @@ def get_config(self, settings: SerializationSettings) -> Optional[Dict[str, str] def run_task(self) -> PythonFunctionTask: return self._run_task + def __call__(self, *args, **kwargs): + """ + This call method modifies the kwargs and adds kwargs from partial. + This is mostly done in the local_execute and compilation only. + At runtime, the map_task is created with all the inputs filled in. to support this, we have modified + the map_task interface in the constructor. + """ + if self._partial: + """If partial exists, then mix-in all partial values""" + kwargs = {**self._partial.keywords, **kwargs} + return super().__call__(*args, **kwargs) + def execute(self, **kwargs) -> Any: ctx = FlyteContextManager.current_context() if ctx.execution_state and ctx.execution_state.mode == ExecutionState.Mode.TASK_EXECUTION: @@ -168,7 +209,7 @@ def _outputs_interface(self) -> Dict[Any, Variable]: return self.interface.outputs return self._run_task.interface.outputs - def get_type_for_output_var(self, k: str, v: Any) -> Optional[Type[Any]]: + def get_type_for_output_var(self, k: str, v: Any) -> type: """ We override this method from flytekit.core.base_task Task because the dispatch_execute method uses this interface to construct outputs. Each instance of an container_array task will however produce outputs @@ -191,7 +232,11 @@ def _execute_map_task(self, ctx: FlyteContext, **kwargs) -> Any: task_index = self._compute_array_job_index() map_task_inputs = {} for k in self.interface.inputs.keys(): - map_task_inputs[k] = kwargs[k][task_index] + v = kwargs[k] + if isinstance(v, list) and k not in self.bound_inputs: + map_task_inputs[k] = v[task_index] + else: + map_task_inputs[k] = v return exception_scopes.user_entry_point(self._run_task.execute)(**map_task_inputs) def _raw_execute(self, **kwargs) -> Any: @@ -213,7 +258,11 @@ def _raw_execute(self, **kwargs) -> Any: for i in range(len(kwargs[any_input_key])): single_instance_inputs = {} for k in self.interface.inputs.keys(): - single_instance_inputs[k] = kwargs[k][i] + v = kwargs[k] + if isinstance(v, list) and k not in self.bound_inputs: + single_instance_inputs[k] = kwargs[k][i] + else: + single_instance_inputs[k] = kwargs[k] o = exception_scopes.user_entry_point(self._run_task.execute)(**single_instance_inputs) if outputs_expected: outputs.append(o) @@ -221,7 +270,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[PythonFunctionTask, functools.partial], + 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`. @@ -267,8 +321,64 @@ def map_task(task_function: PythonFunctionTask, concurrency: int = 0, min_succes successfully before terminating this task and marking it successful. """ - if not isinstance(task_function, PythonFunctionTask): - raise ValueError( - f"Only Flyte python task types are supported in map tasks currently, received {type(task_function)}" - ) return MapPythonTask(task_function, concurrency=concurrency, min_success_ratio=min_success_ratio, **kwargs) + + +class MapTaskResolver(TrackedInstance, TaskResolverMixin): + """ + Special resolver that is used for MapTasks. + This exists because it is possible that MapTasks are created using nested "partial" subtasks. + When a maptask is created its interface is interpolated from the interface of the subtask - the interpolation, + simply converts every input into a list/collection input. + + For example: + interface -> (i: int, j: str) -> str => map_task interface -> (i: List[int], j: List[str]) -> List[str] + + But in cases in which `j` is bound to a fixed value by using `functools.partial` we need a way to ensure that + the interface is not simply interpolated, but only the unbound inputs are interpolated. + + .. code-block:: python + + def foo((i: int, j: str) -> str: + ... + + mt = map_task(functools.partial(foo, j=10)) + + print(mt.interface) + + output: + + (i: List[int], j: str) -> List[str] + + But, at runtime this information is lost. To reconstruct this, we use MapTaskResolver that records the "bound vars" + and then at runtime reconstructs the interface with this knowledge + """ + + def name(self) -> str: + return "MapTaskResolver" + + @timeit("Load map task") + def load_task(self, loader_args: List[str], max_concurrency: int = 0) -> MapPythonTask: + """ + Loader args should be of the form + vars "var1,var2,.." resolver "resolver" [resolver_args] + """ + _, bound_vars, _, resolver, *resolver_args = loader_args + logging.info(f"MapTask found task resolver {resolver} and arguments {resolver_args}") + resolver_obj = load_object_from_module(resolver) + # Use the resolver to load the actual task object + _task_def = resolver_obj.load_task(loader_args=resolver_args) + bound_inputs = set(bound_vars.split(",")) + return MapPythonTask(python_function_task=_task_def, max_concurrency=max_concurrency, bound_inputs=bound_inputs) + + def loader_args(self, settings: SerializationSettings, t: MapPythonTask) -> List[str]: # type:ignore + return [ + "vars", + f'{",".join(t.bound_inputs)}', + "resolver", + t.run_task.task_resolver.location, + *t.run_task.task_resolver.loader_args(settings, t.run_task), + ] + + def get_all_tasks(self) -> List[Task]: + raise NotImplementedError("MapTask resolver cannot return every instance of the map task") diff --git a/flytekit/core/node.py b/flytekit/core/node.py index 617790746f..9caf00adad 100644 --- a/flytekit/core/node.py +++ b/flytekit/core/node.py @@ -84,7 +84,9 @@ def metadata(self) -> _workflow_model.NodeMetadata: def with_overrides(self, *args, **kwargs): if "node_name" in kwargs: - self._id = kwargs["node_name"] + # Convert the node name into a DNS-compliant. + # https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names + self._id = _dnsify(kwargs["node_name"]) if "aliases" in kwargs: alias_dict = kwargs["aliases"] if not isinstance(alias_dict, dict): diff --git a/flytekit/core/pod_template.py b/flytekit/core/pod_template.py index 5e9c746911..98ba92af36 100644 --- a/flytekit/core/pod_template.py +++ b/flytekit/core/pod_template.py @@ -1,22 +1,27 @@ from dataclasses import dataclass -from typing import Dict, Optional - -from kubernetes.client.models import V1PodSpec +from typing import TYPE_CHECKING, Dict, Optional from flytekit.exceptions import user as _user_exceptions +if TYPE_CHECKING: + from kubernetes.client import V1PodSpec + PRIMARY_CONTAINER_DEFAULT_NAME = "primary" -@dataclass +@dataclass(init=True, repr=True, eq=True, frozen=False) class PodTemplate(object): """Custom PodTemplate specification for a Task.""" - pod_spec: V1PodSpec = V1PodSpec(containers=[]) + pod_spec: Optional["V1PodSpec"] = None primary_container_name: str = PRIMARY_CONTAINER_DEFAULT_NAME labels: Optional[Dict[str, str]] = None annotations: Optional[Dict[str, str]] = None def __post_init__(self): + if self.pod_spec is None: + from kubernetes.client import V1PodSpec + + self.pod_spec = V1PodSpec(containers=[]) if not self.primary_container_name: raise _user_exceptions.FlyteValidationException("A primary container name cannot be undefined") diff --git a/flytekit/core/promise.py b/flytekit/core/promise.py index 3a851a50ea..24628cd52e 100644 --- a/flytekit/core/promise.py +++ b/flytekit/core/promise.py @@ -13,8 +13,9 @@ from flytekit.core.context_manager import BranchEvalMode, ExecutionState, FlyteContext, FlyteContextManager from flytekit.core.interface import Interface from flytekit.core.node import Node -from flytekit.core.type_engine import DictTransformer, ListTransformer, TypeEngine +from flytekit.core.type_engine import DictTransformer, ListTransformer, TypeEngine, TypeTransformerFailedError from flytekit.exceptions import user as _user_exceptions +from flytekit.loggers import logger from flytekit.models import interface as _interface_models from flytekit.models import literals as _literal_models from flytekit.models import literals as _literals_models @@ -86,6 +87,12 @@ def extract_value( if len(input_val) == 0: raise sub_type = type(input_val[0]) + # To maintain consistency between translate_inputs_to_literals and ListTransformer.to_literal for batchable types, + # directly call ListTransformer.to_literal to batch process the list items. This is necessary because processing + # each list item separately could lead to errors since ListTransformer.to_python_value may treat the literal + # as it is batched for batchable types. + if ListTransformer.is_batchable(python_type): + return TypeEngine.to_literal(ctx, input_val, python_type, lt) literal_list = [extract_value(ctx, v, sub_type, lt.collection_type) for v in input_val] return _literal_models.Literal(collection=_literal_models.LiteralCollection(literals=literal_list)) elif isinstance(input_val, dict): @@ -135,7 +142,10 @@ def extract_value( raise ValueError(f"Received unexpected keyword argument {k}") var = flyte_interface_types[k] t = native_types[k] - result[k] = extract_value(ctx, v, t, var.type) + try: + result[k] = extract_value(ctx, v, t, var.type) + except TypeTransformerFailedError as exc: + raise TypeTransformerFailedError(f"Failed argument '{k}': {exc}") from exc return result @@ -471,10 +481,14 @@ def create_native_named_tuple( if isinstance(promises, Promise): k, v = [(k, v) for k, v in entity_interface.outputs.items()][0] # get output native type + # only show the name of output key if it's user-defined (by default Flyte names these as "o") + key = k if k != "o0" else 0 try: return TypeEngine.to_python_value(ctx, promises.val, v) except Exception as e: - raise AssertionError(f"Failed to convert value of output {k}, expected type {v}.") from e + raise TypeError( + f"Failed to convert output in position {key} of value {promises.val}, expected type {v}." + ) from e if len(promises) == 0: return None @@ -484,7 +498,7 @@ def create_native_named_tuple( named_tuple_name = entity_interface.output_tuple_name outputs = {} - for p in promises: + for i, p in enumerate(cast(Tuple[Promise], promises)): if not isinstance(p, Promise): raise AssertionError( "Workflow outputs can only be promises that are returned by tasks. Found a value of" @@ -494,7 +508,9 @@ def create_native_named_tuple( try: outputs[p.var] = TypeEngine.to_python_value(ctx, p.val, t) except Exception as e: - raise AssertionError(f"Failed to convert value of output {p.var}, expected type {t}.") from e + # only show the name of output key if it's user-defined (by default Flyte names these as "o") + key = p.var if p.var != f"o{i}" else i + raise TypeError(f"Failed to convert output in position {key} of value {p.val}, expected type {t}.") from e # Should this class be part of the Interface? t = collections.namedtuple(named_tuple_name, list(outputs.keys())) @@ -597,11 +613,22 @@ def binding_data_from_python_std( f"Cannot pass output from task {t_value.task_name} that produces no outputs to a downstream task" ) - elif isinstance(t_value, list): - if expected_literal_type.collection_type is None: - raise AssertionError(f"this should be a list and it is not: {type(t_value)} vs {expected_literal_type}") + elif expected_literal_type.union_type is not None: + for i in range(len(expected_literal_type.union_type.variants)): + try: + lt_type = expected_literal_type.union_type.variants[i] + python_type = get_args(t_value_type)[i] if t_value_type else None + return binding_data_from_python_std(ctx, lt_type, t_value, python_type) + except Exception: + logger.debug( + f"failed to bind data {t_value} with literal type {expected_literal_type.union_type.variants[i]}." + ) + raise AssertionError( + f"Failed to bind data {t_value} with literal type {expected_literal_type.union_type.variants}." + ) - sub_type = ListTransformer.get_sub_type(t_value_type) if t_value_type else None + elif isinstance(t_value, list): + sub_type: Optional[type] = ListTransformer.get_sub_type(t_value_type) if t_value_type else None collection = _literals_models.BindingDataCollection( bindings=[ binding_data_from_python_std(ctx, expected_literal_type.collection_type, t, sub_type) for t in t_value @@ -1049,7 +1076,7 @@ def flyte_entity_call_handler(entity: SupportsNodeCreation, *args, **kwargs): for k, v in kwargs.items(): if k not in cast(SupportsNodeCreation, entity).python_interface.inputs: raise ValueError( - f"Received unexpected keyword argument {k} in function {cast(SupportsNodeCreation, entity).name}" + f"Received unexpected keyword argument '{k}' in function '{cast(SupportsNodeCreation, entity).name}'" ) ctx = FlyteContextManager.current_context() diff --git a/flytekit/core/python_auto_container.py b/flytekit/core/python_auto_container.py index 2d05df3c3d..66a49a819e 100644 --- a/flytekit/core/python_auto_container.py +++ b/flytekit/core/python_auto_container.py @@ -3,12 +3,7 @@ import importlib import re from abc import ABC -from types import ModuleType -from typing import Any, Callable, Dict, List, Optional, TypeVar, Union - -from flyteidl.core import tasks_pb2 as _core_task -from kubernetes.client import ApiClient -from kubernetes.client.models import V1Container, V1EnvVar, V1ResourceRequirements +from typing import Callable, Dict, List, Optional, TypeVar, Union from flytekit.configuration import ImageConfig, SerializationSettings from flytekit.core.base_task import PythonTask, TaskMetadata, TaskResolverMixin @@ -17,7 +12,8 @@ from flytekit.core.resources import Resources, ResourceSpec from flytekit.core.tracked_abc import FlyteTrackedABC from flytekit.core.tracker import TrackedInstance, extract_task_module -from flytekit.core.utils import _get_container_definition +from flytekit.core.utils import _get_container_definition, _serialize_pod_spec, timeit +from flytekit.image_spec.image_spec import ImageBuildEngine, ImageSpec from flytekit.loggers import logger from flytekit.models import task as _task_model from flytekit.models.security import Secret, SecurityContext @@ -26,10 +22,6 @@ _PRIMARY_CONTAINER_NAME_FIELD = "primary_container_name" -def _sanitize_resource_name(resource: _task_model.Resources.ResourceEntry) -> str: - return _core_task.Resources.ResourceName.Name(resource.name).lower().replace("_", "-") - - class PythonAutoContainerTask(PythonTask[T], ABC, metaclass=FlyteTrackedABC): """ A Python AutoContainer task should be used as the base for all extensions that want the user's code to be in the @@ -44,7 +36,7 @@ def __init__( name: str, task_config: T, task_type="python-task", - container_image: Optional[str] = None, + container_image: Optional[Union[str, ImageSpec]] = None, requests: Optional[Resources] = None, limits: Optional[Resources] = None, environment: Optional[Dict[str, str]] = None, @@ -86,7 +78,7 @@ def __init__( raise AssertionError(f"Secret {s} should be of type flytekit.Secret, received {type(s)}") sec_ctx = SecurityContext(secrets=secret_requests) - # pod_template_name overwrites the metedata.pod_template_name + # pod_template_name overwrites the metadata.pod_template_name kwargs["metadata"] = kwargs["metadata"] if "metadata" in kwargs else TaskMetadata() kwargs["metadata"].pod_template_name = pod_template_name @@ -124,7 +116,7 @@ def task_resolver(self) -> Optional[TaskResolverMixin]: return self._task_resolver @property - def container_image(self) -> Optional[str]: + def container_image(self) -> Optional[Union[str, ImageSpec]]: return self._container_image @property @@ -189,6 +181,9 @@ def _get_container(self, settings: SerializationSettings) -> _task_model.Contain for elem in (settings.env, self.environment): if elem: env.update(elem) + if settings.fast_serialization_settings is None or not settings.fast_serialization_settings.enabled: + if isinstance(self.container_image, ImageSpec): + self.container_image.source_root = settings.source_root return _get_container_definition( image=get_registerable_container_image(self.container_image, settings.image_config), command=[], @@ -207,52 +202,11 @@ def _get_container(self, settings: SerializationSettings) -> _task_model.Contain memory_limit=self.resources.limits.mem, ) - def _serialize_pod_spec(self, settings: SerializationSettings) -> Dict[str, Any]: - containers = self.pod_template.pod_spec.containers - primary_exists = False - - for container in containers: - if container.name == self.pod_template.primary_container_name: - primary_exists = True - break - - if not primary_exists: - # insert a placeholder primary container if it is not defined in the pod spec. - containers.append(V1Container(name=self.pod_template.primary_container_name)) - final_containers = [] - for container in containers: - # In the case of the primary container, we overwrite specific container attributes - # with the default values used in the regular Python task. - # The attributes include: image, command, args, resource, and env (env is unioned) - if container.name == self.pod_template.primary_container_name: - sdk_default_container = self._get_container(settings) - container.image = sdk_default_container.image - # clear existing commands - container.command = sdk_default_container.command - # also clear existing args - container.args = sdk_default_container.args - limits, requests = {}, {} - for resource in sdk_default_container.resources.limits: - limits[_sanitize_resource_name(resource)] = resource.value - for resource in sdk_default_container.resources.requests: - requests[_sanitize_resource_name(resource)] = resource.value - resource_requirements = V1ResourceRequirements(limits=limits, requests=requests) - if len(limits) > 0 or len(requests) > 0: - # Important! Only copy over resource requirements if they are non-empty. - container.resources = resource_requirements - container.env = [V1EnvVar(name=key, value=val) for key, val in sdk_default_container.env.items()] + ( - container.env or [] - ) - final_containers.append(container) - self.pod_template.pod_spec.containers = final_containers - - return ApiClient().sanitize_for_serialization(self.pod_template.pod_spec) - def get_k8s_pod(self, settings: SerializationSettings) -> _task_model.K8sPod: if self.pod_template is None: return None return _task_model.K8sPod( - pod_spec=self._serialize_pod_spec(settings), + pod_spec=_serialize_pod_spec(self.pod_template, self._get_container(settings)), metadata=_task_model.K8sObjectMetadata( labels=self.pod_template.labels, annotations=self.pod_template.annotations, @@ -274,7 +228,8 @@ class DefaultTaskResolver(TrackedInstance, TaskResolverMixin): def name(self) -> str: return "DefaultTaskResolver" - def load_task(self, loader_args: List[Union[T, ModuleType]]) -> PythonAutoContainerTask: + @timeit("Load task") + def load_task(self, loader_args: List[str]) -> PythonAutoContainerTask: _, task_module, _, task_name, *_ = loader_args task_module = importlib.import_module(task_module) @@ -298,12 +253,16 @@ def get_all_tasks(self) -> List[PythonAutoContainerTask]: default_task_resolver = DefaultTaskResolver() -def get_registerable_container_image(img: Optional[str], cfg: ImageConfig) -> str: +def get_registerable_container_image(img: Optional[Union[str, ImageSpec]], cfg: ImageConfig) -> str: """ - :param img: Configured image + :param img: Configured image or image spec :param cfg: Registration configuration :return: """ + if isinstance(img, ImageSpec): + ImageBuildEngine.build(img) + return img.image_name() + if img is not None and img != "": matches = _IMAGE_REPLACE_REGEX.findall(img) if matches is None or len(matches) == 0: diff --git a/flytekit/core/task.py b/flytekit/core/task.py index 28c5b5def7..5b08bb6fc8 100644 --- a/flytekit/core/task.py +++ b/flytekit/core/task.py @@ -1,6 +1,6 @@ import datetime as _datetime from functools import update_wrapper -from typing import Any, Callable, Dict, List, Optional, Type, Union +from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union, overload from flytekit.core.base_task import TaskMetadata, TaskResolverMixin from flytekit.core.interface import transform_function_to_interface @@ -8,6 +8,7 @@ from flytekit.core.python_function_task import PythonFunctionTask from flytekit.core.reference_entity import ReferenceEntity, TaskReference from flytekit.core.resources import Resources +from flytekit.image_spec.image_spec import ImageSpec from flytekit.models.documentation import Documentation from flytekit.models.security import Secret @@ -74,9 +75,64 @@ def find_pythontask_plugin(cls, plugin_config_type: type) -> Type[PythonFunction return PythonFunctionTask +T = TypeVar("T") + + +@overload +def task( + _task_function: None = ..., + task_config: Optional[T] = ..., + cache: bool = ..., + cache_serialize: bool = ..., + cache_version: str = ..., + retries: int = ..., + interruptible: Optional[bool] = ..., + deprecated: str = ..., + timeout: Union[_datetime.timedelta, int] = ..., + container_image: Optional[Union[str, ImageSpec]] = ..., + environment: Optional[Dict[str, str]] = ..., + requests: Optional[Resources] = ..., + limits: Optional[Resources] = ..., + secret_requests: Optional[List[Secret]] = ..., + execution_mode: PythonFunctionTask.ExecutionBehavior = ..., + task_resolver: Optional[TaskResolverMixin] = ..., + docs: Optional[Documentation] = ..., + disable_deck: bool = ..., + pod_template: Optional["PodTemplate"] = ..., + pod_template_name: Optional[str] = ..., +) -> Callable[[Callable[..., Any]], PythonFunctionTask[T]]: + ... + + +@overload +def task( + _task_function: Callable[..., Any], + task_config: Optional[T] = ..., + cache: bool = ..., + cache_serialize: bool = ..., + cache_version: str = ..., + retries: int = ..., + interruptible: Optional[bool] = ..., + deprecated: str = ..., + timeout: Union[_datetime.timedelta, int] = ..., + container_image: Optional[Union[str, ImageSpec]] = ..., + environment: Optional[Dict[str, str]] = ..., + requests: Optional[Resources] = ..., + limits: Optional[Resources] = ..., + secret_requests: Optional[List[Secret]] = ..., + execution_mode: PythonFunctionTask.ExecutionBehavior = ..., + task_resolver: Optional[TaskResolverMixin] = ..., + docs: Optional[Documentation] = ..., + disable_deck: bool = ..., + pod_template: Optional["PodTemplate"] = ..., + pod_template_name: Optional[str] = ..., +) -> PythonFunctionTask[T]: + ... + + def task( - _task_function: Optional[Callable] = None, - task_config: Optional[Any] = None, + _task_function: Optional[Callable[..., Any]] = None, + task_config: Optional[T] = None, cache: bool = False, cache_serialize: bool = False, cache_version: str = "", @@ -84,7 +140,7 @@ def task( interruptible: Optional[bool] = None, deprecated: str = "", timeout: Union[_datetime.timedelta, int] = 0, - container_image: Optional[str] = None, + container_image: Optional[Union[str, ImageSpec]] = None, environment: Optional[Dict[str, str]] = None, requests: Optional[Resources] = None, limits: Optional[Resources] = None, @@ -93,9 +149,9 @@ def task( task_resolver: Optional[TaskResolverMixin] = None, docs: Optional[Documentation] = None, disable_deck: bool = True, - pod_template: Optional[PodTemplate] = None, + pod_template: Optional["PodTemplate"] = None, pod_template_name: Optional[str] = None, -) -> Union[Callable, PythonFunctionTask]: +) -> Union[Callable[[Callable[..., Any]], PythonFunctionTask[T]], PythonFunctionTask[T]]: """ This is the core decorator to use for any task type in flytekit. @@ -189,7 +245,7 @@ def foo2(): :param pod_template_name: The name of the existing PodTemplate resource which will be used in this task. """ - def wrapper(fn) -> PythonFunctionTask: + def wrapper(fn: Callable[..., Any]) -> PythonFunctionTask[T]: _metadata = TaskMetadata( cache=cache, cache_serialize=cache_serialize, diff --git a/flytekit/core/tracker.py b/flytekit/core/tracker.py index 2a203d4861..93fbc99a4f 100644 --- a/flytekit/core/tracker.py +++ b/flytekit/core/tracker.py @@ -91,7 +91,6 @@ def find_lhs(self) -> str: # Since dataframes aren't registrable entities to begin with we swallow any errors they raise and # continue looping through m. logger.warning("Caught ValueError {} while attempting to auto-assign name".format(err)) - pass logger.error(f"Could not find LHS for {self} in {self._instantiated_in}") raise _system_exceptions.FlyteSystemException(f"Error looking for LHS in {self._instantiated_in}") diff --git a/flytekit/core/type_engine.py b/flytekit/core/type_engine.py index 7bfc85d1ef..da1f614ba1 100644 --- a/flytekit/core/type_engine.py +++ b/flytekit/core/type_engine.py @@ -22,14 +22,15 @@ from google.protobuf.json_format import ParseDict as _ParseDict from google.protobuf.struct_pb2 import Struct from marshmallow_enum import EnumField, LoadDumpOptions -from marshmallow_jsonschema import JSONSchema from typing_extensions import Annotated, get_args, get_origin from flytekit.core.annotation import FlyteAnnotation from flytekit.core.context_manager import FlyteContext from flytekit.core.hash import HashMethod from flytekit.core.type_helpers import load_type_from_tag +from flytekit.core.utils import timeit from flytekit.exceptions import user as user_exceptions +from flytekit.lazy_import.lazy_module import is_imported from flytekit.loggers import logger from flytekit.models import interface as _interface_models from flytekit.models import types as _type_models @@ -88,7 +89,7 @@ def type_assertions_enabled(self) -> bool: def assert_type(self, t: Type[T], v: T): if not hasattr(t, "__origin__") and not isinstance(v, t): - raise TypeTransformerFailedError(f"Type of Val '{v}' is not an instance of {t}") + raise TypeTransformerFailedError(f"Expected value of type {t} but got '{v}' of type {type(v)}") @abstractmethod def get_literal_type(self, t: Type[T]) -> LiteralType: @@ -129,7 +130,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 @@ -167,7 +167,9 @@ def get_literal_type(self, t: Type[T] = None) -> LiteralType: def to_literal(self, ctx: FlyteContext, python_val: T, python_type: Type[T], expected: LiteralType) -> Literal: if type(python_val) != self._type: - raise TypeTransformerFailedError(f"Expected value of type {self._type} but got type {type(python_val)}") + raise TypeTransformerFailedError( + f"Expected value of type {self._type} but got '{python_val}' of type {type(python_val)}" + ) return self._to_literal_transformer(python_val) def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T]) -> T: @@ -186,7 +188,7 @@ def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: return res except AttributeError: # Assume that this is because a property on `lv` was None - raise TypeTransformerFailedError(f"Cannot convert literal {lv}") + raise TypeTransformerFailedError(f"Cannot convert literal {lv} to {self._type}") def guess_python_type(self, literal_type: LiteralType) -> Type[T]: if literal_type.simple is not None and literal_type.simple == self._lt.simple: @@ -327,6 +329,8 @@ def get_literal_type(self, t: Type[T]) -> LiteralType: # https://github.com/fuhrysteve/marshmallow-jsonschema/blob/81eada1a0c42ff67de216923968af0a6b54e5dcb/marshmallow_jsonschema/base.py#L228 if isinstance(v, EnumField): v.load_by = LoadDumpOptions.name + from marshmallow_jsonschema import JSONSchema + schema = JSONSchema().dump(s) except Exception as e: # https://github.com/lovasoa/marshmallow_dataclass/issues/13 @@ -374,7 +378,7 @@ def _get_origin_type_in_annotation(self, python_type: Type[T]) -> Type[T]: def _fix_structured_dataset_type(self, python_type: Type[T], python_val: typing.Any) -> T: # In python 3.7, 3.8, DataclassJson will deserialize Annotated[StructuredDataset, kwtypes(..)] to a dict, # so here we convert it back to the Structured Dataset. - from flytekit import StructuredDataset + from flytekit.types.structured import StructuredDataset if python_type == StructuredDataset and type(python_val) == dict: return StructuredDataset(**python_val) @@ -657,7 +661,8 @@ class TypeEngine(typing.Generic[T]): _REGISTRY: typing.Dict[type, TypeTransformer[T]] = {} _RESTRICTED_TYPES: typing.List[type] = [] - _DATACLASS_TRANSFORMER: TypeTransformer = DataclassTransformer() + _DATACLASS_TRANSFORMER: TypeTransformer = DataclassTransformer() # type: ignore + has_lazy_import = False @classmethod def register( @@ -701,24 +706,32 @@ def get_transformer(cls, python_type: Type) -> TypeTransformer[T]: d = dictionary of registered transformers, where is a python `type` v = lookup type Step 1: - find a transformer that matches v exactly + If the type is annotated with a TypeTransformer instance, use that. Step 2: - find a transformer that matches the generic type of v. e.g List[int], Dict[str, int] etc + find a transformer that matches v exactly Step 3: + find a transformer that matches the generic type of v. e.g List[int], Dict[str, int] etc + + Step 4: Walk the inheritance hierarchy of v and find a transformer that matches the first base class. This is potentially non-deterministic - will depend on the registration pattern. TODO lets make this deterministic by using an ordered dict - Step 4: + Step 5: if v is of type data class, use the dataclass transformer """ - + cls.lazy_import_transformers() # Step 1 if get_origin(python_type) is Annotated: - python_type = get_args(python_type)[0] + args = get_args(python_type) + for annotation in args: + if isinstance(annotation, TypeTransformer): + return annotation + + python_type = args[0] if python_type in cls._REGISTRY: return cls._REGISTRY[python_type] @@ -757,6 +770,39 @@ def get_transformer(cls, python_type: Type) -> TypeTransformer[T]: raise ValueError(f"Type {python_type} not supported currently in Flytekit. Please register a new transformer") + @classmethod + def lazy_import_transformers(cls): + """ + Only load the transformers if needed. + """ + if cls.has_lazy_import: + return + cls.has_lazy_import = True + from flytekit.types.structured import ( + register_arrow_handlers, + register_bigquery_handlers, + register_pandas_handlers, + ) + + if is_imported("tensorflow"): + from flytekit.extras import tensorflow # noqa: F401 + if is_imported("torch"): + from flytekit.extras import pytorch # noqa: F401 + if is_imported("sklearn"): + from flytekit.extras import sklearn # noqa: F401 + if is_imported("pandas"): + try: + from flytekit.types import schema # noqa: F401 + except ValueError: + logger.debug("Transformer for pandas is already registered.") + register_pandas_handlers() + if is_imported("pyarrow"): + register_arrow_handlers() + if is_imported("google.cloud.bigquery"): + register_bigquery_handlers() + if is_imported("numpy"): + from flytekit.types import numpy # noqa: F401 + @classmethod def to_literal_type(cls, python_type: Type) -> LiteralType: """ @@ -820,7 +866,7 @@ def to_python_value(cls, ctx: FlyteContext, lv: Literal, expected_python_type: T return transformer.to_python_value(ctx, lv, expected_python_type) @classmethod - def to_html(cls, ctx: FlyteContext, python_val: typing.Any, expected_python_type: Type[T]) -> str: + def to_html(cls, ctx: FlyteContext, python_val: typing.Any, expected_python_type: Type[typing.Any]) -> str: transformer = cls.get_transformer(expected_python_type) if get_origin(expected_python_type) is Annotated: expected_python_type, *annotate_args = get_args(expected_python_type) @@ -843,6 +889,7 @@ def named_tuple_to_variable_map(cls, t: typing.NamedTuple) -> _interface_models. return _interface_models.VariableMap(variables=variables) @classmethod + @timeit("Translate literal to python value") def literal_map_to_kwargs( cls, ctx: FlyteContext, lm: LiteralMap, python_types: typing.Dict[str, type] ) -> typing.Dict[str, typing.Any]: @@ -853,7 +900,13 @@ def literal_map_to_kwargs( raise ValueError( f"Received more input values {len(lm.literals)}" f" than allowed by the input spec {len(python_types)}" ) - return {k: TypeEngine.to_python_value(ctx, lm.literals[k], python_types[k]) for k, v in lm.literals.items()} + kwargs = {} + for i, k in enumerate(lm.literals): + try: + kwargs[k] = TypeEngine.to_python_value(ctx, lm.literals[k], python_types[k]) + except TypeTransformerFailedError as exc: + raise TypeTransformerFailedError(f"Error converting input '{k}' at position {i}:\n {exc}") from exc + return kwargs @classmethod def dict_to_literal_map( @@ -957,12 +1010,43 @@ def get_literal_type(self, t: Type[T]) -> Optional[LiteralType]: except Exception as e: raise ValueError(f"Type of Generic List type is not supported, {e}") + @staticmethod + def is_batchable(t: Type): + """ + This function evaluates whether the provided type is batchable or not. + It returns True only if the type is either List or Annotated(List) and the List subtype is FlytePickle. + """ + from flytekit.types.pickle import FlytePickle + + if get_origin(t) is Annotated: + return ListTransformer.is_batchable(get_args(t)[0]) + if get_origin(t) is list: + subtype = get_args(t)[0] + if subtype == FlytePickle or (hasattr(subtype, "__origin__") and subtype.__origin__ == FlytePickle): + return True + return False + def to_literal(self, ctx: FlyteContext, python_val: T, python_type: Type[T], expected: LiteralType) -> Literal: if type(python_val) != list: raise TypeTransformerFailedError("Expected a list") - t = self.get_sub_type(python_type) - lit_list = [TypeEngine.to_literal(ctx, x, t, expected.collection_type) for x in python_val] # type: ignore + if ListTransformer.is_batchable(python_type): + from flytekit.types.pickle.pickle import BatchSize, FlytePickle + + batch_size = len(python_val) # default batch size + # parse annotated to get the number of items saved in a pickle file. + if get_origin(python_type) is Annotated: + for annotation in get_args(python_type)[1:]: + if isinstance(annotation, BatchSize): + batch_size = annotation.val + break + if batch_size > 0: + lit_list = [TypeEngine.to_literal(ctx, python_val[i : i + batch_size], FlytePickle, expected.collection_type) for i in range(0, len(python_val), batch_size)] # type: ignore + else: + lit_list = [] + else: + t = self.get_sub_type(python_type) + lit_list = [TypeEngine.to_literal(ctx, x, t, expected.collection_type) for x in python_val] # type: ignore return Literal(collection=LiteralCollection(literals=lit_list)) def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T]) -> typing.List[T]: @@ -970,9 +1054,18 @@ def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: lits = lv.collection.literals except AttributeError: raise TypeTransformerFailedError() - - st = self.get_sub_type(expected_python_type) - return [TypeEngine.to_python_value(ctx, x, st) for x in lits] + if self.is_batchable(expected_python_type): + from flytekit.types.pickle import FlytePickle + + batch_list = [TypeEngine.to_python_value(ctx, batch, FlytePickle) for batch in lits] + if len(batch_list) > 0 and type(batch_list[0]) is list: + # Make it have backward compatibility. The upstream task may use old version of Flytekit that + # won't merge the elements in the list. Therefore, we should check if the batch_list[0] is the list first. + return [item for batch in batch_list for item in batch] + return batch_list + else: + st = self.get_sub_type(expected_python_type) + return [TypeEngine.to_python_value(ctx, x, st) for x in lits] def guess_python_type(self, literal_type: LiteralType) -> Type[list]: if literal_type.collection_type: @@ -1033,7 +1126,7 @@ def _are_types_castable(upstream: LiteralType, downstream: LiteralType) -> bool: if len(ucols) != len(dcols): return False - for (u, d) in zip(ucols, dcols): + for u, d in zip(ucols, dcols): if u.name != d.name: return False diff --git a/flytekit/core/utils.py b/flytekit/core/utils.py index d23aae3fbb..437d2b71a4 100644 --- a/flytekit/core/utils.py +++ b/flytekit/core/utils.py @@ -1,13 +1,20 @@ +import datetime import os as _os import shutil as _shutil import tempfile as _tempfile import time as _time +from functools import wraps from hashlib import sha224 as _sha224 from pathlib import Path -from typing import Dict, List, Optional +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, cast +from flyteidl.core import tasks_pb2 as _core_task + +from flytekit.core.pod_template import PodTemplate from flytekit.loggers import logger -from flytekit.models import task as _task_models + +if TYPE_CHECKING: + from flytekit.models import task as task_models def _dnsify(value: str) -> str: @@ -51,8 +58,8 @@ def _dnsify(value: str) -> str: def _get_container_definition( image: str, command: List[str], - args: List[str], - data_loading_config: Optional[_task_models.DataLoadingConfig] = None, + args: Optional[List[str]] = None, + data_loading_config: Optional["task_models.DataLoadingConfig"] = None, storage_request: Optional[str] = None, ephemeral_storage_request: Optional[str] = None, cpu_request: Optional[str] = None, @@ -64,7 +71,7 @@ def _get_container_definition( gpu_limit: Optional[str] = None, memory_limit: Optional[str] = None, environment: Optional[Dict[str, str]] = None, -) -> _task_models.Container: +) -> "task_models.Container": storage_limit = storage_limit storage_request = storage_request ephemeral_storage_limit = ephemeral_storage_limit @@ -76,56 +83,107 @@ def _get_container_definition( memory_limit = memory_limit memory_request = memory_request + from flytekit.models import task as task_models + + # TODO: Use convert_resources_to_resource_model instead of manually fixing the resources. requests = [] if storage_request: requests.append( - _task_models.Resources.ResourceEntry(_task_models.Resources.ResourceName.STORAGE, storage_request) + task_models.Resources.ResourceEntry(task_models.Resources.ResourceName.STORAGE, storage_request) ) if ephemeral_storage_request: requests.append( - _task_models.Resources.ResourceEntry( - _task_models.Resources.ResourceName.EPHEMERAL_STORAGE, ephemeral_storage_request + task_models.Resources.ResourceEntry( + task_models.Resources.ResourceName.EPHEMERAL_STORAGE, ephemeral_storage_request ) ) if cpu_request: - requests.append(_task_models.Resources.ResourceEntry(_task_models.Resources.ResourceName.CPU, cpu_request)) + requests.append(task_models.Resources.ResourceEntry(task_models.Resources.ResourceName.CPU, cpu_request)) if gpu_request: - requests.append(_task_models.Resources.ResourceEntry(_task_models.Resources.ResourceName.GPU, gpu_request)) + requests.append(task_models.Resources.ResourceEntry(task_models.Resources.ResourceName.GPU, gpu_request)) if memory_request: - requests.append( - _task_models.Resources.ResourceEntry(_task_models.Resources.ResourceName.MEMORY, memory_request) - ) + requests.append(task_models.Resources.ResourceEntry(task_models.Resources.ResourceName.MEMORY, memory_request)) limits = [] if storage_limit: - limits.append(_task_models.Resources.ResourceEntry(_task_models.Resources.ResourceName.STORAGE, storage_limit)) + limits.append(task_models.Resources.ResourceEntry(task_models.Resources.ResourceName.STORAGE, storage_limit)) if ephemeral_storage_limit: limits.append( - _task_models.Resources.ResourceEntry( - _task_models.Resources.ResourceName.EPHEMERAL_STORAGE, ephemeral_storage_limit + task_models.Resources.ResourceEntry( + task_models.Resources.ResourceName.EPHEMERAL_STORAGE, ephemeral_storage_limit ) ) if cpu_limit: - limits.append(_task_models.Resources.ResourceEntry(_task_models.Resources.ResourceName.CPU, cpu_limit)) + limits.append(task_models.Resources.ResourceEntry(task_models.Resources.ResourceName.CPU, cpu_limit)) if gpu_limit: - limits.append(_task_models.Resources.ResourceEntry(_task_models.Resources.ResourceName.GPU, gpu_limit)) + limits.append(task_models.Resources.ResourceEntry(task_models.Resources.ResourceName.GPU, gpu_limit)) if memory_limit: - limits.append(_task_models.Resources.ResourceEntry(_task_models.Resources.ResourceName.MEMORY, memory_limit)) + limits.append(task_models.Resources.ResourceEntry(task_models.Resources.ResourceName.MEMORY, memory_limit)) if environment is None: environment = {} - return _task_models.Container( + return task_models.Container( image=image, command=command, args=args, - resources=_task_models.Resources(limits=limits, requests=requests), + resources=task_models.Resources(limits=limits, requests=requests), env=environment, config={}, data_loading_config=data_loading_config, ) +def _sanitize_resource_name(resource: "task_models.Resources.ResourceEntry") -> str: + return _core_task.Resources.ResourceName.Name(resource.name).lower().replace("_", "-") + + +def _serialize_pod_spec(pod_template: "PodTemplate", primary_container: "task_models.Container") -> Dict[str, Any]: + from kubernetes.client import ApiClient, V1PodSpec + from kubernetes.client.models import V1Container, V1EnvVar, V1ResourceRequirements + + if pod_template.pod_spec is None: + return {} + containers = cast(V1PodSpec, pod_template.pod_spec).containers + primary_exists = False + + for container in containers: + if container.name == cast(PodTemplate, pod_template).primary_container_name: + primary_exists = True + break + + if not primary_exists: + # insert a placeholder primary container if it is not defined in the pod spec. + containers.append(V1Container(name=cast(PodTemplate, pod_template).primary_container_name)) + final_containers = [] + for container in containers: + # In the case of the primary container, we overwrite specific container attributes + # with the values given to ContainerTask. + # The attributes include: image, command, args, resource, and env (env is unioned) + if container.name == cast(PodTemplate, pod_template).primary_container_name: + container.image = primary_container.image + container.command = primary_container.command + container.args = primary_container.args + + limits, requests = {}, {} + for resource in primary_container.resources.limits: + limits[_sanitize_resource_name(resource)] = resource.value + for resource in primary_container.resources.requests: + requests[_sanitize_resource_name(resource)] = resource.value + resource_requirements = V1ResourceRequirements(limits=limits, requests=requests) + if len(limits) > 0 or len(requests) > 0: + # Important! Only copy over resource requirements if they are non-empty. + container.resources = resource_requirements + if primary_container.env is not None: + container.env = [V1EnvVar(name=key, value=val) for key, val in primary_container.env.items()] + ( + container.env or [] + ) + final_containers.append(container) + cast(V1PodSpec, pod_template.pod_spec).containers = final_containers + + return ApiClient().sanitize_for_serialization(cast(PodTemplate, pod_template).pod_spec) + + def load_proto_from_file(pb2_type, path): with open(path, "rb") as reader: out = pb2_type() @@ -209,26 +267,66 @@ def __str__(self): return self.__repr__() -class PerformanceTimer(object): - def __init__(self, context_statement): +class timeit: + """ + A context manager and a decorator that measures the execution time of the wrapped code block or functions. + It will append a timing information to TimeLineDeck. For instance: + + @timeit("Function description") + def function() + + with timeit("Wrapped code block description"): + # your code + """ + + def __init__(self, name: str = ""): """ - :param Text context_statement: the statement to log + :param name: A string that describes the wrapped code block or function being executed. """ - self._context_statement = context_statement + self._name = name + self.start_time = None self._start_wall_time = None self._start_process_time = None + def __call__(self, func: Callable): + @wraps(func) + def wrapper(*args, **kwargs): + with self: + return func(*args, **kwargs) + + return wrapper + def __enter__(self): - logger.info("Entering timed context: {}".format(self._context_statement)) + self.start_time = datetime.datetime.utcnow() self._start_wall_time = _time.perf_counter() self._start_process_time = _time.process_time() + return self def __exit__(self, exc_type, exc_val, exc_tb): + """ + The exception, if any, will propagate outside the context manager, as the purpose of this context manager + is solely to measure the execution time of the wrapped code block. + """ + from flytekit.core.context_manager import FlyteContextManager + + end_time = datetime.datetime.utcnow() end_wall_time = _time.perf_counter() end_process_time = _time.process_time() + + timeline_deck = FlyteContextManager.current_context().user_space_params.timeline_deck + timeline_deck.append_time_info( + dict( + Name=self._name, + Start=self.start_time, + Finish=end_time, + WallTime=end_wall_time - self._start_wall_time, + ProcessTime=end_process_time - self._start_process_time, + ) + ) + logger.info( - "Exiting timed context: {} [Wall Time: {}s, Process Time: {}s]".format( - self._context_statement, + "{}. [Wall Time: {}s, Process Time: {}s]".format( + self._name, end_wall_time - self._start_wall_time, end_process_time - self._start_process_time, ) diff --git a/flytekit/core/workflow.py b/flytekit/core/workflow.py index 8ba307b767..93b14f9528 100644 --- a/flytekit/core/workflow.py +++ b/flytekit/core/workflow.py @@ -1,9 +1,12 @@ from __future__ import annotations +import typing from dataclasses import dataclass from enum import Enum from functools import update_wrapper -from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union, overload + +from typing_extensions import get_args from flytekit.core import constants as _common_constants from flytekit.core.base_task import PythonTask @@ -32,14 +35,16 @@ from flytekit.core.python_auto_container import PythonAutoContainerTask from flytekit.core.reference_entity import ReferenceEntity, WorkflowReference from flytekit.core.tracker import extract_task_module -from flytekit.core.type_engine import TypeEngine +from flytekit.core.type_engine import TypeEngine, TypeTransformerFailedError, UnionTransformer from flytekit.exceptions import scopes as exception_scopes from flytekit.exceptions.user import FlyteValidationException, FlyteValueException from flytekit.loggers import logger from flytekit.models import interface as _interface_models from flytekit.models import literals as _literal_models +from flytekit.models import types as type_models from flytekit.models.core import workflow as _workflow_model from flytekit.models.documentation import Description, Documentation +from flytekit.models.types import TypeStructure GLOBAL_START_NODE = Node( id=_common_constants.GLOBAL_INPUT_NODE_ID, @@ -49,6 +54,8 @@ flyte_entity=None, ) +T = typing.TypeVar("T") + class WorkflowFailurePolicy(Enum): """ @@ -258,7 +265,11 @@ def __call__(self, *args, **kwargs): input_kwargs = self.python_interface.default_inputs_as_kwargs input_kwargs.update(kwargs) self.compile() - return flyte_entity_call_handler(self, *args, **input_kwargs) + try: + return flyte_entity_call_handler(self, *args, **input_kwargs) + except Exception as exc: + exc.args = (f"Encountered error while executing workflow '{self.name}':\n {exc}", *exc.args[1:]) + raise exc def execute(self, **kwargs): raise Exception("Should not be called") @@ -266,19 +277,63 @@ def execute(self, **kwargs): def compile(self, **kwargs): pass + def ensure_literal( + self, ctx, py_type: Type[T], input_type: type_models.LiteralType, python_value: Any + ) -> _literal_models.Literal: + """ + This function will attempt to convert a python value to a literal. If the python value is a promise, it will + return the promise's value. + """ + if input_type.union_type is not None: + if python_value is None and UnionTransformer.is_optional_type(py_type): + return _literal_models.Literal(scalar=_literal_models.Scalar(none_type=_literal_models.Void())) + for i in range(len(input_type.union_type.variants)): + lt_type = input_type.union_type.variants[i] + python_type = get_args(py_type)[i] + try: + final_lt = self.ensure_literal(ctx, python_type, lt_type, python_value) + lt_type._structure = TypeStructure(tag=TypeEngine.get_transformer(python_type).name) + return _literal_models.Literal( + scalar=_literal_models.Scalar(union=_literal_models.Union(value=final_lt, stored_type=lt_type)) + ) + except Exception as e: + logger.debug(f"Failed to convert {python_value} to {lt_type} with error {e}") + raise TypeError(f"Failed to convert {python_value} to {input_type}") + if isinstance(python_value, list) and input_type.collection_type: + collection_lit_type = input_type.collection_type + collection_py_type = get_args(py_type)[0] + xx = [self.ensure_literal(ctx, collection_py_type, collection_lit_type, pv) for pv in python_value] + return _literal_models.Literal(collection=_literal_models.LiteralCollection(literals=xx)) + elif isinstance(python_value, dict) and input_type.map_value_type: + mapped_lit_type = input_type.map_value_type + mapped_py_type = get_args(py_type)[1] + xx = {k: self.ensure_literal(ctx, mapped_py_type, mapped_lit_type, v) for k, v in python_value.items()} # type: ignore + return _literal_models.Literal(map=_literal_models.LiteralMap(literals=xx)) + # It is a scalar, convert to Promise if necessary. + else: + if isinstance(python_value, Promise): + return python_value.val + if not isinstance(python_value, Promise): + try: + res = TypeEngine.to_literal(ctx, python_value, py_type, input_type) + return res + except TypeTransformerFailedError as exc: + raise TypeError( + f"Failed to convert input '{python_value}' of workflow '{self.name}':\n {exc}" + ) from exc + def local_execute(self, ctx: FlyteContext, **kwargs) -> Union[Tuple[Promise], Promise, VoidPromise, None]: # This is done to support the invariant that Workflow local executions always work with Promise objects # holding Flyte literal values. Even in a wf, a user can call a sub-workflow with a Python native value. for k, v in kwargs.items(): - if not isinstance(v, Promise): - t = self.python_interface.inputs[k] - kwargs[k] = Promise(var=k, val=TypeEngine.to_literal(ctx, v, t, self.interface.inputs[k].type)) + py_type = self.python_interface.inputs[k] + lit_type = self.interface.inputs[k].type + kwargs[k] = Promise(var=k, val=self.ensure_literal(ctx, py_type, lit_type, v)) - # The output of this will always be a combination of Python native values and Promises containing Flyte - # Literals. + # The output of this will always be a combination of Python native values and Promises containing Flyte + # Literals. self.compile() function_outputs = self.execute(**kwargs) - # First handle the empty return case. # A workflow function may return a task that doesn't return anything # def wf(): @@ -595,9 +650,9 @@ class PythonFunctionWorkflow(WorkflowBase, ClassStorageTaskResolver): def __init__( self, - workflow_function: Callable, - metadata: Optional[WorkflowMetadata], - default_metadata: Optional[WorkflowMetadataDefaults], + workflow_function: Callable[..., Any], + metadata: WorkflowMetadata, + default_metadata: WorkflowMetadataDefaults, docstring: Optional[Docstring] = None, docs: Optional[Documentation] = None, ): @@ -719,12 +774,32 @@ def execute(self, **kwargs): return exception_scopes.user_entry_point(self._workflow_function)(**kwargs) +@overload +def workflow( + _workflow_function: None = ..., + failure_policy: Optional[WorkflowFailurePolicy] = ..., + interruptible: bool = ..., + docs: Optional[Documentation] = ..., +) -> Callable[[Callable[..., Any]], PythonFunctionWorkflow]: + ... + + +@overload +def workflow( + _workflow_function: Callable[..., Any], + failure_policy: Optional[WorkflowFailurePolicy] = ..., + interruptible: bool = ..., + docs: Optional[Documentation] = ..., +) -> PythonFunctionWorkflow: + ... + + def workflow( - _workflow_function=None, + _workflow_function: Optional[Callable[..., Any]] = None, failure_policy: Optional[WorkflowFailurePolicy] = None, interruptible: bool = False, docs: Optional[Documentation] = None, -) -> WorkflowBase: +) -> Union[Callable[[Callable[..., Any]], PythonFunctionWorkflow], PythonFunctionWorkflow]: """ This decorator declares a function to be a Flyte workflow. Workflows are declarative entities that construct a DAG of tasks using the data flow between tasks. @@ -755,7 +830,7 @@ def workflow( :param docs: Description entity for the workflow """ - def wrapper(fn): + def wrapper(fn: Callable[..., Any]) -> PythonFunctionWorkflow: workflow_metadata = WorkflowMetadata(on_failure=failure_policy or WorkflowFailurePolicy.FAIL_IMMEDIATELY) workflow_metadata_defaults = WorkflowMetadataDefaults(interruptible) @@ -770,7 +845,7 @@ def wrapper(fn): update_wrapper(workflow_instance, fn) return workflow_instance - if _workflow_function: + if _workflow_function is not None: return wrapper(_workflow_function) else: return wrapper diff --git a/flytekit/deck/deck.py b/flytekit/deck/deck.py index cec59e7318..0d53ec18d6 100644 --- a/flytekit/deck/deck.py +++ b/flytekit/deck/deck.py @@ -2,8 +2,6 @@ import typing from typing import Optional -from jinja2 import Environment, FileSystemLoader, select_autoescape - from flytekit.core.context_manager import ExecutionParameters, ExecutionState, FlyteContext, FlyteContextManager from flytekit.loggers import logger @@ -74,6 +72,58 @@ def html(self) -> str: return self._html +class TimeLineDeck(Deck): + """ + The TimeLineDeck class is designed to render the execution time of each part of a task. + Unlike deck class, the conversion of data to HTML is delayed until the html property is accessed. + This approach is taken because rendering a timeline graph with partial data would not provide meaningful insights. + Instead, the complete data set is used to create a comprehensive visualization of the execution time of each part of the task. + """ + + def __init__(self, name: str, html: Optional[str] = ""): + super().__init__(name, html) + self.time_info = [] + + def append_time_info(self, info: dict): + assert isinstance(info, dict) + self.time_info.append(info) + + @property + def html(self) -> str: + try: + from flytekitplugins.deck.renderer import GanttChartRenderer, TableRenderer + except ImportError: + warning_info = "Plugin 'flytekit-deck-standard' is not installed. To display time line, install the plugin in the image." + logger.warning(warning_info) + return warning_info + + if len(self.time_info) == 0: + return "" + + import pandas + + df = pandas.DataFrame(self.time_info) + note = """ +

Note:

+
    +
  1. if the time duration is too small(< 1ms), it may be difficult to see on the time line graph.
  2. +
  3. For accurate execution time measurements, users should refer to wall time and process time.
  4. +
+ """ + # set the accuracy to microsecond + df["ProcessTime"] = df["ProcessTime"].apply(lambda time: "{:.6f}".format(time)) + df["WallTime"] = df["WallTime"].apply(lambda time: "{:.6f}".format(time)) + + width = 1400 + gantt_chart_html = GanttChartRenderer().to_html(df, chart_width=width) + time_table_html = TableRenderer().to_html( + df[["Name", "WallTime", "ProcessTime"]], + header_labels=["Name", "Wall Time(s)", "Process Time(s)"], + table_width=width, + ) + return gantt_chart_html + time_table_html + note + + def _ipython_check() -> bool: """ Check if interface is launching from iPython (not colab) @@ -98,10 +148,12 @@ def _get_deck( If ignore_jupyter is set to True, then it will return a str even in a jupyter environment. """ deck_map = {deck.name: deck.html for deck in new_user_params.decks} - raw_html = template.render(metadata=deck_map) + raw_html = get_deck_template().render(metadata=deck_map) if not ignore_jupyter and _ipython_check(): - from IPython.core.display import HTML - + try: + from IPython.core.display import HTML + except ImportError: + ... return HTML(raw_html) return raw_html @@ -118,15 +170,18 @@ def _output_deck(task_name: str, new_user_params: ExecutionParameters): logger.info(f"{task_name} task creates flyte deck html to file://{deck_path}") -root = os.path.dirname(os.path.abspath(__file__)) -templates_dir = os.path.join(root, "html") -env = Environment( - loader=FileSystemLoader(templates_dir), - # 🔥 include autoescaping for security purposes - # sources: - # - https://jinja.palletsprojects.com/en/3.0.x/api/#autoescaping - # - https://stackoverflow.com/a/38642558/8474894 (see in comments) - # - https://stackoverflow.com/a/68826578/8474894 - autoescape=select_autoescape(enabled_extensions=("html",)), -) -template = env.get_template("template.html") +def get_deck_template() -> "Template": + from jinja2 import Environment, FileSystemLoader, select_autoescape + + root = os.path.dirname(os.path.abspath(__file__)) + templates_dir = os.path.join(root, "html") + env = Environment( + loader=FileSystemLoader(templates_dir), + # 🔥 include autoescaping for security purposes + # sources: + # - https://jinja.palletsprojects.com/en/3.0.x/api/#autoescaping + # - https://stackoverflow.com/a/38642558/8474894 (see in comments) + # - https://stackoverflow.com/a/68826578/8474894 + autoescape=select_autoescape(enabled_extensions=("html",)), + ) + return env.get_template("template.html") diff --git a/flytekit/deck/html/template.html b/flytekit/deck/html/template.html index 6bec37effe..19e0256880 100644 --- a/flytekit/deck/html/template.html +++ b/flytekit/deck/html/template.html @@ -53,17 +53,19 @@ } #flyte-frame-container { - width: 100%; + width: auto; } #flyte-frame-container > div { - display: none; + display: None; } #flyte-frame-container > div.active { - display: block; + display: Block; padding: 2rem 4rem; + width: 100%; } + diff --git a/flytekit/deck/renderer.py b/flytekit/deck/renderer.py index dddb88e420..cfea92ec4e 100644 --- a/flytekit/deck/renderer.py +++ b/flytekit/deck/renderer.py @@ -1,14 +1,22 @@ -from typing import Any +from typing import TYPE_CHECKING, Any -import pandas -import pyarrow from typing_extensions import Protocol, runtime_checkable +from flytekit import lazy_module + +if TYPE_CHECKING: + # Always import these modules in type-checking mode or when running pytest + import pandas + import pyarrow +else: + pandas = lazy_module("pandas") + pyarrow = lazy_module("pyarrow") + @runtime_checkable class Renderable(Protocol): def to_html(self, python_value: Any) -> str: - """Convert a object(markdown, pandas.dataframe) to HTML and return HTML as a unicode string. + """Convert an object(markdown, pandas.dataframe) to HTML and return HTML as a unicode string. Returns: An HTML document as a string. """ raise NotImplementedError @@ -27,16 +35,16 @@ def __init__(self, max_rows: int = DEFAULT_MAX_ROWS, max_cols: int = DEFAULT_MAX self._max_rows = max_rows self._max_cols = max_cols - def to_html(self, df: pandas.DataFrame) -> str: + def to_html(self, df: "pandas.DataFrame") -> str: assert isinstance(df, pandas.DataFrame) return df.to_html(max_rows=self._max_rows, max_cols=self._max_cols) class ArrowRenderer: """ - Render a Arrow dataframe as an HTML table. + Render an Arrow dataframe as an HTML table. """ - def to_html(self, df: pyarrow.Table) -> str: + def to_html(self, df: "pyarrow.Table") -> str: assert isinstance(df, pyarrow.Table) return df.to_string() diff --git a/flytekit/exceptions/scopes.py b/flytekit/exceptions/scopes.py index 60a4afa97e..bdfb2ba182 100644 --- a/flytekit/exceptions/scopes.py +++ b/flytekit/exceptions/scopes.py @@ -194,10 +194,13 @@ def user_entry_point(wrapped, instance, args, kwargs): _CONTEXT_STACK.append(_USER_CONTEXT) if _is_base_context(): # See comment at this location for system_entry_point + fn_name = wrapped.__name__ try: return wrapped(*args, **kwargs) - except FlyteScopedException as ex: - raise ex.value + except FlyteScopedException as exc: + raise exc.type(f"Error encountered while executing '{fn_name}':\n {exc.value}") from exc + except Exception as exc: + raise type(exc)(f"Error encountered while executing '{fn_name}':\n {exc}") from exc else: try: return wrapped(*args, **kwargs) 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/extend/backend/__init__.py b/flytekit/extend/backend/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/extend/backend/base_plugin.py b/flytekit/extend/backend/base_plugin.py new file mode 100644 index 0000000000..9fc1bc206b --- /dev/null +++ b/flytekit/extend/backend/base_plugin.py @@ -0,0 +1,107 @@ +import typing +from abc import ABC, abstractmethod + +import grpc +from flyteidl.core.tasks_pb2 import TaskTemplate +from flyteidl.service.external_plugin_service_pb2 import ( + RETRYABLE_FAILURE, + RUNNING, + SUCCEEDED, + State, + TaskCreateResponse, + TaskDeleteResponse, + TaskGetResponse, +) + +from flytekit import logger +from flytekit.models.literals import LiteralMap + + +class BackendPluginBase(ABC): + """ + This is the base class for all backend plugins. It defines the interface that all plugins must implement. + The external plugins service will be run either locally or in a pod, and will be responsible for + invoking backend plugins. The propeller will communicate with the external plugins service + to create tasks, get the status of tasks, and delete tasks. + + All the backend plugins should be registered in the BackendPluginRegistry. External plugins service + will look up the plugin based on the task type. Every task type can only have one plugin. + """ + + def __init__(self, task_type: str): + self._task_type = task_type + + @property + def task_type(self) -> str: + """ + task_type is the name of the task type that this plugin supports. + """ + return self._task_type + + @abstractmethod + def create( + self, + context: grpc.ServicerContext, + output_prefix: str, + task_template: TaskTemplate, + inputs: typing.Optional[LiteralMap] = None, + ) -> TaskCreateResponse: + """ + Return a Unique ID for the task that was created. It should return error code if the task creation failed. + """ + pass + + @abstractmethod + def get(self, context: grpc.ServicerContext, job_id: str) -> TaskGetResponse: + """ + Return the status of the task, and return the outputs in some cases. For example, bigquery job + can't write the structured dataset to the output location, so it returns the output literals to the propeller, + and the propeller will write the structured dataset to the blob store. + """ + pass + + @abstractmethod + def delete(self, context: grpc.ServicerContext, job_id: str) -> TaskDeleteResponse: + """ + Delete the task. This call should be idempotent. + """ + pass + + +class BackendPluginRegistry(object): + """ + This is the registry for all backend plugins. The external plugins service will look up the plugin + based on the task type. + """ + + _REGISTRY: typing.Dict[str, BackendPluginBase] = {} + + @staticmethod + def register(plugin: BackendPluginBase): + if plugin.task_type in BackendPluginRegistry._REGISTRY: + raise ValueError(f"Duplicate plugin for task type {plugin.task_type}") + BackendPluginRegistry._REGISTRY[plugin.task_type] = plugin + logger.info(f"Registering backend plugin for task type {plugin.task_type}") + + @staticmethod + def get_plugin(context: grpc.ServicerContext, task_type: str) -> typing.Optional[BackendPluginBase]: + if task_type not in BackendPluginRegistry._REGISTRY: + logger.error(f"Cannot find backend plugin for task type [{task_type}]") + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details(f"Cannot find backend plugin for task type [{task_type}]") + return None + return BackendPluginRegistry._REGISTRY[task_type] + + +def convert_to_flyte_state(state: str) -> State: + """ + Convert the state from the backend plugin to the state in flyte. + """ + state = state.lower() + if state in ["failed"]: + return RETRYABLE_FAILURE + elif state in ["done", "succeeded"]: + return SUCCEEDED + elif state in ["running"]: + return RUNNING + raise ValueError(f"Unrecognized state: {state}") diff --git a/flytekit/extend/backend/external_plugin_service.py b/flytekit/extend/backend/external_plugin_service.py new file mode 100644 index 0000000000..e820a320b1 --- /dev/null +++ b/flytekit/extend/backend/external_plugin_service.py @@ -0,0 +1,53 @@ +import grpc +from flyteidl.service.external_plugin_service_pb2 import ( + PERMANENT_FAILURE, + TaskCreateRequest, + TaskCreateResponse, + TaskDeleteRequest, + TaskDeleteResponse, + TaskGetRequest, + TaskGetResponse, +) +from flyteidl.service.external_plugin_service_pb2_grpc import ExternalPluginServiceServicer + +from flytekit import logger +from flytekit.extend.backend.base_plugin import BackendPluginRegistry +from flytekit.models.literals import LiteralMap +from flytekit.models.task import TaskTemplate + + +class BackendPluginServer(ExternalPluginServiceServicer): + def CreateTask(self, request: TaskCreateRequest, context: grpc.ServicerContext) -> TaskCreateResponse: + try: + tmp = TaskTemplate.from_flyte_idl(request.template) + inputs = LiteralMap.from_flyte_idl(request.inputs) if request.inputs else None + plugin = BackendPluginRegistry.get_plugin(context, tmp.type) + if plugin is None: + return TaskCreateResponse() + return plugin.create(context=context, inputs=inputs, output_prefix=request.output_prefix, task_template=tmp) + except Exception as e: + logger.error(f"failed to create task with error {e}") + context.set_code(grpc.StatusCode.INTERNAL) + context.set_details(f"failed to create task with error {e}") + + def GetTask(self, request: TaskGetRequest, context: grpc.ServicerContext) -> TaskGetResponse: + try: + plugin = BackendPluginRegistry.get_plugin(context, request.task_type) + if plugin is None: + return TaskGetResponse(state=PERMANENT_FAILURE) + return plugin.get(context=context, job_id=request.job_id) + except Exception as e: + logger.error(f"failed to get task with error {e}") + context.set_code(grpc.StatusCode.INTERNAL) + context.set_details(f"failed to get task with error {e}") + + def DeleteTask(self, request: TaskDeleteRequest, context: grpc.ServicerContext) -> TaskDeleteResponse: + try: + plugin = BackendPluginRegistry.get_plugin(context, request.task_type) + if plugin is None: + return TaskDeleteResponse() + return plugin.delete(context=context, job_id=request.job_id) + except Exception as e: + logger.error(f"failed to delete task with error {e}") + context.set_code(grpc.StatusCode.INTERNAL) + context.set_details(f"failed to delete task with error {e}") 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/extras/sqlite3/task.py b/flytekit/extras/sqlite3/task.py index 8e7d8b3b29..ef8013a5da 100644 --- a/flytekit/extras/sqlite3/task.py +++ b/flytekit/extras/sqlite3/task.py @@ -14,7 +14,6 @@ from flytekit.core.python_customized_container_task import PythonCustomizedContainerTask from flytekit.core.shim_task import ShimTaskExecutor from flytekit.models import task as task_models -from flytekit.types.schema import FlyteSchema def unarchive_file(local_path: str, to_dir: str): @@ -78,12 +77,14 @@ def __init__( query_template: str, inputs: typing.Optional[typing.Dict[str, typing.Type]] = None, task_config: typing.Optional[SQLite3Config] = None, - output_schema_type: typing.Optional[typing.Type[FlyteSchema]] = None, + output_schema_type: typing.Optional[typing.Type["FlyteSchema"]] = None, # type: ignore container_image: typing.Optional[str] = None, **kwargs, ): if task_config is None or task_config.uri is None: raise ValueError("SQLite DB uri is required.") + from flytekit.types.schema import FlyteSchema + outputs = kwtypes(results=output_schema_type if output_schema_type else FlyteSchema) super().__init__( name=name, diff --git a/flytekit/extras/tasks/shell.py b/flytekit/extras/tasks/shell.py index 12ef36af3e..87b60126d6 100644 --- a/flytekit/extras/tasks/shell.py +++ b/flytekit/extras/tasks/shell.py @@ -1,5 +1,6 @@ import datetime import os +import platform import string import subprocess import typing @@ -213,6 +214,9 @@ def execute(self, **kwargs) -> typing.Any: print("\n==============================================\n") try: + if platform.system() == "Windows" and os.environ.get("ComSpec") is None: + # https://github.com/python/cpython/issues/101283 + os.environ["ComSpec"] = "C:\\Windows\\System32\\cmd.exe" subprocess.check_call(gen_script, shell=True) except subprocess.CalledProcessError as e: files = os.listdir(".") @@ -356,7 +360,6 @@ def execute(self, **kwargs) -> typing.Any: # This utility function allows for the specification of env variables, arguments, and the actual script within the # workflow definition rather than at `RawShellTask` instantiation def get_raw_shell_task(name: str) -> RawShellTask: - return RawShellTask( name=name, debug=True, diff --git a/flytekit/extras/tensorflow/__init__.py b/flytekit/extras/tensorflow/__init__.py index b5699906fb..2447ffa826 100644 --- a/flytekit/extras/tensorflow/__init__.py +++ b/flytekit/extras/tensorflow/__init__.py @@ -23,9 +23,10 @@ if _tensorflow_installed: + from .model import TensorFlowModelTransformer from .record import TensorFlowRecordFileTransformer, TensorFlowRecordsDirTransformer else: logger.info( - "We won't register TensorFlowRecordFileTransformer and TensorFlowRecordsDirTransformer " + "We won't register TensorFlowRecordFileTransformer, TensorFlowRecordsDirTransformer and TensorFlowModelTransformer" "because tensorflow is not installed." ) diff --git a/flytekit/extras/tensorflow/model.py b/flytekit/extras/tensorflow/model.py new file mode 100644 index 0000000000..857ec2c984 --- /dev/null +++ b/flytekit/extras/tensorflow/model.py @@ -0,0 +1,76 @@ +import pathlib +from typing import Type + +import tensorflow as tf + +from flytekit.core.context_manager import FlyteContext +from flytekit.core.type_engine import TypeEngine, TypeTransformer, TypeTransformerFailedError +from flytekit.models.core import types as _core_types +from flytekit.models.literals import Blob, BlobMetadata, Literal, Scalar +from flytekit.models.types import LiteralType + + +class TensorFlowModelTransformer(TypeTransformer[tf.keras.Model]): + TENSORFLOW_FORMAT = "TensorFlowModel" + + def __init__(self): + super().__init__(name="TensorFlow Model", t=tf.keras.Model) + + def get_literal_type(self, t: Type[tf.keras.Model]) -> LiteralType: + return LiteralType( + blob=_core_types.BlobType( + format=self.TENSORFLOW_FORMAT, + dimensionality=_core_types.BlobType.BlobDimensionality.MULTIPART, + ) + ) + + def to_literal( + self, + ctx: FlyteContext, + python_val: tf.keras.Model, + python_type: Type[tf.keras.Model], + expected: LiteralType, + ) -> Literal: + meta = BlobMetadata( + type=_core_types.BlobType( + format=self.TENSORFLOW_FORMAT, + dimensionality=_core_types.BlobType.BlobDimensionality.MULTIPART, + ) + ) + + local_path = ctx.file_access.get_random_local_path() + pathlib.Path(local_path).parent.mkdir(parents=True, exist_ok=True) + + # save model in SavedModel format + tf.keras.models.save_model(python_val, local_path) + + remote_path = ctx.file_access.get_random_remote_path() + ctx.file_access.put_data(local_path, remote_path, is_multipart=True) + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=remote_path))) + + def to_python_value( + self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[tf.keras.Model] + ) -> tf.keras.Model: + try: + uri = lv.scalar.blob.uri + except AttributeError: + TypeTransformerFailedError(f"Cannot convert from {lv} to {expected_python_type}") + + local_path = ctx.file_access.get_random_local_path() + ctx.file_access.get_data(uri, local_path, is_multipart=True) + + # load model + return tf.keras.models.load_model(local_path) + + def guess_python_type(self, literal_type: LiteralType) -> Type[tf.keras.Model]: + if ( + literal_type.blob is not None + and literal_type.blob.dimensionality == _core_types.BlobType.BlobDimensionality.MULTIPART + and literal_type.blob.format == self.TENSORFLOW_FORMAT + ): + return tf.keras.Model + + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + +TypeEngine.register(TensorFlowModelTransformer()) diff --git a/flytekit/extras/tensorflow/record.py b/flytekit/extras/tensorflow/record.py index d5d750b521..17e7c37ddd 100644 --- a/flytekit/extras/tensorflow/record.py +++ b/flytekit/extras/tensorflow/record.py @@ -159,7 +159,6 @@ def to_literal( def to_python_value( self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[TFRecordsDirectory] ) -> TFRecordDatasetV2: - uri, metadata = extract_metadata_and_uri(lv, expected_python_type) local_dir = ctx.file_access.get_random_local_directory() ctx.file_access.get_data(uri, local_dir, is_multipart=True) diff --git a/flytekit/image_spec/__init__.py b/flytekit/image_spec/__init__.py new file mode 100644 index 0000000000..ca1bdedee6 --- /dev/null +++ b/flytekit/image_spec/__init__.py @@ -0,0 +1 @@ +from .image_spec import ImageSpec diff --git a/flytekit/image_spec/image_spec.py b/flytekit/image_spec/image_spec.py new file mode 100644 index 0000000000..a0ddbc1977 --- /dev/null +++ b/flytekit/image_spec/image_spec.py @@ -0,0 +1,170 @@ +import base64 +import hashlib +import os +import typing +from abc import abstractmethod +from copy import copy +from dataclasses import dataclass +from functools import lru_cache +from typing import List, Optional + +import click +import requests +from dataclasses_json import dataclass_json + +DOCKER_HUB = "docker.io" +_F_IMG_ID = "_F_IMG_ID" + + +@dataclass_json +@dataclass +class ImageSpec: + """ + This class is used to specify the docker image that will be used to run the task. + + Args: + name: name of the image. + python_version: python version of the image. Use default python in the base image if None. + builder: Type of plugin to build the image. Use envd by default. + source_root: source root of the image. + env: environment variables of the image. + registry: registry of the image. + packages: list of python packages to install. + apt_packages: list of apt packages to install. + base_image: base image of the image. + """ + + name: str = "flytekit" + python_version: str = None # Use default python in the base image if None. + builder: str = "envd" + source_root: Optional[str] = None + env: Optional[typing.Dict[str, str]] = None + registry: Optional[str] = None + packages: Optional[List[str]] = None + apt_packages: Optional[List[str]] = None + base_image: Optional[str] = None + + def image_name(self) -> str: + """ + return full image name with tag. + """ + tag = calculate_hash_from_image_spec(self) + container_image = f"{self.name}:{tag}" + if self.registry: + container_image = f"{self.registry}/{container_image}" + return container_image + + def is_container(self) -> bool: + from flytekit.core.context_manager import ExecutionState, FlyteContextManager + + state = FlyteContextManager.current_context().execution_state + if state and state.mode and state.mode != ExecutionState.Mode.LOCAL_WORKFLOW_EXECUTION: + return os.environ.get(_F_IMG_ID) == self.image_name() + return True + + @lru_cache(maxsize=128) + def exist(self) -> bool: + """ + Check if the image exists in the registry. + """ + import docker + from docker.errors import APIError, ImageNotFound + + try: + client = docker.from_env() + if self.registry: + client.images.get_registry_data(self.image_name()) + else: + client.images.get(self.image_name()) + return True + except APIError as e: + if e.response.status_code == 404: + return False + except ImageNotFound: + return False + except Exception as e: + tag = calculate_hash_from_image_spec(self) + # if docker engine is not running locally + container_registry = DOCKER_HUB + if "/" in self.registry: + container_registry = self.registry.split("/")[0] + if container_registry == DOCKER_HUB: + url = f"https://hub.docker.com/v2/repositories/{self.registry}/{self.name}/tags/{tag}" + response = requests.get(url) + if response.status_code == 200: + return True + + if response.status_code == 404: + return False + + click.secho(f"Failed to check if the image exists with error : {e}", fg="red") + click.secho("Flytekit assumes that the image already exists.", fg="blue") + return True + + def __hash__(self): + return hash(self.to_json()) + + +class ImageSpecBuilder: + @abstractmethod + def build_image(self, image_spec: ImageSpec): + """ + Build the docker image and push it to the registry. + + Args: + image_spec: image spec of the task. + """ + raise NotImplementedError("This method is not implemented in the base class.") + + +class ImageBuildEngine: + """ + ImageBuildEngine contains a list of builders that can be used to build an ImageSpec. + """ + + _REGISTRY: typing.Dict[str, ImageSpecBuilder] = {} + + @classmethod + def register(cls, builder_type: str, image_spec_builder: ImageSpecBuilder): + cls._REGISTRY[builder_type] = image_spec_builder + + @classmethod + def build(cls, image_spec: ImageSpec): + if image_spec.builder not in cls._REGISTRY: + raise Exception(f"Builder {image_spec.builder} is not registered.") + if not image_spec.exist(): + click.secho(f"Image {image_spec.image_name()} not found. Building...", fg="blue") + cls._REGISTRY[image_spec.builder].build_image(image_spec) + else: + click.secho(f"Image {image_spec.image_name()} found. Skip building.", fg="blue") + + +@lru_cache(maxsize=128) +def calculate_hash_from_image_spec(image_spec: ImageSpec): + """ + Calculate the hash from the image spec. + """ + # copy the image spec to avoid modifying the original image spec. otherwise, the hash will be different. + spec = copy(image_spec) + spec.source_root = hash_directory(image_spec.source_root) if image_spec.source_root else b"" + image_spec_bytes = bytes(image_spec.to_json(), "utf-8") + tag = base64.urlsafe_b64encode(hashlib.md5(image_spec_bytes).digest()).decode("ascii") + # replace "=" with "." to make it a valid tag + return tag.replace("=", ".") + + +def hash_directory(path): + """ + Return the SHA-256 hash of the directory at the given path. + """ + hasher = hashlib.sha256() + for root, dirs, files in os.walk(path): + for file in files: + with open(os.path.join(root, file), "rb") as f: + while True: + # Read file in small chunks to avoid loading large files into memory all at once + chunk = f.read(4096) + if not chunk: + break + hasher.update(chunk) + return bytes(hasher.hexdigest(), "utf-8") diff --git a/flytekit/lazy_import/__init__.py b/flytekit/lazy_import/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/lazy_import/lazy_module.py b/flytekit/lazy_import/lazy_module.py new file mode 100644 index 0000000000..553386eb52 --- /dev/null +++ b/flytekit/lazy_import/lazy_module.py @@ -0,0 +1,33 @@ +import importlib.util +import sys + +LAZY_MODULES = [] + + +def is_imported(module_name): + """ + This function is used to check if a module has been imported by the regular import. + """ + return module_name in sys.modules and module_name not in LAZY_MODULES + + +def lazy_module(fullname): + """ + This function is used to lazily import modules. It is used in the following way: + .. code-block:: python + from flytekit.lazy_import import lazy_module + sklearn = lazy_module("sklearn") + sklearn.svm.SVC() + :param Text fullname: The full name of the module to import + """ + if fullname in sys.modules: + return sys.modules[fullname] + # https://docs.python.org/3/library/importlib.html#implementing-lazy-imports + spec = importlib.util.find_spec(fullname) + loader = importlib.util.LazyLoader(spec.loader) + spec.loader = loader + module = importlib.util.module_from_spec(spec) + sys.modules[fullname] = module + LAZY_MODULES.append(module) + loader.exec_module(module) + return module diff --git a/flytekit/loggers.py b/flytekit/loggers.py index f047348de0..fdc3c75d3a 100644 --- a/flytekit/loggers.py +++ b/flytekit/loggers.py @@ -2,6 +2,8 @@ import os from pythonjsonlogger import jsonlogger +from rich.console import Console +from rich.logging import RichHandler # Note: # The environment variable controls exposed to affect the individual loggers should be considered to be beta. @@ -10,6 +12,7 @@ # For now, assume this is the environment variable whose usage will remain unchanged and controls output for all # loggers defined in this file. LOGGING_ENV_VAR = "FLYTE_SDK_LOGGING_LEVEL" +LOGGING_FMT_ENV_VAR = "FLYTE_SDK_LOGGING_FORMAT" # By default, the root flytekit logger to debug so everything is logged, but enable fine-tuning logger = logging.getLogger("flytekit") @@ -33,8 +36,18 @@ user_space_logger = child_loggers["user_space"] # create console handler -ch = logging.StreamHandler() -ch.setLevel(logging.DEBUG) +try: + handler = RichHandler( + rich_tracebacks=True, + omit_repeated_times=False, + keywords=["[flytekit]"], + log_time_format="%Y-%m-%d %H:%M:%S,%f", + console=Console(width=os.get_terminal_size().columns), + ) +except OSError: + handler = logging.StreamHandler() + +handler.setLevel(logging.DEBUG) # Root logger control # Don't want to import the configuration library since that will cause all sorts of circular imports, let's @@ -63,10 +76,14 @@ child_logger.setLevel(logging.WARNING) # create formatter -formatter = jsonlogger.JsonFormatter(fmt="%(asctime)s %(name)s %(levelname)s %(message)s") +logging_fmt = os.environ.get(LOGGING_FMT_ENV_VAR, "json") +if logging_fmt == "json": + formatter = jsonlogger.JsonFormatter(fmt="%(asctime)s %(name)s %(levelname)s %(message)s") +else: + formatter = logging.Formatter(fmt="[%(name)s] %(message)s") -# add formatter to ch -ch.setFormatter(formatter) +# add formatter to the handler +handler.setFormatter(formatter) # add ch to logger -logger.addHandler(ch) +logger.addHandler(handler) diff --git a/flytekit/models/common.py b/flytekit/models/common.py index 62018c1eef..4f030e25a4 100644 --- a/flytekit/models/common.py +++ b/flytekit/models/common.py @@ -1,5 +1,6 @@ import abc as _abc import json as _json +import re from flyteidl.admin import common_pb2 as _common_pb2 from google.protobuf import json_format as _json_format @@ -57,7 +58,8 @@ def short_string(self): """ :rtype: Text """ - return str(self.to_flyte_idl()) + literal_str = re.sub(r"\s+", " ", str(self.to_flyte_idl())).strip() + return f"" def verbose_string(self): """ diff --git a/flytekit/models/security.py b/flytekit/models/security.py index 7babb859e4..9af90a4b8a 100644 --- a/flytekit/models/security.py +++ b/flytekit/models/security.py @@ -36,15 +36,13 @@ class MountType(Enum): """ group: str - key: str + key: Optional[str] = None group_version: Optional[str] = None mount_requirement: MountType = MountType.ANY def __post_init__(self): if self.group is None: raise ValueError("Group is a required parameter") - if self.key is None: - raise ValueError("Key is also a required parameter") def to_flyte_idl(self) -> _sec.Secret: return _sec.Secret( @@ -59,7 +57,7 @@ def from_flyte_idl(cls, pb2_object: _sec.Secret) -> "Secret": return cls( group=pb2_object.group, group_version=pb2_object.group_version if pb2_object.group_version else None, - key=pb2_object.key, + key=pb2_object.key if pb2_object.key else None, mount_requirement=Secret.MountType(pb2_object.mount_requirement), ) diff --git a/flytekit/models/task.py b/flytekit/models/task.py index fc79c87a2d..f7f1d710c9 100644 --- a/flytekit/models/task.py +++ b/flytekit/models/task.py @@ -868,12 +868,18 @@ def from_flyte_idl(cls, pb2_object: _core_task.K8sObjectMetadata): class K8sPod(_common.FlyteIdlEntity): - def __init__(self, metadata: K8sObjectMetadata = None, pod_spec: typing.Dict[str, typing.Any] = None): + def __init__( + self, + metadata: K8sObjectMetadata = None, + pod_spec: typing.Dict[str, typing.Any] = None, + data_config: typing.Optional[DataLoadingConfig] = None, + ): """ This defines a kubernetes pod target. It will build the pod target during task execution """ self._metadata = metadata self._pod_spec = pod_spec + self._data_config = data_config @property def metadata(self) -> K8sObjectMetadata: @@ -883,10 +889,15 @@ def metadata(self) -> K8sObjectMetadata: def pod_spec(self) -> typing.Dict[str, typing.Any]: return self._pod_spec + @property + def data_config(self) -> typing.Optional[DataLoadingConfig]: + return self._data_config + def to_flyte_idl(self) -> _core_task.K8sPod: return _core_task.K8sPod( metadata=self._metadata.to_flyte_idl(), pod_spec=_json_format.Parse(_json.dumps(self.pod_spec), _struct.Struct()) if self.pod_spec else None, + data_config=self.data_config.to_flyte_idl() if self.data_config else None, ) @classmethod @@ -894,6 +905,9 @@ def from_flyte_idl(cls, pb2_object: _core_task.K8sPod): return cls( metadata=K8sObjectMetadata.from_flyte_idl(pb2_object.metadata), pod_spec=_json_format.MessageToDict(pb2_object.pod_spec) if pb2_object.HasField("pod_spec") else None, + data_config=DataLoadingConfig.from_flyte_idl(pb2_object.data_config) + if pb2_object.HasField("data_config") + else None, ) diff --git a/flytekit/models/types.py b/flytekit/models/types.py index 4358d7229e..3e3c778d6b 100644 --- a/flytekit/models/types.py +++ b/flytekit/models/types.py @@ -259,7 +259,7 @@ def __init__( :param flytekit.models.core.types.StructuredDatasetType structured_dataset_type: structured dataset :param dict[Text, T] metadata: Additional data describing the type :param flytekit.models.annotation.TypeAnnotation annotation: Additional data - describing the type _intended to be saturated by the client_ + describing the type intended to be saturated by the client """ self._simple = simple self._schema = schema diff --git a/flytekit/remote/backfill.py b/flytekit/remote/backfill.py index 154bf4d1b4..2f31889060 100644 --- a/flytekit/remote/backfill.py +++ b/flytekit/remote/backfill.py @@ -68,6 +68,8 @@ def create_backfill_workflow( logging.info(f"Generating backfill from {start_date} -> {end_date}. Parallel?[{parallel}]") wf = ImperativeWorkflow(name=f"backfill-{for_lp.name}") + + input_name = schedule.kickoff_time_input_arg date_iter = croniter(cron_schedule.schedule, start_time=start_date, ret_type=datetime) prev_node = None actual_start = None @@ -79,7 +81,10 @@ def create_backfill_workflow( if next_start_date >= end_date: break actual_end = next_start_date - next_node = wf.add_launch_plan(for_lp, t=next_start_date) + inputs = {} + if input_name: + inputs[input_name] = next_start_date + next_node = wf.add_launch_plan(for_lp, **inputs) next_node = next_node.with_overrides( name=f"b-{next_start_date}", retries=per_node_retries, timeout=per_node_timeout ) diff --git a/flytekit/remote/remote.py b/flytekit/remote/remote.py index 03cc9a66e9..91189ede74 100644 --- a/flytekit/remote/remote.py +++ b/flytekit/remote/remote.py @@ -6,17 +6,19 @@ from __future__ import annotations import base64 -import functools import hashlib 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 @@ -34,7 +36,11 @@ 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 +68,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_scripts, hash_file from flytekit.tools.translator import ( FlyteControlPlaneEntity, FlyteLocalEntity, @@ -615,6 +621,10 @@ def _serialize_and_register( version=version, ) is_dummy_serialization_setting = True + + if serialization_settings.version is None: + serialization_settings.version = version + _ = get_serializable(m, settings=serialization_settings, entity=entity, options=options) ident = None @@ -704,9 +714,9 @@ def fast_package(self, root: os.PathLike, deref_symlinks: bool = True, output: s md5_bytes, _ = hash_file(pathlib.Path(zip_file)) # Upload zip file to Admin using FlyteRemote. - return self._upload_file(pathlib.Path(zip_file)) + return self.upload_file(pathlib.Path(zip_file)) - def _upload_file( + def upload_file( self, to_upload: pathlib.Path, project: typing.Optional[str] = None, domain: typing.Optional[str] = None ) -> typing.Tuple[bytes, str]: """ @@ -728,7 +738,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}" ) @@ -773,17 +799,19 @@ def register_script( project: typing.Optional[str] = None, domain: typing.Optional[str] = None, destination_dir: str = ".", - default_launch_plan: typing.Optional[bool] = True, + copy_all: bool = False, + default_launch_plan: bool = True, options: typing.Optional[Options] = None, source_path: typing.Optional[str] = None, module_name: typing.Optional[str] = None, ) -> typing.Union[FlyteWorkflow, FlyteTask]: """ Use this method to register a workflow via script mode. - :param destination_dir: - :param domain: - :param project: - :param image_config: + :param destination_dir: The destination directory where the workflow will be copied to. + :param copy_all: If true, the entire source directory will be copied over to the destination directory. + :param domain: The domain to register the workflow in. + :param project: The project to register the workflow in. + :param image_config: The image config to use for the workflow. :param version: version for the entity to be registered as :param entity: The workflow to be registered or the task to be registered :param default_launch_plan: This should be true if a default launch plan should be created for the workflow @@ -795,16 +823,16 @@ 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: + if copy_all: + md5_bytes, upload_native_url = self.fast_package(pathlib.Path(source_path), False, tmp_dir) + else: + archive_fname = pathlib.Path(os.path.join(tmp_dir, "script_mode.tar.gz")) + compress_scripts(source_path, str(archive_fname), module_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 +841,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/fast_registration.py b/flytekit/tools/fast_registration.py index b2f7efcc65..f115480112 100644 --- a/flytekit/tools/fast_registration.py +++ b/flytekit/tools/fast_registration.py @@ -12,6 +12,7 @@ import click from flytekit.core.context_manager import FlyteContextManager +from flytekit.core.utils import timeit from flytekit.tools.ignore import DockerIgnore, GitIgnore, IgnoreGroup, StandardIgnore from flytekit.tools.script_mode import tar_strip_file_attributes @@ -97,6 +98,7 @@ def get_additional_distribution_loc(remote_location: str, identifier: str) -> st return posixpath.join(remote_location, "{}.{}".format(identifier, "tar.gz")) +@timeit("Download distribution") def download_distribution(additional_distribution: str, destination: str): """ Downloads a remote code distribution and overwrites any local files. diff --git a/flytekit/tools/repo.py b/flytekit/tools/repo.py index 3c9fe64068..82d4c2c226 100644 --- a/flytekit/tools/repo.py +++ b/flytekit/tools/repo.py @@ -37,7 +37,7 @@ def serialize( :param pkgs: Dot-delimited Python packages/subpackages to look into for serialization. :param local_source_root: Where to start looking for the code. """ - + settings.source_root = local_source_root ctx = FlyteContextManager.current_context().with_serialization_settings(settings) with FlyteContextManager.with_context(ctx) as ctx: # Scan all modules. the act of loading populates the global singleton that contains all objects @@ -60,6 +60,8 @@ def serialize_to_folder( """ Serialize the given set of python packages to a folder """ + if folder is None: + folder = "." loaded_entities = serialize(pkgs, settings, local_source_root, options=options) persist_registrable_entities(loaded_entities, folder) diff --git a/flytekit/tools/script_mode.py b/flytekit/tools/script_mode.py index 29b617824c..ecc71a2398 100644 --- a/flytekit/tools/script_mode.py +++ b/flytekit/tools/script_mode.py @@ -8,13 +8,12 @@ import typing from pathlib import Path -from flyteidl.service import dataproxy_pb2 as _data_proxy_pb2 - -from flytekit.core import context_manager +from flytekit import PythonFunctionTask from flytekit.core.tracker import get_full_module_path +from flytekit.core.workflow import ImperativeWorkflow, WorkflowBase -def compress_single_script(source_path: str, destination: str, full_module_name: str): +def compress_scripts(source_path: str, destination: str, module_name: str): """ Compresses the single script while maintaining the folder structure for that file. @@ -39,33 +38,14 @@ def compress_single_script(source_path: str, destination: str, full_module_name: │   ├── example.py │   └── __init__.py - Note how `another_example.py` and `yet_another_example.py` were not copied to the destination. + Note: If `example.py` didn't import tasks or workflows from `another_example.py` and `yet_another_example.py`, these files were not copied to the destination.. + """ with tempfile.TemporaryDirectory() as tmp_dir: destination_path = os.path.join(tmp_dir, "code") - # This is the script relative path to the root of the project - script_relative_path = Path() - # For each package in pkgs, create a directory and copy the __init__.py in it. - # Skip the last package as that is the script file. - pkgs = full_module_name.split(".") - for p in pkgs[:-1]: - os.makedirs(os.path.join(destination_path, p)) - source_path = os.path.join(source_path, p) - destination_path = os.path.join(destination_path, p) - script_relative_path = Path(script_relative_path, p) - init_file = Path(os.path.join(source_path, "__init__.py")) - if init_file.exists(): - shutil.copy(init_file, Path(os.path.join(tmp_dir, "code", script_relative_path, "__init__.py"))) - - # Ensure destination path exists to cover the case of a single file and no modules. - os.makedirs(destination_path, exist_ok=True) - script_file = Path(source_path, f"{pkgs[-1]}.py") - script_file_destination = Path(destination_path, f"{pkgs[-1]}.py") - # Build the final script relative path and copy it to a known place. - shutil.copy( - script_file, - script_file_destination, - ) + + visited: typing.List[str] = [] + copy_module_to_destination(source_path, destination_path, module_name, visited) tar_path = os.path.join(tmp_dir, "tmp.tar") with tarfile.open(tar_path, "w") as tar: tar.add(os.path.join(tmp_dir, "code"), arcname="", filter=tar_strip_file_attributes) @@ -74,6 +54,54 @@ def compress_single_script(source_path: str, destination: str, full_module_name: gzipped.write(tar_file.read()) +def copy_module_to_destination( + original_source_path: str, original_destination_path: str, module_name: str, visited: typing.List[str] +): + """ + Copy the module (file) to the destination directory. If the module relative imports other modules, flytekit will + recursively copy them as well. + """ + mod = importlib.import_module(module_name) + full_module_name = get_full_module_path(mod, mod.__name__) + if full_module_name in visited: + return + visited.append(full_module_name) + + source_path = original_source_path + destination_path = original_destination_path + pkgs = full_module_name.split(".") + + for p in pkgs[:-1]: + os.makedirs(os.path.join(destination_path, p), exist_ok=True) + destination_path = os.path.join(destination_path, p) + source_path = os.path.join(source_path, p) + init_file = Path(os.path.join(source_path, "__init__.py")) + if init_file.exists(): + shutil.copy(init_file, Path(os.path.join(destination_path, "__init__.py"))) + + # Ensure destination path exists to cover the case of a single file and no modules. + os.makedirs(destination_path, exist_ok=True) + script_file = Path(source_path, f"{pkgs[-1]}.py") + script_file_destination = Path(destination_path, f"{pkgs[-1]}.py") + # Build the final script relative path and copy it to a known place. + shutil.copy( + script_file, + script_file_destination, + ) + + # Try to copy other files to destination if tasks or workflows aren't in the same file + for flyte_entity_name in mod.__dict__: + flyte_entity = mod.__dict__[flyte_entity_name] + if ( + isinstance(flyte_entity, (PythonFunctionTask, WorkflowBase)) + and not isinstance(flyte_entity, ImperativeWorkflow) + and flyte_entity.instantiated_in + ): + copy_module_to_destination( + original_source_path, original_destination_path, flyte_entity.instantiated_in, visited + ) + + # Takes in a TarInfo and returns the modified TarInfo: # https://docs.python.org/3/library/tarfile.html#tarinfo-objects # intented to be passed as a filter to tarfile.add @@ -96,24 +124,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 @@ -131,7 +141,7 @@ def hash_file(file_path: typing.Union[os.PathLike, str]) -> (bytes, str): return h.digest(), h.hexdigest() -def _find_project_root(source_path) -> Path: +def _find_project_root(source_path) -> str: """ Find the root of the project. The root of the project is considered to be the first ancestor from source_path that does @@ -143,4 +153,4 @@ def _find_project_root(source_path) -> Path: path = Path(source_path).parent.resolve() while os.path.exists(os.path.join(path, "__init__.py")): path = path.parent - return path + return str(path) diff --git a/flytekit/tools/serialize_helpers.py b/flytekit/tools/serialize_helpers.py index 69af2b96b4..86a029d411 100644 --- a/flytekit/tools/serialize_helpers.py +++ b/flytekit/tools/serialize_helpers.py @@ -10,12 +10,10 @@ from flytekit.core import context_manager as flyte_context from flytekit.core.base_task import PythonTask from flytekit.core.workflow import WorkflowBase -from flytekit.exceptions.user import FlyteValidationException from flytekit.models import launch_plan as _launch_plan_models from flytekit.models import task as task_models from flytekit.models.admin import workflow as admin_workflow_models 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 @@ -44,20 +42,6 @@ def _should_register_with_admin(entity) -> bool: ) and not isinstance(entity, RemoteEntity) -def _find_duplicate_tasks(tasks: typing.List[task_models.TaskSpec]) -> typing.Set[task_models.TaskSpec]: - """ - Given a list of `TaskSpec`, this function returns a set containing the duplicated `TaskSpec` if any exists. - """ - seen: typing.Set[_identifier.Identifier] = set() - duplicate_tasks: typing.Set[task_models.TaskSpec] = set() - for task in tasks: - if task.template.id not in seen: - seen.add(task.template.id) - else: - duplicate_tasks.add(task) - return duplicate_tasks - - def get_registrable_entities( ctx: flyte_context.FlyteContext, options: typing.Optional[Options] = None ) -> typing.List[FlyteControlPlaneEntity]: @@ -78,19 +62,6 @@ def get_registrable_entities( new_api_model_values = list(new_api_serializable_entities.values()) entities_to_be_serialized = list(filter(_should_register_with_admin, new_api_model_values)) - serializable_tasks: typing.List[task_models.TaskSpec] = [ - entity for entity in entities_to_be_serialized if isinstance(entity, task_models.TaskSpec) - ] - # Detect if any of the tasks is duplicated. Duplicate tasks are defined as having the same - # metadata identifiers (see :py:class:`flytekit.common.core.identifier.Identifier`). Duplicate - # tasks are considered invalid at registration - # time and usually indicate user error, so we catch this common mistake at serialization time. - duplicate_tasks = _find_duplicate_tasks(serializable_tasks) - if len(duplicate_tasks) > 0: - duplicate_task_names = [task.template.id.name for task in duplicate_tasks] - raise FlyteValidationException( - f"Multiple definitions of the following tasks were found: {duplicate_task_names}" - ) return entities_to_be_serialized diff --git a/flytekit/tools/translator.py b/flytekit/tools/translator.py index 5ec249fa4b..b2835dca10 100644 --- a/flytekit/tools/translator.py +++ b/flytekit/tools/translator.py @@ -9,6 +9,7 @@ from flytekit.core import constants as _common_constants from flytekit.core.base_task import PythonTask from flytekit.core.condition import BranchNode +from flytekit.core.container_task import ContainerTask from flytekit.core.gate import Gate from flytekit.core.launch_plan import LaunchPlan, ReferenceLaunchPlan from flytekit.core.map_task import MapPythonTask @@ -189,7 +190,7 @@ def get_serializable_task( # If the pod spec is not None, we have to get it again, because the one we retrieved above will be incorrect. # The reason we have to call get_k8s_pod again, instead of just modifying the command in this file, is because # the pod spec is a K8s library object, and we shouldn't be messing around with it in this file. - elif pod: + elif pod and not isinstance(entity, ContainerTask): if isinstance(entity, MapPythonTask): entity.set_command_prefix(get_command_prefix_for_fast_execute(settings)) pod = entity.get_k8s_pod(settings) @@ -662,11 +663,6 @@ def get_serializable( elif isinstance(entity, BranchNode): cp_entity = get_serializable_branch_node(entity_mapping, settings, entity, options) - elif isinstance(entity, GateNode): - import ipdb - - ipdb.set_trace() - elif isinstance(entity, FlyteTask) or isinstance(entity, FlyteWorkflow): if entity.should_register: if isinstance(entity, FlyteTask): diff --git a/flytekit/types/directory/types.py b/flytekit/types/directory/types.py index afb59d58d0..b31bfc855c 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] @@ -143,6 +148,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 @@ -171,6 +188,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: """ @@ -179,9 +202,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 9fc55f76ce..9508dee2e2 100644 --- a/flytekit/types/file/file.py +++ b/flytekit/types/file/file.py @@ -3,12 +3,14 @@ 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 typing_extensions import Annotated, get_args, get_origin -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 +29,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 +152,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 +239,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 @@ -272,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/flytekit/types/pickle/__init__.py b/flytekit/types/pickle/__init__.py index 65604e67bb..e5bd1c056d 100644 --- a/flytekit/types/pickle/__init__.py +++ b/flytekit/types/pickle/__init__.py @@ -9,4 +9,4 @@ FlytePickle """ -from .pickle import FlytePickle +from .pickle import BatchSize, FlytePickle diff --git a/flytekit/types/pickle/pickle.py b/flytekit/types/pickle/pickle.py index 3472dec7e6..3de75b765b 100644 --- a/flytekit/types/pickle/pickle.py +++ b/flytekit/types/pickle/pickle.py @@ -13,6 +13,19 @@ T = typing.TypeVar("T") +class BatchSize: + """ + Flyte-specific object used to wrap the hash function for a specific type + """ + + def __init__(self, val: int): + self._val = val + + @property + def val(self) -> int: + return self._val + + class FlytePickle(typing.Generic[T]): """ This type is only used by flytekit internally. User should not use this type. diff --git a/flytekit/types/schema/types.py b/flytekit/types/schema/types.py index 8a8d832b58..7d043910d4 100644 --- a/flytekit/types/schema/types.py +++ b/flytekit/types/schema/types.py @@ -180,7 +180,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]: @@ -197,6 +196,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 @@ -234,6 +234,7 @@ def __init__( supported_mode: SchemaOpenMode = SchemaOpenMode.WRITE, downloader: typing.Callable[[str, os.PathLike], None] = 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 ( diff --git a/flytekit/types/structured/__init__.py b/flytekit/types/structured/__init__.py index 52577a650d..86fa19f4f0 100644 --- a/flytekit/types/structured/__init__.py +++ b/flytekit/types/structured/__init__.py @@ -13,15 +13,9 @@ """ -from flytekit.configuration.internal import LocalSDK +from flytekit.deck.renderer import ArrowRenderer, TopFrameRenderer from flytekit.loggers import logger -from .basic_dfs import ( - ArrowToParquetEncodingHandler, - PandasToParquetEncodingHandler, - ParquetToArrowDecodingHandler, - ParquetToPandasDecodingHandler, -) from .structured_dataset import ( StructuredDataset, StructuredDatasetDecoder, @@ -29,15 +23,42 @@ StructuredDatasetTransformerEngine, ) -try: - from .bigquery import ( - ArrowToBQEncodingHandlers, - BQToArrowDecodingHandler, - BQToPandasDecodingHandler, - PandasToBQEncodingHandlers, - ) -except ImportError: - logger.info( - "We won't register bigquery handler for structured dataset because " - "we can't find the packages google-cloud-bigquery-storage and google-cloud-bigquery" - ) + +def register_pandas_handlers(): + import pandas as pd + + from .basic_dfs import PandasToParquetEncodingHandler, ParquetToPandasDecodingHandler + + StructuredDatasetTransformerEngine.register(PandasToParquetEncodingHandler(), default_format_for_type=True) + StructuredDatasetTransformerEngine.register(ParquetToPandasDecodingHandler(), default_format_for_type=True) + StructuredDatasetTransformerEngine.register_renderer(pd.DataFrame, TopFrameRenderer()) + + +def register_arrow_handlers(): + import pyarrow as pa + + from .basic_dfs import ArrowToParquetEncodingHandler, ParquetToArrowDecodingHandler + + StructuredDatasetTransformerEngine.register(ArrowToParquetEncodingHandler(), default_format_for_type=True) + StructuredDatasetTransformerEngine.register(ParquetToArrowDecodingHandler(), default_format_for_type=True) + StructuredDatasetTransformerEngine.register_renderer(pa.Table, ArrowRenderer()) + + +def register_bigquery_handlers(): + try: + from .bigquery import ( + ArrowToBQEncodingHandlers, + BQToArrowDecodingHandler, + BQToPandasDecodingHandler, + PandasToBQEncodingHandlers, + ) + + StructuredDatasetTransformerEngine.register(PandasToBQEncodingHandlers()) + StructuredDatasetTransformerEngine.register(BQToPandasDecodingHandler()) + StructuredDatasetTransformerEngine.register(ArrowToBQEncodingHandlers()) + StructuredDatasetTransformerEngine.register(BQToArrowDecodingHandler()) + except ImportError: + logger.info( + "We won't register bigquery handler for structured dataset because " + "we can't find the packages google-cloud-bigquery-storage and google-cloud-bigquery" + ) diff --git a/flytekit/types/structured/basic_dfs.py b/flytekit/types/structured/basic_dfs.py index 39f8d11e24..8004867271 100644 --- a/flytekit/types/structured/basic_dfs.py +++ b/flytekit/types/structured/basic_dfs.py @@ -1,14 +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.deck import TopFrameRenderer -from flytekit.deck.renderer import ArrowRenderer +from flytekit import FlyteContext, logger +from flytekit.configuration import DataConfig +from flytekit.core.data_persistence import s3_setup_args from flytekit.models import literals from flytekit.models.literals import StructuredDatasetMetadata from flytekit.models.types import StructuredDatasetType @@ -17,12 +21,20 @@ StructuredDataset, StructuredDatasetDecoder, StructuredDatasetEncoder, - StructuredDatasetTransformerEngine, ) 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,15 +45,19 @@ 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_directory() + 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) - 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) + 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=path, metadata=StructuredDatasetMetadata(structured_dataset_type)) + return literals.StructuredDataset(uri=uri, metadata=StructuredDatasetMetadata(structured_dataset_type)) class ParquetToPandasDecodingHandler(StructuredDatasetDecoder): @@ -54,13 +70,17 @@ def decode( 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) + 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] - return pd.read_parquet(local_dir, columns=columns) - return pd.read_parquet(local_dir) + 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) class ArrowToParquetEncodingHandler(StructuredDatasetEncoder): @@ -73,13 +93,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,19 +112,20 @@ 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) - - -StructuredDatasetTransformerEngine.register(PandasToParquetEncodingHandler(), default_format_for_type=True) -StructuredDatasetTransformerEngine.register(ParquetToPandasDecodingHandler(), default_format_for_type=True) -StructuredDatasetTransformerEngine.register(ArrowToParquetEncodingHandler(), default_format_for_type=True) -StructuredDatasetTransformerEngine.register(ParquetToArrowDecodingHandler(), default_format_for_type=True) - -StructuredDatasetTransformerEngine.register_renderer(pd.DataFrame, TopFrameRenderer()) -StructuredDatasetTransformerEngine.register_renderer(pa.Table, ArrowRenderer()) + 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 diff --git a/flytekit/types/structured/bigquery.py b/flytekit/types/structured/bigquery.py index 85cede1544..049a21c07e 100644 --- a/flytekit/types/structured/bigquery.py +++ b/flytekit/types/structured/bigquery.py @@ -14,7 +14,6 @@ StructuredDatasetDecoder, StructuredDatasetEncoder, StructuredDatasetMetadata, - StructuredDatasetTransformerEngine, ) BIGQUERY = "bq" @@ -110,9 +109,3 @@ def decode( current_task_metadata: StructuredDatasetMetadata, ) -> pa.Table: return pa.Table.from_pandas(_read_from_bq(flyte_value, current_task_metadata)) - - -StructuredDatasetTransformerEngine.register(PandasToBQEncodingHandlers()) -StructuredDatasetTransformerEngine.register(BQToPandasDecodingHandler()) -StructuredDatasetTransformerEngine.register(ArrowToBQEncodingHandlers()) -StructuredDatasetTransformerEngine.register(BQToArrowDecodingHandler()) diff --git a/flytekit/types/structured/structured_dataset.py b/flytekit/types/structured/structured_dataset.py index 0e4649203a..fe5c3595ff 100644 --- a/flytekit/types/structured/structured_dataset.py +++ b/flytekit/types/structured/structured_dataset.py @@ -9,15 +9,13 @@ from typing import Dict, Generator, Optional, Type, Union import _datetime -import numpy as _np -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 import lazy_module 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 @@ -26,6 +24,13 @@ from flytekit.models.literals import Literal, Scalar, StructuredDatasetMetadata from flytekit.models.types import LiteralType, SchemaType, StructuredDatasetType +if typing.TYPE_CHECKING: + import pandas as pd + import pyarrow as pa +else: + pd = lazy_module("pandas") + pa = lazy_module("pyarrow") + T = typing.TypeVar("T") # StructuredDataset type or a dataframe type DF = typing.TypeVar("DF") # Dataframe type @@ -35,6 +40,7 @@ # Storage formats PARQUET: StructuredDatasetFormat = "parquet" GENERIC_FORMAT: StructuredDatasetFormat = "" +GENERIC_PROTOCOL: str = "generic protocol" @dataclass_json @@ -74,7 +80,8 @@ def __init__( # This is not for users to set, the transformer will set this. self._literal_sd: Optional[literals.StructuredDataset] = None # Not meant for users to set, will be set by an open() call - self._dataframe_type: Optional[Type[DF]] = None + self._dataframe_type: Optional[DF] = None # type: ignore + self._already_uploaded = False @property def dataframe(self) -> Optional[Type[DF]]: @@ -109,7 +116,7 @@ def iter(self) -> Generator[DF, None, None]: def extract_cols_and_format( t: typing.Any, -) -> typing.Tuple[Type[T], Optional[typing.OrderedDict[str, Type]], Optional[str], Optional[pa.lib.Schema]]: +) -> typing.Tuple[Type[T], Optional[typing.OrderedDict[str, Type]], Optional[str], Optional["pa.lib.Schema"]]: """ Helper function, just used to iterate through Annotations and extract out the following information: - base type, if not Annotated, it will just be the type that was passed in. @@ -143,7 +150,7 @@ def extract_cols_and_format( if ordered_dict_cols is not None: raise ValueError(f"Column information was already found {ordered_dict_cols}, cannot use {aa}") ordered_dict_cols = aa - elif isinstance(aa, pa.Schema): + elif isinstance(aa, pa.lib.Schema): if pa_schema is not None: raise ValueError(f"Arrow schema was already found {pa_schema}, cannot use {aa}") pa_schema = aa @@ -271,11 +278,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: @@ -295,16 +297,8 @@ def convert_schema_type_to_structured_dataset_type( raise AssertionError(f"Unrecognized SchemaColumnType: {column_type}") -class DuplicateHandlerError(ValueError): - ... - - -class StructuredDatasetTransformerEngine(TypeTransformer[StructuredDataset]): - """ - Think of this transformer as a higher-level meta transformer that is used for all the dataframe types. - If you are bringing a custom data frame type, or any data frame type, to flytekit, instead of - registering with the main type engine, you should register with this transformer instead. - """ +def get_supported_types(): + import numpy as _np _SUPPORTED_TYPES: typing.Dict[Type, LiteralType] = { _np.int32: type_models.LiteralType(simple=type_models.SimpleType.INTEGER), @@ -326,6 +320,19 @@ class StructuredDatasetTransformerEngine(TypeTransformer[StructuredDataset]): _np.object_: type_models.LiteralType(simple=type_models.SimpleType.STRING), str: type_models.LiteralType(simple=type_models.SimpleType.STRING), } + return _SUPPORTED_TYPES + + +class DuplicateHandlerError(ValueError): + ... + + +class StructuredDatasetTransformerEngine(TypeTransformer[StructuredDataset]): + """ + Think of this transformer as a higher-level meta transformer that is used for all the dataframe types. + If you are bringing a custom data frame type, or any data frame type, to flytekit, instead of + registering with the main type engine, you should register with this transformer instead. + """ ENCODERS: Dict[Type, Dict[str, Dict[str, StructuredDatasetEncoder]]] = {} DECODERS: Dict[Type, Dict[str, Dict[str, StructuredDatasetDecoder]]] = {} @@ -337,42 +344,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): @@ -437,18 +456,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}") @@ -471,8 +484,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( @@ -543,13 +555,15 @@ 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}" ) return Literal(scalar=Scalar(structured_dataset=python_val._literal_sd)) - # 2. A task returns a python StructuredDataset with a uri. + # 2. A task returns a python StructuredDataset with an uri. # Note: this case is also what happens we start a local execution of a task with a python StructuredDataset. # It gets converted into a literal first, then back into a python StructuredDataset. # @@ -594,7 +608,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}" ) @@ -623,7 +637,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 @@ -770,7 +787,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,8 +800,8 @@ def iter_as( sd: literals.StructuredDataset, df_type: Type[DF], updated_metadata: StructuredDatasetMetadata, - ) -> Generator[DF, None, None]: - protocol = protocol_prefix(sd.uri) + ) -> typing.Iterator[DF]: + protocol = get_protocol(sd.uri) decoder = self.DECODERS[df_type][protocol][sd.metadata.structured_dataset_type.format] result = decoder.decode(ctx, sd, updated_metadata) if not isinstance(result, types.GeneratorType): @@ -792,8 +809,8 @@ def iter_as( return result def _get_dataset_column_literal_type(self, t: Type) -> type_models.LiteralType: - if t in self._SUPPORTED_TYPES: - return self._SUPPORTED_TYPES[t] + if t in get_supported_types(): + return get_supported_types()[t] if hasattr(t, "__origin__") and t.__origin__ == list: return type_models.LiteralType(collection_type=self._get_dataset_column_literal_type(t.__args__[0])) if hasattr(t, "__origin__") and t.__origin__ == dict: diff --git a/plugins/flytekit-aws-sagemaker/requirements.txt b/plugins/flytekit-aws-sagemaker/requirements.txt index 64d03c18f2..37771df5ce 100644 --- a/plugins/flytekit-aws-sagemaker/requirements.txt +++ b/plugins/flytekit-aws-sagemaker/requirements.txt @@ -46,7 +46,8 @@ cryptography==39.0.1 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 @@ -152,8 +153,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 @@ -193,8 +192,8 @@ requests==2.28.2 # 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 9be6800e49..ade15df0e8 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.1.0b0,<1.3.0,<2.0.0", "sagemaker-training>=3.6.2,<4.0.0"] +plugin_requires = ["flytekit>=1.1.0b0,<1.3.0,<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-bigquery/flytekitplugins/bigquery/__init__.py b/plugins/flytekit-bigquery/flytekitplugins/bigquery/__init__.py index cba899669b..416a021516 100644 --- a/plugins/flytekit-bigquery/flytekitplugins/bigquery/__init__.py +++ b/plugins/flytekit-bigquery/flytekitplugins/bigquery/__init__.py @@ -11,4 +11,5 @@ BigQueryTask """ +from .backend_plugin import BigQueryPlugin from .task import BigQueryConfig, BigQueryTask diff --git a/plugins/flytekit-bigquery/flytekitplugins/bigquery/backend_plugin.py b/plugins/flytekit-bigquery/flytekitplugins/bigquery/backend_plugin.py new file mode 100644 index 0000000000..acd5ece430 --- /dev/null +++ b/plugins/flytekit-bigquery/flytekitplugins/bigquery/backend_plugin.py @@ -0,0 +1,94 @@ +import datetime +from typing import Dict, Optional + +import grpc +from flyteidl.service.external_plugin_service_pb2 import ( + SUCCEEDED, + TaskCreateResponse, + TaskDeleteResponse, + TaskGetResponse, +) +from google.cloud import bigquery + +from flytekit import FlyteContextManager, StructuredDataset, logger +from flytekit.core.type_engine import TypeEngine +from flytekit.extend.backend.base_plugin import BackendPluginBase, BackendPluginRegistry, convert_to_flyte_state +from flytekit.models import literals +from flytekit.models.literals import LiteralMap +from flytekit.models.task import TaskTemplate +from flytekit.models.types import LiteralType, StructuredDatasetType + +pythonTypeToBigQueryType: Dict[type, str] = { + # https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#data_type_sizes + list: "ARRAY", + bool: "BOOL", + bytes: "BYTES", + datetime.datetime: "DATETIME", + float: "FLOAT64", + int: "INT64", + str: "STRING", +} + + +class BigQueryPlugin(BackendPluginBase): + def __init__(self): + super().__init__(task_type="bigquery_query_job_task") + + def create( + self, + context: grpc.ServicerContext, + output_prefix: str, + task_template: TaskTemplate, + inputs: Optional[LiteralMap] = None, + ) -> TaskCreateResponse: + job_config = None + if inputs: + ctx = FlyteContextManager.current_context() + python_interface_inputs = { + name: TypeEngine.guess_python_type(lt.type) for name, lt in task_template.interface.inputs.items() + } + native_inputs = TypeEngine.literal_map_to_kwargs(ctx, inputs, python_interface_inputs) + + logger.info(f"Create BigQuery job config with inputs: {native_inputs}") + job_config = bigquery.QueryJobConfig( + query_parameters=[ + bigquery.ScalarQueryParameter(name, pythonTypeToBigQueryType[python_interface_inputs[name]], val) + for name, val in native_inputs.items() + ] + ) + + custom = task_template.custom + client = bigquery.Client(project=custom["ProjectID"], location=custom["Location"]) + query_job = client.query(task_template.sql.statement, job_config=job_config) + + return TaskCreateResponse(job_id=str(query_job.job_id)) + + def get(self, context: grpc.ServicerContext, job_id: str) -> TaskGetResponse: + client = bigquery.Client() + job = client.get_job(job_id) + cur_state = convert_to_flyte_state(str(job.state)) + res = None + + if cur_state == SUCCEEDED: + ctx = FlyteContextManager.current_context() + output_location = f"bq://{job.destination.project}:{job.destination.dataset_id}.{job.destination.table_id}" + res = literals.LiteralMap( + { + "results": TypeEngine.to_literal( + ctx, + StructuredDataset(uri=output_location), + StructuredDataset, + LiteralType(structured_dataset_type=StructuredDatasetType(format="")), + ) + } + ) + + return TaskGetResponse(state=cur_state, outputs=res.to_flyte_idl()) + + def delete(self, context: grpc.ServicerContext, job_id: str) -> TaskDeleteResponse: + client = bigquery.Client() + client.cancel_job(job_id) + return TaskDeleteResponse() + + +BackendPluginRegistry.register(BigQueryPlugin()) diff --git a/plugins/flytekit-bigquery/flytekitplugins/bigquery/task.py b/plugins/flytekit-bigquery/flytekitplugins/bigquery/task.py index 1d4a7f0dbd..7c24e9e3e9 100644 --- a/plugins/flytekit-bigquery/flytekitplugins/bigquery/task.py +++ b/plugins/flytekit-bigquery/flytekitplugins/bigquery/task.py @@ -5,10 +5,10 @@ from google.protobuf import json_format from google.protobuf.struct_pb2 import Struct -from flytekit import StructuredDataset from flytekit.configuration import SerializationSettings from flytekit.extend import SQLTask from flytekit.models import task as _task_model +from flytekit.types.structured import StructuredDataset @dataclass @@ -81,3 +81,6 @@ def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: def get_sql(self, settings: SerializationSettings) -> Optional[_task_model.Sql]: sql = _task_model.Sql(statement=self.query_template, dialect=_task_model.Sql.Dialect.ANSI) return sql + + def execute(self, **kwargs) -> Any: + raise Exception("Cannot run a SQL Task natively, please mock.") diff --git a/plugins/flytekit-bigquery/requirements.txt b/plugins/flytekit-bigquery/requirements.txt index a9bacca60d..c89afe3cc6 100644 --- a/plugins/flytekit-bigquery/requirements.txt +++ b/plugins/flytekit-bigquery/requirements.txt @@ -48,9 +48,9 @@ docker-image-py==0.1.12 # via flytekit docstring-parser==0.15 # via flytekit -flyteidl==1.2.9 +flyteidl==1.2.10 # via flytekit -flytekit==1.2.7 +flytekit==1.2.9 # via flytekitplugins-bigquery google-api-core[grpc]==2.11.0 # via diff --git a/plugins/flytekit-bigquery/setup.py b/plugins/flytekit-bigquery/setup.py index 88f77429a2..4bb161301a 100644 --- a/plugins/flytekit-bigquery/setup.py +++ b/plugins/flytekit-bigquery/setup.py @@ -4,7 +4,7 @@ microlib_name = f"flytekitplugins-{PLUGIN_NAME}" -plugin_requires = ["flytekit>=1.1.0b0,<1.3.0,<2.0.0", "google-cloud-bigquery"] +plugin_requires = ["flytekit>=1.1.0b0,<1.3.0,<2.0.0", "google-cloud-bigquery", "flyteidl>=1.2.10,<1.3.0"] __version__ = "0.0.0+develop" @@ -33,4 +33,5 @@ "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-bigquery/tests/test_backend_plugin.py b/plugins/flytekit-bigquery/tests/test_backend_plugin.py new file mode 100644 index 0000000000..c95cf308a7 --- /dev/null +++ b/plugins/flytekit-bigquery/tests/test_backend_plugin.py @@ -0,0 +1,94 @@ +from datetime import timedelta +from unittest import mock +from unittest.mock import MagicMock + +import grpc +from flyteidl.service.external_plugin_service_pb2 import SUCCEEDED + +import flytekit.models.interface as interface_models +from flytekit.extend.backend.base_plugin import BackendPluginRegistry +from flytekit.interfaces.cli_identifiers import Identifier +from flytekit.models import literals, task, types +from flytekit.models.core.identifier import ResourceType +from flytekit.models.task import Sql, TaskTemplate + + +@mock.patch("google.cloud.bigquery.job.QueryJob") +@mock.patch("google.cloud.bigquery.Client") +def test_bigquery_plugin(mock_client, mock_query_job): + job_id = "dummy_id" + mock_instance = mock_client.return_value + mock_query_job_instance = mock_query_job.return_value + mock_query_job_instance.state.return_value = "SUCCEEDED" + mock_query_job_instance.job_id.return_value = job_id + + class MockDestination: + def __init__(self): + self.project = "dummy_project" + self.dataset_id = "dummy_dataset" + self.table_id = "dummy_table" + + class MockJob: + def __init__(self): + self.state = "SUCCEEDED" + self.job_id = job_id + self.destination = MockDestination() + + mock_instance.get_job.return_value = MockJob() + mock_instance.query.return_value = MockJob() + mock_instance.cancel_job.return_value = MockJob() + + ctx = MagicMock(spec=grpc.ServicerContext) + p = BackendPluginRegistry.get_plugin(ctx, "bigquery_query_job_task") + + task_id = Identifier( + resource_type=ResourceType.TASK, project="project", domain="domain", name="name", version="version" + ) + task_metadata = task.TaskMetadata( + True, + task.RuntimeMetadata(task.RuntimeMetadata.RuntimeType.FLYTE_SDK, "1.0.0", "python"), + timedelta(days=1), + literals.RetryStrategy(3), + True, + "0.1.1b0", + "This is deprecated!", + True, + "A", + ) + task_config = { + "Location": "us-central1", + "ProjectID": "dummy_project", + } + + int_type = types.LiteralType(types.SimpleType.INTEGER) + interfaces = interface_models.TypedInterface( + { + "a": interface_models.Variable(int_type, "description1"), + "b": interface_models.Variable(int_type, "description2"), + }, + {}, + ) + task_inputs = literals.LiteralMap( + { + "a": literals.Literal(scalar=literals.Scalar(primitive=literals.Primitive(integer=1))), + "b": literals.Literal(scalar=literals.Scalar(primitive=literals.Primitive(integer=1))), + }, + ) + + dummy_template = TaskTemplate( + id=task_id, + custom=task_config, + metadata=task_metadata, + interface=interfaces, + type="bigquery_query_job_task", + sql=Sql("SELECT 1"), + ) + + assert p.create(ctx, "/tmp", dummy_template, task_inputs).job_id == job_id + res = p.get(ctx, job_id) + assert res.state == SUCCEEDED + assert ( + res.outputs.literals["results"].scalar.structured_dataset.uri == "bq://dummy_project:dummy_dataset.dummy_table" + ) + p.delete(ctx, job_id) + mock_instance.cancel_job.assert_called() 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 5e6712f396..7102e4fb5f 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.1.0b0,<1.3.0,<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.7", @@ -42,5 +42,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-deck-standard/flytekitplugins/deck/renderer.py b/plugins/flytekit-deck-standard/flytekitplugins/deck/renderer.py index d2b44e0b65..c090ea6a46 100644 --- a/plugins/flytekit-deck-standard/flytekitplugins/deck/renderer.py +++ b/plugins/flytekit-deck-standard/flytekitplugins/deck/renderer.py @@ -1,7 +1,18 @@ -import markdown -import pandas -import plotly.express as px -from pandas_profiling import ProfileReport +from typing import TYPE_CHECKING, List, Optional, Union + +from flytekit import lazy_module +from flytekit.types.file import FlyteFile + +if TYPE_CHECKING: + import markdown + import pandas as pd + import PIL + import plotly.express as px +else: + pd = lazy_module("pandas") + markdown = lazy_module("markdown") + px = lazy_module("plotly.express") + PIL = lazy_module("PIL") class FrameProfilingRenderer: @@ -12,9 +23,11 @@ class FrameProfilingRenderer: def __init__(self, title: str = "Pandas Profiling Report"): self._title = title - def to_html(self, df: pandas.DataFrame) -> str: - assert isinstance(df, pandas.DataFrame) - profile = ProfileReport(df, title=self._title) + def to_html(self, df: "pd.DataFrame") -> str: + assert isinstance(df, pd.DataFrame) + import ydata_profiling + + profile = ydata_profiling.ProfileReport(df, title=self._title) return profile.to_html() @@ -37,7 +50,7 @@ class BoxRenderer: Each box spans from quartile 1 (Q1) to quartile 3 (Q3). The second quartile (Q2) is marked by a line inside the box. By default, the - whiskers correspond to the box' edges +/- 1.5 times the interquartile + whiskers correspond to the box edges +/- 1.5 times the interquartile range (IQR: Q3-Q1), see "points" for other options. """ @@ -45,6 +58,116 @@ class BoxRenderer: def __init__(self, column_name): self._column_name = column_name - def to_html(self, df: pandas.DataFrame) -> str: + def to_html(self, df: "pd.DataFrame") -> str: fig = px.box(df, y=self._column_name) return fig.to_html() + + +class ImageRenderer: + """Converts a FlyteFile or PIL.Image.Image object to an HTML string with the image data + represented as a base64-encoded string. + """ + + def to_html(self, image_src: Union[FlyteFile, "PIL.Image.Image"]) -> str: + img = self._get_image_object(image_src) + return self._image_to_html_string(img) + + @staticmethod + def _get_image_object(image_src: Union[FlyteFile, "PIL.Image.Image"]) -> "PIL.Image.Image": + if isinstance(image_src, FlyteFile): + local_path = image_src.download() + return PIL.Image.open(local_path) + elif isinstance(image_src, PIL.Image.Image): + return image_src + else: + raise ValueError("Unsupported image source type") + + @staticmethod + def _image_to_html_string(img: "PIL.Image.Image") -> str: + import base64 + from io import BytesIO + + buffered = BytesIO() + img.save(buffered, format="PNG") + img_base64 = base64.b64encode(buffered.getvalue()).decode() + return f'Rendered Image' + + +class TableRenderer: + """ + Convert a pandas DataFrame into an HTML table. + """ + + def to_html(self, df: pd.DataFrame, header_labels: Optional[List] = None, table_width: Optional[int] = None) -> str: + # Check if custom labels are provided and have the correct length + if header_labels is not None and len(header_labels) == len(df.columns): + df = df.copy() + df.columns = header_labels + + style = f""" + + """ + return style + df.to_html(classes="table-class", index=False) + + +class GanttChartRenderer: + """ + This renderer is primarily used by the timeline deck. The input DataFrame should + have at least the following columns: + - "Start": datetime.datetime (represents the start time) + - "Finish": datetime.datetime (represents the end time) + - "Name": string (the name of the task or event) + """ + + def to_html(self, df: pd.DataFrame, chart_width: Optional[int] = None) -> str: + fig = px.timeline(df, x_start="Start", x_end="Finish", y="Name", color="Name", width=chart_width) + + fig.update_xaxes( + tickangle=90, + rangeslider_visible=True, + tickformatstops=[ + dict(dtickrange=[None, 1], value="%3f ms"), + dict(dtickrange=[1, 60], value="%S:%3f s"), + dict(dtickrange=[60, 3600], value="%M:%S m"), + dict(dtickrange=[3600, None], value="%H:%M h"), + ], + ) + + # Remove y-axis tick labels and title since the time line deck space is limited. + fig.update_yaxes(showticklabels=False, title="") + + fig.update_layout( + autosize=True, + # Set the orientation of the legend to horizontal and move the legend anchor 2% beyond the top of the timeline graph's vertical axis + legend=dict(orientation="h", y=1.02), + ) + + return fig.to_html() diff --git a/plugins/flytekit-deck-standard/tests/test_renderer.py b/plugins/flytekit-deck-standard/tests/test_renderer.py index 79eb7e877d..1878193733 100644 --- a/plugins/flytekit-deck-standard/tests/test_renderer.py +++ b/plugins/flytekit-deck-standard/tests/test_renderer.py @@ -1,8 +1,33 @@ +import datetime +import tempfile + import markdown import pandas as pd -from flytekitplugins.deck.renderer import BoxRenderer, FrameProfilingRenderer, MarkdownRenderer +import pytest +from flytekitplugins.deck.renderer import ( + BoxRenderer, + FrameProfilingRenderer, + GanttChartRenderer, + ImageRenderer, + MarkdownRenderer, + TableRenderer, +) +from PIL import Image + +from flytekit.types.file import FlyteFile, JPEGImageFile, PNGImageFile df = pd.DataFrame({"Name": ["Tom", "Joseph"], "Age": [1, 22]}) +time_info_df = pd.DataFrame( + [ + dict( + Name="foo", + Start=datetime.datetime.utcnow(), + Finish=datetime.datetime.utcnow() + datetime.timedelta(microseconds=1000), + WallTime=1.0, + ProcessTime=1.0, + ) + ] +) def test_frame_profiling_renderer(): @@ -19,3 +44,39 @@ def test_markdown_renderer(): def test_box_renderer(): renderer = BoxRenderer("Name") assert "Plotlyconfig = {Mathjaxconfig: 'Local'}" in renderer.to_html(df).title() + + +def create_simple_image(fmt: str): + """Create a simple PNG image using PIL""" + img = Image.new("RGB", (100, 100), color="black") + tmp = tempfile.mktemp() + img.save(tmp, fmt) + return tmp + + +png_image = create_simple_image(fmt="png") +jpeg_image = create_simple_image(fmt="jpeg") + + +@pytest.mark.parametrize( + "image_src", + [ + FlyteFile(path=png_image), + JPEGImageFile(path=jpeg_image), + PNGImageFile(path=png_image), + Image.open(png_image), + ], +) +def test_image_renderer(image_src): + renderer = ImageRenderer() + assert " str: +# return "hello" +``` diff --git a/plugins/flytekit-envd/flytekitplugins/envd/__init__.py b/plugins/flytekit-envd/flytekitplugins/envd/__init__.py new file mode 100644 index 0000000000..d3dec806a1 --- /dev/null +++ b/plugins/flytekit-envd/flytekitplugins/envd/__init__.py @@ -0,0 +1,13 @@ +""" +.. currentmodule:: flytekitplugins.envd + +This plugin enables seamless integration between Flyte and envd. + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + EnvdImageSpecBuilder +""" + +from .image_builder import EnvdImageSpecBuilder diff --git a/plugins/flytekit-envd/flytekitplugins/envd/image_builder.py b/plugins/flytekit-envd/flytekitplugins/envd/image_builder.py new file mode 100644 index 0000000000..fec6647443 --- /dev/null +++ b/plugins/flytekit-envd/flytekitplugins/envd/image_builder.py @@ -0,0 +1,73 @@ +import pathlib +import shutil +import subprocess + +import click + +from flytekit.configuration import DefaultImages +from flytekit.core import context_manager +from flytekit.image_spec.image_spec import _F_IMG_ID, ImageBuildEngine, ImageSpec, ImageSpecBuilder + + +class EnvdImageSpecBuilder(ImageSpecBuilder): + """ + This class is used to build a docker image using envd. + """ + + def build_image(self, image_spec: ImageSpec): + cfg_path = create_envd_config(image_spec) + command = f"envd build --path {pathlib.Path(cfg_path).parent}" + if image_spec.registry: + command += f" --output type=image,name={image_spec.image_name()},push=true" + click.secho(f"Run command: {command} ", fg="blue") + p = subprocess.Popen(command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE) + for line in iter(p.stdout.readline, ""): + if p.poll() is not None: + break + if line.decode().strip() != "": + click.secho(line.decode().strip(), fg="blue") + + if p.returncode != 0: + _, stderr = p.communicate() + raise Exception( + f"failed to build the imageSpec at {cfg_path} with error {stderr}", + ) + + +def create_envd_config(image_spec: ImageSpec) -> str: + base_image = DefaultImages.default_image() if image_spec.base_image is None else image_spec.base_image + packages = [] if image_spec.packages is None else image_spec.packages + apt_packages = [] if image_spec.apt_packages is None else image_spec.apt_packages + env = {"PYTHONPATH": "/root", _F_IMG_ID: image_spec.image_name()} + if image_spec.env: + env.update(image_spec.env) + + envd_config = f"""# syntax=v1 + +def build(): + base(image="{base_image}", dev=False) + install.python_packages(name = [{', '.join(map(str, map(lambda x: f'"{x}"', packages)))}]) + install.apt_packages(name = [{', '.join(map(str, map(lambda x: f'"{x}"', apt_packages)))}]) + runtime.environ(env={env}) +""" + + if image_spec.python_version: + # Indentation is required by envd + envd_config += f' install.python(version="{image_spec.python_version}")\n' + + ctx = context_manager.FlyteContextManager.current_context() + cfg_path = ctx.file_access.get_random_local_path("build.envd") + pathlib.Path(cfg_path).parent.mkdir(parents=True, exist_ok=True) + + if image_spec.source_root: + shutil.copytree(image_spec.source_root, pathlib.Path(cfg_path).parent, dirs_exist_ok=True) + # Indentation is required by envd + envd_config += ' io.copy(host_path="./", envd_path="/root")' + + with open(cfg_path, "w+") as f: + f.write(envd_config) + + return cfg_path + + +ImageBuildEngine.register("envd", EnvdImageSpecBuilder()) diff --git a/plugins/flytekit-envd/requirements.in b/plugins/flytekit-envd/requirements.in new file mode 100644 index 0000000000..16b527ba7e --- /dev/null +++ b/plugins/flytekit-envd/requirements.in @@ -0,0 +1,2 @@ +. +-e file:.#egg=flytekitplugins-envd diff --git a/plugins/flytekit-envd/requirements.txt b/plugins/flytekit-envd/requirements.txt new file mode 100644 index 0000000000..78e9a287ca --- /dev/null +++ b/plugins/flytekit-envd/requirements.txt @@ -0,0 +1,229 @@ +# +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: +# +# pip-compile requirements.in +# +-e file:.#egg=flytekitplugins-envd + # via -r requirements.in +arrow==1.2.3 + # via jinja2-time +binaryornot==0.4.4 + # via cookiecutter +cachetools==5.3.0 + # via google-auth +certifi==2022.12.7 + # via + # kubernetes + # requests +cffi==1.15.1 + # via cryptography +chardet==5.1.0 + # via binaryornot +charset-normalizer==3.1.0 + # via requests +click==8.1.3 + # via + # cookiecutter + # flytekit +cloudpickle==2.2.1 + # via flytekit +cookiecutter==2.1.1 + # via flytekit +croniter==1.3.8 + # via flytekit +cryptography==40.0.1 + # 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 +envd==0.3.16 + # via flytekitplugins-envd +flyteidl==1.3.15 + # via flytekit +flytekit==1.5.0 + # via flytekitplugins-envd +gitdb==4.0.10 + # via gitpython +gitpython==3.1.31 + # via flytekit +google-auth==2.17.1 + # via kubernetes +googleapis-common-protos==1.59.0 + # via + # flyteidl + # flytekit + # grpcio-status +grpcio==1.53.0 + # via + # flytekit + # grpcio-status +grpcio-status==1.53.0 + # via flytekit +idna==3.4 + # via requests +importlib-metadata==6.1.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 +kubernetes==26.1.0 + # via flytekit +markupsafe==2.1.2 + # 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.1.0 + # via jaraco-classes +mypy-extensions==1.0.0 + # via typing-inspect +natsort==8.3.1 + # via flytekit +numpy==1.23.5 + # via + # flytekit + # pandas + # pyarrow +oauthlib==3.2.2 + # via requests-oauthlib +packaging==23.0 + # via + # docker + # marshmallow +pandas==1.5.3 + # via flytekit +protobuf==4.22.1 + # 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 +pyasn1==0.4.8 + # via + # pyasn1-modules + # rsa +pyasn1-modules==0.2.8 + # via google-auth +pycparser==2.21 + # via cffi +pyopenssl==23.1.1 + # via flytekit +python-dateutil==2.8.2 + # via + # arrow + # croniter + # flytekit + # kubernetes + # pandas +python-json-logger==2.0.7 + # via flytekit +python-slugify==8.0.1 + # via cookiecutter +pytimeparse==1.1.8 + # via flytekit +pytz==2023.3 + # via + # flytekit + # pandas +pyyaml==6.0 + # via + # cookiecutter + # flytekit + # kubernetes + # responses +regex==2023.3.23 + # via docker-image-py +requests==2.28.2 + # via + # cookiecutter + # docker + # flytekit + # kubernetes + # requests-oauthlib + # responses +requests-oauthlib==1.3.1 + # via kubernetes +responses==0.23.1 + # via flytekit +retry==0.9.2 + # via flytekit +rsa==4.9 + # via google-auth +six==1.16.0 + # via + # google-auth + # kubernetes + # python-dateutil +smmap==5.0.0 + # via gitdb +sortedcontainers==2.4.0 + # via flytekit +statsd==3.3.0 + # via flytekit +text-unidecode==1.3 + # via python-slugify +types-pyyaml==6.0.12.9 + # via responses +typing-extensions==4.5.0 + # via + # flytekit + # typing-inspect +typing-inspect==0.8.0 + # via dataclasses-json +urllib3==1.26.15 + # via + # docker + # flytekit + # kubernetes + # requests + # responses +websocket-client==1.5.1 + # via + # docker + # kubernetes +wheel==0.40.0 + # via flytekit +wrapt==1.15.0 + # via + # deprecated + # flytekit +zipp==3.15.0 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/plugins/flytekit-envd/setup.py b/plugins/flytekit-envd/setup.py new file mode 100644 index 0000000000..d95a260958 --- /dev/null +++ b/plugins/flytekit-envd/setup.py @@ -0,0 +1,37 @@ +from setuptools import setup + +PLUGIN_NAME = "envd" + +microlib_name = f"flytekitplugins-{PLUGIN_NAME}" + +plugin_requires = ["flytekit", "envd"] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + description="This package enables users to easily build a Docker image for tasks or workflows.", + namespace_packages=["flytekitplugins"], + packages=[f"flytekitplugins.{PLUGIN_NAME}"], + install_requires=plugin_requires, + license="apache2", + 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", + "Programming Language :: Python :: 3.11", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development", + "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-envd/tests/__init__.py b/plugins/flytekit-envd/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/flytekit-envd/tests/test_image_spec.py b/plugins/flytekit-envd/tests/test_image_spec.py new file mode 100644 index 0000000000..7c7ccd2151 --- /dev/null +++ b/plugins/flytekit-envd/tests/test_image_spec.py @@ -0,0 +1,31 @@ +from pathlib import Path + +from flytekitplugins.envd.image_builder import EnvdImageSpecBuilder, create_envd_config + +from flytekit.image_spec.image_spec import ImageSpec + + +def test_image_spec(): + image_spec = ImageSpec( + packages=["pandas"], + apt_packages=["git"], + python_version="3.8", + registry="", + base_image="cr.flyte.org/flyteorg/flytekit:py3.8-latest", + ) + + EnvdImageSpecBuilder().build_image(image_spec) + config_path = create_envd_config(image_spec) + contents = Path(config_path).read_text() + assert ( + contents + == """# syntax=v1 + +def build(): + base(image="cr.flyte.org/flyteorg/flytekit:py3.8-latest", dev=False) + install.python_packages(name = ["pandas"]) + install.apt_packages(name = ["git"]) + runtime.environ(env={'PYTHONPATH': '/root', '_F_IMG_ID': 'flytekit:yZ8jICcDTLoDArmNHbWNwg..'}) + install.python(version="3.8") +""" + ) diff --git a/plugins/flytekit-k8s-pod/tests/test_pod.py b/plugins/flytekit-k8s-pod/tests/test_pod.py index 0d6788ac92..014b88f4f3 100644 --- a/plugins/flytekit-k8s-pod/tests/test_pod.py +++ b/plugins/flytekit-k8s-pod/tests/test_pod.py @@ -355,8 +355,12 @@ def simple_pod_task(i: int): "--prev-checkpoint", "{{.prevCheckpointPrefix}}", "--resolver", - "flytekit.core.python_auto_container.default_task_resolver", + "MapTaskResolver", "--", + "vars", + "", + "resolver", + "flytekit.core.python_auto_container.default_task_resolver", "task-module", "tests.test_pod", "task-name", diff --git a/plugins/flytekit-kf-mpi/flytekitplugins/kfmpi/__init__.py b/plugins/flytekit-kf-mpi/flytekitplugins/kfmpi/__init__.py index 6920c34e84..df5c74288e 100644 --- a/plugins/flytekit-kf-mpi/flytekitplugins/kfmpi/__init__.py +++ b/plugins/flytekit-kf-mpi/flytekitplugins/kfmpi/__init__.py @@ -10,4 +10,4 @@ MPIJob """ -from .task import MPIJob +from .task import HorovodJob, MPIJob diff --git a/plugins/flytekit-kf-mpi/flytekitplugins/kfmpi/task.py b/plugins/flytekit-kf-mpi/flytekitplugins/kfmpi/task.py index 6f207b421d..e1c1be0a03 100644 --- a/plugins/flytekit-kf-mpi/flytekitplugins/kfmpi/task.py +++ b/plugins/flytekit-kf-mpi/flytekitplugins/kfmpi/task.py @@ -133,5 +133,62 @@ def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: return MessageToDict(job.to_flyte_idl()) +@dataclass +class HorovodJob(object): + slots: int + num_launcher_replicas: int = 1 + num_workers: int = 1 + + +class HorovodFunctionTask(MPIFunctionTask): + """ + For more info, check out https://github.com/horovod/horovod + """ + + # Customize your setup here. Please ensure the cmd, path, volume, etc are available in the pod. + ssh_command = "/usr/sbin/sshd -De -f /home/jobuser/.sshd_config" + discovery_script_path = "/etc/mpi/discover_hosts.sh" + + def __init__(self, task_config: HorovodJob, task_function: Callable, **kwargs): + + super().__init__( + task_config=task_config, + task_function=task_function, + **kwargs, + ) + + def get_command(self, settings: SerializationSettings) -> List[str]: + cmd = super().get_command(settings) + mpi_cmd = self._get_horovod_prefix() + cmd + return mpi_cmd + + def get_config(self, settings: SerializationSettings) -> Dict[str, str]: + config = super().get_config(settings) + return {**config, "worker_spec_command": self.ssh_command} + + def _get_horovod_prefix(self) -> List[str]: + np = self.task_config.num_workers * self.task_config.slots + base_cmd = [ + "horovodrun", + "-np", + f"{np}", + "--verbose", + "--log-level", + "INFO", + "--network-interface", + "eth0", + "--min-np", + f"{np}", + "--max-np", + f"{np}", + "--slots-per-host", + f"{self.task_config.slots}", + "--host-discovery-script", + self.discovery_script_path, + ] + return base_cmd + + # Register the MPI Plugin into the flytekit core plugin system TaskPlugins.register_pythontask_plugin(MPIJob, MPIFunctionTask) +TaskPlugins.register_pythontask_plugin(HorovodJob, HorovodFunctionTask) diff --git a/plugins/flytekit-kf-mpi/tests/test_mpi_task.py b/plugins/flytekit-kf-mpi/tests/test_mpi_task.py index ebb0c49b58..7732d520c2 100644 --- a/plugins/flytekit-kf-mpi/tests/test_mpi_task.py +++ b/plugins/flytekit-kf-mpi/tests/test_mpi_task.py @@ -1,4 +1,4 @@ -from flytekitplugins.kfmpi.task import MPIJob, MPIJobModel +from flytekitplugins.kfmpi.task import HorovodJob, MPIJob, MPIJobModel from flytekit import Resources, task from flytekit.configuration import Image, ImageConfig, SerializationSettings @@ -41,3 +41,26 @@ def my_mpi_task(x: int, y: str) -> int: assert my_mpi_task.get_custom(settings) == {"numLauncherReplicas": 10, "numWorkers": 10, "slots": 1} assert my_mpi_task.task_type == "mpi" + + +def test_horovod_task(): + @task( + task_config=HorovodJob(num_workers=5, num_launcher_replicas=5, slots=1), + ) + def my_horovod_task(): + ... + + default_img = Image(name="default", fqn="test", tag="tag") + settings = SerializationSettings( + project="project", + domain="domain", + version="version", + env={"FOO": "baz"}, + image_config=ImageConfig(default_image=default_img, images=[default_img]), + ) + cmd = my_horovod_task.get_command(settings) + assert "horovodrun" in cmd + config = my_horovod_task.get_config(settings) + assert "/usr/sbin/sshd" in config["worker_spec_command"] + custom = my_horovod_task.get_custom(settings) + assert isinstance(custom, dict) is True diff --git a/plugins/flytekit-kf-pytorch/README.md b/plugins/flytekit-kf-pytorch/README.md index 280fe687b6..7de27502bf 100644 --- a/plugins/flytekit-kf-pytorch/README.md +++ b/plugins/flytekit-kf-pytorch/README.md @@ -2,6 +2,9 @@ This plugin uses the Kubeflow PyTorch Operator and provides an extremely simplified interface for executing distributed training using various PyTorch backends. +This plugin can execute torch elastic training, which is equivalent to run `torchrun`. Elastic training can be executed +in a single Pod (without requiring the PyTorch operator, see below) as well as in a distributed multi-node manner. + To install the plugin, run the following command: ```bash diff --git a/plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/__init__.py b/plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/__init__.py index aedb0b192f..cb9add7302 100644 --- a/plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/__init__.py +++ b/plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/__init__.py @@ -8,6 +8,7 @@ :toctree: generated/ PyTorch + Elastic """ -from .task import PyTorch +from .task import Elastic, PyTorch diff --git a/plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/models.py b/plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/models.py deleted file mode 100644 index 517f4a9eb6..0000000000 --- a/plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/models.py +++ /dev/null @@ -1,23 +0,0 @@ -from flyteidl.plugins import pytorch_pb2 as _pytorch_task - -from flytekit.models import common as _common - - -class PyTorchJob(_common.FlyteIdlEntity): - def __init__(self, workers_count): - self._workers_count = workers_count - - @property - def workers_count(self): - return self._workers_count - - def to_flyte_idl(self): - return _pytorch_task.DistributedPyTorchTrainingTask( - workers=self.workers_count, - ) - - @classmethod - def from_flyte_idl(cls, pb2_object): - return cls( - workers_count=pb2_object.workers, - ) diff --git a/plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/task.py b/plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/task.py index 4b0bde78b0..aea2c9a2e6 100644 --- a/plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/task.py +++ b/plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/task.py @@ -2,16 +2,20 @@ This Plugin adds the capability of running distributed pytorch training to Flyte using backend plugins, natively on Kubernetes. It leverages `Pytorch Job `_ Plugin from kubeflow. """ +import os from dataclasses import dataclass -from typing import Any, Callable, Dict +from typing import Any, Callable, Dict, Optional, Union +import cloudpickle +from flyteidl.plugins.pytorch_pb2 import DistributedPyTorchTrainingTask from google.protobuf.json_format import MessageToDict +import flytekit from flytekit import PythonFunctionTask from flytekit.configuration import SerializationSettings -from flytekit.extend import TaskPlugins +from flytekit.extend import IgnoreOutputs, TaskPlugins -from .models import PyTorchJob +TORCH_IMPORT_ERROR_MESSAGE = "PyTorch is not installed. Please install `flytekitplugins-kfpytorch['elastic']`." @dataclass @@ -29,6 +33,31 @@ class PyTorch(object): num_workers: int +@dataclass +class Elastic(object): + """ + Configuration for `torch elastic training `_. + + Use this to run single- or multi-node distributed pytorch elastic training on k8s. + + Single-node elastic training is executed in a k8s pod when `nnodes` is set to 1. + Multi-node training is executed otherwise using a `Pytorch Job `_. + + Args: + nnodes (Union[int, str]): Number of nodes, or the range of nodes in form :. + nproc_per_node (Union[int, str]): Number of workers per node. Supported values are [auto, cpu, gpu, int]. + start_method (str): Multiprocessing start method to use when creating workers. + monitor_interval (int): Interval, in seconds, to monitor the state of workers. + max_restarts (int): Maximum number of worker group restarts before failing. + """ + + nnodes: Union[int, str] = 1 + nproc_per_node: Union[int, str] = "auto" + start_method: str = "spawn" + monitor_interval: int = 5 + max_restarts: int = 0 + + class PyTorchFunctionTask(PythonFunctionTask[PyTorch]): """ Plugin that submits a PyTorchJob (see https://github.com/kubeflow/pytorch-operator) @@ -46,9 +75,173 @@ def __init__(self, task_config: PyTorch, task_function: Callable, **kwargs): ) def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: - job = PyTorchJob(workers_count=self.task_config.num_workers) - return MessageToDict(job.to_flyte_idl()) + job = DistributedPyTorchTrainingTask(workers=self.task_config.num_workers) + return MessageToDict(job) # Register the Pytorch Plugin into the flytekit core plugin system TaskPlugins.register_pythontask_plugin(PyTorch, PyTorchFunctionTask) + + +def spawn_helper(fn: bytes, kwargs) -> Any: + """Help to spawn worker processes. + + The purpose of this function is to 1) be pickleable so that it can be used with + the multiprocessing start method `spawn` and 2) to call a cloudpickle-serialized + function passed to it. This function itself doesn't have to be pickleable. Without + such a helper task functions, which are not pickleable, couldn't be used with the + start method `spawn`. + + Args: + fn (bytes): Cloudpickle-serialized target function to be executed in the worker process. + + Returns: + The return value of the received target function. + """ + fn = cloudpickle.loads(fn) + return_val = fn(**kwargs) + return return_val + + +class PytorchElasticFunctionTask(PythonFunctionTask[Elastic]): + """ + Plugin for distributed training with torch elastic/torchrun (see + https://pytorch.org/docs/stable/elastic/run.html). + """ + + _ELASTIC_TASK_TYPE = "pytorch" + _ELASTIC_TASK_TYPE_STANDALONE = "python-task" + + def __init__(self, task_config: Elastic, task_function: Callable, **kwargs): + task_type = self._ELASTIC_TASK_TYPE_STANDALONE if task_config.nnodes == 1 else self._ELASTIC_TASK_TYPE + + super(PytorchElasticFunctionTask, self).__init__( + task_config=task_config, + task_type=task_type, + task_function=task_function, + **kwargs, + ) + try: + from torch.distributed import run + except ImportError: + raise ImportError(TORCH_IMPORT_ERROR_MESSAGE) + self.min_nodes, self.max_nodes = run.parse_min_max_nnodes(str(self.task_config.nnodes)) + + """ + c10d is the backend recommended by torch elastic. + https://pytorch.org/docs/stable/elastic/run.html#note-on-rendezvous-backend + + For c10d, no backend server has to be deployed. + https://pytorch.org/docs/stable/elastic/run.html#deployment + Instead, the workers will use the master's address as the rendezvous point. + """ + self.rdzv_backend = "c10d" + + def _execute(self, **kwargs) -> Any: + """ + This helper method will be invoked to execute the task. + + + Returns: + The result of rank zero. + """ + try: + from torch.distributed import run + from torch.distributed.launcher.api import LaunchConfig, elastic_launch + except ImportError: + raise ImportError(TORCH_IMPORT_ERROR_MESSAGE) + + if isinstance(self.task_config.nproc_per_node, str): + nproc = run.determine_local_world_size(self.task_config.nproc_per_node) + else: + nproc = self.task_config.nproc_per_node + + config = LaunchConfig( + run_id=flytekit.current_context().execution_id.name, + min_nodes=self.min_nodes, + max_nodes=self.max_nodes, + nproc_per_node=nproc, + rdzv_backend=self.rdzv_backend, # rdzv settings + rdzv_endpoint=os.environ.get("PET_RDZV_ENDPOINT", "localhost:0"), + max_restarts=self.task_config.max_restarts, + monitor_interval=self.task_config.monitor_interval, + start_method=self.task_config.start_method, + ) + + if self.task_config.start_method == "spawn": + """ + We use cloudpickle to serialize the non-pickleable task function. + The torch elastic launcher then launches the spawn_helper function (which is pickleable) + instead of the task function. This helper function, in the child-process, then deserializes + the task function, again with cloudpickle, and executes it. + """ + launcher_target_func = spawn_helper + + dumped_target_function = cloudpickle.dumps(self._task_function) + launcher_args = (dumped_target_function, kwargs) + elif self.task_config.start_method == "fork": + """ + The torch elastic launcher doesn't support passing kwargs to the target function, + only args. Flyte only works with kwargs. Thus, we create a closure which already has + the task kwargs bound. We tell the torch elastic launcher to start this function in + the child processes. + """ + + def fn_partial(): + """Closure of the task function with kwargs already bound.""" + return self._task_function(**kwargs) + + launcher_target_func = fn_partial + launcher_args = () + + else: + raise Exception("Bad start method") + + out = elastic_launch( + config=config, + entrypoint=launcher_target_func, + )(*launcher_args) + + # `out` is a dictionary of rank (not local rank) -> result + # Rank 0 returns the result of the task function + if 0 in out: + return out[0] + else: + raise IgnoreOutputs() + + def execute(self, **kwargs) -> Any: + """ + This method will be invoked to execute the task. + + Handles the exception scope for the `_execute` method. + """ + from flytekit.exceptions import scopes as exception_scopes + + return exception_scopes.user_entry_point(self._execute)(**kwargs) + + def get_custom(self, settings: SerializationSettings) -> Optional[Dict[str, Any]]: + if self.task_config.nnodes == 1: + """ + Torch elastic distributed training is executed in a normal k8s pod so that this + works without the kubeflow train operator. + """ + return super().get_custom(settings) + else: + from flyteidl.plugins.pytorch_pb2 import ElasticConfig + + elastic_config = ElasticConfig( + rdzv_backend=self.rdzv_backend, + min_replicas=self.min_nodes, + max_replicas=self.max_nodes, + nproc_per_node=self.task_config.nproc_per_node, + max_restarts=self.task_config.max_restarts, + ) + job = DistributedPyTorchTrainingTask( + workers=self.max_nodes, + elastic_config=elastic_config, + ) + return MessageToDict(job) + + +# Register the PytorchElastic Plugin into the flytekit core plugin system +TaskPlugins.register_pythontask_plugin(Elastic, PytorchElasticFunctionTask) diff --git a/plugins/flytekit-kf-pytorch/requirements.txt b/plugins/flytekit-kf-pytorch/requirements.txt index 96fa577a3e..9cbdbad5c6 100644 --- a/plugins/flytekit-kf-pytorch/requirements.txt +++ b/plugins/flytekit-kf-pytorch/requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.7 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # pip-compile requirements.in @@ -10,25 +10,31 @@ arrow==1.2.3 # via jinja2-time binaryornot==0.4.4 # via cookiecutter +cachetools==5.3.0 + # via google-auth certifi==2022.12.7 - # via requests + # via + # kubernetes + # requests cffi==1.15.1 # via cryptography chardet==5.1.0 # via binaryornot -charset-normalizer==3.0.1 +charset-normalizer==3.1.0 # via requests click==8.1.3 # via # cookiecutter # flytekit cloudpickle==2.2.1 - # via flytekit + # via + # flytekit + # flytekitplugins-kfpytorch cookiecutter==2.1.1 # via flytekit -croniter==1.3.8 +croniter==1.3.14 # via flytekit -cryptography==39.0.1 +cryptography==40.0.2 # via # pyopenssl # secretstorage @@ -38,7 +44,7 @@ decorator==5.1.1 # via retry deprecated==1.2.13 # via flytekit -diskcache==5.4.0 +diskcache==5.6.1 # via flytekit docker==6.0.1 # via flytekit @@ -46,11 +52,19 @@ docker-image-py==0.1.12 # via flytekit docstring-parser==0.15 # via flytekit -flyteidl==1.2.9 - # via flytekit -flytekit==1.2.7 +flyteidl==1.2.10 + # via + # flytekit + # flytekitplugins-kfpytorch +flytekit==1.2.9 # via flytekitplugins-kfpytorch -googleapis-common-protos==1.58.0 +gitdb==4.0.10 + # via gitpython +gitpython==3.1.31 + # via flytekit +google-auth==2.17.3 + # via kubernetes +googleapis-common-protos==1.59.0 # via # flyteidl # grpcio-status @@ -62,13 +76,10 @@ grpcio-status==1.48.2 # via flytekit idna==3.4 # via requests -importlib-metadata==6.0.0 +importlib-metadata==6.6.0 # via - # click # flytekit # keyring -importlib-resources==5.12.0 - # via keyring jaraco-classes==3.2.3 # via keyring jeepney==0.8.0 @@ -85,6 +96,8 @@ joblib==1.2.0 # via flytekit keyring==23.13.1 # via flytekit +kubernetes==26.1.0 + # via flytekit markupsafe==2.1.2 # via jinja2 marshmallow==3.19.0 @@ -102,12 +115,14 @@ mypy-extensions==1.0.0 # via typing-inspect natsort==8.2.0 # via flytekit -numpy==1.21.6 +numpy==1.23.5 # via # flytekit # pandas # pyarrow -packaging==23.0 +oauthlib==3.2.2 + # via requests-oauthlib +packaging==23.1 # via # docker # marshmallow @@ -126,15 +141,22 @@ py==1.11.0 # via retry pyarrow==10.0.1 # via flytekit +pyasn1==0.5.0 + # via + # pyasn1-modules + # rsa +pyasn1-modules==0.3.0 + # via google-auth pycparser==2.21 # via cffi -pyopenssl==23.0.0 +pyopenssl==23.1.1 # via flytekit python-dateutil==2.8.2 # via # arrow # croniter # flytekit + # kubernetes # pandas python-json-logger==2.0.7 # via flytekit @@ -142,7 +164,7 @@ python-slugify==8.0.0 # via cookiecutter pytimeparse==1.1.8 # via flytekit -pytz==2022.7.1 +pytz==2023.3 # via # flytekit # pandas @@ -150,42 +172,47 @@ pyyaml==6.0 # via # cookiecutter # flytekit -regex==2022.10.31 + # kubernetes + # responses +regex==2023.3.23 # via docker-image-py requests==2.28.2 # via # cookiecutter # docker # flytekit + # kubernetes + # requests-oauthlib # responses -responses==0.22.0 +requests-oauthlib==1.3.1 + # via kubernetes +responses==0.23.1 # via flytekit retry==0.9.2 # via flytekit +rsa==4.9 + # via google-auth secretstorage==3.3.3 # via keyring -singledispatchmethod==1.0 - # via flytekit six==1.16.0 # via + # google-auth # grpcio + # kubernetes # python-dateutil +smmap==5.0.0 + # via gitdb 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.5 +types-pyyaml==6.0.12.9 # via responses typing-extensions==4.5.0 # via - # arrow # flytekit - # importlib-metadata - # responses # typing-inspect typing-inspect==0.8.0 # via dataclasses-json @@ -193,17 +220,21 @@ urllib3==1.26.14 # via # docker # flytekit + # kubernetes # requests # responses websocket-client==1.5.1 - # via docker -wheel==0.38.4 + # via + # docker + # kubernetes +wheel==0.40.0 # via flytekit wrapt==1.14.1 # via # deprecated # flytekit -zipp==3.14.0 - # via - # importlib-metadata - # importlib-resources +zipp==3.15.0 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/plugins/flytekit-kf-pytorch/setup.py b/plugins/flytekit-kf-pytorch/setup.py index c45e409567..a207b9381e 100644 --- a/plugins/flytekit-kf-pytorch/setup.py +++ b/plugins/flytekit-kf-pytorch/setup.py @@ -4,7 +4,7 @@ microlib_name = f"flytekitplugins-{PLUGIN_NAME}" -plugin_requires = ["flytekit>=1.1.0b0,<1.3.0,<2.0.0"] +plugin_requires = ["cloudpickle", "flytekit>=1.1.0b0,<1.3.0,<2.0.0", "flyteidl>=1.2.10,<1.3.0"] __version__ = "0.0.0+develop" @@ -17,6 +17,9 @@ namespace_packages=["flytekitplugins"], packages=[f"flytekitplugins.{PLUGIN_NAME}"], install_requires=plugin_requires, + extras_require={ + "elastic": ["torch>=1.9.0"], + }, license="apache2", python_requires=">=3.7", classifiers=[ diff --git a/plugins/flytekit-kf-pytorch/tests/test_elastic_task.py b/plugins/flytekit-kf-pytorch/tests/test_elastic_task.py new file mode 100644 index 0000000000..2ca6c9cc65 --- /dev/null +++ b/plugins/flytekit-kf-pytorch/tests/test_elastic_task.py @@ -0,0 +1,67 @@ +import os +import typing +from dataclasses import dataclass + +import pytest +import torch +import torch.distributed as dist +from dataclasses_json import dataclass_json +from flytekitplugins.kfpytorch.task import Elastic + +from flytekit import task, workflow + + +@dataclass_json +@dataclass +class Config: + lr: float = 1e-5 + bs: int = 64 + name: str = "foo" + + +def dist_communicate() -> int: + """Communicate between distributed workers.""" + rank = torch.distributed.get_rank() + world_size = dist.get_world_size() + tensor = torch.tensor([5], dtype=torch.int64) + 2 * rank + world_size + dist.all_reduce(tensor, op=dist.ReduceOp.SUM) + + return tensor.item() + + +def train(config: Config) -> typing.Tuple[str, Config, torch.nn.Module, int]: + """Mock training a model using torch-elastic for test purposes.""" + dist.init_process_group(backend="gloo") + + local_rank = os.environ["LOCAL_RANK"] + + out_model = torch.nn.Linear(1000, int(local_rank) + 1) + config.name = "elastic-test" + + distributed_result = dist_communicate() + + return f"result from local rank {local_rank}", config, out_model, distributed_result + + +@pytest.mark.parametrize("start_method", ["spawn", "fork"]) +def test_end_to_end(start_method: str) -> None: + """Test that the workflow with elastic task runs end to end.""" + world_size = 2 + + train_task = task(train, task_config=Elastic(nnodes=1, nproc_per_node=world_size, start_method=start_method)) + + @workflow + def wf(config: Config = Config()) -> typing.Tuple[str, Config, torch.nn.Module, int]: + return train_task(config=config) + + r, cfg, m, distributed_result = wf() + assert "result from local rank 0" in r + assert cfg.name == "elastic-test" + assert m.in_features == 1000 + assert m.out_features == 1 + """ + The distributed result is calculated by the workers of the elastic train + task by performing a `dist.all_reduce` operation. The correct result can + only be obtained if the distributed process group is initialized correctly. + """ + assert distributed_result == sum([5 + 2 * rank + world_size for rank in range(world_size)]) diff --git a/plugins/flytekit-mlflow/dev-requirements.txt b/plugins/flytekit-mlflow/dev-requirements.txt index 6ad9be49bb..5788aeb7d2 100644 --- a/plugins/flytekit-mlflow/dev-requirements.txt +++ b/plugins/flytekit-mlflow/dev-requirements.txt @@ -36,11 +36,7 @@ h5py==3.7.0 # via tensorflow idna==3.4 # via requests -importlib-metadata==5.0.0 - # via markdown -keras==2.10.0 - # via tensorflow -keras-preprocessing==1.1.2 +keras==2.11.0 # via tensorflow libclang==14.0.6 # via tensorflow @@ -51,7 +47,6 @@ markupsafe==2.1.1 numpy==1.23.4 # via # h5py - # keras-preprocessing # opt-einsum # tensorboard # tensorflow @@ -87,17 +82,16 @@ six==1.16.0 # google-auth # google-pasta # grpcio - # keras-preprocessing # tensorflow -tensorboard==2.10.1 +tensorboard==2.11.2 # via tensorflow tensorboard-data-server==0.6.1 # via tensorboard tensorboard-plugin-wit==1.8.1 # via tensorboard -tensorflow==2.10.0 +tensorflow==2.11.1 # via -r dev-requirements.in -tensorflow-estimator==2.10.0 +tensorflow-estimator==2.11.0 # via tensorflow tensorflow-io-gcs-filesystem==0.27.0 # via tensorflow @@ -115,8 +109,6 @@ wheel==0.38.3 # tensorboard wrapt==1.14.1 # via tensorflow -zipp==3.10.0 - # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/plugins/flytekit-mlflow/flytekitplugins/mlflow/tracking.py b/plugins/flytekit-mlflow/flytekitplugins/mlflow/tracking.py index b58aa4a120..9fa897f90e 100644 --- a/plugins/flytekit-mlflow/flytekitplugins/mlflow/tracking.py +++ b/plugins/flytekit-mlflow/flytekitplugins/mlflow/tracking.py @@ -13,7 +13,7 @@ from flytekit import FlyteContextManager from flytekit.bin.entrypoint import get_one_of from flytekit.core.context_manager import ExecutionState -from flytekit.deck import TopFrameRenderer +from flytekit.deck.renderer import TopFrameRenderer def metric_to_df(metrics: typing.List[Metric]) -> pd.DataFrame: diff --git a/plugins/flytekit-mlflow/tests/test_mlflow_tracking.py b/plugins/flytekit-mlflow/tests/test_mlflow_tracking.py index b196327d8d..613cbfcd76 100644 --- a/plugins/flytekit-mlflow/tests/test_mlflow_tracking.py +++ b/plugins/flytekit-mlflow/tests/test_mlflow_tracking.py @@ -29,4 +29,4 @@ def train_model(epochs: int): def test_local_exec(): train_model(epochs=1) - assert len(flytekit.current_context().decks) == 4 # mlflow metrics, params, input, and output + assert len(flytekit.current_context().decks) == 5 # mlflow metrics, params, timeline, input, and output diff --git a/plugins/flytekit-pandera/tests/test_plugin.py b/plugins/flytekit-pandera/tests/test_plugin.py index cc9b26c4fa..7e73aac932 100644 --- a/plugins/flytekit-pandera/tests/test_plugin.py +++ b/plugins/flytekit-pandera/tests/test_plugin.py @@ -55,7 +55,13 @@ def invalid_wf() -> pandera.typing.DataFrame[OutSchema]: def wf_with_df_input(df: pandera.typing.DataFrame[InSchema]) -> pandera.typing.DataFrame[OutSchema]: return transform2(df=transform1(df=df)) - with pytest.raises(pandera.errors.SchemaError, match="^expected series 'col2' to have type float64, got object"): + with pytest.raises( + pandera.errors.SchemaError, + match=( + "^Encountered error while executing workflow 'test_plugin.wf_with_df_input':\n" + " expected series 'col2' to have type float64, got object" + ), + ): wf_with_df_input(df=invalid_df) # raise error when executing workflow with invalid output @@ -67,7 +73,14 @@ def transform2_noop(df: pandera.typing.DataFrame[IntermediateSchema]) -> pandera def wf_invalid_output(df: pandera.typing.DataFrame[InSchema]) -> pandera.typing.DataFrame[OutSchema]: return transform2_noop(df=transform1(df=df)) - with pytest.raises(TypeError, match="^Failed to convert return value"): + with pytest.raises( + TypeError, + match=( + "^Encountered error while executing workflow 'test_plugin.wf_invalid_output':\n" + " Error encountered while executing 'wf_invalid_output':\n" + " Failed to convert outputs of task" + ), + ): wf_invalid_output(df=valid_df) diff --git a/plugins/flytekit-papermill/flytekitplugins/papermill/__init__.py b/plugins/flytekit-papermill/flytekitplugins/papermill/__init__.py index bce2ef2653..648ba1c6e0 100644 --- a/plugins/flytekit-papermill/flytekitplugins/papermill/__init__.py +++ b/plugins/flytekit-papermill/flytekitplugins/papermill/__init__.py @@ -11,4 +11,4 @@ record_outputs """ -from .task import NotebookTask, record_outputs +from .task import NotebookTask, load_flytedirectory, load_flytefile, load_structureddataset, record_outputs diff --git a/plugins/flytekit-papermill/flytekitplugins/papermill/task.py b/plugins/flytekit-papermill/flytekitplugins/papermill/task.py index 6c160c2690..b1f472e99a 100644 --- a/plugins/flytekit-papermill/flytekitplugins/papermill/task.py +++ b/plugins/flytekit-papermill/flytekitplugins/papermill/task.py @@ -1,23 +1,29 @@ import json +import logging import os +import sys +import tempfile import typing from typing import Any import nbformat import papermill as pm +from flyteidl.core.literals_pb2 import Literal as _pb2_Literal from flyteidl.core.literals_pb2 import LiteralMap as _pb2_LiteralMap from google.protobuf import text_format as _text_format from nbconvert import HTMLExporter -from flytekit import FlyteContext, PythonInstanceTask +from flytekit import FlyteContext, PythonInstanceTask, StructuredDataset from flytekit.configuration import SerializationSettings +from flytekit.core import utils from flytekit.core.context_manager import ExecutionParameters from flytekit.deck.deck import Deck from flytekit.extend import Interface, TaskPlugins, TypeEngine from flytekit.loggers import logger from flytekit.models import task as task_models -from flytekit.models.literals import LiteralMap -from flytekit.types.file import HTMLPage, PythonNotebook +from flytekit.models.literals import Literal, LiteralMap +from flytekit.types.directory import FlyteDirectory +from flytekit.types.file import FlyteFile, HTMLPage, PythonNotebook T = typing.TypeVar("T") @@ -26,6 +32,8 @@ def _dummy_task_func(): return None +SAVE_AS_LITERAL = (FlyteFile, FlyteDirectory, StructuredDataset) + PAPERMILL_TASK_PREFIX = "pm.nb" @@ -86,6 +94,13 @@ class NotebookTask(PythonInstanceTask[T]): Users can access these notebooks after execution of the task locally or from remote servers. + .. note: + + By default, print statements in your notebook won't be transmitted to the pod logs/stdout. If you would + like to have logs forwarded as the notebook executes, pass the stream_logs argument. Note that notebook + logs can be quite verbose, so ensure you are prepared for any downstream log ingestion costs + (e.g., cloudwatch) + .. todo: Implicit extraction of SparkConfiguration from the notebook is not supported. @@ -114,6 +129,7 @@ def __init__( name: str, notebook_path: str, render_deck: bool = False, + stream_logs: bool = False, task_config: T = None, inputs: typing.Optional[typing.Dict[str, typing.Type]] = None, outputs: typing.Optional[typing.Dict[str, typing.Type]] = None, @@ -135,6 +151,16 @@ def __init__( self._notebook_path = os.path.abspath(notebook_path) self._render_deck = render_deck + self._stream_logs = stream_logs + + # Send the papermill logger to stdout so that it appears in pod logs. Note that papermill doesn't allow + # injecting a logger, so we cannot redirect logs to the flyte child loggers (e.g., the userspace logger) + # and inherit their settings, but we instead must send logs to stdout directly + if self._stream_logs: + papermill_logger = logging.getLogger("papermill") + papermill_logger.addHandler(logging.StreamHandler(sys.stdout)) + # Papermill leaves the default level of DEBUG. We increase it here. + papermill_logger.setLevel(logging.INFO) if not os.path.exists(self._notebook_path): raise ValueError(f"Illegal notebook path passed in {self._notebook_path}") @@ -235,8 +261,12 @@ def execute(self, **kwargs) -> Any: singleton """ logger.info(f"Hijacking the call for task-type {self.task_type}, to call notebook.") + for k, v in kwargs.items(): + if isinstance(v, SAVE_AS_LITERAL): + kwargs[k] = save_python_val_to_file(v) + # Execute Notebook via Papermill. - pm.execute_notebook(self._notebook_path, self.output_notebook_path, parameters=kwargs) # type: ignore + pm.execute_notebook(self._notebook_path, self.output_notebook_path, parameters=kwargs, log_output=self._stream_logs) # type: ignore outputs = self.extract_outputs(self.output_notebook_path) self.render_nb_html(self.output_notebook_path, self.rendered_output_path) @@ -245,6 +275,7 @@ def execute(self, **kwargs) -> Any: if outputs: m = outputs.literals output_list = [] + for k, type_v in self.python_interface.outputs.items(): if k == self._IMPLICIT_OP_NOTEBOOK: output_list.append(self.output_notebook_path) @@ -254,7 +285,7 @@ def execute(self, **kwargs) -> Any: v = TypeEngine.to_python_value(ctx=FlyteContext.current_context(), lv=m[k], expected_python_type=type_v) output_list.append(v) else: - raise RuntimeError(f"Expected output {k} of type {v} not found in the notebook outputs") + raise TypeError(f"Expected output {k} of type {type_v} not found in the notebook outputs") return tuple(output_list) @@ -287,3 +318,80 @@ def record_outputs(**kwargs) -> str: lit = TypeEngine.to_literal(ctx, python_type=type(v), python_val=v, expected=expected) m[k] = lit return LiteralMap(literals=m).to_flyte_idl() + + +def save_python_val_to_file(input: Any) -> str: + """Save a python value to a local file as a Flyte literal. + + Args: + input (Any): the python value + + Returns: + str: the path to the file + """ + ctx = FlyteContext.current_context() + expected = TypeEngine.to_literal_type(type(input)) + lit = TypeEngine.to_literal(ctx, python_type=type(input), python_val=input, expected=expected) + + tmp_file = tempfile.mktemp(suffix="bin") + utils.write_proto_to_file(lit.to_flyte_idl(), tmp_file) + return tmp_file + + +def load_python_val_from_file(path: str, dtype: T) -> T: + """Loads a python value from a Flyte literal saved to a local file. + + If the path matches the type, it is returned as is. This enables + reusing the parameters cell for local development. + + Args: + path (str): path to the file + dtype (T): the type of the literal + + Returns: + T: the python value of the literal + """ + if isinstance(path, dtype): + return path + + proto = utils.load_proto_from_file(_pb2_Literal, path) + lit = Literal.from_flyte_idl(proto) + ctx = FlyteContext.current_context() + python_value = TypeEngine.to_python_value(ctx, lit, dtype) + return python_value + + +def load_flytefile(path: str) -> T: + """Loads a FlyteFile from a file. + + Args: + path (str): path to the file + + Returns: + T: the python value of the literal + """ + return load_python_val_from_file(path=path, dtype=FlyteFile) + + +def load_flytedirectory(path: str) -> T: + """Loads a FlyteDirectory from a file. + + Args: + path (str): path to the file + + Returns: + T: the python value of the literal + """ + return load_python_val_from_file(path=path, dtype=FlyteDirectory) + + +def load_structureddataset(path: str) -> T: + """Loads a StructuredDataset from a file. + + Args: + path (str): path to the file + + Returns: + T: the python value of the literal + """ + return load_python_val_from_file(path=path, dtype=StructuredDataset) diff --git a/plugins/flytekit-papermill/tests/test_task.py b/plugins/flytekit-papermill/tests/test_task.py index 1947d09445..0e54e7082e 100644 --- a/plugins/flytekit-papermill/tests/test_task.py +++ b/plugins/flytekit-papermill/tests/test_task.py @@ -1,14 +1,17 @@ import datetime import os +import tempfile +import pandas as pd from flytekitplugins.papermill import NotebookTask from flytekitplugins.pod import Pod from kubernetes.client import V1Container, V1PodSpec import flytekit -from flytekit import kwtypes +from flytekit import StructuredDataset, kwtypes, task from flytekit.configuration import Image, ImageConfig -from flytekit.types.file import PythonNotebook +from flytekit.types.directory import FlyteDirectory +from flytekit.types.file import FlyteFile, PythonNotebook from .testdata.datatype import X @@ -134,3 +137,38 @@ def test_notebook_pod_task(): nb.get_command(serialization_settings) == nb.get_k8s_pod(serialization_settings).pod_spec["containers"][0]["args"] ) + + +def test_flyte_types(): + @task + def create_file() -> FlyteFile: + tmp_file = tempfile.mktemp() + with open(tmp_file, "w") as f: + f.write("abc") + return FlyteFile(path=tmp_file) + + @task + def create_dir() -> FlyteDirectory: + tmp_dir = tempfile.mkdtemp() + with open(os.path.join(tmp_dir, "file.txt"), "w") as f: + f.write("abc") + return FlyteDirectory(path=tmp_dir) + + @task + def create_sd() -> StructuredDataset: + df = pd.DataFrame({"a": [1, 2], "b": [3, 4]}) + return StructuredDataset(dataframe=df) + + ff = create_file() + fd = create_dir() + sd = create_sd() + + nb_name = "nb-types" + nb_types = NotebookTask( + name="test", + notebook_path=_get_nb_path(nb_name, abs=False), + inputs=kwtypes(ff=FlyteFile, fd=FlyteDirectory, sd=StructuredDataset), + outputs=kwtypes(success=bool), + ) + success, out, render = nb_types.execute(ff=ff, fd=fd, sd=sd) + assert success is True, "Notebook execution failed" diff --git a/plugins/flytekit-papermill/tests/testdata/nb-simple.ipynb b/plugins/flytekit-papermill/tests/testdata/nb-simple.ipynb index ebdf9a3c71..1ad7aaed4a 100644 --- a/plugins/flytekit-papermill/tests/testdata/nb-simple.ipynb +++ b/plugins/flytekit-papermill/tests/testdata/nb-simple.ipynb @@ -34,7 +34,6 @@ "outputs": [], "source": [ "from flytekitplugins.papermill import record_outputs\n", - "\n", "record_outputs(square=out)" ] }, @@ -49,7 +48,7 @@ "metadata": { "celltoolbar": "Tags", "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -63,9 +62,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.3" + "version": "3.10.10" } }, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 4 } diff --git a/plugins/flytekit-papermill/tests/testdata/nb-types.ipynb b/plugins/flytekit-papermill/tests/testdata/nb-types.ipynb new file mode 100644 index 0000000000..824b1d39ae --- /dev/null +++ b/plugins/flytekit-papermill/tests/testdata/nb-types.ipynb @@ -0,0 +1,100 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "tags": [ + "parameters" + ] + }, + "outputs": [], + "source": [ + "ff = None\n", + "fd = None\n", + "sd = None" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import pandas as pd\n", + "from flytekitplugins.papermill import (\n", + " load_flytefile, load_flytedirectory, load_structureddataset,\n", + " record_outputs\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ff = load_flytefile(ff)\n", + "fd = load_flytedirectory(fd)\n", + "sd = load_structureddataset(sd)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# read file\n", + "with open(ff.download(), 'r') as f:\n", + " text = f.read()\n", + " assert text == \"abc\", \"Text does not match\"\n", + "\n", + "# check file inside directory\n", + "with open(os.path.join(fd.download(),\"file.txt\"), 'r') as f:\n", + " text = f.read()\n", + " assert text == \"abc\", \"Text does not match\"\n", + "\n", + "# check dataset\n", + "df = sd.open(pd.DataFrame).all()\n", + "expected = pd.DataFrame({\"a\": [1, 2], \"b\": [3, 4]})\n", + "assert df.equals(expected), \"Dataframes do not match\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [ + "outputs" + ] + }, + "outputs": [], + "source": [ + "record_outputs(success=True)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.10" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/plugins/flytekit-polars/flytekitplugins/polars/sd_transformers.py b/plugins/flytekit-polars/flytekitplugins/polars/sd_transformers.py index 0b5bf8e577..4290c88ae4 100644 --- a/plugins/flytekit-polars/flytekitplugins/polars/sd_transformers.py +++ b/plugins/flytekit-polars/flytekitplugins/polars/sd_transformers.py @@ -7,6 +7,7 @@ from flytekit.models import literals from flytekit.models.literals import StructuredDatasetMetadata from flytekit.models.types import StructuredDatasetType +from flytekit.types.structured.basic_dfs import get_storage_options from flytekit.types.structured.structured_dataset import ( PARQUET, StructuredDataset, @@ -62,12 +63,12 @@ def decode( flyte_value: literals.StructuredDataset, current_task_metadata: StructuredDatasetMetadata, ) -> pl.DataFrame: - local_dir = ctx.file_access.get_random_local_directory() - ctx.file_access.get_data(flyte_value.uri, local_dir, is_multipart=True) + uri = flyte_value.uri + 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] - return pl.read_parquet(local_dir, columns=columns, use_pyarrow=True) - return pl.read_parquet(local_dir, use_pyarrow=True) + return pl.read_parquet(uri, columns=columns, use_pyarrow=True, storage_options=kwargs) + return pl.read_parquet(uri, use_pyarrow=True, storage_options=kwargs) StructuredDatasetTransformerEngine.register(PolarsDataFrameToParquetEncodingHandler()) diff --git a/plugins/flytekit-polars/tests/test_polars_plugin_sd.py b/plugins/flytekit-polars/tests/test_polars_plugin_sd.py index 15a195e5d5..23fbf6d441 100644 --- a/plugins/flytekit-polars/tests/test_polars_plugin_sd.py +++ b/plugins/flytekit-polars/tests/test_polars_plugin_sd.py @@ -1,3 +1,5 @@ +import tempfile + import pandas as pd import polars as pl from flytekitplugins.polars.sd_transformers import PolarsDataFrameRenderer @@ -79,3 +81,13 @@ def create_sd() -> StructuredDataset: sd = create_sd() polars_df = sd.open(pl.DataFrame).all() assert pl.DataFrame(data).frame_equal(polars_df) + + tmp = tempfile.mktemp() + pl.DataFrame(data).write_parquet(tmp) + + @task + def t1(sd: StructuredDataset) -> pl.DataFrame: + return sd.open(pd.DataFrame).all() + + sd = StructuredDataset(uri=tmp) + t1(sd=sd).frame_equal(polars_df) diff --git a/plugins/flytekit-spark/Dockerfile b/plugins/flytekit-spark/Dockerfile new file mode 100644 index 0000000000..0789df45b7 --- /dev/null +++ b/plugins/flytekit-spark/Dockerfile @@ -0,0 +1,14 @@ +# https://github.com/apache/spark/blob/master/resource-managers/kubernetes/docker/src/main/dockerfiles/spark/bindings/python/Dockerfile +FROM apache/spark-py:3.3.1 +LABEL org.opencontainers.image.source=https://github.com/flyteorg/flytekit + +USER 0 +RUN ln -s /usr/bin/python3 /usr/bin/python + +ARG VERSION +RUN pip install flytekitplugins-spark==$VERSION +RUN pip install flytekit==$VERSION + +RUN chown -R ${spark_uid}:${spark_uid} /root +WORKDIR /root +USER ${spark_uid} diff --git a/plugins/flytekit-spark/flytekitplugins/spark/pyspark_transformers.py b/plugins/flytekit-spark/flytekitplugins/spark/pyspark_transformers.py index e48778ad70..4afb257f9d 100644 --- a/plugins/flytekit-spark/flytekitplugins/spark/pyspark_transformers.py +++ b/plugins/flytekit-spark/flytekitplugins/spark/pyspark_transformers.py @@ -1,4 +1,3 @@ -import pathlib from typing import Type from pyspark.ml import PipelineModel @@ -24,22 +23,17 @@ def to_literal( python_type: Type[PipelineModel], expected: LiteralType, ) -> Literal: - local_path = ctx.file_access.get_random_local_path() - pathlib.Path(local_path).parent.mkdir(parents=True, exist_ok=True) - python_val.save(local_path) - + # Must write to remote directory remote_dir = ctx.file_access.get_random_remote_directory() - ctx.file_access.upload_directory(local_path, remote_dir) + python_val.write().overwrite().save(remote_dir) return Literal(scalar=Scalar(blob=Blob(uri=remote_dir, metadata=BlobMetadata(type=self._TYPE_INFO)))) def to_python_value( self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[PipelineModel] ) -> PipelineModel: - local_dir = ctx.file_access.get_random_local_directory() - ctx.file_access.download_directory(lv.scalar.blob.uri, local_dir) - - return PipelineModel.load(local_dir) + remote_dir = lv.scalar.blob.uri + return PipelineModel.load(remote_dir) TypeEngine.register(PySparkPipelineModelTransformer()) diff --git a/plugins/flytekit-spark/flytekitplugins/spark/task.py b/plugins/flytekit-spark/flytekitplugins/spark/task.py index 7b32e9f28b..564e55778f 100644 --- a/plugins/flytekit-spark/flytekitplugins/spark/task.py +++ b/plugins/flytekit-spark/flytekitplugins/spark/task.py @@ -1,15 +1,15 @@ import os -import typing from dataclasses import dataclass -from typing import Any, Callable, Dict, Optional +from typing import Any, Callable, Dict, Optional, Union, cast from google.protobuf.json_format import MessageToDict from pyspark.sql import SparkSession from flytekit import FlyteContextManager, PythonFunctionTask -from flytekit.configuration import SerializationSettings +from flytekit.configuration import DefaultImages, SerializationSettings from flytekit.core.context_manager import ExecutionParameters from flytekit.extend import ExecutionState, TaskPlugins +from flytekit.image_spec import ImageSpec from .models import SparkJob, SparkType @@ -48,7 +48,7 @@ class Databricks(Spark): databricks_instance: Domain name of your deployment. Use the form .cloud.databricks.com. """ - databricks_conf: typing.Optional[Dict[str, typing.Union[str, dict]]] = None + databricks_conf: Optional[Dict[str, Union[str, dict]]] = None databricks_token: Optional[str] = None databricks_instance: Optional[str] = None @@ -56,7 +56,7 @@ class Databricks(Spark): # This method does not reset the SparkSession since it's a bit hard to handle multiple # Spark sessions in a single application as it's described in: # https://stackoverflow.com/questions/41491972/how-can-i-tear-down-a-sparksession-and-create-a-new-one-within-one-application. -def new_spark_session(name: str, conf: typing.Dict[str, str] = None): +def new_spark_session(name: str, conf: Dict[str, str] = None): """ Optionally creates a new spark session and returns it. In cluster mode (running in hosted flyte, this will disregard the spark conf passed in) @@ -99,26 +99,43 @@ class PysparkFunctionTask(PythonFunctionTask[Spark]): _SPARK_TASK_TYPE = "spark" - def __init__(self, task_config: Spark, task_function: Callable, **kwargs): + def __init__( + self, + task_config: Spark, + task_function: Callable, + container_image: Optional[Union[str, ImageSpec]] = None, + **kwargs, + ): + self.sess: Optional[SparkSession] = None + self._default_executor_path: Optional[str] = None + self._default_applications_path: Optional[str] = None + + if isinstance(container_image, ImageSpec): + if container_image.base_image is None: + img = f"cr.flyte.org/flyteorg/flytekit:spark-{DefaultImages.get_version_suffix()}" + container_image.base_image = img + # default executor path and applications path in apache/spark-py:3.3.1 + self._default_executor_path = "/usr/bin/python3" + self._default_applications_path = "local:///usr/local/bin/entrypoint.py" super(PysparkFunctionTask, self).__init__( task_config=task_config, task_type=self._SPARK_TASK_TYPE, task_function=task_function, + container_image=container_image, **kwargs, ) - self.sess: Optional[SparkSession] = None def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: job = SparkJob( spark_conf=self.task_config.spark_conf, hadoop_conf=self.task_config.hadoop_conf, - application_file="local://" + settings.entrypoint_settings.path, - executor_path=settings.python_interpreter, + application_file=self._default_applications_path or "local://" + settings.entrypoint_settings.path, + executor_path=self._default_executor_path or settings.python_interpreter, main_class="", spark_type=SparkType.PYTHON, ) if isinstance(self.task_config, Databricks): - cfg = typing.cast(Databricks, self.task_config) + cfg = cast(Databricks, self.task_config) job._databricks_conf = cfg.databricks_conf job._databricks_token = cfg.databricks_token job._databricks_instance = cfg.databricks_instance 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/plugins/flytekit-sqlalchemy/Dockerfile b/plugins/flytekit-sqlalchemy/Dockerfile new file mode 100644 index 0000000000..ed1a644d8f --- /dev/null +++ b/plugins/flytekit-sqlalchemy/Dockerfile @@ -0,0 +1,19 @@ +ARG PYTHON_VERSION +FROM python:${PYTHON_VERSION}-slim-buster + +WORKDIR /root +ENV LANG C.UTF-8 +ENV LC_ALL C.UTF-8 +ENV PYTHONPATH /root + +ARG VERSION + +RUN pip install sqlalchemy \ + psycopg2-binary \ + pymysql \ + flytekitplugins-sqlalchemy==$VERSION \ + flytekit==$VERSION + +RUN useradd -u 1000 flytekit +RUN chown flytekit: /root +USER flytekit diff --git a/plugins/flytekit-sqlalchemy/Dockerfile.py3.10 b/plugins/flytekit-sqlalchemy/Dockerfile.py3.10 deleted file mode 100644 index 791b13fa53..0000000000 --- a/plugins/flytekit-sqlalchemy/Dockerfile.py3.10 +++ /dev/null @@ -1,25 +0,0 @@ -FROM python:3.10-slim-buster - -WORKDIR /app -ENV VENV /opt/venv -ENV LANG C.UTF-8 -ENV LC_ALL C.UTF-8 -ENV PYTHONPATH /app - -RUN pip install awscli -RUN pip install gsutil - -ARG VERSION - -# Virtual environment -RUN python3.10 -m venv ${VENV} -RUN ${VENV}/bin/pip install wheel - -RUN ${VENV}/bin/pip install sqlalchemy psycopg2-binary pymysql flytekitplugins-sqlalchemy==$VERSION flytekit==$VERSION - -# Copy over the helper script that the SDK relies on -RUN cp ${VENV}/bin/flytekit_venv /usr/local/bin -RUN chmod a+x /usr/local/bin/flytekit_venv - -# Enable the virtualenv for this image. Note this relies on the VENV variable we've set in this image. -ENTRYPOINT ["/usr/local/bin/flytekit_venv"] diff --git a/plugins/flytekit-sqlalchemy/Dockerfile.py3.7 b/plugins/flytekit-sqlalchemy/Dockerfile.py3.7 deleted file mode 100644 index 879656adb5..0000000000 --- a/plugins/flytekit-sqlalchemy/Dockerfile.py3.7 +++ /dev/null @@ -1,25 +0,0 @@ -FROM python:3.7-slim-buster - -WORKDIR /app -ENV VENV /opt/venv -ENV LANG C.UTF-8 -ENV LC_ALL C.UTF-8 -ENV PYTHONPATH /app - -RUN pip install awscli -RUN pip install gsutil - -ARG VERSION - -# Virtual environment -RUN python3.7 -m venv ${VENV} -RUN ${VENV}/bin/pip install wheel - -RUN ${VENV}/bin/pip install sqlalchemy psycopg2-binary pymysql flytekitplugins-sqlalchemy==$VERSION flytekit==$VERSION - -# Copy over the helper script that the SDK relies on -RUN cp ${VENV}/bin/flytekit_venv /usr/local/bin -RUN chmod a+x /usr/local/bin/flytekit_venv - -# Enable the virtualenv for this image. Note this relies on the VENV variable we've set in this image. -ENTRYPOINT ["/usr/local/bin/flytekit_venv"] diff --git a/plugins/flytekit-sqlalchemy/Dockerfile.py3.8 b/plugins/flytekit-sqlalchemy/Dockerfile.py3.8 deleted file mode 100644 index 93b7048e1b..0000000000 --- a/plugins/flytekit-sqlalchemy/Dockerfile.py3.8 +++ /dev/null @@ -1,25 +0,0 @@ -FROM python:3.8-slim-buster - -WORKDIR /app -ENV VENV /opt/venv -ENV LANG C.UTF-8 -ENV LC_ALL C.UTF-8 -ENV PYTHONPATH /app - -RUN pip install awscli -RUN pip install gsutil - -ARG VERSION - -# Virtual environment -RUN python3.8 -m venv ${VENV} -RUN ${VENV}/bin/pip install wheel - -RUN ${VENV}/bin/pip install sqlalchemy psycopg2-binary pymysql flytekitplugins-sqlalchemy==$VERSION flytekit==$VERSION - -# Copy over the helper script that the SDK relies on -RUN cp ${VENV}/bin/flytekit_venv /usr/local/bin -RUN chmod a+x /usr/local/bin/flytekit_venv - -# Enable the virtualenv for this image. Note this relies on the VENV variable we've set in this image. -ENTRYPOINT ["/usr/local/bin/flytekit_venv"] diff --git a/plugins/flytekit-sqlalchemy/Dockerfile.py3.9 b/plugins/flytekit-sqlalchemy/Dockerfile.py3.9 deleted file mode 100644 index 039956dcd1..0000000000 --- a/plugins/flytekit-sqlalchemy/Dockerfile.py3.9 +++ /dev/null @@ -1,25 +0,0 @@ -FROM python:3.9-slim-buster - -WORKDIR /app -ENV VENV /opt/venv -ENV LANG C.UTF-8 -ENV LC_ALL C.UTF-8 -ENV PYTHONPATH /app - -RUN pip install awscli -RUN pip install gsutil - -ARG VERSION - -# Virtual environment -RUN python3.9 -m venv ${VENV} -RUN ${VENV}/bin/pip install wheel - -RUN ${VENV}/bin/pip install sqlalchemy psycopg2-binary pymysql flytekitplugins-sqlalchemy==$VERSION flytekit==$VERSION - -# Copy over the helper script that the SDK relies on -RUN cp ${VENV}/bin/flytekit_venv /usr/local/bin -RUN chmod a+x /usr/local/bin/flytekit_venv - -# Enable the virtualenv for this image. Note this relies on the VENV variable we've set in this image. -ENTRYPOINT ["/usr/local/bin/flytekit_venv"] diff --git a/plugins/setup.py b/plugins/setup.py index 1b47cc58e0..d607468081 100644 --- a/plugins/setup.py +++ b/plugins/setup.py @@ -18,6 +18,8 @@ "flytekitplugins-dbt": "flytekit-dbt", "flytekitplugins-dolt": "flytekit-dolt", "flytekitplugins-duckdb": "flytekit-duckdb", + "flytekitplugins-data-fsspec": "flytekit-data-fsspec", + "flytekitplugins-envd": "flytekit-envd", "flytekitplugins-great_expectations": "flytekit-greatexpectations", "flytekitplugins-hive": "flytekit-hive", "flytekitplugins-pod": "flytekit-k8s-pod", @@ -30,6 +32,7 @@ "flytekitplugins-onnxpytorch": "flytekit-onnx-pytorch", "flytekitplugins-pandera": "flytekit-pandera", "flytekitplugins-papermill": "flytekit-papermill", + "flytekitplugins-polars": "flytekit-polars", "flytekitplugins-ray": "flytekit-ray", "flytekitplugins-snowflake": "flytekit-snowflake", "flytekitplugins-spark": "flytekit-spark", diff --git a/setup.py b/setup.py index 6519fbccd1..36acb1a81d 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,8 @@ ] }, install_requires=[ - "flyteidl>=1.2.9,<1.3.0", + "googleapis-common-protos>=1.57", + "flyteidl>=1.2.10,<1.3.0", "wheel>=0.30.0,<1.0.0", "pandas>=1.0.0,<2.0.0", "pyarrow>=4.0.0,<11.0.0", @@ -42,6 +43,10 @@ "grpcio>=1.43.0,!=1.45.0,<1.49.1,<2.0", "grpcio-status>=1.43,!=1.45.0,<1.49.1", "importlib-metadata", + "fsspec>=2023.1.0", + "adlfs", + "s3fs>=0.6.0", + "gcsfs", "pyopenssl", "joblib", "protobuf>=3.6.1,<4", @@ -56,7 +61,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", @@ -73,6 +77,8 @@ "numpy<1.24.0", "gitpython", "kubernetes>=12.0.1", + "rich", + "rich_click", ], extras_require=extras_require, scripts=[ 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/cli/pyflyte/imageSpec.yaml b/tests/flytekit/unit/cli/pyflyte/imageSpec.yaml new file mode 100644 index 0000000000..ba67ab4b91 --- /dev/null +++ b/tests/flytekit/unit/cli/pyflyte/imageSpec.yaml @@ -0,0 +1,2 @@ +python_version: 3.8 +builder: test diff --git a/tests/flytekit/unit/cli/pyflyte/image_spec_wf.py b/tests/flytekit/unit/cli/pyflyte/image_spec_wf.py new file mode 100644 index 0000000000..9d5d74ff1b --- /dev/null +++ b/tests/flytekit/unit/cli/pyflyte/image_spec_wf.py @@ -0,0 +1,20 @@ +from flytekit import task, workflow +from flytekit.image_spec import ImageSpec + +image_spec = ImageSpec(packages=["numpy", "pandas"], apt_packages=["git"], registry="", builder="test") + + +@task(container_image=image_spec) +def t2() -> str: + return "flyte" + + +@task(container_image=image_spec) +def t1() -> str: + return "flyte" + + +@workflow +def wf(): + t1() + t2() diff --git a/tests/flytekit/unit/cli/pyflyte/test_backfill.py b/tests/flytekit/unit/cli/pyflyte/test_backfill.py index 8389295af2..0fd328e638 100644 --- a/tests/flytekit/unit/cli/pyflyte/test_backfill.py +++ b/tests/flytekit/unit/cli/pyflyte/test_backfill.py @@ -39,7 +39,6 @@ def test_pyflyte_backfill(mock_remote): "--backfill-window", "5 day", "daily", - "--dry-run", ], ) assert result.exit_code == 0 diff --git a/tests/flytekit/unit/cli/pyflyte/test_build.py b/tests/flytekit/unit/cli/pyflyte/test_build.py new file mode 100644 index 0000000000..7b4b26fb69 --- /dev/null +++ b/tests/flytekit/unit/cli/pyflyte/test_build.py @@ -0,0 +1,31 @@ +import os + +from click.testing import CliRunner + +from flytekit.clis.sdk_in_container import pyflyte +from flytekit.image_spec.image_spec import ImageBuildEngine, ImageSpecBuilder + +WORKFLOW_FILE = os.path.join(os.path.dirname(os.path.realpath(__file__)), "image_spec_wf.py") + + +def test_build(): + class TestImageSpecBuilder(ImageSpecBuilder): + def build_image(self, img): + ... + + ImageBuildEngine.register("test", TestImageSpecBuilder()) + runner = CliRunner() + result = runner.invoke(pyflyte.main, ["build", "--fast", WORKFLOW_FILE, "wf"]) + assert result.exit_code == 0 + + result = runner.invoke(pyflyte.main, ["build", WORKFLOW_FILE, "wf"]) + assert result.exit_code == 0 + + result = runner.invoke(pyflyte.main, ["build", WORKFLOW_FILE, "wf"]) + assert result.exit_code == 0 + + result = runner.invoke(pyflyte.main, ["build", "--help"]) + assert result.exit_code == 0 + + result = runner.invoke(pyflyte.main, ["build", "../", "wf"]) + assert result.exit_code == 1 diff --git a/tests/flytekit/unit/cli/pyflyte/test_launchplan.py b/tests/flytekit/unit/cli/pyflyte/test_launchplan.py new file mode 100644 index 0000000000..1a461bfd35 --- /dev/null +++ b/tests/flytekit/unit/cli/pyflyte/test_launchplan.py @@ -0,0 +1,34 @@ +import pytest +from click.testing import CliRunner +from mock import mock + +from flytekit.clis.sdk_in_container import pyflyte +from flytekit.remote import FlyteRemote + + +@mock.patch("flytekit.clis.sdk_in_container.helpers.FlyteRemote", spec=FlyteRemote) +@pytest.mark.parametrize( + ("action", "expected_state"), + [ + ("activate", "ACTIVE"), + ("deactivate", "INACTIVE"), + ], +) +def test_pyflyte_launchplan(mock_remote, action, expected_state): + mock_remote.generate_console_url.return_value = "ex" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + pyflyte.main, + [ + "launchplan", + f"--{action}", + "-p", + "flytesnacks", + "-d", + "development", + "daily", + ], + ) + assert result.exit_code == 0 + assert f"Launchplan was set to {expected_state}: " in result.output diff --git a/tests/flytekit/unit/cli/pyflyte/test_package.py b/tests/flytekit/unit/cli/pyflyte/test_package.py index e3ccb1d803..4d8251fc57 100644 --- a/tests/flytekit/unit/cli/pyflyte/test_package.py +++ b/tests/flytekit/unit/cli/pyflyte/test_package.py @@ -1,7 +1,6 @@ import os import shutil -import pytest from click.testing import CliRunner import flytekit @@ -10,7 +9,6 @@ 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 @@ -104,56 +102,6 @@ def test_package_with_fast_registration(): shutil.rmtree("core") -def test_duplicate_registrable_entities(): - @flytekit.task - def t_1(): - pass - - # Keep a reference to a task named `t_1` that's going to be duplicated below - reference_1 = t_1 - - @flytekit.workflow - def wf_1(): - return t_1() - - # Duplicate definition of `t_1` - @flytekit.task - def t_1() -> str: - pass - - # Keep a second reference to the duplicate task named `t_1` so that we can use it later - reference_2 = t_1 - - @flytekit.task - def non_duplicate_task(): - pass - - @flytekit.workflow - def wf_2(): - non_duplicate_task() - # refers to the second definition of `t_1` - return t_1() - - ctx = context_manager.FlyteContextManager.current_context().with_serialization_settings( - flytekit.configuration.SerializationSettings( - project="p", - domain="d", - version="v", - image_config=flytekit.configuration.ImageConfig( - default_image=flytekit.configuration.Image("def", "docker.io/def", "latest") - ), - ) - ) - - context_manager.FlyteEntities.entities = [reference_1, wf_1, "str", reference_2, non_duplicate_task, wf_2, "str"] - - with pytest.raises( - FlyteValidationException, - match=r"Multiple definitions of the following tasks were found: \['pyflyte.test_package.t_1'\]", - ): - flytekit.tools.serialize_helpers.get_registrable_entities(ctx) - - def test_package(): runner = CliRunner() with runner.isolated_filesystem(): diff --git a/tests/flytekit/unit/cli/pyflyte/test_register.py b/tests/flytekit/unit/cli/pyflyte/test_register.py index a6c0bb91d8..0a371b76d1 100644 --- a/tests/flytekit/unit/cli/pyflyte/test_register.py +++ b/tests/flytekit/unit/cli/pyflyte/test_register.py @@ -92,7 +92,7 @@ def test_non_fast_register(mock_client, mock_remote): def test_non_fast_register_require_version(mock_client, mock_remote): mock_remote._client = mock_client mock_remote.return_value._version_from_hash.return_value = "dummy_version_from_hash" - mock_remote.return_value._upload_file.return_value = "dummy_md5_bytes", "dummy_native_url" + mock_remote.return_value.upload_file.return_value = "dummy_md5_bytes", "dummy_native_url" runner = CliRunner() context_manager.FlyteEntities.entities.clear() with runner.isolated_filesystem(): diff --git a/tests/flytekit/unit/cli/pyflyte/test_run.py b/tests/flytekit/unit/cli/pyflyte/test_run.py index e963f3dfc6..735df4af2c 100644 --- a/tests/flytekit/unit/cli/pyflyte/test_run.py +++ b/tests/flytekit/unit/cli/pyflyte/test_run.py @@ -1,6 +1,8 @@ import functools +import json import os import pathlib +import tempfile import typing from datetime import datetime, timedelta from enum import Enum @@ -8,6 +10,7 @@ import click import mock import pytest +import yaml from click.testing import CliRunner from flytekit import FlyteContextManager @@ -21,12 +24,14 @@ DurationParamType, FileParamType, FlyteLiteralConverter, + JsonParamType, get_entities_in_file, run_command, ) from flytekit.configuration import Config, Image, ImageConfig from flytekit.core.task import task from flytekit.core.type_engine import TypeEngine +from flytekit.image_spec.image_spec import ImageBuildEngine, ImageSpecBuilder from flytekit.models.types import SimpleType from flytekit.remote import FlyteRemote @@ -62,8 +67,19 @@ def test_imperative_wf(): assert result.exit_code == 0 +def test_copy_all_files(): + runner = CliRunner() + result = runner.invoke( + pyflyte.main, + ["run", "--copy-all", IMPERATIVE_WORKFLOW_FILE, "wf", "--in1", "hello", "--in2", "world"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + + def test_pyflyte_run_cli(): runner = CliRunner() + parquet_file = os.path.join(DIR_NAME, "testdata/df.parquet") result = runner.invoke( pyflyte.main, [ @@ -83,7 +99,7 @@ def test_pyflyte_run_cli(): "--f", '{"x":1.0, "y":2.0}', "--g", - os.path.join(DIR_NAME, "testdata/df.parquet"), + parquet_file, "--i", "2020-05-01", "--j", @@ -97,6 +113,10 @@ def test_pyflyte_run_cli(): "--image", os.path.join(DIR_NAME, "testdata"), "--h", + "--n", + json.dumps([{"x": parquet_file}]), + "--o", + json.dumps({"x": [parquet_file]}), ], catch_exceptions=False, ) @@ -148,19 +168,19 @@ def test_union_type2(input): def test_union_type_with_invalid_input(): runner = CliRunner() - with pytest.raises(ValueError, match="Failed to convert python type typing.Union"): - runner.invoke( - pyflyte.main, - [ - "--verbose", - "run", - os.path.join(DIR_NAME, "workflow.py"), - "test_union2", - "--a", - "hello", - ], - catch_exceptions=False, - ) + result = runner.invoke( + pyflyte.main, + [ + "--verbose", + "run", + os.path.join(DIR_NAME, "workflow.py"), + "test_union2", + "--a", + "hello", + ], + catch_exceptions=False, + ) + assert result.exit_code == 2 def test_get_entities_in_file(): @@ -216,6 +236,7 @@ def test_list_default_arguments(wf_path): ], catch_exceptions=False, ) + print(result.stdout) assert result.exit_code == 0 @@ -239,6 +260,17 @@ def test_list_default_arguments(wf_path): images=[Image(name="xyz", fqn="ghcr.io/asdf/asdf", tag="latest"), Image(name="abc", fqn="docker.io/abc", tag=None)], ) +ic_result_4 = ImageConfig( + default_image=Image(name="default", fqn="flytekit", tag="4VC-c-UDrUvfySJ0aS3qCw.."), + images=[ + Image(name="default", fqn="flytekit", tag="4VC-c-UDrUvfySJ0aS3qCw.."), + Image(name="xyz", fqn="docker.io/xyz", tag="latest"), + Image(name="abc", fqn="docker.io/abc", tag=None), + ], +) + +IMAGE_SPEC = os.path.join(os.path.dirname(os.path.realpath(__file__)), "imageSpec.yaml") + @pytest.mark.parametrize( "image_string, leaf_configuration_file_name, final_image_config", @@ -246,9 +278,16 @@ def test_list_default_arguments(wf_path): ("ghcr.io/flyteorg/mydefault:py3.9-latest", "no_images.yaml", ic_result_1), ("asdf=ghcr.io/asdf/asdf:latest", "sample.yaml", ic_result_2), ("xyz=ghcr.io/asdf/asdf:latest", "sample.yaml", ic_result_3), + (IMAGE_SPEC, "sample.yaml", ic_result_4), ], ) def test_pyflyte_run_run(image_string, leaf_configuration_file_name, final_image_config): + class TestImageSpecBuilder(ImageSpecBuilder): + def build_image(self, img): + ... + + ImageBuildEngine.register("test", TestImageSpecBuilder()) + @task def a(): ... @@ -362,3 +401,34 @@ def test_datetime_type(): v = t.convert("now", None, None) assert v.day == now.day assert v.month == now.month + + +def test_json_type(): + t = JsonParamType() + assert t.convert(value='{"a": "b"}', param=None, ctx=None) == {"a": "b"} + + with pytest.raises(click.BadParameter): + t.convert(None, None, None) + + # test that it loads a json file + with tempfile.NamedTemporaryFile("w", delete=False) as f: + json.dump({"a": "b"}, f) + f.flush() + assert t.convert(value=f.name, param=None, ctx=None) == {"a": "b"} + + # test that if the file is not a valid json, it raises an error + with tempfile.NamedTemporaryFile("w", delete=False) as f: + f.write("asdf") + f.flush() + with pytest.raises(click.BadParameter): + t.convert(value=f.name, param="asdf", ctx=None) + + # test if the file does not exist + with pytest.raises(click.BadParameter): + t.convert(value="asdf", param=None, ctx=None) + + # test if the file is yaml and ends with .yaml it works correctly + with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f: + yaml.dump({"a": "b"}, f) + f.flush() + assert t.convert(value=f.name, param=None, ctx=None) == {"a": "b"} diff --git a/tests/flytekit/unit/cli/pyflyte/test_serve.py b/tests/flytekit/unit/cli/pyflyte/test_serve.py new file mode 100644 index 0000000000..f3ecbef547 --- /dev/null +++ b/tests/flytekit/unit/cli/pyflyte/test_serve.py @@ -0,0 +1,9 @@ +from click.testing import CliRunner + +from flytekit.clis.sdk_in_container import pyflyte + + +def test_pyflyte_serve(): + runner = CliRunner() + result = runner.invoke(pyflyte.main, ["serve", "--port", "0", "--timeout", "1"], catch_exceptions=False) + assert result.exit_code == 0 diff --git a/tests/flytekit/unit/cli/pyflyte/workflow.py b/tests/flytekit/unit/cli/pyflyte/workflow.py index 85438eb00d..01621a6a01 100644 --- a/tests/flytekit/unit/cli/pyflyte/workflow.py +++ b/tests/flytekit/unit/cli/pyflyte/workflow.py @@ -56,8 +56,10 @@ def print_all( k: Color, l: dict, m: dict, + n: typing.List[typing.Dict[str, FlyteFile]], + o: typing.Dict[str, typing.List[FlyteFile]], ): - print(f"{a}, {b}, {c}, {d}, {e}, {f}, {g}, {h}, {i}, {j}, {k}, {l}, {m}") + print(f"{a}, {b}, {c}, {d}, {e}, {f}, {g}, {h}, {i}, {j}, {k}, {l}, {m}, {n}, {o}") @task @@ -84,6 +86,8 @@ def my_wf( j: datetime.timedelta, k: Color, l: dict, + n: typing.List[typing.Dict[str, FlyteFile]], + o: typing.Dict[str, typing.List[FlyteFile]], remote: pd.DataFrame, image: StructuredDataset, m: dict = {"hello": "world"}, @@ -91,5 +95,5 @@ def my_wf( x = get_subset_df(df=remote) # noqa: shown for demonstration; users should use the same types between tasks show_sd(in_sd=x) show_sd(in_sd=image) - print_all(a=a, b=b, c=c, d=d, e=e, f=f, g=g, h=h, i=i, j=j, k=k, l=l, m=m) + print_all(a=a, b=b, c=c, d=d, e=e, f=f, g=g, h=h, i=i, j=j, k=k, l=l, m=m, n=n, o=o) return x diff --git a/tests/flytekit/unit/clients/auth/test_authenticator.py b/tests/flytekit/unit/clients/auth/test_authenticator.py index 4c968cf0bd..fdbddb2ebe 100644 --- a/tests/flytekit/unit/clients/auth/test_authenticator.py +++ b/tests/flytekit/unit/clients/auth/test_authenticator.py @@ -8,10 +8,12 @@ ClientConfig, ClientCredentialsAuthenticator, CommandAuthenticator, + DeviceCodeAuthenticator, PKCEAuthenticator, StaticClientConfigStore, ) from flytekit.clients.auth.exceptions import AuthenticationError +from flytekit.clients.auth.token_client import DeviceCodeResponse ENDPOINT = "example.com" @@ -65,31 +67,69 @@ def test_command_authenticator(mock_subprocess: MagicMock): 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.token_client.requests") +def test_client_creds_authenticator(mock_requests): + authn = ClientCredentialsAuthenticator( + ENDPOINT, client_id="client", client_secret="secret", cfg_store=static_cfg_store + ) -@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 + authn.refresh_credentials() + expected_scopes = static_cfg_store.get_client_config().scopes + assert authn._creds + assert authn._scopes == expected_scopes -@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 +@patch("flytekit.clients.auth.authenticator.KeyringStore") +@patch("flytekit.clients.auth.token_client.get_device_code") +@patch("flytekit.clients.auth.token_client.poll_token_endpoint") +def test_device_flow_authenticator(poll_mock: MagicMock, device_mock: MagicMock, mock_keyring: MagicMock): + with pytest.raises(AuthenticationError): + DeviceCodeAuthenticator( + ENDPOINT, + static_cfg_store, + audience="x", + ) + + cfg_store = StaticClientConfigStore( + ClientConfig( + token_endpoint="token_endpoint", + authorization_endpoint="auth_endpoint", + redirect_uri="redirect_uri", + client_id="client", + device_authorization_endpoint="dev", + ) + ) + authn = DeviceCodeAuthenticator( + ENDPOINT, + cfg_store, + audience="x", ) + device_mock.return_value = DeviceCodeResponse("x", "y", "s", "m", 1000, 0) + poll_mock.return_value = ("access", 100) + authn.refresh_credentials() + assert authn._creds + + +@patch("flytekit.clients.auth.token_client.requests") +def test_client_creds_authenticator_with_custom_scopes(mock_requests): + expected_scopes = ["foo", "baz"] + authn = ClientCredentialsAuthenticator( + ENDPOINT, + client_id="client", + client_secret="secret", + cfg_store=static_cfg_store, + scopes=expected_scopes, + ) 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 + assert authn._scopes == expected_scopes diff --git a/tests/flytekit/unit/clients/auth/test_token_client.py b/tests/flytekit/unit/clients/auth/test_token_client.py new file mode 100644 index 0000000000..c22284cd38 --- /dev/null +++ b/tests/flytekit/unit/clients/auth/test_token_client.py @@ -0,0 +1,81 @@ +import json +from unittest.mock import MagicMock, patch + +import pytest + +from flytekit.clients.auth.exceptions import AuthenticationError +from flytekit.clients.auth.token_client import ( + DeviceCodeResponse, + error_auth_pending, + get_basic_authorization_header, + get_device_code, + get_token, + poll_token_endpoint, +) + + +def test_get_basic_authorization_header(): + header = get_basic_authorization_header("client_id", "abc") + assert header == "Basic Y2xpZW50X2lkOmFiYw==" + + header = get_basic_authorization_header("client_id", "abc%%$?\\/\\/") + assert header == "Basic Y2xpZW50X2lkOmFiYyUyNSUyNSUyNCUzRiU1QyUyRiU1QyUyRg==" + + +@patch("flytekit.clients.auth.token_client.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", client_id="abc123", scopes=["my_scope"]) + assert access == "abc" + assert expiration == 60 + + +@patch("flytekit.clients.auth.token_client.requests") +def test_get_device_code(mock_requests): + response = MagicMock() + response.ok = False + mock_requests.post.return_value = response + with pytest.raises(AuthenticationError): + get_device_code("test.com", "test") + + response.ok = True + response.json.return_value = { + "device_code": "code", + "user_code": "BNDJJFXL", + "verification_uri": "url", + "verification_uri_complete": "url", + "expires_in": 600, + "interval": 5, + } + mock_requests.post.return_value = response + c = get_device_code("test.com", "test") + assert c + assert c.device_code == "code" + + +@patch("flytekit.clients.auth.token_client.requests") +def test_poll_token_endpoint(mock_requests): + response = MagicMock() + response.ok = False + response.json.return_value = {"error": error_auth_pending} + mock_requests.post.return_value = response + + r = DeviceCodeResponse( + device_code="x", user_code="y", verification_uri="v", verification_uri_complete="v1", expires_in=1, interval=1 + ) + with pytest.raises(AuthenticationError): + poll_token_endpoint(r, "test.com", "test") + + response = MagicMock() + response.ok = True + response.json.return_value = {"access_token": "abc", "expires_in": 60} + mock_requests.post.return_value = response + r = DeviceCodeResponse( + device_code="x", user_code="y", verification_uri="v", verification_uri_complete="v1", expires_in=1, interval=0 + ) + t, e = poll_token_endpoint(r, "test.com", "test") + assert t + assert e diff --git a/tests/flytekit/unit/clients/test_auth_helper.py b/tests/flytekit/unit/clients/test_auth_helper.py index 8f14de730e..3bd57918f4 100644 --- a/tests/flytekit/unit/clients/test_auth_helper.py +++ b/tests/flytekit/unit/clients/test_auth_helper.py @@ -9,6 +9,7 @@ ClientConfigStore, ClientCredentialsAuthenticator, CommandAuthenticator, + DeviceCodeAuthenticator, PKCEAuthenticator, ) from flytekit.clients.auth.exceptions import AuthenticationError @@ -31,6 +32,8 @@ OAUTH_AUTHORIZE = "https://your.domain.io/oauth2/authorize" +DEVICE_AUTH_ENDPOINT = "https://your.domain.io/..." + def get_auth_service_mock() -> MagicMock: auth_stub_mock = MagicMock() @@ -66,13 +69,14 @@ def test_remote_client_config_store(mock_auth_service: MagicMock): assert ccfg.authorization_endpoint == OAUTH_AUTHORIZE -def get_client_config() -> ClientConfigStore: +def get_client_config(**kwargs) -> 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, + **kwargs ) return cfg_store @@ -135,6 +139,15 @@ def test_get_authenticator_cmd(): assert authn._cmd == ["echo"] +def test_get_authenticator_deviceflow(): + cfg = PlatformConfig(auth_mode=AuthType.DEVICEFLOW) + with pytest.raises(AuthenticationError): + get_authenticator(cfg, get_client_config()) + + authn = get_authenticator(cfg, get_client_config(device_authorization_endpoint=DEVICE_AUTH_ENDPOINT)) + assert isinstance(authn, DeviceCodeAuthenticator) + + def test_wrap_exceptions_channel(): ch = MagicMock() out_ch = wrap_exceptions_channel(PlatformConfig(), ch) diff --git a/tests/flytekit/unit/configuration/test_image_config.py b/tests/flytekit/unit/configuration/test_image_config.py index 84c767f8fb..c14832df3c 100644 --- a/tests/flytekit/unit/configuration/test_image_config.py +++ b/tests/flytekit/unit/configuration/test_image_config.py @@ -60,3 +60,7 @@ def test_image_create(): ic = ImageConfig.from_images("cr.flyte.org/im/g:latest") assert ic.default_image.fqn == "cr.flyte.org/im/g" + + +def test_get_version_suffix(): + assert DefaultImages.get_version_suffix() == "latest" diff --git a/tests/flytekit/unit/core/flyte_functools/test_decorators.py b/tests/flytekit/unit/core/flyte_functools/test_decorators.py index 3edd547c8a..20e55e9d3c 100644 --- a/tests/flytekit/unit/core/flyte_functools/test_decorators.py +++ b/tests/flytekit/unit/core/flyte_functools/test_decorators.py @@ -39,7 +39,7 @@ def test_wrapped_tasks_error(capfd): ) out = capfd.readouterr().out - assert out.replace("\r", "").strip().split("\n") == [ + assert out.replace("\r", "").strip().split("\n")[:5] == [ "before running my_task", "try running my_task", "error running my_task: my_task failed with input: 0", @@ -74,11 +74,11 @@ def test_unwrapped_task(): capture_output=True, ) error = completed_process.stderr - error_str = error.strip().split("\n")[-1] - assert ( - "TaskFunction cannot be a nested/inner or local function." - " It should be accessible at a module level for Flyte to execute it." in error_str - ) + error_str = "" + for line in error.strip().split("\n"): + if line.startswith("ValueError"): + error_str += line + assert error_str.startswith("ValueError: TaskFunction cannot be a nested/inner or local function.") @pytest.mark.parametrize("script", ["nested_function.py", "nested_wrapped_function.py"]) @@ -90,5 +90,8 @@ def test_nested_function(script): capture_output=True, ) error = completed_process.stderr - error_str = error.strip().split("\n")[-1] + error_str = "" + for line in error.strip().split("\n"): + if line.startswith("ValueError"): + error_str += line assert error_str.startswith("ValueError: TaskFunction cannot be a nested/inner or local function.") diff --git a/tests/flytekit/unit/core/image_spec/__init__.py b/tests/flytekit/unit/core/image_spec/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/flytekit/unit/core/image_spec/test_image_spec.py b/tests/flytekit/unit/core/image_spec/test_image_spec.py new file mode 100644 index 0000000000..be8ea61427 --- /dev/null +++ b/tests/flytekit/unit/core/image_spec/test_image_spec.py @@ -0,0 +1,50 @@ +import os + +import pytest + +from flytekit.core import context_manager +from flytekit.core.context_manager import ExecutionState +from flytekit.image_spec import ImageSpec +from flytekit.image_spec.image_spec import _F_IMG_ID, ImageBuildEngine, ImageSpecBuilder, calculate_hash_from_image_spec + + +def test_image_spec(): + image_spec = ImageSpec( + packages=["pandas"], + apt_packages=["git"], + python_version="3.8", + registry="", + base_image="cr.flyte.org/flyteorg/flytekit:py3.8-latest", + ) + + assert image_spec.python_version == "3.8" + assert image_spec.base_image == "cr.flyte.org/flyteorg/flytekit:py3.8-latest" + assert image_spec.packages == ["pandas"] + assert image_spec.apt_packages == ["git"] + assert image_spec.registry == "" + assert image_spec.name == "flytekit" + assert image_spec.builder == "envd" + assert image_spec.source_root is None + assert image_spec.env is None + assert image_spec.is_container() is True + assert image_spec.image_name() == "flytekit:yZ8jICcDTLoDArmNHbWNwg.." + ctx = context_manager.FlyteContext.current_context() + with context_manager.FlyteContextManager.with_context( + ctx.with_execution_state(ctx.execution_state.with_params(mode=ExecutionState.Mode.TASK_EXECUTION)) + ): + os.environ[_F_IMG_ID] = "flytekit:123" + assert image_spec.is_container() is False + + class DummyImageSpecBuilder(ImageSpecBuilder): + def build_image(self, img): + ... + + ImageBuildEngine.register("dummy", DummyImageSpecBuilder()) + ImageBuildEngine._REGISTRY["dummy"].build_image(image_spec) + assert "dummy" in ImageBuildEngine._REGISTRY + assert calculate_hash_from_image_spec(image_spec) == "yZ8jICcDTLoDArmNHbWNwg.." + assert image_spec.exist() is False + + with pytest.raises(Exception): + image_spec.builder = "flyte" + ImageBuildEngine.build(image_spec) diff --git a/tests/flytekit/unit/core/test_checkpoint.py b/tests/flytekit/unit/core/test_checkpoint.py index 2add1b9e7d..f412af8ca8 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) @@ -84,16 +88,6 @@ def test_sync_checkpoint_restore_default_path(tmpdir): assert cp.restore() == cp._prev_download_path -def test_sync_checkpoint_read_empty_dir(tmpdir): - td_path = Path(tmpdir) - dest = td_path.joinpath("dest") - dest.mkdir() - src = td_path.joinpath("src") - src.mkdir() - cp = SyncCheckpoint(checkpoint_dest=str(dest), checkpoint_src=str(src)) - assert cp.read() is None - - def test_sync_checkpoint_read_multiple_files(tmpdir): """ Read can only work with one file. diff --git a/tests/flytekit/unit/core/test_checkpointer.py b/tests/flytekit/unit/core/test_checkpointer.py index dda786545b..c8080d3b82 100644 --- a/tests/flytekit/unit/core/test_checkpointer.py +++ b/tests/flytekit/unit/core/test_checkpointer.py @@ -1,5 +1,4 @@ import typing -from pathlib import Path import py.path @@ -44,18 +43,6 @@ def test_sync_checkpoint_reader(tmpdir: py.path.local): assert outputs.listdir() == [expected_dst] -def test_sync_checkpoint_folder(tmpdir: py.path.local): - inputs, input_file, outputs = create_folder_write_file(tmpdir) - cp = SyncCheckpoint(checkpoint_dest=str(outputs)) - # Lets try to restore - should not work! - assert not cp.restore("/tmp") - # Now save - cp.save(Path(str(inputs))) - # Expect file in tmpdir - expected_dst = outputs.join(CHECKPOINT_FILE) - assert outputs.listdir() == [expected_dst] - - def test_sync_checkpoint_previous(tmpdir: py.path.local): inputs, input_file, outputs = create_folder_write_file(tmpdir) cp = SyncCheckpoint(checkpoint_dest=str(outputs), checkpoint_src=str(inputs)) diff --git a/tests/flytekit/unit/core/test_container_task.py b/tests/flytekit/unit/core/test_container_task.py new file mode 100644 index 0000000000..599061d403 --- /dev/null +++ b/tests/flytekit/unit/core/test_container_task.py @@ -0,0 +1,80 @@ +from kubernetes.client.models import ( + V1Affinity, + V1NodeAffinity, + V1NodeSelectorRequirement, + V1NodeSelectorTerm, + V1PodSpec, + V1PreferredSchedulingTerm, + V1Toleration, +) + +from flytekit import kwtypes +from flytekit.configuration import Image, ImageConfig, SerializationSettings +from flytekit.core.container_task import ContainerTask +from flytekit.core.pod_template import PodTemplate +from flytekit.tools.translator import get_serializable_task + + +def test_pod_template(): + ps = V1PodSpec( + containers=[], tolerations=[V1Toleration(effect="NoSchedule", key="nvidia.com/gpu", operator="Exists")] + ) + ps.runtime_class_name = "nvidia" + nsr = V1NodeSelectorRequirement(key="nvidia.com/gpu.memory", operator="Gt", values=["10000"]) + pref_sched = V1PreferredSchedulingTerm(preference=V1NodeSelectorTerm(match_expressions=[nsr]), weight=1) + ps.affinity = V1Affinity( + node_affinity=V1NodeAffinity(preferred_during_scheduling_ignored_during_execution=[pref_sched]) + ) + pt = PodTemplate(pod_spec=ps, labels={"somelabel": "foobar"}) + + image = "ghcr.io/flyteorg/rawcontainers-shell:v2" + cmd = [ + "./calculate-ellipse-area.sh", + "{{.inputs.a}}", + "{{.inputs.b}}", + "/var/outputs", + ] + ct = ContainerTask( + name="ellipse-area-metadata-shell", + input_data_dir="/var/inputs", + output_data_dir="/var/outputs", + inputs=kwtypes(a=float, b=float), + outputs=kwtypes(area=float, metadata=str), + image=image, + command=cmd, + pod_template=pt, + pod_template_name="my-base-template", + ) + + assert ct.metadata.pod_template_name == "my-base-template" + + default_image = Image(name="default", fqn="docker.io/xyz", tag="some-git-hash") + default_image_config = ImageConfig(default_image=default_image) + default_serialization_settings = SerializationSettings( + project="p", domain="d", version="v", image_config=default_image_config + ) + + container = ct.get_container(default_serialization_settings) + assert container is None + + k8s_pod = ct.get_k8s_pod(default_serialization_settings) + assert k8s_pod.metadata.labels == {"somelabel": "foobar"} + + primary_container = k8s_pod.pod_spec["containers"][0] + + assert primary_container["image"] == image + assert primary_container["command"] == cmd + + ################# + # Test Serialization + ################# + ts = get_serializable_task(default_serialization_settings, ct) + assert ts.template.metadata.pod_template_name == "my-base-template" + assert ts.template.container is None + assert ts.template.k8s_pod is not None + serialized_pod_spec = ts.template.k8s_pod.pod_spec + assert serialized_pod_spec["affinity"]["nodeAffinity"] is not None + assert serialized_pod_spec["tolerations"] == [ + {"effect": "NoSchedule", "key": "nvidia.com/gpu", "operator": "Exists"} + ] + assert serialized_pod_spec["runtimeClassName"] == "nvidia" diff --git a/tests/flytekit/unit/core/test_context_manager.py b/tests/flytekit/unit/core/test_context_manager.py index 6e68c9d4be..fe535b761c 100644 --- a/tests/flytekit/unit/core/test_context_manager.py +++ b/tests/flytekit/unit/core/test_context_manager.py @@ -115,18 +115,17 @@ def test_secrets_manager_default(): def test_secrets_manager_get_envvar(): sec = SecretsManager() - with pytest.raises(ValueError): - sec.get_secrets_env_var("test", "") with pytest.raises(ValueError): sec.get_secrets_env_var("", "x") cfg = SecretsConfig.auto() assert sec.get_secrets_env_var("group", "test") == f"{cfg.env_prefix}GROUP_TEST" + assert sec.get_secrets_env_var("group", "test", "v1") == f"{cfg.env_prefix}GROUP_V1_TEST" + assert sec.get_secrets_env_var("group", group_version="v1") == f"{cfg.env_prefix}GROUP_V1" + assert sec.get_secrets_env_var("group") == f"{cfg.env_prefix}GROUP" def test_secrets_manager_get_file(): sec = SecretsManager() - with pytest.raises(ValueError): - sec.get_secrets_file("test", "") with pytest.raises(ValueError): sec.get_secrets_file("", "x") cfg = SecretsConfig.auto() @@ -135,6 +134,12 @@ def test_secrets_manager_get_file(): "group", f"{cfg.file_prefix}test", ) + assert sec.get_secrets_file("group", "test", "v1") == os.path.join( + cfg.default_dir, + "group", + "v1", + f"{cfg.file_prefix}test", + ) def test_secrets_manager_file(tmpdir: py.path.local): @@ -145,8 +150,6 @@ def test_secrets_manager_file(tmpdir: py.path.local): with open(f, "w+") as w: w.write("my-password") - with pytest.raises(ValueError): - sec.get("test", "") with pytest.raises(ValueError): sec.get("", "x") # Group dir not exists @@ -207,7 +210,7 @@ def test_serialization_settings_transport(): ss = SerializationSettings.from_transport(tp) assert ss is not None assert ss == serialization_settings - assert len(tp) == 388 + assert len(tp) == 400 def test_exec_params(): diff --git a/tests/flytekit/unit/core/test_data.py b/tests/flytekit/unit/core/test_data.py new file mode 100644 index 0000000000..2d61b58d8c --- /dev/null +++ b/tests/flytekit/unit/core/test_data.py @@ -0,0 +1,330 @@ +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) + + +@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")) + 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) + + +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 == {"cache_regions": True} + + +@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", "cache_regions": True} + + +@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", "cache_regions": True} + + +@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 == {"cache_regions": True} + + +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_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_flyte_file.py b/tests/flytekit/unit/core/test_flyte_file.py index e2123222e0..b7f0a1aeee 100644 --- a/tests/flytekit/unit/core/test_flyte_file.py +++ b/tests/flytekit/unit/core/test_flyte_file.py @@ -5,13 +5,14 @@ from unittest.mock import MagicMock import pytest +from typing_extensions import Annotated 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.hash import HashMethod from flytekit.core.launch_plan import LaunchPlan from flytekit.core.task import task from flytekit.core.type_engine import TypeEngine @@ -81,11 +82,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 +108,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 +137,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 +189,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 +243,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 +254,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 +433,59 @@ def wf(path: str) -> os.PathLike: return t2(ff=n1) 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 + 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/test_flyte_pickle.py b/tests/flytekit/unit/core/test_flyte_pickle.py index 7ceec809b1..c45e200f95 100644 --- a/tests/flytekit/unit/core/test_flyte_pickle.py +++ b/tests/flytekit/unit/core/test_flyte_pickle.py @@ -14,7 +14,7 @@ from flytekit.models.literals import BlobMetadata from flytekit.models.types import LiteralType from flytekit.tools.translator import get_serializable -from flytekit.types.pickle.pickle import FlytePickle, FlytePickleTransformer +from flytekit.types.pickle.pickle import BatchSize, FlytePickle, FlytePickleTransformer default_img = Image(name="default", fqn="test", tag="tag") serialization_settings = flytekit.configuration.SerializationSettings( @@ -55,6 +55,11 @@ def test_get_literal_type(): ) +def test_batch_size(): + bs = BatchSize(5) + assert bs.val == 5 + + def test_nested(): class Foo(object): def __init__(self, number: int): diff --git a/tests/flytekit/unit/core/test_map_task.py b/tests/flytekit/unit/core/test_map_task.py index 95927873d0..d032aca2d1 100644 --- a/tests/flytekit/unit/core/test_map_task.py +++ b/tests/flytekit/unit/core/test_map_task.py @@ -1,3 +1,4 @@ +import functools import typing from collections import OrderedDict @@ -6,7 +7,7 @@ import flytekit.configuration from flytekit import LaunchPlan, map_task from flytekit.configuration import Image, ImageConfig -from flytekit.core.map_task import MapPythonTask +from flytekit.core.map_task import MapPythonTask, MapTaskResolver from flytekit.core.task import TaskMetadata, task from flytekit.core.workflow import workflow from flytekit.tools.translator import get_serializable @@ -36,6 +37,11 @@ def t2(a: int) -> str: return str(b) +@task(cache=True, cache_version="1") +def t3(a: int, b: str, c: float) -> str: + pass + + # This test is for documentation. def test_map_docs(): # test_map_task_start @@ -87,8 +93,12 @@ def test_serialization(serialization_settings): "--prev-checkpoint", "{{.prevCheckpointPrefix}}", "--resolver", - "flytekit.core.python_auto_container.default_task_resolver", + "MapTaskResolver", "--", + "vars", + "", + "resolver", + "flytekit.core.python_auto_container.default_task_resolver", "task-module", "tests.flytekit.unit.core.test_map_task", "task-name", @@ -177,15 +187,42 @@ def test_inputs_outputs_length(): def many_inputs(a: int, b: str, c: float) -> str: return f"{a} - {b} - {c}" - with pytest.raises(ValueError): - _ = map_task(many_inputs) + m = map_task(many_inputs) + assert m.python_interface.inputs == {"a": typing.List[int], "b": typing.List[str], "c": typing.List[float]} + assert m.name == "tests.flytekit.unit.core.test_map_task.map_many_inputs_24c08b3a2f9c2e389ad9fc6a03482cf9" + r_m = MapPythonTask(many_inputs) + assert str(r_m.python_interface) == str(m.python_interface) + + p1 = functools.partial(many_inputs, c=1.0) + m = map_task(p1) + assert m.python_interface.inputs == {"a": typing.List[int], "b": typing.List[str], "c": float} + assert m.name == "tests.flytekit.unit.core.test_map_task.map_many_inputs_697aa7389996041183cf6cfd102be4f7" + r_m = MapPythonTask(many_inputs, bound_inputs=set("c")) + assert str(r_m.python_interface) == str(m.python_interface) + + p2 = functools.partial(p1, b="hello") + m = map_task(p2) + assert m.python_interface.inputs == {"a": typing.List[int], "b": str, "c": float} + assert m.name == "tests.flytekit.unit.core.test_map_task.map_many_inputs_cc18607da7494024a402a5fa4b3ea5c6" + r_m = MapPythonTask(many_inputs, bound_inputs={"c", "b"}) + assert str(r_m.python_interface) == str(m.python_interface) + + p3 = functools.partial(p2, a=1) + m = map_task(p3) + assert m.python_interface.inputs == {"a": int, "b": str, "c": float} + assert m.name == "tests.flytekit.unit.core.test_map_task.map_many_inputs_52fe80b04781ea77ef6f025f4b49abef" + r_m = MapPythonTask(many_inputs, bound_inputs={"a", "c", "b"}) + assert str(r_m.python_interface) == str(m.python_interface) + + with pytest.raises(TypeError): + m(a=[1, 2, 3]) @task def many_outputs(a: int) -> (int, str): return a, f"{a}" with pytest.raises(ValueError): - _ = map_task(many_inputs) + _ = map_task(many_outputs) def test_map_task_metadata(): @@ -194,3 +231,34 @@ def test_map_task_metadata(): assert mapped_1.metadata is map_meta mapped_2 = map_task(t2) assert mapped_2.metadata is t2.metadata + + +def test_map_task_resolver(serialization_settings): + list_outputs = {"o0": typing.List[str]} + mt = map_task(t3) + assert mt.python_interface.inputs == {"a": typing.List[int], "b": typing.List[str], "c": typing.List[float]} + assert mt.python_interface.outputs == list_outputs + mtr = MapTaskResolver() + assert mtr.name() == "MapTaskResolver" + args = mtr.loader_args(serialization_settings, mt) + t = mtr.load_task(loader_args=args) + assert t.python_interface.inputs == mt.python_interface.inputs + assert t.python_interface.outputs == mt.python_interface.outputs + + mt = map_task(functools.partial(t3, b="hello", c=1.0)) + assert mt.python_interface.inputs == {"a": typing.List[int], "b": str, "c": float} + assert mt.python_interface.outputs == list_outputs + mtr = MapTaskResolver() + args = mtr.loader_args(serialization_settings, mt) + t = mtr.load_task(loader_args=args) + assert t.python_interface.inputs == mt.python_interface.inputs + assert t.python_interface.outputs == mt.python_interface.outputs + + mt = map_task(functools.partial(t3, b="hello")) + assert mt.python_interface.inputs == {"a": typing.List[int], "b": str, "c": typing.List[float]} + assert mt.python_interface.outputs == list_outputs + mtr = MapTaskResolver() + args = mtr.loader_args(serialization_settings, mt) + t = mtr.load_task(loader_args=args) + assert t.python_interface.inputs == mt.python_interface.inputs + assert t.python_interface.outputs == mt.python_interface.outputs diff --git a/tests/flytekit/unit/core/test_node_creation.py b/tests/flytekit/unit/core/test_node_creation.py index 48d3020e88..0fb9d6677c 100644 --- a/tests/flytekit/unit/core/test_node_creation.py +++ b/tests/flytekit/unit/core/test_node_creation.py @@ -419,7 +419,7 @@ def t1(a: str) -> str: @workflow def my_wf(a: str) -> str: - return t1(a=a).with_overrides(name="foo") + return t1(a=a).with_overrides(name="foo", node_name="t_1") serialization_settings = flytekit.configuration.SerializationSettings( project="test_proj", @@ -431,6 +431,7 @@ def my_wf(a: str) -> str: wf_spec = get_serializable(OrderedDict(), serialization_settings, my_wf) assert len(wf_spec.template.nodes) == 1 assert wf_spec.template.nodes[0].metadata.name == "foo" + assert wf_spec.template.nodes[0].id == "t-1" def test_config_override(): diff --git a/tests/flytekit/unit/core/test_partials.py b/tests/flytekit/unit/core/test_partials.py new file mode 100644 index 0000000000..24e3908d1d --- /dev/null +++ b/tests/flytekit/unit/core/test_partials.py @@ -0,0 +1,219 @@ +import typing +from collections import OrderedDict +from functools import partial + +import pandas as pd +import pytest + +import flytekit.configuration +from flytekit.configuration import Image, ImageConfig +from flytekit.core.dynamic_workflow_task import dynamic +from flytekit.core.map_task import MapTaskResolver, map_task +from flytekit.core.task import TaskMetadata, task +from flytekit.core.workflow import workflow +from flytekit.tools.translator import gather_dependent_entities, get_serializable + +default_img = Image(name="default", fqn="test", tag="tag") +serialization_settings = flytekit.configuration.SerializationSettings( + project="project", + domain="domain", + version="version", + env=None, + image_config=ImageConfig(default_image=default_img, images=[default_img]), +) + + +df = pd.DataFrame({"Name": ["Tom", "Joseph"], "Age": [20, 22]}) + + +def test_basics_1(): + @task + def t1(a: int, b: str, c: float) -> int: + return a + len(b) + int(c) + + outside_p = partial(t1, b="hello", c=3.14) + + @workflow + def my_wf_1(a: int) -> typing.Tuple[int, int]: + inner_partial = partial(t1, b="world", c=2.7) + out = outside_p(a=a) + inside = inner_partial(a=a) + return out, inside + + with pytest.raises(Exception): + get_serializable(OrderedDict(), serialization_settings, outside_p) + + # check the od todo + od = OrderedDict() + wf_1_spec = get_serializable(od, serialization_settings, my_wf_1) + tts, wspecs, lps = gather_dependent_entities(od) + tts = [t for t in tts.values()] + assert len(tts) == 1 + assert len(wf_1_spec.template.nodes) == 2 + assert wf_1_spec.template.nodes[0].task_node.reference_id.name == tts[0].id.name + assert wf_1_spec.template.nodes[1].task_node.reference_id.name == tts[0].id.name + assert wf_1_spec.template.nodes[0].inputs[0].binding.promise.var == "a" + assert wf_1_spec.template.nodes[0].inputs[1].binding.scalar is not None + assert wf_1_spec.template.nodes[0].inputs[2].binding.scalar is not None + + @task + def get_str() -> str: + return "got str" + + bind_c = partial(t1, c=2.7) + + @workflow + def my_wf_2(a: int) -> int: + s = get_str() + inner_partial = partial(bind_c, b=s) + inside = inner_partial(a=a) + return inside + + wf_2_spec = get_serializable(OrderedDict(), serialization_settings, my_wf_2) + assert len(wf_2_spec.template.nodes) == 2 + + +def test_map_task_types(): + @task(cache=True, cache_version="1") + def t3(a: int, b: str, c: float) -> str: + return str(a) + b + str(c) + + t3_bind_b1 = partial(t3, b="hello") + t3_bind_b2 = partial(t3, b="world") + t3_bind_c1 = partial(t3_bind_b1, c=3.14) + t3_bind_c2 = partial(t3_bind_b2, c=2.78) + + mt1 = map_task(t3_bind_c1, metadata=TaskMetadata(cache=True, cache_version="1")) + mt2 = map_task(t3_bind_c2, metadata=TaskMetadata(cache=True, cache_version="1")) + + @task + def print_lists(i: typing.List[str], j: typing.List[str]): + print(f"First: {i}") + print(f"Second: {j}") + + @workflow + def wf_out(a: typing.List[int]): + i = mt1(a=a) + j = mt2(a=[3, 4, 5]) + print_lists(i=i, j=j) + + wf_out(a=[1, 2]) + + @workflow + def wf_in(a: typing.List[int]): + mt_in1 = map_task(t3_bind_c1, metadata=TaskMetadata(cache=True, cache_version="1")) + mt_in2 = map_task(t3_bind_c2, metadata=TaskMetadata(cache=True, cache_version="1")) + i = mt_in1(a=a) + j = mt_in2(a=[3, 4, 5]) + print_lists(i=i, j=j) + + wf_in(a=[1, 2]) + + od = OrderedDict() + wf_spec = get_serializable(od, serialization_settings, wf_in) + tts, _, _ = gather_dependent_entities(od) + assert len(tts) == 2 # one map task + the print task + assert ( + wf_spec.template.nodes[0].task_node.reference_id.name == wf_spec.template.nodes[1].task_node.reference_id.name + ) + assert wf_spec.template.nodes[0].inputs[0].binding.promise is not None # comes from wf input + assert wf_spec.template.nodes[1].inputs[0].binding.collection is not None # bound to static list + assert wf_spec.template.nodes[1].inputs[1].binding.scalar is not None # these are bound + assert wf_spec.template.nodes[1].inputs[2].binding.scalar is not None + + +def test_lists_cannot_be_used_in_partials(): + @task + def t(a: int, b: typing.List[str]) -> str: + return str(a) + str(b) + + with pytest.raises(ValueError): + map_task(partial(t, b=["hello", "world"]))(a=[1, 2, 3]) + + @task + def t_multilist(a: int, b: typing.List[float], c: typing.List[int]) -> str: + return str(a) + str(b) + str(c) + + with pytest.raises(ValueError): + map_task(partial(t_multilist, b=[3.14, 12.34, 9876.5432], c=[42, 99]))(a=[1, 2, 3, 4]) + + @task + def t_list_of_lists(a: typing.List[typing.List[float]], b: int) -> str: + return str(a) + str(b) + + with pytest.raises(ValueError): + map_task(partial(t_list_of_lists, a=[[3.14]]))(b=[1, 2, 3, 4]) + + +def test_everything(): + @task + def get_static_list() -> typing.List[float]: + return [3.14, 2.718] + + @task + def get_list_of_pd(s: int) -> typing.List[pd.DataFrame]: + df1 = pd.DataFrame({"Name": ["Tom", "Joseph"], "Age": [20, 22]}) + df2 = pd.DataFrame({"Name": ["Rachel", "Eve", "Mary"], "Age": [22, 23, 24]}) + if s == 2: + return [df1, df2] + else: + return [df1, df2, df1] + + @task + def t3(a: int, b: str, c: typing.List[float], d: typing.List[float], a2: pd.DataFrame) -> str: + return str(a) + f"pdsize{len(a2)}" + b + str(c) + "&&" + str(d) + + t3_bind_b2 = partial(t3, b="world") + # TODO: partial lists are not supported yet. + # t3_bind_b1 = partial(t3, b="hello") + # t3_bind_c1 = partial(t3_bind_b1, c=[6.674, 1.618, 6.626], d=[1.0]) + # mt1 = map_task(t3_bind_c1) + + mt1 = map_task(t3_bind_b2) + + mr = MapTaskResolver() + aa = mr.loader_args(serialization_settings, mt1) + # Check bound vars + aa = aa[1].split(",") + aa.sort() + assert aa == ["b"] + + @task + def print_lists(i: typing.List[str], j: typing.List[str], k: typing.List[str]) -> str: + print(f"First: {i}") + print(f"Second: {j}") + print(f"Third: {k}") + return f"{i}-{j}-{k}" + + @dynamic + def dt1(a: typing.List[int], a2: typing.List[pd.DataFrame], sl: typing.List[float]) -> str: + i = mt1(a=a, a2=a2, c=[[1.1, 2.0, 3.0], [1.1, 2.0, 3.0]], d=[sl, sl]) + mt_in2 = map_task(t3_bind_b2) + dfs = get_list_of_pd(s=3) + j = mt_in2(a=[3, 4, 5], a2=dfs, c=[[1.0], [2.0], [3.0]], d=[sl, sl, sl]) + + # Test a2 bound to a fixed dataframe + t3_bind_a2 = partial(t3_bind_b2, a2=a2[0]) + + mt_in3 = map_task(t3_bind_a2) + + aa = mr.loader_args(serialization_settings, mt_in3) + # Check bound vars + aa = aa[1].split(",") + aa.sort() + assert aa == ["a2", "b"] + + k = mt_in3(a=[3, 4, 5], c=[[1.0], [2.0], [3.0]], d=[sl, sl, sl]) + return print_lists(i=i, j=j, k=k) + + @workflow + def wf_dt(a: typing.List[int]) -> str: + sl = get_static_list() + dfs = get_list_of_pd(s=2) + return dt1(a=a, a2=dfs, sl=sl) + + print(wf_dt(a=[1, 2])) + assert ( + wf_dt(a=[1, 2]) + == "['1pdsize2world[1.1, 2.0, 3.0]&&[3.14, 2.718]', '2pdsize3world[1.1, 2.0, 3.0]&&[3.14, 2.718]']-['3pdsize2world[1.0]&&[3.14, 2.718]', '4pdsize3world[2.0]&&[3.14, 2.718]', '5pdsize2world[3.0]&&[3.14, 2.718]']-['3pdsize2world[1.0]&&[3.14, 2.718]', '4pdsize2world[2.0]&&[3.14, 2.718]', '5pdsize2world[3.0]&&[3.14, 2.718]']" + ) diff --git a/tests/flytekit/unit/core/test_promise.py b/tests/flytekit/unit/core/test_promise.py index d8b043116e..9478cc33ba 100644 --- a/tests/flytekit/unit/core/test_promise.py +++ b/tests/flytekit/unit/core/test_promise.py @@ -3,6 +3,7 @@ import pytest from dataclasses_json import dataclass_json +from typing_extensions import Annotated from flytekit import LaunchPlan, task, workflow from flytekit.core import context_manager @@ -14,6 +15,8 @@ translate_inputs_to_literals, ) from flytekit.exceptions.user import FlyteAssertion +from flytekit.types.pickle import FlytePickle +from flytekit.types.pickle.pickle import BatchSize def test_create_and_link_node(): @@ -74,7 +77,7 @@ def wf(i: int, j: int): # without providing the _inputs_not_allowed or _ignorable_inputs, all inputs to lp become required, # which is incorrect - with pytest.raises(FlyteAssertion, match="Missing input `i` type `simple: INTEGER"): + with pytest.raises(FlyteAssertion, match="Missing input `i` type ``"): create_and_link_node_from_remote(ctx, lp) # Even if j is not provided it will default @@ -92,7 +95,7 @@ def wf(i: int, j: int): @pytest.mark.parametrize( "input", - [2.0, {"i": 1, "a": ["h", "e"]}, [1, 2, 3]], + [2.0, {"i": 1, "a": ["h", "e"]}, [1, 2, 3], ["foo"] * 5], ) def test_translate_inputs_to_literals(input): @dataclass_json @@ -102,7 +105,7 @@ class MyDataclass(object): a: typing.List[str] @task - def t1(a: typing.Union[float, typing.List[int], MyDataclass]): + def t1(a: typing.Union[float, typing.List[int], MyDataclass, Annotated[typing.List[FlytePickle], BatchSize(2)]]): print(a) ctx = context_manager.FlyteContext.current_context() @@ -111,7 +114,7 @@ def t1(a: typing.Union[float, typing.List[int], MyDataclass]): def test_translate_inputs_to_literals_with_wrong_types(): ctx = context_manager.FlyteContext.current_context() - with pytest.raises(TypeError, match="Not a map type union_type"): + with pytest.raises(TypeError, match="Not a map type 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_engine.py b/tests/flytekit/unit/core/test_type_engine.py index bd270fd360..a0aafc3c13 100644 --- a/tests/flytekit/unit/core/test_type_engine.py +++ b/tests/flytekit/unit/core/test_type_engine.py @@ -1,10 +1,12 @@ import datetime +import json import os import tempfile import typing from dataclasses import asdict, dataclass from datetime import timedelta from enum import Enum +from typing import Optional, Type import mock import pandas as pd @@ -18,7 +20,7 @@ from marshmallow_enum import LoadDumpOptions from marshmallow_jsonschema import JSONSchema from pandas._testing import assert_frame_equal -from typing_extensions import Annotated +from typing_extensions import Annotated, get_args, get_origin from flytekit import kwtypes from flytekit.core.annotation import FlyteAnnotation @@ -51,7 +53,7 @@ from flytekit.types.file import FileExt, JPEGImageFile from flytekit.types.file.file import FlyteFile, FlyteFilePathTransformer, noop from flytekit.types.pickle import FlytePickle -from flytekit.types.pickle.pickle import FlytePickleTransformer +from flytekit.types.pickle.pickle import BatchSize, FlytePickleTransformer from flytekit.types.schema import FlyteSchema from flytekit.types.schema.types_pandas import PandasDataFrameTransformer from flytekit.types.structured.structured_dataset import StructuredDataset @@ -170,6 +172,51 @@ class Foo(object): assert pv[0].b == Bar(v=[1, 2, 99], w=[3.1415, 2.7182]) +def test_annotated_type(): + class JsonTypeTransformer(TypeTransformer[T]): + LiteralType = LiteralType( + simple=SimpleType.STRING, annotation=TypeAnnotation(annotations=dict(protocol="json")) + ) + + def get_literal_type(self, t: Type[T]) -> LiteralType: + return self.LiteralType + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T]) -> Optional[T]: + return json.loads(lv.scalar.primitive.string_value) + + def to_literal( + self, ctx: FlyteContext, python_val: T, python_type: typing.Type[T], expected: LiteralType + ) -> Literal: + return Literal(scalar=Scalar(primitive=Primitive(string_value=json.dumps(python_val)))) + + class JSONSerialized: + def __class_getitem__(cls, item: Type[T]): + return Annotated[item, JsonTypeTransformer(name=f"json[{item}]", t=item)] + + MyJsonDict = JSONSerialized[typing.Dict[str, int]] + _, test_transformer = get_args(MyJsonDict) + + assert TypeEngine.get_transformer(MyJsonDict) is test_transformer + assert TypeEngine.to_literal_type(MyJsonDict) == JsonTypeTransformer.LiteralType + + test_dict = {"foo": 1} + test_literal = Literal(scalar=Scalar(primitive=Primitive(string_value=json.dumps(test_dict)))) + + assert ( + TypeEngine.to_python_value( + FlyteContext.current_context(), + test_literal, + MyJsonDict, + ) + == test_dict + ) + + assert ( + TypeEngine.to_literal(FlyteContext.current_context(), test_dict, MyJsonDict, JsonTypeTransformer.LiteralType) + == test_literal + ) + + def test_list_of_dataclass_getting_python_value(): @dataclass_json @dataclass() @@ -1574,3 +1621,67 @@ def test_file_ext_with_flyte_file_wrong_type(): with pytest.raises(ValueError) as e: FlyteFile[WRONG_TYPE] assert str(e.value) == "Underlying type of File Extension must be of type " + + +def test_is_batchable(): + assert ListTransformer.is_batchable(typing.List[int]) is False + assert ListTransformer.is_batchable(typing.List[str]) is False + assert ListTransformer.is_batchable(typing.List[typing.Dict]) is False + assert ListTransformer.is_batchable(typing.List[typing.Dict[str, FlytePickle]]) is False + assert ListTransformer.is_batchable(typing.List[typing.List[FlytePickle]]) is False + + assert ListTransformer.is_batchable(typing.List[FlytePickle]) is True + assert ListTransformer.is_batchable(Annotated[typing.List[FlytePickle], BatchSize(3)]) is True + assert ( + ListTransformer.is_batchable(Annotated[typing.List[FlytePickle], HashMethod(function=str), BatchSize(3)]) + is True + ) + + +@pytest.mark.parametrize( + "python_val, python_type, expected_list_length", + [ + # Case 1: List of FlytePickle objects with default batch size. + # (By default, the batch_size is set to the length of the whole list.) + # After converting to literal, the result will be [batched_FlytePickle(5 items)]. + # Therefore, the expected list length is [1]. + ([{"foo"}] * 5, typing.List[FlytePickle], [1]), + # Case 2: List of FlytePickle objects with batch size 2. + # After converting to literal, the result will be + # [batched_FlytePickle(2 items), batched_FlytePickle(2 items), batched_FlytePickle(1 item)]. + # Therefore, the expected list length is [3]. + (["foo"] * 5, Annotated[typing.List[FlytePickle], HashMethod(function=str), BatchSize(2)], [3]), + # Case 3: Nested list of FlytePickle objects with batch size 2. + # After converting to literal, the result will be + # [[batched_FlytePickle(3 items)], [batched_FlytePickle(3 items)]] + # Therefore, the expected list length is [2, 1] (the length of the outer list remains the same, the inner list is batched). + ([["foo", "foo", "foo"]] * 2, typing.List[Annotated[typing.List[FlytePickle], BatchSize(3)]], [2, 1]), + # Case 4: Empty list + ([[], typing.List[FlytePickle], []]), + ], +) +def test_batch_pickle_list(python_val, python_type, expected_list_length): + ctx = FlyteContext.current_context() + expected = TypeEngine.to_literal_type(python_type) + lv = TypeEngine.to_literal(ctx, python_val, python_type, expected) + + tmp_lv = lv + for length in expected_list_length: + # Check that after converting to literal, the length of the literal list is equal to: + # - the length of the original list divided by the batch size if not nested + # - the length of the original list if it contains a nested list + assert len(tmp_lv.collection.literals) == length + tmp_lv = tmp_lv.collection.literals[0] + + pv = TypeEngine.to_python_value(ctx, lv, python_type) + # Check that after converting literal to Python value, the result is equal to the original python values. + assert pv == python_val + if get_origin(python_type) is Annotated: + pv = TypeEngine.to_python_value(ctx, lv, get_args(python_type)[0]) + # Remove the annotation and check that after converting to Python value, the result is equal + # to the original input values. This is used to simulate the following case: + # @workflow + # def wf(): + # data = task0() # task0() -> Annotated[typing.List[FlytePickle], BatchSize(2)] + # task1(data=data) # task1(data: typing.List[FlytePickle]) + assert pv == python_val diff --git a/tests/flytekit/unit/core/test_type_hints.py b/tests/flytekit/unit/core/test_type_hints.py index 9da416c1e8..cb65981963 100644 --- a/tests/flytekit/unit/core/test_type_hints.py +++ b/tests/flytekit/unit/core/test_type_hints.py @@ -3,12 +3,12 @@ import functools import os import random +import re import tempfile import typing from collections import OrderedDict from dataclasses import dataclass from enum import Enum -from textwrap import dedent import pandas import pandas as pd @@ -492,11 +492,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(): @@ -1627,16 +1629,28 @@ def foo2(a: int, b: str) -> typing.Tuple[int, str]: def foo3(a: typing.Dict) -> typing.Dict: return a - with pytest.raises(TypeError, match="Type of Val 'hello' is not an instance of "): + with pytest.raises( + TypeError, + match=( + "Failed to convert inputs of task 'tests.flytekit.unit.core.test_type_hints.foo':\n" + " Failed argument 'a': Expected value of type but got 'hello' of type " + ), + ): foo(a="hello", b=10) # type: ignore with pytest.raises( TypeError, - match="Failed to convert return value for var o0 for " "function tests.flytekit.unit.core.test_type_hints.foo2", + match=( + "Failed to convert outputs of task 'tests.flytekit.unit.core.test_type_hints.foo2' at position 0:\n" + " Expected value of type but got 'hello' of type " + ), ): foo2(a=10, b="hello") - with pytest.raises(TypeError, match="Not a collection type simple: STRUCT\n but got a list \\[{'hello': 2}\\]"): + with pytest.raises( + TypeError, + match="Not a collection type but got a list \\[{'hello': 2}\\]", + ): foo3(a=[{"hello": 2}]) # type: ignore @@ -1672,28 +1686,12 @@ def wf2(a: typing.Union[int, str]) -> typing.Union[int, str]: with pytest.raises( TypeError, - match=dedent( - r""" - Cannot convert from scalar { - union { - value { - scalar { - primitive { - string_value: "2" - } - } - } - type { - simple: STRING - structure { - tag: "str" - } - } - } - } - to typing.Union\[float, dict\] \(using tag str\) - """ - )[1:-1], + match=re.escape( + "Error encountered while executing 'wf2':\n" + " Failed to convert inputs of task 'tests.flytekit.unit.core.test_type_hints.t2':\n" + ' Cannot convert from to typing.Union[float, dict] (using tag str)' + ), ): assert wf2(a="2") == "2" diff --git a/tests/flytekit/unit/core/test_utils.py b/tests/flytekit/unit/core/test_utils.py index 112a864b30..5c191b31ee 100644 --- a/tests/flytekit/unit/core/test_utils.py +++ b/tests/flytekit/unit/core/test_utils.py @@ -1,6 +1,8 @@ import pytest -from flytekit.core.utils import _dnsify +import flytekit +from flytekit import FlyteContextManager, task +from flytekit.core.utils import _dnsify, timeit @pytest.mark.parametrize( @@ -20,3 +22,38 @@ ) def test_dnsify(input, expected): assert _dnsify(input) == expected + + +def test_timeit(): + ctx = FlyteContextManager.current_context() + ctx.user_space_params._decks = [] + + with timeit("Set disable_deck to False"): + kwargs = {} + kwargs["disable_deck"] = False + + ctx = FlyteContextManager.current_context() + time_info_list = ctx.user_space_params.timeline_deck.time_info + names = [time_info["Name"] for time_info in time_info_list] + # check if timeit works for flytekit level code + assert "Set disable_deck to False" in names + + @task(**kwargs) + def t1() -> int: + @timeit("Download data") + def download_data(): + return "1" + + data = download_data() + + with timeit("Convert string to int"): + return int(data) + + t1() + + time_info_list = flytekit.current_context().timeline_deck.time_info + names = [time_info["Name"] for time_info in time_info_list] + + # check if timeit works for user level code + assert "Download data" in names + assert "Convert string to int" in names diff --git a/tests/flytekit/unit/core/test_workflows.py b/tests/flytekit/unit/core/test_workflows.py index 4f1082df63..1aeeba894d 100644 --- a/tests/flytekit/unit/core/test_workflows.py +++ b/tests/flytekit/unit/core/test_workflows.py @@ -136,6 +136,89 @@ def wf(b: int) -> nt: assert x == (7, 7) +def test_sub_wf_varying_types(): + @task + def t1l( + a: typing.List[typing.Dict[str, typing.List[int]]], + b: typing.Dict[str, typing.List[int]], + c: typing.Union[typing.List[typing.Dict[str, typing.List[int]]], typing.Dict[str, typing.List[int]], int], + d: int, + ) -> str: + xx = ",".join([f"{k}:{v}" for d in a for k, v in d.items()]) + yy = ",".join([f"{k}: {i}" for k, v in b.items() for i in v]) + if isinstance(c, list): + zz = ",".join([f"{k}:{v}" for d in c for k, v in d.items()]) + elif isinstance(c, dict): + zz = ",".join([f"{k}: {i}" for k, v in c.items() for i in v]) + else: + zz = str(c) + return f"First: {xx} Second: {yy} Third: {zz} Int: {d}" + + @task + def get_int() -> int: + return 1 + + @workflow + def subwf( + a: typing.List[typing.Dict[str, typing.List[int]]], + b: typing.Dict[str, typing.List[int]], + c: typing.Union[typing.List[typing.Dict[str, typing.List[int]]], typing.Dict[str, typing.List[int]]], + d: int, + ) -> str: + return t1l(a=a, b=b, c=c, d=d) + + @workflow + def wf() -> str: + ds = [ + {"first_map_a": [42], "first_map_b": [get_int(), 2]}, + { + "second_map_c": [33], + "second_map_d": [9, 99], + }, + ] + ll = { + "ll_1": [get_int(), get_int(), get_int()], + "ll_2": [4, 5, 6], + } + out = subwf(a=ds, b=ll, c=ds, d=get_int()) + return out + + wf.compile() + x = wf() + expected = ( + "First: first_map_a:[42],first_map_b:[1, 2],second_map_c:[33],second_map_d:[9, 99] " + "Second: ll_1: 1,ll_1: 1,ll_1: 1,ll_2: 4,ll_2: 5,ll_2: 6 " + "Third: first_map_a:[42],first_map_b:[1, 2],second_map_c:[33],second_map_d:[9, 99] " + "Int: 1" + ) + assert x == expected + + @workflow + def wf() -> str: + ds = [ + {"first_map_a": [42], "first_map_b": [get_int(), 2]}, + { + "second_map_c": [33], + "second_map_d": [9, 99], + }, + ] + ll = { + "ll_1": [get_int(), get_int(), get_int()], + "ll_2": [4, 5, 6], + } + out = subwf(a=ds, b=ll, c=ll, d=get_int()) + return out + + x = wf() + expected = ( + "First: first_map_a:[42],first_map_b:[1, 2],second_map_c:[33],second_map_d:[9, 99] " + "Second: ll_1: 1,ll_1: 1,ll_1: 1,ll_2: 4,ll_2: 5,ll_2: 6 " + "Third: ll_1: 1,ll_1: 1,ll_1: 1,ll_2: 4,ll_2: 5,ll_2: 6 " + "Int: 1" + ) + assert x == expected + + def test_unexpected_outputs(): @task def t1(a: int) -> int: 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_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/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/deck/test_deck.py b/tests/flytekit/unit/deck/test_deck.py index a6b00e79e2..f65c94b877 100644 --- a/tests/flytekit/unit/deck/test_deck.py +++ b/tests/flytekit/unit/deck/test_deck.py @@ -1,3 +1,5 @@ +import datetime + import pandas as pd import pytest from mock import mock @@ -23,12 +25,30 @@ def test_deck(): _output_deck("test_task", ctx.user_space_params) +def test_timeline_deck(): + time_info = dict( + Name="foo", + Start=datetime.datetime.utcnow(), + Finish=datetime.datetime.utcnow() + datetime.timedelta(microseconds=1000), + WallTime=1.0, + ProcessTime=1.0, + ) + ctx = FlyteContextManager.current_context() + ctx.user_space_params._decks = [] + timeline_deck = ctx.user_space_params.timeline_deck + timeline_deck.append_time_info(time_info) + assert timeline_deck.name == "Timeline" + assert len(timeline_deck.time_info) == 1 + assert timeline_deck.time_info[0] == time_info + assert len(ctx.user_space_params.decks) == 1 + + @pytest.mark.parametrize( "disable_deck,expected_decks", [ - (None, 0), - (False, 2), # input and output decks - (True, 0), + (None, 1), # time line deck + (False, 3), # time line deck + input and output decks + (True, 1), # time line deck ], ) def test_deck_for_task(disable_deck, expected_decks): @@ -49,9 +69,9 @@ def t1(a: int) -> str: @pytest.mark.parametrize( "disable_deck, expected_decks", [ - (None, 1), - (False, 1 + 2), # input and output decks - (True, 1), + (None, 2), # default deck and time line deck + (False, 4), # default deck and time line deck + input and output decks + (True, 2), # default deck and time line deck ], ) def test_deck_pandas_dataframe(disable_deck, expected_decks): diff --git a/tests/flytekit/unit/extend/test_backend_plugin.py b/tests/flytekit/unit/extend/test_backend_plugin.py new file mode 100644 index 0000000000..9dfd20d99e --- /dev/null +++ b/tests/flytekit/unit/extend/test_backend_plugin.py @@ -0,0 +1,105 @@ +import typing +from datetime import timedelta +from unittest.mock import MagicMock + +import grpc +from flyteidl.service.external_plugin_service_pb2 import ( + PERMANENT_FAILURE, + SUCCEEDED, + TaskCreateRequest, + TaskCreateResponse, + TaskDeleteRequest, + TaskDeleteResponse, + TaskGetRequest, + TaskGetResponse, +) + +import flytekit.models.interface as interface_models +from flytekit.extend.backend.base_plugin import BackendPluginBase, BackendPluginRegistry +from flytekit.extend.backend.external_plugin_service import BackendPluginServer +from flytekit.models import literals, task, types +from flytekit.models.core.identifier import Identifier, ResourceType +from flytekit.models.literals import LiteralMap +from flytekit.models.task import TaskTemplate + +dummy_id = "dummy_id" + + +class DummyPlugin(BackendPluginBase): + def __init__(self): + super().__init__(task_type="dummy") + + def create( + self, + context: grpc.ServicerContext, + output_prefix: str, + task_template: TaskTemplate, + inputs: typing.Optional[LiteralMap] = None, + ) -> TaskCreateResponse: + return TaskCreateResponse(job_id=dummy_id) + + def get(self, context: grpc.ServicerContext, job_id: str) -> TaskGetResponse: + return TaskGetResponse(state=SUCCEEDED) + + def delete(self, context: grpc.ServicerContext, job_id) -> TaskDeleteResponse: + return TaskDeleteResponse() + + +BackendPluginRegistry.register(DummyPlugin()) + +task_id = Identifier(resource_type=ResourceType.TASK, project="project", domain="domain", name="t1", version="version") +task_metadata = task.TaskMetadata( + True, + task.RuntimeMetadata(task.RuntimeMetadata.RuntimeType.FLYTE_SDK, "1.0.0", "python"), + timedelta(days=1), + literals.RetryStrategy(3), + True, + "0.1.1b0", + "This is deprecated!", + True, + "A", +) + +int_type = types.LiteralType(types.SimpleType.INTEGER) +interfaces = interface_models.TypedInterface( + { + "a": interface_models.Variable(int_type, "description1"), + }, + {}, +) +task_inputs = literals.LiteralMap( + { + "a": literals.Literal(scalar=literals.Scalar(primitive=literals.Primitive(integer=1))), + }, +) + +dummy_template = TaskTemplate( + id=task_id, + metadata=task_metadata, + interface=interfaces, + type="dummy", + custom={}, +) + + +def test_dummy_plugin(): + ctx = MagicMock(spec=grpc.ServicerContext) + p = BackendPluginRegistry.get_plugin(ctx, "dummy") + assert p.create(ctx, "/tmp", dummy_template, task_inputs).job_id == dummy_id + assert p.get(ctx, dummy_id).state == SUCCEEDED + assert p.delete(ctx, dummy_id) == TaskDeleteResponse() + + +def test_backend_plugin_server(): + server = BackendPluginServer() + ctx = MagicMock(spec=grpc.ServicerContext) + request = TaskCreateRequest( + inputs=task_inputs.to_flyte_idl(), output_prefix="/tmp", template=dummy_template.to_flyte_idl() + ) + + assert server.CreateTask(request, ctx).job_id == dummy_id + assert server.GetTask(TaskGetRequest(task_type="dummy", job_id=dummy_id), ctx).state == SUCCEEDED + assert server.DeleteTask(TaskDeleteRequest(task_type="dummy", job_id=dummy_id), ctx) == TaskDeleteResponse() + + res = server.GetTask(TaskGetRequest(task_type="fake", job_id=dummy_id), ctx) + assert res.state == PERMANENT_FAILURE 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/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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 tf.keras.Model: + return get_tf_model() + + task_spec = get_serializable(OrderedDict(), serialization_settings, t1) + assert task_spec.template.interface.outputs["o0"].type.blob.format is TensorFlowModelTransformer.TENSORFLOW_FORMAT diff --git a/tests/flytekit/unit/models/core/test_security.py b/tests/flytekit/unit/models/core/test_security.py new file mode 100644 index 0000000000..c2933f9353 --- /dev/null +++ b/tests/flytekit/unit/models/core/test_security.py @@ -0,0 +1,13 @@ +from flytekit.models.security import Secret + + +def test_secret(): + obj = Secret("grp", "key") + obj2 = Secret.from_flyte_idl(obj.to_flyte_idl()) + assert obj2.key == "key" + assert obj2.group_version is None + + obj = Secret("grp", group_version="v1") + obj2 = Secret.from_flyte_idl(obj.to_flyte_idl()) + assert obj2.key is None + assert obj2.group_version == "v1" diff --git a/tests/flytekit/unit/remote/test_remote.py b/tests/flytekit/unit/remote/test_remote.py index 4b8f82fb7e..5bfd7e4bf6 100644 --- a/tests/flytekit/unit/remote/test_remote.py +++ b/tests/flytekit/unit/remote/test_remote.py @@ -175,15 +175,7 @@ def test_more_stuff(mock_client): # Can't upload a folder with pytest.raises(ValueError): 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__)) + r.upload_file(pathlib.Path(tmp_dir)) serialization_settings = flytekit.configuration.SerializationSettings( project="project", diff --git a/tests/flytekit/unit/tools/test_script_mode.py b/tests/flytekit/unit/tools/test_script_mode.py index a433769075..67898fb780 100644 --- a/tests/flytekit/unit/tools/test_script_mode.py +++ b/tests/flytekit/unit/tools/test_script_mode.py @@ -1,13 +1,51 @@ import os +import subprocess +import sys -from flytekit.tools.script_mode import compress_single_script, hash_file +from flytekit.tools.script_mode import compress_scripts, hash_file + +MAIN_WORKFLOW = """ +from flytekit import task, workflow +from wf1.test import t1 -WORKFLOW = """ @workflow def my_wf() -> str: return "hello world" """ +IMPERATIVE_WORKFLOW = """ +from flytekit import Workflow, task + +@task +def t1(a: int): + print(a) + + +wf = Workflow(name="my.imperative.workflow.example") +wf.add_workflow_input("a", int) +node_t1 = wf.add_entity(t1, a=wf.inputs["a"]) +""" + +T1_TASK = """ +from flytekit import task +from wf2.test import t2 + + +@task() +def t1() -> str: + print("hello") + return "hello" +""" + +T2_TASK = """ +from flytekit import task + +@task() +def t2() -> str: + print("hello") + return "hello" +""" + def test_deterministic_hash(tmp_path): workflows_dir = tmp_path / "workflows" @@ -17,19 +55,46 @@ def test_deterministic_hash(tmp_path): open(workflows_dir / "__init__.py", "a").close() # Write a dummy workflow workflow_file = workflows_dir / "hello_world.py" - workflow_file.write_text(WORKFLOW) + workflow_file.write_text(MAIN_WORKFLOW) + + imperative_workflow_file = workflows_dir / "imperative_wf.py" + imperative_workflow_file.write_text(IMPERATIVE_WORKFLOW) + + t1_dir = tmp_path / "wf1" + t1_dir.mkdir() + open(t1_dir / "__init__.py", "a").close() + t1_file = t1_dir / "test.py" + t1_file.write_text(T1_TASK) + + t2_dir = tmp_path / "wf2" + t2_dir.mkdir() + open(t2_dir / "__init__.py", "a").close() + t2_file = t2_dir / "test.py" + t2_file.write_text(T2_TASK) destination = tmp_path / "destination" - compress_single_script(workflows_dir, destination, "hello_world") - print(f"{os.listdir(tmp_path)}") + sys.path.append(str(workflows_dir.parent)) + compress_scripts(str(workflows_dir.parent), str(destination), "workflows.hello_world") digest, hex_digest = hash_file(destination) # Try again to assert digest determinism destination2 = tmp_path / "destination2" - compress_single_script(workflows_dir, destination2, "hello_world") + compress_scripts(str(workflows_dir.parent), str(destination2), "workflows.hello_world") digest2, hex_digest2 = hash_file(destination) assert digest == digest2 assert hex_digest == hex_digest2 + + test_dir = tmp_path / "test" + test_dir.mkdir() + + result = subprocess.run( + ["tar", "-xvf", str(destination), "-C", str(test_dir)], + stdout=subprocess.PIPE, + ) + result.check_returncode() + assert len(next(os.walk(test_dir))[1]) == 3 + + compress_scripts(str(workflows_dir.parent), str(destination), "workflows.imperative_wf") diff --git a/tests/flytekit/unit/types/directory/__init__.py b/tests/flytekit/unit/types/directory/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/flytekit/unit/types/directory/test_types.py b/tests/flytekit/unit/types/directory/test_types.py new file mode 100644 index 0000000000..199b788733 --- /dev/null +++ b/tests/flytekit/unit/types/directory/test_types.py @@ -0,0 +1,31 @@ +import mock + +from flytekit import FlyteContext +from flytekit.types.directory import FlyteDirectory +from flytekit.types.file import FlyteFile + + +def test_new_file_dir(): + fd = FlyteDirectory(path="s3://my-bucket") + assert fd.sep == "/" + inner_dir = fd.new_dir("test") + assert inner_dir.path == "s3://my-bucket/test" + fd = FlyteDirectory(path="s3://my-bucket/") + inner_dir = fd.new_dir("test") + assert inner_dir.path == "s3://my-bucket/test" + f = inner_dir.new_file("test") + assert isinstance(f, FlyteFile) + assert f.path == "s3://my-bucket/test/test" + + +def test_new_remote_dir(): + fd = FlyteDirectory.new_remote() + assert FlyteContext.current_context().file_access.raw_output_prefix in fd.path + + +@mock.patch("flytekit.types.directory.types.os.name", "nt") +def test_sep_nt(): + fd = FlyteDirectory(path="file://mypath") + assert fd.sep == "\\" + fd = FlyteDirectory(path="s3://mypath") + assert fd.sep == "/" diff --git a/tests/flytekit/unit/types/file/__init__.py b/tests/flytekit/unit/types/file/__init__.py new file mode 100644 index 0000000000..e69de29bb2